From 63cf9d2ec6736ab6085f7e25c797be4b75740a63 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 9 Oct 2026 05:09:17 -0700 Subject: [PATCH 1/2] 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/2] 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