Files
openchamber/packages/electron/ssh-manager.mjs
T

1477 lines
54 KiB
JavaScript

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const LOCAL_HOST_ID = 'local';
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
const DEFAULT_CONTROL_PERSIST_SEC = 300;
const DEFAULT_READY_TIMEOUT_SEC = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5;
const MAX_LOG_LINES_PER_INSTANCE = 1200;
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 MAX_PROCESS_ERROR_CHARS = 2000;
const MAX_PROCESS_ERROR_CAPTURE_CHARS = MAX_PROCESS_ERROR_CHARS * 2;
const childProcessDiagnostics = new WeakMap();
const nowMillis = () => Date.now();
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
const hasGlobWildcard = (value) => /[*?]/.test(value);
const expandSshIncludeToken = (token, baseDir) => {
const trimmed = String(token || '').trim();
if (!trimmed) return [];
const expandedHome = trimmed.startsWith('~/')
? path.join(os.homedir(), trimmed.slice(2))
: (trimmed === '~' ? os.homedir() : trimmed);
const resolved = path.isAbsolute(expandedHome)
? expandedHome
: path.resolve(baseDir, expandedHome);
if (!hasGlobWildcard(resolved)) {
return fs.existsSync(resolved) ? [resolved] : [];
}
const dir = path.dirname(resolved);
const namePattern = path.basename(resolved);
if (hasGlobWildcard(dir) || !fs.existsSync(dir)) {
return [];
}
const matcher = new RegExp(`^${namePattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')}$`);
try {
return fs.readdirSync(dir)
.filter((name) => matcher.test(name))
.map((name) => path.join(dir, name))
.filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => left.localeCompare(right));
} catch {
return [];
}
};
const readJsonRoot = (settingsFilePath) => {
try {
const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};
const writeJsonRoot = async (settingsFilePath, root) => {
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
// Atomic write: concurrent readers (main.mjs, web server) would otherwise
// see partial JSON and readJsonRoot()'s catch would silently coerce to {},
// causing the next read-modify-write to wipe the entire settings file.
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
await fsp.rename(tmp, settingsFilePath);
};
const defaultTrue = () => true;
const sanitizeBindHost = (raw) => {
const trimmed = typeof raw === 'string' ? raw.trim() : '';
if (!trimmed) return DEFAULT_LOCAL_BIND_HOST;
return ['127.0.0.1', 'localhost', '0.0.0.0'].includes(trimmed) ? trimmed : DEFAULT_LOCAL_BIND_HOST;
};
const splitShellWords = (input) => {
const tokens = [];
let current = '';
let inSingle = false;
let inDouble = false;
const chars = [...String(input)];
for (let index = 0; index < chars.length; index += 1) {
const ch = chars[index];
if (ch === '\\' && !inSingle) {
index += 1;
if (index < chars.length) current += chars[index];
continue;
}
if (ch === '\'' && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (/\s/.test(ch) && !inSingle && !inDouble) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += ch;
}
if (inSingle || inDouble) {
throw new Error('Unclosed quote in SSH command');
}
if (current) tokens.push(current);
return tokens;
};
const isDisallowedPrimaryFlag = (token) => {
return ['-M', '-S', '-O', '-N', '-t', '-T', '-f', '-G', '-W', '-v', '-V', '-q', '-n', '-s', '-e', '-E', '-g'].includes(token);
};
const hasDisallowedOOption = (value) => {
const lower = String(value).trim().toLowerCase();
return ['controlmaster', 'controlpath', 'controlpersist', 'batchmode', 'proxycommand'].some((prefix) => lower.startsWith(prefix));
};
const parseSshCommand = (raw) => {
const tokens = splitShellWords(raw);
if (tokens.length === 0) {
throw new Error('SSH command is empty');
}
if (tokens[0] === 'ssh') {
tokens.shift();
}
if (tokens.length === 0) {
throw new Error('SSH command must include destination');
}
const allowedFlags = new Set(['-4', '-6', '-A', '-a', '-C', '-K', '-k', '-X', '-x', '-Y', '-y']);
const allowedWithValues = ['-B', '-b', '-c', '-D', '-F', '-I', '-i', '-J', '-l', '-m', '-o', '-P', '-p', '-R'];
const args = [];
let destination = null;
for (let index = 0; index < tokens.length;) {
const token = tokens[index];
if (destination) {
throw new Error(`SSH command has unsupported trailing argument: ${token}`);
}
if (!token.startsWith('-')) {
destination = token.trim();
index += 1;
continue;
}
if (isDisallowedPrimaryFlag(token)) {
throw new Error(`SSH option ${token} is not allowed`);
}
if (allowedFlags.has(token)) {
args.push(token);
index += 1;
continue;
}
let matched = false;
for (const option of allowedWithValues) {
if (token === option) {
const value = tokens[index + 1];
if (!value) {
throw new Error(`SSH option ${option} requires a value`);
}
if (option === '-o' && hasDisallowedOOption(value)) {
throw new Error(`SSH option -o ${value} is not allowed`);
}
args.push(token, value);
index += 2;
matched = true;
break;
}
if (token.startsWith(option) && token.length > option.length) {
const value = token.slice(option.length);
if (option === '-o' && hasDisallowedOOption(value)) {
throw new Error(`SSH option -o ${value} is not allowed`);
}
args.push(token);
index += 1;
matched = true;
break;
}
}
if (!matched) {
throw new Error(`Unsupported SSH option: ${token}`);
}
}
if (!destination) {
throw new Error('SSH command must include destination');
}
return { destination, args };
};
const buildSshArgs = (parsed, preDestinationArgs = [], remoteCommand = null) => {
const args = [...parsed.args, ...preDestinationArgs, parsed.destination];
if (remoteCommand) args.push(remoteCommand);
return args;
};
const askpassScriptContent = () => `#!/bin/bash
PROMPT="$1"
if [[ -n "$OPENCHAMBER_SSH_ASKPASS_VALUE" ]]; then
if [[ "$PROMPT" == *"assword"* || "$PROMPT" == *"passphrase"* ]]; then
printf '%s\\n' "$OPENCHAMBER_SSH_ASKPASS_VALUE"
exit 0
fi
fi
DEFAULT_ANSWER=""
HIDDEN_INPUT="true"
if [[ "$PROMPT" == *"yes/no"* ]]; then
DEFAULT_ANSWER="yes"
HIDDEN_INPUT="false"
fi
if command -v osascript >/dev/null 2>&1; then
/usr/bin/osascript <<'APPLESCRIPT' "$PROMPT" "$DEFAULT_ANSWER" "$HIDDEN_INPUT"
on run argv
set promptText to item 1 of argv
set defaultAnswer to item 2 of argv
set hiddenInput to item 3 of argv
try
if hiddenInput is "true" then
set response to display dialog promptText default answer defaultAnswer with hidden answer buttons {"Cancel", "OK"} default button "OK"
else
set response to display dialog promptText default answer defaultAnswer buttons {"Cancel", "OK"} default button "OK"
end if
return text returned of response
on error
error number -128
end try
end run
APPLESCRIPT
exit $?
fi
printf '%s\\n' "$DEFAULT_ANSWER"
`;
const writeAskpassScript = async (scriptPath) => {
await fsp.writeFile(scriptPath, askpassScriptContent(), { mode: 0o700 });
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()}`;
for (let index = 0; index < source.length; index += 1) {
hash = ((hash << 5) - hash + source.charCodeAt(index)) | 0;
}
const base = 20000;
const span = 30000;
return base + Math.abs(hash % span);
};
const pickUnusedLocalPort = async () => {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
server.on('error', reject);
});
};
const isLocalPortAvailable = async (bindHost, port) => {
return await new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.listen(port, bindHost, () => {
server.close(() => resolve(true));
});
});
};
const isLocalTunnelReachable = async (localPort) => {
return await new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port: localPort });
const finish = (value) => {
socket.destroy();
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(500, () => finish(false));
});
};
const waitLocalForwardReady = async (localPort) => {
const deadline = Date.now() + (DEFAULT_READY_TIMEOUT_SEC * 1000);
let pollMs = 250;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${localPort}/health`, { signal: AbortSignal.timeout(1000) });
if (response.ok || response.status === 401) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
pollMs = Math.min(pollMs * 2, 2000);
}
throw new Error('Timed out waiting for forwarded OpenChamber health');
};
const parseVersionToken = (raw) => {
for (const token of String(raw).split(/\s+/)) {
let candidate = token.trim().replace(/^v/, '');
candidate = candidate.replace(/[,)]+$/g, '');
const parts = candidate.split('.');
if (parts.length >= 2 && parts.every((part) => /^\d+$/.test(part))) {
return candidate;
}
}
return null;
};
const parseProbeStatusLine = (line, prefix) => {
if (!line || !line.startsWith(prefix)) return null;
const value = Number.parseInt(line.slice(prefix.length).trim(), 10);
return Number.isFinite(value) ? value : null;
};
const isAuthHttpStatus = (status) => status === 401 || status === 403;
const isLivenessHttpStatus = (status) => (status >= 200 && status <= 299) || isAuthHttpStatus(status);
export class ElectronSshManager {
constructor(options) {
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();
this.monitorTimers = new Map();
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) {
const line = `[${nowMillis()}] [${level}] ${message}`;
const current = this.logs.get(id) || [];
current.push(line);
if (current.length > MAX_LOG_LINES_PER_INSTANCE) {
current.splice(0, current.length - MAX_LOG_LINES_PER_INSTANCE);
}
this.logs.set(id, current);
}
appendLog(id, message) {
this.appendLogWithLevel(id, 'INFO', message);
}
appendAttemptSeparator(id, connectAttempt, retryAttempt) {
const scope = retryAttempt > 0 ? `retry ${retryAttempt}` : 'manual';
this.appendLogWithLevel(id, 'INFO', `---------------- attempt #${connectAttempt} (${scope}) ----------------`);
}
statusSnapshotForInstance(id) {
return this.statuses.get(id) || {
id,
phase: 'idle',
detail: null,
localUrl: null,
localPort: null,
remotePort: null,
startedByUs: false,
retryAttempt: 0,
requiresUserAction: false,
updatedAtMs: nowMillis(),
};
}
setStatus(id, phase, detail = null, localUrl = null, localPort = null, remotePort = null, startedByUs = false, retryAttempt = 0, requiresUserAction = false) {
const level = phase === 'error' ? 'ERROR' : (phase === 'degraded' ? 'WARN' : 'INFO');
this.appendLogWithLevel(
id,
level,
`phase=${JSON.stringify(phase)} detail=${detail || ''} retry=${retryAttempt} requires_user_action=${requiresUserAction}`,
);
const status = {
id,
phase,
detail,
localUrl,
localPort,
remotePort,
startedByUs,
retryAttempt,
requiresUserAction,
updatedAtMs: nowMillis(),
};
this.statuses.set(id, status);
this.emit(SSH_STATUS_EVENT, status);
}
clearRetryAttempt(id) {
this.reconnectAttempts.delete(id);
}
nextRetryAttempt(id) {
const next = (this.reconnectAttempts.get(id) || 0) + 1;
this.reconnectAttempts.set(id, next);
return next;
}
currentRetryAttempt(id) {
return this.reconnectAttempts.get(id) || 0;
}
nextConnectAttempt(id) {
const next = (this.connectAttempts.get(id) || 0) + 1;
this.connectAttempts.set(id, next);
return next;
}
logsForInstance(id, limit = 200) {
const lines = [...(this.logs.get(id) || [])];
return limit > 0 && lines.length > limit ? lines.slice(-limit) : lines;
}
clearLogsForInstance(id) {
this.logs.delete(id);
}
parseSshConfigCandidates(filePath, source, visited = new Set()) {
const resolvedPath = path.resolve(filePath);
if (visited.has(resolvedPath) || !fs.existsSync(resolvedPath)) return [];
visited.add(resolvedPath);
const content = fs.readFileSync(resolvedPath, 'utf8');
const candidates = [];
const baseDir = path.dirname(resolvedPath);
for (const line of content.split(/\r?\n/)) {
const trimmed = (line.split('#')[0] || '').trim();
if (!trimmed) continue;
if (/^include(?:\s|$)/i.test(trimmed)) {
const includeExpr = trimmed.replace(/^include\s+/i, '').trim();
if (!includeExpr) continue;
let includeTokens = [];
try {
includeTokens = splitShellWords(includeExpr);
} catch {
includeTokens = includeExpr.split(/\s+/).filter(Boolean);
}
for (const includeToken of includeTokens) {
const includePaths = expandSshIncludeToken(includeToken, baseDir);
for (const includePath of includePaths) {
candidates.push(...this.parseSshConfigCandidates(includePath, source, visited));
}
}
continue;
}
if (!/^host(?:\s|$)/i.test(trimmed)) continue;
const rest = trimmed.replace(/^host\s+/i, '').trim();
if (!rest) continue;
for (const token of rest.split(/\s+/)) {
const host = token.trim();
if (!host || host.startsWith('!') || host === '*') continue;
candidates.push({
host,
pattern: /[*?]/.test(host),
source,
sshCommand: `ssh ${host}`,
});
}
}
return candidates;
}
async importHosts() {
const candidates = [
...this.parseSshConfigCandidates(path.join(os.homedir(), '.ssh', 'config'), 'user'),
...this.parseSshConfigCandidates('/etc/ssh/ssh_config', 'global'),
];
const seen = new Set();
return candidates
.filter((item) => !seen.has(item.host) && seen.add(item.host))
.sort((left, right) => left.host.localeCompare(right.host));
}
readInstances() {
const root = readJsonRoot(this.settingsFilePath);
return { instances: Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [] };
}
async setInstances(config) {
const root = readJsonRoot(this.settingsFilePath);
const previousSshIds = new Set(
(Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [])
.map((entry) => String(entry?.id || '').trim())
.filter((id) => id && id !== LOCAL_HOST_ID)
);
const instances = Array.isArray(config?.instances) ? config.instances.map((instance) => this.sanitizeInstance(instance)) : [];
root.desktopSshInstances = instances;
const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts.filter(Boolean) : [];
const nextIds = new Set(instances.map((instance) => instance.id));
const filteredHosts = hosts.filter((entry) => {
const id = String(entry?.id || '').trim();
return id && id !== LOCAL_HOST_ID && !(previousSshIds.has(id) && !nextIds.has(id));
});
for (const instance of instances) {
const label = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const existing = filteredHosts.find((entry) => entry?.id === instance.id);
if (existing) {
existing.label = label;
if (!existing.url || !String(existing.url).trim()) {
existing.url = 'http://127.0.0.1/';
}
} else {
filteredHosts.push({ id: instance.id, label, url: 'http://127.0.0.1/' });
}
}
root.desktopHosts = filteredHosts;
if (typeof root.desktopDefaultHostId === 'string' && previousSshIds.has(root.desktopDefaultHostId) && !nextIds.has(root.desktopDefaultHostId)) {
root.desktopDefaultHostId = LOCAL_HOST_ID;
}
await writeJsonRoot(this.settingsFilePath, root);
}
sanitizeStoredSecret(secret) {
if (!secret || typeof secret !== 'object') return undefined;
return {
enabled: Boolean(secret.enabled),
store: secret.store === 'settings' ? 'settings' : 'never',
...(typeof secret.value === 'string' && secret.value.trim() ? { value: secret.value } : {}),
};
}
sanitizeForward(forward) {
const id = typeof forward?.id === 'string' ? forward.id.trim() : '';
if (!id) return null;
const type = forward?.type === 'remote' || forward?.type === 'dynamic' ? forward.type : 'local';
const normalized = {
id,
enabled: forward?.enabled !== false,
type,
...(forward?.localHost ? { localHost: sanitizeBindHost(forward.localHost) } : {}),
...(Number.isFinite(forward?.localPort) ? { localPort: Number(forward.localPort) } : {}),
...(forward?.remoteHost ? { remoteHost: String(forward.remoteHost).trim() || '127.0.0.1' } : {}),
...(Number.isFinite(forward?.remotePort) ? { remotePort: Number(forward.remotePort) } : {}),
};
if (type === 'local' || type === 'remote') {
if (!normalized.localPort || !normalized.remotePort) return null;
normalized.remoteHost = normalized.remoteHost || '127.0.0.1';
normalized.localHost = normalized.localHost || '127.0.0.1';
}
if (type === 'dynamic' && !normalized.localPort) {
return null;
}
return normalized;
}
sanitizeInstance(instance) {
const id = typeof instance?.id === 'string' ? instance.id.trim() : '';
const sshCommand = typeof instance?.sshCommand === 'string' ? instance.sshCommand.trim() : '';
if (!id || id === LOCAL_HOST_ID) {
throw new Error('SSH instance id is required');
}
if (!sshCommand) {
throw new Error('SSH command is required');
}
const parsed = parseSshCommand(sshCommand);
const seen = new Set();
const portForwards = Array.isArray(instance?.portForwards)
? instance.portForwards
.map((forward) => this.sanitizeForward(forward))
.filter((forward) => forward && !seen.has(forward.id) && seen.add(forward.id))
: [];
return {
id,
...(typeof instance?.nickname === 'string' && instance.nickname.trim() ? { nickname: instance.nickname.trim() } : {}),
sshCommand,
sshParsed: parsed,
connectionTimeoutSec: Number.isFinite(instance?.connectionTimeoutSec) && Number(instance.connectionTimeoutSec) > 0
? Number(instance.connectionTimeoutSec)
: DEFAULT_CONNECTION_TIMEOUT_SEC,
remoteOpenchamber: {
mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed',
keepRunning: instance?.remoteOpenchamber?.keepRunning !== false,
...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}),
installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod)
? instance.remoteOpenchamber.installMethod
: 'bun',
uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh),
},
localForward: {
bindHost: sanitizeBindHost(instance?.localForward?.bindHost),
...(Number.isFinite(instance?.localForward?.preferredLocalPort) ? { preferredLocalPort: Number(instance.localForward.preferredLocalPort) } : {}),
},
auth: {
...(this.sanitizeStoredSecret(instance?.auth?.sshPassword) ? { sshPassword: this.sanitizeStoredSecret(instance.auth.sshPassword) } : {}),
...(this.sanitizeStoredSecret(instance?.auth?.openchamberPassword) ? { openchamberPassword: this.sanitizeStoredSecret(instance.auth.openchamberPassword) } : {}),
},
portForwards,
};
}
async updateHostUrl(instanceId, label, localUrl) {
return this.updateHostRuntime(instanceId, label, localUrl, '');
}
async updateHostRuntime(instanceId, label, localUrl, clientToken = '') {
const root = readJsonRoot(this.settingsFilePath);
const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : [];
const existing = hosts.find((entry) => entry?.id === instanceId);
const token = typeof clientToken === 'string' ? clientToken.trim() : '';
if (existing) {
existing.label = label;
existing.url = localUrl;
existing.apiUrl = localUrl;
if (token) existing.clientToken = token;
} else {
hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) });
}
root.desktopHosts = hosts;
await writeJsonRoot(this.settingsFilePath, root);
}
async issueClientToken(localUrl, openchamberPassword) {
const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : '';
if (!password) return '';
const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), {
method: 'POST',
signal: AbortSignal.timeout(10_000),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
password,
trustDevice: true,
issueClientToken: true,
clientLabel: 'OpenChamber Desktop SSH',
}),
});
if (!loginResponse.ok) {
throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`);
}
const payload = await loginResponse.json().catch(() => null);
const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : '';
if (token) return token;
const cookie = this.extractCookieHeader(loginResponse);
if (!cookie) return '';
const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), {
method: 'POST',
signal: AbortSignal.timeout(10_000),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Cookie: cookie,
},
body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }),
});
if (!tokenResponse.ok) return '';
const tokenPayload = await tokenResponse.json().catch(() => null);
return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : '';
}
extractCookieHeader(response) {
const getSetCookie = typeof response.headers?.getSetCookie === 'function'
? response.headers.getSetCookie.bind(response.headers)
: null;
const cookies = getSetCookie ? getSetCookie() : [];
const rawCookies = cookies.length > 0
? cookies
: String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/);
return rawCookies
.map((cookie) => String(cookie || '').split(';')[0].trim())
.filter(Boolean)
.join('; ');
}
async persistLocalPort(instanceId, localPort) {
const root = readJsonRoot(this.settingsFilePath);
const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [];
for (const instance of instances) {
if (instance?.id !== instanceId) continue;
instance.localForward = instance.localForward && typeof instance.localForward === 'object' ? instance.localForward : {};
instance.localForward.preferredLocalPort = localPort;
}
root.desktopSshInstances = instances;
await writeJsonRoot(this.settingsFilePath, root);
}
async resolveSshConfig(parsed) {
const { code, stdout, stderr } = await this.runSshOutput(parsed, ['-G']);
if (code !== 0) {
throw new Error(stderr.trim() || 'Failed to resolve SSH config');
}
const map = new Map();
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const [key, ...rest] = trimmed.split(' ');
if (!key || rest.length === 0) continue;
map.set(key.toLowerCase(), rest.join(' ').trim());
}
return map;
}
ensureSessionDir(id) {
const base = path.join(path.dirname(this.settingsFilePath), 'ssh', id);
fs.mkdirSync(base, { recursive: true });
return base;
}
controlPathForInstance(id) {
let hash = 0;
for (const char of id) {
hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
}
return path.join(os.tmpdir(), `ocssh-${Math.abs(hash).toString(16)}.sock`);
}
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'],
});
}
async waitForMasterReady(parsed, controlPath, timeoutSec, master) {
const deadline = Date.now() + (timeoutSec * 1000);
let pollMs = 250;
while (Date.now() < deadline) {
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(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);
}
throw new Error('SSH ControlMaster connection timed out');
}
configuredOpenChamberPassword(instance) {
const secret = instance?.auth?.openchamberPassword;
return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null;
}
async remoteCommandExists(parsed, controlPath, commandName) {
try {
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;
}
}
async currentRemoteOpenChamberVersion(parsed, controlPath) {
try {
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
return parseVersionToken(output);
} catch {
return null;
}
}
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun');
const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm');
const commands = [];
if (preferred === 'bun') {
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
} else if (preferred === 'npm') {
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
} else {
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
}
if (commands.length === 0) {
throw new Error('Remote host has neither bun nor npm available');
}
let lastError = null;
for (const command of commands) {
try {
await this.runRemoteCommand(parsed, controlPath, command);
return;
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('Failed to install OpenChamber on remote host');
}
async probeRemoteSystemInfo(parsed, controlPath, port, openchamberPassword) {
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 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;
const healthStatus = parseProbeStatusLine(lines[2], 'HEALTH_STATUS=') || 0;
const body = lines.slice(3).join('\n');
if (isLivenessHttpStatus(infoStatus)) {
if (isAuthHttpStatus(infoStatus)) {
if (openchamberPassword && authStatus !== 200) {
throw new Error(`Remote OpenChamber requires UI authentication and configured password was rejected (auth status ${authStatus})`);
}
if (isLivenessHttpStatus(healthStatus)) return {};
throw new Error('Remote OpenChamber requires UI authentication on /api/system/info; configure OpenChamber UI password');
}
} else if (isLivenessHttpStatus(healthStatus)) {
return {};
} else {
throw new Error(`Remote OpenChamber probe failed (info status ${infoStatus}, health status ${healthStatus})`);
}
try {
return JSON.parse(body);
} catch {
return {};
}
}
async remoteServerRunning(parsed, controlPath, port, openchamberPassword) {
try {
await this.probeRemoteSystemInfo(parsed, controlPath, port, openchamberPassword);
return true;
} catch {
return false;
}
}
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) {
let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote';
const secret = this.configuredOpenChamberPassword(instance);
if (secret) {
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
}
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 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`,
);
} catch {
}
}
async spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort) {
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'],
});
}
async spawnExtraForward(parsed, controlPath, 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') {
args.push('-R', `${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}:${forward.localHost || '127.0.0.1'}:${forward.localPort}`);
} else {
args.push('-D', `${forward.localHost || '127.0.0.1'}:${forward.localPort}`);
}
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) {
if (instance.remoteOpenchamber.mode === 'external') {
if (!instance.remoteOpenchamber.preferredPort) {
throw new Error('External mode requires a preferred remote OpenChamber port');
}
const port = instance.remoteOpenchamber.preferredPort;
this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false);
await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance));
return { remotePort: port, startedByUs: false };
}
this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation');
const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath);
if (!installedVersion) {
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
} else if (installedVersion !== this.appVersion) {
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`);
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
}
this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server');
let remotePort = instance.remoteOpenchamber.preferredPort || null;
let startedByUs = false;
if (remotePort && !(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
remotePort = null;
}
if (!remotePort) {
this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server');
const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id);
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort);
startedByUs = true;
}
if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
throw new Error('Managed OpenChamber server failed to become reachable');
}
return { remotePort, startedByUs };
}
async disconnectInternal(id, reportIdle) {
const timer = this.monitorTimers.get(id);
if (timer) {
clearTimeout(timer);
this.monitorTimers.delete(id);
}
const session = this.sessions.get(id);
this.sessions.delete(id);
if (session) {
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
}
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 {
}
}
try {
await fsp.rm(session.controlPath, { 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);
if (reportIdle) {
this.setStatus(id, 'idle', null, null, null, null, false, 0, false);
}
}
async connectBlocking(instance) {
const id = instance.id;
this.setStatus(id, 'config_resolved', 'Resolving SSH command');
const parsed = instance.sshParsed || parseSshCommand(instance.sshCommand);
await this.resolveSshConfig(parsed);
this.setStatus(id, 'auth_check', 'Checking SSH connectivity');
const sessionDir = this.ensureSessionDir(id);
const controlPath = this.controlPathForInstance(id);
try { await fsp.rm(controlPath, { force: true }); } catch {}
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', 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 this.runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
if (!['linux', 'darwin'].includes(remoteOs)) {
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);
let localPort = Number(instance.localForward?.preferredLocalPort) || 0;
if (!localPort) {
localPort = await pickUnusedLocalPort();
}
if (!(await isLocalPortAvailable(bindHost, localPort))) {
localPort = await pickUnusedLocalPort();
}
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' || childProcessDiagnostics.get(mainForward)?.error) {
if (this.usesControlMaster() && mainForward.exitCode === 0) {
mainForwardDetached = true;
this.appendLogWithLevel(id, 'INFO', 'Main tunnel helper exited after ControlMaster handoff');
} else {
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 {
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))) {
extraErrors.push(`${forward.id}: local listener 127.0.0.1:${forward.localPort} is not reachable`);
}
}
} catch (error) {
extraErrors.push(`${forward.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
await waitLocalForwardReady(localPort);
const localUrl = `http://127.0.0.1:${localPort}`;
const label = instance.nickname?.trim() || parsed.destination || id;
const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance));
await this.updateHostRuntime(id, label, localUrl, clientToken);
if (instance.localForward?.preferredLocalPort !== localPort) {
await this.persistLocalPort(id, localPort);
}
session.localPort = localPort;
this.clearRetryAttempt(id);
this.setStatus(
id,
'ready',
extraErrors.length === 0 ? 'SSH instance is ready' : `SSH instance is ready with forward warnings: ${extraErrors.join('; ')}`,
localUrl,
localPort,
remotePort,
startedByUs,
0,
false,
);
this.spawnMonitor(id);
}
spawnMonitor(id) {
const existing = this.monitorTimers.get(id);
if (existing) clearTimeout(existing);
let healthyTicks = 0;
const tick = async () => {
const session = this.sessions.get(id);
if (!session) {
this.monitorTimers.delete(id);
return;
}
let droppedReason = null;
let detachedNotice = null;
if (!session.mainForwardDetached) {
if (typeof session.mainForward.exitCode === 'number') {
if (this.usesControlMaster() && session.mainForward.exitCode === 0) {
session.mainForwardDetached = true;
detachedNotice = 'Main tunnel helper exited after ControlMaster handoff';
} else {
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`,
);
}
}
if (!droppedReason) {
if (session.mainForwardDetached) {
// Fast path: cheap TCP probe before expensive SSH subprocess
if (await isLocalTunnelReachable(session.localPort)) {
// Tunnel alive — skip SSH check
} else if (!await this.isControlMasterAlive(session.parsed, session.controlPath)) {
droppedReason = 'SSH ControlMaster is not reachable';
} else {
detachedNotice = 'Local tunnel unreachable but ControlMaster is alive';
}
}
}
if (detachedNotice) {
this.appendLogWithLevel(id, 'INFO', detachedNotice);
}
if (!droppedReason) {
healthyTicks++;
const pollMs = healthyTicks >= MONITOR_STABILIZE_TICKS ? MONITOR_STEADY_POLL_MS : MONITOR_INITIAL_POLL_MS;
this.monitorTimers.set(id, setTimeout(tick, pollMs));
return;
}
this.appendLogWithLevel(id, 'WARN', droppedReason);
await this.disconnectInternal(id, false);
const attempt = this.nextRetryAttempt(id);
if (attempt > DEFAULT_RECONNECT_MAX_ATTEMPTS) {
this.setStatus(id, 'error', `${droppedReason}. Retry limit reached`, null, null, null, false, attempt, true);
return;
}
this.setStatus(id, 'degraded', `${droppedReason}. Reconnecting`, null, null, null, false, attempt, false);
const delayMs = Math.min((2 ** Math.max(attempt - 1, 0)) * 1000 + (nowMillis() % 700) + 100, 30000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
try {
await this.connect(id);
} catch (error) {
this.setStatus(id, 'error', error instanceof Error ? error.message : String(error), null, null, null, false, attempt, true);
}
};
this.monitorTimers.set(id, setTimeout(tick, MONITOR_INITIAL_POLL_MS));
}
async connect(id) {
const trimmed = String(id || '').trim();
if (!trimmed || trimmed === LOCAL_HOST_ID) {
throw new Error('SSH instance id is required');
}
if (this.connecting.has(trimmed)) {
this.appendLogWithLevel(trimmed, 'INFO', 'Connection already in progress');
return this.connecting.get(trimmed);
}
const instance = this.readInstances().instances.find((entry) => entry?.id === trimmed);
if (!instance) {
throw new Error('SSH instance not found');
}
const retryAttempt = this.currentRetryAttempt(trimmed);
const connectAttempt = this.nextConnectAttempt(trimmed);
this.appendAttemptSeparator(trimmed, connectAttempt, retryAttempt);
this.appendLog(trimmed, 'Starting SSH connection');
await this.disconnectInternal(trimmed, false);
const task = this.connectBlocking(this.sanitizeInstance(instance))
.catch(async (error) => {
this.setStatus(trimmed, 'error', error instanceof Error ? error.message : String(error), null, null, null, false, 0, true);
await this.disconnectInternal(trimmed, false);
throw error;
})
.finally(() => {
this.connecting.delete(trimmed);
});
this.connecting.set(trimmed, task);
return task;
}
async disconnect(id) {
const trimmed = String(id || '').trim();
if (!trimmed || trimmed === LOCAL_HOST_ID) {
throw new Error('SSH instance id is required');
}
await this.disconnectInternal(trimmed, true);
}
async statusesWithDefaults(id) {
if (id) {
return [this.statusSnapshotForInstance(id)];
}
return this.readInstances().instances
.map((instance) => this.statusSnapshotForInstance(instance.id))
.sort((left, right) => left.id.localeCompare(right.id));
}
async shutdownAll() {
const ids = [...new Set([...this.sessions.keys(), ...this.connecting.keys(), ...this.monitorTimers.keys()])];
for (const id of ids) {
await this.disconnectInternal(id, false);
}
}
}