fix: refuse Caddy /load over a config owned by another instance - #191
Merged
Merged
Conversation
The container runs network_mode: host, so the Caddy admin API on 127.0.0.1:2019 is shared with every process on the host — including dev/test runs in .claude worktrees. On 2026-06-25 a host-side run without NODE_ENV=test pushed test-seed routes (x*.0.example.com) via POST /load, replacing the 18 real routes for ~90s and breaking TLS for domaincaster.com (ERR_SSL_PROTOCOL_ERROR). The NODE_ENV guard only protects processes that remember to set it. Add a config-driven ownership guard: - caddyOwner.js: a persistent per-instance id (stored under the Caddy data dir, stable across restarts; ephemeral fallback when the dir is not writable — which, being different from prod's id, makes a foreign process refuse to push). - buildCaddyConfig stamps an owner marker route (impossible host match, gc_owner_<id> @id) as the last route of srv0. Route-level because Caddy only echoes route @ids back in GET /config/; the foreign owner must be readable to be compared. - _syncToCaddyInner reads the live config before /load and uses ownershipDecision(): fail CLOSED on a genuine read error (cannot verify ownership), refuse on a foreign owner, proceed when null/fresh or our own. (A null "Caddy not running" is claimable; only a thrown read error fails closed, so prod recovery after a Caddy restart is preserved.) - caddyReconciler counts only gc_route_ ids, so the gc_owner_ marker never triggers a divergence/auto-repair loop. The guard is advisory (prevents accidental clobbering), not a security boundary — see caddyOwner.js header. Verified: the full prod config with the marker passes `caddy validate` against GateControl's custom Caddy build; 51 unit tests + 159 buildCaddyConfig tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Heute (2026-06-25) gab es einen
ERR_SSL_PROTOCOL_ERRORaufdomaincaster.com. Root Cause: Der Container läuft mitnetwork_mode: host, also ist die Caddy-Admin-API auf127.0.0.1:2019mit jedem Host-Prozess geteilt — auch Dev-/Test-Läufen in.claude-Worktrees. Ein Host-Lauf ohneNODE_ENV=test(aus.../worktrees/fix+footgun-domain-routes/) hat perPOST /loadTest-Seed-Routen (x*.0.example.com) gepusht und die 18 echten Routen für ~90 s ersetzt → kein Cert fürdomaincaster.com→ TLS-Fehler. Der Reconciler heilte es danach aus der DB (deshalb „ging es wieder"). Der bisherige einzige Schutz (NODE_ENV==='test') greift nur, wenn der Fremdprozess die Variable setzt.Lösung — config-getriebener Ownership-Guard
caddyOwner.js(neu): persistente Instanz-ID unter dem Caddy-Data-Dir (stabil über Neustarts; ephemerer Fallback wenn nicht schreibbar — die abweichende ID lässt einen Fremdprozess dann erst recht verweigern).buildCaddyConfigstempelt eine Marker-Route (@id: gc_owner_<id>, unmöglicher Host-Match, nie geroutet) als letzte Route vonsrv0. Route-Ebene, weil Caddy nur Route-@ids imGET /config/-Body zurückgibt — der fremde Owner muss lesbar sein._syncToCaddyInnerliest die Live-Config vor/loadund entscheidet viaownershipDecision(): fail-closed bei echtem Lesefehler, refuse bei fremdem Owner, proceed bei null/fresh oder eigenem. (null= „Caddy down" ist claimable → Prod-Recovery nach Caddy-Neustart bleibt erhalten.)caddyReconcilerzählt nurgc_route_-IDs → dergc_owner_-Marker löst keine Divergenz/Repair-Schleife aus.Der Guard ist advisory (verhindert versehentliches Clobbering), keine Sicherheitsgrenze — siehe Datei-Header.
Verifikation
caddy validategegen GateControls Custom-Caddy-Build (alle Plugins).caddyOwner.test.js: ID-Persistenz/ephemer, extract/isForeign/ownershipDecision inkl. fail-closed) + Reconciler-Marker-Ignoranz-Test.buildCaddyConfig-berührende Tests grün; Isolation-Guard inkl. End-to-End-syncToCaddyintakt.Hinweis
Nicht gemergt lassen bis OK — Merge löst via CI
:latest+ Auto-Update-Cron einen automatischen Re-Deploy auf den Prod-Host (~5 Min) aus.🤖 Generated with Claude Code