Files
Panama/docs/superpowers/plans/2026-08-27-server-firewall-transaction.md
T

407 lines
19 KiB
Markdown

# 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"
```