'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}`));
