#!/usr/bin/env bash # setup-server's firewall step: what it opens, and what it refuses to widen. # # The bug this pins cost a production VPS its firewall posture. That machine # reached 80 and 443 only from one CDN's address sets, through rich rules; # neither port appeared in --list-ports. The old step queried --query-port, # saw "not open", and blanket-opened 80, 443 and 81 -- turning a narrow grant # into a public one, and port 81 into a publicly reachable admin portal. It # did it during `panama update`, which asks nothing and runs unattended, so # there was no prompt and no diff for anyone to read. # # What is pinned here, and why each half matters: # # * A port already granted -- by an open port, an enabled service, or an # accepting rich rule -- is left alone. Panama opens what is shut; it # never widens what is already open. # * A port granted by nothing is still opened. The opposite failure is a # fresh server that is silently unreachable, which is worse: see the # firewalld comment in setup/packages/server-packages. # * A rich rule that DROPS a port is not coverage. Reading a block as a # grant would leave the reverse proxy unreachable on a machine nobody # thought to re-check. # * `echo manual > $XDG_STATE_HOME/panama/firewall` stops Panama touching # firewalld at all, and that opt-out is a file so it survives the next # unattended update. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" stage="$repo_dir/setup/scripts/setup-server" findings=() note() { findings+=("$1"); } work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # ── Stubs ──────────────────────────────────────────────────────────────────── # Everything setup-server reaches for outside the firewall block is answered # "already done", so the only behaviour under test is which ports it decides # to open. firewall-cmd is the one stub that reports: it appends each mutating # call to $FW_LOG, and answers queries from the scenario in the environment. bin="$work/bin" mkdir -p "$bin" cat >"$bin/firewall-cmd" <<'STUB' #!/usr/bin/env bash for arg in "$@"; do case "$arg" in --query-port=*) want="${arg#--query-port=}"; want="${want%/tcp}" for open in ${FW_OPEN:-}; do [[ "$open" == "$want" ]] && exit 0; done exit 1 ;; --add-port=*) printf 'add %s\n' "${arg#--add-port=}" >>"$FW_LOG"; exit 0 ;; --reload) printf 'reload\n' >>"$FW_LOG"; exit 0 ;; --list-services) printf '%s\n' "${FW_SERVICES:-}"; exit 0 ;; --info-service=*) name="${arg#--info-service=}" for pair in ${FW_SERVICE_PORTS:-}; do [[ "${pair%%:*}" == "$name" ]] && printf ' ports: %s\n' "${pair#*:}" done exit 0 ;; --list-rich-rules) printf '%s\n' "${FW_RICH:-}"; exit 0 ;; esac done exit 0 STUB # Forwards firewall-cmd so the stub above sees the call; swallows everything # else, stdin included, so `printf | sudo tee` cannot die of SIGPIPE here. cat >"$bin/sudo" <<'STUB' #!/usr/bin/env bash case "${1:-}" in firewall-cmd) shift; exec firewall-cmd "$@" ;; *) cat >/dev/null 2>&1; exit 0 ;; esac STUB cat >"$bin/systemctl" <<'STUB' #!/usr/bin/env bash [[ "$*" == "is-active firewalld" ]] && exit "${FW_INACTIVE:-0}" exit 0 STUB cat >"$bin/loginctl" <<'STUB' #!/usr/bin/env bash printf 'Linger=yes\n' STUB cat >"$bin/podman" <<'STUB' #!/usr/bin/env bash exit 0 STUB chmod +x "$bin"/* # Runs the stage in a scenario and prints the firewall actions it took, in # order, as a single space-separated line so a whole run compares in one go. run_stage() { local log="$work/fw.log" home="$work/home" state="$work/state" rm -rf "$home" "$state"; mkdir -p "$home" "$state/panama" : >"$log" [[ -n "${DECISION:-}" ]] && printf '%s\n' "$DECISION" >"$state/panama/firewall" env PATH="$bin:$PATH" HOME="$home" XDG_STATE_HOME="$state" \ PANAMA_PATH="$repo_dir" FW_LOG="$log" \ FW_OPEN="${FW_OPEN:-}" FW_SERVICES="${FW_SERVICES:-}" \ FW_SERVICE_PORTS="${FW_SERVICE_PORTS:-}" FW_RICH="${FW_RICH:-}" \ FW_INACTIVE="${FW_INACTIVE:-0}" \ bash "$stage" "$work/out" 2>&1 tr '\n' ' ' <"$log" | sed 's/ *$//' } # Each scenario is one call: clear the environment it reads, set what it needs. scenario() { unset FW_OPEN FW_SERVICES FW_SERVICE_PORTS FW_RICH FW_INACTIVE DECISION; } cloudflare_rules='rule family="ipv4" source ipset="cloudflare4" port port="80" protocol="tcp" accept rule family="ipv4" source ipset="cloudflare4" port port="443" protocol="tcp" accept' # ── A machine with nothing granted gets all three ──────────────────────────── scenario got="$(run_stage)" [[ "$got" == "add 80/tcp add 443/tcp add 81/tcp reload" ]] \ || note "a bare machine did not get all three ports opened; got: $got" # ── Rich rules granting 80/443 are not widened ─────────────────────────────── scenario; FW_RICH="$cloudflare_rules" got="$(run_stage)" [[ "$got" == "add 81/tcp reload" ]] \ || note "a CDN-only rich rule on 80/443 was widened to a public port; got: $got" # ── An enabled service granting 80/443 is not widened ──────────────────────── scenario; FW_SERVICES="http https"; FW_SERVICE_PORTS="http:80/tcp https:443/tcp" got="$(run_stage)" [[ "$got" == "add 81/tcp reload" ]] \ || note "ports granted by an enabled service were opened again; got: $got" # ── Already-open ports are left alone, and nothing reloads ─────────────────── scenario; FW_OPEN="80 443 81" got="$(run_stage)" [[ -z "$got" ]] \ || note "a fully configured firewall was touched anyway; got: $got" # ── A drop rule is not a grant ─────────────────────────────────────────────── scenario; FW_RICH='rule family="ipv4" port port="80" protocol="tcp" drop' got="$(run_stage)" [[ "$got" == "add 80/tcp add 443/tcp add 81/tcp reload" ]] \ || note "a rich rule DROPPING 80 was read as granting it; got: $got" # ── A port range grants every port inside it ───────────────────────────────── scenario; FW_RICH='rule family="ipv4" port port="79-82" protocol="tcp" accept' got="$(run_stage)" [[ "$got" == "add 443/tcp reload" ]] \ || note "a rich rule granting 79-82 did not cover 80 and 81; got: $got" # ── A udp grant is not a tcp grant ─────────────────────────────────────────── scenario; FW_RICH='rule family="ipv4" port port="80" protocol="udp" accept' got="$(run_stage)" [[ "$got" == "add 80/tcp add 443/tcp add 81/tcp reload" ]] \ || note "a udp rich rule was read as granting tcp 80; got: $got" # ── The opt-out ────────────────────────────────────────────────────────────── scenario; DECISION=manual got="$(run_stage)" [[ -z "$got" ]] \ || note "the manual opt-out did not stop Panama touching firewalld; got: $got" grep -q 'Firewall left to this machine' "$work/out" \ || note 'the manual opt-out does not say why it skipped the firewall' # ── An inactive firewalld is not an error ──────────────────────────────────── scenario; FW_INACTIVE=1 got="$(run_stage)" [[ -z "$got" ]] \ || note "firewalld reported inactive but ports were still touched; got: $got" # ── Report ─────────────────────────────────────────────────────────────────── if (( ${#findings[@]} > 0 )); then printf 'setup-server firewall contract FAILED:\n' >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'setup-server firewall contract passed\n'