From 30052e32959e20c276012e86a29d24ddf0f265c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 21:25:13 +0000 Subject: [PATCH] port the firewall, last, and bind the Docker rules to the real interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last in the run for the reason the original gave: enabling a firewall is the one step that can cut the connection it is running over. The security bug is in the shipped rules. ufw-docker-rules.conf hardcodes eth0 in all three of its rules. Docker publishes container ports by writing its own iptables rules underneath ufw — DOCKER-USER is the hook that lets ufw have a say at all — so on a machine with predictable interface names (ens18, enp1s0, most VPS images) none of those rules match, the final DROP never fires, and every published port is open to the internet while `ufw status` reports active. A firewall that says it is working and is not is worse than no firewall. The rules are now substituted with the interface the machine actually uses, verified by applying them against a stubbed ens18. Order inside the section is the other thing that matters: OpenSSH is allowed BEFORE anything is enabled, unconditionally, because a firewall enabled without an ssh rule on a machine reached over ssh needs a console to fix. The prompt says so, and says to open a second session before closing the current one. tailscale0 is checked and offered, because the default is deny inbound and the tailnet is an inbound interface like any other — without that rule Officer is unreachable over the tailnet while Tailscale reports itself connected. A correction to something I said while writing this: I reported that this host was missing its tailscale0 rule. It is not. I had run `ufw status verbose | head -8`, which cut the output above the rule list. The full status shows it allowed, and nothing was wrong. That leaves the NOT PORTED list empty. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/network.sh | 41 +++++++ scripts/setup/machine-setup/machine-setup.sh | 106 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/scripts/setup/machine-setup/lib/network.sh b/scripts/setup/machine-setup/lib/network.sh index 65454809..29906ffe 100644 --- a/scripts/setup/machine-setup/lib/network.sh +++ b/scripts/setup/machine-setup/lib/network.sh @@ -195,3 +195,44 @@ EOF } netplan_check() { netplan generate 2>&1; } + +# ----------------------------------------------------------------------------- +# Firewall +# ----------------------------------------------------------------------------- +# +# Last in the run, for the reason the original gave: enabling a firewall is the +# one step that can cut the connection it is being run over. Everything else +# should be done and working first. +# +# ── The bug in the shipped Docker rules ── +# +# ufw-docker-rules.conf hardcodes eth0. Docker publishes ports by writing its own +# iptables rules, which bypass ufw entirely — DOCKER-USER is the hook that lets +# ufw have a say. But every rule in that file names eth0, so on a machine with +# predictable interface names (ens18, enp1s0, and most VPS images) they match +# nothing, the final DROP never fires, and every published container port is open +# to the internet while `ufw status` says active. A firewall that reports itself +# working and is not is worse than none. + +UFW_AFTER_RULES=/etc/ufw/after.rules + +ufw_is_active() { ufw status 2>/dev/null | grep -q "^Status: active"; } +ufw_allows_ssh() { ufw status 2>/dev/null | grep -qiE "^(22/tcp|OpenSSH)"; } +ufw_has_rule() { ufw status 2>/dev/null | grep -qF "$1"; } +ufw_docker_rules_applied() { grep -q "DOCKER-USER" "$UFW_AFTER_RULES" 2>/dev/null; } + +# The shipped rules, with eth0 replaced by the interface this machine actually +# uses. Appended once — the DOCKER-USER marker is the guard. +apply_ufw_docker_rules() { + local src="$1" iface + iface="$(default_iface)" + [[ -n "$iface" ]] || return 1 + [[ -r "$src" ]] || return 1 + + { + echo "" + echo "# Appended by machine-setup. Interface substituted for the one this" + echo "# machine actually uses; the shipped file hardcodes eth0." + sed "s/-i eth0/-i ${iface}/g" "$src" + } >>"$UFW_AFTER_RULES" +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index c816bdad..586282cc 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -2223,6 +2223,112 @@ EOF step_ok fi +# ============================================================================= +# 27. Firewall +# ============================================================================= +# +# Last, deliberately. Enabling a firewall is the one step in this script that can +# cut the connection it is running over, so everything else is done and working +# before it happens. + +step "Firewall" +if ! skip; then + echo "" + info "Firewall — what this machine will accept connections on" + echo " ufw: $(ufw_is_active && echo active || echo inactive)" + echo " ssh allowed: $(ufw_allows_ssh && echo yes || echo 'NO')" + echo " tailscale0: $(ufw_has_rule tailscale0 && echo allowed || echo 'not allowed')" + echo " docker rules: $(ufw_docker_rules_applied && echo applied || echo 'not applied')" + + # ── ssh first, always, before anything is enabled ── + # + # The order matters more than anything else here. A firewall enabled without an + # ssh rule on a machine reached over ssh is unrecoverable without a console. + if ! ufw_allows_ssh; then + info " allowing OpenSSH before anything else" + ufw allow OpenSSH >/dev/null 2>&1 + fi + + # ── the tailnet ── + # + # Checked rather than assumed. Without this rule Officer is unreachable over + # the tailnet even though Tailscale is connected — the default is deny + # inbound, and the tailnet is an inbound interface like any other. + if ip link show tailscale0 &>/dev/null; then + if ufw_has_rule tailscale0; then + echo " tailnet traffic already allowed" + else + echo "" + echo " tailscale0 exists but is not allowed through. Everything Officer" + echo " serves is reached over the tailnet, so without this rule the" + echo " platform is unreachable even though Tailscale is connected." + if confirm "Allow all traffic on tailscale0?"; then + ufw allow in on tailscale0 >/dev/null 2>&1 + ok "tailnet traffic allowed" + SUMMARY+=("Firewall: tailscale0 allowed") + fi + fi + fi + + # ── docker ── + # + # Docker publishes ports by writing its own iptables rules, which bypass ufw + # entirely. DOCKER-USER is the hook that lets ufw have a say at all. + if command -v docker &>/dev/null && [[ -r "$SCRIPT_DIR/ufw-docker-rules.conf" ]]; then + if ufw_docker_rules_applied; then + echo " docker rules already in ${UFW_AFTER_RULES}" + else + echo "" + echo " Docker publishes container ports by writing iptables rules of its" + echo " own, underneath ufw — a published port is reachable from the" + echo " internet whatever ufw says. These rules close that, allowing only" + echo " 80 and 443 in from outside." + if confirm "Apply the Docker firewall rules?"; then + if apply_ufw_docker_rules "$SCRIPT_DIR/ufw-docker-rules.conf"; then + ok "docker rules appended, bound to $(default_iface)" + SUMMARY+=("Firewall: Docker rules applied on $(default_iface)") + else + warn "could not apply the Docker rules" + fi + fi + fi + fi + + # ── enable ── + if ufw_is_active; then + ufw reload >/dev/null 2>&1 + ok "firewall active and reloaded" + SUMMARY+=("Firewall: active") + else + echo "" + info "Enable the firewall?" + echo " Default: deny everything inbound, allow everything outbound." + echo " Allowed in: $(ufw_allows_ssh && echo 'OpenSSH') $(ufw_has_rule tailscale0 && echo '· all traffic on tailscale0')" + echo "" + warn "If you are connected over ssh, this is the moment it could go wrong." + echo " OpenSSH is allowed above before anything is enabled, which is what" + echo " makes this safe — but check you can open a second session before" + echo " closing this one." + if confirm "Enable it?"; then + ufw --force enable >/dev/null 2>&1 + if ufw_is_active; then + ok "firewall enabled" + SUMMARY+=("Firewall: enabled") + else + warn "ufw did not come up — check: ufw status verbose" + ERRORS+=("Firewall: enable failed") + fi + else + warn "skipped by request — this machine has no firewall" + SUMMARY+=("Firewall: SKIPPED by request, not enabled") + fi + fi + + echo "" + ufw status 2>/dev/null | sed 's/^/ /' + step_ok +fi + # ============================================================================= # NOT PORTED YET # =============================================================================