19 KiB
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-servermust fail unless firewalld is installed, enabled, and active after oneenable --nowattempt.- 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-cmdmutations 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/.bashrcoutside 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 publicsetup-serverfixture.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:
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:
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:
{
"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:
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 -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:
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:
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:
"$PANAMA_PATH/server/scripts/update-firewall" apply
- Step 5: Add the manifest entry and verify first-run behavior
# Server firewall policy runs against stateful systemctl/firewall-cmd adapters.
hermetic tests/setup/server-firewall-contract
Run:
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
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:
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
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
python3 -m py_compile server/scripts/update-firewall
tests/setup/server-firewall-contract
./bin/panama test --safe server-firewall
git diff --check
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
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:
@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
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
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.mdif it documents server setup - Modify:
tests/setup/readme-contract - Modify:
tests/server/containers-shape-contractonly 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
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 -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
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"