ssh tests - multi auth
CI / Build & Test (push) Successful in 2m49s

This commit is contained in:
Raven Scott
2026-02-28 17:19:13 -05:00
parent 08eddc48ba
commit d6e4991c4a
+56 -161
View File
@@ -183,78 +183,67 @@ async function startSession(payload) {
// password prompt appears in xterm.js and the user's keystrokes reach ssh.
function _spawnSsh(pendingInput) {
const sshArgs = [
// Ignore all user/system ssh_config files so our -o flags are not
// overridden by local settings (e.g. IdentityFile, BatchMode, etc.)
'-F', '/dev/null',
'-p', String(tunnelPort),
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'BatchMode=no',
'-o', 'PasswordAuthentication=yes',
// Use password auth only — keyboard-interactive sends its prompt via the
// SSH protocol layer (not the PTY), so the user never sees it and SSH
// gets an empty response, causing immediate "Permission denied".
// With password-only auth, SSH writes the prompt to the PTY and reads
// the response from the PTY, which is what xterm.js needs.
'-o', 'PreferredAuthentications=password,publickey',
// 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.
//
// Password delivery via SSH_ASKPASS + a named pipe:
//
// SSH cannot read passwords from /dev/tty when the process has no
// controlling terminal (the case for Chrome's native messaging host).
// We work around this by always setting SSH_ASKPASS to a tiny shell
// helper and SSH_ASKPASS_REQUIRE=force.
//
// The helper script reads the password from a named pipe (FIFO).
// This process writes the password (or waits for the user to type one)
// into the FIFO from the other end.
//
// For the interactive (no saved password) case:
// - We write the prompt text to the WebSocket so xterm.js shows it.
// - We collect keystrokes from the WebSocket until Enter is pressed.
// - We write the collected password into the FIFO.
//
// For the saved-password case:
// - We write the password directly into the FIFO.
// When a password is saved on the connection we use SSH_ASKPASS + a FIFO
// to deliver it automatically. SSH_ASKPASS_REQUIRE=force tells ssh to
// always use the helper instead of /dev/tty (which may not be available
// in the native messaging host process launched by Chrome).
//
// When NO password is saved we do NOT set SSH_ASKPASS at all. SSH will
// try public key auth first (using keys from ~/.ssh/ and ssh-agent).
// If a key works the user is logged in with no prompt. If no key works,
// ssh falls back to password auth and writes the prompt to the PTY —
// the user types their password directly into xterm.js.
let askpassFile = null;
let fifoPath = null;
let fifoFd = null;
let fifoPath = null;
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';
if (password) {
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';
fifoPath = tmpDir + '/hs-pw-' + sessionId + '.fifo';
askpassFile = tmpDir + '/hs-askpass-' + sessionId + '.sh';
// Create the FIFO (bare-fs has no mkfifo — use spawnSync)
const mkfifoResult = require('child_process').spawnSync('mkfifo', [fifoPath]);
if (mkfifoResult.status !== 0) {
throw new Error('mkfifo failed: ' + (mkfifoResult.stderr ? mkfifoResult.stderr.toString() : 'status ' + mkfifoResult.status));
// Create the FIFO
const mkfifoResult = require('child_process').spawnSync('mkfifo', [fifoPath]);
if (mkfifoResult.status !== 0) {
throw new Error('mkfifo failed: status ' + mkfifoResult.status);
}
// The askpass helper reads one line from the FIFO and prints it
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;
}
// The askpass helper reads one line from the FIFO and prints it
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 + ' — SSH password prompts may not work');
askpassFile = null;
fifoPath = null;
}
function cleanupAskpass() {
@@ -282,115 +271,21 @@ async function startSession(payload) {
const sess = sessions.get(sessionId);
if (sess) sess.pty = pty;
// ── Password FIFO writer ────────────────────────────────────────────────
// When SSH invokes the askpass helper, the helper blocks reading the FIFO.
// We open the FIFO for writing (non-blocking open, then write + close) to
// deliver the password.
//
// If a password was saved: deliver it immediately after a short delay
// (SSH needs a moment to exec the helper before we write).
//
// If no password was saved: intercept the first keystrokes from the user
// (before forwarding them to the PTY), collect until Enter, then deliver.
//
// We detect that SSH has invoked the helper by watching for the PTY to go
// quiet for ~200 ms after the connection banner — at that point SSH is
// waiting for the askpass helper which is waiting for the FIFO.
let passwordDelivered = false;
function deliverPassword(pw) {
if (passwordDelivered || !fifoPath) return;
passwordDelivered = true;
// Write the password to the FIFO in a subprocess so we don't block the
// event loop (opening a FIFO for writing blocks until a reader opens it).
try {
const child = require('child_process').spawn(
'/bin/sh',
['-c', 'printf "%s\n" "$PW" > "' + fifoPath + '"'],
{ env: Object.assign({}, process.env, { PW: pw }) }
);
child.on('error', (e) => log('FIFO write error: ' + e.message));
} catch (e) {
log('deliverPassword spawn error: ' + e.message);
}
}
if (password) {
// Saved password: deliver after a short delay so the helper has time to start.
// 500 ms is enough for SSH to complete key exchange and invoke the askpass
// helper, which then blocks reading the FIFO until we write to it.
setTimeout(() => deliverPassword(password), 500);
} else if (fifoPath) {
// Interactive: intercept keystrokes until Enter, show a prompt, then deliver
let collectingPassword = false;
let collectedPw = '';
// We'll start collecting once SSH has had time to invoke the helper
// (detected by a quiet period on the PTY after the initial banner).
let quietTimer = null;
// Monkey-patch the wsConn data handler temporarily to intercept keystrokes
// for password collection. We save the original handler and restore it
// once the password is delivered.
const origDataListeners = activeWsConn.listeners('data').slice();
function startPasswordCollection() {
if (collectingPassword || passwordDelivered) return;
collectingPassword = true;
// Show password prompt in the terminal
if (activeWsConn) {
try { activeWsConn.write(Buffer.from(username + '@127.0.0.1\'s password: ')); } catch (_) {}
// Deliver the saved password via the FIFO once SSH has had time to
// complete key exchange and invoke the askpass helper (~500 ms).
if (password && fifoPath) {
setTimeout(() => {
try {
const child = require('child_process').spawn(
'/bin/sh',
['-c', 'printf "%s\n" "$PW" > "' + fifoPath + '"'],
{ env: Object.assign({}, process.env, { PW: password }) }
);
child.on('error', (e) => log('FIFO write error: ' + e.message));
} catch (e) {
log('deliverPassword spawn error: ' + e.message);
}
// Remove existing data listeners and replace with password collector
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') {
// Enter pressed — deliver password, restore normal input forwarding
if (activeWsConn) {
try { activeWsConn.write(Buffer.from('\r\n')); } catch (_) {}
}
const pw = collectedPw;
collectedPw = '';
collectingPassword = false;
// Restore normal PTY input forwarding
activeWsConn.removeAllListeners('data');
activeWsConn.on('data', (d) => {
if (pty) try { pty.write(d); } catch (_) {}
});
deliverPassword(pw);
return;
} else if (ch === '\x7f' || ch === '\x08') {
// Backspace
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;
deliverPassword('');
} else if (ch.charCodeAt(0) >= 0x20) {
collectedPw += ch;
// Don't echo the character (password masking)
}
}
});
}
// Start collecting after a 500 ms quiet period on the PTY
// (gives SSH time to do key exchange and invoke the askpass helper)
quietTimer = setTimeout(startPasswordCollection, 500);
// If PTY produces output before the timer fires, reset the timer
// (we want to start collecting after output has settled)
const resetQuietTimer = () => {
clearTimeout(quietTimer);
quietTimer = setTimeout(startPasswordCollection, 500);
};
pty.once('data', resetQuietTimer);
}, 500);
}
// PTY output → WebSocket (live, no buffering needed — browser is already open)