From 89a44b58b2669bddc54658e4a5111ac1d14f1351 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 30 Aug 2026 14:56:24 +0200 Subject: [PATCH] feat: gate on nftables drift so the docker cut-off cannot come back silently The forward-chain fix from 334c0cd9 only ever reached the live ruleset by hand; /etc/nftables.conf kept the bare default-drop chain, so the next reboot cut every bridged container off again and the Signal bot went deaf a second time. Nothing compared generated against persisted. Co-Authored-By: Claude Opus 5 --- .../nftables-drift-verify-2026-08-30.json | 40 ++++++++++++++ .../features/lib/wg_firewall.sh | 54 ++++++++++++++++--- linux_configuration/features/lib/wg_sshd.sh | 2 + .../features/setup_wireguard_ssh.sh | 6 ++- 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/evidence/nftables-drift-verify-2026-08-30.json diff --git a/docs/superpowers/evidence/nftables-drift-verify-2026-08-30.json b/docs/superpowers/evidence/nftables-drift-verify-2026-08-30.json new file mode 100644 index 00000000..937c87c0 --- /dev/null +++ b/docs/superpowers/evidence/nftables-drift-verify-2026-08-30.json @@ -0,0 +1,40 @@ +{ + "intent": "Make the Docker cut-off caused by a stale /etc/nftables.conf detectable instead of silent. The 2026-08-29 fix landed in wg_firewall.sh but was only ever hand-applied to the live ruleset; /etc/nftables.conf kept the bare default-drop forward chain, so the 2026-08-30 reboot reloaded it and cut every bridged container off again. Nothing compared the generated ruleset with the persisted one, so the regression was invisible until the Signal bot went deaf for a second time.", + "scope": [ + "linux_configuration/features/lib/wg_firewall.sh (split render_nftables_ruleset out of write_nftables_ruleset; add verify_nft)", + "linux_configuration/features/setup_wireguard_ssh.sh (new 'verify' subcommand, added to the require_root list)", + "linux_configuration/features/lib/wg_sshd.sh (usage text only)", + "Non-goal: no change to the generated ruleset itself — the forward-chain fix from 334c0cd9 is unchanged", + "Non-goal: no change to the input chain, WireGuard, sshd hardening or DuckDNS" + ], + "changes": [ + "render_nftables_ruleset(target) is split out of write_nftables_ruleset so the ruleset can be regenerated into a scratch file with no backup and no side effects; write_nftables_ruleset keeps the backup and delegates to it", + "verify_nft() gates on two invariants and exits non-zero: (1) /etc/nftables.conf is byte-identical to what the script generates, printing a unified diff when it is not, and (2) the LIVE 'inet filter forward' chain still contains 'iifname \"docker0\" accept'. It names the exact re-apply command on failure", + "'verify' wired into main() and into usage; it needs root because 'nft list chain' does" + ], + "verification": [ + { + "command": "sudo setup_wireguard_ssh.sh verify (on the live host, before re-applying)", + "result": "pass", + "evidence": "Exits non-zero and reports both failures on the currently-broken host: '/etc/nftables.conf has DRIFTED from this script' with a diff showing exactly the missing forward-chain block (ct established,related + docker0/br-* accepts), and 'live forward chain does NOT accept docker0'. This is the regression reproducing, which is what the gate is for." + }, + { + "command": "diff of rendered output vs /etc/nftables.conf", + "result": "pass", + "evidence": "The only difference is the intended forward-chain block; the input and output chains, DHCP/DNS/web rules and LAN subnet render byte-identical. Confirms the render/write refactor did not change the generated ruleset." + }, + { + "command": "shellcheck -x --severity=style linux_configuration/features/setup_wireguard_ssh.sh", + "result": "pass", + "evidence": "exit 0, no findings (follows the sourced libs)" + } + ], + "risks": [ + "verify_nft needs root for 'nft list chain'; run without sudo it reports the live-chain check as failed rather than as unknown", + "The drift check is byte-exact, so an intentional hand-edit of /etc/nftables.conf will be reported as drift — which is the intended behaviour, but means the fix is always 're-run allow-web', never 'edit the file'" + ], + "rollback": [ + "git revert this commit; write_nftables_ruleset keeps working because render_nftables_ruleset is only a refactor of its body", + "After rollback, re-run 'sudo setup_wireguard_ssh.sh allow-web' if /etc/nftables.conf is still stale, and confirm with: docker run --rm --network bridge redis:8-alpine sh -c 'nc -w4 -z 1.1.1.1 443 && echo OK'" + ] +} diff --git a/linux_configuration/features/lib/wg_firewall.sh b/linux_configuration/features/lib/wg_firewall.sh index 909cd879..b322249d 100755 --- a/linux_configuration/features/lib/wg_firewall.sh +++ b/linux_configuration/features/lib/wg_firewall.sh @@ -5,12 +5,12 @@ # 250-line cap. Sourced rather than run, so it inherits the caller's # strict mode and the variables defined above the source line. -write_nftables_ruleset() { +# Rendering is separate from writing so that 'verify' can regenerate the +# ruleset into a scratch file and diff it against what is actually on disk, +# without backing anything up or touching the live configuration. +render_nftables_ruleset() { + local target="$1" detect_lan_subnet - if [[ -f $NFT_CONF ]]; then - cp "$NFT_CONF" "${NFT_CONF}.bak.$(date +%s)" - log_warn "Backed up existing ${NFT_CONF} before overwriting." - fi local web_rule="" if [[ $ALLOW_WEB == "true" ]]; then web_rule=$'\n\t\ttcp dport { 80, 443 } accept' @@ -28,7 +28,7 @@ write_nftables_ruleset() { # saddr. Link-local only: :67 broadcasts never route in from the internet. dhcp_rule=$'\n\t\tudp sport 68 udp dport 67 accept' fi - cat >"${NFT_CONF}.new" <"$target" </dev/null 2>&1; then + log_ok "${NFT_CONF} matches what this script generates." + else + log_error "${NFT_CONF} has DRIFTED from this script. A reboot would apply the stale file." + diff -u "$NFT_CONF" "$rendered" | head -40 || true + status=1 + fi + rm -f "$rendered" + + if nft list chain inet filter forward 2>/dev/null | grep -qF "$DOCKER_FORWARD_RULE"; then + log_ok "live forward chain lets Docker through." + else + log_error "live forward chain does NOT accept docker0 -- bridged containers have no network." + status=1 + fi + + if ((status != 0)); then + log_error "Re-apply with: sudo $0 allow-web" + fi + return "$status" +} + # 'flush ruleset' above deletes Docker's own tables along with everything # else, and dockerd only rebuilds them on its next network event -- so # without this, containers keep running with no NAT and no forwarding until diff --git a/linux_configuration/features/lib/wg_sshd.sh b/linux_configuration/features/lib/wg_sshd.sh index a2f57bfe..00645d8a 100755 --- a/linux_configuration/features/lib/wg_sshd.sh +++ b/linux_configuration/features/lib/wg_sshd.sh @@ -227,6 +227,8 @@ Commands: add-peer Provision a new phone/laptop and print its QR code. allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host). allow-dns Open udp/tcp 53 from the LAN in the firewall (for the DNS blocker on this host). + verify Check /etc/nftables.conf matches this script and that + the live forward chain still lets Docker through. status Show WireGuard/firewall/sshd status. revoke Remove a peer. help Show this message. diff --git a/linux_configuration/features/setup_wireguard_ssh.sh b/linux_configuration/features/setup_wireguard_ssh.sh index ad8560b0..992a0ac8 100755 --- a/linux_configuration/features/setup_wireguard_ssh.sh +++ b/linux_configuration/features/setup_wireguard_ssh.sh @@ -82,7 +82,6 @@ source "$SCRIPT_DIR/lib/wg_firewall.sh" # shellcheck source=lib/wg_sshd.sh source "$SCRIPT_DIR/lib/wg_sshd.sh" - main() { local cmd="${1:-help}" @@ -90,7 +89,7 @@ main() { # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in - setup | add-peer | revoke | allow-web | allow-dns) + setup | add-peer | revoke | allow-web | allow-dns | verify) require_root "$@" ;; esac @@ -120,6 +119,9 @@ main() { allow-dns) allow_dns ;; + verify) + verify_nft + ;; status) status_cmd ;;