diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 3a13e29e..a73c65a7 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -53,6 +53,31 @@ done if [[ "$ready" == *"nat=yes"* ]]; then pass "net-ready" "$ready"; else fail "net-ready" "no READY line with nat=yes in the catch-all log (last: $(netzone_said '' | tail -1))"; fi # The routed zones' resolver, named on its own: routed-dns only times out. if [[ "$ready" == *" dns=yes "* ]]; then pass "net-dns" "dnsmasq is running"; else fail "net-dns" "$(uncaught | grep -a 'dnsmasq' | tail -2 | tr '\n' ' ')"; fi +# dhcpcd's privilege separation: what parses a lease runs as the net zone's +# dhcpcd user (host uid_base + 100) in an empty root, with no capability and +# dhcpcd's own seccomp filter over the zone's, while a helper stays its root; +# and the lease, which that helper writes, arrived. +net_base="$(sed -n 's/^uid_base *= *\([0-9]*\).*/\1/p' "$Z/net.toml")" +nz_init="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" +separated=0; helpers=0; seen="" +for p in $(pgrep -x dhcpcd); do + uid="$(awk '/^Uid:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + caps="$(awk '/^CapEff:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + filters="$(awk '/^Seccomp_filters:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + # 2>&1: a root that cannot be listed is not an empty one. + inside="$(ls -A "/proc/$p/root" 2>&1 | head -3 | tr '\n' ',')" + seen="${seen} ${p}:uid=${uid},caps=${caps},filters=${filters},root=$(readlink "/proc/$p/root" 2>/dev/null)[${inside}]" + if [[ "$uid" == "$((net_base + 100))" && "$caps" == 0000000000000000 && "${filters:-0}" -ge 2 && -z "$inside" ]]; then + separated=$((separated + 1)) + fi + [[ "$uid" == "$net_base" ]] && helpers=$((helpers + 1)) +done +leased="$(nsenter -t "${nz_init:-0}" -m sh -c 'ls /var/lib/dhcpcd/*.lease 2>/dev/null' | head -1)" +if [[ "$separated" -ge 1 && "$helpers" -ge 1 && -n "$leased" ]]; then + pass "dhcpcd-separated" "${separated} dhcpcd process(es) as uid $((net_base + 100)) in an empty root with no capability under two filters, ${helpers} root helper, lease ${leased}" +else + fail "dhcpcd-separated" "separated ${separated}, helpers ${helpers}, lease ${leased:-none}:${seen:- no dhcpcd running}" +fi if ip link show eth0 >/dev/null 2>&1; then fail "zone0-nic" "eth0 is still in zone 0"; else pass "zone0-nic" "eth0 is not in zone 0 (moved into the net zone)"; fi if [[ -z "$(ip route show default 2>/dev/null)" ]]; then pass "zone0-no-route" "zone 0 has no default route"; else fail "zone0-no-route" "$(ip route show default)"; fi if ping -c1 -W2 10.0.2.2 >/dev/null 2>&1; then fail "zone0-offline" "zone 0 reached the VM gateway"; else pass "zone0-offline" "zone 0 cannot reach the VM gateway"; fi @@ -718,7 +743,7 @@ zrun untrusted 30 -- grep -c /usr/lib/libhardened_malloc.so /proc/self/maps [[ "$ZRC" = 0 ]] && pass "allocator-zone" "a process in untrusted runs on it too" || fail "allocator-zone" "rc=$ZRC $(tail -1 "$LOG/untrusted.err")" # --- the installed root: privilege only where the allowlist says ------------------- -# What stage 06 stripped stays stripped: on the root filesystem a setuid or +# Stage 06 fails the build on any other bit; the installed root shows it: a setuid or # setgid bit is on the listed binaries alone (build/config/setuid-allowlist.txt) # and file capabilities are on none (capability-allowlist.txt is empty). setuid_found="$(find / -xdev -type f -perm /6000 2>/dev/null | LC_ALL=C sort | tr '\n' ' ')" diff --git a/build/recipes/netzone.sh b/build/recipes/netzone.sh index e3232d7a..ef320a78 100644 --- a/build/recipes/netzone.sh +++ b/build/recipes/netzone.sh @@ -12,6 +12,9 @@ s_netzone() { python3 -m py_compile /usr/libexec/kryptik/sntp-offset.py || { echo "sntp-offset.py does not compile under the target python"; return 1; } install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/update-fetch.py" /usr/libexec/kryptik/update-fetch.py python3 -m py_compile /usr/libexec/kryptik/update-fetch.py || { echo "update-fetch.py does not compile under the target python"; return 1; } + # dhcpcd's hook: its root helper runs it with what the unprivileged side sends. + install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/dhcpcd-hook.py" /usr/libexec/kryptik/dhcpcd-hook + python3 -m py_compile /usr/libexec/kryptik/dhcpcd-hook || { echo "dhcpcd-hook does not compile under the target python"; return 1; } rm -rf /usr/libexec/kryptik/__pycache__ for t in dhcpcd nft dnsmasq ip; do command -v "$t" >/dev/null 2>&1 && echo " ok $t" || { echo " MISSING $t"; return 1; } diff --git a/compartments/kryptikd/src/caps.rs b/compartments/kryptikd/src/caps.rs index 09b7beae..f135c64a 100644 --- a/compartments/kryptikd/src/caps.rs +++ b/compartments/kryptikd/src/caps.rs @@ -5,7 +5,7 @@ use std::io; /// Capability numbers from `linux/capability.h` (stable ABI; `libc` lacks them). #[allow(dead_code)] -mod cap { +pub(crate) mod cap { use libc::c_int; pub const CHOWN: c_int = 0; pub const DAC_OVERRIDE: c_int = 1; @@ -21,6 +21,7 @@ mod cap { pub const NET_RAW: c_int = 13; pub const IPC_LOCK: c_int = 14; pub const SYS_MODULE: c_int = 16; + pub const SYS_CHROOT: c_int = 18; pub const SYS_PTRACE: c_int = 19; pub const SYS_ADMIN: c_int = 21; pub const SYS_NICE: c_int = 23; @@ -35,9 +36,17 @@ pub const KEEP: libc::c_int = cap::NET_BIND_SERVICE; pub const KEEPABLE: &[libc::c_int] = &[ cap::NET_BIND_SERVICE, cap::NET_ADMIN, cap::NET_RAW, cap::NET_BROADCAST, cap::SYS_NICE, cap::IPC_LOCK, cap::KILL, cap::CHOWN, cap::FOWNER, cap::FSETID, - cap::DAC_READ_SEARCH, + cap::DAC_READ_SEARCH, cap::SETUID, cap::SETGID, cap::SYS_CHROOT, ]; +/// What a daemon needs to drop to a user of its own (dhcpcd's privilege separation): kept +/// together or not at all, and each opens the calls `seccomp::CAP_CALLS` names. +pub const PRIVSEP: &[libc::c_int] = &[cap::SETUID, cap::SETGID, cap::SYS_CHROOT]; + +pub fn keeps_privsep(keep: &[libc::c_int]) -> bool { + PRIVSEP.iter().all(|c| keep.contains(c)) +} + /// Capability numbers by name, as linux/capability.h defines them. pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHOWN", 0), ("CAP_DAC_OVERRIDE", 1), ("CAP_DAC_READ_SEARCH", 2), ("CAP_FOWNER", 3), @@ -53,9 +62,9 @@ pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHECKPOINT_RESTORE", 40), ]; -/// Only the nic zone may keep these (`policy::check_for_zone`): another zone could use them to -/// re-address its veth or forge frames. -pub const NIC_ONLY: &[libc::c_int] = &[cap::NET_ADMIN, cap::NET_RAW]; +/// Only the nic zone may keep these (`policy::check_for_zone`): another zone could use the +/// network ones to re-address its veth or forge frames, and only dhcpcd there needs `PRIVSEP`. +pub const NIC_ONLY: &[libc::c_int] = &[cap::NET_ADMIN, cap::NET_RAW, cap::SETUID, cap::SETGID, cap::SYS_CHROOT]; pub fn cap_by_name(name: &str) -> Option { CAP_NAMES.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) diff --git a/compartments/kryptikd/src/caps/tests.rs b/compartments/kryptikd/src/caps/tests.rs index 8637ad81..e37667bd 100644 --- a/compartments/kryptikd/src/caps/tests.rs +++ b/compartments/kryptikd/src/caps/tests.rs @@ -23,6 +23,21 @@ fn keep_is_not_dangerous() { } } +#[test] +fn privsep_is_the_nic_zones_alone() { + assert_eq!(PRIVSEP, &[cap::SETUID, cap::SETGID, cap::SYS_CHROOT]); + for c in PRIVSEP { + assert!(KEEPABLE.contains(c) && NIC_ONLY.contains(c), "{}", cap_name(*c)); + } + assert_eq!(cap_by_name("CAP_SYS_CHROOT"), Some(cap::SYS_CHROOT)); + assert!(keeps_privsep(&[cap::NET_ADMIN, cap::SYS_CHROOT, cap::SETGID, cap::SETUID])); + assert!(!keeps_privsep(&[cap::SETUID, cap::SETGID])); + // Nothing else comes with them: the dangerous ones stay unkeepable. + for c in [cap::SYS_ADMIN, cap::SYS_PTRACE, cap::DAC_OVERRIDE, cap::SYS_MODULE, cap::MKNOD] { + assert!(!KEEPABLE.contains(&c), "{}", cap_name(c)); + } +} + #[test] fn last_cap_is_sane() { let n = last_cap(); diff --git a/compartments/kryptikd/src/isolate.rs b/compartments/kryptikd/src/isolate.rs index 98436cd5..77c132e9 100644 --- a/compartments/kryptikd/src/isolate.rs +++ b/compartments/kryptikd/src/isolate.rs @@ -72,22 +72,33 @@ pub fn unshare_namespaces(flags: libc::c_int) -> Result<(), IsolateError> { check("unshare", unsafe { libc::unshare(flags) }) } +/// The id a daemon in a zone that keeps `caps::PRIVSEP` drops to: dhcpcd's, in the nic zone. +pub const SERVICE_ID: u32 = 100; + /// Write a new user namespace's id maps, denying setgroups first as an unprivileged gid_map -/// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. +/// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. `service` +/// maps `SERVICE_ID` as well and leaves setgroups allowed, for that daemon's drop: a root launch +/// of a zone that keeps `caps::PRIVSEP` only. pub fn write_id_maps( pid: libc::pid_t, outer_uid: u32, outer_gid: u32, with_nobody: bool, + service: bool, ) -> Result<(), IsolateError> { use std::fs; - let deny = format!("/proc/{pid}/setgroups"); - fs::write(&deny, "deny") - .map_err(|e| IsolateError::Refused(format!("{deny}: {e}")))?; + if !service { + let deny = format!("/proc/{pid}/setgroups"); + fs::write(&deny, "deny") + .map_err(|e| IsolateError::Refused(format!("{deny}: {e}")))?; + } let map = |outer: u32| { let mut m = format!("0 {outer} 1\n"); + if service { + m.push_str(&format!("{SERVICE_ID} {} 1\n", outer + SERVICE_ID)); + } if with_nobody { m.push_str(&format!("65534 {} 1\n", outer + 65534)); } diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 464c9ebd..c31f6e9e 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -1148,7 +1148,7 @@ const fn seccomp_iowr(nr: u32, size: usize) -> libc::c_ulong { const NOTIF_RECV: libc::c_ulong = seccomp_iowr(0, std::mem::size_of::()); const NOTIF_SEND: libc::c_ulong = seccomp_iowr(1, std::mem::size_of::()); -/// The filter zone `name` runs under: the base, widened by its policy file. +/// The filter zone `name` runs under: the base, widened by its policy file and kept capabilities. fn trace_filter(dir: &Path, name: &str) -> Result<(Vec, seccomp::SocketPolicy), String> { let zones = zone::load_all(dir).map_err(|e| e.to_string())?; let z = zones.iter().find(|z| z.name == name).ok_or_else(|| format!("no zone named {name:?}"))?; @@ -1158,7 +1158,7 @@ fn trace_filter(dir: &Path, name: &str) -> Result<(Vec, seccomp::S let p = policy::load(&policy::resolve(dir, rel)) .and_then(|p| p.check_for_zone(z).map(|_| p)) .map_err(|e| format!("{rel}: {e}"))?; - Ok((seccomp::widened(&p.extra_syscalls).map_err(|e| e.to_string())?, p.sockets)) + Ok((seccomp::widened_for(&p.extra_syscalls, &p.keep_caps).map_err(|e| e.to_string())?, p.sockets)) } /// Run CMD under a zone filter, naming every call it refuses. Refusals arrive by user diff --git a/compartments/kryptikd/src/policy.rs b/compartments/kryptikd/src/policy.rs index 14025146..ec096eb8 100755 --- a/compartments/kryptikd/src/policy.rs +++ b/compartments/kryptikd/src/policy.rs @@ -154,12 +154,25 @@ pub fn parse(text: &str, source: &str) -> Result { } } } + // One of them alone opens its calls and serves no daemon's drop to its own user. + let privsep = caps::PRIVSEP.iter().filter(|c| p.keep_caps.contains(c)).count(); + if privsep != 0 && privsep != caps::PRIVSEP.len() { + return Err(PolicyError::Line { + path: source.to_string(), + line: 0, + msg: format!( + "{} are kept together or not at all", + caps::PRIVSEP.iter().map(|c| caps::cap_name(*c)).collect::>().join(", ") + ), + }); + } Ok(p) } impl Policy { - /// Only the nic zone may keep `CAP_NET_ADMIN` or `CAP_NET_RAW`; elsewhere they let a zone - /// re-address its veth, route around port isolation via the bridge address, or forge frames. + /// Only the nic zone may keep `caps::NIC_ONLY`: elsewhere `CAP_NET_ADMIN` or `CAP_NET_RAW` let a + /// zone re-address its veth, route around port isolation via the bridge address, or forge + /// frames, and no other zone runs a daemon that drops to a user of its own. pub fn check_for_zone(&self, zone: &crate::zone::Zone) -> Result<(), PolicyError> { if zone.network != crate::zone::NetworkMode::Nic { for (c, name) in self.keep_caps.iter().zip(&self.keep_cap_names) { diff --git a/compartments/kryptikd/src/policy/tests.rs b/compartments/kryptikd/src/policy/tests.rs index 82064bd2..3981fe15 100644 --- a/compartments/kryptikd/src/policy/tests.rs +++ b/compartments/kryptikd/src/policy/tests.rs @@ -52,7 +52,7 @@ fn errors_carry_line_number() { #[test] fn dangerous_capabilities_cannot_be_kept() { - for name in ["CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_DAC_OVERRIDE", "CAP_SETUID", "CAP_SYS_MODULE", "CAP_MKNOD"] { + for name in ["CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_DAC_OVERRIDE", "CAP_SETPCAP", "CAP_SYS_MODULE", "CAP_MKNOD"] { let err = parse(&format!("keep-capability {name}\n"), "t").unwrap_err(); assert!(err.to_string().contains("cannot be kept"), "{name}: {err}"); } @@ -87,11 +87,31 @@ fn only_nic_zone_keeps_net_caps() { assert!(e.to_string().contains("owns the NIC"), "{cap}: {e}"); assert!(p.check_for_zone(&z("none")).is_err()); } + // dhcpcd's three, for its drop to a user of its own, likewise. + let p = parse("keep-capability CAP_SETUID\nkeep-capability CAP_SETGID\nkeep-capability CAP_SYS_CHROOT\n", "t").unwrap(); + assert!(crate::caps::keeps_privsep(&p.keep_caps)); + assert!(p.check_for_zone(&z("nic")).is_ok()); + assert!(p.check_for_zone(&z("routed")).unwrap_err().to_string().contains("owns the NIC")); + assert!(p.check_for_zone(&z("none")).is_err()); // Other keepable capabilities are not mode-restricted. let p = parse("keep-capability CAP_SYS_NICE\n", "t").unwrap(); assert!(p.check_for_zone(&z("routed")).is_ok()); } +#[test] +fn privsep_capabilities_kept_together() { + for text in [ + "keep-capability CAP_SETUID\n", + "keep-capability CAP_SETGID\nkeep-capability CAP_SYS_CHROOT\n", + "keep-capability CAP_SYS_CHROOT\nkeep-capability CAP_NET_ADMIN\n", + ] { + let err = parse(text, "t").unwrap_err(); + assert!(err.to_string().contains("kept together or not at all"), "{text:?}: {err}"); + } + let p = parse("keep-capability CAP_SYS_CHROOT\nkeep-capability CAP_SETGID\nkeep-capability CAP_SETUID\n", "t").unwrap(); + assert_eq!(p.keep_caps.len(), 3); +} + #[test] fn af_netlink_lifts_protocol_check() { let p = parse("allow-socket AF_NETLINK\n", "t").unwrap(); diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index ef9f44fd..2affc055 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -198,16 +198,28 @@ pub fn zone_home(zone: &str) -> String { format!("/home/{zone}") } -/// Synthesized /etc/passwd: the zone's root and nobody. -pub fn passwd_for(zone: &str, home: &str) -> String { - format!( - "root:x:0:0:{zone}:{home}:/bin/sh\n\ - nobody:x:65534:65534:nobody:/nonexistent:/bin/false\n" - ) +/// The home and chroot of the user `service` adds: an empty directory on the sealed root. +pub const SERVICE_HOME: &str = "/var/empty"; + +/// Synthesized /etc/passwd: the zone's root and nobody, and with `service` dhcpcd at +/// `isolate::SERVICE_ID`, the user its privilege separation drops to. +pub fn passwd_for(zone: &str, home: &str, service: bool) -> String { + let mut s = format!("root:x:0:0:{zone}:{home}:/bin/sh\n"); + if service { + let id = crate::isolate::SERVICE_ID; + s.push_str(&format!("dhcpcd:x:{id}:{id}:dhcpcd:{SERVICE_HOME}:/bin/false\n")); + } + s.push_str("nobody:x:65534:65534:nobody:/nonexistent:/bin/false\n"); + s } -pub fn group_for() -> String { - "root:x:0:\nnogroup:x:65534:\n".to_string() +pub fn group_for(service: bool) -> String { + let mut s = "root:x:0:\n".to_string(); + if service { + s.push_str(&format!("dhcpcd:x:{}:\n", crate::isolate::SERVICE_ID)); + } + s.push_str("nogroup:x:65534:\n"); + s } pub fn nsswitch() -> String { @@ -280,7 +292,9 @@ pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> } /// Pivot into a root holding only what the zone should see; returns the home. Runs as root in the -/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size. +/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, and +/// `service` adds dhcpcd's user and `SERVICE_HOME`. +#[allow(clippy::too_many_arguments)] pub fn pivot_into( data_dir: &str, zone: &str, @@ -289,6 +303,7 @@ pub fn pivot_into( broker: Option<&str>, wayland: Option<&str>, wifi_conf: Option<&str>, + service: bool, ) -> Result { let home = zone_home(zone); @@ -348,7 +363,11 @@ pub fn pivot_into( } } - populate_etc(root, zone, &home, resolver)?; + populate_etc(root, zone, &home, resolver, service)?; + // Made on the root tmpfs, so the seal below leaves it empty and read-only. + if service { + mkdir(&SERVICE_HOME[1..])?; + } // Fresh /proc, showing only this zone's pid namespace. let proc_dir = mkdir("proc")?; @@ -507,15 +526,15 @@ pub fn pivot_into( } /// The zone's /etc: synthesized identity files plus `ETC_RO_FILES` and `ETC_RO_DIRS`. -fn populate_etc(root: &str, zone: &str, home: &str, resolver: Resolver) -> Result<(), RootfsError> { +fn populate_etc(root: &str, zone: &str, home: &str, resolver: Resolver, service: bool) -> Result<(), RootfsError> { let etc = format!("{root}/etc"); fs::create_dir_all(&etc).map_err(|e| RootfsError::Setup(format!("{etc}: {e}")))?; let write = |name: &str, content: String| -> Result<(), RootfsError> { let p = format!("{etc}/{name}"); fs::write(&p, content).map_err(|e| RootfsError::Setup(format!("{p}: {e}"))) }; - write("passwd", passwd_for(zone, home))?; - write("group", group_for())?; + write("passwd", passwd_for(zone, home, service))?; + write("group", group_for(service))?; write("nsswitch.conf", nsswitch())?; write("hosts", hosts_for(zone))?; write("hostname", format!("{zone}\n"))?; diff --git a/compartments/kryptikd/src/rootfs/tests.rs b/compartments/kryptikd/src/rootfs/tests.rs index 8f88ec05..6b4fb69a 100644 --- a/compartments/kryptikd/src/rootfs/tests.rs +++ b/compartments/kryptikd/src/rootfs/tests.rs @@ -90,9 +90,16 @@ fn bridge_resolver() { #[test] fn identity_names_zone() { - let pw = passwd_for("work", "/home/work"); + let pw = passwd_for("work", "/home/work", false); assert!(pw.starts_with("root:x:0:0:work:/home/work:"), "{pw}"); assert_eq!(pw.lines().count(), 2); + assert_eq!(group_for(false).lines().count(), 2); + // The nic zone's dhcpcd drops to the third mapped id, chrooted to an empty directory. + let pw = passwd_for("net", "/home/net", true); + assert_eq!(pw.lines().count(), 3); + assert!(pw.contains("\ndhcpcd:x:100:100:dhcpcd:/var/empty:/bin/false\n")); + assert!(group_for(true).contains("\ndhcpcd:x:100:\n")); + assert_eq!(crate::isolate::SERVICE_ID, 100); assert!(hosts_for("work").contains("127.0.0.1 localhost work")); assert_eq!(zone_home("work"), "/home/work"); assert!(nsswitch().contains("passwd: files")); diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index fe667119..be3daf2c 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -298,7 +298,8 @@ syscalls! { } denied! { - /// Syscalls left out of the allowlist, and why; a zone policy cannot allow them. + /// Syscalls left out of the allowlist, and why; a zone policy cannot allow them. The one + /// exception is `CAP_CALLS`: a zone that keeps `caps::PRIVSEP` gets the four calls it serves. DENIED_RATIONALE, DENIED_NAMES = [ (libc::SYS_ptrace, "read/write another process's memory; the classic escape"), (libc::SYS_process_vm_readv, "read another process's memory directly"), @@ -661,9 +662,34 @@ pub fn confine_zone() -> Result<(), SeccompError> { install(BASE_ALLOWLIST) } -/// Install the zone filter widened by a policy's `extra` syscalls and `sockets` rule. -pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy) -> Result<(), SeccompError> { - install_with(&widened(extra)?, SECCOMP_RET_KILL_PROCESS, sockets, SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) +/// Install the zone filter widened by a policy's `extra` syscalls, `sockets` rule and kept +/// capabilities. +pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy, kept_caps: &[libc::c_int]) -> Result<(), SeccompError> { + install_with(&widened_for(extra, kept_caps)?, SECCOMP_RET_KILL_PROCESS, sockets, SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) +} + +/// Denied calls a kept capability opens again, for the one zone that may keep it +/// (`caps::PRIVSEP`, the nic zone's): a daemon dropping to its own user makes them, and in the +/// zone's user and mount namespaces they reach only its mapped ids and its own tree. +pub const CAP_CALLS: &[(libc::c_int, &[libc::c_long])] = &[ + (crate::caps::cap::SETUID, &[libc::SYS_setuid]), + (crate::caps::cap::SETGID, &[libc::SYS_setgid, libc::SYS_setgroups]), + (crate::caps::cap::SYS_CHROOT, &[libc::SYS_chroot]), +]; + +/// `widened`, plus the calls `CAP_CALLS` gives the capabilities in `kept_caps`. +pub fn widened_for(extra: &[libc::c_long], kept_caps: &[libc::c_int]) -> Result, SeccompError> { + let mut allow = widened(extra)?; + for &(cap, calls) in CAP_CALLS { + if kept_caps.contains(&cap) { + for &nr in calls { + if !allow.contains(&nr) { + allow.push(nr); + } + } + } + } + Ok(allow) } /// The base allowlist plus a policy's `extra` syscalls, each checked again against the denied list. diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index 553b5a8c..67c3120c 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -463,6 +463,31 @@ fn arg_rules_leave_allowlist_alone() { } } +#[test] +fn kept_capabilities_open_their_calls() { + let calls = [libc::SYS_setuid, libc::SYS_setgid, libc::SYS_setgroups, libc::SYS_chroot]; + // Kept by no zone, nothing changes. + assert_eq!(widened_for(&[], &[]).unwrap(), widened(&[]).unwrap()); + let all = widened_for(&[], crate::caps::PRIVSEP).unwrap(); + for nr in calls { + assert!(is_denied(nr) && !BASE_ALLOWLIST.contains(&nr), "{nr} is denied to every other zone"); + assert!(all.contains(&nr), "{nr} is opened by its capability"); + } + assert_eq!(all.len(), BASE_ALLOWLIST.len() + calls.len()); + // Each capability opens its own calls and no other. + let chroot = widened_for(&[], &[crate::caps::cap::SYS_CHROOT]).unwrap(); + assert!(chroot.contains(&libc::SYS_chroot) && !chroot.contains(&libc::SYS_setuid) && !chroot.contains(&libc::SYS_setgroups)); + let p = build_program(&all).unwrap(); + for nr in calls { + assert_eq!(evaluate(&p, X86, nr as u32), SECCOMP_RET_ALLOW, "{nr}"); + } + // Every other zone's program still refuses them as before. + let base = build_program(BASE_ALLOWLIST).unwrap(); + assert_eq!(evaluate(&base, X86, libc::SYS_setuid as u32), errno_action(EPERM)); + assert_eq!(evaluate(&base, X86, libc::SYS_setgroups as u32), errno_action(EPERM)); + assert_eq!(evaluate(&base, X86, libc::SYS_chroot as u32), SECCOMP_RET_KILL_PROCESS); +} + #[test] fn widened_refuses_denied() { let e = widened(&[libc::SYS_ptrace]).unwrap_err(); diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 0957735d..86d99d99 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -952,7 +952,7 @@ pub fn run_in_zone( } // Map the child's root to the zone identity: this is the privilege drop. - if let Err(e) = isolate::write_id_maps(pid, id.uid, id.gid, id.privileged) { + if let Err(e) = isolate::write_id_maps(pid, id.uid, id.gid, id.privileged, maps_service(id.privileged, zone_policy.as_ref())) { // Close our end so the child reads EOF and dies rather than blocking. mapped.close_write(); let _ = wait_for(pid); @@ -1248,7 +1248,8 @@ fn intermediate_main( if inner == 0 { alive.close_write(); - let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, &alive); + let service = maps_service(id.privileged, zone_policy); + let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, service, &alive); unsafe { libc::_exit(rc) }; } alive.close_read(); @@ -1269,6 +1270,11 @@ fn intermediate_main( } } +/// Whether a launch maps dhcpcd's user: a root launch of a zone that keeps `caps::PRIVSEP`. +fn maps_service(privileged: bool, policy: Option<&policy::Policy>) -> bool { + privileged && policy.is_some_and(|p| caps::keeps_privsep(&p.keep_caps)) +} + /// pid 1 of the zone. Returns only on failure; on success it has exec'd. fn zone_init( zone: &Zone, @@ -1281,6 +1287,7 @@ fn zone_init( broker_path: &str, wayland_path: Option<&str>, wifi_conf: Option<&str>, + service: bool, alive: &SyncPipe, ) -> i32 { macro_rules! bail { @@ -1313,7 +1320,7 @@ fn zone_init( (crate::zone::NetworkMode::Routed, true) => rootfs::Resolver::Bridge, _ => rootfs::Resolver::None, }; - let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf) { + let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, service) { Ok(h) => h, Err(e) => bail!("could not build the zone root: {e}"), }; @@ -1364,7 +1371,7 @@ fn zone_init( // seccomp last: mount() and the other setup calls are not in its allowlist. let installed = match zone_policy { - Some(p) => seccomp::confine_zone_with(&p.extra_syscalls, &p.sockets), + Some(p) => seccomp::confine_zone_with(&p.extra_syscalls, &p.sockets, &p.keep_caps), None => seccomp::confine_zone(), }; if let Err(e) = installed { diff --git a/compartments/zones/net.toml b/compartments/zones/net.toml index 9d35d5dc..3435411e 100644 --- a/compartments/zones/net.toml +++ b/compartments/zones/net.toml @@ -23,7 +23,8 @@ memory_max = "1G" pids_max = 256 [identity] -# Root inside maps to host uid/gid 196608, nobody to 262142; fixed, never derived from zone order. +# Root inside maps to host uid/gid 196608, dhcpcd's user (100) to 196708 and nobody to 262142; +# fixed, never derived from zone order. uid_base = 196608 [ui] diff --git a/compartments/zones/policy/net.seccomp b/compartments/zones/policy/net.seccomp index 12dd8f77..3e4a16be 100644 --- a/compartments/zones/policy/net.seccomp +++ b/compartments/zones/policy/net.seccomp @@ -5,3 +5,8 @@ keep-capability CAP_NET_ADMIN keep-capability CAP_NET_RAW # dhcpcd exits if refused NETLINK_GENERIC (nl80211 events), leaving routed zones no uplink. allow-netlink NETLINK_GENERIC +# dhcpcd's privilege separation: its parsers drop to the dhcpcd user, chrooted to +# /var/empty, and only a small helper stays root (kept together or not at all). +keep-capability CAP_SETUID +keep-capability CAP_SETGID +keep-capability CAP_SYS_CHROOT diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 116ac602..efb31ae4 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -80,11 +80,13 @@ query and the [update](update-channel.md) fetcher. Builds on routed zone's data, no broker access beyond its own clipboard and the time and update verbs, and ephemeral storage. Its seccomp policy is the base one plus `policy/net.seccomp`: `AF_PACKET`, `NETLINK_NETFILTER`, - `NETLINK_GENERIC` (dhcpcd opens one for nl80211 and exits if refused), and - `CAP_NET_ADMIN` / `CAP_NET_RAW` over its own interfaces. `chown`, which - dhcpcd calls on its control socket, is in the base list. The net zone alone - gets private tmpfs mounts at `/run` and `/var/lib` (writable under Landlock, - no exec), where dhcpcd keeps its pid file, control socket and leases. Every + `NETLINK_GENERIC` (dhcpcd opens one for nl80211 and exits if refused), + `CAP_NET_ADMIN` / `CAP_NET_RAW` over its own interfaces, and `CAP_SETUID`, + `CAP_SETGID` and `CAP_SYS_CHROOT` for dhcpcd's privilege separation. + `chown`, which dhcpcd calls on its control socket, is in the base list. + The net zone alone gets private tmpfs mounts at `/run` and `/var/lib` + (writable under Landlock, no exec), where dhcpcd keeps its pid file, + control socket and leases. Every other zone's `/run` is read-only and holds only its broker and proxy sockets. @@ -113,11 +115,23 @@ query and the [update](update-channel.md) fetcher. Builds on change replaces the file and sends SIGHUP. A lease that lapsed leaves the last servers in place. It answers the test TLD `.test` itself, so resolving `kryptik.test` tests the path to the resolver, not the internet. -- **dhcpcd runs without its own privilege separation.** That needs - `setgroups`, which the zone denies, a `dhcpcd` user, which its synthesized - passwd lacks, and `CAP_SETUID`, `CAP_SETGID` and `CAP_SYS_CHROOT`. Giving - the hostile zone three capabilities so one program can build a smaller - sandbox inside it would be a net loss; the zone is the sandbox. +- **dhcpcd separates its privileges.** What parses a lease, a DHCPv6 reply + or a router advertisement runs as the zone's `dhcpcd` user, chrooted to an + empty `/var/empty`, with no capability and dhcpcd's own seccomp filter over + the zone's; a small helper stays the zone's root. For that the net zone + keeps `CAP_SETUID`, `CAP_SETGID` and `CAP_SYS_CHROOT` (kept together, and + by the nic zone alone), its filter allows the four calls they serve + (`setuid`, `setgid`, `setgroups`, `chroot`; `seccomp::CAP_CALLS`), its + user namespace maps a third id, 100, to `uid_base` + 100 and allows + `setgroups`, and its passwd names the user. In the zone's own namespaces + these reach only its mapped ids and its own tree. + The helper still does for the parsers what dhcpcd needs: addresses, routes + and links over netlink, the net sysctls, and dhcpcd's own files. It also + runs the hook, with the environment the parsers send it, so the net zone + does not use dhcpcd's: `dhcpcd-hook` checks every value for form and writes + nothing but `nameserver` lines (`tools/tests/netzone-hook.sh`). A bug in a + parser no longer reaches the Wi-Fi credentials, the broker's socket, the + firewall's netlink socket or a program to run. - **Readiness**, printed again on any change: ```text diff --git a/docs/design/zone-policy-files.md b/docs/design/zone-policy-files.md index 1b83dd49..1ddff2fc 100755 --- a/docs/design/zone-policy-files.md +++ b/docs/design/zone-policy-files.md @@ -23,6 +23,9 @@ keep-capability CAP_NET_RAW # left in the bounding set cannot be re-allowed, one on the base allowlist is reported as already allowed, and any other name is an error. The id and capability calls and `unshare` on the denied list fail with EPERM instead of killing the caller. + The one way back for a denied call is a kept capability: `CAP_SETUID`, + `CAP_SETGID` and `CAP_SYS_CHROOT` open `setuid`, `setgid` and `setgroups`, + and `chroot` (`seccomp::CAP_CALLS`). - `allow-socket`: `AF_PACKET`, `AF_KEY`, `AF_ALG`, `AF_VSOCK`, `AF_BLUETOOTH`, `AF_CAN`, `AF_RDS`, `AF_TIPC` or `AF_XDP`; `AF_NETLINK` drops the netlink protocol check. `socketpair(2)` stays `AF_UNIX` only whatever the file @@ -33,11 +36,14 @@ keep-capability CAP_NET_RAW # left in the bounding set `CAP_NET_BIND_SERVICE`, which every zone keeps. `CAP_NET_ADMIN` and `CAP_NET_RAW` are accepted only for the `network.mode = "nic"` zone (`Policy::check_for_zone`): with either, a routed zone could re-address its - veth or forge frames. The chown, chmod and xattr calls are in the base - list, so keeping `CAP_CHOWN`, `CAP_FOWNER` or `CAP_FSETID` takes effect - with no `allow-syscall` line. A zone's user namespace maps only its root - and nobody, so the most such a zone can do is move its own files between - those two. + veth or forge frames. So are `CAP_SETUID`, `CAP_SETGID` and + `CAP_SYS_CHROOT`, which are kept together or not at all (`caps::PRIVSEP`): + they let dhcpcd drop to a user of its own, and a zone that keeps them also + gets that user, id 100, mapped and named in its passwd. The chown, chmod + and xattr calls are in the base list, so keeping `CAP_CHOWN`, `CAP_FOWNER` + or `CAP_FSETID` takes effect with no `allow-syscall` line. A zone's user + namespace maps only its root and nobody (and in the nic zone dhcpcd's + user), so the most such a zone can do is move its own files between those. To find what a program needs, run it under the base filter with `kryptikd seccomp-trace -- CMD [ARGS]`. Each call the filter would kill the program for @@ -72,7 +78,7 @@ zone names a policy file, and only `net.seccomp` adds anything. `kryptikd explain` prints the additions: ```text -policy policy/net.seccomp: socket AF_PACKET, netlink NETLINK_NETFILTER, netlink NETLINK_GENERIC, keep CAP_NET_ADMIN, keep CAP_NET_RAW +policy policy/net.seccomp: socket AF_PACKET, netlink NETLINK_NETFILTER, netlink NETLINK_GENERIC, keep CAP_NET_ADMIN, keep CAP_NET_RAW, keep CAP_SETUID, keep CAP_SETGID, keep CAP_SYS_CHROOT ``` ## Landlock policy files diff --git a/docs/status.md b/docs/status.md index 9ffea50d..bbc69167 100644 --- a/docs/status.md +++ b/docs/status.md @@ -64,8 +64,6 @@ media it tested. RPATHs among them. Each has a reason in `build/config/artifact-accepted.txt`, and acceptance fails on any other. Each run's audit log has the counts. -- dhcpcd runs without its own privilege separation; the net zone is its - sandbox. - The builds are not reproducible bit for bit. - A resumed tree builds a changed step again over what its old version installed, and nothing records what a step installed. In CI a new package diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 7118396b..1d4027d0 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -65,7 +65,7 @@ zp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; zf="$(sed -n 's/.*f if [[ -n "$summary" && "${zf:-1}" -eq 0 && "${zp:-0}" -ge 30 ]]; then green "every guest check passed (${zp})"; else red "guest checks: ${zp:-0} passed, ${zf:-?} failed"; fi 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 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 \ +for name in kernel-support policies net-ready net-dns dhcpcd-separated 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 \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ 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 \ terminal-terminfo man-page text-browser tls-trust \ diff --git a/tools/net/dhcpcd-hook.py b/tools/net/dhcpcd-hook.py new file mode 100755 index 00000000..1739e946 --- /dev/null +++ b/tools/net/dhcpcd-hook.py @@ -0,0 +1,134 @@ +#!/usr/bin/python3 -I +"""dhcpcd's hook in the net zone: the uplinks' name servers into the zone's resolv.conf. + +dhcpcd's privileged helper runs it as root with the environment the unprivileged +side sends, so nothing in that environment is trusted: each value is checked for +form, the state names only an interface and a protocol that pass, and the one +file written holds nothing but nameserver lines. Paths come from the command +line, which dhcpcd never fills, so the tests can move them and a lease cannot. + + dhcpcd-hook [--state DIR] [--out FILE] +""" +import ipaddress +import os +import re +import sys +import tempfile + +STATE = "/run/lease-dns" +OUT = "/tmp/resolv.conf" +IFACE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,14}\Z") +PROTOCOLS = ("dhcp", "dhcp6", "ra", "ipv4ll", "link", "static", "static6") +PER_SOURCE = 8 +IN_ALL = 16 + + +def address(token, iface): + """An IP literal, or None; a scope may only name the interface itself.""" + text, _, scope = token.partition("%") + if scope and scope != iface: + return None + try: + addr = ipaddress.ip_address(text) + except ValueError: + return None + if addr.is_unspecified or addr.is_multicast: + return None + return str(addr) + ("%" + scope if scope else "") + + +def servers(env, iface): + """The servers this event names, in order, each checked.""" + found = [] + words = env.get("new_domain_name_servers", "").split() + env.get("new_dhcp6_name_servers", "").split() + # Router advertisements: nd_rdnss_servers, kept while their lifetime runs. + for i in range(1, 9): + for j in range(1, 9): + listed = env.get("nd%d_rdnss%d_servers" % (i, j)) + if listed is None: + continue + life = env.get("nd%d_rdnss%d_lifetime" % (i, j), "0") + # ASCII digits only: isdigit() also takes "²", which int() refuses. + if re.fullmatch(r"[0-9]{1,10}", life) and int(life) > 0: + words += listed.split() + for w in words[:4 * PER_SOURCE]: + a = address(w, iface) + if a is not None and a not in found: + found.append(a) + return found[:PER_SOURCE] + + +def write_state(state, key, addrs): + os.makedirs(state, mode=0o700, exist_ok=True) + path = os.path.join(state, key) + if not addrs: + try: + os.unlink(path) + except FileNotFoundError: + pass + return + fd, tmp = tempfile.mkstemp(dir=state, prefix=".new.") + with os.fdopen(fd, "w") as f: + f.write("".join(a + "\n" for a in addrs)) + os.replace(tmp, path) + + +def rebuild(state, out): + """resolv.conf from every source's file, each line checked again.""" + names = [] + try: + keys = sorted(os.listdir(state)) + except FileNotFoundError: + keys = [] + for key in keys: + iface, _, proto = key.rpartition(".") + if not IFACE.match(iface) or proto not in PROTOCOLS: + continue + try: + fd = os.open(os.path.join(state, key), os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + continue + with os.fdopen(fd) as f: + lines = f.read(4096).split("\n") + for line in lines[:PER_SOURCE]: + a = address(line.strip(), iface) + if a is not None and a not in names: + names.append(a) + names = names[:IN_ALL] + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix=".resolv.") + with os.fdopen(fd, "w") as f: + f.write("".join("nameserver %s\n" % a for a in names)) + os.chmod(tmp, 0o644) + os.replace(tmp, out) + + +def main(argv, env): + state, out = STATE, OUT + args = argv[1:] + while args: + if args[0] == "--state" and len(args) > 1: + state = args[1] + elif args[0] == "--out" and len(args) > 1: + out = args[1] + else: + print("usage: dhcpcd-hook [--state DIR] [--out FILE]", file=sys.stderr) + return 2 + args = args[2:] + iface = env.get("interface", "") + proto = env.get("protocol", "") + reason = env.get("reason", "") + if not IFACE.match(iface) or proto not in PROTOCOLS: + return 0 + key = "%s.%s" % (iface, proto) + if env.get("if_up") == "true" or reason == "ROUTERADVERT": + write_state(state, key, servers(env, iface)) + elif env.get("if_down") == "true": + write_state(state, key, []) + else: + return 0 + rebuild(state, out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv, dict(os.environ))) diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 7698fe6c..739a2a74 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -221,10 +221,10 @@ uplink_addr() { # the first IPv4 address any uplink holds } if command -v dhcpcd >/dev/null 2>&1; then mkdir -p /run/dhcpcd /var/lib/dhcpcd 2>/dev/null # the zone's own tmpfs mounts - # -b: background and retry; --nodev: no device manager; its hook writes the zone's resolv.conf. - if dhcpcd -b -q --nodev "$@" 2>/tmp/dhcpcd.err; then - # The zone has no dhcpcd user, so dhcpcd runs as its root, sandboxed by the zone. - grep -v 'no such user dhcpcd' /tmp/dhcpcd.err + # -b: background and retry; --nodev: no device manager. Its parsers drop to the dhcpcd user; + # Kryptik's hook, which trusts nothing it is handed, writes the zone's resolv.conf. + if dhcpcd -b -q --nodev -c /usr/libexec/kryptik/dhcpcd-hook "$@" 2>/tmp/dhcpcd.err; then + cat /tmp/dhcpcd.err # Up to 15 s for a lease, so the resolver starts with its servers. i=0 while [ "$i" -lt 30 ] && [ -z "$(uplink_addr "$@")" ]; do sleep 0.5; i=$((i+1)); done @@ -368,7 +368,6 @@ cleanup() { [ -n "$DNSPID" ] && kill "$DNSPID" 2>/dev/null [ -n "$UPDATE_PID" ] && kill "$UPDATE_PID" 2>/dev/null for n in $WIRELESS; do p="$(wpa_pid "$n")"; [ -n "$p" ] && kill "$p" 2>/dev/null; done - command -v dhcpcd >/dev/null 2>&1 && dhcpcd -x 2>/dev/null exit 0 } trap cleanup TERM INT diff --git a/tools/tests/netzone-hook.sh b/tools/tests/netzone-hook.sh new file mode 100755 index 00000000..07940a67 --- /dev/null +++ b/tools/tests/netzone-hook.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Tests for dhcpcd's hook in the net zone (tools/net/dhcpcd-hook.py): the +# servers a lease, a DHCPv6 reply or a router advertisement names reach the +# zone's resolv.conf, a lease that ends takes them away, and nothing else that +# dhcpcd's unprivileged side could put in the environment reaches a file. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HOOK="${ROOT}/tools/net/dhcpcd-hook.py" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +same() { if [[ "$2" == "$3" ]]; then green "$1"; else red "$1" "got '$(tr '\n' '|' <<<"$2")', want '$(tr '\n' '|' <<<"$3")'"; fi; } +command -v python3 >/dev/null 2>&1 || { echo "no python3 here"; exit 77; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +mkdir -p "$T/state" "$T/tmp" + +# hook VAR=VALUE...: one event, as dhcpcd's helper runs it, with nothing else +# in the environment; prints the resolv.conf it leaves. +hook() { + env -i "$@" python3 -I "$HOOK" --state "$T/state" --out "$T/tmp/resolv.conf" + cat "$T/tmp/resolv.conf" 2>/dev/null +} + +echo "-- what a network names" +same "a lease's servers" \ + "$(hook interface=eth0 protocol=dhcp reason=BOUND if_up=true new_domain_name_servers='10.0.2.3 192.0.2.53')" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53')" +same "a router advertisement's, beside them, while its lifetime runs" \ + "$(hook interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers='fec0::3' nd1_rdnss1_lifetime=3600)" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53\nnameserver fec0::3')" +same "a lifetime of 0 takes it away" \ + "$(hook interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers='fec0::3' nd1_rdnss1_lifetime=0)" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53')" +same "a lease that ends takes its servers with it" \ + "$(hook interface=eth0 protocol=dhcp reason=EXPIRE if_down=true)" "" +same "a link-local server keeps its scope only when it names the interface" \ + "$(hook interface=wlan0 protocol=dhcp6 reason=BOUND6 if_up=true new_dhcp6_name_servers='fe80::1%wlan0 fe80::2%eth9 2001:db8::53')" \ + "$(printf 'nameserver fe80::1%%wlan0\nnameserver 2001:db8::53')" +hook interface=wlan0 protocol=dhcp6 reason=STOP6 if_down=true > /dev/null + +echo "-- what the unprivileged side could send instead" +same "words that are not addresses are dropped, and with them a second line" \ + "$(hook interface=eth0 protocol=dhcp reason=BOUND if_up=true new_domain_name_servers=$'10.0.2.3;\nsearch evil.example\n192.0.2.7 0.0.0.0 224.0.0.1')" \ + "nameserver 192.0.2.7" +before="$(cat "$T/tmp/resolv.conf")" +same "an interface name that is a path writes nothing" \ + "$(hook interface=../../etc protocol=dhcp reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" +same "a protocol not on the list writes nothing" \ + "$(hook interface=eth0 protocol=../x reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" +# "²", which str.isdigit() takes and int() refuses, as UTF-8 bytes whatever the locale. +env -i interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers=fec0::9 nd1_rdnss1_lifetime=$'\xc2\xb2' \ + python3 -I "$HOOK" --state "$T/state" --out "$T/tmp/resolv.conf" 2> "$T/err"; rc=$? +same "a lifetime that is not plain digits is no lifetime, and the hook does not fall over on it" \ + "rc=${rc} $(cat "$T/tmp/resolv.conf") $(head -1 "$T/err")" "rc=0 ${before} " +same "an event that is neither up nor down changes nothing" \ + "$(hook interface=eth0 protocol=dhcp reason=PREINIT new_domain_name_servers=192.0.2.66)" "$before" +[[ "$(ls -A "$T/state")" == "eth0.dhcp" ]] && green "the state holds one file per interface and protocol that passed, and no other" \ + || red "files in the state" "$(ls -A "$T/state" | tr '\n' ' ')" +printf '192.0.2.99\nnameserver 1.1.1.1\n../../x\n' > "$T/state/eth0.static" +same "a state file is checked again when read: a line that is not an address is no server" \ + "$(hook interface=eth0 protocol=dhcp reason=RENEW if_up=true new_domain_name_servers=192.0.2.7)" \ + "$(printf 'nameserver 192.0.2.7\nnameserver 192.0.2.99')" +same "the paths come from the command line alone: PYTHON* and the like change nothing" \ + "$(hook PYTHONPATH="$T" PYTHONSTARTUP=/dev/null interface=eth0 protocol=dhcp reason=RENEW if_up=true new_domain_name_servers=192.0.2.7)" \ + "$(printf 'nameserver 192.0.2.7\nnameserver 192.0.2.99')" +python3 -I "$HOOK" --nonsense > /dev/null 2>&1 +[[ $? -eq 2 ]] && green "an argument it does not know is refused" || red "an unknown argument was taken" + +echo "-- the net zone runs it" +grep -q -- '-c /usr/libexec/kryptik/dhcpcd-hook' "${ROOT}/tools/net/netzone-init.sh" \ + && green "netzone-init.sh starts dhcpcd with this hook" || red "netzone-init.sh does not name the hook" +grep -q 'dhcpcd-hook.py.*/usr/libexec/kryptik/dhcpcd-hook' "${ROOT}/build/recipes/netzone.sh" \ + && green "the recipe installs it where dhcpcd is told to look" || red "the recipe does not install the hook" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]]