feat(terminal): proxy /terminal (+WebSocket) through the Void app to ttyd

Makes the embedded Terminal work via the raw LAN IP too (bypasses Traefik's
/terminal route). ttyd base-path preserved; firewall on CT300 opened to the app host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 23:44:31 +10:00
parent 5aba750102
commit 4a55c24700
3 changed files with 74 additions and 3 deletions

51
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.0.0-alpha.6", "version": "2.0.0-alpha.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "void-server", "name": "void-server",
"version": "2.0.0-alpha.6", "version": "2.0.0-alpha.16",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
@@ -14,6 +14,7 @@
"dompurify": "^3.4.7", "dompurify": "^3.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"http-proxy": "^1.18.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"marked": "^18.0.4", "marked": "^18.0.4",
"multer": "^2.1.1", "multer": "^2.1.1",
@@ -1548,6 +1549,12 @@
"node": ">= 0.6" "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": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -1713,6 +1720,26 @@
"url": "https://opencollective.com/express" "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": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -1966,6 +1993,20 @@
"url": "https://opencollective.com/express" "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": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -3185,6 +3226,12 @@
"node": ">=0.10.0" "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": { "node_modules/rolldown": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",

View File

@@ -16,6 +16,7 @@
"dompurify": "^3.4.7", "dompurify": "^3.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"http-proxy": "^1.18.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"marked": "^18.0.4", "marked": "^18.0.4",
"multer": "^2.1.1", "multer": "^2.1.1",

View File

@@ -11,11 +11,29 @@ import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js'; import { seedFromConfig } from './lib/health/registry.js';
import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js'; import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
const VERSION = '2.0.0-alpha.16'; 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() { export function createApp() {
const app = express(); 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({ app.use(express.json({
limit: '10mb', limit: '10mb',
verify: (req, _res, buf) => { req.rawBody = buf; } 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. // 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'); }) seedFromConfig().then(n => { if (n) log.info({ seeded: n }, 'monitored_services seeded from config'); })
.catch(err => log.error({ err }, 'service registry seed failed')); .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']) { for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, async () => { process.on(sig, async () => {
log.info({ sig }, 'shutting down'); log.info({ sig }, 'shutting down');