/** * SSH session manager for Holesail Browser. * * Each SSH session: * 1. Allocates a unique local port and starts a Holesail client tunnel. * 2. Starts a bare-ws WebSocket server on a unique local port. * 3. Waits for the browser xterm.js client to connect and send its ready- * signal (\x00) before spawning ssh. This is critical for password auth: * if ssh were spawned before the terminal is open, the password prompt * would appear with no one to type into it, causing ssh to time out and * give "Permission denied" before the user ever sees the prompt. * 4. Spawns `ssh` via tt-native which allocates a real local PTY. * A real PTY means SIGWINCH propagates correctly through SSH to the * remote side, so full-screen apps (htop, nano, vim) resize properly. * pty.resize(w, h) updates the local PTY winsize and SSH forwards the * window-change request to the remote automatically. * 5. Bridges the PTY stream ↔ WebSocket so xterm.js can connect directly. * * 100% bare-compatible — no Node.js built-ins, no native .node addons beyond * those already used by the bare ecosystem (tt-native uses libtt, bare-ws * uses bare-tcp). */ let Holesail = null; try { Holesail = require('holesail'); } catch (e) { if (process.stderr) process.stderr.write('[ssh-manager] holesail not available: ' + e.message + '\n'); } let ptySpawn = null; try { ptySpawn = require('tt-native').spawn; } catch (e) { if (process.stderr) process.stderr.write('[ssh-manager] tt-native not available (SSH PTY disabled)\n'); } let WsServer = null; try { WsServer = require('bare-ws').Server; } catch (e) { if (process.stderr) process.stderr.write('[ssh-manager] bare-ws not available: ' + e.message + '\n'); } function log(...args) { const msg = '[ssh-manager] ' + args.join(' '); if (process.stderr) process.stderr.write(msg + '\n'); } // Port allocators — tunnels use 20000+, WS servers use 21000+ let nextTunnelPort = 20000; let nextWsPort = 21000; const tunnelPortFreeList = []; const wsPortFreeList = []; function allocateTunnelPort() { return tunnelPortFreeList.length > 0 ? tunnelPortFreeList.pop() : nextTunnelPort++; } function releaseTunnelPort(p) { if (typeof p === 'number') tunnelPortFreeList.push(p); } function allocateWsPort() { return wsPortFreeList.length > 0 ? wsPortFreeList.pop() : nextWsPort++; } function releaseWsPort(p) { if (typeof p === 'number') wsPortFreeList.push(p); } // Active sessions map const sessions = new Map(); let nextSessionId = 1; // --------------------------------------------------------------------------- // Session management // --------------------------------------------------------------------------- /** * Start a new SSH session: allocate a Holesail tunnel, start a WebSocket server, * wait for the browser terminal to connect, then spawn the SSH process via PTY. * @param {object} payload * @param {string} payload.hsUrl - hs:// key of the remote SSH server. * @param {string} payload.username - SSH username. * @param {string} [payload.password=''] - SSH password (empty for key-based auth). * @param {number} [payload.cols=80] - Initial terminal width in columns. * @param {number} [payload.rows=24] - Initial terminal height in rows. * @param {string} [payload.label=''] - Human-readable label for the UI. * @returns {Promise<{ok: boolean, sessionId?: string, wsPort?: number, error?: string}>} */ async function startSession(payload) { const { hsUrl, username, password = '', cols = 80, rows = 24, label = '' } = payload; if (!Holesail) return { ok: false, error: 'holesail not available' }; if (!ptySpawn) return { ok: false, error: 'tt-native not available — cannot allocate PTY' }; if (!WsServer) return { ok: false, error: 'bare-ws not available' }; if (!hsUrl) return { ok: false, error: 'hsUrl required' }; if (!username) return { ok: false, error: 'username required' }; const sessionId = 'ssh-' + (nextSessionId++); const tunnelPort = allocateTunnelPort(); const wsPort = allocateWsPort(); log('startSession id=' + sessionId + ' user=' + username + ' tunnelPort=' + tunnelPort + ' wsPort=' + wsPort); // 1. Start Holesail client tunnel (with timeout to avoid hanging indefinitely) let holesailInst = null; try { holesailInst = new Holesail({ client: true, key: hsUrl, host: '127.0.0.1', port: tunnelPort }); const TUNNEL_READY_TIMEOUT_MS = 30000; await Promise.race([ holesailInst.ready(), new Promise((_, reject) => setTimeout(() => reject(new Error('Tunnel ready timeout after ' + TUNNEL_READY_TIMEOUT_MS + 'ms')), TUNNEL_READY_TIMEOUT_MS)) ]); log('tunnel ready on 127.0.0.1:' + tunnelPort); } catch (e) { if (holesailInst) try { holesailInst.close(); } catch (_) {} releaseTunnelPort(tunnelPort); releaseWsPort(wsPort); return { ok: false, error: 'Tunnel failed: ' + e.message }; } // Shared state between the WS handler and the PTY (set up after spawn) let pty = null; let activeWsConn = null; // 2. Start the WebSocket server BEFORE spawning ssh. // // This is the key ordering fix for password authentication. If ssh were // spawned first, its password prompt would appear in the PTY immediately // but the browser terminal wouldn't be connected yet — ssh would time out // waiting for input and give "Permission denied" before the user ever sees // the prompt. By starting the WS server first and waiting for the browser's // ready-signal (\x00) before spawning ssh, the terminal is guaranteed to be // open and interactive by the time ssh asks for a password. // Shared reference so the WS close handler can cancel password collection // even though cancelPasswordWatch is defined inside _spawnSsh. let _cancelPasswordWatchFn = null; let wsServer = null; try { wsServer = new WsServer({ port: wsPort, host: '127.0.0.1' }, (wsConn) => { // Close any previous connection before replacing it if (activeWsConn && activeWsConn !== wsConn) { try { activeWsConn.end(); } catch (_) {} } activeWsConn = wsConn; log('WS client connected id=' + sessionId); // The first message from the browser is a '\x00' ready-signal sent // after ws.onopen fires. We spawn ssh at this point so that the // terminal is fully open before ssh can show a password prompt. let sshSpawned = false; wsConn.on('data', (data) => { if (!sshSpawned) { sshSpawned = true; // The ready-signal byte itself is not user input — drop it. // Any extra bytes in the same frame (unlikely but possible) are // queued and forwarded to the PTY once ssh is running. const extraInput = data.length > 1 ? data.slice(1) : null; _spawnSsh(extraInput); return; } // Forward all subsequent input directly to the PTY if (pty) { try { pty.write(data); } catch (_) {} } }); wsConn.on('close', () => { activeWsConn = null; log('WS client disconnected id=' + sessionId); if (_cancelPasswordWatchFn) { try { _cancelPasswordWatchFn(); } catch (_) {} } }); wsConn.on('error', () => { activeWsConn = null; }); }); wsServer.on('error', (err) => { log('SSH WS server error: ' + err.message); }); } catch (e) { if (holesailInst) holesailInst.close().catch(() => {}); releaseTunnelPort(tunnelPort); releaseWsPort(wsPort); return { ok: false, error: 'Failed to start WebSocket server: ' + e.message }; } // 3. Spawn ssh with a real PTY via tt-native — called once the browser is ready. // // tt-native calls forkpty(3) under the hood, giving ssh a proper local PTY. // This means: // - The initial terminal size (cols × rows) is set in the PTY winsize at // spawn time and forwarded to the remote via the SSH handshake. // - pty.resize(w, h) calls ioctl(TIOCSWINSZ) on the local PTY master, // which ssh detects via SIGWINCH and forwards as an SSH window-change // request to the remote sshd, which then resizes the remote PTY and // delivers SIGWINCH to the remote foreground process. // - No shell commands are ever injected into the terminal stream. // // Password delivery strategy: // When a password is provided we use SSH_ASKPASS + SSH_ASKPASS_REQUIRE=force. // This tells ssh to run a helper program to obtain the password instead of // reading from /dev/tty. The helper is a tiny shell one-liner written to a // temp file that simply echoes the password. This works even when the // process has no controlling terminal (which is the case for the native // messaging host launched by Chrome). // // When no password is provided the user types it interactively — the PTY // allocated by tt-native is the controlling terminal for ssh, so the // password prompt appears in xterm.js and the user's keystrokes reach ssh. function _spawnSsh(pendingInput) { const sshArgs = [ '-p', String(tunnelPort), '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'BatchMode=no', '-o', 'PasswordAuthentication=yes', // Try public key first, then keyboard-interactive (used by most modern // servers), then plain password as a final fallback. '-o', 'PreferredAuthentications=publickey,keyboard-interactive,password', '-o', 'NumberOfPasswordPrompts=1', username + '@127.0.0.1' ]; // Build the environment for the SSH process. // // When a saved password is provided we use SSH_ASKPASS + SSH_ASKPASS_REQUIRE=force // so SSH uses our FIFO helper script instead of reading from /dev/tty. // // When no password is saved we do NOT set SSH_ASKPASS_REQUIRE=force — SSH // interacts with the PTY directly (keyboard-interactive prompts appear in // xterm.js and the user types naturally). Setting REQUIRE=force would break // keyboard-interactive auth because that method ignores SSH_ASKPASS entirely // and expects to read from the PTY, causing an immediate auth failure. let askpassFile = null; let fifoPath = null; let fifoWriteChild = null; // tracked so we can kill it if key auth succeeds const sshEnv = Object.assign({}, process.env, { TERM: 'xterm-256color' }); // Remove any inherited SSH_ASKPASS settings — only set them below when needed. delete sshEnv.SSH_ASKPASS; delete sshEnv.SSH_ASKPASS_REQUIRE; if (password) { // Saved password: set up the FIFO askpass helper so SSH never blocks on // /dev/tty (which doesn't exist in the native messaging host process). try { const bareFs = require('bare-fs'); const os = require('bare-os'); const tmpDir = (os && os.tmpdir) ? os.tmpdir() : '/tmp'; fifoPath = tmpDir + '/hs-pw-' + sessionId + '.fifo'; askpassFile = tmpDir + '/hs-askpass-' + sessionId + '.sh'; const mkfifoResult = require('child_process').spawnSync('mkfifo', [fifoPath]); if (mkfifoResult.status !== 0) { throw new Error('mkfifo failed: status ' + mkfifoResult.status); } bareFs.writeFileSync(askpassFile, '#!/bin/sh\ncat "' + fifoPath + '"\n'); bareFs.chmodSync(askpassFile, 0o700); sshEnv.SSH_ASKPASS = askpassFile; sshEnv.SSH_ASKPASS_REQUIRE = 'force'; if (!sshEnv.DISPLAY) sshEnv.DISPLAY = ':0'; log('askpass FIFO=' + fifoPath + ' helper=' + askpassFile); } catch (e) { log('WARNING: askpass setup failed: ' + e.message); askpassFile = null; fifoPath = null; } } function cleanupAskpass() { // Kill the FIFO writer if it is still blocked (e.g. key auth succeeded // and the askpass helper was never invoked, so the FIFO was never read). if (fifoWriteChild) { try { fifoWriteChild.kill('SIGTERM'); } catch (_) {} fifoWriteChild = null; } try { if (fifoPath) require('bare-fs').unlinkSync(fifoPath); } catch (_) {} try { if (askpassFile) require('bare-fs').unlinkSync(askpassFile); } catch (_) {} } function deliverPasswordToFifo(pw) { if (!fifoPath) return; try { const child = require('child_process').spawn( '/bin/sh', ['-c', 'printf "%s\n" "$PW" > "' + fifoPath + '"'], { env: Object.assign({}, process.env, { PW: pw }) } ); fifoWriteChild = child; child.on('error', (e) => log('FIFO write error: ' + e.message)); child.on('exit', () => { if (fifoWriteChild === child) fifoWriteChild = null; }); } catch (e) { log('deliverPassword spawn error: ' + e.message); } } try { pty = ptySpawn('ssh', sshArgs, { width: cols, height: rows, env: sshEnv }); } catch (e) { cleanupAskpass(); if (activeWsConn) { try { activeWsConn.write(Buffer.from('\r\n[Failed to spawn ssh: ' + e.message + ']\r\n')); } catch (_) {} } // Release all resources — ports, WS server, and Holesail tunnel — so they // are not leaked for the lifetime of the process. stopSession({ sessionId }).catch(() => {}); return; } pty.once('exit', cleanupAskpass); pty.once('error', cleanupAskpass); // Update session record with the live PTY reference const sess = sessions.get(sessionId); if (sess) sess.pty = pty; if (password) { // Saved password: deliver after a short delay so SSH has time to complete // key exchange and invoke the askpass helper before we write to the FIFO. setTimeout(() => deliverPasswordToFifo(password), 500); } else if (fifoPath) { // No saved password: show a prompt in xterm.js, collect keystrokes, then // deliver via the FIFO. // // Strategy: watch PTY output. SSH_ASKPASS is set, so when key auth fails // SSH invokes the askpass helper which blocks reading the FIFO — the PTY // goes quiet. We detect this quiet period (400 ms of no PTY output) and // show the password prompt. // // If key auth succeeds the shell starts producing output. We treat any // PTY output that contains a printable character after the initial SSH // banner phase as a sign that we are already logged in and cancel the // password collection window entirely. let collectingPassword = false; let passwordDelivered = false; let collectedPw = ''; let quietTimer = null; let fallbackTimer = null; // Only watch for the password prompt within the first 8 seconds. const authDeadline = Date.now() + 8000; function cancelPasswordWatch() { _cancelPasswordWatchFn = null; passwordDelivered = true; clearTimeout(quietTimer); clearTimeout(fallbackTimer); quietTimer = null; fallbackTimer = null; pty.removeListener('data', onPtyDataForAuth); } _cancelPasswordWatchFn = cancelPasswordWatch; function startPasswordCollection() { if (collectingPassword || passwordDelivered) return; // Past the deadline — key auth must have succeeded, bail out. if (Date.now() > authDeadline) { cancelPasswordWatch(); return; } collectingPassword = true; if (!activeWsConn) { // Terminal disconnected — deliver empty password to unblock the FIFO deliverPasswordToFifo(''); return; } try { activeWsConn.write(Buffer.from(username + '@127.0.0.1\'s password: ')); } catch (_) {} activeWsConn.removeAllListeners('data'); activeWsConn.on('data', (data) => { const str = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); for (const ch of str) { if (ch === '\r' || ch === '\n') { if (activeWsConn) { try { activeWsConn.write(Buffer.from('\r\n')); } catch (_) {} } const pw = collectedPw; collectedPw = ''; collectingPassword = false; cancelPasswordWatch(); // Restore normal PTY input forwarding activeWsConn.removeAllListeners('data'); activeWsConn.on('data', (d) => { if (pty) try { pty.write(d); } catch (_) {} }); deliverPasswordToFifo(pw); return; } else if (ch === '\x7f' || ch === '\x08') { if (collectedPw.length > 0) collectedPw = collectedPw.slice(0, -1); } else if (ch === '\x03') { // Ctrl+C — cancel if (activeWsConn) { try { activeWsConn.write(Buffer.from('\r\n')); } catch (_) {} } collectingPassword = false; cancelPasswordWatch(); deliverPasswordToFifo(''); } else if (ch.charCodeAt(0) >= 0x20) { collectedPw += ch; // No echo — password masking } } }); } // Watch PTY output to decide whether to show a password prompt. // // Key insight: when public key auth succeeds, SSH immediately starts // streaming the MOTD/banner/shell prompt — substantial output arrives // within the first few hundred ms. When key auth fails and SSH invokes // the askpass helper, the PTY goes completely silent (the helper is // blocked waiting for the FIFO). // // Rules: // 1. Don't start the quiet timer until the PTY has produced at least // one chunk of output (proves SSH connected and started auth). // 2. Once output has arrived, accumulate it. If it contains typical // post-login content (MOTD keywords, shell prompt chars) cancel the // password watch — we are already logged in. // 3. Only show the password prompt after 600 ms of silence AND we have // NOT seen post-login content. let seenOutput = false; let outputSoFar = ''; // Patterns that indicate a successful login (MOTD / shell prompt) const LOGIN_RE = /Welcome|Last login|Ubuntu|Debian|CentOS|Alpine|[$#>%]\s*$/i; function onPtyDataForAuth(chunk) { if (passwordDelivered || collectingPassword) return; const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); // Keep only the tail so LOGIN_RE can match recent output without // accumulating the entire session transcript in memory. outputSoFar = (outputSoFar + str).slice(-4096); seenOutput = true; // If the accumulated output looks like post-login content, we are in — // cancel the password watch immediately. if (LOGIN_RE.test(outputSoFar)) { cancelPasswordWatch(); return; } // Reset the quiet timer — show prompt only after 600 ms of silence clearTimeout(quietTimer); quietTimer = setTimeout(startPasswordCollection, 600); } pty.on('data', onPtyDataForAuth); // If SSH produces absolutely no output within 2 s (e.g. very slow tunnel) // fall through to the password prompt anyway. fallbackTimer = setTimeout(startPasswordCollection, 2000); // If the PTY exits or errors before auth completes, cancel the timers so // they don't fire against a dead session. pty.once('exit', cancelPasswordWatch); pty.once('error', cancelPasswordWatch); } // PTY output → WebSocket (live, no buffering needed — browser is already open) pty.on('data', (chunk) => { if (activeWsConn) { try { activeWsConn.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } catch (_) {} } }); pty.on('exit', (code) => { log('pty exit code=' + code + ' id=' + sessionId); if (activeWsConn) { try { activeWsConn.write(Buffer.from('\r\n[Connection closed]\r\n')); } catch (_) {} setTimeout(() => { if (activeWsConn) try { activeWsConn.end(); } catch (_) {} }, 200); } const sess = sessions.get(sessionId); if (sess) sess.state = 'closed'; }); pty.on('close', () => { const sess = sessions.get(sessionId); if (sess) sess.state = 'closed'; }); pty.on('error', (err) => { log('pty error: ' + err.message + ' id=' + sessionId); if (activeWsConn) { try { activeWsConn.write(Buffer.from('\r\n[SSH error: ' + err.message + ']\r\n')); } catch (_) {} } }); // Forward any input bytes that arrived in the same frame as the ready-signal if (pendingInput && pendingInput.length > 0) { try { pty.write(pendingInput); } catch (_) {} } } sessions.set(sessionId, { holesail: holesailInst, pty: null, // set by _spawnSsh once the browser connects wsServer, tunnelPort, wsPort, label, username, hsUrl, cols, rows, state: 'connected', createdAt: Date.now(), hasPassword: !!password }); return { ok: true, sessionId, wsPort }; } /** * Resize the PTY of an active SSH session. * Silently ignored if the session does not exist or has no PTY. * @param {object} payload * @param {string} payload.sessionId * @param {number} payload.cols - New terminal width. * @param {number} payload.rows - New terminal height. */ function resizeSession(payload) { const { sessionId, cols, rows } = payload; const sess = sessions.get(sessionId); if (!sess || !sess.pty) return; sess.cols = cols; sess.rows = rows; // tt-native calls ioctl(TIOCSWINSZ) on the local PTY master. // ssh detects the SIGWINCH, sends an SSH window-change request to the // remote sshd, which resizes the remote PTY and signals the foreground // process. Full-screen apps redraw automatically — no shell commands needed. try { sess.pty.resize(cols, rows); } catch (_) {} } /** * Stop an SSH session: kill the PTY process, close the WebSocket server, * close the Holesail tunnel, and release all allocated ports. * @param {object} payload * @param {string} payload.sessionId * @returns {Promise<{ok: boolean, error?: string}>} */ async function stopSession(payload) { const { sessionId } = payload; const sess = sessions.get(sessionId); if (!sess) return { ok: false, error: 'Session not found' }; log('stopSession id=' + sessionId); sess.state = 'stopping'; if (sess.pty) try { sess.pty.kill('SIGTERM'); } catch (_) {} if (sess.wsServer) try { sess.wsServer.close(); } catch (_) {} if (sess.holesail) try { await sess.holesail.close(); } catch (_) {} releaseTunnelPort(sess.tunnelPort); releaseWsPort(sess.wsPort); sessions.delete(sessionId); return { ok: true }; } /** * Return a snapshot of all active SSH sessions. * @returns {Array<{sessionId: string, label: string, username: string, hsUrl: string, wsPort: number, state: string, cols: number, rows: number, createdAt: number}>} */ function getSessions() { const list = []; for (const [sessionId, s] of sessions) { list.push({ sessionId, label: s.label, username: s.username, hsUrl: s.hsUrl, wsPort: s.wsPort, state: s.state, cols: s.cols, rows: s.rows, createdAt: s.createdAt }); } return list; } /** * Stop all active SSH sessions. Called during native host shutdown. * @returns {Promise} */ async function cleanup() { for (const [sessionId] of sessions) { await stopSession({ sessionId }).catch(() => {}); } } module.exports = { startSession, stopSession, resizeSession, getSessions, cleanup };