# Server firewall transaction implementation plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make `setup-server` establish Cloudflare-scoped public web access and WireGuard-only admin access as one recoverable firewalld transaction. **Architecture:** Put validation, desired-state calculation, journaling, and rollback in one standard-library Python command. Keep `setup-server` as the public orchestrator that verifies firewalld and invokes the updater; test both through stateful curl/systemd/firewalld command adapters with separate permanent/runtime state. Production endpoint URLs stay hardcoded, so no environment variable can redirect policy input. **Tech Stack:** Python 3 standard library (`argparse`, `dataclasses`, `fcntl`, `hashlib`, `ipaddress`, `json`, `os`, `pathlib`, `signal`, `subprocess`), Bash 5, curl/firewalld/systemd command adapters, Panama contract runner. **Spec:** `docs/superpowers/specs/2026-08-27-secure-bootstrap-privileged-installation-design.md` ## Global constraints - Do not call the host's `firewall-cmd`, mutate firewalld/systemd, contact Cloudflare from a contract, or apply a real server policy. - `setup-server` must fail unless firewalld is installed, enabled, and active after one `enable --now` attempt. - Validate both Cloudflare families completely before any privileged mutation. One bad family changes nothing. - Public 80/443 must use dedicated Panama ipsets and exactly four rich rules. Port 81 belongs only to a unique WireGuard-only zone; no WireGuard zone leaves it closed. - Never remove unrelated user-managed rules or service-specific ports. Only exact old direct 80/443/81 rules are eligible for the documented legacy migration. - Keep old runtime rules until one successful reload. Roll back every failed transaction and retain the journal when rollback cannot be verified. - Run the repository updater as the normal user. Only narrow `systemctl`/`firewall-cmd` mutations use sudo. - No privileged timer or background service is added. - The new contract is hermetic and runs under `panama test --safe`. - Preserve `/home/gib/.local/share/Panama/config/bash/.bashrc` outside this worktree. ## File map - `server/scripts/update-firewall`: parser, range validation, zone/policy inspection, desired state, journal, mutation, reload/readback, rollback. - `server/firewall/cloudflare-v4.cidrs`: committed current canonical IPv4 ranges. - `server/firewall/cloudflare-v6.cidrs`: committed current canonical IPv6 ranges. - `tests/setup/server-firewall-contract`: stateful public `setup-server` fixture. - `tests/setup/fixtures/firewall/`: valid/invalid/refreshed CIDR response data. - `setup/scripts/setup-server`: required firewalld postconditions and updater call. - `setup/packages/server-packages`: document firewalld as a required package, not a best-effort extra. - `tests/contracts.manifest`: one new hermetic contract. - `README.md`, `server/README.md`: exact exposure, refresh, and rollback policy. --- ### Task 1: Require firewalld and establish the first desired policy **Files:** - Create: `server/scripts/update-firewall` - Create: `server/firewall/cloudflare-v4.cidrs` - Create: `server/firewall/cloudflare-v6.cidrs` - Create: `tests/setup/server-firewall-contract` - Create: `tests/setup/fixtures/firewall/valid-v4.cidrs` - Create: `tests/setup/fixtures/firewall/valid-v6.cidrs` - Modify: `setup/scripts/setup-server:40-60` - Modify: `setup/packages/server-packages` - Modify: `tests/contracts.manifest` **Interfaces:** - Consumes: `server/scripts/update-firewall apply`, `server/scripts/update-firewall --validate-only DIRECTORY`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, PATH adapters, and committed bootstrap CIDRs. - Produces: `main(argv) -> int`, `parse_cidrs(text, family) -> tuple[str, ...]`, `generation_name(family, cidrs) -> str`, `run_firewall(args, permanent=True)`, validation-only mode, and first-run policy application. - [ ] **Step 1: Create current canonical range fixtures** Use the official 2026-08-27 Cloudflare list as both committed bootstrap and initial valid fixture. `cloudflare-v4.cidrs` must contain exactly: ```text 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 104.16.0.0/13 104.24.0.0/14 108.162.192.0/18 131.0.72.0/22 141.101.64.0/18 162.158.0.0/15 172.64.0.0/13 173.245.48.0/20 188.114.96.0/20 190.93.240.0/20 197.234.240.0/22 198.41.128.0/17 ``` `cloudflare-v6.cidrs` must contain exactly: ```text 2400:cb00::/32 2405:8100::/32 2405:b500::/32 2606:4700::/32 2803:f800::/32 2a06:98c0::/29 2c0f:f248::/32 ``` The production refresh endpoints are exactly `https://www.cloudflare.com/ips-v4` and `https://www.cloudflare.com/ips-v6`. The contract's PATH `curl` adapter returns fixture bytes for only those exact URLs; no environment variable or config can replace them, and the contract never contacts either endpoint. - [ ] **Step 2: Build the stateful command model and red first-run cases** Store fixture state in JSON: ```json { "systemctl": {"installed": true, "enabled": true, "active": true}, "permanent": { "defaultZone": "public", "zones": { "public": {"interfaces": ["eth0"], "target": "default", "services": ["ssh"], "ports": ["80/tcp", "443/tcp", "81/tcp", "2222/tcp"], "richRules": []}, "wireguard": {"interfaces": ["wg0"], "target": "default", "services": [], "ports": [], "richRules": []} }, "ipsets": {} }, "runtime": {} } ``` The Python `firewall-cmd` stub must support only the exact query/mutation argv used by the updater, append JSON argv arrays to `calls.jsonl`, keep permanent/runtime separate, copy permanent to runtime on reload, and reject unknown argv with status `97`. The curl stub accepts only `--fail --silent --show-error --location --connect-timeout 10 --max-time 30 --max-filesize 65536 --output PATH URL`, logs argv, and copies the selected fixture response. `systemctl` supports `list-unit-files`, `is-enabled`, `is-active`, and `enable --now`. `sudo` logs and execs. Stub linger, sysctl, podman, and user-systemctl paths so the real `setup-server` finishes without host access. Write red cases: ```text firewalld missing -> nonzero; zero firewall mutations enable/start failure -> nonzero; zero firewall mutations postcheck inactive/disabled -> nonzero; zero firewall mutations valid first run -> content-addressed v4/v6 ipsets, four rich rules, 81 only in wireguard, legacy direct 80/443/81 removed, ssh and 2222 preserved, one check-config and one reload no wireguard zone -> 80/443 policy succeeds, 81 absent, exact closed-port diagnostic ``` - [ ] **Step 3: Run the contract and confirm current setup is red** ```bash bash -n setup/scripts/setup-server tests/setup/server-firewall-contract tests/setup/server-firewall-contract ``` Expected: nonzero because current setup treats inactive firewalld as success and opens all three ports directly. - [ ] **Step 4: Implement validation, desired state, and first apply** Start the updater with: ```python def parse_cidrs(text: str, family: int) -> tuple[str, ...]: if len(text.encode("utf-8")) > 65536: raise PolicyError("CIDR response exceeds 65536 bytes") lines = text.splitlines() if not lines: raise PolicyError("CIDR list is empty") parsed: list[str] = [] for raw in lines: if not raw or raw != raw.strip() or any(ch.isspace() for ch in raw): raise PolicyError(f"invalid CIDR line: {raw!r}") network = ipaddress.ip_network(raw, strict=True) if network.version != family or network.prefixlen == 0 or str(network) != raw: raise PolicyError(f"invalid IPv{family} CIDR: {raw}") parsed.append(raw) if len(parsed) != len(set(parsed)): raise PolicyError("CIDR list contains duplicates") return tuple(parsed) def generation_name(family: int, cidrs: tuple[str, ...]) -> str: digest = hashlib.sha256(("\n".join(cidrs) + "\n").encode()).hexdigest()[:12] return f"panama-cf{family}-{digest}" ``` Build exactly these rich-rule strings, substituting the content-addressed generation name: ```text rule family="ipv4" source ipset="V4_GENERATION" port port="80" protocol="tcp" accept rule family="ipv4" source ipset="V4_GENERATION" port port="443" protocol="tcp" accept rule family="ipv6" source ipset="V6_GENERATION" port port="80" protocol="tcp" accept rule family="ipv6" source ipset="V6_GENERATION" port port="443" protocol="tcp" accept ``` Implement `main(argv)` with only two accepted forms: `apply` and `--validate-only DIRECTORY`. Validation-only mode reads `DIRECTORY/cloudflare-v4.cidrs` and `DIRECTORY/cloudflare-v6.cidrs`, runs the same strict parser used by apply, prints the two generation names, and exits without consulting systemd, the network, config/state directories, sudo, or firewalld. Unknown or combined arguments exit nonzero with usage. Query default/public and WireGuard zones before mutation. Apply new permanent ipsets/rules, exact legacy removal, `--check-config`, one `--reload`, and readback. The first task may use an in-memory snapshot; Task 3 adds durable recovery. Replace the old port loop in `setup-server` with exact firewalld installation/state checks: `command -v firewall-cmd`, `systemctl list-unit-files firewalld.service`, one `sudo systemctl enable --now firewalld.service`, then successful `systemctl is-enabled firewalld.service` and `systemctl is-active firewalld.service`. Only after those postconditions invoke: ```bash "$PANAMA_PATH/server/scripts/update-firewall" apply ``` - [ ] **Step 5: Add the manifest entry and verify first-run behavior** ```text # Server firewall policy runs against stateful systemctl/firewall-cmd adapters. hermetic tests/setup/server-firewall-contract ``` Run: ```bash bash -n setup/scripts/setup-server tests/setup/server-firewall-contract python3 -m py_compile server/scripts/update-firewall server/scripts/update-firewall --validate-only server/firewall tests/setup/server-firewall-contract tests/setup/contract-manifest-contract ./bin/panama test --safe server-firewall git diff --check ``` - [ ] **Step 6: Commit the required first policy** ```bash git add server/scripts/update-firewall server/firewall tests/setup/fixtures/firewall \ tests/setup/server-firewall-contract setup/scripts/setup-server \ setup/packages/server-packages tests/contracts.manifest git commit -m "Fix: Establish scoped server firewall policy" ``` --- ### Task 2: Reject bad ranges, unsafe zones, and foreign exposure **Files:** - Modify: `server/scripts/update-firewall` - Modify: `tests/setup/server-firewall-contract` - Create: `tests/setup/fixtures/firewall/refreshed-v4.cidrs` - Create: `tests/setup/fixtures/firewall/refreshed-v6.cidrs` - Create: `tests/setup/fixtures/firewall/invalid-*.cidrs` **Interfaces:** - Consumes: `parse_cidrs`, content generations, first-run apply, the exact PATH curl adapter, and stored zone config. - Produces: `load_zone_config`, `select_zones`, `find_exposure_conflicts`, refresh fallback, and idempotent convergence. - [ ] **Step 1: Add exhaustive red validation and selection tables** Fixture-invalid classes are exact: ```text empty blank-line leading-space trailing-token comment host-bits-set wrong-family slash-zero duplicate non-UTF8 oversized-65537-bytes valid-v4-plus-invalid-v6 invalid-v4-plus-valid-v6 ``` For each, assert byte-for-byte permanent/runtime/config/journal equality and zero mutation argv. Add zone cases for multiple `wg*` zones, public equal to WireGuard, stored missing zone, and WireGuard zone containing `wg0` plus `eth1`; all fail before mutation. Zero WireGuard candidates remains the safe success from Task 1. Add conflicts: public `http`/`https` service, port range covering 80/443, ACCEPT zone target, and unrelated rich rule accepting 80/443. Assert a diagnostic with exact `firewall-cmd --zone=ZONE --list-all` inspection command and no deletion. - [ ] **Step 2: Add red idempotence and two-family refresh cases** Run the same valid policy twice. The second run must have no mutating `firewall-cmd`, check-config, or reload calls. Then provide valid refreshed v4/v6 lists and assert both generations change in one transaction; one changed plus one invalid changes neither. Simulate curl network failure. With a valid installed policy, assert warning plus no changes. With no policy, assert the committed bootstrap pair is used. - [ ] **Step 3: Run the contract and confirm missing validation behavior** ```bash tests/setup/server-firewall-contract ``` Expected: nonzero on malformed, conflict, refresh, and idempotence cases not yet implemented. - [ ] **Step 4: Implement strict refresh, zone config, conflict detection, and no-op convergence** Fetch both hardcoded official endpoints without sudo using curl argv `--fail --silent --show-error --location --connect-timeout 10 --max-time 30 --max-filesize 65536 --output PART URL`. Use a private destination, verify its actual size is at most 65,536 bytes, and decode with strict ASCII before calling `parse_cidrs`. Treat the pair as one candidate and remove partial files on every exit/signal. Parse config with explicit `key=value` names and no shell execution. Before mutation, compute normalized current and desired Panama-owned state. If identical, print `Server firewall policy already current` and return without check-config/reload. Detect foreign broad exposure but allow unrelated ports/services such as SSH and 2222. Remove only exact direct legacy ports. Write selected-zone config to a private `.part` and rename it only after successful policy readback. Omit `wireguard_zone` when no candidate exists. - [ ] **Step 5: Run focused checks and commit** ```bash python3 -m py_compile server/scripts/update-firewall tests/setup/server-firewall-contract ./bin/panama test --safe server-firewall git diff --check ``` ```bash git add server/scripts/update-firewall tests/setup/server-firewall-contract \ tests/setup/fixtures/firewall git commit -m "Fix: Validate firewall ranges and ownership" ``` --- ### Task 3: Journal and roll back interrupted firewall transactions **Files:** - Modify: `server/scripts/update-firewall` - Modify: `tests/setup/server-firewall-contract` **Interfaces:** - Consumes: normalized current/desired state and mutation adapter from Tasks 1-2. - Produces: `PolicySnapshot`, `write_pending`, `restore_snapshot`, `verify_snapshot`, signal-safe rollback, and pending-journal recovery. - [ ] **Step 1: Add red failure injection and journal cases** The firewall stub accepts `FAIL_ONCE_JSON`, an exact normalized argv array. Inject one failure at each mutation boundary: create ipset, add entry, add rich rule, add 81, remove legacy port, remove old rule, remove old ipset, check-config, and reload. Separately inject permanent/runtime readback mismatch, selected-zone config write/rename failure, and pending-journal deletion failure after reload; each is still a failed transaction and must restore the snapshot. For every case assert updater nonzero, permanent/runtime restored byte-for-byte, rollback reload performed when needed, and journal removed only after verified restoration. Add rollback-reload failure: journal remains with mode `0600`, diagnostic names its absolute path and exact retry command. Seed a valid `pending.json` before invocation and assert restoration happens before curl or new-policy evaluation. Seed malformed, symlinked, foreign-owned, and mode-wrong journals; assert fail closed without mutation. Start an apply process, block one mutation, send TERM to the updater's exact PID, release the stub, and require status `143`, restored state, and no orphan process. - [ ] **Step 2: Run the contract and confirm current failure paths are red** ```bash tests/setup/server-firewall-contract ``` Expected: nonzero because the updater has no durable journal or exact-PID signal rollback. - [ ] **Step 3: Implement checked journal and rollback ownership** Use a frozen dataclass with JSON round-trip methods: ```python @dataclasses.dataclass(frozen=True) class PolicySnapshot: public_zone: str wireguard_zone: str | None panama_ipsets: dict[str, tuple[str, ...]] panama_rich_rules: tuple[str, ...] legacy_ports: tuple[str, ...] wireguard_has_81: bool ``` Acquire `${state}/firewall/lock` with `fcntl.flock(LOCK_EX | LOCK_NB)`. Reject symlinked state components. Write `pending.json.part` with mode `0600`, fsync file and directory, then rename before the first mutation. Start each mutating sudo/firewalld subprocess in its own process group and track its leader PID. INT/TERM handlers signal that exact group, wait for it, call `restore_snapshot`, verify permanent and runtime equality after rollback reload, then exit 130/143. The contract asserts the recorded child is gone before it returns. Normal completion clears handlers before deleting the journal. At startup, a valid pending journal is restored and verified before network/config evaluation. Invalid journal ownership/mode/shape fails closed and prints inspection instructions. - [ ] **Step 4: Verify all injected failures and signals** ```bash python3 -m py_compile server/scripts/update-firewall tests/setup/server-firewall-contract ./bin/panama test --safe server-firewall git diff --check ``` - [ ] **Step 5: Commit recovery behavior** ```bash git add server/scripts/update-firewall tests/setup/server-firewall-contract git commit -m "Fix: Roll back firewall transactions" ``` --- ### Task 4: Document policy and run the complete firewall gate **Files:** - Modify: `README.md:56-64` - Modify: `server/README.md:1-90` - Modify: `.claude/skills/panama/SKILL.md` - Modify: `skills/panama-sudo/SKILL.md` if it documents server setup - Modify: `tests/setup/readme-contract` - Modify: `tests/server/containers-shape-contract` only if it asserts the old global 81 policy **Interfaces:** - Consumes: final updater behavior and its exact operator diagnostics. - Produces: accurate exposure/refresh/rollback documentation and Package 2 firewall verification evidence. - [ ] **Step 1: Write documentation assertions before prose** Require README/server README to state: firewalld required, Cloudflare-only 80/443, dedicated IPv4/IPv6 ipsets, WireGuard-only 81, no automatic service ports, invalid refresh preserves last known good, one recoverable transaction, and no privileged timer. Reject the old statement that setup simply opens 80/443/81. - [ ] **Step 2: Confirm old documentation fails** ```bash tests/setup/readme-contract ``` - [ ] **Step 3: Update docs without live-cutover claims** Explain that `setup-server` and server upgrades refresh the policy; first setup may use committed official ranges; missing WireGuard leaves 81 closed; conflicts require manual inspection; fixture tests model rollback but do not apply host rules. - [ ] **Step 4: Run the firewall plan gate** ```bash bash -n setup/scripts/setup-server tests/setup/server-firewall-contract python3 -m py_compile server/scripts/update-firewall server/scripts/update-firewall --validate-only server/firewall tests/setup/server-firewall-contract tests/setup/role-contract tests/setup/readme-contract tests/server/containers-shape-contract ./bin/panama test --safe git diff --check ``` Expected: 134 hermetic contracts pass after the SSH, provenance, and firewall contracts exist; non-hermetic skip counts remain unchanged. - [ ] **Step 5: Commit documentation and final fixture adjustments** ```bash git add README.md server/README.md .claude/skills/panama/SKILL.md \ skills/panama-sudo/SKILL.md tests/setup/readme-contract \ tests/server/containers-shape-contract git commit -m "Docs: Explain the server firewall transaction" ```