From 8e1a2ff5a793620db2eb73ee14fa3ae42b5be85c Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 28 Sep 2026 12:04:24 +0200 Subject: [PATCH 1/3] refactor: move gateway veth lookup into NetworkCore Co-Authored-By: Claude Opus 5.5 (1M context) --- patchbay/src/core.rs | 20 ++++++++++++++++++++ patchbay/src/iface.rs | 16 +--------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/patchbay/src/core.rs b/patchbay/src/core.rs index c1e7485..236a6aa 100644 --- a/patchbay/src/core.rs +++ b/patchbay/src/core.rs @@ -667,6 +667,26 @@ impl NetworkCore { self.switches.get(&id) } + /// Returns the gateway router namespace and the name of the router-side + /// veth for a device interface, or `None` for a dummy interface. + /// + /// The router-side veth is the peer of the device's interface and sits + /// on the gateway router's downstream bridge. + pub(crate) fn gateway_veth( + &self, + iface: &DeviceIfaceData, + ) -> Result, String)>> { + let Some(uplink) = iface.uplink() else { + return Ok(None); + }; + let gw_router = self + .switch(uplink) + .and_then(|sw| sw.owner_router) + .and_then(|rid| self.router(rid)) + .ok_or_else(|| anyhow!("gateway router not found for interface '{}'", iface.ifname))?; + Ok(Some((gw_router.ns.clone(), format!("v{}", iface.idx)))) + } + /// Returns mutable switch data for `id`. pub(crate) fn switch_mut(&mut self, id: NodeId) -> Option<&mut Switch> { self.switches.get_mut(&id) diff --git a/patchbay/src/iface.rs b/patchbay/src/iface.rs index cf30b79..13a6503 100644 --- a/patchbay/src/iface.rs +++ b/patchbay/src/iface.rs @@ -295,21 +295,7 @@ impl Iface { ); } - let gateway = if !iface.is_dummy() { - let uplink = iface.uplink().expect("routed interface has uplink"); - let gw_router = inner - .switch(uplink) - .and_then(|sw| sw.owner_router) - .and_then(|rid| inner.router(rid)) - .ok_or_else(|| { - anyhow!("gateway router not found for interface '{}'", self.ifname) - })?; - Some((gw_router.ns.clone(), format!("v{}", iface.idx))) - } else { - None - }; - - (dev.ns.clone(), gateway, op) + (dev.ns.clone(), inner.gateway_veth(iface)?, op) }; let _guard = op.lock().await; From 72e85c716d60a357ec6df428afc155bbd9f1521d Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 28 Sep 2026 12:05:53 +0200 Subject: [PATCH 2/3] feat!: add Iface::carrier_down and carrier_up to simulate a pulled cable `link_down` is an administrative down. The kernel removes routes and flushes IPv6 addresses, and sends fail with an error. A pulled cable or a lost Wi-Fi association only drops carrier: the interface stays up, keeps its addresses and routes, and packets vanish silently. Apps that watch netlink see different events for the two, so tests need both. Carrier is toggled by taking down the router-side end of the veth pair, which the kernel reports as NO-CARRIER on the device side. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++ docs/guide/running-code.md | 17 +++++ docs/reference/patterns.md | 1 + patchbay/src/event.rs | 21 ++++++- patchbay/src/iface.rs | 66 ++++++++++++++++++++ patchbay/src/tests/link_condition.rs | 93 ++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5800200..8aa3c26 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,10 @@ dev.set_default_route("eth0").await?; dev.iface("wlan0").unwrap().link_down().await?; dev.iface("wlan0").unwrap().link_up().await?; +// Pull and replug the cable: carrier drops, addresses and routes stay. +dev.iface("wlan0").unwrap().carrier_down().await?; +dev.iface("wlan0").unwrap().carrier_up().await?; + // Change link condition dynamically. dev.iface("wlan0").unwrap().set_condition( LinkCondition::new().rate_kbit(1000).loss_pct(5.0).latency_ms(100), diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index e5d364b..336edf4 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -163,6 +163,23 @@ dev.iface("wlan0").unwrap().link_up().await?; // The interface is back and traffic flows again. ``` +An administrative down is what `ip link set wlan0 down` does. The kernel +removes the interface's routes, and sends fail with an error. A pulled +cable or a lost Wi-Fi association looks different: the interface stays up +and keeps its addresses and routes, but loses carrier, and packets vanish +without an error. Use `carrier_down` and `carrier_up` for that case: + +```rust +dev.iface("eth0").unwrap().carrier_down().await?; +// eth0 shows NO-CARRIER; addresses and routes stay, traffic is dropped. + +dev.iface("eth0").unwrap().carrier_up().await?; +// Carrier is back and traffic flows again, with nothing to restore. +``` + +Applications that watch netlink see different events for the two, so test +both if your code reacts to network changes. + ### Changing link conditions at runtime Modify link impairment on the fly to simulate degrading or improving diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index b667103..83678c0 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -370,6 +370,7 @@ for _ in 0..3 { | VPN split tunnel | Two interfaces on different routers + `set_default_route` | | WiFi to cellular | `iface.replug()` + `iface.set_condition()` | | Network goes down briefly | `iface.link_down()`, sleep, `iface.link_up()` | +| Cable unplugged, Wi-Fi drops | `iface.carrier_down()`, sleep, `iface.carrier_up()` | | Cone NAT | `Nat::Moderate` | | Symmetric NAT | `Nat::Strict` | | Double NAT / CGNAT | Chain routers: `home.upstream(cgnat.id())` | diff --git a/patchbay/src/event.rs b/patchbay/src/event.rs index 648d9ec..f201e41 100644 --- a/patchbay/src/event.rs +++ b/patchbay/src/event.rs @@ -183,6 +183,20 @@ pub enum LabEventKind { /// Interface name. iface: String, }, + /// Device interface regained carrier, like a cable plugged back in. + CarrierUp { + /// Device name. + device: String, + /// Interface name. + iface: String, + }, + /// Device interface lost carrier, like a pulled cable. + CarrierDown { + /// Device name. + device: String, + /// Interface name. + iface: String, + }, /// A new interface was added to a device. InterfaceAdded { /// Device name. @@ -628,8 +642,11 @@ impl LabState { r.downlink_condition = *condition; } } - LabEventKind::LinkUp { .. } | LabEventKind::LinkDown { .. } => { - // State doesn't track link up/down currently. + LabEventKind::LinkUp { .. } + | LabEventKind::LinkDown { .. } + | LabEventKind::CarrierUp { .. } + | LabEventKind::CarrierDown { .. } => { + // State doesn't track link or carrier state currently. } LabEventKind::InterfaceAdded { device, iface } => { if let Some(d) = self.devices.get_mut(device) { diff --git a/patchbay/src/iface.rs b/patchbay/src/iface.rs index 13a6503..557def4 100644 --- a/patchbay/src/iface.rs +++ b/patchbay/src/iface.rs @@ -443,6 +443,72 @@ impl Iface { Ok(()) } + // ── Mutate: carrier ── + + /// Removes carrier from this interface, like pulling its network cable. + /// + /// The interface stays administratively up and keeps its addresses and + /// routes, which the kernel flags as `linkdown`. Packets sent while the + /// carrier is down are dropped without an error to the sender. Unlike + /// [`link_down`](Self::link_down), nothing is lost, so + /// [`carrier_up`](Self::carrier_up) restores traffic as it was. + /// + /// This takes down the router-side end of the interface's veth pair. + /// Returns an error for dummy interfaces, which have no peer. + pub async fn carrier_down(&self) -> Result<()> { + self.set_carrier(false).await?; + self.lab.emit(LabEventKind::CarrierDown { + device: self.device_name(), + iface: self.ifname.to_string(), + }); + Ok(()) + } + + /// Restores carrier on this interface, like plugging its network cable + /// back in. + /// + /// Returns an error for dummy interfaces, which have no peer. + pub async fn carrier_up(&self) -> Result<()> { + self.set_carrier(true).await?; + self.lab.emit(LabEventKind::CarrierUp { + device: self.device_name(), + iface: self.ifname.to_string(), + }); + Ok(()) + } + + /// Sets the admin state of the router-side veth, which the kernel + /// reports as carrier on this interface. + async fn set_carrier(&self, up: bool) -> Result<()> { + use crate::{netlink::Netlink, wiring}; + + let (gw_ns, peer, op) = { + let inner = self.lab.core.lock().expect("poisoned"); + let dev = inner + .device(self.device) + .ok_or_else(|| anyhow!("device removed"))?; + let iface = dev + .iface(&self.ifname) + .ok_or_else(|| anyhow!("interface '{}' removed", self.ifname))?; + let Some((gw_ns, peer)) = inner.gateway_veth(iface)? else { + bail!( + "cannot change carrier on dummy interface '{}' (no router-side peer)", + self.ifname + ); + }; + (gw_ns, peer, Arc::clone(&dev.op)) + }; + let _guard = op.lock().await; + wiring::nl_run(&self.lab.netns, &gw_ns, move |nl: Netlink| async move { + if up { + nl.set_link_up(&peer).await + } else { + nl.set_link_down(&peer).await + } + }) + .await + } + // ── Mutate: addressing ── /// Adds a secondary IPv4 address to this interface. diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index 66b5808..985de02 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -1107,3 +1107,96 @@ async fn lab_bidirectional_via_two_calls() -> Result<()> { Ok(()) } + +// ── Carrier ────────────────────────────────────────────────────────── + +/// Runs `ip ` inside the device namespace and returns stdout. +fn ip_output(dev: &Device, args: &[&str]) -> Result { + let mut cmd = std::process::Command::new("ip"); + cmd.args(args); + cmd.stdout(std::process::Stdio::piped()); + let output = dev + .spawn_command_sync(cmd)? + .wait_with_output() + .context("wait for ip")?; + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Carrier down behaves like a pulled cable: the interface stays up with its +/// addresses and routes, traffic stops, and carrier up restores it without +/// re-adding anything. The ingress condition on the router-side veth +/// survives the flap. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn carrier_down_up() -> Result<()> { + check_caps()?; + let lab = Lab::new().await?; + let dc = lab + .add_router("dc") + .ip_support(IpSupport::DualStack) + .build() + .await?; + let dev = lab.add_device("dev").uplink(dc.id()).build().await?; + let eth0 = dev.iface("eth0").context("eth0")?; + let ip6 = eth0.ip6().context("eth0 has no v6 address")?; + eth0.set_condition(LinkCondition::new().latency_ms(40), LinkDirection::Ingress) + .await?; + + let r4 = SocketAddr::new(IpAddr::V4(dc.uplink_ip().context("dc v4")?), 16_720); + let r6 = SocketAddr::new(IpAddr::V6(dc.uplink_ip_v6().context("dc v6")?), 16_721); + let _r4 = dc.spawn_reflector(r4).await?; + let _r6 = dc.spawn_reflector(r6).await?; + for r in [r4, r6] { + dev.run_sync(move || test_utils::udp_roundtrip(r)) + .with_context(|| format!("{r} before carrier_down"))?; + } + + eth0.carrier_down().await?; + let link = ip_output(&dev, &["link", "show", "eth0"])?; + assert!(link.contains("NO-CARRIER"), "expected NO-CARRIER: {link}"); + assert!(link.contains(",UP"), "expected admin up: {link}"); + let addrs = ip_output(&dev, &["addr", "show", "eth0"])?; + assert!(addrs.contains(&ip6.to_string()), "v6 address lost: {addrs}"); + let routes = ip_output(&dev, &["-6", "route", "show", "default"])?; + assert!( + routes.contains("linkdown"), + "v6 default route lost: {routes}" + ); + for r in [r4, r6] { + let probe = + dev.run_sync(move || test_utils::probe_udp(r, Duration::from_millis(300), None)); + assert!(probe.is_err(), "{r} reachable while carrier is down"); + } + + eth0.carrier_up().await?; + tokio::time::sleep(Duration::from_millis(100)).await; + for r in [r4, r6] { + dev.run_sync(move || test_utils::udp_roundtrip(r)) + .with_context(|| format!("{r} after carrier_up"))?; + } + let rtt = dev.run_sync(move || test_utils::udp_rtt_sync(r4))?; + assert!( + rtt >= Duration::from_millis(40), + "ingress latency lost after carrier_up: {rtt:?}" + ); + Ok(()) +} + +/// Carrier changes need a router-side peer, so dummy interfaces reject them. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn carrier_down_dummy_fails() -> Result<()> { + check_caps()?; + let lab = Lab::new().await?; + let dc = lab.add_router("dc").build().await?; + let dev = lab + .add_device("dev") + .iface("eth0", dc.id()) + .iface("docker0", IfaceConfig::dummy()) + .build() + .await?; + let docker0 = dev.iface("docker0").context("docker0")?; + assert!(docker0.carrier_down().await.is_err()); + assert!(docker0.carrier_up().await.is_err()); + Ok(()) +} From 0fff9651a2885aaf21d2c6e6db6aa713ed76507a Mon Sep 17 00:00:00 2001 From: Frando Date: Mon, 28 Sep 2026 12:08:56 +0200 Subject: [PATCH 3/3] feat: add carrier-down and carrier-up sim steps Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/toml-reference.md | 13 +++++++++++ patchbay-runner/src/sim/mod.rs | 8 +++++++ patchbay-runner/src/sim/runner.rs | 2 ++ patchbay-runner/src/sim/steps.rs | 37 +++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/docs/reference/toml-reference.md b/docs/reference/toml-reference.md index 4a5719c..518c983 100644 --- a/docs/reference/toml-reference.md +++ b/docs/reference/toml-reference.md @@ -374,6 +374,19 @@ Brings a device interface up or down. --- +### `action = "carrier-down"` / `action = "carrier-up"` + +Removes or restores carrier on a device interface, like pulling and +replugging its cable. The interface stays up and keeps its addresses and +routes while traffic is dropped. + +| Key | Type | Description | +|-------------|--------|-------------| +| `device` | string | Target device. | +| `interface` | string | Interface name. | + +--- + ### `action = "set-default-route"` Switches the default route on a device to a given interface. Useful for diff --git a/patchbay-runner/src/sim/mod.rs b/patchbay-runner/src/sim/mod.rs index 5dcc10e..849320d 100644 --- a/patchbay-runner/src/sim/mod.rs +++ b/patchbay-runner/src/sim/mod.rs @@ -285,6 +285,14 @@ pub enum Step { device: String, interface: String, }, + CarrierDown { + device: String, + interface: String, + }, + CarrierUp { + device: String, + interface: String, + }, Assert { check: Option, #[serde(default)] diff --git a/patchbay-runner/src/sim/runner.rs b/patchbay-runner/src/sim/runner.rs index c43549e..05dacf6 100644 --- a/patchbay-runner/src/sim/runner.rs +++ b/patchbay-runner/src/sim/runner.rs @@ -1646,6 +1646,8 @@ fn set_step_device(step: &mut Step, device: String) { Step::SetDefaultRoute { device: d, .. } => *d = device, Step::LinkDown { device: d, .. } => *d = device, Step::LinkUp { device: d, .. } => *d = device, + Step::CarrierDown { device: d, .. } => *d = device, + Step::CarrierUp { device: d, .. } => *d = device, Step::GenCerts { device: d, .. } => *d = Some(device), Step::GenFile { device: d, .. } => *d = Some(device), _ => {} diff --git a/patchbay-runner/src/sim/steps.rs b/patchbay-runner/src/sim/steps.rs index fa9cac3..aafb9c5 100644 --- a/patchbay-runner/src/sim/steps.rs +++ b/patchbay-runner/src/sim/steps.rs @@ -28,6 +28,8 @@ pub(crate) fn step_action(step: &Step) -> &'static str { Step::SetDefaultRoute { .. } => "set-default-route", Step::LinkDown { .. } => "link-down", Step::LinkUp { .. } => "link-up", + Step::CarrierDown { .. } => "carrier-down", + Step::CarrierUp { .. } => "carrier-up", Step::Assert { .. } => "assert", Step::GenCerts { .. } => "gen-certs", Step::GenFile { .. } => "gen-file", @@ -53,6 +55,8 @@ pub(crate) fn step_device(step: &Step) -> Option<&str> { Step::SetDefaultRoute { device, .. } => Some(device), Step::LinkDown { device, .. } => Some(device), Step::LinkUp { device, .. } => Some(device), + Step::CarrierDown { device, .. } => Some(device), + Step::CarrierUp { device, .. } => Some(device), Step::GenCerts { device, .. } => device.as_deref(), Step::GenFile { device, .. } => device.as_deref(), _ => None, @@ -464,6 +468,28 @@ pub(crate) async fn execute_step(state: &mut SimState, step: &Step) -> Result<() .await?; } + // ── carrier-down / carrier-up ───────────────────────────────────── + Step::CarrierDown { device, interface } => { + state + .lab + .device_by_name(device) + .ok_or_else(|| anyhow::anyhow!("unknown device '{}'", device))? + .iface(interface) + .ok_or_else(|| anyhow::anyhow!("interface '{}' not found", interface))? + .carrier_down() + .await?; + } + Step::CarrierUp { device, interface } => { + state + .lab + .device_by_name(device) + .ok_or_else(|| anyhow::anyhow!("unknown device '{}'", device))? + .iface(interface) + .ok_or_else(|| anyhow::anyhow!("interface '{}' not found", interface))? + .carrier_up() + .await?; + } + // ── assert ──────────────────────────────────────────────────────── Step::Assert { check, checks } => { if let Some(expr) = check { @@ -1010,4 +1036,15 @@ mod tests { assert_eq!(parse_duration("3m").unwrap(), Duration::from_secs(180)); assert!(parse_duration("3h").is_err()); } + + #[test] + fn parse_carrier_steps() { + for (action, down) in [("carrier-down", true), ("carrier-up", false)] { + let raw = format!("action = \"{action}\"\ndevice = \"dev\"\ninterface = \"eth0\""); + let step: Step = toml::from_str(&raw).expect("parse step"); + assert_eq!(step_action(&step), action); + assert_eq!(step_device(&step), Some("dev")); + assert_eq!(matches!(step, Step::CarrierDown { .. }), down); + } + } }