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 # =============================================================================