CI / Build & Test (push) Successful in 3m12s
- Move connect-proxy and https-proxy into proxy/ - Move certificate-authority, backup-manager, ssh-manager, rdp-manager into managers/ - Move messenger.js into host/ - Move test-dirname.cjs into test/ - Update imports, CI lint paths, and ARCHITECTURE.md
594 lines
23 KiB
JavaScript
594 lines
23 KiB
JavaScript
/**
|
||
* 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<void>}
|
||
*/
|
||
async function cleanup() {
|
||
for (const [sessionId] of sessions) {
|
||
await stopSession({ sessionId }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
module.exports = { startSession, stopSession, resizeSession, getSessions, cleanup };
|