Files
dashboards/deploy/gateway-routectl-README.md
T
ducoterra 86c8bf4992 phase: 33_gateway_route_switching
Phase 33 completion pass is done. Final report:

## Phase 33 — completion report

**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.

### Work in this pass

1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).

2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.

3. **Documentation consistency fixes** (task 08 criterion 6):
   - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
   - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
   - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
   - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.

### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported

### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
2026-11-01 11:50:51 -05:00

2.7 KiB

The route-switch helper (gateway hosts)

The dashboard's one narrow write is a route switch: one named connection on the ingress goes #off (a marker the agent's own parser reads) or comes back. It is not a sudoers line. The grant is a file mode systemd enforces: the socket the agent talks to is 0600 and owned by the agent's own user, and the helper's peer check (--allow-uid) is the second look. No account on the host — ops included — can reach the write; only the agent's uid can connect, and only that uid is accepted on the other end.

The two units

unit what it is
dashboards-routectl.socket a systemd-activated stream socket at /run/dashboards-routectl.sock, 0600, owned by dashboards-agent
dashboards-routectl@.service the helper, root, one instance per connection (the connected socket on stdin), answers one request and exits

The service unit's ReadWritePaths lists exactly the directories whose file the helper replaces — /etc/haproxy, /etc/nginx/conf.d, /etc/nginx/stream.d — and nothing else. There is never a long-lived privileged process: each request spawns a fresh instance that exits after its one answer.

See them

systemctl status dashboards-routectl.socket
systemctl status 'dashboards-routectl@*.service'
journalctl -u 'dashboards-routectl@*'

Run the helper by hand on the box

The installed copy is /usr/local/sbin/dashboards-routectl.py. It takes its agent parser by file path and refuses to run without one:

# what is off (the state sidecar)
sudo /usr/local/sbin/dashboards-routectl.py list

# preview one route without writing a byte (no checker, no reload, no state)
sudo /usr/local/sbin/dashboards-routectl.py diff --id nginx:443->127.0.0.1:44450 --action enable

# verify the sidecar against the files (drift is reported, never repaired)
sudo /usr/local/sbin/dashboards-routectl.py verify

A manual disable --apply runs the whole protocol: write, the proxy's own checker (nginx -t / haproxy -c -f), restore the previous bytes on a checker failure, systemctl reload (never restart) on success, then record the state.

Undo it all

sudo ./deploy/install-agent.sh --without-routectl

That disables and removes the helper and its two units, leaves the proxy configs and the helper's state directory alone, and prints the list of still-off routes first — those are yours to switch back on.

The one safety sentence

No sudoers line is involved. The agent's unit keeps every one of its security directives (NoNewPrivileges=true, ProtectSystem=strict, …); the read-only collector stayed read-only in its sandbox and talked to a peer outside it. The grant is the socket's mode and ownership, enforced by systemd.