Add inline edit for custom routes (Pencil icon → inline form with all fields). Log route add/update/delete/import to the logs table (type: system) so operations appear in the Logbook. Fix loadCaddyRoutes() called without await after settings save, causing a race between the success message and route list.
1249 lines
54 KiB
TypeScript
1249 lines
54 KiB
TypeScript
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, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
|
|
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
|
|
|
|
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
|
|
const JWT_EXPIRY = '24h';
|
|
|
|
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<string, string>): Record<string, string> {
|
|
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');
|
|
lines.push(` reverse_proxy ${route.upstream}`);
|
|
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) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(uid('log'), new Date().toISOString(), 'system',
|
|
`Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`,
|
|
null, userId);
|
|
}
|
|
}
|
|
|
|
async function pushCaddyConfig(): Promise<void> {
|
|
if (getSetting('caddy_enabled') !== 'true') return;
|
|
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost: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');
|
|
}
|
|
|
|
app.use(express.json());
|
|
|
|
// -------------------------------------------------------------
|
|
// 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 };
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'system', `${row.name} logged in.`, null, 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://localhost:${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\/.*$/, ''),
|
|
});
|
|
});
|
|
|
|
// 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://localhost:${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://localhost:${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 });
|
|
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<string, string>;
|
|
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;
|
|
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);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
|
|
id, 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 logId = uid("log");
|
|
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`${operatorText}refining the device specifications for "${hostname}".`, id, 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
|
|
);
|
|
}
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
|
null, 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 || '',
|
|
}));
|
|
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 } = req.body;
|
|
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
|
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
|
}
|
|
|
|
const id = uid("lab");
|
|
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
|
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 || '' });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
|
|
|
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`)
|
|
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Modified the active topology mapping schema for the "${name}" lab template.`, 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 || '' });
|
|
} 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.' });
|
|
|
|
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
|
|
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`Withdrew the lab testing template "${lab.name || id}".`, 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 logId = uid("log");
|
|
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' });
|
|
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), '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;
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
|
|
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;
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, 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 = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(id, new Date().toISOString(), type, message, deviceId || null, 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<string, number> = {};
|
|
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<typeof DatabaseConstructor> | 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<string, unknown>[] = [];
|
|
try { rows = importDb!.prepare(`SELECT * FROM "${table}"`).all() as Record<string, unknown>[]; } 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 });
|
|
}
|
|
}
|
|
);
|
|
|
|
// -------------------------------------------------------------
|
|
// VITE / STATIC SERVING
|
|
// -------------------------------------------------------------
|
|
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'));
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 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) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.');
|
|
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<string, string>;
|
|
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<string, string>();
|
|
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);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', msg);
|
|
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);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
|
|
}
|
|
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) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);
|
|
}
|
|
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);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', msg, dev.id);
|
|
counts.unknown++;
|
|
}
|
|
}
|
|
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system',
|
|
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`);
|
|
}
|
|
|
|
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<string, string>): Promise<number | null> {
|
|
const now = new Date().toISOString();
|
|
const apiUrl = getSetting('semaphore_api_url');
|
|
const token = getSetting('semaphore_api_token');
|
|
const projectId = getSetting('semaphore_project_id');
|
|
|
|
if (!apiUrl || !token || !projectId) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, '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;
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, '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);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, '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://localhost: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({ system: [], custom: getCaddyRoutes() });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
|
|
try {
|
|
const { hostname, upstream, tls, compress } = req.body as {
|
|
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
|
|
};
|
|
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
|
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(uid('log'), new Date().toISOString(), 'system',
|
|
`Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, null, 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 {
|
|
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 {
|
|
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
|
|
};
|
|
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
|
const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(uid('log'), new Date().toISOString(), 'system',
|
|
`Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, null, 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 {
|
|
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) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(uid('log'), new Date().toISOString(), 'system',
|
|
`Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, null, 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));
|
|
|
|
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);
|
|
});
|