From 63cf9d2ec6736ab6085f7e25c797be4b75740a63 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:09:17 -0700 Subject: [PATCH 1/9] A NIC a net zone renamed is renamed nic before the next one takes it The net zone holds CAP_NET_ADMIN in its namespace and can give its NIC any name the kernel takes: a leading dash, a quote, an escape, bytes that are not UTF-8. When the zone ends the NIC comes back to zone 0 under that name, and the next start handed it on as it was. A name that is not UTF-8 failed every later start, since kryptikd looked up the lossy copy, and kept the machine offline until a reboot. Any other reached the fresh zone's script: a quote ended the name in the nft uplink set and could add kryptik0 to it, and a leading dash reached dhcpcd as an option. kryptikd now reads each physical interface's name as the kernel holds it, with its index, and renames one that is not plain, a lower-case letter then lower-case letters and digits, to the kernel's nic%d before it moves. netzone-init.sh names the device to ip with dev, so a plain name that is also an ip keyword is still read as a name. --- build/guest-tests/zones-check.sh | 25 ++++++++++++ compartments/kryptikd/src/netlink.rs | 21 ++++++++++ compartments/kryptikd/src/netlink/tests.rs | 26 ++++++++++++ compartments/kryptikd/src/netzone.rs | 47 +++++++++++++++++----- compartments/kryptikd/src/netzone/tests.rs | 12 ++++++ docs/design/net-zone.md | 14 +++++-- tools/image/zones-test.sh | 1 + tools/net/netzone-init.sh | 2 +- 8 files changed, 135 insertions(+), 13 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 9bea55ad..e5e22893 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -557,6 +557,31 @@ else fail "reattach-egress" "untrusted (init ${upid:-none}): gateway before ${out_before}; eth0 gone while down ${eth0_gone}; gateway while down ${out_down}; gateway after ${out_after}; $(tail -2 "$LOG/untrusted-bg.out" | tr '\n' ' ')" fi "$KD" stop untrusted >/dev/null 2>&1; wait "$UBG" 2>/dev/null +# A net zone can rename its NIC, which comes back to zone 0 under that name: +# a leading dash would reach the next zone's dhcpcd as an option, and a quote +# would end the name in its nft set. The next start renames it nic first. +ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" +named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" +rn_before="$(ready_count)" +s6-svc -d /run/service/net-zone +back="" +for _ in $(seq 1 20); do back="$(physical)"; [[ -n "$back" ]] && break; sleep 0.5; done +s6-svc -u /run/service/net-zone +took="" +for _ in $(seq 1 60); do [[ "$(ready_count)" -gt "$rn_before" ]] && { took="$(last_ready)"; break; }; sleep 1; done +plain_uplink='uplinks=nic[0-9]+( |$)' +if [[ "$named" == *NAMED* && "$back" == '-x"y ' && "$took" =~ $plain_uplink && -z "$(physical)" ]]; then + pass "uplink-renamed-plain" "the net zone named its NIC -x\"y, it came back to zone 0 so named, and the next start took it as ${took##*uplinks=}" +else + fail "uplink-renamed-plain" "renamed: ${named:-nothing}; back in zone 0: ${back:-nothing}; READY: ${took:-none}; $(netzone_said 'NOT READY' | tail -1); zone 0 holds: $(physical)" +fi +# eth0 again, for what follows: the NIC is back in zone 0, down, while the net zone is. +s6-svc -d /run/service/net-zone +for _ in $(seq 1 20); do [[ -n "$(physical)" ]] && break; sleep 0.5; done +for d in /sys/class/net/*; do [[ -e "$d/device" ]] && ip link set dev "${d##*/}" name eth0; done +rn_before="$(ready_count)" +s6-svc -u /run/service/net-zone +for _ in $(seq 1 60); do [[ "$(ready_count)" -gt "$rn_before" ]] && break; sleep 1; done # --- zones: the net zone over a radio ----------------------------------------------- # QEMU has no radio, so mac80211_hwsim makes two. phy1 goes into a network diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 1b068136..8ddbb060 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -247,6 +247,27 @@ fn check_name(name: &str) -> io::Result<()> { Ok(()) } +/// Rename the interface at `idx`, which must be down, and return its new name: a `%d` in `name` +/// takes the first number free in this namespace. +pub fn rename_index(idx: u32, name: &str) -> io::Result { + check_name(name)?; + let mut m = Msg::new(RTM_NEWLINK, 0, 1); + m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); + m.attr_str(IFLA_IFNAME, name); + transact(m.finish(), &format!("rename interface {idx} to {name:?}"))?; + name_of(idx) +} + +/// The name of the interface at `idx` in this namespace. +pub fn name_of(idx: u32) -> io::Result { + let mut buf = [0 as libc::c_char; libc::IF_NAMESIZE]; + // SAFETY: if_indextoname writes at most IF_NAMESIZE bytes, its NUL included. + if unsafe { libc::if_indextoname(idx, buf.as_mut_ptr()) }.is_null() { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy().into_owned()) +} + /// Create a veth pair `a` <-> `b`, `b` born in namespace `peer_ns` and with MAC `peer_mac` when given. pub fn create_veth(a: &str, b: &str, peer_ns: Option, peer_mac: Option<[u8; 6]>) -> io::Result<()> { check_name(a)?; diff --git a/compartments/kryptikd/src/netlink/tests.rs b/compartments/kryptikd/src/netlink/tests.rs index 6352bd6d..6697b5c8 100644 --- a/compartments/kryptikd/src/netlink/tests.rs +++ b/compartments/kryptikd/src/netlink/tests.rs @@ -424,3 +424,29 @@ fn isolated_ports_block_zone_to_zone() { other => panic!("bridge isolation test failed at step {other}"), } } + +/// A name a net zone could leave on a NIC is replaced by index, the kernel numbering `nic%d`. +#[test] +fn rename_by_index_takes_a_template() { + let rc = in_userns_netns(|| { + let r: Result<(), i32> = (|| { + step(1, create_veth("x\"y", "pb", None, None))?; + let idx = index_of("x\"y").map_err(|_| 2)?; + let now = rename_index(idx, "nic%d").map_err(|e| { + eprintln!("rename_index: {e}"); + 3 + })?; + if now != "nic0" || index_of("x\"y").is_ok() || index_of("nic0").ok() != Some(idx) { + eprintln!("renamed {now:?}"); + return Err(4); + } + Ok(()) + })(); + r.err().unwrap_or(0) + }); + match rc { + 0 => {} + 77 => eprintln!("no unprivileged user namespace; skipping"), + other => panic!("rename test failed at step {other}"), + } +} diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3b5a50fe..8f2dcb27 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -13,8 +13,9 @@ //! Runs in the root parent while the zone waits at its handshake; an unprivileged launch gets //! loopback only. NAT, forwarding policy and the resolver belong to tools/net/netzone-init.sh. -use std::ffi::CStr; +use std::ffi::{CStr, OsString}; use std::io; +use std::os::unix::ffi::OsStrExt; use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; use std::path::Path; @@ -221,22 +222,50 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { } /// Interfaces on a bus device (`/sys/class/net//device`): what `[network] nic = "*"` moves. +/// +/// A NIC comes back to zone 0 under whatever name the net zone that held it gave it, and the +/// next zone's script, nft set and dhcpcd would take that name as it is: one that is not plain +/// becomes `nic` first. It came back down, as a rename needs. pub fn physical_interfaces() -> io::Result> { - physical_interfaces_under(Path::new("/sys/class/net")) + let mut out = Vec::new(); + for (name, idx) in physical_interfaces_under(Path::new("/sys/class/net"))? { + if plain_name(name.as_bytes()) { + out.push(name.to_string_lossy().into_owned()); + continue; + } + match netlink::rename_index(idx, "nic%d") { + Ok(now) => { + eprintln!("kryptikd: interface {name:?} renamed {now:?} before it moves"); + out.push(now); + } + Err(e) => eprintln!("kryptikd: interface {name:?} left in zone 0: {e}"), + } + } + out.sort(); + Ok(out) } -/// `physical_interfaces` over any sysfs `class/net` directory, for tests. -fn physical_interfaces_under(class_net: &Path) -> io::Result> { +/// A name as the kernel and eudev give one: a lower-case letter, then lower-case letters and +/// digits, so no option, quote, glob or control byte. +fn plain_name(name: &[u8]) -> bool { + name.len() < 16 + && name.first().is_some_and(|b| b.is_ascii_lowercase()) + && name.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +/// Each physical interface's name, as the kernel holds it, and index, under any sysfs +/// `class/net` directory, for tests. +fn physical_interfaces_under(class_net: &Path) -> io::Result> { let mut out = Vec::new(); for e in std::fs::read_dir(class_net)? { let e = e?; - let name = e.file_name().to_string_lossy().into_owned(); - if name == "lo" { + let name = e.file_name(); + if name == "lo" || !e.path().join("device").exists() { continue; } - if e.path().join("device").exists() { - out.push(name); - } + let idx = std::fs::read_to_string(e.path().join("ifindex"))?.trim().parse::(); + let idx = idx.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "a sysfs ifindex is not a number"))?; + out.push((name, idx)); } out.sort(); Ok(out) diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index cad74459..d8d455ca 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -340,3 +340,15 @@ fn replumb_empty_directory_attempts_nothing() { assert!(replumb_routed_zones(&dir, -1).is_empty()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn only_plain_names_pass_on() { + for ok in ["eth0", "wlan0", "enp0s31f6", "wlp2s0", "enx001122334455"] { + assert!(plain_name(ok.as_bytes()), "{ok}"); + } + // What a net zone could leave behind: an option, a quote that ends nft's string, an escape. + for bad in ["-k", "x\"y", "x\",\"kryptik0", "e\u{1b}th0", "Eth0", "eth0.1", "0eth", "", "abcdefghijklmnop"] { + assert!(!plain_name(bad.as_bytes()), "{bad:?}"); + } + assert!(!plain_name(b"\xff")); +} diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 31d370c5..07bfa882 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -289,9 +289,13 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. started, so one started before any net zone resolves names once attached; kryptikd never edits a running zone's sealed root. - The kernel returns the physical interface to the initial namespace, down - and unaddressed, under its own name (`dev` only if zone 0 has an - interface by that name by then); kryptikd leaves it so until the next net - zone start, which takes it again whatever its name. + and unaddressed, under the name it had there (`dev` only if zone 0 has + an interface by that name by then); kryptikd leaves it so until the next + net zone start, which takes it again. That name was the net zone's to + choose, anything but `/`, `:` and whitespace, so the start first renames + one that is not plain (a lower-case letter, then lower-case letters and + digits) `nic`: no option, quote or escape a past net zone chose reaches + the next one's script, nft set or dhcpcd. ## What this guarantees @@ -332,6 +336,10 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. (`netlink::tests::wireless_moves_by_wiphy`). - `wifi.rs` unit tests and the serve and cli suites cover the credentials file and `kryptik wifi`. +- The zones suite has the net zone rename its NIC `-x"y` and restarts it: the + NIC is back in zone 0 under that name, and the next start comes up `READY` + with it as `nic`. `netlink::tests::rename_by_index_takes_a_template` + renames a veth by its index to `nic%d`. - `tools/tests/netzone-uplink.sh`: the zones a definition lets through, by the address kryptikd derives for each, every host's addresses pinned to its own MAC and the MAC the same as `netlink::zone_mac`, the gateway sets as nft is diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index fac4bce3..d177a09a 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -66,6 +66,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' # The key verdicts one by one, so a pass is not a single line. for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ + uplink-renamed-plain \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 4bca9055..a25f867d 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -230,7 +230,7 @@ wifi_last="$(wifi_state)" # --- uplink: DHCP if anyone answers, else what zone 0 carried over --------- uplink_addr() { # the first IPv4 address any uplink holds for n in "$@"; do - a="$(ip -4 -o addr show "$n" 2>/dev/null | awk '{print $4}' | head -1)" + a="$(ip -4 -o addr show dev "$n" 2>/dev/null | awk '{print $4}' | head -1)" [ -n "$a" ] && { echo "$a"; return; } done } From 099a7a64002f4a9259dc9a217649e87a9d8dd3be Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:13:31 -0700 Subject: [PATCH 2/9] The bridge's own name is not plain: a NIC by it would stop the next start making kryptik0 --- compartments/kryptikd/src/netzone.rs | 4 +++- compartments/kryptikd/src/netzone/tests.rs | 5 +++-- docs/design/net-zone.md | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 8f2dcb27..0e967657 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -246,9 +246,11 @@ pub fn physical_interfaces() -> io::Result> { } /// A name as the kernel and eudev give one: a lower-case letter, then lower-case letters and -/// digits, so no option, quote, glob or control byte. +/// digits, so no option, quote, glob or control byte. Not `BRIDGE`, which the start makes in +/// the zone after the NICs move: a NIC by that name would fail it. fn plain_name(name: &[u8]) -> bool { name.len() < 16 + && name != BRIDGE.as_bytes() && name.first().is_some_and(|b| b.is_ascii_lowercase()) && name.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) } diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index d8d455ca..05da60ae 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -346,8 +346,9 @@ fn only_plain_names_pass_on() { for ok in ["eth0", "wlan0", "enp0s31f6", "wlp2s0", "enx001122334455"] { assert!(plain_name(ok.as_bytes()), "{ok}"); } - // What a net zone could leave behind: an option, a quote that ends nft's string, an escape. - for bad in ["-k", "x\"y", "x\",\"kryptik0", "e\u{1b}th0", "Eth0", "eth0.1", "0eth", "", "abcdefghijklmnop"] { + // What a net zone could leave behind: an option, a quote that ends nft's string, an escape, + // the bridge's own name. + for bad in ["-k", "x\"y", "x\",\"kryptik0", "e\u{1b}th0", "kryptik0", "Eth0", "eth0.1", "0eth", "", "abcdefghijklmnop"] { assert!(!plain_name(bad.as_bytes()), "{bad:?}"); } assert!(!plain_name(b"\xff")); diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 07bfa882..cfccbd62 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -294,8 +294,9 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. net zone start, which takes it again. That name was the net zone's to choose, anything but `/`, `:` and whitespace, so the start first renames one that is not plain (a lower-case letter, then lower-case letters and - digits) `nic`: no option, quote or escape a past net zone chose reaches - the next one's script, nft set or dhcpcd. + digits, and not `kryptik0`, the bridge it makes there) `nic`: no option, + quote or escape a past net zone chose reaches the next one's script, nft set + or dhcpcd, and no name of its own stops it. ## What this guarantees From a0c093ae79301280986d74851486949d5b7562bd Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:22:16 -0700 Subject: [PATCH 3/9] A radio leaves zone 0 with one station, whatever netdevs the last net zone left on it The net zone can add netdevs to its radio, an AP, mesh or monitor one, or delete the one it has, and cfg80211 returns the wiphy to zone 0 with whatever is on it. A second netdev failed every later start: the first carry moved the wiphy with both, and the second found no such interface. A radio with none was never carried again, so Wi-Fi was gone until a reboot. Before it lists what to carry, kryptikd now gives a radio with no netdev a station named nic (nl80211 NEW_INTERFACE), deletes all but the first netdev of a radio with several (DEL_INTERFACE), and lists each radio once. The zones suite leaves the net zone's radio with a monitor netdev beside the station, then with none, restarts the zone each time, and sees the next one get the radio with one station and join the access point again. --- build/guest-tests/zones-check.sh | 29 +++++++++++++ compartments/kryptikd/src/netlink.rs | 28 +++++++++++++ compartments/kryptikd/src/netzone.rs | 48 ++++++++++++++++++---- compartments/kryptikd/src/netzone/tests.rs | 16 ++++++++ docs/design/net-zone.md | 12 +++++- tools/image/zones-test.sh | 1 + 6 files changed, 126 insertions(+), 8 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index e5e22893..a04867cd 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -684,6 +684,35 @@ if [[ "$ZOUT" == *AP-REACHED* && "${sta_seen:-0}" -ge 1 ]]; then else fail "wifi-egress" "$ZOUT; stations at the access point: ${sta_seen:-0}; $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" fi +# A net zone can leave its radio with a second netdev or with none, and the +# radio comes back to zone 0 so. Each round makes that change in the net +# zone, stops it, waits for phy0 in zone 0 and starts it: the next zone must +# get the radio with one station and join the access point again. +radio_rounds="" +radio_netdevs() { nsenter -t "${1:-0}" -n iw dev 2>/dev/null | grep -c '^[[:space:]]*Interface '; } +for round in extra none; do + ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" + if [[ "$round" = extra ]]; then + nsenter -t "${ninit:-0}" -n iw phy phy0 interface add zmon0 type monitor > "$LOG/radio-$round.out" 2>&1 + else + for w in $(nsenter -t "${ninit:-0}" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2 }'); do + nsenter -t "${ninit:-0}" -n iw dev "$w" del >> "$LOG/radio-$round.out" 2>&1 + done + fi + left="$(radio_netdevs "$ninit")" + rb="$(ready_count)" + s6-svc -d /run/service/net-zone + for _ in $(seq 1 30); do [[ -e /sys/class/ieee80211/phy0 ]] && break; sleep 0.5; done + s6-svc -u /run/service/net-zone + line="$(ready_after "$rb" " wifi=$AP_SSID " 90)" + joined="did not join"; [[ "$line" == *" wifi=$AP_SSID "* ]] && joined=joined + radio_rounds="${radio_rounds}${round}: left ${left}, back with $(radio_netdevs "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)"), ${joined}; " +done +if [[ "$radio_rounds" == "extra: left 2, back with 1, joined; none: left 0, back with 1, joined; " ]]; then + pass "radio-recarried" "netdevs on the radio as the net zone left it and as the next one got it: ${radio_rounds}" +else + fail "radio-recarried" "${radio_rounds}$(uncaught | grep -a 'kryptikd: \(wiphy\|interface\)' | tail -3 | tr '\n' ' ') $(tr '\n' ' ' < "$LOG/radio-extra.out")" +fi # personal's definition does not open the radio's own network: once the net # zone has taken the access point as a gateway, its address is refused # personal, and what lies past it is not. diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 8ddbb060..8985b168 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -14,8 +14,14 @@ const GENL_ID_CTRL: u16 = 0x10; const CTRL_CMD_GETFAMILY: u8 = 3; const CTRL_ATTR_FAMILY_ID: u16 = 1; const CTRL_ATTR_FAMILY_NAME: u16 = 2; +const NL80211_CMD_NEW_INTERFACE: u8 = 7; +const NL80211_CMD_DEL_INTERFACE: u8 = 8; const NL80211_CMD_SET_WIPHY_NETNS: u8 = 49; const NL80211_ATTR_WIPHY: u16 = 1; +const NL80211_ATTR_IFINDEX: u16 = 3; +const NL80211_ATTR_IFNAME: u16 = 4; +const NL80211_ATTR_IFTYPE: u16 = 5; +const NL80211_IFTYPE_STATION: u32 = 2; const NL80211_ATTR_NETNS_FD: u16 = 219; const NLA_TYPE_MASK: u16 = 0x3fff; @@ -398,6 +404,28 @@ pub fn set_wiphy_netns(phy: u32, ns_fd: RawFd) -> io::Result<()> { transact_on(NETLINK_GENERIC, m.finish(), &format!("move wiphy {phy} into namespace")).map(|_| ()) } +/// `iw phy interface add type managed`: a station netdev on a wiphy, `%d` in +/// `name` taking the first free number. +pub fn new_station(phy: u32, name: &str) -> io::Result<()> { + check_name(name)?; + let family = genl_family_id("nl80211")?; + let mut m = Msg::new(family, 0, 1); + m.genlmsghdr(NL80211_CMD_NEW_INTERFACE, 0); + m.attr_u32(NL80211_ATTR_WIPHY, phy); + m.attr_str(NL80211_ATTR_IFNAME, name); + m.attr_u32(NL80211_ATTR_IFTYPE, NL80211_IFTYPE_STATION); + transact_on(NETLINK_GENERIC, m.finish(), &format!("add a station to wiphy {phy}")).map(|_| ()) +} + +/// `iw dev del`: remove a radio's netdev, by index. +pub fn del_interface(idx: u32) -> io::Result<()> { + let family = genl_family_id("nl80211")?; + let mut m = Msg::new(family, 0, 1); + m.genlmsghdr(NL80211_CMD_DEL_INTERFACE, 0); + m.attr_u32(NL80211_ATTR_IFINDEX, idx); + transact_on(NETLINK_GENERIC, m.finish(), &format!("delete wireless interface {idx}")).map(|_| ()) +} + pub fn add_addr4(dev: &str, addr: [u8; 4], prefix: u8) -> io::Result<()> { let idx = index_of(dev)?; let mut m = Msg::new(RTM_NEWADDR, NLM_F_CREATE | NLM_F_EXCL, 1); diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 0e967657..8baff1ea 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -226,9 +226,28 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { /// A NIC comes back to zone 0 under whatever name the net zone that held it gave it, and the /// next zone's script, nft set and dhcpcd would take that name as it is: one that is not plain /// becomes `nic` first. It came back down, as a rename needs. +/// +/// A radio comes back with whatever netdevs that zone left on it, and leaves with one, as a +/// boot gives it: one left with none gets a station, or nothing would carry it, and one left +/// with several keeps the first, since moving its wiphy would take an AP, mesh or monitor +/// netdev along, and a second station can knock the first off its network. pub fn physical_interfaces() -> io::Result> { + for phy in bare_radios(Path::new("/sys/class")) { + match netlink::new_station(phy, "nic%d") { + Ok(()) => eprintln!("kryptikd: wiphy {phy} came back with no interface; it has a station now"), + Err(e) => eprintln!("kryptikd: wiphy {phy} came back with no interface, and none could be added: {e}"), + } + } let mut out = Vec::new(); - for (name, idx) in physical_interfaces_under(Path::new("/sys/class/net"))? { + let mut radios = std::collections::BTreeSet::new(); + for (name, idx, phy) in physical_interfaces_under(Path::new("/sys/class/net"))? { + if phy.is_some_and(|p| !radios.insert(p)) { + match netlink::del_interface(idx) { + Ok(()) => eprintln!("kryptikd: interface {name:?} deleted: its radio leaves with one"), + Err(e) => eprintln!("kryptikd: interface {name:?} not deleted, so it goes with its radio: {e}"), + } + continue; + } if plain_name(name.as_bytes()) { out.push(name.to_string_lossy().into_owned()); continue; @@ -255,9 +274,9 @@ fn plain_name(name: &[u8]) -> bool { && name.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) } -/// Each physical interface's name, as the kernel holds it, and index, under any sysfs -/// `class/net` directory, for tests. -fn physical_interfaces_under(class_net: &Path) -> io::Result> { +/// Each physical interface's name, as the kernel holds it, its index, and its wiphy's if it +/// is a radio's, under any sysfs `class/net` directory, for tests. +fn physical_interfaces_under(class_net: &Path) -> io::Result)>> { let mut out = Vec::new(); for e in std::fs::read_dir(class_net)? { let e = e?; @@ -265,14 +284,29 @@ fn physical_interfaces_under(class_net: &Path) -> io::Result(); - let idx = idx.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "a sysfs ifindex is not a number"))?; - out.push((name, idx)); + let idx = sysfs_index(&e.path().join("ifindex")) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "a sysfs ifindex is not a number"))?; + out.push((name, idx, sysfs_index(&e.path().join("phy80211/index")))); } out.sort(); Ok(out) } +/// The wiphys under a sysfs `class` directory that no netdev sits on, by index. +fn bare_radios(class: &Path) -> Vec { + let entries = |d: &str| std::fs::read_dir(class.join(d)).into_iter().flatten().flatten(); + let held: std::collections::BTreeSet = + entries("net").filter_map(|e| sysfs_index(&e.path().join("phy80211/index"))).collect(); + let mut bare: Vec = + entries("ieee80211").filter_map(|e| sysfs_index(&e.path().join("index"))).filter(|p| !held.contains(p)).collect(); + bare.sort(); + bare +} + +fn sysfs_index(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() +} + /// Plumb the nic zone (uplinks and bridge), then reattach running routed zones. pub fn plumb_nic_zone(zone: &Zone, zone_ns: i32, zones_dir: &Path) -> Result<(), NetError> { plumb_nic_zone_bridge(zone, zone_ns)?; diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index 05da60ae..b85ba3fe 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -353,3 +353,19 @@ fn only_plain_names_pass_on() { } assert!(!plain_name(b"\xff")); } + +#[test] +fn bare_radios_are_the_ones_no_netdev_holds() { + let dir = std::env::temp_dir().join(format!("kryptik-radios-{}", std::process::id())); + let put = |path: &str, value: &str| { + let f = dir.join(path); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(f, value).unwrap(); + }; + put("ieee80211/phy0/index", "0\n"); + put("ieee80211/phy3/index", "3\n"); + put("net/wlan0/phy80211/index", "0\n"); + put("net/eth0/ifindex", "2\n"); + assert_eq!(bare_radios(&dir), vec![3]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index cfccbd62..d3bce468 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -249,6 +249,13 @@ configured the zone keeps its IPv4 path and the launcher says so. `/sys/class/net//phy80211` exists. Every interface on the wiphy moves with its name, and cfg80211 returns the wiphy to the initial namespace when the net zone's namespace dies. +- **It leaves zone 0 as a boot gives it, with one station.** The net zone + can add netdevs to its radio, an AP, mesh or monitor one, or delete the + one it has, and the radio comes back so. The next start deletes all but the + first (`NL80211_CMD_DEL_INTERFACE`) and gives a radio with none a station + named `nic` (`NL80211_CMD_NEW_INTERFACE`), then carries each radio once. + Otherwise a second netdev failed the start once its radio had moved with + the first, and a radio with none stayed in zone 0 until a reboot. - **It associates before it leases.** The script finds radios by their `phy80211` link, not by name, and runs one `wpa_supplicant` per radio on `/etc/wpa_supplicant.conf`, restarting one that dies; dhcpcd takes the @@ -340,7 +347,10 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. - The zones suite has the net zone rename its NIC `-x"y` and restarts it: the NIC is back in zone 0 under that name, and the next start comes up `READY` with it as `nic`. `netlink::tests::rename_by_index_takes_a_template` - renames a veth by its index to `nic%d`. + renames a veth by its index to `nic%d`. Over the simulated radio it leaves + the net zone's radio with a monitor netdev beside the station, then with no + netdev at all, and each time the next start carries it with one station + that joins the access point again. - `tools/tests/netzone-uplink.sh`: the zones a definition lets through, by the address kryptikd derives for each, every host's addresses pinned to its own MAC and the MAC the same as `netlink::zone_mac`, the gateway sets as nft is diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index d177a09a..c89d7a01 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -67,6 +67,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ uplink-renamed-plain \ + radio-recarried \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ From 0a2d26153d6699a8c804ac83ba7b4766a9f03002 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:26:10 -0700 Subject: [PATCH 4/9] A radio with several netdevs loses them all and gets a fresh station, since which to keep was the last zone's choice --- build/guest-tests/zones-check.sh | 10 ++++---- compartments/kryptikd/src/netzone.rs | 34 +++++++++++++++++++--------- docs/design/net-zone.md | 20 +++++++++------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index a04867cd..3efa724b 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -687,13 +687,14 @@ fi # A net zone can leave its radio with a second netdev or with none, and the # radio comes back to zone 0 so. Each round makes that change in the net # zone, stops it, waits for phy0 in zone 0 and starts it: the next zone must -# get the radio with one station and join the access point again. +# get the radio with one fresh station, not one the last zone named (amon0 +# sorts before the station), and join the access point again. radio_rounds="" -radio_netdevs() { nsenter -t "${1:-0}" -n iw dev 2>/dev/null | grep -c '^[[:space:]]*Interface '; } +radio_netdevs() { nsenter -t "${1:-0}" -n iw dev 2>/dev/null | awk '$1 == "Interface" { n++; s = s " " $2 } END { printf "%d%s", n, s }'; } for round in extra none; do ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" if [[ "$round" = extra ]]; then - nsenter -t "${ninit:-0}" -n iw phy phy0 interface add zmon0 type monitor > "$LOG/radio-$round.out" 2>&1 + nsenter -t "${ninit:-0}" -n iw phy phy0 interface add amon0 type monitor > "$LOG/radio-$round.out" 2>&1 else for w in $(nsenter -t "${ninit:-0}" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2 }'); do nsenter -t "${ninit:-0}" -n iw dev "$w" del >> "$LOG/radio-$round.out" 2>&1 @@ -708,7 +709,8 @@ for round in extra none; do joined="did not join"; [[ "$line" == *" wifi=$AP_SSID "* ]] && joined=joined radio_rounds="${radio_rounds}${round}: left ${left}, back with $(radio_netdevs "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)"), ${joined}; " done -if [[ "$radio_rounds" == "extra: left 2, back with 1, joined; none: left 0, back with 1, joined; " ]]; then +fresh_radio='^extra: left 2 [^,]*, back with 1 nic[0-9]+, joined; none: left 0, back with 1 nic[0-9]+, joined; $' +if [[ "$radio_rounds" =~ $fresh_radio ]]; then pass "radio-recarried" "netdevs on the radio as the net zone left it and as the next one got it: ${radio_rounds}" else fail "radio-recarried" "${radio_rounds}$(uncaught | grep -a 'kryptikd: \(wiphy\|interface\)' | tail -3 | tr '\n' ' ') $(tr '\n' ' ' < "$LOG/radio-extra.out")" diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 8baff1ea..3191d771 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -227,25 +227,37 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { /// next zone's script, nft set and dhcpcd would take that name as it is: one that is not plain /// becomes `nic` first. It came back down, as a rename needs. /// -/// A radio comes back with whatever netdevs that zone left on it, and leaves with one, as a -/// boot gives it: one left with none gets a station, or nothing would carry it, and one left -/// with several keeps the first, since moving its wiphy would take an AP, mesh or monitor -/// netdev along, and a second station can knock the first off its network. +/// A radio comes back with whatever netdevs that zone left on it, and leaves with one station, +/// as a boot gives it. One with several loses them all, since which to keep would be that +/// zone's choice, and moving the wiphy would take an AP, mesh or monitor netdev along; one with +/// none, then or before, gets a fresh station, which takes the radio's own address. pub fn physical_interfaces() -> io::Result> { + let class_net = Path::new("/sys/class/net"); + let listed = physical_interfaces_under(class_net)?; + let mut per_radio = std::collections::BTreeMap::new(); + for (_, _, phy) in &listed { + if let Some(p) = phy { + *per_radio.entry(*p).or_insert(0) += 1; + } + } + for (name, idx, phy) in &listed { + if phy.is_some_and(|p| per_radio[&p] > 1) { + if let Err(e) = netlink::del_interface(*idx) { + eprintln!("kryptikd: interface {name:?} on a radio with several could not be deleted: {e}"); + } + } + } for phy in bare_radios(Path::new("/sys/class")) { match netlink::new_station(phy, "nic%d") { - Ok(()) => eprintln!("kryptikd: wiphy {phy} came back with no interface; it has a station now"), - Err(e) => eprintln!("kryptikd: wiphy {phy} came back with no interface, and none could be added: {e}"), + Ok(()) => eprintln!("kryptikd: wiphy {phy} came back with no interface or several; it has one station now"), + Err(e) => eprintln!("kryptikd: wiphy {phy} has no interface, and none could be added: {e}"), } } let mut out = Vec::new(); let mut radios = std::collections::BTreeSet::new(); - for (name, idx, phy) in physical_interfaces_under(Path::new("/sys/class/net"))? { + for (name, idx, phy) in physical_interfaces_under(class_net)? { + // A netdev that could not be deleted goes along with its radio's first. if phy.is_some_and(|p| !radios.insert(p)) { - match netlink::del_interface(idx) { - Ok(()) => eprintln!("kryptikd: interface {name:?} deleted: its radio leaves with one"), - Err(e) => eprintln!("kryptikd: interface {name:?} not deleted, so it goes with its radio: {e}"), - } continue; } if plain_name(name.as_bytes()) { diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index d3bce468..682e64ff 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -251,11 +251,15 @@ configured the zone keeps its IPv4 path and the launcher says so. the net zone's namespace dies. - **It leaves zone 0 as a boot gives it, with one station.** The net zone can add netdevs to its radio, an AP, mesh or monitor one, or delete the - one it has, and the radio comes back so. The next start deletes all but the - first (`NL80211_CMD_DEL_INTERFACE`) and gives a radio with none a station - named `nic` (`NL80211_CMD_NEW_INTERFACE`), then carries each radio once. - Otherwise a second netdev failed the start once its radio had moved with - the first, and a radio with none stayed in zone 0 until a reboot. + one it has, and the radio comes back so. The next start deletes every + netdev of a radio with several (`NL80211_CMD_DEL_INTERFACE`), since which + to keep would be that zone's choice, gives a radio with none a fresh + station named `nic` (`NL80211_CMD_NEW_INTERFACE`), which takes the + radio's own address, and carries each radio once. Otherwise a second + netdev failed the start once its radio had moved with the first, and a + radio with none stayed in zone 0 until a reboot. A driver that cannot add + an interface (some FullMAC ones) leaves such a radio in zone 0, and the + log says why. - **It associates before it leases.** The script finds radios by their `phy80211` link, not by name, and runs one `wpa_supplicant` per radio on `/etc/wpa_supplicant.conf`, restarting one that dies; dhcpcd takes the @@ -348,9 +352,9 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. NIC is back in zone 0 under that name, and the next start comes up `READY` with it as `nic`. `netlink::tests::rename_by_index_takes_a_template` renames a veth by its index to `nic%d`. Over the simulated radio it leaves - the net zone's radio with a monitor netdev beside the station, then with no - netdev at all, and each time the next start carries it with one station - that joins the access point again. + the net zone's radio with a monitor netdev named to sort before the + station, then with no netdev at all, and each time the next start carries + it with one fresh `nic` station that joins the access point again. - `tools/tests/netzone-uplink.sh`: the zones a definition lets through, by the address kryptikd derives for each, every host's addresses pinned to its own MAC and the MAC the same as `netlink::zone_mac`, the gateway sets as nft is From ab1e852072145f169b4cbe86c1a26fb1dd40036a Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:30:41 -0700 Subject: [PATCH 5/9] A NIC goes to the net zone with its own address, Wake-on-LAN off and an MTU of 1500 What the last net zone set on its NIC outlived it: the address it chose, which would follow the machine from network to network after the restart meant to evict that zone; Wake-on-LAN, which lets anyone on the network wake the machine; and the MTU. Before a NIC is carried, kryptikd now sets its address back to the one the hardware came with (ETHTOOL_GPERMADDR), its MTU to 1500, and Wake-on-LAN off (ETHTOOL_SWOL), for the NICs "*" finds and for a NIC named in the zone file. The zones suite's rename round also gives the NIC another address and an MTU of 1400 inside the net zone, sees both come back to zone 0, and sees the next net zone get the NIC with its own address and 1500. --- build/guest-tests/zones-check.sh | 14 ++++- compartments/kryptikd/src/netlink.rs | 83 ++++++++++++++++++++++++++++ compartments/kryptikd/src/netzone.rs | 34 ++++++++++-- docs/design/net-zone.md | 11 +++- tools/image/zones-test.sh | 2 +- 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 3efa724b..8d545854 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -560,12 +560,17 @@ fi # A net zone can rename its NIC, which comes back to zone 0 under that name: # a leading dash would reach the next zone's dhcpcd as an option, and a quote # would end the name in its nft set. The next start renames it nic first. +# Its address and MTU come back as that zone set them too, and the next start +# puts back the NIC's own. +link_of() { sed -n 's/.* mtu \([0-9]*\) .*link\/ether \([0-9a-f:]*\) .*/\2 \1/p'; } ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" +own_link="$(nsenter -t "${ninit:-0}" -n ip -o link show dev eth0 2>/dev/null | link_of)" +named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" rn_before="$(ready_count)" s6-svc -d /run/service/net-zone back="" for _ in $(seq 1 20); do back="$(physical)"; [[ -n "$back" ]] && break; sleep 0.5; done +back_link="$(ip -o link show dev '-x"y' 2>/dev/null | link_of)" s6-svc -u /run/service/net-zone took="" for _ in $(seq 1 60); do [[ "$(ready_count)" -gt "$rn_before" ]] && { took="$(last_ready)"; break; }; sleep 1; done @@ -575,6 +580,13 @@ if [[ "$named" == *NAMED* && "$back" == '-x"y ' && "$took" =~ $plain_uplink && - else fail "uplink-renamed-plain" "renamed: ${named:-nothing}; back in zone 0: ${back:-nothing}; READY: ${took:-none}; $(netzone_said 'NOT READY' | tail -1); zone 0 holds: $(physical)" fi +taken_link="$(nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n ip -o link show dev "${took##*uplinks=}" 2>/dev/null | link_of)" +if [[ -n "$own_link" && "$back_link" == "02:00:5e:00:53:01 1400" && "$taken_link" == "${own_link% *} 1500" ]]; then + pass "uplink-state-reset" "the net zone left its NIC at 02:00:5e:00:53:01 with MTU 1400; the next start took it back to ${taken_link}" +else + fail "uplink-state-reset" "own: ${own_link:-unread}; back in zone 0: ${back_link:-unread}; in the next net zone: ${taken_link:-unread}; $(uncaught | grep -a 'kryptikd: .*\(address and MTU\|Wake-on-LAN\)' | tail -2 | tr '\n' ' ')" +fi +info "uplink-wol virtio-net has no Wake-on-LAN, so this run shows the address and MTU being set back, not Wake-on-LAN" # eth0 again, for what follows: the NIC is back in zone 0, down, while the net zone is. s6-svc -d /run/service/net-zone for _ in $(seq 1 20); do [[ -n "$(physical)" ]] && break; sleep 0.5; done diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 8985b168..883bb35e 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -41,6 +41,7 @@ const NLA_F_NESTED: u16 = 0x8000; const IFLA_ADDRESS: u16 = 1; const IFLA_IFNAME: u16 = 3; +const IFLA_MTU: u16 = 4; const IFLA_MASTER: u16 = 10; // A bridge acks and ignores an unknown attribute, so the isolation test checks sysfs and the wire. const IFLA_PROTINFO: u16 = 12; @@ -513,6 +514,88 @@ pub fn mac_of(dev: &str) -> io::Result<[u8; 6]> { Ok(mac) } +/// Set `dev`'s address and MTU, which needs it down. +pub fn set_mac_mtu(dev: &str, mac: Option<[u8; 6]>, mtu: Option) -> io::Result<()> { + let idx = index_of(dev)?; + let mut m = Msg::new(RTM_NEWLINK, 0, 1); + m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); + if let Some(a) = mac { + m.attr(IFLA_ADDRESS, &a); + } + if let Some(n) = mtu { + m.attr_u32(IFLA_MTU, n); + } + transact(m.finish(), &format!("set the address and MTU of {dev:?}")) +} + +const SIOCETHTOOL: libc::c_ulong = 0x8946; +const ETHTOOL_GWOL: u32 = 5; +const ETHTOOL_SWOL: u32 = 6; +const ETHTOOL_GPERMADDR: u32 = 0x20; + +// As linux/ethtool.h lays them out: the kernel reads fields this code never does. +#[allow(dead_code)] +#[repr(C)] +struct WolInfo { + cmd: u32, + supported: u32, + wolopts: u32, + sopass: [u8; 6], +} + +#[allow(dead_code)] +#[repr(C)] +struct PermAddr { + cmd: u32, + size: u32, + data: [u8; 32], +} + +/// One `SIOCETHTOOL` request on `dev`, whose answer the kernel writes back into `req`. +fn ethtool(dev: &str, req: &mut T) -> io::Result<()> { + let c = CString::new(dev).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL"))?; + let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if sock < 0 { + return Err(io::Error::last_os_error()); + } + let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; + for (i, b) in c.as_bytes_with_nul().iter().take(libc::IFNAMSIZ).enumerate() { + ifr.ifr_name[i] = *b as libc::c_char; + } + ifr.ifr_ifru.ifru_data = req as *mut T as *mut libc::c_char; + // SAFETY: the kernel reads and writes `req`, which outlives the call, as the command says. + let r = unsafe { libc::ioctl(sock, SIOCETHTOOL as _, &mut ifr) }; + let e = io::Error::last_os_error(); + unsafe { libc::close(sock) }; + if r < 0 { + return Err(e); + } + Ok(()) +} + +/// The address the hardware came with, if its driver gives one. +pub fn perm_mac_of(dev: &str) -> io::Result> { + let mut p = PermAddr { cmd: ETHTOOL_GPERMADDR, size: 32, data: [0; 32] }; + ethtool(dev, &mut p)?; + let mac: [u8; 6] = p.data[..6].try_into().unwrap(); + Ok((p.size == 6 && mac != [0; 6]).then_some(mac)) +} + +/// Turn Wake-on-LAN off; false when it was off already or the driver has none. +pub fn wol_off(dev: &str) -> io::Result { + let mut w = WolInfo { cmd: ETHTOOL_GWOL, supported: 0, wolopts: 0, sopass: [0; 6] }; + match ethtool(dev, &mut w) { + Err(e) if e.raw_os_error() == Some(libc::EOPNOTSUPP) => return Ok(false), + r => r?, + } + if w.wolopts == 0 { + return Ok(false); + } + let mut off = WolInfo { cmd: ETHTOOL_SWOL, supported: 0, wolopts: 0, sopass: [0; 6] }; + ethtool(dev, &mut off)?; + Ok(true) +} + /// Open a handle on a process's network namespace. pub fn open_netns_of(pid: libc::pid_t) -> io::Result { let p = CString::new(format!("/proc/{pid}/ns/net")).unwrap(); diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 3191d771..81d5fe9e 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -273,9 +273,32 @@ pub fn physical_interfaces() -> io::Result> { } } out.sort(); + for n in &out { + reset_nic(n); + } Ok(out) } +/// What a NIC keeps from the net zone that held it, set back as its hardware gives it: an +/// address that zone chose would follow the machine from network to network, and Wake-on-LAN +/// would let anyone on the network wake it. The MTU goes back to Ethernet's. +fn reset_nic(nic: &str) { + let sys = Path::new("/sys/class/net").join(nic); + let own = netlink::perm_mac_of(nic).ok().flatten().filter(|p| netlink::mac_of(nic).ok() != Some(*p)); + let mtu = (sysfs_u32(&sys.join("type")) == Some(1) && sysfs_u32(&sys.join("mtu")) != Some(1500)).then_some(1500); + if own.is_some() || mtu.is_some() { + match netlink::set_mac_mtu(nic, own, mtu) { + Ok(()) => eprintln!("kryptikd: {nic:?}: its own address and MTU are back"), + Err(e) => eprintln!("kryptikd: {nic:?}: its address and MTU could not be set back: {e}"), + } + } + match netlink::wol_off(nic) { + Ok(true) => eprintln!("kryptikd: {nic:?}: Wake-on-LAN turned off"), + Ok(false) => {} + Err(e) => eprintln!("kryptikd: {nic:?}: Wake-on-LAN could not be turned off: {e}"), + } +} + /// A name as the kernel and eudev give one: a lower-case letter, then lower-case letters and /// digits, so no option, quote, glob or control byte. Not `BRIDGE`, which the start makes in /// the zone after the NICs move: a NIC by that name would fail it. @@ -296,9 +319,9 @@ fn physical_interfaces_under(class_net: &Path) -> io::Result io::Result Vec { let entries = |d: &str| std::fs::read_dir(class.join(d)).into_iter().flatten().flatten(); let held: std::collections::BTreeSet = - entries("net").filter_map(|e| sysfs_index(&e.path().join("phy80211/index"))).collect(); + entries("net").filter_map(|e| sysfs_u32(&e.path().join("phy80211/index"))).collect(); let mut bare: Vec = - entries("ieee80211").filter_map(|e| sysfs_index(&e.path().join("index"))).filter(|p| !held.contains(p)).collect(); + entries("ieee80211").filter_map(|e| sysfs_u32(&e.path().join("index"))).filter(|p| !held.contains(p)).collect(); bare.sort(); bare } -fn sysfs_index(path: &Path) -> Option { +fn sysfs_u32(path: &Path) -> Option { std::fs::read_to_string(path).ok()?.trim().parse().ok() } @@ -375,6 +398,7 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { "[network] nic = {n:?} is not an interface in this namespace" ))); } + reset_nic(n); vec![n.to_string()] } }; diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 682e64ff..a5a57f23 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -307,7 +307,12 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. one that is not plain (a lower-case letter, then lower-case letters and digits, and not `kryptik0`, the bridge it makes there) `nic`: no option, quote or escape a past net zone chose reaches the next one's script, nft set - or dhcpcd, and no name of its own stops it. + or dhcpcd, and no name of its own stops it. It also sets back what that zone + could change on the NIC itself: the address, to the one the hardware came + with, as one the zone chose would follow the machine from network to + network; Wake-on-LAN, off, as it would let anyone on the network wake the + machine; and the MTU, to Ethernet's 1500. EEE, offloads, rings and coalescing, + which ethtool's netlink sets past the zone's ioctl rule, are not set back. ## What this guarantees @@ -350,7 +355,9 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. file and `kryptik wifi`. - The zones suite has the net zone rename its NIC `-x"y` and restarts it: the NIC is back in zone 0 under that name, and the next start comes up `READY` - with it as `nic`. `netlink::tests::rename_by_index_takes_a_template` + with it as `nic`, with its own address and an MTU of 1500 again after + the zone set them to others. virtio-net has no Wake-on-LAN, so no suite + shows that being turned off. `netlink::tests::rename_by_index_takes_a_template` renames a veth by its index to `nic%d`. Over the simulated radio it leaves the net zone's radio with a monitor netdev named to sort before the station, then with no netdev at all, and each time the next start carries diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index c89d7a01..0ee9cec8 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -66,7 +66,7 @@ grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' # The key verdicts one by one, so a pass is not a single line. for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused zone-flows-capped \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ - uplink-renamed-plain \ + uplink-renamed-plain uplink-state-reset \ radio-recarried \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry resolver-after-attach dns-cache-off dns-counters-hidden net-zone-sysfs-nics-only \ terminal-terminfo man-page text-browser tls-trust \ From 9166a008effdd39a88c8bb2e0b9b54622d431014 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:37:09 -0700 Subject: [PATCH 6/9] A NIC's altnames and alias go too, and mac_of is built outside tests An altname sits in the same name table as names and moves with the NIC, so a net zone that gave its NIC the altname kryptik0 stopped every later start making the bridge, as #304's plain names otherwise prevent. The alias is free text that ip link prints. reset_nic now reads both with RTM_GETLINK, removes the altnames with RTM_DELLINKPROP and drops the alias with the address and MTU. mac_of was test-only, which the release build refused. --- build/guest-tests/zones-check.sh | 17 ++++---- compartments/kryptikd/src/netlink.rs | 63 ++++++++++++++++++++++++++-- compartments/kryptikd/src/netzone.rs | 22 +++++++--- docs/design/net-zone.md | 11 +++-- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 8d545854..34a1eb15 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -560,12 +560,13 @@ fi # A net zone can rename its NIC, which comes back to zone 0 under that name: # a leading dash would reach the next zone's dhcpcd as an option, and a quote # would end the name in its nft set. The next start renames it nic first. -# Its address and MTU come back as that zone set them too, and the next start -# puts back the NIC's own. +# Its address, MTU, an altname and an alias come back as that zone set them +# too; an altname kryptik0 would stop the next start making the bridge. The +# next start puts back the NIC's own address and MTU and drops the rest. link_of() { sed -n 's/.* mtu \([0-9]*\) .*link\/ether \([0-9a-f:]*\) .*/\2 \1/p'; } ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" own_link="$(nsenter -t "${ninit:-0}" -n ip -o link show dev eth0 2>/dev/null | link_of)" -named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" +named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && ip link property add dev eth0 altname kryptik0 && ip link set dev eth0 alias left-by-the-zone && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" rn_before="$(ready_count)" s6-svc -d /run/service/net-zone back="" @@ -580,11 +581,13 @@ if [[ "$named" == *NAMED* && "$back" == '-x"y ' && "$took" =~ $plain_uplink && - else fail "uplink-renamed-plain" "renamed: ${named:-nothing}; back in zone 0: ${back:-nothing}; READY: ${took:-none}; $(netzone_said 'NOT READY' | tail -1); zone 0 holds: $(physical)" fi -taken_link="$(nsenter -t "$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -n ip -o link show dev "${took##*uplinks=}" 2>/dev/null | link_of)" -if [[ -n "$own_link" && "$back_link" == "02:00:5e:00:53:01 1400" && "$taken_link" == "${own_link% *} 1500" ]]; then - pass "uplink-state-reset" "the net zone left its NIC at 02:00:5e:00:53:01 with MTU 1400; the next start took it back to ${taken_link}" +nnow="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" +taken_link="$(nsenter -t "${nnow:-0}" -n ip -o link show dev "${took##*uplinks=}" 2>/dev/null | link_of)" +taken_extra="$(nsenter -t "${nnow:-0}" -n ip -d -o link show dev "${took##*uplinks=}" 2>/dev/null | grep -o 'altname [^ ]*\|alias [^ ]*' | tr '\n' ' ')" +if [[ -n "$own_link" && "$back_link" == "02:00:5e:00:53:01 1400" && "$taken_link" == "${own_link% *} 1500" && -n "$taken_link" && -z "$taken_extra" ]]; then + pass "uplink-state-reset" "the net zone left its NIC at 02:00:5e:00:53:01, MTU 1400, altname kryptik0 and an alias; the next start took it back to ${taken_link}, with neither" else - fail "uplink-state-reset" "own: ${own_link:-unread}; back in zone 0: ${back_link:-unread}; in the next net zone: ${taken_link:-unread}; $(uncaught | grep -a 'kryptikd: .*\(address and MTU\|Wake-on-LAN\)' | tail -2 | tr '\n' ' ')" + fail "uplink-state-reset" "own: ${own_link:-unread}; back in zone 0: ${back_link:-unread}; in the next net zone: ${taken_link:-unread} ${taken_extra}; $(uncaught | grep -a 'kryptikd: .*\(hardware gives it\|altname\|Wake-on-LAN\)' | tail -3 | tr '\n' ' ')" fi info "uplink-wol virtio-net has no Wake-on-LAN, so this run shows the address and MTU being set back, not Wake-on-LAN" # eth0 again, for what follows: the NIC is back in zone 0, down, while the net zone is. diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 883bb35e..e3cdce7e 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -27,9 +27,11 @@ const NLA_TYPE_MASK: u16 = 0x3fff; const RTM_NEWLINK: u16 = 16; const RTM_DELLINK: u16 = 17; +const RTM_GETLINK: u16 = 18; const RTM_SETLINK: u16 = 19; const RTM_NEWADDR: u16 = 20; const RTM_NEWROUTE: u16 = 24; +const RTM_DELLINKPROP: u16 = 109; const NLMSG_ERROR: u16 = 2; const NLMSG_DONE: u16 = 3; @@ -46,7 +48,10 @@ const IFLA_MASTER: u16 = 10; // A bridge acks and ignores an unknown attribute, so the isolation test checks sysfs and the wire. const IFLA_PROTINFO: u16 = 12; const IFLA_LINKINFO: u16 = 18; +const IFLA_IFALIAS: u16 = 20; const IFLA_NET_NS_FD: u16 = 28; +const IFLA_PROP_LIST: u16 = 52; +const IFLA_ALT_IFNAME: u16 = 53; const IFLA_INFO_KIND: u16 = 1; const IFLA_INFO_DATA: u16 = 2; const VETH_INFO_PEER: u16 = 1; @@ -489,7 +494,6 @@ pub fn is_up(dev: &str) -> io::Result { } /// `dev`'s MAC. -#[cfg(test)] pub fn mac_of(dev: &str) -> io::Result<[u8; 6]> { let c = CString::new(dev).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL"))?; let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; @@ -514,8 +518,8 @@ pub fn mac_of(dev: &str) -> io::Result<[u8; 6]> { Ok(mac) } -/// Set `dev`'s address and MTU, which needs it down. -pub fn set_mac_mtu(dev: &str, mac: Option<[u8; 6]>, mtu: Option) -> io::Result<()> { +/// Set `dev`'s address and MTU, which needs it down, and drop its alias. +pub fn set_link(dev: &str, mac: Option<[u8; 6]>, mtu: Option, drop_alias: bool) -> io::Result<()> { let idx = index_of(dev)?; let mut m = Msg::new(RTM_NEWLINK, 0, 1); m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); @@ -525,9 +529,62 @@ pub fn set_mac_mtu(dev: &str, mac: Option<[u8; 6]>, mtu: Option) -> io::Res if let Some(n) = mtu { m.attr_u32(IFLA_MTU, n); } + if drop_alias { + m.attr(IFLA_IFALIAS, &[]); + } transact(m.finish(), &format!("set the address and MTU of {dev:?}")) } +/// The (type, data) attributes in `buf` from `off`. +fn attrs(buf: &[u8], mut off: usize) -> Vec<(u16, &[u8])> { + let mut out = Vec::new(); + while off + 4 <= buf.len() { + let len = u16::from_ne_bytes(buf[off..off + 2].try_into().unwrap()) as usize; + let kind = u16::from_ne_bytes(buf[off + 2..off + 4].try_into().unwrap()) & NLA_TYPE_MASK; + if len < 4 || off + len > buf.len() { + break; + } + out.push((kind, &buf[off + 4..off + len])); + off += align4(len); + } + out +} + +/// `dev`'s altnames, which answer to a lookup as its name does, and whether it has an alias. +pub fn names_left(dev: &str) -> io::Result<(Vec>, bool)> { + let idx = index_of(dev)?; + let mut m = Msg::new(RTM_GETLINK, 0, 1); + m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); + let reply = transact_on(NETLINK_ROUTE, m.finish(), &format!("read {dev:?}"))?; + let (mut alt, mut alias) = (Vec::new(), false); + // An ifinfomsg, 16 bytes, then the link's attributes. + for (kind, data) in attrs(&reply, 16) { + if kind == IFLA_PROP_LIST { + for (k, name) in attrs(data, 0) { + if k == IFLA_ALT_IFNAME { + alt.push(name.split(|b| *b == 0).next().unwrap_or(name).to_vec()); + } + } + } else if kind == IFLA_IFALIAS { + alias = data.first().is_some_and(|b| *b != 0); + } + } + Ok((alt, alias)) +} + +/// Remove altnames from `dev`. +pub fn del_altnames(dev: &str, names: &[Vec]) -> io::Result<()> { + let idx = index_of(dev)?; + let mut m = Msg::new(RTM_DELLINKPROP, 0, 1); + m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); + let list = m.begin_nested(IFLA_PROP_LIST); + for n in names { + m.attr(IFLA_ALT_IFNAME, &[n.as_slice(), b"\0"].concat()); + } + m.end_nested(list); + transact(m.finish(), &format!("remove the altnames of {dev:?}")) +} + const SIOCETHTOOL: libc::c_ulong = 0x8946; const ETHTOOL_GWOL: u32 = 5; const ETHTOOL_SWOL: u32 = 6; diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 81d5fe9e..6f1a2b5e 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -281,15 +281,27 @@ pub fn physical_interfaces() -> io::Result> { /// What a NIC keeps from the net zone that held it, set back as its hardware gives it: an /// address that zone chose would follow the machine from network to network, and Wake-on-LAN -/// would let anyone on the network wake it. The MTU goes back to Ethernet's. +/// would let anyone on the network wake it. The MTU goes back to Ethernet's. Its altnames go, +/// as one named `BRIDGE` would stop the next start making the bridge, and so does its alias, +/// free text that `ip link` prints. fn reset_nic(nic: &str) { let sys = Path::new("/sys/class/net").join(nic); let own = netlink::perm_mac_of(nic).ok().flatten().filter(|p| netlink::mac_of(nic).ok() != Some(*p)); let mtu = (sysfs_u32(&sys.join("type")) == Some(1) && sysfs_u32(&sys.join("mtu")) != Some(1500)).then_some(1500); - if own.is_some() || mtu.is_some() { - match netlink::set_mac_mtu(nic, own, mtu) { - Ok(()) => eprintln!("kryptikd: {nic:?}: its own address and MTU are back"), - Err(e) => eprintln!("kryptikd: {nic:?}: its address and MTU could not be set back: {e}"), + let (alt, alias) = netlink::names_left(nic).unwrap_or_else(|e| { + eprintln!("kryptikd: {nic:?}: its altnames could not be read: {e}"); + Default::default() + }); + if !alt.is_empty() { + match netlink::del_altnames(nic, &alt) { + Ok(()) => eprintln!("kryptikd: {nic:?}: {} altname(s) removed", alt.len()), + Err(e) => eprintln!("kryptikd: {nic:?}: its altnames could not be removed: {e}"), + } + } + if own.is_some() || mtu.is_some() || alias { + match netlink::set_link(nic, own, mtu, alias) { + Ok(()) => eprintln!("kryptikd: {nic:?}: set back as its hardware gives it"), + Err(e) => eprintln!("kryptikd: {nic:?}: could not be set back as its hardware gives it: {e}"), } } match netlink::wol_off(nic) { diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index a5a57f23..c3e84b4a 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -311,8 +311,12 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. could change on the NIC itself: the address, to the one the hardware came with, as one the zone chose would follow the machine from network to network; Wake-on-LAN, off, as it would let anyone on the network wake the - machine; and the MTU, to Ethernet's 1500. EEE, offloads, rings and coalescing, - which ethtool's netlink sets past the zone's ioctl rule, are not set back. + machine; the MTU, to Ethernet's 1500; and its altnames and alias, which go, + since an altname answers as a name does (one named `kryptik0` would stop the + bridge being made) and `ip link` prints both. EEE, offloads, rings and + coalescing, which ethtool's netlink sets past the zone's ioctl rule, are not + set back, nor are a radio's wake triggers (nl80211 WoWLAN), which act only + in suspend. ## What this guarantees @@ -356,7 +360,8 @@ as `SIGSYS` in the zone's log and a `wifi=connecting` that never changes. - The zones suite has the net zone rename its NIC `-x"y` and restarts it: the NIC is back in zone 0 under that name, and the next start comes up `READY` with it as `nic`, with its own address and an MTU of 1500 again after - the zone set them to others. virtio-net has no Wake-on-LAN, so no suite + the zone set them to others, and without the altname `kryptik0` and the + alias the zone gave it. virtio-net has no Wake-on-LAN, so no suite shows that being turned off. `netlink::tests::rename_by_index_takes_a_template` renames a veth by its index to `nic%d`. Over the simulated radio it leaves the net zone's radio with a monitor netdev named to sort before the From dd0ce8b3c33879686ea858c8b7d8ce7b9c0e1137 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:44:57 -0700 Subject: [PATCH 7/9] Netlink replies are read whole, so a NIC's altnames cannot outgrow the reset transact_on read into one 8 KiB buffer and refused a longer reply, and a NIC may hold altnames until their list nears 64 KiB. A net zone that gave its NIC seventy long altnames beside kryptik0 made the listing fail, so kryptik0 stayed and the next start could not make the bridge. Each read is now sized to its message (MSG_PEEK | MSG_TRUNC) under a 128 KiB cap, del_altnames removes them 64 to a request, as the list's length is 16 bits, and a listing that fails all the same still removes kryptik0 by name. --- build/guest-tests/zones-check.sh | 6 +++-- compartments/kryptikd/src/netlink.rs | 40 ++++++++++++++++++++-------- compartments/kryptikd/src/netzone.rs | 6 +++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 34a1eb15..5a89604e 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -566,7 +566,9 @@ fi link_of() { sed -n 's/.* mtu \([0-9]*\) .*link\/ether \([0-9a-f:]*\) .*/\2 \1/p'; } ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" own_link="$(nsenter -t "${ninit:-0}" -n ip -o link show dev eth0 2>/dev/null | link_of)" -named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && ip link property add dev eth0 altname kryptik0 && ip link set dev eth0 alias left-by-the-zone && ip link set dev eth0 name "-x\"y" && echo NAMED' 2>&1)" +# Seventy long altnames first: the link's listing then outgrows one 8 KiB read. +long="$(head -c 100 /dev/zero | tr '\0' a)" +named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && for i in $(seq 1 70); do ip link property add dev eth0 altname "$1$i" || exit 1; done && ip link property add dev eth0 altname kryptik0 && ip link set dev eth0 alias left-by-the-zone && ip link set dev eth0 name "-x\"y" && echo NAMED' sh "$long" 2>&1)" rn_before="$(ready_count)" s6-svc -d /run/service/net-zone back="" @@ -585,7 +587,7 @@ nnow="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" taken_link="$(nsenter -t "${nnow:-0}" -n ip -o link show dev "${took##*uplinks=}" 2>/dev/null | link_of)" taken_extra="$(nsenter -t "${nnow:-0}" -n ip -d -o link show dev "${took##*uplinks=}" 2>/dev/null | grep -o 'altname [^ ]*\|alias [^ ]*' | tr '\n' ' ')" if [[ -n "$own_link" && "$back_link" == "02:00:5e:00:53:01 1400" && "$taken_link" == "${own_link% *} 1500" && -n "$taken_link" && -z "$taken_extra" ]]; then - pass "uplink-state-reset" "the net zone left its NIC at 02:00:5e:00:53:01, MTU 1400, altname kryptik0 and an alias; the next start took it back to ${taken_link}, with neither" + pass "uplink-state-reset" "the net zone left its NIC at 02:00:5e:00:53:01, MTU 1400, 71 altnames (kryptik0 among them) and an alias; the next start took it back to ${taken_link}, with neither" else fail "uplink-state-reset" "own: ${own_link:-unread}; back in zone 0: ${back_link:-unread}; in the next net zone: ${taken_link:-unread} ${taken_extra}; $(uncaught | grep -a 'kryptikd: .*\(hardware gives it\|altname\|Wake-on-LAN\)' | tail -3 | tr '\n' ' ')" fi diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index e3cdce7e..d60123a0 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -163,8 +163,9 @@ impl Msg { } } -/// Cap on the reply payload one request gathers; replies are a few hundred bytes. -const MAX_REPLY: usize = 64 * 1024; +/// Cap on the reply payload one request gathers: most are a few hundred bytes, and a link's +/// altnames, up to 64 KiB, are the most any reply here carries. +const MAX_REPLY: usize = 128 * 1024; /// One request/ack exchange on a fresh NETLINK_ROUTE socket. fn transact(msg: Vec, what: &str) -> io::Result<()> { @@ -195,9 +196,22 @@ fn transact_on(proto: libc::c_int, msg: Vec, what: &str) -> io::Result MAX_REPLY { + return Err(io::Error::new(io::ErrorKind::InvalidData, "netlink reply too large")); + } + buf.resize((need as usize).max(16), 0); let n = unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) }; if n < 0 { let e = io::Error::last_os_error(); @@ -572,17 +586,21 @@ pub fn names_left(dev: &str) -> io::Result<(Vec>, bool)> { Ok((alt, alias)) } -/// Remove altnames from `dev`. +/// Remove altnames from `dev`, 64 to a request: a property list's length is 16 bits, and one +/// altname can take 132 bytes of it. pub fn del_altnames(dev: &str, names: &[Vec]) -> io::Result<()> { let idx = index_of(dev)?; - let mut m = Msg::new(RTM_DELLINKPROP, 0, 1); - m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); - let list = m.begin_nested(IFLA_PROP_LIST); - for n in names { - m.attr(IFLA_ALT_IFNAME, &[n.as_slice(), b"\0"].concat()); + for some in names.chunks(64) { + let mut m = Msg::new(RTM_DELLINKPROP, 0, 1); + m.ifinfomsg(libc::AF_UNSPEC as u8, idx as i32, 0, 0); + let list = m.begin_nested(IFLA_PROP_LIST); + for n in some { + m.attr(IFLA_ALT_IFNAME, &[n.as_slice(), b"\0"].concat()); + } + m.end_nested(list); + transact(m.finish(), &format!("remove the altnames of {dev:?}"))?; } - m.end_nested(list); - transact(m.finish(), &format!("remove the altnames of {dev:?}")) + Ok(()) } const SIOCETHTOOL: libc::c_ulong = 0x8946; diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 6f1a2b5e..b70e4198 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -290,6 +290,12 @@ fn reset_nic(nic: &str) { let mtu = (sysfs_u32(&sys.join("type")) == Some(1) && sysfs_u32(&sys.join("mtu")) != Some(1500)).then_some(1500); let (alt, alias) = netlink::names_left(nic).unwrap_or_else(|e| { eprintln!("kryptikd: {nic:?}: its altnames could not be read: {e}"); + // Still the one that would stop the start, by name; none by that name is the usual case. + if let Err(e) = netlink::del_altnames(nic, &[BRIDGE.as_bytes().to_vec()]) { + if e.kind() != io::ErrorKind::NotFound { + eprintln!("kryptikd: {nic:?}: an altname {BRIDGE:?} could not be removed: {e}"); + } + } Default::default() }); if !alt.is_empty() { From e90a1dda75bf906b0444416ee1d09dfd0ce8e6f0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 06:28:51 -0700 Subject: [PATCH 8/9] The radio check adds its monitor netdev by the phy's index, which iw cannot find by name through zone 0's sysfs --- build/guest-tests/zones-check.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 3efa724b..562030c9 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -694,7 +694,9 @@ radio_netdevs() { nsenter -t "${1:-0}" -n iw dev 2>/dev/null | awk '$1 == "Inter for round in extra none; do ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" if [[ "$round" = extra ]]; then - nsenter -t "${ninit:-0}" -n iw phy phy0 interface add amon0 type monitor > "$LOG/radio-$round.out" 2>&1 + # By index: iw finds a phy's name in sysfs, which is zone 0's here and no longer lists it. + phy="$(nsenter -t "${ninit:-0}" -n iw dev 2>/dev/null | sed -n 's/^phy#\([0-9]*\)$/\1/p' | head -1)" + nsenter -t "${ninit:-0}" -n iw "phy#${phy:-0}" interface add amon0 type monitor > "$LOG/radio-$round.out" 2>&1 else for w in $(nsenter -t "${ninit:-0}" -n iw dev 2>/dev/null | awk '$1 == "Interface" { print $2 }'); do nsenter -t "${ninit:-0}" -n iw dev "$w" del >> "$LOG/radio-$round.out" 2>&1 From c45345e422c86943733c8c5c8124d0c90a7da6bc Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 07:33:08 -0700 Subject: [PATCH 9/9] The hostile round deletes the net zone's bridge before it takes kryptik0 as an altname, as only then can a zone --- build/guest-tests/zones-check.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index df4b751f..fbe741d8 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -561,14 +561,15 @@ fi # a leading dash would reach the next zone's dhcpcd as an option, and a quote # would end the name in its nft set. The next start renames it nic first. # Its address, MTU, an altname and an alias come back as that zone set them -# too; an altname kryptik0 would stop the next start making the bridge. The +# too; an altname kryptik0, which the zone takes once it has deleted its own +# bridge, would stop the next start making the bridge. The # next start puts back the NIC's own address and MTU and drops the rest. link_of() { sed -n 's/.* mtu \([0-9]*\) .*link\/ether \([0-9a-f:]*\) .*/\2 \1/p'; } ninit="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" own_link="$(nsenter -t "${ninit:-0}" -n ip -o link show dev eth0 2>/dev/null | link_of)" # Seventy long altnames first: the link's listing then outgrows one 8 KiB read. long="$(head -c 100 /dev/zero | tr '\0' a)" -named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && for i in $(seq 1 70); do ip link property add dev eth0 altname "$1$i" || exit 1; done && ip link property add dev eth0 altname kryptik0 && ip link set dev eth0 alias left-by-the-zone && ip link set dev eth0 name "-x\"y" && echo NAMED' sh "$long" 2>&1)" +named="$(nsenter -t "${ninit:-0}" -n sh -c 'ip link set dev eth0 down && ip link set dev eth0 address 02:00:5e:00:53:01 mtu 1400 && for i in $(seq 1 70); do ip link property add dev eth0 altname "$1$i" || exit 1; done && ip link del kryptik0 && ip link property add dev eth0 altname kryptik0 && ip link set dev eth0 alias left-by-the-zone && ip link set dev eth0 name "-x\"y" && echo NAMED' sh "$long" 2>&1)" rn_before="$(ready_count)" s6-svc -d /run/service/net-zone back=""