ADR-0030 — The proxy reloads its settings instead of restarting¶
- Status: Accepted (2026-09-23). Implemented (on
mainsince #31). - Sources: the 2026-09-23
repowerbuild failure below;tests/test_proxy_reload_e2e.py(a real mitmdump). Related: 0007 (the proxy is where credentials are attached), 0022 (the host runs the adopted config; reporting is part of the fix), 0029 (decryption andpassthrough).
Context¶
The supervisor restarts a daemon whose command or environment changed. The proxy's environment
carried everything that varies with posture: the injection rules, the wall switch, the
passthrough list, and a hash of the secrets its rules read (because a minter read its secret
from an environment fixed at launch). So every mode switch, every learn window ending, every
adopted passthrough edit and every rotated or newly captured token restarted mitmdump. A
restart closes every connection it carries.
On 2026-09-23 a fy box up in repower was installing Playwright's system packages when the
Sanity axis was switched from Viewer to Editor. The proxy restarted between two packages; the
download in flight was cut, and the new process took a quarter of a second to listen. apt got
one "connection refused", treated the proxy as down for the rest of its run, and failed all
~40 remaining packages without trying them. Anything without its own retry fails the same way,
including an agent's API stream.
The restart did one useful thing by accident: it also closed connections the new policy no
longer allowed. A blind tunnel (a passthrough host) is decided once, at its TLS handshake, and
never looked at again. The same was never true of fy allow remove, which has always been
applied without a restart and so left a revoked host's open tunnels running.
Decision¶
- Posture travels in a live file, not the launch environment. The proxy's spec carries only
what never changes with posture (log path, allowlist path, the live file's path,
host.env's path, the port). The rules, the wall switch and the passthrough list go inspec["live"]; the supervisor writes it each tick when it changed — whole, renamed into place — and logs… settings updated — … (no restart)when the change reaches a running proxy. - The addon re-reads it at the top of every hook and once a second (an idle proxy sees no hooks). A rule whose spec and secrets are unchanged keeps its object and so its cached token; a new rule warms off the request path at WARN, as at startup. An unreadable file fails closed: no rules, the wall enforcing, nothing tunnelled blind.
- Secrets are read by name from
host.env. Each rule already declares the names its minter may read (InjectRule.env). The addon resolves them itself — the operator's own exports first, as before, thenhost.env— and re-resolves when the file changes, dropping a rotated rule's cached token. The secret stamp is gone. - The proxy runs without
host.envin its environment. The supervisor merges every axis's secret into its own environment; a daemon whose spec asks (scrub_host_env) is launched without the keys that came fromhost.env. Before this, all of them sat in mitmdump's process environment. - A narrowing closes what it no longer allows — and only that. The addon remembers each
CONNECT that got through. After any policy change (the live file or the allowlist) it closes
the connections whose CONNECT the wall would now refuse, and the blind tunnels it would now
decrypt (the host left
passthrough, or an injector now owns it). Widening closes nothing; a build tunnel stays open while its host is allowed. A revoked grant now closes its tunnels too. - Keychains are not part of this. A
security/secret-toolentry is readable by any process running as the operator through the same CLI, so it adds at-rest protection (backups) but no per-process isolation, at the cost of a second backend on every host without a Secret Service (headless Linux, WSL2, CI). If a secret source is ever wanted, the seam is a minter kind (static_token.pynotes it), chosen per secret.
Consequences¶
- A mode switch, a learn window ending, an adopted
passthroughand a token rotation no longer touch a running connection. The proxy restarts only when its own code, its port or mitmdump itself changes. Pinned bytest_a_posture_change_leaves_the_proxy_launch_settings_aloneand, on the wire,test_a_download_in_flight_survives_a_settings_change. - Closing a connection relies on mitmproxy internals. mitmproxy has no public API to close a
connection without a flow (checked on 12.2.3 and on
main, 2026-09-23), and a blind tunnel has no flow by construction. The addon reachesproxyserver.connections[client.id]and calls the handler'sclose_connection. A failure is logged at WARN and leaves the tunnel open, so a mitmproxy upgrade that moves these must be caught bytest_proxy_reload_e2e.py, which fails without the close (mutation-checked). requiresgates only the proxy's first launch. A rule that turns on later, with a prerequisite missing, can't stop a proxy that is already running; its mints fail at WARN, one host degraded, which is the outcomerequiresis kept credential-free to protect. The doctor rows still name what is missing.- The proxy's
started …line no longer names the current posture. The settings-updated line does, per change;fy host logsshows both.
Rejected¶
- Drain, then restart. Wait for open connections to finish, bind the new process, stop the old one. The handover still refuses connections for a moment on a single port (mitmdump can't take over a listening socket), keep-alive clients and streams rarely let the count reach zero, and while it waits the old posture stays in force — a credential switched off keeps being injected. About as much code as the reload, with a worse result.
- Restart only for narrowing changes. Simpler than closing tunnels selectively, but every narrowing (the wall going up, a learn window ending) cuts everything again, and revoked grants would still leave their tunnels open.
- Write secret values into the live file. One reader instead of two, but a second plaintext
copy of
host.envon disk, to protect exactly as well as the first.