mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 11:38:09 +09:00
Found by the full-stack VM harness, which 3524 unit tests and a 7-image container matrix had all missed. On a real Ubuntu 24.04 install the log read "Opening HTTP/HTTPS (80, 443) via nftables" and not one line of bootstrap_firewall appeared: it returned at its "a firewall is already active" guard, so the box got its two ports opened and no default-deny policy at all. The headline feature of that work was a no-op on exactly the population it was written for, and it looked green the whole way. The guard asked `[ "$(firewall_detect)" != "none" ]`. firewall_detect answers a different question — "which backend do I open a port THROUGH" — and returns `nftables` whenever the nft binary exists and can list a ruleset, including an empty one. That is true of every modern Linux, so the guard reported every box as already protected. Verified directly against a stock-image fixture: same inputs, `firewall_detect` -> nftables, inbound-default-deny -> unprotected. firewall_inbound_default_deny() asks what the bootstrap actually cares about: does this host already deny inbound by default? Presence of rules is deliberately NOT the test -- Docker installs plenty of nft and iptables chains and protects nothing. A default-deny policy on the input hook is: firewalld running, ufw active *with* "deny (incoming)", an nft base chain hooked to input with policy drop or reject, or an iptables INPUT policy that is not ACCEPT. Unknown answers 1 (unprotected), because the caller's response is to offer protection and every caller re-checks before acting. firewall_detect is left alone -- configure_firewall legitimately wants a backend name for a binary that exists -- and both functions now carry a comment saying which question they answer, since that is what was conflated. Six cases pin the behaviour, five of them shapes the old guard got wrong: an empty nft ruleset must run the bootstrap, Docker's chains must not read as protection, an active-but-allow-inbound ufw must be fixed rather than respected, a real nft policy drop must be left alone, and the two predicates must disagree on a stock image -- that last one asserted at the predicate level so the distinction survives a refactor of either function. make_stub_stdout is new because an exit-code stub cannot express "the tool ran fine and reported nothing", which is the whole stock-image case. Green on all seven CI images (265/265/265/275/275/275/263, 0 failed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.0 KiB
Bash
225 lines
9.0 KiB
Bash
# shellcheck shell=bash
|
|
#
|
|
# scripts/lib/firewall.sh — sourceable firewall abstraction.
|
|
#
|
|
# Detects the active host firewall and opens/closes the ports ServerKit needs
|
|
# (80/443, optionally 5000). Fresh Rocky/Alma/RHEL/Fedora boxes ship firewalld
|
|
# active by default, which silently blocks 80/443 — making a perfectly good
|
|
# install look "broken" because the panel is unreachable. This helper fixes
|
|
# that automatically and, crucially, is reversible: callers persist exactly
|
|
# what was opened (see scripts/lib/state.sh) so uninstall can undo only those
|
|
# rules and never touch a user's own firewall config.
|
|
#
|
|
# Contract:
|
|
# firewall_detect → echoes: firewalld|ufw|nftables|iptables|none
|
|
# firewall_open <backend> <port...> → open ports (each "80/tcp")
|
|
# firewall_close <backend> <port...> → close the same ports
|
|
#
|
|
# Honors:
|
|
# FW_DRY_RUN=1 print intended commands instead of running them
|
|
# FIREWALL_BACKEND=... force the detected backend (used by unit tests)
|
|
#
|
|
# Everything is best-effort: a firewall we can't drive cleanly degrades to a
|
|
# warning rather than aborting the install.
|
|
|
|
# Run a privileged firewall command, or just print it under dry-run. Kept tiny
|
|
# so the dry-run output is exactly the command that would otherwise execute —
|
|
# which is what the installer unit tests assert against.
|
|
_fw_run() {
|
|
if [ "${FW_DRY_RUN:-0}" = "1" ]; then
|
|
printf ' [firewall] would run: %s\n' "$*"
|
|
return 0
|
|
fi
|
|
# Discard the tool's own chatter ("success", rule-exists noise) here so
|
|
# callers don't redirect — that redirection would also swallow the dry-run
|
|
# line above, which the installer unit tests assert on.
|
|
"$@" >/dev/null 2>&1
|
|
}
|
|
|
|
# Is inbound traffic already filtered by default on this host?
|
|
#
|
|
# Distinct from firewall_detect() on purpose, and the difference is load-bearing.
|
|
# firewall_detect answers "which backend do I open a port THROUGH" — it happily
|
|
# returns `nftables` when the nft binary merely exists and can list an EMPTY
|
|
# ruleset, which is true of a stock Ubuntu/Debian cloud image. Using it as an
|
|
# "is a firewall already protecting this box" test therefore reports every
|
|
# modern Linux as protected, and a default-deny bootstrap gated on it never
|
|
# runs (found on a real Ubuntu 24.04 VM, where install logged "via nftables"
|
|
# and the bootstrap said nothing at all).
|
|
#
|
|
# This asks the question that actually matters, and the only one the bootstrap
|
|
# cares about: does the host already DENY inbound by default? Presence of rules
|
|
# is not the test — Docker installs plenty of nft/iptables chains and protects
|
|
# nothing — a default-deny policy on the input hook is.
|
|
#
|
|
# Returns 0 when inbound is default-denied, 1 otherwise (including when it
|
|
# cannot tell: an unprotected box is the safer assumption for a caller whose
|
|
# response is to *offer* protection, and every caller re-checks before acting).
|
|
firewall_inbound_default_deny() {
|
|
# firewalld: running means a zone policy is in force; its default zones all
|
|
# reject unsolicited inbound.
|
|
if command -v firewall-cmd >/dev/null 2>&1; then
|
|
case "$(firewall-cmd --state 2>/dev/null || true)" in
|
|
*running*) return 0 ;;
|
|
esac
|
|
fi
|
|
|
|
# ufw: active AND its default incoming policy is deny/reject. `ufw status
|
|
# verbose` prints e.g. "Default: deny (incoming), allow (outgoing)".
|
|
if command -v ufw >/dev/null 2>&1; then
|
|
local ufw_out
|
|
ufw_out="$(ufw status verbose 2>/dev/null || true)"
|
|
case "$ufw_out" in
|
|
*[Ss]tatus:*[Ii]nactive*) : ;;
|
|
*[Ss]tatus:*[Aa]ctive*)
|
|
case "$ufw_out" in
|
|
*[Dd]efault:*deny\ \(incoming\)*|*[Dd]efault:*reject\ \(incoming\)*)
|
|
return 0 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# nftables: a base chain hooked to input whose policy is drop or reject.
|
|
# An empty ruleset, or Docker's nat/filter chains, match neither.
|
|
if command -v nft >/dev/null 2>&1; then
|
|
local nft_out
|
|
nft_out="$(nft -a list ruleset 2>/dev/null || true)"
|
|
if printf '%s' "$nft_out" \
|
|
| tr '\n' ' ' \
|
|
| grep -qE 'hook[[:space:]]+input[^}]*policy[[:space:]]+(drop|reject)'; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# iptables/ip6tables: an INPUT policy that is not ACCEPT.
|
|
if command -v iptables >/dev/null 2>&1; then
|
|
case "$(iptables -S INPUT 2>/dev/null | head -1 || true)" in
|
|
*'-P INPUT DROP'*|*'-P INPUT REJECT'*) return 0 ;;
|
|
esac
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Detect the active firewall. Order matters: a box can have several of these
|
|
# binaries installed but only one actually managing the input chain, so we pick
|
|
# the highest-level manager that reports itself active.
|
|
#
|
|
# NOTE: this answers "which backend should I open a port through", NOT "is this
|
|
# box protected" — it returns a backend for a binary that exists even with no
|
|
# rules at all. For the latter use firewall_inbound_default_deny() above.
|
|
firewall_detect() {
|
|
if [ -n "${FIREWALL_BACKEND:-}" ]; then
|
|
printf '%s\n' "$FIREWALL_BACKEND"
|
|
return 0
|
|
fi
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
printf 'firewalld\n'; return 0
|
|
fi
|
|
if command -v ufw >/dev/null 2>&1; then
|
|
# Capture-then-case rather than `ufw status | grep -q`: grep -q exits
|
|
# at the first match, ufw can then die of SIGPIPE, and under a caller's
|
|
# pipefail the pipeline goes non-zero — misdetecting an active ufw.
|
|
local ufw_out
|
|
ufw_out="$(ufw status 2>/dev/null || true)"
|
|
case "$ufw_out" in
|
|
*[Ss]tatus:*[Ii]nactive*) : ;; # "inactive" contains "active" — rule it out first
|
|
*[Ss]tatus:*[Aa]ctive*) printf 'ufw\n'; return 0 ;;
|
|
esac
|
|
fi
|
|
if command -v nft >/dev/null 2>&1 && nft list ruleset >/dev/null 2>&1; then
|
|
printf 'nftables\n'; return 0
|
|
fi
|
|
if command -v iptables >/dev/null 2>&1; then
|
|
printf 'iptables\n'; return 0
|
|
fi
|
|
printf 'none\n'
|
|
}
|
|
|
|
# Split "80/tcp" into port + proto (proto defaults to tcp).
|
|
_fw_port() { printf '%s' "${1%%/*}"; }
|
|
_fw_proto() { case "$1" in */*) printf '%s' "${1##*/}" ;; *) printf 'tcp' ;; esac; }
|
|
|
|
firewall_open() {
|
|
# No backend argument (a zero-arg call under a `set -u` caller) is a
|
|
# no-op, not an unbound-variable abort.
|
|
local backend="${1:-}"
|
|
[ -n "$backend" ] || return 0
|
|
shift
|
|
local spec port proto
|
|
case "$backend" in
|
|
firewalld)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
_fw_run firewall-cmd --permanent "--add-port=${port}/${proto}" || true
|
|
done
|
|
_fw_run firewall-cmd --reload || true
|
|
;;
|
|
ufw)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
_fw_run ufw allow "${port}/${proto}" || true
|
|
done
|
|
;;
|
|
iptables)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
# -C tests for an existing rule so we stay idempotent; -I only if absent.
|
|
_fw_run iptables -C INPUT -p "$proto" --dport "$port" -j ACCEPT || \
|
|
_fw_run iptables -I INPUT -p "$proto" --dport "$port" -j ACCEPT || true
|
|
done
|
|
;;
|
|
nftables)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
# Best-effort: requires an inet filter/input chain to exist.
|
|
_fw_run nft add rule inet filter input "$proto" dport "$port" accept || true
|
|
done
|
|
;;
|
|
none|*)
|
|
return 0
|
|
;;
|
|
esac
|
|
}
|
|
|
|
firewall_close() {
|
|
# Same zero-arg guard as firewall_open.
|
|
local backend="${1:-}"
|
|
[ -n "$backend" ] || return 0
|
|
shift
|
|
local spec port proto
|
|
case "$backend" in
|
|
firewalld)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
_fw_run firewall-cmd --permanent "--remove-port=${port}/${proto}" || true
|
|
done
|
|
_fw_run firewall-cmd --reload || true
|
|
;;
|
|
ufw)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
_fw_run ufw delete allow "${port}/${proto}" || true
|
|
done
|
|
;;
|
|
iptables)
|
|
for spec in "$@"; do
|
|
port="$(_fw_port "$spec")"; proto="$(_fw_proto "$spec")"
|
|
_fw_run iptables -D INPUT -p "$proto" --dport "$port" -j ACCEPT || true
|
|
done
|
|
;;
|
|
nftables|none|*)
|
|
# nftables rule deletion needs the rule handle; we can't reliably
|
|
# find it after the fact, so leave it to the operator.
|
|
return 0
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Warn-and-document fallback text for when no firewall could be driven.
|
|
firewall_manual_hint() {
|
|
local ports="${1:-}"
|
|
printf 'Open these ports manually so the panel is reachable: %s\n' "$ports"
|
|
}
|