158 lines
6.8 KiB
JavaScript
158 lines
6.8 KiB
JavaScript
'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 || '/workspace';
|
|
|
|
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) {
|
|
let p;
|
|
try {
|
|
p = pty.spawn(HOST_SHELL, ['-l'], {
|
|
name: 'xterm-256color', cwd: HOST_CWD, cols: 80, rows: 24,
|
|
env: { ...process.env, TERM: 'xterm-256color' },
|
|
});
|
|
} 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) {
|
|
const conn = new Client();
|
|
let stream = null;
|
|
|
|
conn.on('ready', () => {
|
|
conn.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (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 = '';
|
|
try { token = new URL(req.url, 'http://x').searchParams.get('token') || ''; } 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);
|
|
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec);
|
|
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}`));
|