From fbad141a824702c1723f81cc243d9194fd23ad8b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 20:45:21 +0000 Subject: [PATCH] doctor: judge UFW's own DNS carve-out against the live gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new ACL carve-out check compared only the incus ACL to the live gateway — which setup-host now converges — so a remapped UFW host got a clean bill while the stale UFW allow silently dropped box DNS: the safety net had a blind spot precisely for UFW hosts using the #80 escape hatch (#86 review, item 1). ufw_dns_findings is pure text in ('ufw status' output, the network, the live gateway), findings out (silence is agreement) — the gw_squat_signature seam, so the suite drives it against canned tables. It flags a DNS allow that does not match the gateway, our deny with no DNS allow at all, and stale allows left beside a live one; a UFW host box-firewall never touched has nothing to judge and stays clean. Wired into the admin Firewall section wherever UFW is active and a bridge exists to compare against, with the fix pointing at the now-converging box-firewall. Co-Authored-By: Claude Fable 5 --- drill/doctor.sh | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/drill/doctor.sh b/drill/doctor.sh index 4fac72b..6a33185 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -62,6 +62,38 @@ gw_squat_signature() { print "duplicate connected routes for the uplink subnet " up " (" devs[up] ") — whichever link last gains carrier wins, and a nested bridge with carrier blackholes egress" }' } +# --- The UFW half of the gateway carve-out (#86 review) --------------------- +# setup-host converges the ACL's gateway allow and box-firewall converges +# UFW's — but a doctor that reads only the ACL hands a remapped UFW host a +# clean bill while a stale 'allow … to port 53' quietly drops +# box→gateway DNS. This reads UFW's own table. Pure text in (`ufw status` +# output, the network, the live gateway), findings out (one per line, +# silence is agreement) — the gw_squat_signature seam, so test/cli.sh drives +# it against canned tables. Judged only where UFW is active: the no-UFW nft +# carve-out is interface-scoped (no gateway address to go stale). +ufw_dns_findings() { + local status="$1" net="$2" gw="$3" allows stale + allows="$(printf '%s\n' "$status" | awk -v net="$net" ' + $2 ~ /^53\// && $3 == "on" && $4 == net { print $1 }' | sort -u)" + if [ -z "$allows" ]; then + # No DNS allow at all is a drop only if OUR deny is there to do the + # dropping — a UFW host box-firewall never touched has nothing to judge. + printf '%s\n' "$status" | grep " on $net" | grep -q "DENY" \ + && printf 'UFW denies in on %s with NO DNS allow at all — box DNS to the gateway is dropped\n' "$net" + return 0 + fi + if ! printf '%s\n' "$allows" | grep -qxF "$gw"; then + printf "UFW's DNS allow points at %s — NOT %s's live gateway (%s): box DNS dies at UFW's deny\n" \ + "$(printf '%s' "$allows" | tr '\n' ' ')" "$net" "$gw" + return 0 + fi + stale="$(printf '%s\n' "$allows" | grep -vxF "$gw")" || true + [ -n "$stale" ] \ + && printf "stale UFW DNS allow(s) for %s left beside the live gateway's — box-firewall converges these away now\n" \ + "$(printf '%s' "$stale" | tr '\n' ' ')" + return 0 +} + # The signature, probed INSIDE a box: its routes, read where they live. A # poisoned guest looks healthy from every host-side config check — the nested # bridge and the captured gateway exist only in the guest's kernel. @@ -243,6 +275,26 @@ else inf " (or: sudo systemctl restart box-firewall.service)" fi +# The UFW blind spot: the ACL check further down compares the ACL's carve-out +# to the live gateway, but on a UFW host the SAME stale-carve-out failure can +# live in UFW's own table — and did, invisibly (#86 review). Judge it here, +# from `ufw status`, wherever UFW is the active firewall and a bridge exists +# to compare against (a fresh host has neither). +if command -v ufw >/dev/null 2>&1; then + ufw_out="$(sudo ufw status 2>/dev/null)" + ufw_gw="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1)" + if printf '%s\n' "$ufw_out" | grep -q "Status: active" && [ -n "$ufw_gw" ]; then + findings="$(ufw_dns_findings "$ufw_out" boxnet "$ufw_gw")" + if [ -n "$findings" ]; then + while IFS= read -r line; do no "$line"; done <<<"$findings" + inf "the bridge moved (#80's escape hatch) and UFW did not follow" + inf "fix: sudo /usr/local/sbin/box-firewall (it converges the UFW allows off the live bridge now)" + else + ok "no stale UFW DNS carve-out — box DNS to the gateway ($ufw_gw) survives UFW" + fi + fi +fi + # box-net is the placement contract since the 0.4.0 rename; claude-dev is its # pre-rename ancestor and may linger while legacy boxes still reference it. # Check whichever exist — an unisolated NIC is a fault on either.