Load native Windows app icons for open-in menu Open Explorer and Terminal to the selected project directory Resolve Windows Terminal icon from installed app assets
1254 lines
45 KiB
JavaScript
1254 lines
45 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 WINDOWS_HIDDEN_SPAWN_OPTIONS = process.platform === 'win32' ? { windowsHide: true } : {};
|
|
|
|
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 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"
|
|
|
|
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 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.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();
|
|
}
|
|
|
|
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) {
|
|
const root = readJsonRoot(this.settingsFilePath);
|
|
const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : [];
|
|
const existing = hosts.find((entry) => entry?.id === instanceId);
|
|
if (existing) {
|
|
existing.label = label;
|
|
existing.url = localUrl;
|
|
} else {
|
|
hosts.push({ id: instanceId, label, url: localUrl });
|
|
}
|
|
root.desktopHosts = hosts;
|
|
await writeJsonRoot(this.settingsFilePath, root);
|
|
}
|
|
|
|
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 runOutput('ssh', buildSshArgs(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, askpassPath, sshPassword) {
|
|
const child = spawn('ssh', buildSshArgs(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, [
|
|
'-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');
|
|
}
|
|
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 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 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 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 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 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(
|
|
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) {
|
|
return spawn('ssh', buildSshArgs(parsed, [
|
|
'-o', 'ControlMaster=no',
|
|
'-o', `ControlPath=${controlPath}`,
|
|
'-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',
|
|
];
|
|
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}`);
|
|
}
|
|
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, args));
|
|
if (code !== 0) {
|
|
throw new Error((stderr || stdout || `Failed to configure extra SSH forward ${forward.id}`).trim());
|
|
}
|
|
}
|
|
|
|
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.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]) {
|
|
try {
|
|
child.kill('SIGTERM');
|
|
} catch {
|
|
}
|
|
}
|
|
try {
|
|
await fsp.rm(session.controlPath, { force: true });
|
|
} catch {
|
|
}
|
|
try {
|
|
await fsp.rm(path.join(session.sessionDir, 'askpass.sh'), { force: true });
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
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 = path.join(sessionDir, 'askpass.sh');
|
|
await writeAskpassScript(askpassPath);
|
|
|
|
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, 'remote_probe', 'Probing remote platform');
|
|
const remoteOs = (await 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);
|
|
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);
|
|
let mainForwardDetached = false;
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
if (typeof mainForward.exitCode === 'number') {
|
|
if (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})`);
|
|
}
|
|
}
|
|
|
|
const extraErrors = [];
|
|
for (const forward of instance.portForwards.filter((item) => item.enabled)) {
|
|
try {
|
|
await this.spawnExtraForward(parsed, controlPath, forward);
|
|
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;
|
|
await this.updateHostUrl(id, label, localUrl);
|
|
if (instance.localForward?.preferredLocalPort !== localPort) {
|
|
await this.persistLocalPort(id, localPort);
|
|
}
|
|
|
|
this.sessions.set(id, {
|
|
instance,
|
|
parsed,
|
|
sessionDir,
|
|
controlPath,
|
|
localPort,
|
|
remotePort,
|
|
startedByUs,
|
|
master,
|
|
masterDetached: false,
|
|
mainForward,
|
|
mainForwardDetached,
|
|
});
|
|
|
|
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 (session.mainForward.exitCode === 0) {
|
|
session.mainForwardDetached = true;
|
|
detachedNotice = 'Main tunnel helper exited after ControlMaster handoff';
|
|
} else {
|
|
droppedReason = `Main SSH forward exited (${session.mainForward.exitCode})`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|
|
}
|