clusev/docker/terminal/server.js

183 lines
8.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 crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { Client } = require('ssh2');
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 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));
});
}
// Parse the TOFU-pinned host key the app sends (phpseclib's "<algo> <base64>" string) into the raw
// wire-format key for comparison with what ssh2 presents. Returns:
// null → no pin configured (empty/absent) → accept, TOFU first-use
// false → a pin IS present but is malformed/undecodable → FAIL CLOSED (never silently unpin)
// Buffer → the pinned key to enforce
function parseHostKeyPin(hostKey) {
if (typeof hostKey !== 'string' || hostKey.trim() === '') return null; // no pin
const parts = hostKey.trim().split(/\s+/);
const b64 = (parts.length >= 2 ? parts[1] : parts[0]) || '';
if (b64 === '') return false;
let buf;
try { buf = Buffer.from(b64, 'base64'); } catch (_) { return false; }
// Buffer.from is lenient (skips invalid chars); re-encode and compare to reject non-canonical/garbage.
if (buf.length === 0 || buf.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) return false;
return buf;
}
function startSsh(ws, spec, size) {
const conn = new Client();
let stream = null;
const pin = parseHostKeyPin(spec.host_key); // null | false | Buffer
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 };
// Pin the host key against the app's TOFU record — refuse a mismatch (MITM) before sending the
// credential. null = no pin (Clusev host / never-contacted server) → accept like TOFU first use;
// false = a stored-but-malformed pin → FAIL CLOSED (a corrupted record must not silently unpin).
cfg.hostVerifier = (key, cb) => {
if (pin === null) { if (cb) cb(true); return true; }
if (pin === false) {
send(ws, { type: 'error', data: 'Host-Key-Pin ungültig — Verbindung abgebrochen.' });
if (cb) cb(false);
return false;
}
const buf = Buffer.isBuffer(key) ? key : Buffer.from(key || '');
const ok = buf.length === pin.length && crypto.timingSafeEqual(buf, pin);
if (!ok) send(ws, { type: 'error', data: 'Host-Key stimmt nicht — möglicher MITM. Verbindung abgebrochen.' });
if (cb) cb(ok);
return ok;
};
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;
}
// resolve() always returns kind:'server' (the host terminal resolves to an SSH login too), so the
// sidecar only ever opens an SSH PTY — there is no local-shell path.
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}`));