From 00cf5dd02d333ac19dd0295f4a939c3d4de74205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Mon, 8 Jun 2026 10:09:26 +0200 Subject: [PATCH] feat(caddy): auto-import Caddyfile on first enable; seed default admin user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Caddy is enabled for the first time (caddy routes table empty), importCaddyfileRoutes() reads /etc/caddy/Caddyfile and seeds all hostname/upstream blocks as custom routes — no manual entry needed after deploy. On first startup with an empty users table, a default admin user is created (admin@ghostgrid.local / admin) so the system is immediately usable. --- ARCHITECTURE.md | 26 +++++++++++++++++++++++++- server.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bcdf2aa..a5eae34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,7 @@ | Version | Date | Changes | |---------|------|---------| +| 1.3 | Jun 8, 2026 | Auto-import Caddyfile on first Caddy enable; default admin user on first start | | 1.2 | Jun 8, 2026 | Removed `caddy_prod_domain` / `caddy_dev_domain` settings; Caddy now routes only custom entries | | 1.1 | Jun 8, 2026 | Dropped the migration layer (fresh-install schema); renamed the `caddy_routes` table to `caddy` | | 1.0 | Jun 8, 2026 | Initial architecture reference generated from the codebase | @@ -474,6 +475,10 @@ buildCaddyfile(): { local_certs } # global block per custom route { [encode] [tls internal] reverse_proxy } +importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable + parses hostname/upstream blocks → seeds caddy table as custom routes + (no-op if caddy table already has entries or file not found) + 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') @@ -481,6 +486,24 @@ pushCaddyConfig(): POST /load (Content-Type: text/caddyfile) --- +### 6.4 First-start Initialization + +Runs in `startServer()` before any routes are registered, every startup — both operations +are idempotent and only fire once on a blank database. + +``` +Default admin user: + if users table is empty: + INSERT user (name='Admin', role='Admin', email='admin@ghostgrid.local', password=bcrypt('admin')) + → log "[Init] Default admin user created" + +Default settings: + INSERT OR IGNORE all DEFAULT_SETTINGS keys from server-db.ts + → existing values in the settings table are never overwritten +``` + +--- + ## 7. Frontend Architecture ### 7.1 Application Structure @@ -560,7 +583,8 @@ Settings +-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group) +-- CheckMK (API URL/user/secret, sync interval, "Run sync now") +-- Ansible Semaphore (API URL/token/project, "Test connection") -+-- Caddy (admin URL, custom route management) ++-- Caddy (admin URL, custom route management; + auto-seeded from /etc/caddy/Caddyfile on first enable) +-- Secret inputs use the __SET__ sentinel (blank = keep existing) ``` diff --git a/server.ts b/server.ts index 91460ee..9413871 100644 --- a/server.ts +++ b/server.ts @@ -89,6 +89,37 @@ function buildCaddyfile(): string { return lines.join('\n'); } +function importCaddyfileRoutes(): void { + if (getCaddyRoutes().length > 0) return; + const caddyfilePath = '/etc/caddy/Caddyfile'; + if (!fs.existsSync(caddyfilePath)) return; + const lines = fs.readFileSync(caddyfilePath, 'utf-8').split('\n'); + let i = 0; + while (i < lines.length) { + const line = lines[i].trim(); + const headerMatch = line.match(/^(\S+)\s*\{$/); + if (headerMatch && headerMatch[1] !== '{') { + const hostname = headerMatch[1]; + const blockLines: string[] = []; + i++; + while (i < lines.length && lines[i].trim() !== '}') { + blockLines.push(lines[i]); + i++; + } + const block = blockLines.join('\n'); + const upstreamMatch = block.match(/reverse_proxy\s+(\S+)/); + if (upstreamMatch) { + const upstream = upstreamMatch[1]; + const tls = /tls\s+internal/.test(block); + const compress = /encode/.test(block); + addCaddyRoute(hostname, upstream, tls, compress); + console.log(`[Caddy] Imported route from Caddyfile: ${hostname} → ${upstream}`); + } + } + i++; + } +} + async function pushCaddyConfig(): Promise { if (getSetting('caddy_enabled') !== 'true') return; const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; @@ -108,6 +139,14 @@ async function startServer() { const app = express(); const PORT = Number(process.env.PORT) || 3000; + const { cnt } = db.prepare('SELECT COUNT(*) as cnt FROM users').get() as { cnt: number }; + if (cnt === 0) { + const passwordHash = bcrypt.hashSync('admin', 10); + db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)') + .run(uid('u'), 'Admin', 'Admin', 'admin@ghostgrid.local', passwordHash); + console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin'); + } + app.use(express.json()); // ------------------------------------------------------------- @@ -285,11 +324,15 @@ async function startServer() { 'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id', 'caddy_enabled', 'caddy_admin_url']; const updates = req.body as Record; + const caddyWasEnabled = getSetting('caddy_enabled') === 'true'; for (const key of allowed) { if (key in updates && updates[key] !== '__SET__') { setSetting(key, String(updates[key])); } } + if (!caddyWasEnabled && updates.caddy_enabled === 'true') { + importCaddyfileRoutes(); + } pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message)); res.json(maskSettings(getAllSettings())); } catch (err: any) {