From 56f1116684c23d8f018aa2875cef93888c103777 Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 5 Oct 2026 10:39:41 -0700 Subject: [PATCH 1/2] The net zone's words reach the log marked, bounded and as text: its service starts it on a pipe, so the launcher marks each line as the zone's, replaces control bytes and logs a megabyte a start at most; the zone prints what the network says with printf, never echo; the update helper's last line counts as zone 0's answer only when the helper ended well; and the broker no longer logs the calls a net zone makes by the minute, which used up its log's bound in days --- build/services/net-zone/run | 9 ++++- compartments/kryptikd/src/spawn.rs | 5 ++- docs/design/net-zone.md | 6 ++++ tools/net/netzone-init.sh | 22 +++++++----- tools/net/update-fetch.py | 3 +- tools/tests/netzone-words.sh | 58 ++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 11 deletions(-) create mode 100755 tools/tests/netzone-words.sh diff --git a/build/services/net-zone/run b/build/services/net-zone/run index 1363a848..a6154d4d 100755 --- a/build/services/net-zone/run +++ b/build/services/net-zone/run @@ -17,5 +17,12 @@ if ! have_nic; then sleep 3 echo "net-zone: an interface has appeared" fi +# --ready-fd: nobody waits to hear that the zone is up, but with it the launcher +# keeps this service's log and hands the zone a pipe. Each line the zone +# writes is then marked as its own and cannot pass for the launcher's or a +# zone 0 service's, its control bytes are replaced, and a megabyte a start is +# the most it logs (spawn.rs, ZoneOutput). A zone that says more goes unlogged +# until its next start; filling the allowance again by the hour is the way on +# if that proves too little. exec /usr/bin/kryptikd run net --zones /usr/lib/kryptik/zones --rootfs /var/lib/kryptik/zones \ - -- /usr/libexec/kryptik/netzone-init.sh + --ready-fd 3 -- /usr/libexec/kryptik/netzone-init.sh 3>/dev/null diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 3770a008..bd883b3a 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -394,8 +394,11 @@ fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served, out: let pfd = pfds[0]; if n > 0 && pfd.revents & libc::POLLIN != 0 { match broker::serve_one(listen_fd, s) { + /* Not the calls a net zone makes by the minute: they would use + * up the log's bound in days, and what they decide is logged + * where it is decided. */ + Ok(Some("update-poll" | "update-put" | "version")) | Ok(None) => {} Ok(Some(verb)) => log(&format!("kryptikd[zone {zone}]: broker request: {verb}")), - Ok(None) => {} Err(e) => log(&format!("kryptikd[zone {zone}]: broker: {e}")), } } diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 937a5191..8dc351ad 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -109,6 +109,12 @@ netzone: READY uplink= nat=yes dns= wifi= (forwarding off) ``` + The zone writes to a pipe, never to the log: its launcher marks each line + `zone net| ` in the catch-all log, replaces control bytes, and logs at most + a megabyte a start. A line without the mark is the launcher's or a zone 0 + service's, whatever it says. The zone prints words from the network (an + SSID, a server's refusal) as they came, never as escapes. + ## The uplinks' own networks A zone needs the gateway only as a next hop, and its names go through the diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index c77a6984..77358c20 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -8,7 +8,9 @@ # time= ... # netzone: NOT READY (forwarding is off) set -u -say() { echo "netzone: $*"; } +# printf, not echo: a word from the network (an SSID, a server's reason for a +# refusal) is printed as it came, and dash's echo would act on its backslashes. +say() { printf 'netzone: %s\n' "$*"; } BR=kryptik0 STATUS=/run/netzone-status WPA_CONF=/etc/wpa_supplicant.conf @@ -326,14 +328,17 @@ UPDATE_CONF=/etc/kryptik/update.conf UPDATE_FETCH="${KRYPTIK_UPDATE_FETCH:-/usr/libexec/kryptik/update-fetch.py}" UPDATE_BROUGHT=/run/kryptik-update-statement-brought UPDATE_PID="" -update_run() { # update_run latest|poll: in the background, one at a time +update_run() { # update_run latest|poll: in the background, one at a time; 1 while the last one runs { command -v python3 >/dev/null 2>&1 && [ -r "$UPDATE_FETCH" ] && [ -r "$UPDATE_CONF" ]; } || return 0 - [ -n "$UPDATE_PID" ] && kill -0 "$UPDATE_PID" 2>/dev/null && return 0 + [ -n "$UPDATE_PID" ] && kill -0 "$UPDATE_PID" 2>/dev/null && return 1 ( - out="$(python3 "$UPDATE_FETCH" "$1" --broker "$BROKER" 2>&1 | tail -1)" - case "$1:$out" in - poll:idle|*:) ;; - latest:ok*) : > "$UPDATE_BROUGHT"; say "update: zone 0 on the statement of what is current: ${out}" ;; + out="$(python3 "$UPDATE_FETCH" "$1" --broker "$BROKER" 2>&1)"; rc=$? + out="$(printf '%s\n' "$out" | tail -1)" + # Zone 0's answer counts only from a fetch that ended well: the last + # line of one that failed is the far host's words. + case "$rc:$1:$out" in + 0:poll:idle|*:*:) ;; + 0:latest:ok*) : > "$UPDATE_BROUGHT"; say "update: zone 0 on the statement of what is current: ${out}" ;; *) say "update $1: ${out}" ;; esac ) & @@ -411,7 +416,8 @@ while :; do if [ -n "$(uplink_addr "$@")" ]; then if [ -e "$UPDATE_BROUGHT" ]; then statement_every=8640; else statement_every=180; fi if [ "$statement_ticks" -ge "$statement_every" ]; then - statement_ticks=0; update_run latest + # Asked again at the next pass when a poll was still running. + update_run latest && statement_ticks=0 elif [ "$update_ticks" -ge 6 ]; then update_ticks=0; update_run poll fi diff --git a/tools/net/update-fetch.py b/tools/net/update-fetch.py index affe9f86..f0447c43 100755 --- a/tools/net/update-fetch.py +++ b/tools/net/update-fetch.py @@ -114,7 +114,8 @@ def main(): try: return latest(args) if args.what == "latest" else poll(args) except (OSError, ValueError) as e: # urllib's errors are OSErrors - print("update-fetch: %s" % e, file=sys.stderr) + # One line: the text can hold a host's own words, line ends among them. + print("update-fetch: %s" % " ".join(str(e).split()), file=sys.stderr) return 1 diff --git a/tools/tests/netzone-words.sh b/tools/tests/netzone-words.sh new file mode 100755 index 00000000..ed92c19d --- /dev/null +++ b/tools/tests/netzone-words.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Tests for the words the net zone prints and takes from the network +# (tools/net/netzone-init.sh, say and update_run): a backslash is printed, not +# acted on, and the update helper's last line counts as zone 0's answer only +# when the helper ended well. Offline, under each POSIX shell here. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/tools/net/netzone-init.sh" + +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 "python3 is not installed here; cannot run this test"; exit 77; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +{ sed -n '/^say() {/p' "$SCRIPT"; sed -n '/^update_run() {/,/^}/p' "$SCRIPT"; } > "$T/functions.sh" +grep -q '^say() {' "$T/functions.sh" && grep -q '^update_run() {' "$T/functions.sh" \ + || { echo "could not find say or update_run in ${SCRIPT#"$ROOT"/} (did their first lines move?)"; exit 1; } +grep -q -- '--ready-fd 3 -- /usr/libexec/kryptik/netzone-init.sh 3>/dev/null' "${ROOT}/build/services/net-zone/run" \ + && green "the service starts the zone on a pipe, so its launcher marks and bounds what it says" \ + || red "build/services/net-zone/run hands the zone the service's own log" + +# The update helper's stand-in: prints what it is told to, ends as it is told to. +cat > "$T/fetch.py" <<'EOF' +import os, sys +sys.stdout.write(os.environ.get("SAYS", "")) +sys.exit(int(os.environ.get("ENDS", "0"))) +EOF +: > "$T/update.conf" +cat > "$T/harness.sh" </dev/null 2>&1 || continue + run() { rm -f "$T/brought"; "$sh" "$T/harness.sh" "$@" 2>&1; } + echo "-- ${sh}" + same "a backslash from the network is printed as it came" \ + "$(run say 'wifi: Caf\x65\nnetzone: READY \033[2J')" 'netzone: wifi: Caf\x65\nnetzone: READY \033[2J' + same "zone 0's ok to a statement is taken, and the statement is not asked for again that day" \ + "$(SAYS='ok current' ENDS=0 run run latest)" "$(printf 'netzone: update: zone 0 on the statement of what is current: ok current\nrc=0\nbrought')" + same "the same words at the end of a fetch that failed are the far host's: said, and not taken" \ + "$(SAYS=$'update-fetch: HTTP Error 302: loop\nok current' ENDS=1 run run latest)" "$(printf 'netzone: update latest: ok current\nrc=0\nnot-brought')" + same "an idle poll says nothing" "$(SAYS='idle' ENDS=0 run run poll)" "$(printf 'rc=0\nnot-brought')" + same "a poll that failed is said, whatever its last line" "$(SAYS='idle' ENDS=1 run run poll)" "$(printf 'netzone: update poll: idle\nrc=0\nnot-brought')" + same "while one runs, the next is not started and says so by its status" "$(run busy)" "rc=1" +done + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From 66337331ddbbe64957118ece6c24d68f4ee7f63d Mon Sep 17 00:00:00 2001 From: DevomB Date: Mon, 5 Oct 2026 11:15:54 -0700 Subject: [PATCH 2/2] The resolver's guest check says whether the lease reaches the net zone's resolv.conf, and no longer needs it to: it writes the servers dnsmasq has and one more, then takes it away --- build/guest-tests/zones-check.sh | 29 ++++++++++++++++++++--------- tools/image/zones-test.sh | 2 +- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index 68413eb4..96a31ed0 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -77,15 +77,25 @@ PY' [[ "$ZOUT" == *PING6-OK* ]] && pass "routed-ping6" "ping reaches the bridge over IPv6" || fail "routed-ping6" "$(grep -o 'PING6-FAIL.*' "$LOG/untrusted.out")" [[ "$ZOUT" == *DNS-ANSWERED* ]] && pass "routed-dns" "$(grep -o 'DNS-ANSWERED.*' "$LOG/untrusted.out")" || fail "routed-dns" "$(grep -o 'DNS-.*' "$LOG/untrusted.out")" -# The resolver follows the servers a lease names. The net zone's resolv.conf -# gains one, as a lease that came late or another network would change it, and -# loses it again: each time dnsmasq must be told within two of the zone's 10 s -# passes. The first server stays throughout, so names still resolve. +# The lease reaches the zone's resolv.conf: every server the resolver forwards +# to comes from there, and under QEMU the stand-in is the lease's own server, +# so a hook that wrote nothing would go unseen without this. net_init="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" -netsh() { nsenter -t "${net_init:-0}" -m sh -c "$1" 2>/dev/null; } +netsh() { nsenter -t "${net_init:-0}" -m sh -c "$1" 2>&1; } +lease_dns="$(netsh 'grep "^nameserver" /etc/resolv.conf')" +if [[ "$lease_dns" == nameserver* ]]; then + pass "net-lease-names-resolver" "the net zone's resolv.conf: $(tr '\n' ' ' <<<"$lease_dns")" +else + fail "net-lease-names-resolver" "the net zone's resolv.conf names no server (${lease_dns:-nothing read}); $(netsh 'ls -l /etc/resolv.conf; ls /tmp /run/dhcpcd 2>&1 | head -12' | tr '\n' ' ')" +fi +# The resolver follows the servers a lease names. The net zone's resolv.conf +# is written with the servers dnsmasq has and one more, as a lease that came +# late or another network would change it, and then without it: each time +# dnsmasq must be told within a few of the zone's 10 s passes. Its own servers +# stay throughout, so names still resolve. forwards_to() { # forwards_to yes|no: wait until dnsmasq's file does, or does not, name the added server for _ in $(seq 1 40); do - if netsh 'grep -q "^nameserver 192\.0\.2\.53$" /run/uplink-resolv.conf'; then [[ "$1" == yes ]] && return 0 + if netsh 'grep -q "^nameserver 192\.0\.2\.53$" /run/uplink-resolv.conf' > /dev/null; then [[ "$1" == yes ]] && return 0 else [[ "$1" == no ]] && return 0; fi sleep 1 done @@ -93,9 +103,10 @@ forwards_to() { # forwards_to yes|no: wait until dnsmasq's file does, or does } told() { grep -hc 'netzone: dnsmasq: now forwarding to' /run/uncaught-logs/current /run/uncaught-logs/@* 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'; } told_before="$(told)" -if netsh 'grep -q "^nameserver" /etc/resolv.conf && cp /etc/resolv.conf /run/resolv.before && echo "nameserver 192.0.2.53" >> /etc/resolv.conf'; then +was="$(netsh 'grep "^nameserver" /run/uplink-resolv.conf')" +if [[ "$was" == nameserver* ]] && wrote="$(netsh "printf '%s\n' '${was}' 'nameserver 192.0.2.53' > /etc/resolv.conf")"; then forwards_to yes; came=$? - netsh 'cat /run/resolv.before > /etc/resolv.conf; rm -f /run/resolv.before' + netsh "printf '%s\n' '${was}' > /etc/resolv.conf" > /dev/null forwards_to no; went=$? sleep 2 # the zone says so after it has told dnsmasq if [[ "$came" -eq 0 && "$went" -eq 0 && "$(( $(told) - told_before ))" -ge 2 ]]; then @@ -104,7 +115,7 @@ if netsh 'grep -q "^nameserver" /etc/resolv.conf && cp /etc/resolv.conf /run/res fail "dns-follows-lease" "gained: rc=$came, lost: rc=$went, told $(( $(told) - told_before )) time(s); $(grep -h 'netzone: dnsmasq' /run/uncaught-logs/current 2>/dev/null | tail -2 | tr '\n' ' ')" fi else - fail "dns-follows-lease" "the net zone (init ${net_init:-none}) has no resolv.conf with a server to add to" + fail "dns-follows-lease" "dnsmasq's servers could not be read in the net zone (init ${net_init:-none}), or its resolv.conf not written: ${was:-nothing read} ${wrote:-}" fi # dnsmasq is still there after reading its servers again, as routed-dns found it. zrun untrusted 20 -- python3 -c 'import socket, struct diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 23a0c145..70fc654d 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 net-ready net-dns zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation fail-closed net-restart-ready reattach-after-restart uplink-refused wifi-beyond \ +for name in kernel-support 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 fail-closed net-restart-ready reattach-after-restart uplink-refused wifi-beyond \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ time-floor-ran time-clamp time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set \ terminal-terminfo man-page text-browser tls-trust \