Skip to content

fix: refuse Caddy /load over a config owned by another instance - #191

Merged
CallMeTechie merged 1 commit into
masterfrom
fix/caddy-owner-guard
Jun 25, 2026
Merged

CallMeTechie merged 1 commit into
masterfrom
fix/caddy-owner-guard

Conversation

@CallMeTechie

Copy link
Copy Markdown
Owner

Problem

Heute (2026-06-25) gab es einen ERR_SSL_PROTOCOL_ERROR auf domaincaster.com. Root Cause: Der Container läuft mit network_mode: host, also ist die Caddy-Admin-API auf 127.0.0.1:2019 mit jedem Host-Prozess geteilt — auch Dev-/Test-Läufen in .claude-Worktrees. Ein Host-Lauf ohne NODE_ENV=test (aus .../worktrees/fix+footgun-domain-routes/) hat per POST /load Test-Seed-Routen (x*.0.example.com) gepusht und die 18 echten Routen für ~90 s ersetzt → kein Cert für domaincaster.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).
  • buildCaddyConfig stempelt eine Marker-Route (@id: gc_owner_<id>, unmöglicher Host-Match, nie geroutet) als letzte Route von srv0. Route-Ebene, weil Caddy nur Route-@ids im GET /config/-Body zurückgibt — der fremde Owner muss lesbar sein.
  • _syncToCaddyInner liest die Live-Config vor /load und entscheidet via ownershipDecision(): 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.)
  • caddyReconciler zählt nur gc_route_-IDs → der gc_owner_-Marker löst keine Divergenz/Repair-Schleife aus.

Der Guard ist advisory (verhindert versehentliches Clobbering), keine Sicherheitsgrenze — siehe Datei-Header.

Verifikation

  • Voll-Prod-Config mit Marker besteht caddy validate gegen GateControls Custom-Caddy-Build (alle Plugins).
  • Neue Unit-Tests (caddyOwner.test.js: ID-Persistenz/ephemer, extract/isForeign/ownershipDecision inkl. fail-closed) + Reconciler-Marker-Ignoranz-Test.
  • 159 buildCaddyConfig-berührende Tests grün; Isolation-Guard inkl. End-to-End-syncToCaddy intakt.
  • Interne mehrstufige JS-Review: APPROVE WITH NOTES — der major-Befund (fail-open bei Lesefehler) ist in diesem PR gefixt.

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

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.
@CallMeTechie
CallMeTechie merged commit a50662d into master Jun 25, 2026
8 checks passed
@CallMeTechie
CallMeTechie deleted the fix/caddy-owner-guard branch June 25, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant