84275d0fe4
print_status and print_degraded_sections carried four byte-identical blocks - host firewall (25 lines), anti-torrent (24), last actions (9) and recent events (10) - plus a Conntrack block that was the same logic assembled differently and a Traffic desired block of four printlns. Roughly 80 lines of copy-paste with no test covering either copy, so a format change applied to one path would have silently diverged from the other. The divergence would only ever be visible while the container is down, which is the worst moment to discover the diagnostic output is wrong. Every shared section is now one function called from both paths: print_traffic_desired_section, print_host_firewall_section, print_antitorrent_section, print_conntrack_section, print_last_actions_section, print_recent_events_section. The file already had this shape for print_swap_status and print_reboot_section; the pattern is now followed consistently. The Docker/Amnezia port path block keeps two variants because they genuinely differ - real values against unknown - but the shared FORWARD ownership note is a single constant. The runtime state paths were also duplicated across modules: status.rs re-declared the literals that daemon.rs already owns. daemon::LAST_STATE_FILE and daemon::EVENTS_FILE are now pub and reused, and the recent-events window is a named constant instead of a bare 10. Ordering fix in the same function: print_network_tuning_status ran between the service line and the container lines, so container/container_id/pid/netns/interface appeared under the "Network tuning:" header at the same two-space indent as its fields. Container identity is now printed inside the discovery match, before any other section opens, in both the healthy and degraded paths. Field names are unchanged; only the order of two blocks moved. IMPLEMENTATION_SPEC section 15 documented the old order and is updated. print_degraded_sections returned anyhow::Result<()> while never failing; it returns unit now. status.rs: 645 -> 602 lines. cargo test --workspace: 110 + 4 passed. clippy -D warnings clean.
603 lines
20 KiB
Rust
603 lines
20 KiB
Rust
use crate::{
|
|
config::Config,
|
|
daemon::{EVENTS_FILE, LAST_STATE_FILE},
|
|
docker, firewall,
|
|
netns::build_transport,
|
|
swap, sysctl_tuning, traffic, wg,
|
|
};
|
|
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
process::Command,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
const ONLINE_HANDSHAKE_THRESHOLD_SECS: u64 = 180;
|
|
const RECENT_EVENTS_LIMIT: usize = 10;
|
|
const FORWARD_OWNERSHIP_NOTE: &str =
|
|
" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD";
|
|
|
|
pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
|
|
println!("System:");
|
|
let svc = Command::new("systemctl")
|
|
.args(["is-active", "awg2-harness"])
|
|
.output();
|
|
match svc {
|
|
Ok(out) if out.status.success() => println!(" service: active"),
|
|
Ok(_) => println!(" service: inactive"),
|
|
Err(_) => println!(" service: unknown"),
|
|
}
|
|
|
|
// The System block stays contiguous: container identity is printed before any other
|
|
// section opens, otherwise its two-space fields read as belonging to that section.
|
|
let target = match docker::discover_target(cfg) {
|
|
Ok(t) => {
|
|
println!(" container: {}", t.container_name);
|
|
println!(" container_id: {}", t.container_id);
|
|
println!(" pid: {}", t.container_pid);
|
|
println!(" netns: {}", t.netns_path);
|
|
println!(" interface: {}", t.interface_name);
|
|
Some(t)
|
|
}
|
|
Err(e) => {
|
|
println!(" container: not available ({e})");
|
|
println!(" container_id: n/a");
|
|
println!(" pid: n/a");
|
|
println!(" netns: n/a");
|
|
println!(" interface: n/a");
|
|
None
|
|
}
|
|
};
|
|
|
|
print_network_tuning_status(cfg);
|
|
|
|
let Some(target) = target else {
|
|
print_degraded_sections(cfg);
|
|
return Ok(());
|
|
};
|
|
|
|
let docker_path_diag = docker::collect_port_path_diagnostics(
|
|
&target.container_name,
|
|
cfg.host_firewall.amnezia_port,
|
|
);
|
|
|
|
// Peer discovery is one container round-trip, shared by both sections below.
|
|
let peers = wg::discover_peers(cfg, &target);
|
|
println!("Peers:");
|
|
match &peers {
|
|
Ok(list) => {
|
|
println!(" total: {}", list.len());
|
|
for p in list {
|
|
println!(" - {}", p.ipv4);
|
|
}
|
|
}
|
|
Err(e) => println!(" unavailable ({e})"),
|
|
}
|
|
|
|
println!("WireGuard clients:");
|
|
println!(
|
|
" IP State Latest handshake RX TX Public key Endpoint"
|
|
);
|
|
match (&peers, wg::discover_peer_runtime(cfg, &target)) {
|
|
(Ok(peers_sorted), Ok(runtime_peers)) => {
|
|
let mut by_ip: BTreeMap<String, &wg::PeerRuntime> = BTreeMap::new();
|
|
for rp in &runtime_peers {
|
|
for ip in &rp.allowed_ips {
|
|
by_ip.insert(ip.clone(), rp);
|
|
}
|
|
}
|
|
|
|
for p in peers_sorted {
|
|
let ip = p.ipv4.to_string();
|
|
if let Some(rp) = by_ip.get(&ip) {
|
|
let state = handshake_is_online(
|
|
rp.latest_handshake_epoch,
|
|
ONLINE_HANDSHAKE_THRESHOLD_SECS,
|
|
);
|
|
let hs = format_handshake_age(rp.latest_handshake_epoch);
|
|
let rx = format_bytes(rp.transfer_rx_bytes.unwrap_or(0));
|
|
let tx = format_bytes(rp.transfer_tx_bytes.unwrap_or(0));
|
|
let ep = rp.endpoint.clone().unwrap_or_else(|| "-".to_string());
|
|
println!(
|
|
" {:<11} {:<8} {:<19} {:<9} {:<9} {:<44} {}",
|
|
ip, state, hs, rx, tx, rp.public_key, ep
|
|
);
|
|
} else {
|
|
println!(
|
|
" {:<11} {:<8} {:<19} {:<9} {:<9} {:<44} -",
|
|
ip, "offline", "never", "0B", "0B", "-"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
(Err(e), _) => println!(" unavailable ({e})"),
|
|
(_, Err(e)) => println!(" unavailable ({e})"),
|
|
}
|
|
|
|
print_traffic_desired_section(cfg);
|
|
|
|
println!("Traffic actual:");
|
|
match traffic::inspect_drift_summary(cfg) {
|
|
Ok(None) => println!(" drift: no"),
|
|
Ok(Some(reason)) => {
|
|
println!(" drift: yes");
|
|
println!(" reason: {}", reason);
|
|
}
|
|
Err(e) => println!(" drift: unknown ({e})"),
|
|
}
|
|
|
|
println!(" topology:");
|
|
let transport = build_transport(target.container_pid, &target.container_name);
|
|
let egress_qdisc = transport
|
|
.run(&["tc", "qdisc", "show", "dev", &target.interface_name])
|
|
.unwrap_or_default();
|
|
let ingress_qdisc = egress_qdisc.clone();
|
|
let ifb_qdisc = transport
|
|
.run(&["tc", "qdisc", "show", "dev", &cfg.traffic.ifb_name])
|
|
.unwrap_or_default();
|
|
println!(" egress_htb_present: {}", egress_qdisc.contains("htb"));
|
|
println!(" ingress_present: {}", ingress_qdisc.contains("ingress"));
|
|
println!(" ifb_htb_present: {}", ifb_qdisc.contains("htb"));
|
|
|
|
println!(" peer_table:");
|
|
match traffic::peer_status_table(cfg) {
|
|
Ok(Some(rows)) if rows.is_empty() => println!(" no peers discovered"),
|
|
Ok(Some(rows)) => {
|
|
for r in rows {
|
|
println!(
|
|
" ip={} class_id={} egress_filter={} ingress_filter={} egress={}/{} ingress={}/{} drift={}",
|
|
r.ip,
|
|
r.class_id,
|
|
r.egress_filter_present,
|
|
r.ingress_filter_present,
|
|
r.egress_rate.unwrap_or_else(|| "n/a".to_string()),
|
|
r.egress_ceil.unwrap_or_else(|| "n/a".to_string()),
|
|
r.ingress_rate.unwrap_or_else(|| "n/a".to_string()),
|
|
r.ingress_ceil.unwrap_or_else(|| "n/a".to_string()),
|
|
r.drift_reason.unwrap_or_else(|| "no".to_string())
|
|
);
|
|
}
|
|
}
|
|
Ok(None) => println!(" traffic disabled"),
|
|
Err(e) => println!(" unavailable ({e})"),
|
|
}
|
|
|
|
println!("Peer traffic counters:");
|
|
println!(" IP Egress hits Ingress hits");
|
|
match collect_peer_filter_hits(
|
|
&target.interface_name,
|
|
&cfg.traffic.ifb_name,
|
|
target.container_pid,
|
|
&target.container_name,
|
|
) {
|
|
Ok(map) if map.is_empty() => println!(" filter statistics: unavailable"),
|
|
Ok(map) => {
|
|
for (ip, (eg, ing)) in map {
|
|
println!(" {:<11} {:<13} {}", ip, eg, ing);
|
|
}
|
|
}
|
|
Err(_) => println!(" filter statistics: unavailable"),
|
|
}
|
|
|
|
print_host_firewall_section(cfg);
|
|
print_docker_amnezia_port_path(&docker_path_diag);
|
|
print_antitorrent_section(cfg);
|
|
print_swap_status(cfg);
|
|
print_conntrack_section();
|
|
print_last_actions_section();
|
|
print_recent_events_section();
|
|
print_reboot_section(cfg);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn print_network_tuning_status(cfg: &Config) {
|
|
match sysctl_tuning::inspect_sysctl_tuning(cfg) {
|
|
Ok(s) => sysctl_tuning::print_sysctl_tuning_status(&s),
|
|
Err(e) => {
|
|
println!("Network tuning:");
|
|
println!(" enabled: {}", cfg.sysctl_tuning.enabled);
|
|
println!(" drift: unknown ({e})");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sections that stay meaningful when the container was not discovered.
|
|
///
|
|
/// Every section shared with the healthy path is rendered by the same helper, so the two
|
|
/// paths cannot drift apart in format. Only the sections that genuinely depend on the
|
|
/// container differ, and they say so explicitly.
|
|
fn print_degraded_sections(cfg: &Config) {
|
|
println!("Peers:");
|
|
println!(" unavailable (container/netns not discovered)");
|
|
|
|
println!("WireGuard clients:");
|
|
println!(" unavailable (container/netns not discovered)");
|
|
|
|
print_traffic_desired_section(cfg);
|
|
|
|
println!("Traffic actual:");
|
|
println!(" drift: unknown (container/netns not discovered)");
|
|
|
|
print_host_firewall_section(cfg);
|
|
print_unknown_docker_amnezia_port_path();
|
|
print_antitorrent_section(cfg);
|
|
print_swap_status(cfg);
|
|
print_conntrack_section();
|
|
print_last_actions_section();
|
|
print_recent_events_section();
|
|
print_reboot_section(cfg);
|
|
}
|
|
|
|
fn print_traffic_desired_section(cfg: &Config) {
|
|
println!("Traffic desired:");
|
|
println!(" peer_limit: {}", cfg.traffic.peer_limit);
|
|
println!(" root_rate: {}", cfg.traffic.root_rate);
|
|
println!(" ifb_name: {}", cfg.traffic.ifb_name);
|
|
println!(" class_start_id: {}", cfg.traffic.class_start_id);
|
|
}
|
|
|
|
fn print_host_firewall_section(cfg: &Config) {
|
|
println!("Host firewall:");
|
|
println!(" enabled: {}", cfg.host_firewall.enabled);
|
|
println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port);
|
|
println!(
|
|
" amnezia_port: {}/{}",
|
|
cfg.host_firewall.amnezia_port,
|
|
cfg.host_firewall.amnezia_protocols.join(",")
|
|
);
|
|
println!(
|
|
" input_policy: {}",
|
|
cfg.host_firewall
|
|
.default_input_policy
|
|
.trim()
|
|
.to_ascii_lowercase()
|
|
);
|
|
if cfg.host_firewall.enabled {
|
|
match firewall::host_firewall_drift_reason(cfg) {
|
|
Ok(None) => println!(" drift: no"),
|
|
Ok(Some(reason)) => {
|
|
println!(" drift: yes");
|
|
println!(" reason: {}", reason);
|
|
}
|
|
Err(e) => println!(" drift: unknown ({e})"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_antitorrent_section(cfg: &Config) {
|
|
println!("Anti-torrent:");
|
|
println!(" enabled: {}", cfg.anti_torrent.enabled);
|
|
println!(
|
|
" max_connections_per_peer: {}",
|
|
cfg.anti_torrent.max_connections_per_peer
|
|
);
|
|
if !cfg.anti_torrent.enabled {
|
|
return;
|
|
}
|
|
match firewall::antitorrent_drift_reason(cfg) {
|
|
Ok(None) => println!(" drift: no"),
|
|
Ok(Some(reason)) => {
|
|
println!(" drift: yes");
|
|
println!(" reason: {}", reason);
|
|
}
|
|
Err(e) => println!(" drift: unknown ({e})"),
|
|
}
|
|
match firewall::antitorrent_drop_counter(cfg) {
|
|
Ok(Some((packets, bytes))) => {
|
|
println!(" dropped_packets: {}", packets);
|
|
println!(" dropped_bytes: {}", bytes);
|
|
}
|
|
Ok(None) => println!(" dropped_packets: n/a"),
|
|
Err(e) => println!(" dropped_packets: inspect error ({e})"),
|
|
}
|
|
}
|
|
|
|
fn print_conntrack_section() {
|
|
println!("Conntrack:");
|
|
println!(
|
|
" nf_conntrack_count: {}",
|
|
read_sysctl_or_na("net.netfilter.nf_conntrack_count")
|
|
);
|
|
println!(
|
|
" nf_conntrack_max: {}",
|
|
read_sysctl_or_na("net.netfilter.nf_conntrack_max")
|
|
);
|
|
}
|
|
|
|
fn read_sysctl_or_na(key: &str) -> String {
|
|
Command::new("sysctl")
|
|
.args(["-n", key])
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
.unwrap_or_else(|| "n/a".to_string())
|
|
}
|
|
|
|
fn print_last_actions_section() {
|
|
println!("Last actions:");
|
|
let last = fs::read_to_string(LAST_STATE_FILE).unwrap_or_default();
|
|
if last.trim().is_empty() {
|
|
println!(" no persisted state");
|
|
return;
|
|
}
|
|
for line in last.lines() {
|
|
println!(" {}", line);
|
|
}
|
|
}
|
|
|
|
fn print_recent_events_section() {
|
|
println!("Recent events:");
|
|
let events = fs::read_to_string(EVENTS_FILE).unwrap_or_default();
|
|
let lines: Vec<&str> = events.lines().collect();
|
|
if lines.is_empty() {
|
|
println!(" no recent events");
|
|
return;
|
|
}
|
|
for l in lines.iter().rev().take(RECENT_EVENTS_LIMIT).rev() {
|
|
println!(" {l}");
|
|
}
|
|
}
|
|
|
|
fn print_reboot_section(cfg: &Config) {
|
|
println!("Reboot scheduler:");
|
|
println!(" enabled: {}", cfg.reboot.enabled);
|
|
println!(" window_start_utc: {}", cfg.reboot.window_start_utc);
|
|
println!(" window_end_utc: {}", cfg.reboot.window_end_utc);
|
|
let svc = Command::new("systemctl")
|
|
.args(["is-active", "awg2-reboot-scheduler"])
|
|
.output();
|
|
match svc {
|
|
Ok(out) if out.status.success() => println!(" service: active"),
|
|
Ok(_) => println!(" service: inactive"),
|
|
Err(_) => println!(" service: unknown"),
|
|
}
|
|
|
|
let plan = fs::read_to_string("/run/awg2-harness/reboot.plan").unwrap_or_default();
|
|
if plan.trim().is_empty() {
|
|
println!(" planned: n/a");
|
|
} else {
|
|
for line in plan.lines() {
|
|
println!(" {}", line);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_swap_status(cfg: &Config) {
|
|
println!("Swap:");
|
|
match swap::inspect_swap(cfg) {
|
|
Ok(s) => {
|
|
println!(" enabled: {}", s.enabled);
|
|
println!(" desired_path: {}", s.desired_path.display());
|
|
println!(" desired_size_mb: {}", cfg.swap.size_mb);
|
|
println!(" desired_priority: {}", cfg.swap.priority);
|
|
println!(" active_entries: {}", s.active_entries.len());
|
|
println!(
|
|
" desired_active: {}",
|
|
if s.desired_active { "yes" } else { "no" }
|
|
);
|
|
println!(" exclusive: {}", if s.exclusive { "yes" } else { "no" });
|
|
println!(
|
|
" fstab_exclusive: {}",
|
|
if s.fstab_exclusive { "yes" } else { "no" }
|
|
);
|
|
println!(" fstab_swap_entries: {}", s.fstab_swap_entries.len());
|
|
println!(
|
|
" swappiness_current: {}",
|
|
s.current_swappiness
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "n/a".to_string())
|
|
);
|
|
println!(
|
|
" swappiness_persisted: {}",
|
|
s.persisted_swappiness
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "n/a".to_string())
|
|
);
|
|
if let Some(reason) = s.drift_reason {
|
|
println!(" drift: yes");
|
|
println!(" drift_reason: {}", reason);
|
|
} else {
|
|
println!(" drift: no");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!(" enabled: {}", cfg.swap.enabled);
|
|
println!(" desired_path: {}", cfg.swap.path);
|
|
println!(" drift: unknown ({e})");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) {
|
|
println!("Docker/Amnezia port path:");
|
|
println!(
|
|
" docker_userland_proxy: {}",
|
|
diag.docker_userland_proxy.as_str()
|
|
);
|
|
match &diag.published_udp_port {
|
|
Some(p) => println!(
|
|
" published_udp_port: {} -> {}:{}",
|
|
p.host_port, p.container_ip, p.container_port
|
|
),
|
|
None => println!(" published_udp_port: unknown"),
|
|
}
|
|
println!(
|
|
" host_forward_policy_drop: {}",
|
|
diag.host_forward_policy_drop.as_str()
|
|
);
|
|
println!(
|
|
" docker_forward_rules_present: {}",
|
|
diag.docker_forward_rules_present.as_str()
|
|
);
|
|
println!("{FORWARD_OWNERSHIP_NOTE}");
|
|
if let Some(w) = diag.warning() {
|
|
println!(" warning: {w}");
|
|
}
|
|
}
|
|
|
|
/// Same block as [`print_docker_amnezia_port_path`], but every probe needs the container,
|
|
/// so all four fields are reported as unknown rather than omitted.
|
|
fn print_unknown_docker_amnezia_port_path() {
|
|
println!("Docker/Amnezia port path:");
|
|
println!(" docker_userland_proxy: unknown");
|
|
println!(" published_udp_port: unknown");
|
|
println!(" host_forward_policy_drop: unknown");
|
|
println!(" docker_forward_rules_present: unknown");
|
|
println!("{FORWARD_OWNERSHIP_NOTE}");
|
|
}
|
|
|
|
fn handshake_is_online(epoch: Option<u64>, threshold_secs: u64) -> &'static str {
|
|
if let Some(ts) = epoch {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
if now.saturating_sub(ts) <= threshold_secs {
|
|
return "online";
|
|
}
|
|
}
|
|
"offline"
|
|
}
|
|
|
|
fn format_handshake_age(epoch: Option<u64>) -> String {
|
|
let Some(ts) = epoch else {
|
|
return "never".to_string();
|
|
};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let age = now.saturating_sub(ts);
|
|
format!("{}s ago", age)
|
|
}
|
|
|
|
fn format_bytes(v: u64) -> String {
|
|
const KB: f64 = 1024.0;
|
|
const MB: f64 = KB * 1024.0;
|
|
const GB: f64 = MB * 1024.0;
|
|
let b = v as f64;
|
|
if b >= GB {
|
|
format!("{:.1}GB", b / GB)
|
|
} else if b >= MB {
|
|
format!("{:.1}MB", b / MB)
|
|
} else if b >= KB {
|
|
format!("{:.1}KB", b / KB)
|
|
} else {
|
|
format!("{}B", v)
|
|
}
|
|
}
|
|
|
|
fn collect_peer_filter_hits(
|
|
iface: &str,
|
|
ifb: &str,
|
|
pid: u32,
|
|
container_name: &str,
|
|
) -> anyhow::Result<BTreeMap<String, (u64, u64)>> {
|
|
let transport = build_transport(pid, container_name);
|
|
let egress = transport.run(&["tc", "-s", "filter", "show", "dev", iface, "parent", "1:"])?;
|
|
let ingress = transport.run(&["tc", "-s", "filter", "show", "dev", ifb, "parent", "2:"])?;
|
|
let egress_hits = parse_tc_filter_hits(&egress);
|
|
let ingress_hits = parse_tc_filter_hits(&ingress);
|
|
|
|
let mut out: BTreeMap<String, (u64, u64)> = BTreeMap::new();
|
|
for (ip, c) in egress_hits {
|
|
out.entry(ip).or_insert((0, 0)).0 = c;
|
|
}
|
|
for (ip, c) in ingress_hits {
|
|
out.entry(ip).or_insert((0, 0)).1 = c;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn parse_tc_filter_hits(raw: &str) -> BTreeMap<String, u64> {
|
|
let mut out = BTreeMap::new();
|
|
let mut current_ip: Option<String> = None;
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
|
|
if t.contains("match ") {
|
|
current_ip = extract_tc_match_ip(t);
|
|
}
|
|
|
|
if t.starts_with("Sent ") {
|
|
if let Some(ip) = current_ip.clone() {
|
|
let hits = parse_sent_packets(t).unwrap_or(0);
|
|
out.insert(ip, hits);
|
|
}
|
|
} else if (t.contains(" success ") || t.starts_with("success "))
|
|
&& let Some(ip) = current_ip.clone()
|
|
{
|
|
let hits = parse_success_packets(t).unwrap_or(0);
|
|
out.insert(ip, hits);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn parse_sent_packets(line: &str) -> Option<u64> {
|
|
let toks: Vec<&str> = line.split_whitespace().collect();
|
|
if toks.len() >= 5 && toks[0] == "Sent" && toks[2] == "bytes" && toks[4] == "pkt" {
|
|
return toks[3].parse::<u64>().ok();
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_success_packets(line: &str) -> Option<u64> {
|
|
let toks: Vec<&str> = line.split_whitespace().collect();
|
|
let pos = toks.iter().position(|v| *v == "success")?;
|
|
toks.get(pos + 1)?.parse::<u64>().ok()
|
|
}
|
|
|
|
fn extract_tc_match_ip(line: &str) -> Option<String> {
|
|
let toks: Vec<&str> = line.split_whitespace().collect();
|
|
let pos = toks.iter().position(|v| *v == "match")?;
|
|
let match_val = *toks.get(pos + 1)?;
|
|
let (hex, mask) = match_val.split_once('/')?;
|
|
if !mask.eq_ignore_ascii_case("ffffffff") || hex.len() != 8 {
|
|
return None;
|
|
}
|
|
let b1 = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
|
let b2 = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
|
let b3 = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
|
let b4 = u8::from_str_radix(&hex[6..8], 16).ok()?;
|
|
Some(format!("{b1}.{b2}.{b3}.{b4}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{parse_sent_packets, parse_success_packets, parse_tc_filter_hits};
|
|
|
|
#[test]
|
|
fn parse_sent_pkt_counter() {
|
|
let line = "Sent 12345 bytes 67 pkt (dropped 0, overlimits 0 requeues 0)";
|
|
assert_eq!(parse_sent_packets(line), Some(67));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_success_counter() {
|
|
let line = "match 0a080102/ffffffff at 16 success 42";
|
|
assert_eq!(parse_success_packets(line), Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_tc_filter_hits_supports_sent_and_success() {
|
|
let raw = r#"
|
|
filter parent 1: protocol ip pref 1 u32 chain 0
|
|
filter parent 1: protocol ip pref 1 u32 chain 0 fh 800: ht divisor 1
|
|
filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:10 not_in_hw
|
|
match 0a080102/ffffffff at 16
|
|
Sent 100 bytes 7 pkt (dropped 0, overlimits 0 requeues 0)
|
|
filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::801 order 2049 key ht 800 bkt 0 flowid 1:11 not_in_hw
|
|
match 0a080103/ffffffff at 16 success 9
|
|
"#;
|
|
|
|
let map = parse_tc_filter_hits(raw);
|
|
assert_eq!(map.get("10.8.1.2"), Some(&7));
|
|
assert_eq!(map.get("10.8.1.3"), Some(&9));
|
|
}
|
|
}
|