clusev/docker/terminal/server.js

170 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

'use strict';
/*
* Clusev terminal sidecar.
*
* php-fpm cannot hold an interactive PTY, so this small Node service does it. The browser opens a
* WebSocket (proxied by nginx/Caddy at /terminal/ws) carrying a single-use token. The sidecar
* exchanges that token at the Clusev app's internal resolve endpoint (authenticated by a shared
* secret over the private Docker network) for a connection spec, then:
* - kind=server → opens an SSH PTY to the target server (ssh2) with the decrypted credential, or
* - kind=host → spawns a local shell PTY (node-pty) in this sidecar ("the Clusev terminal").
* It then forwards bytes + window resizes both ways. Credentials never reach the browser; the token
* is single-use and short-lived; the sidecar is only reachable behind the app's reverse proxy.
*/
const http = require('http');
const { WebSocketServer } = require('ws');
const { Client } = require('ssh2');
const pty = require('node-pty');
const PORT = parseInt(process.env.TERMINAL_PORT || '3000', 10);
const APP_URL = (process.env.APP_INTERNAL_URL || 'http://app:80').replace(/\/$/, '');
const SECRET = process.env.TERMINAL_SIDECAR_SECRET || '';
const HOST_SHELL = process.env.TERMINAL_HOST_SHELL || 'bash';
const HOST_CWD = process.env.TERMINAL_HOST_CWD || '/home/node';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('clusev-terminal-sidecar\n');
});
// No path filter: only /terminal/ws is routed here by the reverse proxy, so every upgrade is a
// terminal session. The token comes from the query string.
const wss = new WebSocketServer({ server, maxPayload: 1 << 20 });
function send(ws, obj) {
if (ws.readyState === 1) {
try { ws.send(JSON.stringify(obj)); } catch (_) { /* socket gone */ }
}
}
async function resolve(token) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000);
try {
const r = await fetch(`${APP_URL}/_internal/terminal/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Sidecar-Secret': SECRET },
body: JSON.stringify({ token }),
signal: ctrl.signal,
});
if (!r.ok) throw new Error('resolve ' + r.status);
return await r.json();
} finally {
clearTimeout(t);
}
}
// Client → sidecar messages: {type:'data',data} keystrokes, {type:'resize',cols,rows} window change.
function bindInput(ws, io) {
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw.toString()); } catch (_) { return; }
if (m.type === 'data' && typeof m.data === 'string') io.write(m.data);
else if (m.type === 'resize') io.resize(Math.max(1, m.cols | 0), Math.max(1, m.rows | 0));
});
}
function startHost(ws, size) {
let p;
try {
p = pty.spawn(HOST_SHELL, ['-l'], {
name: 'xterm-256color', cwd: HOST_CWD, cols: size.cols, rows: size.rows,
// HOME = the landing dir so the shell opens at ~ (not a bare absolute path) and the prompt
// reads cleanly; the container's `hostname: clusev` makes \h show "clusev", not the hash.
env: { ...process.env, TERM: 'xterm-256color', HOME: HOST_CWD },
});
} catch (e) {
send(ws, { type: 'error', data: 'Shell konnte nicht gestartet werden.' });
ws.close();
return;
}
send(ws, { type: 'ready' });
p.onData((d) => send(ws, { type: 'data', data: d }));
p.onExit(() => { send(ws, { type: 'exit' }); try { ws.close(); } catch (_) {} });
bindInput(ws, { write: (d) => p.write(d), resize: (c, r) => { try { p.resize(c, r); } catch (_) {} } });
ws.on('close', () => { try { p.kill(); } catch (_) {} });
}
function startSsh(ws, spec, size) {
const conn = new Client();
let stream = null;
conn.on('ready', () => {
conn.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (err, s) => {
if (err) { send(ws, { type: 'error', data: 'PTY: ' + err.message }); conn.end(); return; }
stream = s;
send(ws, { type: 'ready' });
s.on('data', (d) => send(ws, { type: 'data', data: d.toString('utf8') }));
if (s.stderr) s.stderr.on('data', (d) => send(ws, { type: 'data', data: d.toString('utf8') }));
s.on('close', () => conn.end());
bindInput(ws, {
write: (d) => { try { s.write(d); } catch (_) {} },
resize: (c, r) => { try { s.setWindow(r, c, 0, 0); } catch (_) {} },
});
});
});
conn.on('error', (e) => { send(ws, { type: 'error', data: 'SSH: ' + e.message }); try { ws.close(); } catch (_) {} });
conn.on('close', () => { try { ws.close(); } catch (_) {} });
// Some hosts negotiate the password via keyboard-interactive rather than the password method.
conn.on('keyboard-interactive', (name, instr, lang, prompts, finish) => finish(prompts.map(() => spec.secret || '')));
ws.on('close', () => { try { if (stream) stream.close(); } catch (_) {} conn.end(); });
const cfg = { host: spec.host, port: spec.port || 22, username: spec.username, readyTimeout: 15000, keepaliveInterval: 20000 };
if (spec.auth_type === 'key') {
cfg.privateKey = spec.secret;
if (spec.passphrase) cfg.passphrase = spec.passphrase;
} else {
cfg.password = spec.secret;
cfg.tryKeyboard = true;
}
try { conn.connect(cfg); } catch (e) { send(ws, { type: 'error', data: 'SSH: ' + e.message }); ws.close(); }
}
wss.on('connection', async (ws, req) => {
// Same-origin guard (defence in depth): the reverse proxy forwards the panel's real Host (nginx
// `Host $host`; Caddy passes it through), so a browser's Origin must match it. A cross-site page
// could never read a token anyway, but this rejects any future leakage being replayed off-origin.
// Non-browser clients send no Origin and are still gated by the single-use token + shared secret.
const origin = req.headers.origin;
if (origin) {
// Compare HOSTNAME only — the proxy's forwarded Host (nginx `$host`) carries no port, while the
// browser's Origin does on a non-standard panel port (APP_PORT≠80/443). Stripping the port both
// sides keeps the hostname boundary (the security-relevant part) without false-rejecting those.
const reqHost = (req.headers.host || '').replace(/:\d+$/, '');
let sameOrigin = false;
try { sameOrigin = new URL(origin).hostname === reqHost; } catch (_) {}
if (!sameOrigin) { send(ws, { type: 'error', data: 'Ungültige Herkunft.' }); ws.close(); return; }
}
let token = '';
const size = { cols: 80, rows: 24 };
try {
const q = new URL(req.url, 'http://x').searchParams;
token = q.get('token') || '';
// Open the PTY at the browser's reported grid so the first screenful matches the viewport. Clamp
// to sane bounds (a bad/huge value must never reach pty/ssh); fall back to 80×24 when absent.
const c = parseInt(q.get('cols'), 10);
const r = parseInt(q.get('rows'), 10);
if (c >= 1 && c <= 1000) size.cols = c;
if (r >= 1 && r <= 1000) size.rows = r;
} catch (_) {}
if (!token || !SECRET) { send(ws, { type: 'error', data: 'Sitzung ungültig.' }); ws.close(); return; }
let spec;
try {
spec = await resolve(token);
} catch (e) {
send(ws, { type: 'error', data: 'Sitzung abgelaufen oder ungültig.' });
ws.close();
return;
}
if (spec && spec.kind === 'host') startHost(ws, size);
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec, size);
else { send(ws, { type: 'error', data: 'Kein gültiges Ziel.' }); ws.close(); }
});
server.listen(PORT, '0.0.0.0', () => console.log(`clusev-terminal-sidecar listening on :${PORT}`));