Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Enable sudo fallback for Docker panel * Prefer sudo for Docker panel commands * Use pending saved sudo password immediately * Try plain Docker before sudo fallback * Detect Docker before sudo fallback * Add sudo fallback for Docker popup commands * Harden Docker popup sudo fallback
893 lines
36 KiB
JavaScript
893 lines
36 KiB
JavaScript
/* eslint-disable no-undef */
|
|
const crypto = require("node:crypto");
|
|
const { createSystemKnownHostsApi } = require("../sshBridge/systemKnownHosts.cjs");
|
|
|
|
//
|
|
// EternalTerminal session backend, factored into the createXxxSessionApi
|
|
// pattern used by moshSession.cjs / telnetSession.cjs. Dependencies arrive
|
|
// via `ctx`; `with (ctx)` exposes them as free identifiers.
|
|
//
|
|
// Unlike Mosh, the `et` client performs its own SSH bootstrap and ET protocol
|
|
// handshake — Netcatty just spawns the bundled `et` binary as a PTY. Saved
|
|
// credentials (password / passphrase / jump host) are injected into et's
|
|
// internal ssh via a private ~/.ssh home + SSH_ASKPASS helper, since et drives
|
|
// ssh itself rather than exposing the prompts for us to type into.
|
|
function createEtSessionApi(ctx) {
|
|
with (ctx) {
|
|
// Node script invoked by ssh as SSH_ASKPASS. It reads the prompt text from
|
|
// argv, matches it against the entries in NETCATTY_ET_ASKPASS_MAP, and
|
|
// prints the matching secret. Written to the session's private .ssh dir.
|
|
const ET_ASKPASS_SCRIPT = String.raw`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
function normalizePrompt(prompt) {
|
|
return String(prompt || "").toLowerCase();
|
|
}
|
|
|
|
function loadEntries() {
|
|
const mapPath = process.env.NETCATTY_ET_ASKPASS_MAP;
|
|
if (!mapPath) return [];
|
|
try {
|
|
const raw = fs.readFileSync(mapPath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function matchesPrompt(entry, prompt) {
|
|
const matchers = Array.isArray(entry.matchers) ? entry.matchers : [];
|
|
return matchers.some((matcher) => prompt.includes(String(matcher || "").toLowerCase()));
|
|
}
|
|
|
|
function promptMatchScore(entry, prompt) {
|
|
const matchers = Array.isArray(entry.matchers) ? entry.matchers : [];
|
|
let score = 0;
|
|
for (const matcher of matchers) {
|
|
const value = String(matcher || "").toLowerCase();
|
|
if (value && prompt.includes(value)) score = Math.max(score, value.length);
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function pickEntry(entries, prompt) {
|
|
const wantsPassphrase = prompt.includes("passphrase");
|
|
const scoped = entries.filter((entry) => entry.type === (wantsPassphrase ? "passphrase" : "password"));
|
|
const matched = scoped
|
|
.map((entry, index) => ({ entry, index, score: promptMatchScore(entry, prompt) }))
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score || a.index - b.index)[0]?.entry;
|
|
if (matched) return matched;
|
|
if (scoped.length === 1) return scoped[0];
|
|
return null;
|
|
}
|
|
|
|
function main() {
|
|
const prompt = normalizePrompt(process.argv.slice(2).join(" "));
|
|
const entries = loadEntries();
|
|
const entry = pickEntry(entries, prompt);
|
|
if (!entry?.secretFile) return;
|
|
try {
|
|
const secret = fs.readFileSync(entry.secretFile, "utf8").replace(/\r?\n$/, "");
|
|
process.stdout.write(secret + "\n");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
main();
|
|
`;
|
|
|
|
/**
|
|
* Resolve Netcatty's bundled `et` client. System `et` installs are
|
|
* intentionally ignored so dev, CI, and release builds exercise the same
|
|
* binary (mirrors resolveBareMoshClient).
|
|
*/
|
|
function resolveBareEtClient(opts = {}) {
|
|
return bundledEtClient(opts);
|
|
}
|
|
|
|
function writeSecureFile(filePath, content, mode = 0o600) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, typeof content === "string" ? "utf8" : undefined);
|
|
if (process.platform === "win32") {
|
|
try {
|
|
// Remove inherited ACLs, grant only current user full control
|
|
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`], {
|
|
windowsHide: true,
|
|
timeout: 5000,
|
|
});
|
|
} catch {
|
|
// ignore ACL failures (e.g. network drives)
|
|
}
|
|
} else {
|
|
try {
|
|
fs.chmodSync(filePath, mode);
|
|
} catch {
|
|
// ignore chmod failures on non-POSIX filesystems
|
|
}
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function normalizeSshConfigPath(targetPath) {
|
|
return path.resolve(String(targetPath)).replace(/\\/g, "/");
|
|
}
|
|
|
|
function quoteSshConfigValue(value) {
|
|
const normalized = normalizeSshConfigPath(value);
|
|
return `"${normalized.replace(/(["\\])/g, "\\$1")}"`;
|
|
}
|
|
|
|
// POSIX single-quote a string so it is safe to embed verbatim in a /bin/sh
|
|
// script (handles spaces and embedded single quotes in e.g. an .app path).
|
|
function shellSingleQuote(value) {
|
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function createPasswordPromptMatchers({ hostname, username, port }) {
|
|
const values = new Set();
|
|
const addHostVariant = (hostValue) => {
|
|
if (!hostValue) return;
|
|
const lowerHost = String(hostValue).toLowerCase();
|
|
values.add(lowerHost);
|
|
if (username) {
|
|
values.add(`${String(username).toLowerCase()}@${lowerHost}`);
|
|
values.add(`${String(username).toLowerCase()}@${lowerHost}'s password`);
|
|
if (port) values.add(`${String(username).toLowerCase()}@${lowerHost}:${port}`);
|
|
}
|
|
};
|
|
|
|
addHostVariant(hostname);
|
|
return [...values];
|
|
}
|
|
|
|
function createPassphrasePromptMatchers(keyPath) {
|
|
const normalizedPath = normalizeSshConfigPath(keyPath).toLowerCase();
|
|
return [normalizedPath, path.basename(normalizedPath)];
|
|
}
|
|
|
|
function addAskpassEntry(entries, type, matchers, secretFile) {
|
|
if (!secretFile) return;
|
|
entries.push({
|
|
type,
|
|
matchers: [...new Set((matchers || []).map((value) => String(value || "").toLowerCase()).filter(Boolean))],
|
|
secretFile,
|
|
});
|
|
}
|
|
|
|
function createEtAskpassArtifacts(sshDir, askpassEntries) {
|
|
if (!Array.isArray(askpassEntries) || askpassEntries.length === 0) {
|
|
return { env: {}, artifacts: [] };
|
|
}
|
|
|
|
const askpassMapPath = path.join(sshDir, "netcatty-et-askpass-map.json");
|
|
const askpassScriptPath = path.join(sshDir, "netcatty-et-askpass.cjs");
|
|
writeSecureFile(askpassMapPath, `${JSON.stringify(askpassEntries, null, 2)}\n`, 0o600);
|
|
writeSecureFile(askpassScriptPath, ET_ASKPASS_SCRIPT, 0o700);
|
|
|
|
if (process.platform === "win32") {
|
|
const askpassCmdPath = path.join(sshDir, "netcatty-et-askpass.cmd");
|
|
writeSecureFile(
|
|
askpassCmdPath,
|
|
`@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath.replace(/"/g, '""')}" "%~dp0netcatty-et-askpass.cjs" %*\r\n`,
|
|
0o700,
|
|
);
|
|
return {
|
|
env: {
|
|
SSH_ASKPASS: askpassCmdPath,
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
DISPLAY: process.env.DISPLAY || "netcatty:0",
|
|
NETCATTY_ET_ASKPASS_MAP: askpassMapPath,
|
|
},
|
|
artifacts: [askpassMapPath, askpassScriptPath, askpassCmdPath],
|
|
};
|
|
}
|
|
|
|
// Unix: ssh execs SSH_ASKPASS directly, so the helper must be runnable
|
|
// without relying on a `node` on PATH. The `.cjs` shebang (#!/usr/bin/env
|
|
// node) breaks in packaged builds because Electron does not put a `node`
|
|
// binary on the user's PATH. Mirror the Windows .cmd wrapper: run the
|
|
// script through Electron's own executable with ELECTRON_RUN_AS_NODE=1.
|
|
const askpassWrapperPath = path.join(sshDir, "netcatty-et-askpass.sh");
|
|
const electronExec = shellSingleQuote(process.execPath);
|
|
writeSecureFile(
|
|
askpassWrapperPath,
|
|
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} "$(dirname "$0")/netcatty-et-askpass.cjs" "$@"\n`,
|
|
0o700,
|
|
);
|
|
return {
|
|
env: {
|
|
SSH_ASKPASS: askpassWrapperPath,
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
DISPLAY: process.env.DISPLAY || "netcatty:0",
|
|
NETCATTY_ET_ASKPASS_MAP: askpassMapPath,
|
|
},
|
|
artifacts: [askpassMapPath, askpassScriptPath, askpassWrapperPath],
|
|
};
|
|
}
|
|
|
|
function copyIfExists(sourcePath, targetPath) {
|
|
try {
|
|
if (fs.existsSync(sourcePath)) {
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
}
|
|
} catch {
|
|
// ignore copy failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a private SSH home + options for the `et` client's internal ssh.
|
|
* Returns { userHost, sshOptions, env, artifacts }. comma-free option
|
|
* values go in `sshOptions` (passed via --ssh-option); options that need
|
|
* commas/spaces are written to a config file under HOME/.ssh/config.
|
|
*/
|
|
function prepareEtSshEnvironment(sessionId, options) {
|
|
const jumpHosts = Array.isArray(options.jumpHosts) ? options.jumpHosts : [];
|
|
if (jumpHosts.length > 1) {
|
|
throw new Error("EternalTerminal currently supports at most one jump host in Netcatty.");
|
|
}
|
|
|
|
const tempDir = tempDirBridge.getTempFilePath(`et-ssh-home-${sessionId}`);
|
|
const sshDir = path.join(tempDir, ".ssh");
|
|
fs.mkdirSync(sshDir, { recursive: true });
|
|
|
|
const safeId = String(sessionId || "session").replace(/[^\w.-]/g, "_");
|
|
// sshOptions: comma-free values safe for --ssh-option (ET may split on commas)
|
|
const sshOptions = [];
|
|
// configLines: options that need commas or spaces, written to config file
|
|
const configLines = [];
|
|
const askpassEntries = [];
|
|
|
|
// Copy known_hosts from real ~/.ssh so already-trusted hosts verify
|
|
// silently. Always point ssh at the persistent user file so
|
|
// StrictHostKeyChecking=accept-new records a first-seen key for later
|
|
// mismatch detection instead of trusting it again on every ET session.
|
|
const realSshDir = path.join(os.homedir(), ".ssh");
|
|
fs.mkdirSync(realSshDir, { recursive: true });
|
|
const knownHostsPath = path.join(realSshDir, "known_hosts");
|
|
sshOptions.push(`UserKnownHostsFile=${normalizeSshConfigPath(knownHostsPath)}`);
|
|
|
|
// et drives ssh itself and feeds credentials through SSH_ASKPASS, which
|
|
// only answers password/passphrase prompts — never the interactive
|
|
// host-key "yes/no" confirmation. Without this, a first-time host makes
|
|
// et's internal ssh stall on that unanswerable prompt while its
|
|
// handshake text leaks to the PTY, and the renderer flips the tab to
|
|
// "connected" on that first byte (terminalSessionAttachment.ts) even
|
|
// though no shell exists yet. accept-new trusts a brand-new host
|
|
// automatically but still rejects a *changed* key (MITM protection);
|
|
// LogLevel=ERROR silences the "Permanently added..." notice and other
|
|
// ssh banners so the first real PTY bytes are the remote shell. Mirrors
|
|
// the options already used by execOnEtSession.
|
|
sshOptions.push("StrictHostKeyChecking=accept-new");
|
|
sshOptions.push("LogLevel=ERROR");
|
|
|
|
// Port
|
|
if (options.port && options.port !== 22) {
|
|
sshOptions.push(`Port=${options.port}`);
|
|
}
|
|
|
|
// Private key
|
|
const identityPaths = [];
|
|
let tempKeyPath = null;
|
|
if (options.privateKey) {
|
|
tempKeyPath = path.join(sshDir, `${safeId}-key`);
|
|
writeSecureFile(tempKeyPath, options.privateKey, 0o600);
|
|
identityPaths.push(tempKeyPath);
|
|
if (options.passphrase) {
|
|
const passphrasePath = path.join(sshDir, `${safeId}-passphrase.txt`);
|
|
writeSecureFile(passphrasePath, `${options.passphrase}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "passphrase", createPassphrasePromptMatchers(tempKeyPath), passphrasePath);
|
|
}
|
|
}
|
|
|
|
// Certificate
|
|
if (options.certificate) {
|
|
const certPath = path.join(sshDir, `${safeId}-cert.pub`);
|
|
writeSecureFile(certPath, options.certificate, 0o600);
|
|
sshOptions.push(`CertificateFile=${normalizeSshConfigPath(certPath)}`);
|
|
}
|
|
|
|
// Additional identity file paths from host config
|
|
if (Array.isArray(options.identityFilePaths)) {
|
|
for (const idPath of options.identityFilePaths) {
|
|
if (idPath) identityPaths.push(idPath);
|
|
}
|
|
}
|
|
|
|
for (const idPath of identityPaths) {
|
|
sshOptions.push(`IdentityFile=${normalizeSshConfigPath(idPath)}`);
|
|
}
|
|
|
|
if (identityPaths.length > 0 || options.authMethod === "key" || options.authMethod === "certificate") {
|
|
sshOptions.push("IdentitiesOnly=yes");
|
|
}
|
|
|
|
// Password
|
|
const hasPassword = typeof options.password === "string" && options.password.length > 0;
|
|
if (hasPassword) {
|
|
const passwordPath = path.join(sshDir, `${safeId}-password.txt`);
|
|
writeSecureFile(passwordPath, `${options.password}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "password", createPasswordPromptMatchers({
|
|
hostname: options.hostname,
|
|
username: options.username,
|
|
port: options.port,
|
|
}), passwordPath);
|
|
}
|
|
|
|
// Auth method preferences
|
|
// NOTE: values with commas (e.g. "password,keyboard-interactive") MUST go into
|
|
// the config file — ET on Windows passes --ssh-option values through cmd.exe
|
|
// which treats commas as argument delimiters.
|
|
if (options.authMethod === "password") {
|
|
sshOptions.push("PubkeyAuthentication=no");
|
|
configLines.push("PreferredAuthentications password,keyboard-interactive");
|
|
} else if (identityPaths.length > 0 && hasPassword) {
|
|
configLines.push("PreferredAuthentications publickey,password,keyboard-interactive");
|
|
} else if (identityPaths.length > 0) {
|
|
sshOptions.push("PreferredAuthentications=publickey");
|
|
} else if (hasPassword) {
|
|
configLines.push("PreferredAuthentications password,keyboard-interactive");
|
|
}
|
|
|
|
sshOptions.push("KbdInteractiveAuthentication=yes");
|
|
sshOptions.push("NumberOfPasswordPrompts=1");
|
|
|
|
// Legacy algorithms (all values contain commas → config file only)
|
|
if (options.legacyAlgorithms) {
|
|
configLines.push("KexAlgorithms +diffie-hellman-group14-sha1,diffie-hellman-group1-sha1");
|
|
configLines.push("Ciphers +aes128-cbc,aes256-cbc,3des-cbc");
|
|
configLines.push("HostKeyAlgorithms +ssh-rsa,ssh-dss");
|
|
configLines.push("PubkeyAcceptedAlgorithms +ssh-rsa,ssh-dss");
|
|
}
|
|
|
|
// Jump host — route through ET's own --jumphost/--jport so the ET TCP
|
|
// socket connects to the jumphost's etserver and the destination is
|
|
// reached over the SSH tunnel ET sets up with `ssh -J jumphost dest`.
|
|
// (A bare ssh ProxyCommand only fixes the SSH bootstrap; ET would still
|
|
// open its socket straight at the unreachable destination etserver.)
|
|
//
|
|
// ET passes the destination's --ssh-option values via `ssh -o`, which
|
|
// OpenSSH applies to the final hop only. The jump hop is configured by
|
|
// OpenSSH from ssh_config, so the jump's per-hop credentials/settings go
|
|
// into a `Host <jumphost>` block in the config file. To keep the
|
|
// destination's auth from leaking onto the jump hop, scope the
|
|
// destination's comma/space config lines under a `Host <dest>` block too
|
|
// whenever a jump host is present.
|
|
let etJumpArgs = [];
|
|
const jumpConfigLines = [];
|
|
if (jumpHosts[0]) {
|
|
const jump = jumpHosts[0];
|
|
const jumpUser = jump.username || os.userInfo().username;
|
|
const jumpHost = jump.hostname;
|
|
const jumpPort = jump.port || 22;
|
|
// ET server port on the jumphost. ET's own default is 2022; honor an
|
|
// explicit override if the jump host model ever carries one.
|
|
const jumpEtPort = jump.etPort || 2022;
|
|
|
|
// Tell ET to tunnel through the jumphost. ET opens its ET socket to
|
|
// <jumphost>:<jport> and adds `ssh -J <jumpUser@jumpHost>` for the
|
|
// bootstrap; we feed the destination via the positional host as usual.
|
|
etJumpArgs = ["--jumphost", `${jumpUser}@${jumpHost}`, "--jport", String(jumpEtPort)];
|
|
|
|
// Per-hop jump settings live in a `Host <jumpHost>` block so they apply
|
|
// to the ProxyJump connection only (not the destination).
|
|
jumpConfigLines.push(`Host ${jumpHost}`);
|
|
jumpConfigLines.push(` HostName ${jumpHost}`);
|
|
jumpConfigLines.push(` User ${jumpUser}`);
|
|
jumpConfigLines.push(` Port ${jumpPort}`);
|
|
|
|
// Jump host key
|
|
if (jump.privateKey) {
|
|
const jumpKeyPath = path.join(sshDir, `${safeId}-jump-key`);
|
|
writeSecureFile(jumpKeyPath, jump.privateKey, 0o600);
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(jumpKeyPath)}`);
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
if (jump.passphrase) {
|
|
const jumpPassPath = path.join(sshDir, `${safeId}-jump-passphrase.txt`);
|
|
writeSecureFile(jumpPassPath, `${jump.passphrase}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "passphrase", createPassphrasePromptMatchers(jumpKeyPath), jumpPassPath);
|
|
}
|
|
} else if (Array.isArray(jump.identityFilePaths)) {
|
|
const jumpIdentityPaths = jump.identityFilePaths.filter(Boolean);
|
|
for (const idPath of jumpIdentityPaths) {
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(idPath)}`);
|
|
}
|
|
if (jumpIdentityPaths.length > 0) {
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
}
|
|
}
|
|
|
|
// Jump host certificate
|
|
if (jump.certificate) {
|
|
const jumpCertPath = path.join(sshDir, `${safeId}-jump-cert.pub`);
|
|
writeSecureFile(jumpCertPath, jump.certificate, 0o600);
|
|
jumpConfigLines.push(` CertificateFile ${quoteSshConfigValue(jumpCertPath)}`);
|
|
}
|
|
|
|
// Jump host password
|
|
if (jump.password) {
|
|
const jumpPwPath = path.join(sshDir, `${safeId}-jump-password.txt`);
|
|
writeSecureFile(jumpPwPath, `${jump.password}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "password", createPasswordPromptMatchers({
|
|
hostname: jumpHost,
|
|
username: jumpUser,
|
|
port: jumpPort,
|
|
}), jumpPwPath);
|
|
}
|
|
|
|
// Share known_hosts with the jump connection and apply the same
|
|
// non-interactive host-key handling as the target hop — the jump's
|
|
// ssh is just as unable to answer a yes/no prompt via SSH_ASKPASS.
|
|
jumpConfigLines.push(` UserKnownHostsFile ${quoteSshConfigValue(knownHostsPath)}`);
|
|
jumpConfigLines.push(" StrictHostKeyChecking accept-new");
|
|
jumpConfigLines.push(" LogLevel ERROR");
|
|
jumpConfigLines.push(" KbdInteractiveAuthentication yes");
|
|
jumpConfigLines.push(" NumberOfPasswordPrompts 1");
|
|
|
|
if (options.legacyAlgorithms) {
|
|
jumpConfigLines.push(" KexAlgorithms +diffie-hellman-group14-sha1,diffie-hellman-group1-sha1");
|
|
jumpConfigLines.push(" Ciphers +aes128-cbc,aes256-cbc,3des-cbc");
|
|
jumpConfigLines.push(" HostKeyAlgorithms +ssh-rsa,ssh-dss");
|
|
jumpConfigLines.push(" PubkeyAcceptedAlgorithms +ssh-rsa,ssh-dss");
|
|
}
|
|
}
|
|
|
|
// Write config file. When a jump host is present, scope the destination's
|
|
// comma/space options under `Host <dest>` so they don't bleed onto the
|
|
// jump hop, add `ProxyJump <jumpHost>` there so the standalone `ssh`
|
|
// used by execOnEtSession also tunnels through the jump (ET's own
|
|
// command-line -J overrides this for the interactive session, resolving
|
|
// to the same single hop), then append the `Host <jumpHost>` block.
|
|
const configFileLines = [];
|
|
if (jumpConfigLines.length > 0) {
|
|
const jump = jumpHosts[0];
|
|
configFileLines.push(`Host ${options.hostname}`);
|
|
configFileLines.push(` ProxyJump ${jump.hostname}`);
|
|
for (const line of configLines) {
|
|
configFileLines.push(` ${line}`);
|
|
}
|
|
configFileLines.push(...jumpConfigLines);
|
|
} else {
|
|
configFileLines.push(...configLines);
|
|
}
|
|
|
|
const writesConfigFile = configFileLines.length > 0;
|
|
if (writesConfigFile) {
|
|
const configPath = path.join(sshDir, "config");
|
|
writeSecureFile(configPath, configFileLines.join("\n") + "\n", 0o600);
|
|
}
|
|
|
|
// Create askpass artifacts
|
|
const askpass = createEtAskpassArtifacts(sshDir, askpassEntries);
|
|
|
|
const userHost = `${options.username || os.userInfo().username}@${options.hostname}`;
|
|
|
|
return {
|
|
userHost,
|
|
sshOptions,
|
|
etJumpArgs,
|
|
env: {
|
|
// Set HOME/USERPROFILE so ssh finds .ssh/config for comma-containing options
|
|
...(writesConfigFile ? { HOME: tempDir, USERPROFILE: tempDir } : {}),
|
|
...askpass.env,
|
|
},
|
|
artifacts: [tempDir, ...askpass.artifacts],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove leftover et-ssh-home-* temp directories from previous sessions
|
|
* that were not cleaned up (e.g. due to a crash).
|
|
*/
|
|
function cleanupStaleEtTempDirs() {
|
|
try {
|
|
const tempDir = tempDirBridge.getTempDir?.() || path.join(os.tmpdir(), "Netcatty");
|
|
if (!fs.existsSync(tempDir)) return;
|
|
const entries = fs.readdirSync(tempDir);
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith("et-ssh-home-")) continue;
|
|
try {
|
|
fs.rmSync(path.join(tempDir, entry), { recursive: true, force: true });
|
|
} catch {
|
|
// ignore per-entry cleanup failures
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore — best-effort cleanup
|
|
}
|
|
}
|
|
|
|
function cleanupSessionExternalAuthArtifacts(session) {
|
|
if (!session || session.externalAuthArtifactsCleaned) return;
|
|
session.externalAuthArtifactsCleaned = true;
|
|
const artifacts = Array.isArray(session.externalAuthArtifacts)
|
|
? session.externalAuthArtifacts
|
|
: [];
|
|
|
|
for (const artifactPath of artifacts) {
|
|
try {
|
|
fs.rmSync(artifactPath, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup failures
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepend an optional bundled DLL directory (dynamically-linked Windows
|
|
* builds only) to PATH so the spawned et.exe can find its runtime DLLs.
|
|
* Static MSVC builds ship no DLLs and this is a no-op.
|
|
*/
|
|
function addBundledEtDllPath(env, etClient, opts = {}) {
|
|
const platform = opts.platform || process.platform;
|
|
if (platform !== "win32" || !etClient) return env;
|
|
const clientDir = path.dirname(etClient);
|
|
const arch = opts.arch || process.arch;
|
|
const dllDir = path.join(clientDir, `et-win32-${arch}-dlls`);
|
|
if (fs.existsSync(dllDir) && fs.statSync(dllDir).isDirectory()) {
|
|
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") || "PATH";
|
|
const current = env[pathKey] || "";
|
|
env[pathKey] = current ? `${dllDir};${current}` : dllDir;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function formatVaultKnownHostLine(knownHost) {
|
|
if (!knownHost?.hostname || !knownHost?.keyType) return null;
|
|
const port = Number.isFinite(knownHost.port) ? Number(knownHost.port) : 22;
|
|
const hostField = port !== 22 ? `[${knownHost.hostname}]:${port}` : knownHost.hostname;
|
|
const pubKey = String(knownHost.publicKey || "").trim();
|
|
const parts = pubKey.split(/\s+/);
|
|
let keyType = knownHost.keyType;
|
|
let keyBlob = "";
|
|
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
|
|
keyType = parts[0];
|
|
keyBlob = parts[1];
|
|
} else if (parts.length === 1 && parts[0].length > 0 && !/^SHA256:/i.test(parts[0])) {
|
|
keyBlob = parts[0];
|
|
} else {
|
|
return null;
|
|
}
|
|
if (!keyBlob) return null;
|
|
return `${hostField} ${keyType} ${keyBlob}`;
|
|
}
|
|
|
|
/**
|
|
* Build a known_hosts file for background ET exec (stats / distro probes).
|
|
* Merges the user's system known_hosts with any Netcatty-vault entries that
|
|
* carry a full public key blob, then pins StrictHostKeyChecking=yes on exec
|
|
* so accept-new cannot auto-trust a host in a non-interactive flow.
|
|
*/
|
|
function ensureStrictExecKnownHostsFile(session, knownHosts) {
|
|
if (session.etStrictExecKnownHostsPath) {
|
|
return session.etStrictExecKnownHostsPath;
|
|
}
|
|
|
|
const { readSystemKnownHostsContent } = createSystemKnownHostsApi({
|
|
fs, path, os, crypto, log: console,
|
|
});
|
|
const chunks = [];
|
|
|
|
try {
|
|
const systemContent = readSystemKnownHostsContent();
|
|
if (systemContent) chunks.push(systemContent);
|
|
} catch {
|
|
// ignore read failures — strict checking fails closed below
|
|
}
|
|
|
|
const configuredKnownHosts = (session.sshOptions || []).find(
|
|
(opt) => opt.startsWith("UserKnownHostsFile="),
|
|
);
|
|
if (configuredKnownHosts) {
|
|
const configuredPath = configuredKnownHosts.slice("UserKnownHostsFile=".length);
|
|
try {
|
|
const configuredContent = fs.readFileSync(configuredPath, "utf8");
|
|
if (configuredContent) chunks.push(configuredContent);
|
|
} catch {
|
|
// ignore missing configured file
|
|
}
|
|
}
|
|
|
|
const vaultLines = [];
|
|
if (Array.isArray(knownHosts)) {
|
|
for (const knownHost of knownHosts) {
|
|
const line = formatVaultKnownHostLine(knownHost);
|
|
if (line) vaultLines.push(line);
|
|
}
|
|
}
|
|
if (vaultLines.length > 0) {
|
|
chunks.push(vaultLines.join("\n"));
|
|
}
|
|
|
|
const artifact = Array.isArray(session.externalAuthArtifacts)
|
|
? session.externalAuthArtifacts[0]
|
|
: null;
|
|
const sshDir = artifact ? path.dirname(artifact) : tempDirBridge.getTempDir();
|
|
const strictKhPath = path.join(sshDir, "netcatty-et-strict-known_hosts");
|
|
writeSecureFile(strictKhPath, chunks.filter(Boolean).join("\n") + (chunks.length ? "\n" : ""), 0o600);
|
|
session.etStrictExecKnownHostsPath = strictKhPath;
|
|
if (Array.isArray(session.externalAuthArtifacts)) {
|
|
session.externalAuthArtifacts.push(strictKhPath);
|
|
}
|
|
return strictKhPath;
|
|
}
|
|
|
|
/**
|
|
* Execute a remote command on an ET session by spawning a system ssh
|
|
* process. Reuses the SSH environment (keys, config, askpass) already
|
|
* prepared by prepareEtSshEnvironment() for the ET connection.
|
|
*
|
|
* @param {object} [execOpts]
|
|
* @param {boolean} [execOpts.requireTrustedHost] When true, refuse unknown
|
|
* host keys (StrictHostKeyChecking=yes) using system + vault known_hosts
|
|
* instead of accept-new. Used for background stats/distro probes.
|
|
* @param {Array} [execOpts.knownHosts] Netcatty vault known hosts to merge
|
|
* into the strict known_hosts file (defaults to session.etStatsAuth).
|
|
*/
|
|
function execOnEtSession(session, command, timeoutMs = 5000, execOpts = {}) {
|
|
if (!session?.sshUserHost || session.externalAuthArtifactsCleaned) {
|
|
return Promise.resolve({ success: false, error: "ET SSH environment not available" });
|
|
}
|
|
|
|
const requireTrustedHost = execOpts.requireTrustedHost === true;
|
|
const knownHosts = execOpts.knownHosts ?? session.etStatsAuth?.knownHosts;
|
|
|
|
const sshCmd = process.platform === "win32" ? findExecutable("ssh") : "ssh";
|
|
const args = ["-o", "BatchMode=no"];
|
|
if (!requireTrustedHost) {
|
|
args.push("-o", "StrictHostKeyChecking=accept-new");
|
|
}
|
|
for (const opt of session.sshOptions) {
|
|
if (requireTrustedHost && opt.startsWith("StrictHostKeyChecking=")) continue;
|
|
if (requireTrustedHost && opt.startsWith("UserKnownHostsFile=")) continue;
|
|
args.push("-o", opt);
|
|
}
|
|
if (requireTrustedHost) {
|
|
const strictKhPath = ensureStrictExecKnownHostsFile(session, knownHosts);
|
|
args.push("-o", `UserKnownHostsFile=${normalizeSshConfigPath(strictKhPath)}`);
|
|
args.push("-o", "StrictHostKeyChecking=yes");
|
|
}
|
|
args.push(session.sshUserHost, command);
|
|
|
|
return new Promise((resolve) => {
|
|
const child = execFile(sshCmd, args, {
|
|
env: { ...process.env, ...session.sshEnv },
|
|
timeout: timeoutMs,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
}, (err, stdout, stderr) => {
|
|
if (err) {
|
|
resolve({
|
|
success: false,
|
|
error: err.message,
|
|
stdout: stdout || "",
|
|
stderr: stderr || "",
|
|
code: typeof err.code === "number" && err.code !== 0 ? err.code : 1,
|
|
});
|
|
} else {
|
|
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
|
|
}
|
|
});
|
|
if (typeof execOpts.stdin === "string") {
|
|
child.stdin?.end(execOpts.stdin);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start an EternalTerminal session using Netcatty's bundled `et` client.
|
|
*/
|
|
async function startEtSession(event, options) {
|
|
const sessionId =
|
|
options.sessionId ||
|
|
`et-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
|
|
const cols = options.cols || 80;
|
|
const rows = options.rows || 24;
|
|
|
|
const etCmd = resolveBareEtClient({});
|
|
if (!etCmd) {
|
|
throw new Error(
|
|
"Bundled et client not found. Run `npm run fetch:et:dev` for local dev, " +
|
|
"or ensure release packaging downloads the et binary release before building.",
|
|
);
|
|
}
|
|
|
|
const args = [];
|
|
|
|
// ET server port (default 2022)
|
|
if (options.etPort && options.etPort !== 2022) {
|
|
args.push("-p", String(options.etPort));
|
|
}
|
|
|
|
// SSH Agent forwarding (ET supports -f natively)
|
|
if (options.agentForwarding) {
|
|
args.push("-f");
|
|
}
|
|
|
|
let sshEnvironment;
|
|
try {
|
|
sshEnvironment = prepareEtSshEnvironment(sessionId, options);
|
|
} catch (err) {
|
|
throw new Error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
|
|
// Pass all SSH options inline via --ssh-option (bypasses config file lookup)
|
|
for (const opt of sshEnvironment.sshOptions) {
|
|
args.push("--ssh-option", opt);
|
|
}
|
|
|
|
// Route through a jump host via ET's own --jumphost/--jport when set, so
|
|
// ET's TCP socket targets the jumphost and the destination is reached
|
|
// over the SSH tunnel rather than a direct (often unreachable) etserver.
|
|
if (Array.isArray(sshEnvironment.etJumpArgs) && sshEnvironment.etJumpArgs.length > 0) {
|
|
args.push(...sshEnvironment.etJumpArgs);
|
|
}
|
|
|
|
args.push(sshEnvironment.userHost);
|
|
|
|
const env = {
|
|
...process.env,
|
|
...(options.env || {}),
|
|
...(sshEnvironment?.env || {}),
|
|
TERM: "xterm-256color",
|
|
// et prints a 3-line telemetry notice to stdout on first run. The
|
|
// "first run" flag is tracked per-HOME, and prepareEtSshEnvironment
|
|
// gives each session a fresh private temp HOME, so et treats every
|
|
// connection as first-run and prints it every time. That banner is
|
|
// pre-connection output that both pollutes the terminal and trips the
|
|
// renderer's "first PTY byte = connected" check
|
|
// (terminalSessionAttachment.ts). Opt out unconditionally — this also
|
|
// suppresses the notice and disables anonymous error reporting.
|
|
ET_NO_TELEMETRY: "1",
|
|
};
|
|
|
|
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
|
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
|
}
|
|
|
|
addBundledEtDllPath(env, etCmd);
|
|
|
|
try {
|
|
const proc = pty.spawn(etCmd, args, {
|
|
cols,
|
|
rows,
|
|
env,
|
|
cwd: os.homedir(),
|
|
encoding: null, // Return Buffer for ZMODEM binary support
|
|
});
|
|
|
|
const session = {
|
|
proc,
|
|
pty: proc,
|
|
type: "et",
|
|
protocol: "et",
|
|
webContentsId: event.sender.id,
|
|
hostname: options.hostname || "",
|
|
username: options.username || "",
|
|
label: options.label || options.hostname || "ET Session",
|
|
shellKind: "posix",
|
|
shellExecutable: "remote-shell",
|
|
externalAuthArtifacts: sshEnvironment?.artifacts || [],
|
|
externalAuthArtifactsCleaned: false,
|
|
// SSH environment for remote command execution (stats, distro detection)
|
|
sshEnv: sshEnvironment?.env || {},
|
|
sshOptions: sshEnvironment?.sshOptions || [],
|
|
sshUserHost: sshEnvironment?.userHost || "",
|
|
etStatsAuth: {
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
username: options.username,
|
|
password: options.password,
|
|
privateKey: options.privateKey,
|
|
passphrase: options.passphrase,
|
|
certificate: options.certificate,
|
|
keyId: options.keyId,
|
|
identityFilePaths: options.identityFilePaths,
|
|
legacyAlgorithms: options.legacyAlgorithms,
|
|
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
|
algorithmOverrides: options.algorithmOverrides,
|
|
knownHosts: options.knownHosts,
|
|
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
|
|
},
|
|
systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
|
|
? options.sudoAutofillPassword
|
|
: undefined,
|
|
flushPendingData: null,
|
|
lastIdlePrompt: "",
|
|
lastIdlePromptAt: 0,
|
|
_promptTrackTail: "",
|
|
};
|
|
sessions.set(sessionId, session);
|
|
|
|
// Start real-time session log stream if configured
|
|
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
|
sessionLogStreamManager.startStream(sessionId, {
|
|
hostLabel: options.label || options.hostname,
|
|
hostname: options.hostname,
|
|
directory: options.sessionLog.directory,
|
|
format: options.sessionLog.format || "txt",
|
|
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
|
|
startTime: Date.now(),
|
|
});
|
|
}
|
|
|
|
const { bufferData: bufferEtData, flush: flushEt } = createPtyOutputBuffer((data) => {
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
|
contents?.send("netcatty:data", { sessionId, data });
|
|
});
|
|
session.flushPendingData = flushEt;
|
|
|
|
if (process.platform !== "win32") {
|
|
const etDecoder = new StringDecoder("utf8");
|
|
const etZmodemSentry = createZmodemSentry({
|
|
sessionId,
|
|
onData(buf) {
|
|
const str = etDecoder.write(buf);
|
|
if (!str) return;
|
|
trackSessionIdlePrompt(session, str);
|
|
bufferEtData(str);
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
|
},
|
|
writeToRemote(buf) {
|
|
try { return proc.write(buf); } catch { return true; }
|
|
},
|
|
getWebContents() {
|
|
return electronModule.webContents.fromId(session.webContentsId);
|
|
},
|
|
label: "ET",
|
|
});
|
|
session.zmodemSentry = etZmodemSentry;
|
|
|
|
proc.onData((data) => {
|
|
etZmodemSentry.consume(data);
|
|
});
|
|
} else {
|
|
proc.onData((data) => {
|
|
trackSessionIdlePrompt(session, data);
|
|
bufferEtData(data);
|
|
sessionLogStreamManager.appendData(sessionId, data);
|
|
});
|
|
}
|
|
|
|
proc.onExit((evt) => {
|
|
flushEt();
|
|
try { session.etStatsConn?.end(); } catch { /* ignore */ }
|
|
cleanupSessionExternalAuthArtifacts(session);
|
|
sessionLogStreamManager.stopStream(sessionId);
|
|
sessions.delete(sessionId);
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
|
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
|
|
});
|
|
|
|
return { sessionId };
|
|
} catch (err) {
|
|
if (sshEnvironment?.artifacts) {
|
|
cleanupSessionExternalAuthArtifacts({
|
|
externalAuthArtifacts: sshEnvironment.artifacts,
|
|
externalAuthArtifactsCleaned: false,
|
|
});
|
|
}
|
|
console.error("[ET] Failed to start EternalTerminal session:", err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
resolveBareEtClient,
|
|
prepareEtSshEnvironment,
|
|
cleanupStaleEtTempDirs,
|
|
cleanupSessionExternalAuthArtifacts,
|
|
addBundledEtDllPath,
|
|
execOnEtSession,
|
|
startEtSession,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createEtSessionApi };
|