diff --git a/package-lock.json b/package-lock.json index ffbd530..b854eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "void-server", - "version": "2.0.0-alpha.6", + "version": "2.0.0-alpha.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "void-server", - "version": "2.0.0-alpha.6", + "version": "2.0.0-alpha.16", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", @@ -14,6 +14,7 @@ "dompurify": "^3.4.7", "dotenv": "^17.4.2", "express": "^5.2.1", + "http-proxy": "^1.18.1", "jsdom": "^29.1.1", "marked": "^18.0.4", "multer": "^2.1.1", @@ -1548,6 +1549,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1713,6 +1720,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -1966,6 +1993,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -3185,6 +3226,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", diff --git a/package.json b/package.json index ce7e2dc..2710b37 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dompurify": "^3.4.7", "dotenv": "^17.4.2", "express": "^5.2.1", + "http-proxy": "^1.18.1", "jsdom": "^29.1.1", "marked": "^18.0.4", "multer": "^2.1.1", diff --git a/server.js b/server.js index cdd39db..cadd2d7 100644 --- a/server.js +++ b/server.js @@ -11,11 +11,29 @@ 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'; const VERSION = '2.0.0-alpha.16'; +// 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; } @@ -71,7 +89,12 @@ if (import.meta.url === `file://${process.argv[1]}`) { // 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')); - app.listen(port, () => log.info({ port }, 'void-server listening')); + 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');