From e0332b05ad4e911af69589089230432d2aa3f4eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Tue, 9 Jun 2026 12:47:20 +0200 Subject: [PATCH] feat(caddy): single owner via CADDY_MANAGER env flag One Caddy serves the whole container and POST /load replaces the entire config, so two instances pushing would clobber each other. Now only the instance with CADDY_MANAGER=true (production) pushes, seeds routes from the Caddyfile, and accepts route mutations (others get 403). /api/auth/config exposes caddyManaged so the non-owner Settings UI shows the Caddy section read-only. The installer sets the flag on the production .env only. --- ARCHITECTURE.md | 11 +++++++++- deploy/proxmox-ghostgrid.sh | 2 ++ server.ts | 14 +++++++++++-- src/components/Settings.tsx | 40 +++++++++++++++++++++++++++---------- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e67fcd7..4709e86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -478,7 +478,15 @@ importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable pushCaddyConfig(): POST /load (Content-Type: text/caddyfile) called on startup, after settings save, after route add/delete - (failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true') + (failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true' + or if this instance is not the Caddy manager) + +Ownership — one Caddy serves the whole container (admin API on :2019); POST /load +replaces the ENTIRE config. Only the instance with env CADDY_MANAGER=true (production) +pushes, seeds routes, and accepts route edits (POST/PUT/DELETE → 403 otherwise). The +other instance shows the Caddy section read-only (/api/auth/config → caddyManaged:false) +and never pushes — otherwise its own (partial) config would clobber the owner's. The +owner's caddy table therefore holds ALL routes (both GhostGrid domains + every service). ``` --- @@ -660,6 +668,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm | `APP_URL` | `http://localhost:` | Base URL for deriving the Azure redirect URI | | `PORT` | `3000` | HTTP listen port | | `NODE_ENV` | — | `production` switches to static `dist/` serving | +| `CADDY_MANAGER` | unset | `true` makes this instance the sole Caddy owner (push/seed/edit). Set on production only — one Caddy per container | | `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI | --- diff --git a/deploy/proxmox-ghostgrid.sh b/deploy/proxmox-ghostgrid.sh index a506730..d6bd8fe 100644 --- a/deploy/proxmox-ghostgrid.sh +++ b/deploy/proxmox-ghostgrid.sh @@ -190,6 +190,8 @@ msg_info "Creating .env file for each instance" for d in "${APP_DIR}" "${DEV_DIR}"; do SECRET="$(openssl rand -hex 32)" run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" + # Only the production instance owns the shared Caddy (one Caddy per container). + [[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env" done msg_ok ".env files created (main + dev)" diff --git a/server.ts b/server.ts index fe9ed33..493a440 100644 --- a/server.ts +++ b/server.ts @@ -15,6 +15,11 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production'; const JWT_EXPIRY = '24h'; +// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true +// owns it (pushes config, seeds routes, accepts route edits). The other instance +// must never push — POST /load replaces the entire config and would clobber it. +const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true'; + interface JwtPayload { userId: string; email: string; @@ -90,7 +95,7 @@ function buildCaddyfile(): string { lines.push(' header_up X-Real-IP {remote_host}'); lines.push(' header_up Host {host}'); if (/^https:\/\//i.test(route.upstream)) { - // HTTPS upstream (e.g. Semaphore) — connect over TLS and skip certificate + // HTTPS upstream - connect over TLS and skip certificate // verification, since such backends typically use a self-signed cert. lines.push(' transport http {'); lines.push(' tls_insecure_skip_verify'); @@ -142,6 +147,7 @@ function importCaddyfileRoutes(userId?: string): void { } async function pushCaddyConfig(): Promise { + if (!IS_CADDY_MANAGER) return; if (getSetting('caddy_enabled') !== 'true') return; const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const body = buildCaddyfile(); @@ -168,7 +174,7 @@ async function startServer() { console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin'); } - if (getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { + if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { importCaddyfileRoutes(); } @@ -265,6 +271,7 @@ async function startServer() { effectiveRedirectUri, checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), + caddyManaged: IS_CADDY_MANAGER, }); }); @@ -1192,6 +1199,7 @@ async function startServer() { app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { + if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const { hostname, upstream, tls, compress } = req.body as { hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; }; @@ -1211,6 +1219,7 @@ async function startServer() { app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { try { + if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); const { hostname, upstream, tls, compress } = req.body as { @@ -1230,6 +1239,7 @@ async function startServer() { app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { try { + if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); const existing = getCaddyRouteById(id); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 0a763d9..3a34263 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -191,6 +191,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [semaphoreTestResult, setSemaphoreTestResult] = useState(null); const [caddyEnabled, setCaddyEnabled] = useState(false); + const [caddyManaged, setCaddyManaged] = useState(true); const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019'); const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown'); const [caddyRoutes, setCaddyRoutes] = useState([]); @@ -219,7 +220,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { loadDbInfo(); fetch('/api/auth/config') .then(r => r.json()) - .then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); }) + .then(d => { + if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); + setCaddyManaged(d.caddyManaged !== false); + }) .catch(() => {}); }, []); @@ -906,21 +910,34 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
- - {caddyEnabled ? 'ENABLED' : 'DISABLED'} - - + {caddyManaged ? ( + <> + + {caddyEnabled ? 'ENABLED' : 'DISABLED'} + + + + ) : ( + MANAGED BY PRODUCTION + )}
+ {!caddyManaged && ( +

+ Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there. +

+ )} + + {caddyManaged && (
} /> @@ -1047,6 +1064,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
)}
+ )} {/* ── Database ── */}