Files
holesail-browser/native-host/ssh-manager.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -05:00

592 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 — if the user has a key in ~/.ssh/ or ssh-agent
// it will be used and no password prompt appears at all.
// Fall back to password only when public key auth fails.
// keyboard-interactive is excluded because it delivers its prompt via the
// SSH protocol layer (not the PTY), so the user never sees it.
'-o', 'PreferredAuthentications=publickey,password',
'-o', 'NumberOfPasswordPrompts=1',
username + '@127.0.0.1'
];
// Build the environment for the SSH process.
//
// The native messaging host launched by Chrome has no controlling terminal,
// so SSH cannot read passwords from /dev/tty. We always set SSH_ASKPASS +
// SSH_ASKPASS_REQUIRE=force so SSH uses our helper script instead.
//
// The helper reads the password from a named FIFO. We write to that FIFO:
// • Saved password → written automatically after a short delay.
// • No saved password → we show a prompt in xterm.js, collect the user's
// keystrokes via the WebSocket, then write the result to the FIFO.
// Public key auth is tried first; the FIFO is only read if SSH actually
// invokes the askpass helper (i.e. key auth failed).
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' });
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<void>}
*/
async function cleanup() {
for (const [sessionId] of sessions) {
await stopSession({ sessionId }).catch(() => {});
}
}
module.exports = { startSession, stopSession, resizeSession, getSessions, cleanup };