Files
GhostGrid/server.ts

829 lines
36 KiB
TypeScript

import 'dotenv/config';
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { createServer as createViteServer } from 'vite';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { ConfidentialClientApplication } from '@azure/msal-node';
import db, { getSetting, setSetting, getAllSettings } 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'];
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;
}
async function startServer() {
const app = express();
const PORT = Number(process.env.PORT) || 3000;
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`;
res.json({
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
});
});
// 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', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
const updates = req.body as Record<string, string>;
for (const key of allowed) {
if (key in updates && updates[key] !== '__SET__') {
setSetting(key, String(updates[key]));
}
}
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)
}));
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 } = 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) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []));
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) });
} 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 } = req.body;
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`)
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), 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) });
} 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
}));
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, (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 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',
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
req.user!.userId);
}
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 });
} 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 });
}
});
// -------------------------------------------------------------
// 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: fetch live monitoring state for all hosts in one call.
// /domain-types/host/collections/all returns monitoring objects with extensions.state
// where 0=UP, 1=DOWN, 2=UNREACHABLE.
let hostnameToState: Map<string, number>;
try {
const monRes = await fetch(
`${CHECKMK_API_URL}/domain-types/host/collections/all`,
{ headers }
);
if (!monRes.ok) throw new Error(checkmkHttpHint(monRes.status));
const monData = await monRes.json();
hostnameToState = new Map<string, number>();
for (const host of monData?.value ?? []) {
const name: string | undefined = host?.id;
const state: number | undefined = host?.extensions?.state;
if (name !== undefined && state !== undefined) hostnameToState.set(name, state);
}
// Diagnostic: log a sample so mismatches between config and monitoring IDs are visible
const cfgSample = [...ipToHostname.values()].slice(0, 3).join(', ');
const monSample = [...hostnameToState.keys()].slice(0, 3).join(', ');
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
.run(uid('log'), now, 'system',
`CheckMK diagnostic — config hosts (${ipToHostname.size}): [${cfgSample}] | monitoring hosts (${hostnameToState.size}): [${monSample}]`);
} catch (err: any) {
const msg = `CheckMK sync failed — could not fetch monitoring states: ${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 3: update each device based on IP lookup + live state
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 = ? 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;
}
const state = hostnameToState.get(cmkHost);
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown';
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run(newStatus, now, 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']++;
}
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 });
}
});
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);
});