diff --git a/patchbay/src/iface.rs b/patchbay/src/iface.rs index cf30b79..2f3eabd 100644 --- a/patchbay/src/iface.rs +++ b/patchbay/src/iface.rs @@ -375,15 +375,20 @@ impl Iface { /// Brings this interface administratively up. /// - /// If this is the device's default route interface (and it is routed), - /// the default route is re-added (Linux removes routes when a link - /// goes down). + /// Linux removes routes and the IPv6 link-local address when a link + /// goes down, so this re-adds the link-local and, if this is the + /// device's routed default route interface, the default routes. Routes + /// added outside patchbay are not restored. + /// + /// With [`Ipv6DadMode::Enabled`](crate::Ipv6DadMode::Enabled), IPv6 + /// addresses rerun duplicate address detection after this returns and + /// stay unusable until it completes, which takes about two seconds. pub async fn link_up(&self) -> Result<()> { use crate::{ device::select_default_v6_gateway, netlink::Netlink, wiring, Ipv6ProvisioningMode, }; - let (ns, uplink, is_default_via, dummy, op) = { + let (ns, uplink, is_default_via, dummy, ll_v6, op) = { let inner = self.lab.core.lock().expect("poisoned"); let dev = inner .device(self.device) @@ -396,6 +401,7 @@ impl Iface { iface.uplink(), *dev.default_via == *self.ifname, iface.is_dummy(), + iface.ll_v6, Arc::clone(&dev.op), ) }; @@ -404,7 +410,16 @@ impl Iface { let ifname = self.ifname.to_string(); wiring::nl_run(&self.lab.netns, &ns, { let ifname = ifname.clone(); - move |nl: Netlink| async move { nl.set_link_up(&ifname).await } + move |nl: Netlink| async move { + nl.set_link_up(&ifname).await?; + // Global v6 addresses survive link down via `keep_addr_on_down` + // (see `wiring::create_named_netns`), but the kernel always + // drops link-locals, so the seeded one is re-added here. + if let Some(ll6) = ll_v6 { + nl.add_addr6(&ifname, ll6, 64).await?; + } + Ok(()) + } }) .await?; diff --git a/patchbay/src/test_utils.rs b/patchbay/src/test_utils.rs index 9b7c98b..86a56fd 100644 --- a/patchbay/src/test_utils.rs +++ b/patchbay/src/test_utils.rs @@ -48,6 +48,27 @@ pub(crate) async fn run_reflector( Ok(()) } +/// Lists the IPv6 addresses assigned to `ifname` in the current namespace. +/// +/// Reads `/proc/net/if_inet6`, whose rows are the address as 32 hex digits +/// followed by index, prefix length, scope, flags, and the interface name. +pub(crate) fn iface_v6_addrs(ifname: &str) -> Result> { + let table = std::fs::read_to_string("/proc/net/if_inet6").context("read if_inet6")?; + let mut addrs = Vec::new(); + for line in table.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let [hex, .., name] = fields.as_slice() else { + continue; + }; + if *name != ifname { + continue; + } + let raw = u128::from_str_radix(hex, 16).with_context(|| format!("parse {hex}"))?; + addrs.push(Ipv6Addr::from(raw)); + } + Ok(addrs) +} + /// Sends a UDP probe to `reflector` and returns the observed external address. /// /// Assumes the calling thread is already in the target namespace. diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index 66b5808..caf6983 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -123,6 +123,51 @@ async fn link_down_up() -> Result<()> { Ok(()) } +/// Link down then up on a dual-stack device keeps its IPv6 addresses and +/// restores v6 connectivity. +/// +/// Linux flushes global IPv6 addresses when a link goes down, so without +/// `keep_addr_on_down` the v6 default route cannot be re-added on link up. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn link_down_up_dual_stack() -> 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")?; + let ll6 = eth0.ll6().context("eth0 has no v6 link-local")?; + + let dc_ip_v6 = dc.uplink_ip_v6().context("no dc v6 uplink ip")?; + let r = SocketAddr::new(IpAddr::V6(dc_ip_v6), 16_700); + let _r = dc.spawn_reflector(r).await?; + dev.run_sync(move || test_utils::udp_roundtrip(r)) + .context("before link_down")?; + + eth0.link_down().await?; + eth0.link_up().await?; + tokio::time::sleep(Duration::from_millis(100)).await; + + let addrs = dev.run_sync(|| test_utils::iface_v6_addrs("eth0"))?; + assert!( + addrs.contains(&ip6), + "global v6 {ip6} lost after link_up: {addrs:?}" + ); + assert!( + addrs.contains(&ll6), + "link-local {ll6} lost after link_up: {addrs:?}" + ); + + dev.run_sync(move || test_utils::udp_roundtrip(r)) + .context("after link_up")?; + Ok(()) +} + // ── Rate limiting ──────────────────────────────────────────────────── /// 2 Mbit/s upload cap via tc on device interface. diff --git a/patchbay/src/wiring.rs b/patchbay/src/wiring.rs index 1126091..66393f2 100644 --- a/patchbay/src/wiring.rs +++ b/patchbay/src/wiring.rs @@ -876,10 +876,13 @@ pub(crate) fn link_local_from_seed(seed: u64) -> Ipv6Addr { // Netns + process helpers // ───────────────────────────────────────────── -/// Creates a namespace with optional DNS overlay and applies IPv6 DAD mode. +/// Creates a namespace with optional DNS overlay and applies IPv6 sysctls. /// -/// When `dad_mode` is disabled, this sets `accept_dad=0` and -/// `dad_transmits=0` before interfaces are moved in. +/// Global IPv6 addresses are kept across link down/up (`keep_addr_on_down=1`) +/// so that `Iface::link_up` can re-add the v6 default route. Linux flushes +/// them by default, which leaves the gateway off-link. When `dad_mode` is +/// disabled, this also sets `accept_dad=0` and `dad_transmits=0`. All sysctls +/// are set before interfaces are moved in. pub(crate) fn create_named_netns( netns: &netns::NetnsManager, name: &str, @@ -888,17 +891,17 @@ pub(crate) fn create_named_netns( dad_mode: Ipv6DadMode, ) -> Result<()> { netns.create_netns(name, dns_overlay, log_prefix)?; - if dad_mode == Ipv6DadMode::Disabled { - // Disable DAD before any interfaces are created or moved in. - netns.run_closure_in(name, || { + netns.run_closure_in(name, move || { + set_sysctl_root("net/ipv6/conf/all/keep_addr_on_down", "1").ok(); + set_sysctl_root("net/ipv6/conf/default/keep_addr_on_down", "1").ok(); + if dad_mode == Ipv6DadMode::Disabled { set_sysctl_root("net/ipv6/conf/all/accept_dad", "0").ok(); set_sysctl_root("net/ipv6/conf/default/accept_dad", "0").ok(); set_sysctl_root("net/ipv6/conf/all/dad_transmits", "0").ok(); set_sysctl_root("net/ipv6/conf/default/dad_transmits", "0").ok(); - Ok(()) - })?; - } - Ok(()) + } + Ok(()) + }) } /// Sets a sysctl value in the current namespace (caller must already be in the ns).