import 'dotenv/config'; import express from 'express'; import { pool } from './lib/db/pool.js'; import { log } from './lib/log.js'; import { mountApi } from './lib/api/index.js'; import * as queue from './lib/jobs/queue.js'; import { registerWorkers } from './lib/jobs/index.js'; import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as iconsRouter } from './lib/api/routes/icons.js'; import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js'; import { router as devicesRouter } from './lib/api/routes/devices.js'; import { startCron } from './lib/cron/index.js'; import { seedFromConfig } from './lib/health/registry.js'; import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; import { handleMcp } from './lib/mcp/http.js'; import httpProxy from 'http-proxy'; import { readFileSync } from 'node:fs'; // Read the version from package.json so a deploy never serves a stale /health // version (the old hardcoded const had to be bumped by hand and caused the // health-gated deploy to roll back 3x when forgotten). const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version; // Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal // works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the // raw LAN IP (which bypasses Traefik's /terminal route). ttyd's base-path is // /terminal, so the path is preserved as-is. const TTYD_URL = process.env.TTYD_URL || 'http://192.168.1.212:7681'; const terminalProxy = httpProxy.createProxyServer({ target: TTYD_URL, ws: true, changeOrigin: true }); terminalProxy.on('error', (err) => log.error({ err }, 'terminal proxy error')); const isTerminalPath = (url = '') => url === '/terminal' || url.startsWith('/terminal/') || url.startsWith('/terminal?'); export function createApp() { const app = express(); // Terminal proxy first — before body parsing/static — so the raw request // stream is forwarded untouched to ttyd. app.use((req, res, next) => { if (isTerminalPath(req.url)) return terminalProxy.web(req, res, {}, () => { res.status(502).end('terminal unavailable'); }); next(); }); app.use(express.json({ limit: '10mb', verify: (req, _res, buf) => { req.rawBody = buf; } })); // no-cache (not no-store): the browser may cache but must revalidate, so a // deploy's new CSS/JS takes effect immediately instead of serving stale assets. app.use(express.static('public', { setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') })); // /api/ingest/* bypasses agentOrOwner — webhooks authenticate via HMAC // and need access to req.rawBody captured above. app.use('/api/ingest', ingestRouter); // /api/icons/* bypasses agentOrOwner — tags can't send bearer headers; // slugs are sanitized to [a-z0-9-] to prevent path traversal. app.use('/api/icons', iconsRouter); // /api/icon-sets/* — GET routes are open (same reason as above); // POST/DELETE are protected by requireOwner inside the router. app.use('/api/icon-sets', iconSetsRouter); // /api/devices — band data is public (like the static devices.json it replaces); // discovered/edit/scan sub-routes use requireOwner (401/403) internally. app.use('/api/devices', devicesRouter); app.get('/health', async (_req, res) => { let db_ok = false; try { await pool.query('SELECT 1'); db_ok = true; } catch (e) { log.error({ err: e }, 'healthcheck db ping failed'); } res.json({ ok: true, db_ok, version: VERSION }); }); mountApi(app); // MCP Streamable HTTP for external agents (read + suggest-only, space-scoped). app.all('/mcp', mcpAuth, handleMcp); app.use((_req, res) => res.status(404).json({ error: { code: 'not_found' } })); app.use((err, _req, res, _next) => { log.error({ err }, 'unhandled'); res.status(500).json({ error: { code: 'internal', message: 'internal server error' } }); }); return app; } if (import.meta.url === `file://${process.argv[1]}`) { const port = process.env.PORT || 3000; const app = createApp(); queue.start() .then(registerWorkers) .then(() => log.info('job queue ready')) .catch(err => log.error({ err }, 'queue boot failed')); startCron(); // One-time bootstrap of the service registry from config/services.json if empty. seedFromConfig().then(n => { if (n) log.info({ seeded: n }, 'monitored_services seeded from config'); }) .catch(err => log.error({ err }, 'service registry seed failed')); const server = app.listen(port, () => log.info({ port }, 'void-server listening')); // Proxy the terminal's WebSocket upgrade to ttyd as well. server.on('upgrade', (req, socket, head) => { if (isTerminalPath(req.url)) terminalProxy.ws(req, socket, head); else socket.destroy(); }); for (const sig of ['SIGTERM', 'SIGINT']) { process.on(sig, async () => { log.info({ sig }, 'shutting down'); try { await queue.stop(); } catch { /* */ } process.exit(0); }); } }