Open what is shut, and never widen what is already open

setup-server queried --query-port, saw "not open", and blanket-opened 80,
443 and 81. On a machine reaching those ports through rich rules -- one CDN's
address sets, say -- neither appears in --list-ports, so the step read a
deliberate, narrow grant as "closed" and replaced it with a public one. It
did this during `panama update`, which asks nothing and runs unattended, so
there was no prompt and no diff for anyone to read. A production server lost
its firewall posture that way, port 81 and the proxy's admin portal included.

Each port is now checked for existing coverage first -- an open port, an
enabled service, or an accepting rich rule, ranges included -- and left alone
when it has some. The bias stays where it was: only a rule that positively
reads as a grant counts, and anything unrecognised still falls through to
opening the port, because a fresh server that is silently unreachable is the
worse failure. A rule that DROPS a port is not coverage either; reading a
block as a grant would leave the proxy unreachable on a machine nobody
thought to re-check.

A machine that manages its own firewall opts out once, with
`echo manual > ~/.local/state/panama/firewall`, and Panama never touches
firewalld on it again. A file rather than an environment variable, because
the run that needs to respect it is the unattended one.

tests/setup/server-firewall-contract pins all of it, and fails against the
previous code on exactly the cases that cost the posture.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
gibandClaude Opus 5 committed 2026-10-01 17:50:14 -04:00
1 parent eb0056ac94
commit a493fa5584
6 files changed
+268 -7

No files matched your search

+3 -3
View File
@@ -2,9 +2,9 @@
# is the shell environment plus containers, and almost everything it needs is
# already core.
#
# setup-server opens 80/443/81 through it. Fedora Server ships it; a minimal
# cloud image may not, and a server whose firewall step silently no-ops is a
# server somebody believes is firewalled.
# setup-server opens 80/443/81 through it, where nothing already grants them.
# Fedora Server ships it; a minimal cloud image may not, and a server whose
# firewall step silently no-ops is a server somebody believes is firewalled.
firewalld
# Node comes through nvm here for the same reason as on the desktop --
# config/bash/shell switches versions per project from .nvmrc -- and because
+71 -1
View File
@@ -43,11 +43,81 @@ fi
# documents that in its own folder and it is opened by hand, because a list of
# per-service firewall holes maintained by an installer is a list nobody
# audits.
if systemctl is-active firewalld >/dev/null 2>&1; then
#
# "Open" means reachable, not literally listed by --list-ports. A machine can
# already reach 80 through a rich rule admitting one CDN's address set, or
# through an enabled service. On such a machine `--add-port` is not the no-op
# it looks like: it replaces a deliberate, narrow grant with a blanket one,
# and does it during a routine `panama update` that asked nobody anything.
# So each port is checked for existing coverage and left alone when it has
# some. Panama opens what is shut; it does not widen what is already open.
#
# The bias is deliberate. Only a rule that positively reads as covering the
# port counts, and anything unrecognised falls through to opening it, because
# a fresh server that silently ends up unreachable is the worse failure --
# see the firewalld comment in setup/packages/server-packages. For the same
# reason only `accept` rules count: a rich rule that drops a port is not
# coverage, it is the opposite, and treating it as coverage would leave the
# reverse proxy unreachable on a machine nobody thought to check.
#
# A machine that manages its own firewall opts out once:
#
# echo manual > ~/.local/state/panama/firewall
#
# after which Panama never touches firewalld on it again. A file rather than
# an environment variable because `panama update` runs unattended and asks
# nothing; a decision that lives only as long as somebody exports a variable
# is a decision that quietly lapses on the next run.
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
FIREWALL_DECISION="$STATE_DIR/firewall"
# Every port an enabled permanent service already grants, one <port>/<proto>
# per line. Read back out of firewalld rather than from a hardcoded http=80
# table, so a machine's own service definition counts for exactly as much as
# a shipped one.
service_ports() {
local service ports
for service in $(sudo firewall-cmd --permanent --list-services 2>/dev/null || true); do
ports="$(sudo firewall-cmd --permanent --info-service="$service" 2>/dev/null \
| sed -n 's/^[[:space:]]*ports:[[:space:]]*//p')" || true
[[ -n "$ports" ]] || continue
printf '%s\n' $ports
done
}
# Does an accepting rich rule already admit this tcp port? Ranges count, so a
# rule granting 8000-8100 covers 8080.
rich_rule_covers() {
local port="$1" rule value low high
while IFS= read -r rule; do
[[ "$rule" == *accept* ]] || continue
[[ "$rule" == *'protocol="tcp"'* ]] || continue
value="${rule#*port port=\"}"
[[ "$value" != "$rule" ]] || continue
value="${value%%\"*}"
if [[ "$value" == *-* ]]; then
low="${value%%-*}"; high="${value##*-}"
[[ "$low" =~ ^[0-9]+$ && "$high" =~ ^[0-9]+$ ]] || continue
(( port >= low && port <= high )) && return 0
else
[[ "$value" == "$port" ]] && return 0
fi
done < <(sudo firewall-cmd --permanent --list-rich-rules 2>/dev/null || true)
return 1
}
if [[ "$(cat "$FIREWALL_DECISION" 2>/dev/null || true)" == manual ]]; then
log "Firewall left to this machine ($FIREWALL_DECISION says manual)"
elif systemctl is-active firewalld >/dev/null 2>&1; then
granted_ports="$(service_ports)"
reload_needed=0
for port in 80 443 81; do
if sudo firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then
log "Port ${port}/tcp already open"
elif grep -qxF "${port}/tcp" <<<"$granted_ports"; then
log "Port ${port}/tcp already granted by an enabled service; leaving it alone"
elif rich_rule_covers "$port"; then
log "Port ${port}/tcp already granted by a rich rule; leaving it alone"
else
log "Opening port ${port}/tcp"
sudo firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null