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]>
171 lines
8.0 KiB
Bash
Executable File
171 lines
8.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# What makes a Fedora machine able to run rootless compose services. Server
|
|
# role only -- ./install never runs this on a desktop. Idempotent throughout:
|
|
# every step checks the machine before touching it, so a re-run on a machine
|
|
# that already has all of this changes nothing and says so.
|
|
|
|
set -euo pipefail
|
|
|
|
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
|
|
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
|
|
|
# ── Linger ───────────────────────────────────────────────────────────────────
|
|
# Without it every user unit -- which is every service -- stops at logout and
|
|
# starts only at login, which on a server means "runs while somebody is SSHed
|
|
# in". Linger is what makes the user session a real init.
|
|
if loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
|
|
log "Linger already enabled for $USER"
|
|
else
|
|
log "Enabling linger for $USER"
|
|
sudo loginctl enable-linger "$USER"
|
|
fi
|
|
|
|
# ── Unprivileged ports from 80 ───────────────────────────────────────────────
|
|
# Rootless containers cannot bind 80/443 while the kernel reserves everything
|
|
# below 1024 for root. Lowering the floor to 80 is what lets the reverse proxy
|
|
# be a rootless container like everything else. A file in /etc/sysctl.d so it
|
|
# survives reboots; sysctl --system so it applies now.
|
|
SYSCTL_FILE=/etc/sysctl.d/99-rootless-ports.conf
|
|
SYSCTL_WANT='net.ipv4.ip_unprivileged_port_start=80'
|
|
if [[ -r "$SYSCTL_FILE" ]] && grep -qx "$SYSCTL_WANT" "$SYSCTL_FILE"; then
|
|
log "Unprivileged ports already start at 80 ($SYSCTL_FILE)"
|
|
else
|
|
log "Allowing unprivileged binds from port 80"
|
|
printf '%s\n' "$SYSCTL_WANT" | sudo tee "$SYSCTL_FILE" >/dev/null
|
|
sudo sysctl --system >/dev/null
|
|
fi
|
|
|
|
# ── Firewall ─────────────────────────────────────────────────────────────────
|
|
# 80 and 443 because everything is reverse-proxied; 81 for the proxy's own
|
|
# admin portal. Deliberately nothing else: a service needing another port open
|
|
# 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.
|
|
#
|
|
# "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
|
|
reload_needed=1
|
|
fi
|
|
done
|
|
(( reload_needed )) && sudo firewall-cmd --reload >/dev/null
|
|
else
|
|
log "firewalld is not active; no ports to open"
|
|
fi
|
|
|
|
# ── The shared container network ─────────────────────────────────────────────
|
|
# Every compose file expects nginx-bridge as an external network: the reverse
|
|
# proxy reaches each service by container name across it, and no service needs
|
|
# a published port of its own. External means compose will not create it, so
|
|
# somebody has to -- this is that somebody.
|
|
if podman network exists nginx-bridge 2>/dev/null; then
|
|
log "podman network nginx-bridge already exists"
|
|
else
|
|
log "Creating podman network nginx-bridge"
|
|
podman network create nginx-bridge >/dev/null
|
|
fi
|
|
|
|
# ── Nightly image updates ────────────────────────────────────────────────────
|
|
# server/scripts/update-containers, on a midnight timer. Linked rather than
|
|
# copied so a pull updates the machinery with everything else; see the script
|
|
# header for why this replaced watchtower.
|
|
UNIT_SRC="$PANAMA_PATH/server/systemd"
|
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
mkdir -p "$UNIT_DIR" "$HOME/Server/logs"
|
|
for unit in podman-update.service podman-update.timer; do
|
|
src="$UNIT_SRC/$unit"
|
|
dst="$UNIT_DIR/$unit"
|
|
[[ -e "$src" ]] || { log "Missing $src; skipping the update timer"; continue; }
|
|
if [[ -L "$dst" ]]; then
|
|
rm "$dst"
|
|
elif [[ -e "$dst" ]]; then
|
|
mv "$dst" "$dst.pre-panama"
|
|
log "Kept the existing $unit as $unit.pre-panama"
|
|
fi
|
|
ln -s "$src" "$dst"
|
|
log "Linked $unit"
|
|
done
|
|
systemctl --user daemon-reload
|
|
if systemctl --user is-enabled podman-update.timer >/dev/null 2>&1; then
|
|
log "podman-update.timer already enabled"
|
|
else
|
|
log "Enabling podman-update.timer (nightly image updates)"
|
|
systemctl --user enable --now podman-update.timer
|
|
fi
|