import 'dotenv/config'; import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import fs from 'fs'; import { createServer as createViteServer } from 'vite'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import DatabaseConstructor from 'better-sqlite3'; import { ConfidentialClientApplication } from '@azure/msal-node'; import db, { uid, addLog, getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db'; import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types'; const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production'; const JWT_EXPIRY = '24h'; // DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config, // seeds routes, accepts route edits) and shows "Production" in the UI. The dev // instance must never push to Caddy — POST /load replaces the entire config. const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production'; interface JwtPayload { userId: string; email: string; } declare global { namespace Express { interface Request { user?: JwtPayload; } } } function requireAuth(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader?.split(' ')[1]; if (!token) { res.status(401).json({ error: 'Authentication required.' }); return; } try { const payload = jwt.verify(token, JWT_SECRET) as JwtPayload; req.user = payload; next(); } catch { res.status(401).json({ error: 'Invalid or expired token.' }); } } function requireAdmin(req: Request, res: Response, next: NextFunction) { const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined; if (!row || row.role.toLowerCase() !== 'admin') { res.status(403).json({ error: 'Admin access required.' }); return; } next(); } function getMsalClient(): ConfidentialClientApplication | null { const clientId = getSetting('azure_client_id'); const tenantId = getSetting('azure_tenant_id'); const secret = getSetting('azure_client_secret'); if (getSetting('azure_enabled') !== 'true' || !clientId || !tenantId || !secret) return null; return new ConfidentialClientApplication({ auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, clientSecret: secret }, }); } const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret', 'semaphore_api_token']; function maskSettings(raw: Record): Record { const out = { ...raw }; for (const k of SECRET_KEYS) { if (out[k]) out[k] = '__SET__'; } return out; } function buildCaddyfile(): string { const customRoutes = getCaddyRoutes(); const lines: string[] = ['{\n local_certs\n}', '']; for (const route of customRoutes) { lines.push(`${route.hostname} {`); if (route.compress) lines.push(' encode zstd gzip'); if (route.tls) lines.push(' tls internal'); if (route.redirect) { // Redirect only the bare root ('/') to the given path — other paths pass // through to the backend unchanged (e.g. CheckMK at //check_mk/). const target = route.redirect.startsWith('/') ? route.redirect : `/${route.redirect}`; lines.push(` redir / ${target}`); } lines.push(` reverse_proxy ${route.upstream} {`); // Standard forwarding headers for every backend. Caddy already sets the // X-Forwarded-* family and the Host header by default; these make them // explicit and add X-Real-IP (nginx convention) for backends that expect it. lines.push(' header_up X-Forwarded-Proto {scheme}'); lines.push(' header_up X-Real-IP {remote_host}'); lines.push(' header_up Host {host}'); if (/^https:\/\//i.test(route.upstream)) { // 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'); lines.push(' }'); } lines.push(' }'); lines.push('}', ''); } return lines.join('\n'); } function importCaddyfileRoutes(userId?: string): 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'); const imported: string[] = []; 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 }); imported.push(`${hostname} → ${upstream}`); } } i++; } if (imported.length > 0) { addLog('system', `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, { userId }); } } async function pushCaddyConfig(): Promise { if (!IS_PRODUCTION) return; if (getSetting('caddy_enabled') !== 'true') return; const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019'; const body = buildCaddyfile(); const res = await fetch(`${adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': 'text/caddyfile' }, body, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Caddy /load returned ${res.status}: ${text}`); } } 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'); } if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { importCaddyfileRoutes(); } app.use(express.json()); // API responses must never be cached by the browser — otherwise a stale // (or HTML fallback) response can get served from cache via a 304. app.use('/api', (_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); }); // ------------------------------------------------------------- // AUTH API // ------------------------------------------------------------- app.post('/api/auth/register', (req, res) => { try { const { name, email, password } = req.body; if (!name || !email || !password) { return res.status(400).json({ error: 'Name, email and password are required.' }); } if (password.length < 8) { return res.status(400).json({ error: 'Password must be at least 8 characters.' }); } const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (existing) { return res.status(409).json({ error: 'An account with this email already exists.' }); } const passwordHash = bcrypt.hashSync(password, 10); const id = uid("u"); db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)') .run(id, name, 'User', email, passwordHash); const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; const token = jwt.sign({ userId: id, email }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); res.status(201).json({ token, user }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/auth/login', (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required.' }); } const row = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as (User & { password_hash: string }) | undefined; if (!row || !bcrypt.compareSync(password, row.password_hash)) { return res.status(401).json({ error: 'Invalid email or password.' }); } const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); const user: User = { id: row.id, name: row.name, role: row.role, email: row.email }; addLog('system', `${row.name} logged in.`, { userId: row.id }); res.json({ token, user }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.get('/api/auth/me', requireAuth, (req, res) => { try { const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(req.user!.userId) as User | undefined; if (!user) { return res.status(404).json({ error: 'User not found.' }); } res.json(user); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // Public: frontend checks this before rendering the Azure login button app.get('/api/auth/config', (_req, res) => { const enabled = getSetting('azure_enabled') === 'true'; const clientId = getSetting('azure_client_id'); const tenantId = getSetting('azure_tenant_id'); const secret = getSetting('azure_client_secret'); const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`; const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || ''; res.json({ azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret), effectiveRedirectUri, checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), isProduction: IS_PRODUCTION, }); }); // Start Azure OAuth flow app.get('/api/auth/azure', async (_req, res) => { const msalClient = getMsalClient(); if (!msalClient) { return res.redirect('/?auth_error=Azure+login+not+configured'); } const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; try { const authCodeUrl = await msalClient.getAuthCodeUrl({ scopes: ['openid', 'profile', 'email'], redirectUri, }); res.redirect(authCodeUrl); } catch (err: any) { console.error('[Azure Auth] getAuthCodeUrl error:', err); res.redirect('/?auth_error=Failed+to+start+Microsoft+login'); } }); // Azure OAuth callback app.get('/api/auth/azure/callback', async (req, res) => { const { code, error, error_description } = req.query; if (error) { const msg = encodeURIComponent(String(error_description || error)); return res.redirect(`/?auth_error=${msg}`); } if (!code) { return res.redirect('/?auth_error=No+authorization+code+received'); } const msalClient = getMsalClient(); if (!msalClient) { return res.redirect('/?auth_error=Azure+login+not+configured'); } const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; try { const result = await msalClient.acquireTokenByCode({ code: String(code), scopes: ['openid', 'profile', 'email'], redirectUri, }); const email = (result.account?.username ?? '').toLowerCase(); const name = result.account?.name || email; if (!email) { return res.redirect('/?auth_error=No+email+returned+by+Microsoft'); } const allowedGroup = getSetting('azure_allowed_group'); if (allowedGroup) { const claims = result.idTokenClaims as { groups?: string[] } | undefined; if (!claims?.groups?.includes(allowedGroup)) { return res.redirect('/?auth_error=Not+a+member+of+the+required+group'); } } let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined; if (!user) { const id = uid("u"); db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)') .run(id, name, 'User', email, ''); user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; } const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); addLog('system', `${user.name} logged in via Microsoft.`, { userId: user.id }); res.redirect(`/?token=${encodeURIComponent(token)}`); } catch (err: any) { console.error('[Azure Auth] acquireTokenByCode error:', err); res.redirect('/?auth_error=Authentication+failed'); } }); // ------------------------------------------------------------- // RESTFUL API: Settings (admin only) // ------------------------------------------------------------- app.get('/api/settings', requireAuth, (_req, res) => { try { res.json(maskSettings(getAllSettings())); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/settings', requireAuth, (req, res) => { try { const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret', 'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms', '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(req.user!.userId); } pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message)); res.json(maskSettings(getAllSettings())); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Users // ------------------------------------------------------------- app.get('/api/users', requireAuth, (_req, res) => { try { const users = db.prepare('SELECT id, name, role, email FROM users').all() as User[]; res.json(users); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/users/:id', requireAuth, (req, res) => { try { const id = req.params.id; const { name, email } = req.body as { name?: string; email?: string }; if (!name && !email) return res.status(400).json({ error: 'Nothing to update.' }); const existing = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(id) as User | undefined; if (!existing) return res.status(404).json({ error: 'User not found.' }); if (email && email !== existing.email) { const dupe = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id); if (dupe) return res.status(409).json({ error: 'Email already in use.' }); } db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?') .run(name ?? null, email ?? null, id); const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; const changes: string[] = []; if (name && name !== existing.name) changes.push(`name "${existing.name}" → "${name}"`); if (email && email !== existing.email) changes.push(`email "${existing.email}" → "${email}"`); if (changes.length > 0) { addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId }); } res.json(updated); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.patch('/api/users/:id/role', requireAuth, requireAdmin, (req, res) => { try { const id = req.params.id; const { role } = req.body as { role: string }; const safeRole = role?.toLowerCase() === 'admin' ? 'admin' : 'User'; const existing = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(id) as User | undefined; if (!existing) return res.status(404).json({ error: 'User not found.' }); if (existing.role === safeRole) return res.json(existing); db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id); const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId }); res.json(updated); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/users/:id', requireAuth, (req, res) => { try { const id = req.params.id; if (id === req.user!.userId) return res.status(400).json({ error: 'You cannot delete your own account.' }); const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id); if (!existing) return res.status(404).json({ error: 'User not found.' }); const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n; if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' }); db.prepare('DELETE FROM users WHERE id = ?').run(id); res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Devices / Inventory // ------------------------------------------------------------- app.get('/api/devices', requireAuth, (_req, res) => { try { const devices = db.prepare('SELECT * FROM devices').all() as Device[]; res.json(devices); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/devices', requireAuth, (req, res) => { try { const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt } = req.body; if (!hostname || !ip || !type) { return res.status(400).json({ error: 'Missing required device specifications.' }); } const id = uid("dev"); db.prepare(` INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null); addLog('maintenance', `Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`, { deviceId: id, userId: req.user!.userId }); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; res.status(201).json(device); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/devices/:id', requireAuth, (req, res) => { try { const id = req.params.id; const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt, operatorName } = req.body; db.prepare(` UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, lastCheckedAt = ? WHERE id = ? `).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id); const operatorText = operatorName ? `${operatorName} finished ` : 'Updated '; addLog('maintenance', `${operatorText}refining the device specifications for "${hostname}".`, { deviceId: id, userId: req.user!.userId }); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; res.json(device); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/devices/:id', requireAuth, (req, res) => { try { const id = req.params.id; const dev = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; if (!dev) return res.status(404).json({ error: 'Device not found.' }); db.prepare('DELETE FROM devices WHERE id = ?').run(id); const labs = db.prepare('SELECT * FROM labs').all() as any[]; const updateLabStmt = db.prepare('UPDATE labs SET deviceIds = ?, topology = ? WHERE id = ?'); for (const lab of labs) { const deviceIds: string[] = JSON.parse(lab.deviceIds); const topology: any[] = JSON.parse(lab.topology); updateLabStmt.run( JSON.stringify(deviceIds.filter(dId => dId !== id)), JSON.stringify(topology.filter(t => t.fromDevice !== id && t.toDevice !== id)), lab.id ); } addLog('maintenance', `Permanently removed the host device "${dev.hostname || id}" from the inventory records.`, { userId: req.user!.userId }); res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Lab Templates // ------------------------------------------------------------- app.get('/api/labs', requireAuth, (_req, res) => { try { const rows = db.prepare('SELECT * FROM labs').all() as any[]; const labs: LabTemplate[] = rows.map(r => ({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal', ownerId: r.ownerId ?? '', })); res.json(labs); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/labs', requireAuth, (req, res) => { try { const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body; if (!name || !deviceIds || !Array.isArray(deviceIds)) { return res.status(400).json({ error: 'Missing name or associated device configurations.' }); } const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; const ownerId = req.user!.userId; const id = uid("lab"); db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId); addLog('maintenance', `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`, { userId: req.user!.userId }); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/labs/:id', requireAuth, async (req, res) => { try { const id = req.params.id; const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body; const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any; if (!existing) return res.status(404).json({ error: 'Lab template not found.' }); const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; const isOwner = existing.ownerId === req.user!.userId; const isLegacy = existing.ownerId === ''; if (!isOwner && !isAdmin && !isLegacy) { return res.status(403).json({ error: 'You do not have permission to edit this topology.' }); } const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ?, scope = ? WHERE id = ?`) .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, id); addLog('maintenance', `Modified the active topology mapping schema for the "${name}" lab template.`, { userId: req.user!.userId }); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/labs/:id', requireAuth, (req, res) => { try { const id = req.params.id; const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; if (!lab) return res.status(404).json({ error: 'Lab template not found.' }); const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; const isOwner = lab.ownerId === req.user!.userId; const isLegacy = lab.ownerId === ''; if (!isOwner && !isAdmin && !isLegacy) { return res.status(403).json({ error: 'You do not have permission to delete this topology.' }); } db.prepare('DELETE FROM labs WHERE id = ?').run(id); db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id); addLog('booking', `Withdrew the lab testing template "${lab.name || id}".`, { userId: req.user!.userId }); res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Bookings / Reservations // ------------------------------------------------------------- app.get('/api/bookings', requireAuth, (_req, res) => { try { const rows = db.prepare('SELECT * FROM bookings').all() as any[]; const bookings: Booking[] = rows.map(r => ({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status as any, notified: r.notified === 1, emailSent: r.emailSent === 1, ansibleSetupTriggered: r.ansibleSetupTriggered === 1, ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1, ansibleSetupJobId: r.ansibleSetupJobId || '', ansibleTeardownJobId: r.ansibleTeardownJobId || '', })); res.json(bookings); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/bookings', requireAuth, (req, res) => { try { const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body; if (!labId || !userId || !startDateTime || !endDateTime) { return res.status(400).json({ error: 'Missing reservation timestamps or laboratory ID.' }); } const id = uid("book"); db.prepare(`INSERT INTO bookings (id, labId, userId, startDateTime, endDateTime, notes, status, notified, emailSent) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1)`) .run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming'); const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined; const operatorText = operatorName || 'An operator'; const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); addLog('booking', `${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, { userId }); const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; res.status(201).json({ booking: { id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 }, alertGenerated: `Reservation successfully approved: Lab "${lab?.name || 'Scenario'}" is reserved for your exclusive usage during this window.` }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/bookings/:id', requireAuth, async (req, res) => { try { const id = req.params.id; const { status, operatorName } = req.body; const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; if (!booking) return res.status(404).json({ error: 'Reservation not found.' }); db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id); if (status === 'cancelled') { const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined; addLog('booking', `${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`, { userId: req.user!.userId }); // Trigger teardown if booking had already started and teardown not yet triggered const now = new Date(); if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) { const templateId = lab?.semaphoreTeardownTemplateId; if (templateId) { const jobId = await triggerSemaphoreTask(Number(templateId), { booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId, start_time: booking.startDateTime, end_time: booking.endDateTime, }); db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') .run(jobId !== null ? String(jobId) : '', booking.id); } } } const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; res.json({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1, ansibleSetupTriggered: r.ansibleSetupTriggered === 1, ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1, ansibleSetupJobId: r.ansibleSetupJobId || '', ansibleTeardownJobId: r.ansibleTeardownJobId || '', }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/bookings/:id', requireAuth, (req, res) => { try { const id = req.params.id; const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; if (!booking) return res.status(404).json({ error: 'Reservation not found.' }); db.prepare('DELETE FROM bookings WHERE id = ?').run(id); const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined; addLog('booking', `Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, { userId: req.user!.userId }); res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Logs // ------------------------------------------------------------- app.get('/api/logs', requireAuth, (_req, res) => { try { const logs = db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all() as LogEntry[]; res.json(logs); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/logs', requireAuth, (req, res) => { try { const { type, message, deviceId, userId } = req.body; if (!message || !type) { return res.status(400).json({ error: 'Missing log message or classification type.' }); } const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null }); const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry; res.status(201).json(log); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // RESTFUL API: Quick Links (shared link dashboard) // ------------------------------------------------------------- app.get('/api/links', requireAuth, (_req, res) => { try { const links = db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all() as QuickLink[]; res.json(links); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/links', requireAuth, (req, res) => { try { const { title, url, description, category, color } = req.body; if (!title || !url) { return res.status(400).json({ error: 'A title and a URL are required.' }); } const id = uid("link"); const createdAt = new Date().toISOString(); db.prepare(`INSERT INTO links (id, title, url, description, category, color, createdBy, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) .run(id, title, url, description || '', category || '', color || 'emerald', req.user!.userId, createdAt); const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink; res.status(201).json(link); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/links/:id', requireAuth, (req, res) => { try { const id = req.params.id; const { title, url, description, category, color } = req.body; const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id); if (!existing) return res.status(404).json({ error: 'Link not found.' }); db.prepare(`UPDATE links SET title = ?, url = ?, description = ?, category = ?, color = ? WHERE id = ?`) .run(title, url, description || '', category || '', color || 'emerald', id); const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink; res.json(link); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/links/:id', requireAuth, (req, res) => { try { const id = req.params.id; const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id); if (!existing) return res.status(404).json({ error: 'Link not found.' }); db.prepare('DELETE FROM links WHERE id = ?').run(id); res.json({ success: true, message: 'Link removed from the shared dashboard.' }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // DATABASE API // ------------------------------------------------------------- app.get('/api/database/info', requireAuth, (_req, res) => { try { const stats = fs.statSync(DB_FILE); const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy']; const counts: Record = {}; for (const t of tables) { counts[t] = (db.prepare(`SELECT COUNT(*) as n FROM "${t}"`).get() as { n: number }).n; } res.json({ sizeBytes: stats.size, lastModified: stats.mtime.toISOString(), tables: counts, path: path.basename(DB_FILE), }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.get('/api/database/backup', requireAuth, async (_req, res) => { const tempPath = `${DB_FILE}.backup-${Date.now()}`; try { await db.backup(tempPath); const filename = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`; res.download(tempPath, filename, () => { fs.unlink(tempPath, () => {}); }); } catch (err: any) { fs.unlink(tempPath, () => {}); if (!res.headersSent) res.status(500).json({ error: err.message }); } }); app.post('/api/database/import', requireAuth, express.raw({ type: 'application/octet-stream', limit: '50mb' }), (req, res) => { const tempPath = `${DB_FILE}.import-${Date.now()}`; try { const buf = req.body as Buffer; if (!Buffer.isBuffer(buf) || buf.length < 16) { return res.status(400).json({ error: 'No file data received.' }); } // Validate SQLite magic header: "SQLite format 3\0" if (buf.slice(0, 16).toString('latin1') !== 'SQLite format 3\x00') { return res.status(400).json({ error: 'Not a valid SQLite database file.' }); } fs.writeFileSync(tempPath, buf); let importDb: InstanceType | null = null; try { importDb = new DatabaseConstructor(tempPath, { readonly: true }); const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy']; db.transaction(() => { for (const table of tables) { const schemaCols = (db.prepare(`PRAGMA table_info("${table}")`).all() as { name: string }[]).map(c => c.name); db.prepare(`DELETE FROM "${table}"`).run(); let rows: Record[] = []; try { rows = importDb!.prepare(`SELECT * FROM "${table}"`).all() as Record[]; } catch { continue; } if (rows.length === 0) continue; const importCols = Object.keys(rows[0]); const cols = schemaCols.filter(c => importCols.includes(c)); if (cols.length === 0) continue; const stmt = db.prepare( `INSERT INTO "${table}" (${cols.map(c => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})` ); for (const row of rows) stmt.run(cols.map(c => row[c])); } })(); res.json({ ok: true }); } finally { importDb?.close(); fs.unlink(tempPath, () => {}); } } catch (err: any) { fs.unlink(tempPath, () => {}); res.status(500).json({ error: err.message }); } } ); // ------------------------------------------------------------- // CYCLIC CHECKMK STATUS SYNC // Looks up each device by IP address in CheckMK's host_config collection, // then fetches the monitoring state. Devices not found in CheckMK are reset // to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes // in Settings take effect on the next cycle without a server restart. // ------------------------------------------------------------- function checkmkHttpHint(status: number): string { if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings > CheckMK)'; if (status === 403) return 'HTTP 403 Forbidden - automation user lacks permission in CheckMK'; if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong'; return `HTTP ${status}`; } async function syncCheckMkStatuses() { const now = new Date().toISOString(); if (getSetting('checkmk_enabled') !== 'true') return; const CHECKMK_API_URL = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL; const CHECKMK_API_USER = getSetting('checkmk_api_user') || process.env.CHECKMK_API_USER || 'automation'; const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET; if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) { addLog('system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.', { timestamp: now }); return; } const authHeader = `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`; const headers = { Authorization: authHeader, Accept: 'application/json' }; // Step 1: build IP > hostname map from host configuration // Checks both attributes (explicitly set) and effective_attributes (inherited). let ipToHostname: Map; try { const cfgRes = await fetch( `${CHECKMK_API_URL}/domain-types/host_config/collections/all`, { headers } ); if (!cfgRes.ok) throw new Error(checkmkHttpHint(cfgRes.status)); const cfgData = await cfgRes.json(); ipToHostname = new Map(); for (const host of cfgData?.value ?? []) { const ext = host?.extensions; const ip: string | undefined = ext?.attributes?.ipaddress || ext?.effective_attributes?.ipaddress; const name: string | undefined = host?.id; if (ip && name) ipToHostname.set(ip, name); } } catch (err: any) { const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`; console.error('[CheckMK]', msg); addLog('system', msg, { timestamp: now }); return; } // Step 2: for each device, look up its CheckMK hostname by IP, then query the // individual monitoring object at /objects/host/{name} for the live state. // The collection endpoint (/domain-types/host/collections/all) only returns // minimal fields without state, so per-host calls are required. const rows = db.prepare('SELECT id, hostname, ip, status FROM devices').all() as { id: string; hostname: string; ip: string; status: string }[]; const counts = { online: 0, offline: 0, unknown: 0 }; for (const dev of rows) { const cmkHost = ipToHostname.get(dev.ip); if (!cmkHost) { if (dev.status !== 'unknown') { db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id); addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, { deviceId: dev.id, timestamp: now }); } counts.unknown++; continue; } try { const hostRes = await fetch( `${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`, { headers } ); if (!hostRes.ok) throw new Error(checkmkHttpHint(hostRes.status)); const hostData = await hostRes.json(); const state: number = hostData?.extensions?.state ?? -1; const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown'; db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id); if (dev.status !== newStatus) { addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, { deviceId: dev.id, timestamp: now }); } counts[newStatus as 'online' | 'offline' | 'unknown']++; } catch (err: any) { const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`; console.error('[CheckMK]', msg); addLog('system', msg, { deviceId: dev.id, timestamp: now }); counts.unknown++; } } addLog('system', `CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`, { timestamp: now }); } async function scheduleSync() { await syncCheckMkStatuses(); const ms = Number(getSetting('checkmk_sync_interval_ms')) || 60_000; setTimeout(scheduleSync, ms); } scheduleSync(); app.post('/api/checkmk/sync', requireAuth, async (_req, res) => { try { await syncCheckMkStatuses(); res.json({ ok: true }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // ANSIBLE SEMAPHORE INTEGRATION // Triggers Semaphore task templates at booking start (setup) and // booking end (teardown). Uses the same self-rescheduling pattern // as CheckMK. Template IDs are configured per lab template. // ------------------------------------------------------------- async function triggerSemaphoreTask(templateId: number, extraVars: Record): Promise { const apiUrl = getSetting('semaphore_api_url'); const token = getSetting('semaphore_api_token'); const projectId = getSetting('semaphore_project_id'); if (!apiUrl || !token || !projectId) { addLog('system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.'); return null; } try { const res = await fetch(`${apiUrl}/api/project/${projectId}/tasks`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ template_id: templateId, environment: JSON.stringify(extraVars) }), }); if (!res.ok) { const hint = res.status === 401 ? 'HTTP 401 Unauthorized — check API token' : res.status === 403 ? 'HTTP 403 Forbidden — token lacks permission' : res.status === 404 ? 'HTTP 404 — wrong project ID or Semaphore URL' : `HTTP ${res.status}`; throw new Error(hint); } const data = await res.json() as { id?: number }; const jobId = data?.id ?? null; addLog('booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`); return jobId; } catch (err: any) { const msg = `Semaphore trigger failed for template #${templateId} — ${err?.message ?? err}`; console.error('[Semaphore]', msg); addLog('system', msg); return null; } } async function checkAndTriggerAnsibleTasks() { if (getSetting('semaphore_enabled') !== 'true') return; const now = new Date().toISOString(); // Bookings that have started but setup hasn't been triggered yet const setupPending = db.prepare( `SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'` ).all(now) as any[]; for (const row of setupPending) { const templateId = row.semaphoreSetupTemplateId; if (!templateId) { db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id); continue; } const jobId = await triggerSemaphoreTask(Number(templateId), { booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, start_time: row.startDateTime, end_time: row.endDateTime, }); db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?') .run(jobId !== null ? String(jobId) : '', row.id); } // Bookings that have ended but teardown hasn't been triggered yet const teardownPending = db.prepare( `SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'` ).all(now) as any[]; for (const row of teardownPending) { const templateId = row.semaphoreTeardownTemplateId; if (!templateId) { db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id); continue; } const jobId = await triggerSemaphoreTask(Number(templateId), { booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, start_time: row.startDateTime, end_time: row.endDateTime, }); db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') .run(jobId !== null ? String(jobId) : '', row.id); } } async function scheduleSemaphoreCheck() { await checkAndTriggerAnsibleTasks(); setTimeout(scheduleSemaphoreCheck, 30_000); } scheduleSemaphoreCheck(); // Proxy Semaphore template list so the UI can populate dropdowns app.get('/api/semaphore/templates', requireAuth, async (_req, res) => { const apiUrl = getSetting('semaphore_api_url'); const token = getSetting('semaphore_api_token'); const projectId = getSetting('semaphore_project_id'); if (!apiUrl || !token || !projectId) { return res.status(400).json({ error: 'Semaphore not fully configured.' }); } try { const r = await fetch(`${apiUrl}/api/project/${projectId}/templates`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (!r.ok) return res.status(r.status).json({ error: `Semaphore returned HTTP ${r.status}` }); res.json(await r.json()); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // Manual re-trigger for a specific booking (admin use / testing) app.post('/api/semaphore/trigger/:bookingId', requireAuth, async (req, res) => { try { const { bookingId } = req.params; const { type } = req.body as { type: 'setup' | 'teardown' }; const row = db.prepare( `SELECT b.*, l.semaphoreSetupTemplateId, l.semaphoreTeardownTemplateId, l.name AS labName FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.id = ?` ).get(bookingId) as any; if (!row) return res.status(404).json({ error: 'Booking not found.' }); const templateId = type === 'setup' ? row.semaphoreSetupTemplateId : row.semaphoreTeardownTemplateId; if (!templateId) return res.status(400).json({ error: `No Semaphore ${type} template configured for this lab.` }); const jobId = await triggerSemaphoreTask(Number(templateId), { booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, start_time: row.startDateTime, end_time: row.endDateTime, }); if (type === 'setup') { db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?') .run(jobId !== null ? String(jobId) : '', bookingId); } else { db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') .run(jobId !== null ? String(jobId) : '', bookingId); } res.json({ ok: true, jobId }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ------------------------------------------------------------- // CADDY API // ------------------------------------------------------------- app.get('/api/caddy/status', requireAuth, async (_req, res) => { try { const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019'; const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) }); res.json({ available: r.ok }); } catch { res.json({ available: false }); } }); app.get('/api/caddy/routes', requireAuth, (_req, res) => { try { res.json(getCaddyRoutes()); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const { hostname, upstream, tls, compress, redirect } = req.body as { hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); const route = addCaddyRoute({ hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() }); addLog('system', `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId }); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message)); res.json(route); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { try { if (!IS_PRODUCTION) 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, redirect } = req.body as { hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); const route = updateCaddyRoute(id, { hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() }); addLog('system', `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId }); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message)); res.json(route); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { try { if (!IS_PRODUCTION) 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); deleteCaddyRoute(id); if (existing) { addLog('system', `Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, { userId: req.user!.userId }); } pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message)); res.status(204).send(); } catch (err: any) { res.status(500).json({ error: err.message }); } }); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config on startup:', err.message)); // ------------------------------------------------------------- // VITE / STATIC SERVING — registered LAST so the SPA catch-all ('*') // never shadows the /api routes registered above it. // ------------------------------------------------------------- if (process.env.NODE_ENV !== 'production') { const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'spa', }); app.use(vite.middlewares); } else { const distPath = path.join(process.cwd(), 'dist'); app.use(express.static(distPath)); app.get('*', (_req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); } app.listen(PORT, '0.0.0.0', () => { console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`); }); } startServer().catch(err => { console.error('[Server] Critical Crash during bootstrap:', err); });