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.
20 KiB
Phase 33 — The switch is wired: turning one route off, and back on, without ever weakening the box
Source: operator request, 2026-10-23 — "Also write the phase to disable and enable services. I want to be able to turn off and on connections as needed in the event of security concerns or upgrades." + the two decisions taken with them in that conversation: enforcement is a config edit, validated and reloaded, by a narrow root helper on the gateway (so a disabled route stays disabled across a reload and a reboot), and one switch per connection line, as already drawn.
Story: n/a
Context: deploy/install-agent.sh (the unit template, whose NoNewPrivileges=true / ProtectSystem=strict / SystemCallFilter=@system-service / empty CapabilityBoundingSet this phase does not touch, the --force re-run guard, deploy/agent-sudoers as the precedent for a path-pinned grant), agent/host_agent.py (1.3.0 → 1.4.0: do_GET-only Handler, _certificate_allows, the haproxy/nginx parsers that must learn the disabled marker), src/dashboards/sources/gateway.py (connections[], config_connections, warnings()' seven kinds), src/dashboards/api.py (/api/ups/command's four-gate shape and the certs_router()'s audit-event rule), src/dashboards/collectors.py (AgentTls, fetch_gateway_snapshot, the revoke-before-dial rule), src/dashboards/config.py (GATEWAY_* block), src/dashboards/web/static/page-gateway.js (buildSwitch, routeInConfig, renderLeaf), tests/test_api.py (the verb-enumeration pins), tests/test_auth_api.py (the served-route walk), tests/test_deploy_scripts.py (the fake-root sandbox the helper's tests extend).
Objective
/gateway's switch becomes a control: disable brain.reeseapps.com and the nginx stream map entry stops routing it, persistently, with the config validated and the proxy reloaded and the page showing that exact route red until it is re-enabled. One new write route, POST /api/gateway/route, gated seven times, with the write itself performed off-box by a root helper on the gateway that the agent reaches over a systemd-activated unix socket — because the agent's own unit forbids privilege (NoNewPrivileges=true) and this phase is not going to weaken the sandbox of a read-only collector to give it a root shell.
This amends AGENTS.md invariant 1 from one exception to two, and that is the phase's real risk: a bug here is an outage on the fleet's only ingress. Everything below exists to make the blast radius one line of one file, and to make the app refuse rather than improvise whenever it cannot see what it is doing.
Scope decisions taken with the operator (binding for this phase)
| # | Decision | Consequence |
|---|---|---|
| 1 | Invariant 1 becomes "two exceptions". | POST /api/ups/command and POST /api/gateway/route. Both: session, CSRF/origin on a cookie write, an env gate (NUT_ALLOW_ACTIONS / GATEWAY_ALLOW_WRITES, default off), a typed DANGER, and every gate re-checked server-side on every request. PLAN.md's anchor table records a read-only app, so this phase's overview carries the sign-off request; the phase never edits PLAN.md. |
| 2 | DANGER is required for both verbs. |
Enabling is not the safe direction: applying either change re-validates the file and reloads the proxy, and a reload touches every route on the box. A word that means "I know this affects the ingress" has to mean it both ways. |
| 3 | The agent keeps its sandbox; the privilege lives outside it. | No sudo from the agent, no setuid, no change to NoNewPrivileges/ProtectSystem/SystemCallFilter. The write peer is dashboards-routectl.socket + dashboards-routectl@.service (systemd socket activation, Accept=yes, User=root, SocketUser=dashboards-agent, SocketGroup=dashboards-agent, SocketMode=0600): the agent connects to a socket it is filemode-permitted to connect to, and no other uid on the box can even open it, and the helper proves the peer uid with SO_PEERCRED. Access control is a file mode plus the mTLS client certificate that already authorises the agent's other paths — not a capability the agent process can be tricked into. |
| 4 | Only an id crosses a wire. Never a path, never a line, never text. | The body is {id, action, danger?, dry_run?} where id is the payload's own connections[].id. The helper re-parses the configs itself, maps that id to the one line it declared, and refuses anything it cannot map. There is no file, no line, no pattern, no command anywhere in the contract, and the argv to the helper is fixed with no shell. |
| 5 | A route is off in the way that actually stops traffic for its proxy. | haproxy: the server line is commented with a # dashboards:off … was: <verbatim original> marker. nginx vhost: the proxy_pass line is commented the same way. nginx stream map entry: commenting it would fall through to the map's default (127.0.0.1:4443) and mis-route the domain instead of blocking it, so the helper rewrites that entry's value to a sink (127.0.0.1:44450) and keeps the original in the marker; the sink is a server block the helper maintains in /etc/nginx/stream.d/00-dashboards-sink.conf, which the existing stream { include /etc/nginx/stream.d/*.conf; } glob already loads — so no hand edit of the main config is ever required. The map's default entry is refused: it is the fallback for every unmapped name, not a service. |
| 6 | Validate, restore on failure, reload — never restart. | Write the new bytes, run nginx -t / haproxy -c -f …; on failure put the previous bytes back, do not reload, and answer applied: false, validated: false, restored: true. On success systemctl reload <unit> — reload, because a restart cuts every Minecraft session and every in-flight upload on an ingress that is doing its job. A reload failure is reported with the unit's own status and the file is left in the applied state (the config is correct; the reload is the operator's next action, said out loud). |
| 7 | Self-describing marker + drift refusal, not silent trust. | The disabled line carries the original verbatim after was:, so a human can restore it by eye and the helper can restore it mechanically. A sidecar /var/lib/dashboards-routectl/state.json (0600, root) records actor, timestamp and the sha256 of what was written. Enabling restores only if the current line still matches what the helper wrote; otherwise it refuses and reports drift — a file an operator hand-edited is theirs to fix, not the app's to guess at. |
| 8 | Refuse rather than act blind. | The write is refused when: writes are not enabled; the id is not in the current payload; the route is already in that state (changed: false, no reload); either config section is not available: true; the gateway job is ok: false or stale beyond 3× its interval; the host:<GATEWAY_HOST> system job is not ok; the agent is revoked (refused before any socket, the existing rule); the throttle is tripped. That is the UPS "refused while on battery" rule, translated: do not rewrite a config you cannot currently read end to end. |
| 9 | off finally means something the payload said. |
The agent's parsers learn the marker and report the marked server/proxy_pass/map entry with disabled: true, so the route stays in the graph as a declaration and connections[].enabled becomes false. routeInConfig() stops being a literal, the off styling phase 30 built becomes reachable, and warnings() learns the marker: a backend emptied by a marker is not haproxy_backend_empty (the operator chose that; the page shows it red), while a backend with no server line at all still is. |
| 10 | A live route's count is never stamped. | Disabling does not write 0 into the payload. The sockets drain on the reload and the next poll reports what /proc says. Invariant: no code path in this phase writes a count. |
| 11 | The audit is one event, fixed in shape, with no config text in it. | gateway-source events route-disabled / route-enabled / route-denied: verb, id, the session's actor name, applied/validated/reloaded/drift, and the proxy's own one-line error when there was one. The unified diff is returned to the caller's response (it is what the confirm dialog shows) and never stored — events are served to a browser and /api/events is a feed, not a config archive. |
| 12 | The overview is told, because "something is switched off" is not a page-only fact. | /api/summary's gateway block gains one field, disabled (an int count of enabled is false rows, null in the arms that did not measure) — the thirteenth key, restated in the pinned key set with its reason. A notifier has to learn about a deliberate outage without scraping a page, and it prints nothing at 0 (the warnings rule: a permanent 0 switched off is ink that trains the eye). |
| 13 | One route, one file, one reload per request. | A request touches exactly one config file and reloads exactly one proxy. There is no batch verb, no "disable all to this host", no dry_run that writes. If a security incident needs five routes off, that is five reviewed requests, each with its own event. |
Out of scope: editing anything a marker cannot express (adding/removing vhosts, certificates, bind lines, the map's default), haproxy runtime disable server via a stats socket (a runtime-only state that a reload silently undoes — decision 5 is persistent instead), disabling an entire host or port, per-user authorisation (one admin, gap 6), a scheduler ("off at 02:00"), and any change to the agent's unit security directives or to another host's agent (gateway alone runs 1.4.0; deskwork/driveripper's 1.1.0 agents have no routes and are unaffected).
Dependencies
32_gateway_resolved_target_liveness(todo, must complete first) — agent 1.3.0's resolver section and the counted name routes are what a switched-off line reads against:off+ a draining count to0is the proof the switch worked, and without resolution a disabled route and a never-counted route look identical.31_gateway_route_readability(todo) — the row that names the inbound domain, the green end-to-end route, and theoffstyling this phase finally reaches.30_gateway_connection_map(complete) —connections[].idas the vocabulary, the switch markup and its a11y contract,routeInConfig()as the named seam.15_agent_mtls/24_dev_mtls_rollout(complete) — the verified-client-certificate gate the new POST path inherits, and the revoke-before-dial rule this phase's collector reuses.11_admin_login(complete) — session, CSRF, origin checks,LoginThrottle(theroute:<address>bucket is its third key shape).- Operator actions, not repo work: the
dashboards-agent-to-helper socket grant lands through the units (no sudoers line needed), but installing units on a host and running the first live drill are the operator's — and the drill stops unless they are present.PLAN.md's anchor sign-off for invariant 1 is theirs too.
Tasks
01_the_root_helper_edits_exactly_one_route.md—deploy/dashboards-routectl.py:show/list/diff/disable/enable/verify, the marker withwas:, the sink file it manages, validate-restore-reload, drift refusal,--serveover a systemd-passed socket,--rootfor tests.02_the_privileged_peer_is_a_socket_not_a_sudo_line.md— the two systemd units +install-agent.sh --with-routectl; the proof that the agent'sNoNewPrivileges=true/ProtectSystem=strictunit is unchanged and can still reach the socket.03_the_agent_accepts_one_write.md— agent 1.4.0:POST /routebehind the verified client certificate and--allow-writes, body validation, one fixed peer call, plus the parsers reportingdisabledfor a marked line;/schemaand the additivity control.04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md—connections[].enabled,offreachable in the payload,warnings()marker-aware, stub/skeleton parity, the one new/api/summaryfield (gateway.disabled), and no count ever written.05_the_api_gates_the_write_ten_times.md—GATEWAY_ALLOW_WRITES,DASH_READ_ONLY,collectors.post_route,POST /api/gateway/route, the ten-gate order, the throttle, the events,/api/config.gateway.writes_enabled, and the verb-enumeration + served-route pins restated (this is where invariant 1 is amended inAGENTS.md).06_the_switch_obeys.md—page-gateway.js+gateway.html+dash.css:DANGERconfirm with the diff from adry_runfirst, patch from the response,routeInConfig()readingenabled, the writes-disabled arm that keeps the switch honest, footer paragraph 3.07_rehearse_then_drill_on_the_live_ingress.md— the fake-tree rehearsal, then the live install ongatewayand a drill on the one named-safe route, restore, verify,data/logs/NOTES-33-switching.md. Stops, correctly, without the operator present.08_docs_and_live_verify.md—AGENTS.md(invariant 1, the agent bullet,sources/gateway.py,api.py,collectors.py,config.py's block, the/gatewayUI paragraph, the fleet record, gap list) +README.md(including the operator's sudo/units steps), the browser pass, the mobile audit, the full gate.
Testing & Quality
- The helper is tested unprivileged, against a fake tree, with the proxy commands stubbed.
tests/test_routectl.pyruns the real helper in atmp_pathtree (--root) withnginx/haproxy/systemctlfaked onPATH, and pins: one line changed and nothing else in the file (byte-diff assertion);was:restores verbatim; a failing validator restores the exact previous bytes and reloads nothing;defaultrefused; an unknown id refused; a second disablechanged: false; the sink file created on first use, left alone if an operator's file is there, removed only when it wrote it and nothing is disabled; drift refused rather than restored;SO_PEERCREDrefused for a non-dashboards-agentuid in the fake-peer test; noshell=Trueanywhere (greppin, the installer's precedent). - The agent's write path is proven against a fake peer, not a real one.
tests/test_agent.pyruns the real handler over a TLS loopback port (the phase-15 harness) plus a unix-socket stub:POST /routewithout a client certificate is 403client certificate required; with--allow-writesabsent it is 403writes are not enabled on this agent; a body with an unexpected key, a bad id, or a missingdangeris refused before the peer is touched (asserted by the stub logging nothing);GETbehaviour is byte-identical to 1.3.0 (the additivity control re-run). - The API gates are proven twice, as the UPS ones are.
tests/test_api.pywalks every gate with spies: writes-disabled refuses without dialling; a stalegatewayjob refuses; a revoked agent refuses without a socket;DANGERis required fordisableandenable; a right request produces one peer call, one event, and the response'sapplied/validated/reloadedtriple.tests/test_auth_api.py's served-route walk demands a session for/api/gateway/route, and the CSRF pin is extended to it on the wire (a scripted helper records nothing without the header, once with it) — a 403 after the write is an outage with an error message attached. - Live, opt-in, and reversible:
-m live --livegainstest_the_switch_round_trip_on_one_named_routewhich is skipped unless--allow-live-writesis passed as well, names the route in the skip reason, and always restores in afinally. The default live suite stays read-only (AGENTS.md's rule survives this phase; the drill belongs to task 07 with the operator watching). - Coverage >90% on
deploy/dashboards-routectl.py,agent/host_agent.py,sources/gateway.py,collectors.py,api.py; the whole suite 100%;bash .agents/validate.shgreen.
Documentation & Consistency
- The final pass reviews
README.mdandAGENTS.mdin full against the project as it will stand after this phase and fixes every inconsistency found, including pre-existing ones. This phase's specific list: invariant 1 rewritten to two exceptions with the seven gates named (and the sentence "hiding a button is not a safety control" kept, because it is the reason the switches are gated server-side rather than greyed in JS); the agent bullet'sPOST /routeand thedisabledmarker; thesources/gateway.pybullet'senabled/offrules and the marker-awarewarnings(); theapi.pybullet's write lists (fleet writes are now two); thecollectors.pybullet (post_route, the revocation check it shares);config.py'sGATEWAY_ALLOW_WRITESrow and README's configuration table; the/gatewayUI-conventions paragraph (the switch is wired, whatofflooks like, what the writes-disabled arm says); the fleet record (gateway 1.4.0, its unit's security directives unchanged, the two new systemd units,NOTES-33-switching.md); and the gap list (name-route switching closed, plus the honest limits: one admin, no scheduler, no per-host kill). README.mdgains the operator's install steps for the helper units and the exact--with-routectlinvocation, plus the recovery command for the state file (systemctl status dashboards-routectl.socket,routectl list,routectl verify).- Tasks update both files immediately when their own changes are user-visible.
Completion Criteria
- With
GATEWAY_ALLOW_WRITES=1,/gatewaydisablesbrain.reeseapps.com: the confirm dialog shows the diff from adry_run, asks forDANGER, and the row turns red (off) on the poll after the reload while its live count drains to0on its own — no code wrote that zero. ssh gateway-ops 'sudo /usr/local/sbin/dashboards-routectl.py list'names exactly that route;verifyreports no drift; the marked line contains the original verbatim afterwas:; the map entry points at the sink andstream.d/00-dashboards-sink.confexists and is owned by the helper's header.- Enabling restores the original bytes exactly (byte-compare against a pre-drill copy), the row turns green, and
listis empty afterwards. The live ingress ends the phase in the state it started in. - Every gate refuses with its own sentence and without touching a peer: writes disabled, no session, no CSRF, wrong
Origin, missingDANGER, unknown id, already-in-state, section unreadable,gatewayjob stale,host:gatewaynotok, agent revoked. Each is a test, and each test asserts the stub peer logged nothing. - The agent's unit is unchanged:
systemctl cat dashboards-agentstill showsNoNewPrivileges=true,ProtectSystem=strict,SystemCallFilter=@system-service, and the same sha256 the phase recorded before installing the units — the sandbox was not traded for the feature. - A failing validator (
nginx -tstubbed to exit 1) restores the previous bytes, reloads nothing, answersapplied: false, validated: false, restored: true, and the page's line never turnedoff. POSTinventory: the app now has exactly two upstream writes (/api/ups/command,/api/gateway/route) and the enumeration pin lists them as such;AGENTS.mdinvariant 1 says two and names both gates;/api/healthgained nothing.uv run pytest(with--cov≥ 94),uv run ruff check src tests agent deploy,uv run pyright,node --check,bash .agents/validate.shall green;uv run pytest -m live --livegreen without--allow-live-writes(the round-trip test skipped by default, its reason naming the route it would touch).data/logs/33-*.png(switch off in both themes, theDANGERdialog with its diff, the writes-disabled arm, 360 px) anddata/logs/33-*.out;data/logs/27-task02-mobile-audit.cjsexits 0 with 0 FAIL / 0 FINDING;data/logs/NOTES-33-switching.mdrecords the drill, the hashes and the restore.- README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass), including the operator's steps for undoing all of this (
--without-routectl, remove the units, re-enable every route). - No behaviour change in completed work: the other five dashboards,
/settings,/certs,/login, the UPS command path and its four gates, and every non-gateway source behave exactly as phase 32 left them.