#!/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 / # 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