fix: support Windows SSH remote instances
This commit is contained in:
@@ -132,6 +132,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
- Desktop host switcher and deep-link imports.
|
||||
- Local and remote instance handling.
|
||||
- SSH host import, connections, logs, and port forwarding.
|
||||
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
||||
- Tunnel lifecycle integration through the web server runtime.
|
||||
- Auto-update checks, downloads, and restart/apply flow.
|
||||
|
||||
|
||||
+285
-128
@@ -17,7 +17,9 @@ const MONITOR_INITIAL_POLL_MS = 2000;
|
||||
const MONITOR_STEADY_POLL_MS = 10000;
|
||||
const MONITOR_STABILIZE_TICKS = 5;
|
||||
const SSH_STATUS_EVENT = 'openchamber:ssh-instance-status';
|
||||
const WINDOWS_HIDDEN_SPAWN_OPTIONS = process.platform === 'win32' ? { windowsHide: true } : {};
|
||||
const MAX_PROCESS_ERROR_CHARS = 2000;
|
||||
const MAX_PROCESS_ERROR_CAPTURE_CHARS = MAX_PROCESS_ERROR_CHARS * 2;
|
||||
const childProcessDiagnostics = new WeakMap();
|
||||
|
||||
const nowMillis = () => Date.now();
|
||||
|
||||
@@ -218,71 +220,12 @@ const parseSshCommand = (raw) => {
|
||||
return { destination, args };
|
||||
};
|
||||
|
||||
const runOutput = async (command, args, options = {}) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
...options,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const buildSshArgs = (parsed, preDestinationArgs = [], remoteCommand = null) => {
|
||||
const args = [...parsed.args, ...preDestinationArgs, parsed.destination];
|
||||
if (remoteCommand) args.push(remoteCommand);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runRemoteCommand = async (parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) => {
|
||||
const args = buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', `ConnectTimeout=${timeoutSec}`,
|
||||
'-T',
|
||||
], `sh -lc ${shellQuote(script)}`);
|
||||
const { code, stdout, stderr } = await runOutput('ssh', args);
|
||||
if (code !== 0) {
|
||||
throw new Error((stderr || stdout || 'Remote command failed').trim());
|
||||
}
|
||||
return stdout;
|
||||
};
|
||||
|
||||
const controlMasterOperation = async (parsed, controlPath, op) => {
|
||||
return await runOutput('ssh', buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=3',
|
||||
'-O', op,
|
||||
]));
|
||||
};
|
||||
|
||||
const isControlMasterAlive = async (parsed, controlPath) => {
|
||||
const { code } = await controlMasterOperation(parsed, controlPath, 'check');
|
||||
return code === 0;
|
||||
};
|
||||
|
||||
const stopControlMasterBestEffort = async (parsed, controlPath) => {
|
||||
try {
|
||||
await controlMasterOperation(parsed, controlPath, 'exit');
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const askpassScriptContent = () => `#!/bin/bash
|
||||
PROMPT="$1"
|
||||
|
||||
@@ -331,6 +274,27 @@ const writeAskpassScript = async (scriptPath) => {
|
||||
await fsp.chmod(scriptPath, 0o700);
|
||||
};
|
||||
|
||||
const windowsAskpassScriptContent = () => `$value = [Environment]::GetEnvironmentVariable('OPENCHAMBER_SSH_ASKPASS_VALUE')
|
||||
if ($null -ne $value) {
|
||||
[Console]::Out.WriteLine($value)
|
||||
}
|
||||
`;
|
||||
|
||||
const windowsAskpassWrapperContent = () => `@echo off\r
|
||||
"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0askpass.ps1"\r
|
||||
`;
|
||||
|
||||
const sanitizeProcessDiagnostic = (value, secret = '') => {
|
||||
let sanitized = String(value || '')
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
||||
.trim();
|
||||
if (secret) sanitized = sanitized.split(secret).join('[redacted]');
|
||||
if (sanitized.length > MAX_PROCESS_ERROR_CHARS) {
|
||||
sanitized = `${sanitized.slice(0, MAX_PROCESS_ERROR_CHARS - 3)}...`;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const randomPortCandidate = (seed) => {
|
||||
let hash = 0;
|
||||
const source = `${seed}:${Date.now()}`;
|
||||
@@ -420,6 +384,8 @@ export class ElectronSshManager {
|
||||
this.settingsFilePath = options.settingsFilePath;
|
||||
this.appVersion = options.appVersion;
|
||||
this.emit = options.emit;
|
||||
this.platform = options.platform || process.platform;
|
||||
this.spawnProcess = options.spawn || spawn;
|
||||
this.logs = new Map();
|
||||
this.statuses = new Map();
|
||||
this.sessions = new Map();
|
||||
@@ -427,6 +393,162 @@ export class ElectronSshManager {
|
||||
this.reconnectAttempts = new Map();
|
||||
this.connectAttempts = new Map();
|
||||
this.connecting = new Map();
|
||||
this.sshAuth = new WeakMap();
|
||||
}
|
||||
|
||||
usesControlMaster() {
|
||||
return this.platform !== 'win32';
|
||||
}
|
||||
|
||||
hiddenSpawnOptions() {
|
||||
return this.platform === 'win32' ? { windowsHide: true } : {};
|
||||
}
|
||||
|
||||
authEnvironment(parsed) {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
if (!auth) return process.env;
|
||||
return {
|
||||
...process.env,
|
||||
SSH_ASKPASS_REQUIRE: 'force',
|
||||
SSH_ASKPASS: auth.askpassPath,
|
||||
DISPLAY: '1',
|
||||
...(auth.sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: auth.sshPassword.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
independentConnectionArgs() {
|
||||
return [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', 'ControlPath=none',
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
];
|
||||
}
|
||||
|
||||
trackSshProcess(child, parsed) {
|
||||
const diagnostics = { stderr: '', error: null, parsed };
|
||||
childProcessDiagnostics.set(child, diagnostics);
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
auth?.children.add(child);
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
diagnostics.stderr = `${diagnostics.stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
diagnostics.error = error;
|
||||
});
|
||||
child.on('close', () => {
|
||||
auth?.children.delete(child);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
processErrorDetail(child, fallback) {
|
||||
const diagnostics = childProcessDiagnostics.get(child);
|
||||
const auth = diagnostics ? this.sshAuth.get(diagnostics.parsed) : null;
|
||||
const detail = sanitizeProcessDiagnostic(
|
||||
diagnostics?.error instanceof Error ? diagnostics.error.message : diagnostics?.stderr,
|
||||
auth?.sshPassword,
|
||||
);
|
||||
return detail || fallback;
|
||||
}
|
||||
|
||||
spawnSsh(parsed, preDestinationArgs, options, remoteCommand = null) {
|
||||
const child = this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
|
||||
...options,
|
||||
...this.hiddenSpawnOptions(),
|
||||
env: this.authEnvironment(parsed),
|
||||
});
|
||||
return this.trackSshProcess(child, parsed);
|
||||
}
|
||||
|
||||
async runSshOutput(parsed, preDestinationArgs, remoteCommand = null) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = this.trackSshProcess(this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...this.hiddenSpawnOptions(),
|
||||
env: this.authEnvironment(parsed),
|
||||
}), parsed);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
|
||||
});
|
||||
child.on('error', () => {
|
||||
reject(new Error(this.processErrorDetail(child, 'Failed to start SSH process')));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
resolve({
|
||||
code: typeof code === 'number' ? code : -1,
|
||||
stdout,
|
||||
stderr: sanitizeProcessDiagnostic(stderr, auth?.sshPassword),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runRemoteCommand(parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) {
|
||||
const connectionArgs = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
|
||||
: this.independentConnectionArgs();
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, [
|
||||
...connectionArgs,
|
||||
'-o', `ConnectTimeout=${timeoutSec}`,
|
||||
'-T',
|
||||
], `sh -lc ${shellQuote(script)}`);
|
||||
if (code !== 0) {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
throw new Error(sanitizeProcessDiagnostic(stderr || stdout, auth?.sshPassword) || 'Remote command failed');
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async controlMasterOperation(parsed, controlPath, op) {
|
||||
return await this.runSshOutput(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=3',
|
||||
'-O', op,
|
||||
]);
|
||||
}
|
||||
|
||||
async isControlMasterAlive(parsed, controlPath) {
|
||||
const { code } = await this.controlMasterOperation(parsed, controlPath, 'check');
|
||||
return code === 0;
|
||||
}
|
||||
|
||||
async stopControlMasterBestEffort(parsed, controlPath) {
|
||||
if (!this.usesControlMaster()) return;
|
||||
try {
|
||||
await this.controlMasterOperation(parsed, controlPath, 'exit');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async writeAskpassFiles(sessionDir) {
|
||||
if (this.platform === 'win32') {
|
||||
const scriptPath = path.join(sessionDir, 'askpass.ps1');
|
||||
const wrapperPath = path.join(sessionDir, 'askpass.cmd');
|
||||
try {
|
||||
await fsp.writeFile(scriptPath, windowsAskpassScriptContent());
|
||||
await fsp.writeFile(wrapperPath, windowsAskpassWrapperContent());
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
fsp.rm(scriptPath, { force: true }),
|
||||
fsp.rm(wrapperPath, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
return { askpassPath: wrapperPath, cleanupPaths: [wrapperPath, scriptPath] };
|
||||
}
|
||||
|
||||
const askpassPath = path.join(sessionDir, 'askpass.sh');
|
||||
await writeAskpassScript(askpassPath);
|
||||
return { askpassPath, cleanupPaths: [askpassPath] };
|
||||
}
|
||||
|
||||
appendLogWithLevel(id, level, message) {
|
||||
@@ -791,7 +913,7 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
async resolveSshConfig(parsed) {
|
||||
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, ['-G']));
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, ['-G']);
|
||||
if (code !== 0) {
|
||||
throw new Error(stderr.trim() || 'Failed to resolve SSH config');
|
||||
}
|
||||
@@ -820,41 +942,34 @@ export class ElectronSshManager {
|
||||
return path.join(os.tmpdir(), `ocssh-${Math.abs(hash).toString(16)}.sock`);
|
||||
}
|
||||
|
||||
async spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword) {
|
||||
const child = spawn('ssh', buildSshArgs(parsed, [
|
||||
async spawnMasterProcess(parsed, controlPath) {
|
||||
return this.spawnSsh(parsed, [
|
||||
'-o', 'ControlMaster=yes',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', `ControlPersist=${DEFAULT_CONTROL_PERSIST_SEC}`,
|
||||
'-N',
|
||||
]), {
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
env: {
|
||||
...process.env,
|
||||
SSH_ASKPASS_REQUIRE: 'force',
|
||||
SSH_ASKPASS: askpassPath,
|
||||
DISPLAY: '1',
|
||||
...(sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: sshPassword.trim() } : {}),
|
||||
},
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
async waitForMasterReady(parsed, controlPath, timeoutSec, master) {
|
||||
const deadline = Date.now() + (timeoutSec * 1000);
|
||||
let pollMs = 250;
|
||||
while (Date.now() < deadline) {
|
||||
const { code } = await runOutput('ssh', buildSshArgs(parsed, [
|
||||
const { code } = await this.runSshOutput(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-O', 'check',
|
||||
]));
|
||||
]);
|
||||
if (code === 0) return;
|
||||
|
||||
const exited = master.exitCode;
|
||||
if (typeof exited === 'number') {
|
||||
throw new Error('SSH master process exited before ready');
|
||||
throw new Error(this.processErrorDetail(master, 'SSH master process exited before ready'));
|
||||
}
|
||||
const spawnError = childProcessDiagnostics.get(master)?.error;
|
||||
if (spawnError) throw new Error(this.processErrorDetail(master, 'Failed to start SSH master process'));
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
pollMs = Math.min(pollMs * 2, 2000);
|
||||
}
|
||||
@@ -868,7 +983,7 @@ export class ElectronSshManager {
|
||||
|
||||
async remoteCommandExists(parsed, controlPath, commandName) {
|
||||
try {
|
||||
const output = await runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
|
||||
return output.trim() === 'yes';
|
||||
} catch {
|
||||
return false;
|
||||
@@ -877,7 +992,7 @@ export class ElectronSshManager {
|
||||
|
||||
async currentRemoteOpenChamberVersion(parsed, controlPath) {
|
||||
try {
|
||||
const output = await runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
|
||||
return parseVersionToken(output);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -907,7 +1022,7 @@ export class ElectronSshManager {
|
||||
let lastError = null;
|
||||
for (const command of commands) {
|
||||
try {
|
||||
await runRemoteCommand(parsed, controlPath, command);
|
||||
await this.runRemoteCommand(parsed, controlPath, command);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -920,7 +1035,7 @@ export class ElectronSshManager {
|
||||
const authPayload = openchamberPassword ? JSON.stringify({ password: openchamberPassword }) : '{}';
|
||||
const authEnabled = openchamberPassword ? '1' : '0';
|
||||
const script = `AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE="$(mktemp)"; COOKIE_FILE="$(mktemp)"; cleanup(){ rm -f "$BODY_FILE" "$COOKIE_FILE"; }; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ "${authEnabled}" = "1" ]; then AUTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' -c "$COOKIE_FILE" -H 'content-type: application/json' --data ${shellQuote(authPayload)} http://127.0.0.1:${port}/auth/session || true)"; if [ "$AUTH_STATUS" = "200" ]; then INFO_STATUS="$(curl -sS --max-time 3 -b "$COOKIE_FILE" -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; HEALTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/health || true)"; elif command -v wget >/dev/null 2>&1; then wget -qO "$BODY_FILE" http://127.0.0.1:${port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:${port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' "$INFO_STATUS" "$AUTH_STATUS" "$HEALTH_STATUS"; cat "$BODY_FILE" 2>/dev/null || true`;
|
||||
const output = await runRemoteCommand(parsed, controlPath, script);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, script);
|
||||
const lines = output.split(/\r?\n/);
|
||||
const infoStatus = parseProbeStatusLine(lines[0], 'INFO_STATUS=') || 0;
|
||||
const authStatus = parseProbeStatusLine(lines[1], 'AUTH_STATUS=') || 0;
|
||||
@@ -963,14 +1078,14 @@ export class ElectronSshManager {
|
||||
if (secret) {
|
||||
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
|
||||
}
|
||||
const output = await runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
|
||||
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
|
||||
return port || desiredPort;
|
||||
}
|
||||
|
||||
async stopRemoteServerBestEffort(parsed, controlPath, remotePort) {
|
||||
try {
|
||||
await runRemoteCommand(
|
||||
await this.runRemoteCommand(
|
||||
parsed,
|
||||
controlPath,
|
||||
`if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
|
||||
@@ -980,23 +1095,23 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
async spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort) {
|
||||
return spawn('ssh', buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
const connectionArgs = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
|
||||
: this.independentConnectionArgs();
|
||||
return this.spawnSsh(parsed, [
|
||||
...connectionArgs,
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-N',
|
||||
'-L', `${bindHost}:${localPort}:127.0.0.1:${remotePort}`,
|
||||
]), {
|
||||
], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
async spawnExtraForward(parsed, controlPath, forward) {
|
||||
const args = [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-O', 'forward',
|
||||
];
|
||||
const args = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-O', 'forward']
|
||||
: [...this.independentConnectionArgs(), '-o', 'ExitOnForwardFailure=yes', '-N'];
|
||||
if (forward.type === 'local') {
|
||||
args.push('-L', `${forward.localHost || '127.0.0.1'}:${forward.localPort}:${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}`);
|
||||
} else if (forward.type === 'remote') {
|
||||
@@ -1004,10 +1119,20 @@ export class ElectronSshManager {
|
||||
} else {
|
||||
args.push('-D', `${forward.localHost || '127.0.0.1'}:${forward.localPort}`);
|
||||
}
|
||||
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, args));
|
||||
if (!this.usesControlMaster()) {
|
||||
const child = this.spawnSsh(parsed, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error) {
|
||||
throw new Error(this.processErrorDetail(child, `Failed to configure extra SSH forward ${forward.id}`));
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, args);
|
||||
if (code !== 0) {
|
||||
throw new Error((stderr || stdout || `Failed to configure extra SSH forward ${forward.id}`).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async ensureRemoteServer(instance, parsed, controlPath) {
|
||||
@@ -1060,11 +1185,18 @@ export class ElectronSshManager {
|
||||
this.sessions.delete(id);
|
||||
|
||||
if (session) {
|
||||
if (session.startedByUs && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
|
||||
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
|
||||
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
|
||||
}
|
||||
await stopControlMasterBestEffort(session.parsed, session.controlPath);
|
||||
for (const child of [session.mainForward, session.master]) {
|
||||
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
|
||||
const auth = this.sshAuth.get(session.parsed);
|
||||
const children = new Set([
|
||||
session.mainForward,
|
||||
session.master,
|
||||
...session.extraForwards.map((entry) => entry.child),
|
||||
...(auth?.children || []),
|
||||
].filter(Boolean));
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
@@ -1074,10 +1206,13 @@ export class ElectronSshManager {
|
||||
await fsp.rm(session.controlPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await fsp.rm(path.join(session.sessionDir, 'askpass.sh'), { force: true });
|
||||
} catch {
|
||||
for (const askpassFilePath of session.askpassCleanupPaths) {
|
||||
try {
|
||||
await fsp.rm(askpassFilePath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.sshAuth.delete(session.parsed);
|
||||
}
|
||||
|
||||
this.clearRetryAttempt(id);
|
||||
@@ -1096,22 +1231,40 @@ export class ElectronSshManager {
|
||||
const sessionDir = this.ensureSessionDir(id);
|
||||
const controlPath = this.controlPathForInstance(id);
|
||||
try { await fsp.rm(controlPath, { force: true }); } catch {}
|
||||
const askpassPath = path.join(sessionDir, 'askpass.sh');
|
||||
await writeAskpassScript(askpassPath);
|
||||
const { askpassPath, cleanupPaths: askpassCleanupPaths } = await this.writeAskpassFiles(sessionDir);
|
||||
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value?.trim() : null;
|
||||
this.sshAuth.set(parsed, { askpassPath, sshPassword, children: new Set() });
|
||||
const session = {
|
||||
instance,
|
||||
parsed,
|
||||
sessionDir,
|
||||
controlPath,
|
||||
askpassCleanupPaths,
|
||||
localPort: null,
|
||||
remotePort: null,
|
||||
startedByUs: false,
|
||||
master: null,
|
||||
mainForward: null,
|
||||
mainForwardDetached: false,
|
||||
extraForwards: [],
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
this.setStatus(id, 'master_connecting', 'Establishing SSH ControlMaster');
|
||||
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value : null;
|
||||
const master = await this.spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword);
|
||||
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, master);
|
||||
this.setStatus(id, 'master_connecting', this.usesControlMaster() ? 'Establishing SSH ControlMaster' : 'Checking SSH connectivity');
|
||||
if (this.usesControlMaster()) {
|
||||
session.master = await this.spawnMasterProcess(parsed, controlPath);
|
||||
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, session.master);
|
||||
}
|
||||
|
||||
this.setStatus(id, 'remote_probe', 'Probing remote platform');
|
||||
const remoteOs = (await runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
|
||||
const remoteOs = (await this.runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
|
||||
if (!['linux', 'darwin'].includes(remoteOs)) {
|
||||
master.kill('SIGTERM');
|
||||
throw new Error(`Unsupported remote OS: ${remoteOs}`);
|
||||
}
|
||||
|
||||
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
|
||||
session.remotePort = remotePort;
|
||||
session.startedByUs = startedByUs;
|
||||
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
|
||||
|
||||
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
|
||||
@@ -1124,22 +1277,24 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
const mainForward = await this.spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort);
|
||||
session.mainForward = mainForward;
|
||||
let mainForwardDetached = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (typeof mainForward.exitCode === 'number') {
|
||||
if (mainForward.exitCode === 0) {
|
||||
if (typeof mainForward.exitCode === 'number' || childProcessDiagnostics.get(mainForward)?.error) {
|
||||
if (this.usesControlMaster() && mainForward.exitCode === 0) {
|
||||
mainForwardDetached = true;
|
||||
this.appendLogWithLevel(id, 'INFO', 'Main tunnel helper exited after ControlMaster handoff');
|
||||
} else {
|
||||
master.kill('SIGTERM');
|
||||
throw new Error(`Failed to start main port forward (status: ${mainForward.exitCode})`);
|
||||
throw new Error(this.processErrorDetail(mainForward, `Failed to start main port forward (status: ${mainForward.exitCode ?? 'spawn error'})`));
|
||||
}
|
||||
}
|
||||
session.mainForwardDetached = mainForwardDetached;
|
||||
|
||||
const extraErrors = [];
|
||||
for (const forward of instance.portForwards.filter((item) => item.enabled)) {
|
||||
try {
|
||||
await this.spawnExtraForward(parsed, controlPath, forward);
|
||||
const extraForward = await this.spawnExtraForward(parsed, controlPath, forward);
|
||||
if (extraForward) session.extraForwards.push({ id: forward.id, child: extraForward });
|
||||
if (forward.type === 'local' && forward.localPort) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (!(await isLocalTunnelReachable(forward.localPort))) {
|
||||
@@ -1161,19 +1316,7 @@ export class ElectronSshManager {
|
||||
await this.persistLocalPort(id, localPort);
|
||||
}
|
||||
|
||||
this.sessions.set(id, {
|
||||
instance,
|
||||
parsed,
|
||||
sessionDir,
|
||||
controlPath,
|
||||
localPort,
|
||||
remotePort,
|
||||
startedByUs,
|
||||
master,
|
||||
masterDetached: false,
|
||||
mainForward,
|
||||
mainForwardDetached,
|
||||
});
|
||||
session.localPort = localPort;
|
||||
|
||||
this.clearRetryAttempt(id);
|
||||
this.setStatus(
|
||||
@@ -1206,12 +1349,26 @@ export class ElectronSshManager {
|
||||
|
||||
if (!session.mainForwardDetached) {
|
||||
if (typeof session.mainForward.exitCode === 'number') {
|
||||
if (session.mainForward.exitCode === 0) {
|
||||
if (this.usesControlMaster() && session.mainForward.exitCode === 0) {
|
||||
session.mainForwardDetached = true;
|
||||
detachedNotice = 'Main tunnel helper exited after ControlMaster handoff';
|
||||
} else {
|
||||
droppedReason = `Main SSH forward exited (${session.mainForward.exitCode})`;
|
||||
droppedReason = this.processErrorDetail(session.mainForward, `Main SSH forward exited (${session.mainForward.exitCode})`);
|
||||
}
|
||||
} else if (childProcessDiagnostics.get(session.mainForward)?.error) {
|
||||
droppedReason = this.processErrorDetail(session.mainForward, 'Main SSH forward failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (!droppedReason) {
|
||||
const stoppedExtraForward = session.extraForwards.find(({ child }) => (
|
||||
typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error
|
||||
));
|
||||
if (stoppedExtraForward) {
|
||||
droppedReason = this.processErrorDetail(
|
||||
stoppedExtraForward.child,
|
||||
`Extra SSH forward ${stoppedExtraForward.id} exited`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,7 +1377,7 @@ export class ElectronSshManager {
|
||||
// Fast path: cheap TCP probe before expensive SSH subprocess
|
||||
if (await isLocalTunnelReachable(session.localPort)) {
|
||||
// Tunnel alive — skip SSH check
|
||||
} else if (!await isControlMasterAlive(session.parsed, session.controlPath)) {
|
||||
} else if (!await this.isControlMasterAlive(session.parsed, session.controlPath)) {
|
||||
droppedReason = 'SSH ControlMaster is not reachable';
|
||||
} else {
|
||||
detachedNotice = 'Local tunnel unreachable but ControlMaster is alive';
|
||||
|
||||
@@ -4,12 +4,27 @@ import fsp from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ElectronSshManager } from './ssh-manager.mjs';
|
||||
|
||||
const servers = [];
|
||||
const tempDirs = [];
|
||||
|
||||
const createChild = () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.exitCode = null;
|
||||
child.kill = () => {
|
||||
child.exitCode = 0;
|
||||
return true;
|
||||
};
|
||||
return child;
|
||||
};
|
||||
|
||||
const listen = async (server) => {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
@@ -35,6 +50,213 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('ElectronSshManager', () => {
|
||||
test('runs Windows SSH commands without ControlMaster and hides the process window', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
const child = createChild();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.end('Linux\n');
|
||||
child.exitCode = 0;
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
|
||||
await expect(manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s')).resolves.toBe('Linux\n');
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].command).toBe('ssh');
|
||||
expect(calls[0].options.windowsHide).toBe(true);
|
||||
expect(calls[0].args).toContain('ControlMaster=no');
|
||||
expect(calls[0].args).toContain('ControlPath=none');
|
||||
expect(calls[0].args).toContain('StrictHostKeyChecking=accept-new');
|
||||
expect(calls[0].args).not.toContain('ControlPath=C:\\Temp\\unused.sock');
|
||||
});
|
||||
|
||||
test('creates a PowerShell-backed askpass helper on Windows', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-askpass-test-'));
|
||||
tempDirs.push(tempDir);
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(tempDir, 'settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
const result = await manager.writeAskpassFiles(tempDir);
|
||||
|
||||
expect(path.basename(result.askpassPath)).toBe('askpass.cmd');
|
||||
expect(result.cleanupPaths.map((filePath) => path.basename(filePath))).toEqual(['askpass.cmd', 'askpass.ps1']);
|
||||
expect(await fsp.readFile(path.join(tempDir, 'askpass.cmd'), 'utf8')).toContain('WindowsPowerShell');
|
||||
expect(await fsp.readFile(path.join(tempDir, 'askpass.ps1'), 'utf8')).toContain('OPENCHAMBER_SSH_ASKPASS_VALUE');
|
||||
});
|
||||
|
||||
test('runs each Windows port forward as an independent hidden SSH process', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return createChild();
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
|
||||
sshPassword: 'secret-value',
|
||||
children: new Set(),
|
||||
});
|
||||
|
||||
await manager.spawnMainForward(parsed, 'C:\\Temp\\unused.sock', '127.0.0.1', 3000, 4000);
|
||||
await manager.spawnExtraForward(parsed, 'C:\\Temp\\unused.sock', {
|
||||
id: 'dynamic-1',
|
||||
type: 'dynamic',
|
||||
localHost: '127.0.0.1',
|
||||
localPort: 5000,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
expect(call.command).toBe('ssh');
|
||||
expect(call.args).toContain('ControlPath=none');
|
||||
expect(call.args).toContain('-N');
|
||||
expect(call.options.windowsHide).toBe(true);
|
||||
expect(call.options.env.SSH_ASKPASS).toBe('C:\\OpenChamber\\askpass.cmd');
|
||||
expect(call.options.env.OPENCHAMBER_SSH_ASKPASS_VALUE).toBe('secret-value');
|
||||
}
|
||||
expect(calls[0].args).toContain('-L');
|
||||
expect(calls[1].args).toContain('-D');
|
||||
});
|
||||
|
||||
test('keeps ControlMaster-backed forwarding on non-Windows platforms', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'darwin',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return createChild();
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
|
||||
await manager.spawnMainForward(parsed, '/tmp/control.sock', '127.0.0.1', 3000, 4000);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].args).toContain('ControlPath=/tmp/control.sock');
|
||||
expect(calls[0].args).not.toContain('ControlPath=none');
|
||||
expect(calls[0].options.windowsHide).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stops in-flight commands and forwards when disconnecting Windows SSH', async () => {
|
||||
const killedChildren = [];
|
||||
const spawnedChildren = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: () => {
|
||||
const child = createChild();
|
||||
child.kill = () => {
|
||||
killedChildren.push(child);
|
||||
child.exitCode = 1;
|
||||
child.emit('close', 1);
|
||||
return true;
|
||||
};
|
||||
spawnedChildren.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
const mainForward = createChild();
|
||||
const extraForward = createChild();
|
||||
for (const child of [mainForward, extraForward]) {
|
||||
child.kill = () => {
|
||||
killedChildren.push(child);
|
||||
child.exitCode = 0;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
|
||||
sshPassword: null,
|
||||
children: new Set(),
|
||||
});
|
||||
manager.sessions.set('ssh-1', {
|
||||
instance: { remoteOpenchamber: { mode: 'external', keepRunning: true } },
|
||||
parsed,
|
||||
controlPath: 'C:\\Temp\\unused.sock',
|
||||
askpassCleanupPaths: [],
|
||||
startedByUs: false,
|
||||
remotePort: null,
|
||||
master: null,
|
||||
mainForward,
|
||||
extraForwards: [{ id: 'dynamic-1', child: extraForward }],
|
||||
});
|
||||
|
||||
let commandError = null;
|
||||
const command = manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s').catch((error) => {
|
||||
commandError = error;
|
||||
});
|
||||
await manager.disconnectInternal('ssh-1', false);
|
||||
|
||||
await command;
|
||||
expect(commandError?.message).toBe('Remote command failed');
|
||||
expect(spawnedChildren).toHaveLength(1);
|
||||
expect(new Set(killedChildren)).toEqual(new Set([spawnedChildren[0], mainForward, extraForward]));
|
||||
expect(manager.sessions.has('ssh-1')).toBe(false);
|
||||
});
|
||||
|
||||
test('reports bounded, sanitized, and redacted SSH master stderr when startup fails', async () => {
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
spawn: () => {
|
||||
const child = createChild();
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 1;
|
||||
child.emit('close', 1);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
const master = createChild();
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: '/tmp/askpass.sh',
|
||||
sshPassword: 'secret-value',
|
||||
children: new Set(),
|
||||
});
|
||||
manager.trackSshProcess(master, parsed);
|
||||
master.stderr.write(`muxclient socket failed: secret-value\u0007${'x'.repeat(3000)}`);
|
||||
master.exitCode = 255;
|
||||
|
||||
try {
|
||||
await manager.waitForMasterReady(parsed, '/tmp/control.sock', 1, master);
|
||||
throw new Error('Expected SSH master startup to fail');
|
||||
} catch (error) {
|
||||
expect(error.message).toStartWith('muxclient socket failed: [redacted]');
|
||||
expect(error.message).not.toContain('secret-value');
|
||||
expect(error.message).not.toContain('\u0007');
|
||||
expect(error.message.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
});
|
||||
|
||||
test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => {
|
||||
let loginPayload = null;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user