use std::{net::Ipv4Addr, time::Duration};
use nlink::netlink::{
impair::{PeerImpairment, PerPeerImpairer},
link::DummyLink,
tc::NetemConfig,
};
use crate::common::TestNamespace;
fn netem_50ms() -> NetemConfig {
NetemConfig::new().delay(Duration::from_millis(50)).build()
}
#[tokio::test]
async fn test_apply_creates_full_tree() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_apply")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 3).into(), netem_50ms())
.apply(&conn)
.await?;
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
assert!(
qdiscs
.iter()
.any(|q| q.kind() == Some("htb") && q.is_root()),
"root HTB should exist"
);
let netem_count = qdiscs.iter().filter(|q| q.kind() == Some("netem")).count();
assert_eq!(
netem_count, 3,
"expected 3 netem leaves (one per rule, no default impairment)"
);
let classes = conn.get_classes_by_index(ifindex).await?;
let htb_classes = classes.iter().filter(|c| c.kind() == Some("htb")).count();
assert_eq!(
htb_classes, 5,
"expected 5 HTB classes (1 parent + 3 rules + 1 default)"
);
let filters = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?;
let flower_filters = filters
.iter()
.filter(|f| f.kind() == Some("flower"))
.count();
assert_eq!(flower_filters, 3, "expected 3 flower filters at parent 1:");
Ok(())
}
#[tokio::test]
async fn test_apply_with_default_impairment_adds_default_leaf() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_default")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.default_impairment(NetemConfig::new().delay(Duration::from_millis(20)).build())
.apply(&conn)
.await?;
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
let netem_count = qdiscs.iter().filter(|q| q.kind() == Some("netem")).count();
assert_eq!(
netem_count, 2,
"expected 2 netem leaves (1 rule + 1 default impairment)"
);
Ok(())
}
#[tokio::test]
async fn test_apply_no_default_means_no_default_leaf() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_no_default")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.apply(&conn)
.await?;
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
let netem_count = qdiscs.iter().filter(|q| q.kind() == Some("netem")).count();
assert_eq!(
netem_count, 1,
"expected 1 netem leaf (rule only, no default impairment)"
);
Ok(())
}
#[tokio::test]
async fn test_apply_idempotent() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_idempotent")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
let imp = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms());
imp.apply(&conn).await?;
let first_filter_count = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?
.iter()
.filter(|f| f.kind() == Some("flower"))
.count();
imp.apply(&conn).await?;
let second_filter_count = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?
.iter()
.filter(|f| f.kind() == Some("flower"))
.count();
assert_eq!(first_filter_count, second_filter_count);
assert_eq!(first_filter_count, 2);
Ok(())
}
#[tokio::test]
async fn test_clear_removes_all() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_clear")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
let imp = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms());
imp.apply(&conn).await?;
assert!(
conn.get_qdiscs_by_index(ifindex)
.await?
.iter()
.any(|q| q.kind() == Some("htb")),
"HTB should exist after apply"
);
imp.clear(&conn).await?;
assert!(
!conn
.get_qdiscs_by_index(ifindex)
.await?
.iter()
.any(|q| q.kind() == Some("htb")),
"HTB should be removed after clear"
);
imp.clear(&conn).await?;
Ok(())
}
#[tokio::test]
async fn test_apply_with_ipv6_match() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_v6")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_subnet("2001:db8::/32", netem_50ms())?
.apply(&conn)
.await?;
let filters = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?;
let flower_filters: Vec<_> = filters
.iter()
.filter(|f| f.kind() == Some("flower"))
.collect();
assert_eq!(flower_filters.len(), 1, "one flower filter expected");
Ok(())
}
#[tokio::test]
async fn test_apply_with_dst_mac_match() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_mac")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_mac([0x52, 0x54, 0x00, 0x12, 0x34, 0x56], netem_50ms())
.apply(&conn)
.await?;
let filters = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?;
assert!(
filters.iter().any(|f| f.kind() == Some("flower")),
"flower filter for MAC match should exist"
);
Ok(())
}
#[tokio::test]
async fn test_apply_by_index_constructor() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_byidx")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new_by_index(ifindex)
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.apply(&conn)
.await?;
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
assert!(
qdiscs.iter().any(|q| q.kind() == Some("htb")),
"tree should be created when constructed by ifindex"
);
Ok(())
}
#[tokio::test]
async fn test_rate_cap_per_rule() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_ratecap")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_ip(
Ipv4Addr::new(10, 0, 0, 1).into(),
PeerImpairment::new(netem_50ms()).rate_cap(nlink::Rate::mbit(100)),
)
.apply(&conn)
.await?;
let classes = conn.get_classes_by_index(ifindex).await?;
let htb_classes: Vec<_> = classes.iter().filter(|c| c.kind() == Some("htb")).collect();
assert_eq!(htb_classes.len(), 3, "expected 3 HTB classes with rate cap");
Ok(())
}
#[tokio::test]
async fn test_reconcile_first_call_creates_tree() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_reconcile_first")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
let report = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms())
.reconcile(&conn)
.await?;
assert!(report.changes_made > 0, "first reconcile must mutate");
assert_eq!(report.rules_added, 2);
assert!(report.root_modified);
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
assert!(
qdiscs
.iter()
.any(|q| q.kind() == Some("htb") && q.is_root())
);
assert_eq!(
qdiscs.iter().filter(|q| q.kind() == Some("netem")).count(),
2,
);
Ok(())
}
#[tokio::test]
async fn test_reconcile_idempotent() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_reconcile_idem")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let imp = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms());
let r1 = imp.reconcile(&conn).await?;
assert!(r1.changes_made > 0);
let r2 = imp.reconcile(&conn).await?;
assert!(
r2.is_noop(),
"second reconcile should be a no-op (got {} changes: {:?})",
r2.changes_made,
r2,
);
Ok(())
}
#[tokio::test]
async fn test_reconcile_modify_netem() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_reconcile_mod")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let imp_a = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms());
imp_a.reconcile(&conn).await?;
let imp_b = PerPeerImpairer::new("test0").impair_dst_ip(
Ipv4Addr::new(10, 0, 0, 1).into(),
NetemConfig::new().delay(Duration::from_millis(120)).build(),
);
let r = imp_b.reconcile(&conn).await?;
assert_eq!(
r.rules_modified, 1,
"rule should be modified, not added/removed (report: {:?})",
r,
);
assert_eq!(r.rules_added, 0);
assert_eq!(r.rules_removed, 0);
Ok(())
}
#[tokio::test]
async fn test_reconcile_remove_rule() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_reconcile_rm")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 3).into(), netem_50ms())
.reconcile(&conn)
.await?;
let r = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms())
.reconcile(&conn)
.await?;
assert!(
r.rules_removed >= 1,
"expected at least 1 rule removed: {r:?}"
);
Ok(())
}
#[tokio::test]
async fn test_reconcile_dry_run_makes_no_changes() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_reconcile_dry")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
let report = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.reconcile_dry_run(&conn)
.await?;
assert!(report.dry_run);
assert!(report.changes_made > 0, "would-do count should be > 0");
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
assert!(
qdiscs.iter().all(|q| q.kind() != Some("htb")),
"dry-run installed an HTB qdisc",
);
Ok(())
}
#[tokio::test]
async fn test_reconcile_wrong_root_kind_errors() -> nlink::Result<()> {
require_root!();
use nlink::netlink::tc::PrioConfig;
let ns = TestNamespace::new("impair_reconcile_wrongroot")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
conn.add_qdisc_full(
"test0",
nlink::TcHandle::ROOT,
Some(nlink::TcHandle::major_only(1)),
PrioConfig::new().build(),
)
.await?;
let r = PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.reconcile(&conn)
.await;
assert!(r.is_err(), "expected error for wrong root kind, got {r:?}");
Ok(())
}
#[tokio::test]
async fn test_reconcile_with_fallback_to_apply() -> nlink::Result<()> {
require_root!();
use nlink::ReconcileOptions;
use nlink::netlink::tc::PrioConfig;
let ns = TestNamespace::new("impair_reconcile_fallback")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
conn.add_qdisc_full(
"test0",
nlink::TcHandle::ROOT,
Some(nlink::TcHandle::major_only(1)),
PrioConfig::new().build(),
)
.await?;
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.reconcile_with_options(&conn, ReconcileOptions::new().with_fallback_to_apply(true))
.await?;
let qdiscs = conn.get_qdiscs_by_index(ifindex).await?;
assert!(
qdiscs
.iter()
.any(|q| q.kind() == Some("htb") && q.is_root()),
"fallback should have rebuilt the HTB tree",
);
Ok(())
}
#[tokio::test]
async fn test_get_filters_by_parent_filters_correctly() -> nlink::Result<()> {
require_root!();
let ns = TestNamespace::new("impair_byparent")?;
let conn = ns.connection()?;
conn.add_link(DummyLink::new("test0")).await?;
conn.set_link_up("test0").await?;
let link = conn.get_link_by_name("test0").await?.expect("dummy exists");
let ifindex = link.ifindex();
PerPeerImpairer::new("test0")
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 1).into(), netem_50ms())
.impair_dst_ip(Ipv4Addr::new(10, 0, 0, 2).into(), netem_50ms())
.apply(&conn)
.await?;
let at_root = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(1))
.await?;
assert_eq!(at_root.len(), 2, "expected 2 filters at parent 1:");
assert!(at_root.iter().all(|f| f.kind() == Some("flower")));
let at_other = conn
.get_filters_by_parent_index(ifindex, nlink::TcHandle::major_only(2))
.await?;
assert!(at_other.is_empty(), "no filters expected at parent 2:");
Ok(())
}