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/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); + } + } } 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/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 cf30b79..557def4 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; @@ -457,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(()) +}