1006 lines
30 KiB
Rust
1006 lines
30 KiB
Rust
use crate::{
|
|
config::Config,
|
|
docker,
|
|
lock::FileLock,
|
|
netns::{NsTransport, build_transport},
|
|
wg,
|
|
};
|
|
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
net::Ipv4Addr,
|
|
};
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TrafficDrift {
|
|
has_drift: bool,
|
|
reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FilterInspect {
|
|
map: BTreeMap<String, u16>,
|
|
duplicate_ips: BTreeSet<String>,
|
|
duplicate_classes: BTreeSet<u16>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ClassRateCeil {
|
|
rate: String,
|
|
ceil: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PeerTrafficStatus {
|
|
pub ip: String,
|
|
pub class_id: u16,
|
|
pub egress_filter_present: bool,
|
|
pub ingress_filter_present: bool,
|
|
pub egress_rate: Option<String>,
|
|
pub egress_ceil: Option<String>,
|
|
pub ingress_rate: Option<String>,
|
|
pub ingress_ceil: Option<String>,
|
|
pub drift_reason: Option<String>,
|
|
}
|
|
|
|
pub fn inspect_drift_summary(cfg: &Config) -> anyhow::Result<Option<String>> {
|
|
if !cfg.traffic.enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let target = docker::discover_target(cfg)?;
|
|
let transport = build_transport(target.container_pid, &target.container_name);
|
|
let peers = wg::discover_peers(
|
|
target.container_pid,
|
|
&target.container_name,
|
|
&cfg.amnezia.peer_ipv4_cidr,
|
|
)?;
|
|
|
|
if peers.is_empty() {
|
|
return Ok(Some("no peers discovered".to_string()));
|
|
}
|
|
|
|
let drift = inspect_and_diff(
|
|
transport.as_ref(),
|
|
&target.interface_name,
|
|
&cfg.traffic.ifb_name,
|
|
cfg.traffic.class_start_id,
|
|
&cfg.traffic.root_rate,
|
|
&cfg.traffic.peer_limit,
|
|
&peers,
|
|
)?;
|
|
|
|
if drift.has_drift {
|
|
Ok(Some(drift.reason))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> {
|
|
if !cfg.traffic.enabled {
|
|
return Ok(());
|
|
}
|
|
|
|
let _lock = FileLock::acquire("/run/awg2-harness/traffic.lock")?;
|
|
let target = docker::discover_target(cfg)?;
|
|
let transport = build_transport(target.container_pid, &target.container_name);
|
|
let peers = wg::discover_peers(
|
|
target.container_pid,
|
|
&target.container_name,
|
|
&cfg.amnezia.peer_ipv4_cidr,
|
|
)?;
|
|
|
|
if peers.is_empty() {
|
|
println!("no peers found, skip tc apply");
|
|
return Ok(());
|
|
}
|
|
|
|
let drift = inspect_and_diff(
|
|
transport.as_ref(),
|
|
&target.interface_name,
|
|
&cfg.traffic.ifb_name,
|
|
cfg.traffic.class_start_id,
|
|
&cfg.traffic.root_rate,
|
|
&cfg.traffic.peer_limit,
|
|
&peers,
|
|
)?;
|
|
|
|
if !drift.has_drift {
|
|
println!("traffic rules up-to-date, skip apply");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("traffic drift detected: {}", drift.reason);
|
|
|
|
teardown_owned_topology(
|
|
transport.as_ref(),
|
|
&target.interface_name,
|
|
&cfg.traffic.ifb_name,
|
|
)?;
|
|
apply_full_topology(
|
|
cfg,
|
|
transport.as_ref(),
|
|
&target.interface_name,
|
|
&cfg.traffic.ifb_name,
|
|
&peers,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn peer_status_table(cfg: &Config) -> anyhow::Result<Option<Vec<PeerTrafficStatus>>> {
|
|
if !cfg.traffic.enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let target = docker::discover_target(cfg)?;
|
|
let transport = build_transport(target.container_pid, &target.container_name);
|
|
let peers = wg::discover_peers(
|
|
target.container_pid,
|
|
&target.container_name,
|
|
&cfg.amnezia.peer_ipv4_cidr,
|
|
)?;
|
|
if peers.is_empty() {
|
|
return Ok(Some(Vec::new()));
|
|
}
|
|
|
|
let filters_egress = transport
|
|
.run(&[
|
|
"tc",
|
|
"filter",
|
|
"show",
|
|
"dev",
|
|
&target.interface_name,
|
|
"parent",
|
|
"1:",
|
|
])
|
|
.unwrap_or_default();
|
|
let filters_ingress = transport
|
|
.run(&[
|
|
"tc",
|
|
"filter",
|
|
"show",
|
|
"dev",
|
|
&cfg.traffic.ifb_name,
|
|
"parent",
|
|
"2:",
|
|
])
|
|
.unwrap_or_default();
|
|
let classes_egress_full = transport
|
|
.run(&["tc", "class", "show", "dev", &target.interface_name])
|
|
.unwrap_or_default();
|
|
let classes_ingress_full = transport
|
|
.run(&["tc", "class", "show", "dev", &cfg.traffic.ifb_name])
|
|
.unwrap_or_default();
|
|
|
|
let inspect_egress = extract_filter_ip_class_map(&filters_egress);
|
|
let inspect_ingress = extract_filter_ip_class_map(&filters_ingress);
|
|
let actual_map_egress = inspect_egress.map;
|
|
let actual_map_ingress = inspect_ingress.map;
|
|
let egress_rates = parse_class_rate_ceil_map(&classes_egress_full, "1:");
|
|
let ingress_rates = parse_class_rate_ceil_map(&classes_ingress_full, "2:");
|
|
let expected_map = expected_peer_class_map(&peers, cfg.traffic.class_start_id);
|
|
let expected_limit = normalize_tc_rate(&cfg.traffic.peer_limit);
|
|
|
|
let mut out = Vec::with_capacity(peers.len());
|
|
for p in peers {
|
|
let ip = p.ipv4.to_string();
|
|
let class_id = *expected_map.get(&ip).unwrap_or(&cfg.traffic.class_start_id);
|
|
let class_egress = format!("1:{class_id}");
|
|
let class_ingress = format!("2:{class_id}");
|
|
|
|
let egress_filter_present = actual_map_egress.get(&ip).copied() == Some(class_id);
|
|
let ingress_filter_present = actual_map_ingress.get(&ip).copied() == Some(class_id);
|
|
let e = egress_rates.get(&class_egress);
|
|
let i = ingress_rates.get(&class_ingress);
|
|
|
|
let mut reasons = Vec::new();
|
|
if !egress_filter_present {
|
|
reasons.push("missing/mismatched egress filter".to_string());
|
|
}
|
|
if !ingress_filter_present {
|
|
reasons.push("missing/mismatched ingress filter".to_string());
|
|
}
|
|
if e.is_none() {
|
|
reasons.push("missing egress class".to_string());
|
|
}
|
|
if i.is_none() {
|
|
reasons.push("missing ingress class".to_string());
|
|
}
|
|
if let Some(v) = e
|
|
&& !rate_ceil_matches_limit(v, &expected_limit)
|
|
{
|
|
reasons.push("egress rate/ceil mismatch".to_string());
|
|
}
|
|
if let Some(v) = i
|
|
&& !rate_ceil_matches_limit(v, &expected_limit)
|
|
{
|
|
reasons.push("ingress rate/ceil mismatch".to_string());
|
|
}
|
|
if inspect_egress.duplicate_ips.contains(&ip) {
|
|
reasons.push("duplicate egress filters".to_string());
|
|
}
|
|
if inspect_ingress.duplicate_ips.contains(&ip) {
|
|
reasons.push("duplicate ingress filters".to_string());
|
|
}
|
|
|
|
out.push(PeerTrafficStatus {
|
|
ip,
|
|
class_id,
|
|
egress_filter_present,
|
|
ingress_filter_present,
|
|
egress_rate: e.map(|v| v.rate.clone()),
|
|
egress_ceil: e.map(|v| v.ceil.clone()),
|
|
ingress_rate: i.map(|v| v.rate.clone()),
|
|
ingress_ceil: i.map(|v| v.ceil.clone()),
|
|
drift_reason: if reasons.is_empty() {
|
|
None
|
|
} else {
|
|
Some(reasons.join(", "))
|
|
},
|
|
});
|
|
}
|
|
|
|
Ok(Some(out))
|
|
}
|
|
|
|
fn inspect_and_diff(
|
|
transport: &dyn NsTransport,
|
|
iface: &str,
|
|
ifb: &str,
|
|
class_start_id: u16,
|
|
root_rate: &str,
|
|
peer_limit: &str,
|
|
peers: &[wg::Peer],
|
|
) -> anyhow::Result<TrafficDrift> {
|
|
let root_qdisc = inspect_cmd(
|
|
transport,
|
|
&["tc", "qdisc", "show", "dev", iface],
|
|
"egress qdisc",
|
|
)?;
|
|
if !root_qdisc.contains("htb") {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "missing egress root htb qdisc".to_string(),
|
|
});
|
|
}
|
|
|
|
let ingress_qdisc = inspect_cmd(
|
|
transport,
|
|
&["tc", "qdisc", "show", "dev", iface],
|
|
"ingress qdisc",
|
|
)?;
|
|
if !ingress_qdisc.contains("ingress") {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "missing ingress qdisc".to_string(),
|
|
});
|
|
}
|
|
|
|
let ifb_link = inspect_cmd(transport, &["ip", "link", "show", ifb], "ifb link")?;
|
|
if ifb_link.trim().is_empty() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "missing ifb interface".to_string(),
|
|
});
|
|
}
|
|
if !ifb_link.contains(" UP ") && !ifb_link.contains("state UP") {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "ifb interface is not UP".to_string(),
|
|
});
|
|
}
|
|
|
|
let filters_egress = inspect_cmd(
|
|
transport,
|
|
&["tc", "filter", "show", "dev", iface, "parent", "1:"],
|
|
"egress filters",
|
|
)?;
|
|
let filters_ingress = inspect_cmd(
|
|
transport,
|
|
&["tc", "filter", "show", "dev", ifb, "parent", "2:"],
|
|
"ingress filters",
|
|
)?;
|
|
|
|
let inspect_egress = extract_filter_ip_class_map(&filters_egress);
|
|
let inspect_ingress = extract_filter_ip_class_map(&filters_ingress);
|
|
if !inspect_egress.duplicate_ips.is_empty() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"duplicate egress filters detected for {} peer(s)",
|
|
inspect_egress.duplicate_ips.len()
|
|
),
|
|
});
|
|
}
|
|
if !inspect_egress.duplicate_classes.is_empty() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "duplicate egress class flowid mapping detected".to_string(),
|
|
});
|
|
}
|
|
if !inspect_ingress.duplicate_ips.is_empty() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"duplicate ingress filters detected for {} peer(s)",
|
|
inspect_ingress.duplicate_ips.len()
|
|
),
|
|
});
|
|
}
|
|
if !inspect_ingress.duplicate_classes.is_empty() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "duplicate ingress class flowid mapping detected".to_string(),
|
|
});
|
|
}
|
|
let actual_map_egress = inspect_egress.map;
|
|
let actual_map_ingress = inspect_ingress.map;
|
|
let actual_ips_egress: BTreeSet<String> = actual_map_egress.keys().cloned().collect();
|
|
let actual_ips_ingress: BTreeSet<String> = actual_map_ingress.keys().cloned().collect();
|
|
let expected_ips: BTreeSet<String> = peers.iter().map(|p| p.ipv4.to_string()).collect();
|
|
|
|
if actual_ips_egress != expected_ips {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"egress peer filter mismatch (actual={}, expected={})",
|
|
actual_ips_egress.len(),
|
|
expected_ips.len()
|
|
),
|
|
});
|
|
}
|
|
|
|
if actual_ips_ingress != expected_ips {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"ingress peer filter mismatch (actual={}, expected={})",
|
|
actual_ips_ingress.len(),
|
|
expected_ips.len()
|
|
),
|
|
});
|
|
}
|
|
|
|
let expected_map = expected_peer_class_map(peers, class_start_id);
|
|
if actual_map_egress != expected_map {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"egress class mapping mismatch (actual={}, expected={})",
|
|
actual_map_egress.len(),
|
|
expected_map.len()
|
|
),
|
|
});
|
|
}
|
|
if actual_map_ingress != expected_map {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!(
|
|
"ingress class mapping mismatch (actual={}, expected={})",
|
|
actual_map_ingress.len(),
|
|
expected_map.len()
|
|
),
|
|
});
|
|
}
|
|
|
|
let expected_limit = normalize_tc_rate(peer_limit);
|
|
let classes_egress_full = inspect_cmd(
|
|
transport,
|
|
&["tc", "class", "show", "dev", iface],
|
|
"egress classes",
|
|
)?;
|
|
let classes_ingress_full = inspect_cmd(
|
|
transport,
|
|
&["tc", "class", "show", "dev", ifb],
|
|
"ingress classes",
|
|
)?;
|
|
let expected_root = normalize_tc_rate(root_rate);
|
|
let egress_rates = parse_class_rate_ceil_map(&classes_egress_full, "1:");
|
|
let ingress_rates = parse_class_rate_ceil_map(&classes_ingress_full, "2:");
|
|
|
|
let root_egress = parse_root_class_rate_ceil(&classes_egress_full, "1:1");
|
|
let root_ingress = parse_root_class_rate_ceil(&classes_ingress_full, "2:1");
|
|
if root_egress
|
|
.as_ref()
|
|
.map(|v| !rate_ceil_matches_limit(v, &expected_root))
|
|
.unwrap_or(true)
|
|
{
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "egress root class 1:1 rate/ceil mismatch".to_string(),
|
|
});
|
|
}
|
|
if root_ingress
|
|
.as_ref()
|
|
.map(|v| !rate_ceil_matches_limit(v, &expected_root))
|
|
.unwrap_or(true)
|
|
{
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "ingress root class 2:1 rate/ceil mismatch".to_string(),
|
|
});
|
|
}
|
|
|
|
for class_id in expected_map.values() {
|
|
let class_egress = format!("1:{class_id}");
|
|
let class_ingress = format!("2:{class_id}");
|
|
let e = egress_rates.get(&class_egress);
|
|
let i = ingress_rates.get(&class_ingress);
|
|
if e.is_none() || i.is_none() {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!("missing rate/ceil class details for peer class_id={class_id}"),
|
|
});
|
|
}
|
|
if e.map(|v| !rate_ceil_matches_limit(v, &expected_limit))
|
|
.unwrap_or(true)
|
|
|| i.map(|v| !rate_ceil_matches_limit(v, &expected_limit))
|
|
.unwrap_or(true)
|
|
{
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!("rate/ceil mismatch for peer class_id={class_id}"),
|
|
});
|
|
}
|
|
}
|
|
|
|
let classes_egress = inspect_cmd(
|
|
transport,
|
|
&["tc", "class", "show", "dev", iface, "parent", "1:1"],
|
|
"egress leaf classes",
|
|
)?;
|
|
let classes_ingress = inspect_cmd(
|
|
transport,
|
|
&["tc", "class", "show", "dev", ifb, "parent", "2:1"],
|
|
"ingress leaf classes",
|
|
)?;
|
|
let expected_set: BTreeSet<u16> = expected_map.values().copied().collect();
|
|
if collect_leaf_class_ids(&classes_egress) != expected_set {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "egress per-peer class set mismatch".to_string(),
|
|
});
|
|
}
|
|
if collect_leaf_class_ids(&classes_ingress) != expected_set {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: "ingress per-peer class set mismatch".to_string(),
|
|
});
|
|
}
|
|
|
|
let qdisc_egress = inspect_cmd(
|
|
transport,
|
|
&["tc", "qdisc", "show", "dev", iface],
|
|
"egress leaf qdisc",
|
|
)?;
|
|
let qdisc_ingress = inspect_cmd(
|
|
transport,
|
|
&["tc", "qdisc", "show", "dev", ifb],
|
|
"ingress leaf qdisc",
|
|
)?;
|
|
for class_id in expected_map.values() {
|
|
let class_egress = format!("1:{class_id}");
|
|
let class_ingress = format!("2:{class_id}");
|
|
if !has_fq_codel_for_parent(&qdisc_egress, &class_egress) {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!("missing fq_codel for egress class {class_egress}"),
|
|
});
|
|
}
|
|
if !has_fq_codel_for_parent(&qdisc_ingress, &class_ingress) {
|
|
return Ok(TrafficDrift {
|
|
has_drift: true,
|
|
reason: format!("missing fq_codel for ingress class {class_ingress}"),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(TrafficDrift {
|
|
has_drift: false,
|
|
reason: "no drift".to_string(),
|
|
})
|
|
}
|
|
|
|
fn inspect_cmd(transport: &dyn NsTransport, args: &[&str], what: &str) -> anyhow::Result<String> {
|
|
transport
|
|
.run(args)
|
|
.map_err(|e| anyhow::anyhow!("{what} inspect failed: {e}"))
|
|
}
|
|
|
|
fn extract_filter_ip_class_map(filters: &str) -> FilterInspect {
|
|
let mut out = BTreeMap::new();
|
|
let mut duplicate_ips = BTreeSet::new();
|
|
let mut class_to_ip = BTreeMap::new();
|
|
let mut duplicate_classes = BTreeSet::new();
|
|
|
|
for block in split_filter_blocks(filters) {
|
|
let mut ip: Option<String> = None;
|
|
let mut class_id: Option<u16> = None;
|
|
|
|
for line in block.lines() {
|
|
let t = line.trim();
|
|
if t.contains("match") && ip.is_none() {
|
|
ip = extract_u32_match_hex(t).and_then(|hex| hex_to_ipv4_string(&hex));
|
|
}
|
|
if t.contains("flowid") && class_id.is_none() {
|
|
class_id = extract_flowid_class_id(t);
|
|
}
|
|
}
|
|
|
|
if let (Some(ip), Some(class_id)) = (ip, class_id) {
|
|
if out.contains_key(&ip) {
|
|
duplicate_ips.insert(ip.clone());
|
|
}
|
|
if class_to_ip.insert(class_id, ip.clone()).is_some() {
|
|
duplicate_classes.insert(class_id);
|
|
}
|
|
out.insert(ip, class_id);
|
|
}
|
|
}
|
|
|
|
FilterInspect {
|
|
map: out,
|
|
duplicate_ips,
|
|
duplicate_classes,
|
|
}
|
|
}
|
|
|
|
fn has_fq_codel_for_parent(raw_qdisc: &str, parent_class: &str) -> bool {
|
|
raw_qdisc.lines().map(str::trim).any(|line| {
|
|
line.contains("fq_codel") && line.contains("parent") && line.contains(parent_class)
|
|
})
|
|
}
|
|
|
|
fn parse_root_class_rate_ceil(raw: &str, class_id: &str) -> Option<ClassRateCeil> {
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
if !t.contains("class htb")
|
|
|| !t.contains(class_id)
|
|
|| !t.contains(" rate ")
|
|
|| !t.contains(" ceil ")
|
|
{
|
|
continue;
|
|
}
|
|
let toks: Vec<&str> = t.split_whitespace().collect();
|
|
let rate = toks
|
|
.iter()
|
|
.position(|v| *v == "rate")
|
|
.and_then(|i| toks.get(i + 1))
|
|
.map(|s| s.to_string())?;
|
|
let ceil = toks
|
|
.iter()
|
|
.position(|v| *v == "ceil")
|
|
.and_then(|i| toks.get(i + 1))
|
|
.map(|s| s.to_string())?;
|
|
return Some(ClassRateCeil { rate, ceil });
|
|
}
|
|
None
|
|
}
|
|
|
|
fn split_filter_blocks(raw: &str) -> Vec<String> {
|
|
let mut blocks = Vec::new();
|
|
let mut cur = Vec::new();
|
|
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
if t.starts_with("filter") && !cur.is_empty() {
|
|
blocks.push(cur.join("\n"));
|
|
cur.clear();
|
|
}
|
|
if !t.is_empty() {
|
|
cur.push(t.to_string());
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
blocks.push(cur.join("\n"));
|
|
}
|
|
|
|
blocks
|
|
}
|
|
|
|
fn extract_flowid_class_id(line: &str) -> Option<u16> {
|
|
let toks: Vec<&str> = line.split_whitespace().collect();
|
|
let flowid = toks
|
|
.iter()
|
|
.position(|v| *v == "flowid")
|
|
.and_then(|i| toks.get(i + 1))?;
|
|
let class_tail = flowid.split(':').nth(1)?;
|
|
class_tail.parse::<u16>().ok()
|
|
}
|
|
|
|
fn expected_peer_class_map(peers: &[wg::Peer], class_start_id: u16) -> BTreeMap<String, u16> {
|
|
let mut map = BTreeMap::new();
|
|
for (idx, p) in peers.iter().enumerate() {
|
|
map.insert(p.ipv4.to_string(), class_start_id + idx as u16);
|
|
}
|
|
map
|
|
}
|
|
|
|
fn extract_u32_match_hex(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 Some(hex.to_ascii_lowercase());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn hex_to_ipv4_string(hex: &str) -> Option<String> {
|
|
if 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(Ipv4Addr::new(b1, b2, b3, b4).to_string())
|
|
}
|
|
|
|
fn collect_leaf_class_ids(raw: &str) -> BTreeSet<u16> {
|
|
let mut out = BTreeSet::new();
|
|
for l in raw.lines() {
|
|
let line = l.trim();
|
|
let Some(pos) = line.find("class htb ") else {
|
|
continue;
|
|
};
|
|
let class = &line[(pos + "class htb ".len())..];
|
|
let Some(head) = class.split_whitespace().next() else {
|
|
continue;
|
|
};
|
|
if let Some(class_id) = head.split(':').nth(1).and_then(|v| v.parse::<u16>().ok())
|
|
&& class_id != 1
|
|
{
|
|
out.insert(class_id);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn teardown_owned_topology(
|
|
transport: &dyn NsTransport,
|
|
iface: &str,
|
|
ifb: &str,
|
|
) -> anyhow::Result<()> {
|
|
run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "root"])?;
|
|
run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "ingress"])?;
|
|
run_allow_notfound(transport, &["ip", "link", "del", ifb])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_full_topology(
|
|
cfg: &Config,
|
|
transport: &dyn NsTransport,
|
|
iface: &str,
|
|
ifb: &str,
|
|
peers: &[wg::Peer],
|
|
) -> anyhow::Result<()> {
|
|
let root = cfg.traffic.root_rate.as_str();
|
|
run_allow_exists(transport, &["modprobe", "ifb"])?;
|
|
run_allow_exists(
|
|
transport,
|
|
&["ip", "link", "add", "name", ifb, "type", "ifb"],
|
|
)?;
|
|
run_step(transport, &["ip", "link", "set", "dev", ifb, "up"])?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "qdisc", "add", "dev", iface, "root", "handle", "1:", "htb",
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "class", "add", "dev", iface, "parent", "1:", "classid", "1:1", "htb", "rate",
|
|
root, "ceil", root,
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress",
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "u32",
|
|
"match", "u32", "0", "0", "action", "mirred", "egress", "redirect", "dev", ifb,
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "qdisc", "add", "dev", ifb, "root", "handle", "2:", "htb",
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc", "class", "add", "dev", ifb, "parent", "2:", "classid", "2:1", "htb", "rate",
|
|
root, "ceil", root,
|
|
],
|
|
)?;
|
|
|
|
for (idx, p) in peers.iter().enumerate() {
|
|
let class_id = cfg.traffic.class_start_id + idx as u16;
|
|
let ip = p.ipv4.to_string();
|
|
let class_egress = format!("1:{class_id}");
|
|
let class_ingress = format!("2:{class_id}");
|
|
let limit = cfg.traffic.peer_limit.as_str();
|
|
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"class",
|
|
"add",
|
|
"dev",
|
|
iface,
|
|
"parent",
|
|
"1:1",
|
|
"classid",
|
|
class_egress.as_str(),
|
|
"htb",
|
|
"rate",
|
|
limit,
|
|
"ceil",
|
|
limit,
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"qdisc",
|
|
"add",
|
|
"dev",
|
|
iface,
|
|
"parent",
|
|
class_egress.as_str(),
|
|
"fq_codel",
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"filter",
|
|
"add",
|
|
"dev",
|
|
iface,
|
|
"parent",
|
|
"1:",
|
|
"protocol",
|
|
"ip",
|
|
"u32",
|
|
"match",
|
|
"ip",
|
|
"dst",
|
|
ip.as_str(),
|
|
"flowid",
|
|
class_egress.as_str(),
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"class",
|
|
"add",
|
|
"dev",
|
|
ifb,
|
|
"parent",
|
|
"2:1",
|
|
"classid",
|
|
class_ingress.as_str(),
|
|
"htb",
|
|
"rate",
|
|
limit,
|
|
"ceil",
|
|
limit,
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"qdisc",
|
|
"add",
|
|
"dev",
|
|
ifb,
|
|
"parent",
|
|
class_ingress.as_str(),
|
|
"fq_codel",
|
|
],
|
|
)?;
|
|
run_allow_exists(
|
|
transport,
|
|
&[
|
|
"tc",
|
|
"filter",
|
|
"add",
|
|
"dev",
|
|
ifb,
|
|
"parent",
|
|
"2:",
|
|
"protocol",
|
|
"ip",
|
|
"u32",
|
|
"match",
|
|
"ip",
|
|
"src",
|
|
ip.as_str(),
|
|
"flowid",
|
|
class_ingress.as_str(),
|
|
],
|
|
)?;
|
|
}
|
|
|
|
let verify = inspect_and_diff(
|
|
transport,
|
|
iface,
|
|
ifb,
|
|
cfg.traffic.class_start_id,
|
|
&cfg.traffic.root_rate,
|
|
&cfg.traffic.peer_limit,
|
|
peers,
|
|
)?;
|
|
if verify.has_drift {
|
|
anyhow::bail!("verify-after-apply failed: {}", verify.reason);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn run_step(transport: &dyn NsTransport, args: &[&str]) -> anyhow::Result<()> {
|
|
let _ = transport.run(args)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn run_allow_exists(transport: &dyn NsTransport, args: &[&str]) -> anyhow::Result<()> {
|
|
match transport.run(args) {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("File exists") || msg.contains("EEXIST") {
|
|
return Ok(());
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_allow_notfound(transport: &dyn NsTransport, args: &[&str]) -> anyhow::Result<()> {
|
|
match transport.run(args) {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("No such file")
|
|
|| msg.contains("No such")
|
|
|| msg.contains("not found")
|
|
|| msg.contains("Cannot find")
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_class_rate_ceil_map(raw: &str, prefix: &str) -> BTreeMap<String, ClassRateCeil> {
|
|
let mut out = BTreeMap::new();
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
if !t.contains("class htb") || !t.contains(" rate ") || !t.contains(" ceil ") {
|
|
continue;
|
|
}
|
|
let toks: Vec<&str> = t.split_whitespace().collect();
|
|
let class = toks
|
|
.iter()
|
|
.position(|v| *v == "htb")
|
|
.and_then(|i| toks.get(i + 1))
|
|
.map(|s| s.to_string());
|
|
let rate = toks
|
|
.iter()
|
|
.position(|v| *v == "rate")
|
|
.and_then(|i| toks.get(i + 1))
|
|
.map(|s| s.to_string());
|
|
let ceil = toks
|
|
.iter()
|
|
.position(|v| *v == "ceil")
|
|
.and_then(|i| toks.get(i + 1))
|
|
.map(|s| s.to_string());
|
|
if let (Some(class), Some(rate), Some(ceil)) = (class, rate, ceil)
|
|
&& class.starts_with(prefix)
|
|
&& !class.ends_with(":1")
|
|
{
|
|
out.insert(class, ClassRateCeil { rate, ceil });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn rate_ceil_matches_limit(v: &ClassRateCeil, limit: &str) -> bool {
|
|
normalize_tc_rate(&v.rate) == limit && normalize_tc_rate(&v.ceil) == limit
|
|
}
|
|
|
|
fn normalize_tc_rate(v: &str) -> String {
|
|
v.trim().to_ascii_lowercase().replace(' ', "")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_tc_u32_hex_ipv4() {
|
|
let raw = " match 0a080105/ffffffff at 16\n flowid 1:42";
|
|
let inspect = extract_filter_ip_class_map(raw);
|
|
assert!(inspect.duplicate_ips.is_empty());
|
|
assert!(inspect.duplicate_classes.is_empty());
|
|
assert_eq!(inspect.map.get("10.8.1.5").copied(), Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_block_based_filters() {
|
|
let raw = "filter parent 1: protocol ip pref 1 u32\n match 0a080102/ffffffff at 16\n flowid 1:10\nfilter parent 1: protocol ip pref 2 u32\n match 0a080103/ffffffff at 16\n flowid 1:11";
|
|
let inspect = extract_filter_ip_class_map(raw);
|
|
assert!(inspect.duplicate_ips.is_empty());
|
|
assert!(inspect.duplicate_classes.is_empty());
|
|
assert_eq!(inspect.map.get("10.8.1.2").copied(), Some(10));
|
|
assert_eq!(inspect.map.get("10.8.1.3").copied(), Some(11));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_duplicate_filter_ip() {
|
|
let raw = "filter parent 1: protocol ip pref 1 u32\n match 0a080102/ffffffff at 16\n flowid 1:10\nfilter parent 1: protocol ip pref 2 u32\n match 0a080102/ffffffff at 16\n flowid 1:10";
|
|
let inspect = extract_filter_ip_class_map(raw);
|
|
assert!(inspect.duplicate_ips.contains("10.8.1.2"));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_duplicate_filter_class_flowid() {
|
|
let raw = "filter parent 1: protocol ip pref 1 u32\n match 0a080102/ffffffff at 16\n flowid 1:10\nfilter parent 1: protocol ip pref 2 u32\n match 0a080103/ffffffff at 16\n flowid 1:10";
|
|
let inspect = extract_filter_ip_class_map(raw);
|
|
assert!(inspect.duplicate_classes.contains(&10));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_fq_codel_parent() {
|
|
let raw = "qdisc fq_codel 8001: dev wg0 parent 1:10 limit 10240p";
|
|
assert!(has_fq_codel_for_parent(raw, "1:10"));
|
|
assert!(!has_fq_codel_for_parent(raw, "1:11"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_root_rate_ceil() {
|
|
let raw = "class htb 1:1 root rate 1000Mbit ceil 1000Mbit";
|
|
let rc = parse_root_class_rate_ceil(raw, "1:1").expect("root class exists");
|
|
assert_eq!(normalize_tc_rate(&rc.rate), "1000mbit");
|
|
assert_eq!(normalize_tc_rate(&rc.ceil), "1000mbit");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_class_rate_ceil() {
|
|
let raw = "class htb 1:10 parent 1:1 rate 70Mbit ceil 70Mbit burst 1599b cburst 1599b\nclass htb 1:1 root rate 1000Mbit ceil 1000Mbit";
|
|
let m = parse_class_rate_ceil_map(raw, "1:");
|
|
let rc = m.get("1:10").expect("class exists");
|
|
assert_eq!(normalize_tc_rate(&rc.rate), "70mbit");
|
|
assert_eq!(normalize_tc_rate(&rc.ceil), "70mbit");
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limit_match_normalized() {
|
|
let rc = ClassRateCeil {
|
|
rate: "70Mbit".to_string(),
|
|
ceil: "70mbit".to_string(),
|
|
};
|
|
assert!(rate_ceil_matches_limit(&rc, "70mbit"));
|
|
assert!(!rate_ceil_matches_limit(&rc, "50mbit"));
|
|
}
|
|
}
|