Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/running-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/reference/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())` |
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/toml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions patchbay-runner/src/sim/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ pub enum Step {
device: String,
interface: String,
},
CarrierDown {
device: String,
interface: String,
},
CarrierUp {
device: String,
interface: String,
},
Assert {
check: Option<String>,
#[serde(default)]
Expand Down
2 changes: 2 additions & 0 deletions patchbay-runner/src/sim/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
_ => {}
Expand Down
37 changes: 37 additions & 0 deletions patchbay-runner/src/sim/steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
}
20 changes: 20 additions & 0 deletions patchbay/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<(Arc<str>, 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)
Expand Down
21 changes: 19 additions & 2 deletions patchbay/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
82 changes: 67 additions & 15 deletions patchbay/src/iface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading