feat(vault): add host connection test to the host editor (#2984)
* feat(vault): add host connection test to the host editor Add a 'Test' button next to Save in the host editor that opens a dialog reusing the terminal's connection progress/auth/host-key UI, and runs a headless connection probe: - Main process: netcatty:test-connection reuses startSSHSession in a testMode that authenticates then tears down without opening a shell, reports via netcatty:test:result, and supports cancellation (netcatty:test-connection:cancel). - Renderer: useHostConnectionTest hook + HostConnectionTestDialog component; single-credential auth (no agent/default-key fallback) with short test timeouts (8s TCP / 15s auth). - Reuses terminal chain progress, host-key verification, and auth prompts; success keeps the same dialog with retry/close. * fix(ssh): cancel test connections during chain/proxy dial Register the headless test-connection transport before connectThroughChain / createProxySocket runs, and update its pendingConn / chainConnections as hops and proxy sockets are established. This lets cancelTestConnection abort a slow/unreachable bastion or proxy dial instead of leaving it running until timeout. * fix(ssh): address host connection test review feedback - Route jump-host keyboard-interactive prompts through the external scope in test mode - Always offer keyboard-interactive for requiresMfa hosts in test mode, including key/certificate-only auth - Derive the renderer test deadline from the probe options and cancel the attempt on timeout - Run the macOS Local Network preflight before connection tests - Add regression tests for jump-host MFA, MFA auth order, test deadline, and macOS preflight Author: yangheng Model: claude-opus-5 --------- Co-authored-by: yangheng <492238647@qq.com>
This commit is contained in:
1 parent
6fcd6a3ec3
commit
75c155b748
22 files changed
+2022
-7
No files matched your search
@@ -199,6 +199,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.connected': 'Connected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
|
||||
@@ -714,6 +714,16 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'vault.panel.resizeWidth': 'Resize panel width',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Test Connection',
|
||||
'hostConnectionTest.test': 'Test',
|
||||
'hostConnectionTest.chainOf': 'Chain {current} of {total}',
|
||||
'hostConnectionTest.success': 'Connection successful',
|
||||
'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.',
|
||||
'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s',
|
||||
'hostConnectionTest.failed': 'Connection failed.',
|
||||
'hostConnectionTest.retry': 'Retry',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.',
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
|
||||
@@ -220,6 +220,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.connected': 'Подключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
|
||||
@@ -752,6 +752,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'vault.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostConnectionTest.title': 'Проверка подключения',
|
||||
'hostConnectionTest.test': 'Проверить',
|
||||
'hostConnectionTest.chainOf': 'Цепочка {current} из {total}',
|
||||
'hostConnectionTest.success': 'Подключение успешно',
|
||||
'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.',
|
||||
'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с',
|
||||
'hostConnectionTest.failed': 'Не удалось подключиться.',
|
||||
'hostConnectionTest.retry': 'Повторить',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.',
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
|
||||
@@ -130,6 +130,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'vault.panel.resizeWidth': '调整面板宽度',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostConnectionTest.title': '测试连接',
|
||||
'hostConnectionTest.test': '测试',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '连接成功',
|
||||
'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。',
|
||||
'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时',
|
||||
'hostConnectionTest.failed': '连接失败。',
|
||||
'hostConnectionTest.retry': '重试',
|
||||
'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -591,6 +600,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.waitingForUserInput': '等待用户输入',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.connected': '已连接',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
|
||||
@@ -129,6 +129,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新增主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostConnectionTest.title': '測試連線',
|
||||
'hostConnectionTest.test': '測試',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '連線成功',
|
||||
'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。',
|
||||
'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時',
|
||||
'hostConnectionTest.failed': '連線失敗。',
|
||||
'hostConnectionTest.retry': '重試',
|
||||
'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。',
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -591,6 +600,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
|
||||
'terminal.progress.waitingForUserInput': '等待使用者輸入',
|
||||
'terminal.progress.disconnected': '已斷開',
|
||||
'terminal.progress.connected': '已連線',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新開始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { Host } from "../../domain/models.ts";
|
||||
import { useHostConnectionTest } from "./useHostConnectionTest.ts";
|
||||
|
||||
type HookApi = ReturnType<typeof useHostConnectionTest>;
|
||||
|
||||
const targetHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
// Shorter than the fixed 15s test auth timeout sent to the bridge.
|
||||
sshAuthReadyTimeoutSeconds: 5,
|
||||
};
|
||||
|
||||
test("connection test countdown follows the probe timeouts and invalidates the attempt on timeout", async (t) => {
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
let resolveProbe: (value: unknown) => void = () => {};
|
||||
const cancelled: string[] = [];
|
||||
const startedSessionIds: string[] = [];
|
||||
const bridge = {
|
||||
testConnection: (options: { sessionId: string }) => {
|
||||
startedSessionIds.push(options.sessionId);
|
||||
return new Promise((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
},
|
||||
cancelTestConnection: async (sessionId: string) => {
|
||||
cancelled.push(sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
onChainProgress: () => () => {},
|
||||
onHostKeyVerification: () => () => {},
|
||||
respondHostKeyVerification: async () => ({ success: true }),
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { netcatty: bridge },
|
||||
});
|
||||
|
||||
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
||||
let api: HookApi | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
api = useHostConnectionTest({ host: targetHost, hosts: [targetHost], keys: [] });
|
||||
return null;
|
||||
};
|
||||
const current = () => {
|
||||
assert.ok(api);
|
||||
return api;
|
||||
};
|
||||
|
||||
t.after(async () => {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
mock.timers.reset();
|
||||
if (previousWindowDescriptor) Object.defineProperty(globalThis, "window", previousWindowDescriptor);
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
void current().startTest();
|
||||
});
|
||||
assert.equal(current().state.status, "connecting");
|
||||
// 8s TCP + 15s auth sent to the bridge, plus a 2s grace.
|
||||
assert.equal(current().state.timeLeft, 25);
|
||||
|
||||
// The host's own 5s auth timeout must not end the test early.
|
||||
await act(async () => {
|
||||
mock.timers.tick(6000);
|
||||
});
|
||||
assert.equal(current().state.status, "connecting");
|
||||
assert.deepEqual(cancelled, []);
|
||||
|
||||
await act(async () => {
|
||||
mock.timers.tick(19_000);
|
||||
});
|
||||
assert.equal(current().state.status, "disconnected");
|
||||
assert.equal(current().state.error, "Connection timed out. Please try again.");
|
||||
assert.deepEqual(cancelled, startedSessionIds, "timeout must cancel the in-flight probe");
|
||||
|
||||
// A late success from the cancelled probe must not overwrite the timeout.
|
||||
await act(async () => {
|
||||
resolveProbe({ sessionId: startedSessionIds[0], testResult: "connected" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.equal(current().state.status, "disconnected");
|
||||
assert.equal(current().state.error, "Connection timed out. Please try again.");
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "../../domain/models";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import { toHostKeyInfo } from "../../domain/hostKey";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts";
|
||||
import {
|
||||
buildHostConnectionTestPlan,
|
||||
formatConnectionTestProgressLog,
|
||||
resolveHostConnectionTestDeadlineMs,
|
||||
type HostConnectionTestAuthOverride,
|
||||
} from "../../domain/hostConnectionTest";
|
||||
import { useTerminalBackend } from "./useTerminalBackend";
|
||||
|
||||
export type ChainProgress = {
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
currentHostLabel: string;
|
||||
connectionPhase: string;
|
||||
} | null;
|
||||
|
||||
export type HostKeyVerificationState = {
|
||||
requestId: string;
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
};
|
||||
|
||||
export type HostConnectionTestStatus = "idle" | "connecting" | "connected" | "disconnected";
|
||||
|
||||
export type HostConnectionTestState = {
|
||||
status: HostConnectionTestStatus;
|
||||
error: string | null;
|
||||
needsAuth: boolean;
|
||||
progressValue: number;
|
||||
chainProgress: ChainProgress;
|
||||
progressLogs: string[];
|
||||
hostKeyVerification: HostKeyVerificationState | null;
|
||||
timeLeft: number;
|
||||
isAwaitingUserInput: boolean;
|
||||
isCancelling: boolean;
|
||||
};
|
||||
|
||||
export type HostConnectionTestInput = {
|
||||
host: Host;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
terminalSettings?: TerminalSettings;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
};
|
||||
|
||||
const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
export function useHostConnectionTest(input: HostConnectionTestInput) {
|
||||
const {
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities = [],
|
||||
knownHosts,
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
terminalSettings,
|
||||
onAddKnownHost,
|
||||
} = input;
|
||||
const backend = useTerminalBackend();
|
||||
|
||||
const [status, setStatus] = useState<HostConnectionTestStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsAuth, setNeedsAuth] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(0);
|
||||
const [chainProgress, setChainProgress] = useState<ChainProgress>(null);
|
||||
const [progressLogs, setProgressLogs] = useState<string[]>([]);
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<HostKeyVerificationState | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [isAwaitingUserInput, setIsAwaitingUserInput] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const bootEpochRef = useRef(0);
|
||||
const pendingHostKeyRequestIdRef = useRef<string | null>(null);
|
||||
const disposedRef = useRef(false);
|
||||
// Deadline of the active attempt, derived from the options actually sent to
|
||||
// the bridge so the countdown matches the probe's real timeouts.
|
||||
const deadlineMsRef = useRef(0);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setError(null);
|
||||
setNeedsAuth(false);
|
||||
setProgressValue(0);
|
||||
setChainProgress(null);
|
||||
setProgressLogs([]);
|
||||
setHostKeyVerification(null);
|
||||
setIsAwaitingUserInput(false);
|
||||
setIsCancelling(false);
|
||||
setTimeLeft(0);
|
||||
pendingHostKeyRequestIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
sessionIdRef.current = null;
|
||||
if (sessionId && !disposedRef.current) {
|
||||
void backend.cancelTestConnection(sessionId).catch(() => {});
|
||||
}
|
||||
}, [backend]);
|
||||
|
||||
// Unmount cleanup. The per-attempt subscriptions below dispose themselves via
|
||||
// their own effect returns; nothing here touches them.
|
||||
useEffect(() => {
|
||||
disposedRef.current = false;
|
||||
return () => {
|
||||
disposedRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to chain progress for the active attempt.
|
||||
useEffect(() => {
|
||||
const unsub = backend.onChainProgress?.((sessionId, hop, total, label, phase, phaseError) => {
|
||||
if (sessionId !== sessionIdRef.current) return;
|
||||
if (total > 1) {
|
||||
setChainProgress({
|
||||
currentHop: hop,
|
||||
totalHops: total,
|
||||
currentHostLabel: label,
|
||||
connectionPhase: phase,
|
||||
});
|
||||
}
|
||||
setProgressLogs((prev) => [
|
||||
...prev,
|
||||
formatConnectionTestProgressLog({ hop, total, label, phase, error: phaseError }),
|
||||
]);
|
||||
const hopProgress = (hop / total) * 80 + 10;
|
||||
setProgressValue((prev) => Math.max(prev, Math.min(95, hopProgress)));
|
||||
setIsAwaitingUserInput(phase === "auth-attempt" && phaseError === "waiting for user input...");
|
||||
});
|
||||
return () => unsub?.();
|
||||
}, [backend]);
|
||||
|
||||
// Subscribe to host-key verification for the active attempt.
|
||||
useEffect(() => {
|
||||
const unsub = backend.onHostKeyVerification?.((request) => {
|
||||
if (request.sessionId !== sessionIdRef.current) return;
|
||||
pendingHostKeyRequestIdRef.current = request.requestId;
|
||||
setHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
hostKeyInfo: toHostKeyInfo(request),
|
||||
});
|
||||
});
|
||||
return () => unsub?.();
|
||||
}, [backend]);
|
||||
|
||||
const respondHostKey = useCallback(
|
||||
(requestId: string, accept: boolean, addToKnownHosts: boolean, hostKeyInfo: HostKeyInfo) => {
|
||||
if (accept && addToKnownHosts && onAddKnownHost) {
|
||||
onAddKnownHost(createKnownHostFromHostKeyInfo(hostKeyInfo));
|
||||
}
|
||||
void backend.respondHostKeyVerification(requestId, accept, addToKnownHosts);
|
||||
pendingHostKeyRequestIdRef.current = null;
|
||||
setHostKeyVerification(null);
|
||||
},
|
||||
[backend, onAddKnownHost],
|
||||
);
|
||||
|
||||
const handleHostKeyContinue = useCallback(() => {
|
||||
if (!hostKeyVerification) return;
|
||||
respondHostKey(hostKeyVerification.requestId, true, false, hostKeyVerification.hostKeyInfo);
|
||||
}, [hostKeyVerification, respondHostKey]);
|
||||
|
||||
const handleHostKeyAddAndContinue = useCallback(() => {
|
||||
if (!hostKeyVerification) return;
|
||||
respondHostKey(hostKeyVerification.requestId, true, true, hostKeyVerification.hostKeyInfo);
|
||||
}, [hostKeyVerification, respondHostKey]);
|
||||
|
||||
const handleHostKeyClose = useCallback(() => {
|
||||
if (!hostKeyVerification) return;
|
||||
respondHostKey(hostKeyVerification.requestId, false, false, hostKeyVerification.hostKeyInfo);
|
||||
}, [hostKeyVerification, respondHostKey]);
|
||||
|
||||
const startTest = useCallback(
|
||||
async (authOverride?: HostConnectionTestAuthOverride | null) => {
|
||||
clearSession();
|
||||
resetState();
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const bootEpoch = (bootEpochRef.current += 1);
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
terminalSettings: terminalSettings
|
||||
? {
|
||||
keepaliveInterval: terminalSettings.keepaliveInterval,
|
||||
keepaliveCountMax: terminalSettings.keepaliveCountMax,
|
||||
verifyHostKeys: terminalSettings.verifyHostKeys,
|
||||
}
|
||||
: undefined,
|
||||
sessionId,
|
||||
bootEpoch,
|
||||
authOverride,
|
||||
});
|
||||
|
||||
if (!plan.ok) {
|
||||
setError(plan.error);
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.needsCredentialReentry) {
|
||||
setNeedsAuth(true);
|
||||
setStatus("connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
deadlineMsRef.current = resolveHostConnectionTestDeadlineMs(plan.options);
|
||||
setStatus("connecting");
|
||||
setIsAwaitingUserInput(false);
|
||||
|
||||
try {
|
||||
await backend.testConnection(plan.options);
|
||||
if (disposedRef.current || sessionIdRef.current !== sessionId) return;
|
||||
setProgressValue(100);
|
||||
setChainProgress(null);
|
||||
setStatus("connected");
|
||||
} catch (err) {
|
||||
if (disposedRef.current || sessionIdRef.current !== sessionId) return;
|
||||
setError(messageOf(err));
|
||||
setStatus("disconnected");
|
||||
}
|
||||
},
|
||||
[
|
||||
backend,
|
||||
clearSession,
|
||||
groupConfigs,
|
||||
host,
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
knownHosts,
|
||||
proxyProfiles,
|
||||
resetState,
|
||||
terminalSettings,
|
||||
],
|
||||
);
|
||||
|
||||
const submitAuth = useCallback(
|
||||
(auth: HostConnectionTestAuthOverride) => {
|
||||
void startTest(auth);
|
||||
},
|
||||
[startTest],
|
||||
);
|
||||
|
||||
const cancelTest = useCallback(() => {
|
||||
setIsCancelling(true);
|
||||
// Reject any pending host-key prompt so the dial fails fast, then clear
|
||||
// this attempt's session. An in-flight TCP/auth dial without a prompt
|
||||
// settles on its own (it is not registered as a terminal session).
|
||||
if (pendingHostKeyRequestIdRef.current) {
|
||||
void backend.respondHostKeyVerification(pendingHostKeyRequestIdRef.current, false, false);
|
||||
pendingHostKeyRequestIdRef.current = null;
|
||||
setHostKeyVerification(null);
|
||||
}
|
||||
clearSession();
|
||||
setStatus("idle");
|
||||
setNeedsAuth(false);
|
||||
setError(null);
|
||||
}, [backend, clearSession]);
|
||||
|
||||
// Connection timeout countdown + gentle progress while connecting.
|
||||
useEffect(() => {
|
||||
if (status !== "connecting" || needsAuth || hostKeyVerification || isAwaitingUserInput) {
|
||||
return;
|
||||
}
|
||||
const deadlineMs = deadlineMsRef.current;
|
||||
if (deadlineMs <= 0) return;
|
||||
setTimeLeft(Math.ceil(deadlineMs / 1000));
|
||||
const countdown = setInterval(() => {
|
||||
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
}, 1000);
|
||||
const timeout = setTimeout(() => {
|
||||
// Cancel the probe and invalidate this attempt so a late success or
|
||||
// failure from the bridge cannot overwrite the timeout outcome.
|
||||
clearSession();
|
||||
setError("Connection timed out. Please try again.");
|
||||
setStatus("disconnected");
|
||||
}, deadlineMs);
|
||||
const prog = setInterval(() => {
|
||||
setProgressValue((prev) => {
|
||||
if (prev >= 95) return prev;
|
||||
const remaining = 95 - prev;
|
||||
return Math.min(95, prev + Math.max(1, remaining * 0.15));
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
};
|
||||
}, [status, needsAuth, hostKeyVerification, isAwaitingUserInput, clearSession]);
|
||||
|
||||
return {
|
||||
state: {
|
||||
status,
|
||||
error,
|
||||
needsAuth,
|
||||
progressValue,
|
||||
chainProgress,
|
||||
progressLogs,
|
||||
hostKeyVerification,
|
||||
timeLeft,
|
||||
isAwaitingUserInput,
|
||||
isCancelling,
|
||||
} satisfies HostConnectionTestState,
|
||||
startTest,
|
||||
submitAuth,
|
||||
cancelTest,
|
||||
retry: () => void startTest(),
|
||||
handleHostKeyContinue,
|
||||
handleHostKeyAddAndContinue,
|
||||
handleHostKeyClose,
|
||||
};
|
||||
}
|
||||
@@ -133,6 +133,18 @@ export const useTerminalBackend = () => {
|
||||
return bridge.startSSHSession(options);
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (options: NetcattySSHOptions) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.testConnection) throw new Error("testConnection unavailable");
|
||||
return bridge.testConnection(options);
|
||||
}, []);
|
||||
|
||||
const cancelTestConnection = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.cancelTestConnection) return { success: false, error: "cancelTestConnection unavailable" };
|
||||
return bridge.cancelTestConnection(sessionId);
|
||||
}, []);
|
||||
|
||||
const startTelnetSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable");
|
||||
@@ -572,6 +584,8 @@ export const useTerminalBackend = () => {
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
testConnection,
|
||||
cancelTestConnection,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
@@ -655,6 +669,8 @@ export const useTerminalBackend = () => {
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
testConnection,
|
||||
cancelTestConnection,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, KnownHost, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey, TerminalSettings } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -77,6 +77,8 @@ import {
|
||||
stripBuiltInConnectionFieldsForPluginHost,
|
||||
} from "../domain/pluginConnection";
|
||||
import { PluginConnectionSection } from "./PluginConnectionSection";
|
||||
import { useHostConnectionTest } from "../application/state/useHostConnectionTest";
|
||||
import { HostConnectionTestDialog } from "./host-details/HostConnectionTestDialog";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
@@ -111,6 +113,9 @@ interface HostDetailsPanelProps {
|
||||
snippets?: Snippet[];
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: TerminalSettings;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -139,6 +144,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
onHostsChange,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -198,6 +206,28 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
|
||||
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(null);
|
||||
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const hostConnectionTest = useHostConnectionTest({
|
||||
host: form,
|
||||
hosts: allHosts,
|
||||
keys: availableKeys,
|
||||
identities,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
terminalSettings,
|
||||
onAddKnownHost,
|
||||
});
|
||||
|
||||
const handleStartTest = () => {
|
||||
setTestOpen(true);
|
||||
void hostConnectionTest.startTest();
|
||||
};
|
||||
|
||||
const handleCloseTest = () => {
|
||||
setTestOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (form.agentForwarding || form.useSshAgent === true) {
|
||||
@@ -1309,15 +1339,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<AsidePanelFooter className="flex flex-row gap-2">
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-4"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("hostConnectionTest.test")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
className="flex-1 h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</AsidePanelFooter>
|
||||
<HostConnectionTestDialog
|
||||
open={testOpen}
|
||||
onClose={handleCloseTest}
|
||||
host={form}
|
||||
keys={availableKeys}
|
||||
test={hostConnectionTest}
|
||||
/>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
} from "../domain/hostGroupPathMutations";
|
||||
@@ -664,6 +665,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddKnownHost = useCallback(
|
||||
(kh: KnownHost) => {
|
||||
onUpdateKnownHosts(upsertKnownHost(knownHosts, kh));
|
||||
},
|
||||
[knownHosts, onUpdateKnownHosts],
|
||||
);
|
||||
|
||||
const handleDuplicateHost = useCallback(
|
||||
(host: Host) => {
|
||||
setCurrentSection("hosts");
|
||||
@@ -1494,6 +1502,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keys,
|
||||
knownHosts,
|
||||
knownHostsManagerElement,
|
||||
onAddKnownHost: handleAddKnownHost,
|
||||
Label,
|
||||
lastPinnedId,
|
||||
LayoutGrid,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { Host, SSHKey } from "../../types";
|
||||
import { TerminalConnectionDialog } from "../terminal/TerminalConnectionDialog";
|
||||
import type { TerminalAuthMethod } from "../terminal/TerminalAuthDialog";
|
||||
import type { useHostConnectionTest } from "../../application/state/useHostConnectionTest";
|
||||
|
||||
type HostConnectionTest = ReturnType<typeof useHostConnectionTest>;
|
||||
|
||||
export interface HostConnectionTestDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
test: HostConnectionTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-editor "test connection" overlay. It reuses the terminal's own
|
||||
* connection dialog verbatim (progress bar, per-hop chain header, auth prompt,
|
||||
* and host-key verification) and keeps it mounted on success so the final
|
||||
* "Connected" state shows through the same dialog with retry/close.
|
||||
*/
|
||||
export const HostConnectionTestDialog: React.FC<HostConnectionTestDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
host,
|
||||
keys,
|
||||
test,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { state } = test;
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
// Auth re-entry form state (shown when state.needsAuth is true).
|
||||
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
|
||||
const [authUsername, setAuthUsername] = useState(host.username || "root");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
|
||||
const [authPassphrase, setAuthPassphrase] = useState("");
|
||||
const [showAuthPassphrase, setShowAuthPassphrase] = useState(false);
|
||||
const [showAuthPassword, setShowAuthPassword] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (state.status === "connecting") {
|
||||
test.cancelTest();
|
||||
}
|
||||
setShowLogs(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const authIsValid =
|
||||
authMethod === "password" ? authPassword.length > 0 : Boolean(authKeyId);
|
||||
|
||||
const submitAuth = () => {
|
||||
if (!authIsValid) return;
|
||||
test.submitAuth({
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
...(authMethod === "password"
|
||||
? { password: authPassword }
|
||||
: { keyId: authKeyId ?? undefined, passphrase: authPassphrase || undefined }),
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const dialogStatus =
|
||||
state.status === "connecting"
|
||||
? "connecting"
|
||||
: state.status === "connected"
|
||||
? "connected"
|
||||
: "disconnected";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
style={
|
||||
{
|
||||
// The terminal dialog's card/backdrop colors resolve through
|
||||
// --terminal-ui-bg/--terminal-ui-fg (full colors set inside a
|
||||
// terminal pane). Outside a terminal those variables are unset and
|
||||
// the fallback `var(--background)` is only a raw HSL triplet, which
|
||||
// is invalid inside color-mix() and silently drops the card border,
|
||||
// background, and backdrop dim. Pin them to the app theme here so
|
||||
// the reused dialog keeps its border/shadow/dim.
|
||||
"--terminal-ui-bg": "hsl(var(--background))",
|
||||
"--terminal-ui-fg": "hsl(var(--foreground))",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={dialogStatus}
|
||||
error={state.error}
|
||||
progressValue={state.progressValue}
|
||||
chainProgress={state.chainProgress}
|
||||
needsAuth={state.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
authUsername,
|
||||
setAuthUsername,
|
||||
authPassword,
|
||||
setAuthPassword,
|
||||
authKeyId,
|
||||
setAuthKeyId,
|
||||
authPassphrase,
|
||||
setAuthPassphrase,
|
||||
showAuthPassphrase,
|
||||
setShowAuthPassphrase,
|
||||
showAuthPassword,
|
||||
setShowAuthPassword,
|
||||
authRetryMessage: state.needsAuth
|
||||
? t("hostConnectionTest.credentialsUnavailable")
|
||||
: null,
|
||||
onSubmit: submitAuth,
|
||||
onSubmitWithoutSave: submitAuth,
|
||||
onCancel: handleClose,
|
||||
isValid: authIsValid,
|
||||
}}
|
||||
hostKeyVerification={
|
||||
state.hostKeyVerification
|
||||
? {
|
||||
hostKeyInfo: state.hostKeyVerification.hostKeyInfo,
|
||||
onClose: test.handleHostKeyClose,
|
||||
onContinue: test.handleHostKeyContinue,
|
||||
onAddAndContinue: test.handleHostKeyAddAndContinue,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
progressProps={{
|
||||
timeLeft: state.timeLeft,
|
||||
isAwaitingUserInput: state.isAwaitingUserInput,
|
||||
isCancelling: state.isCancelling,
|
||||
progressLogs: state.progressLogs,
|
||||
onCancelConnect: handleClose,
|
||||
onCloseSession: handleClose,
|
||||
onRetry: () => void test.retry(),
|
||||
reconnectLabel: t("hostConnectionTest.retry"),
|
||||
closeLabel: t("common.close"),
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default HostConnectionTestDialog;
|
||||
@@ -21,6 +21,7 @@ export interface TerminalConnectionProgressProps {
|
||||
onCloseSession: () => void;
|
||||
onRetry?: () => void;
|
||||
reconnectLabel?: string;
|
||||
closeLabel?: string;
|
||||
}
|
||||
|
||||
export interface TerminalConnectionLogListProps {
|
||||
@@ -65,6 +66,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
onCloseSession,
|
||||
onRetry,
|
||||
reconnectLabel,
|
||||
closeLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -81,6 +83,13 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
: t('terminal.progress.timeoutIn', { seconds: timeLeft })}
|
||||
</span>
|
||||
</>
|
||||
) : status === 'connected' ? (
|
||||
<>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-emerald-500">
|
||||
{t('terminal.progress.connected')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
|
||||
@@ -105,7 +114,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
|
||||
{t('terminal.toolbar.closeSession')}
|
||||
{closeLabel ?? t('terminal.toolbar.closeSession')}
|
||||
</Button>
|
||||
{onRetry && (
|
||||
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
|
||||
|
||||
@@ -371,6 +371,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
knownHosts,
|
||||
NotebookText,
|
||||
NotesManager,
|
||||
onAddKnownHost,
|
||||
onOpenHostFromNote,
|
||||
onOpenNoteIdHandled,
|
||||
onOpenSnippetIdHandled,
|
||||
@@ -1470,6 +1471,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
knownHosts={knownHosts}
|
||||
onAddKnownHost={onAddKnownHost}
|
||||
terminalSettings={terminalSettings}
|
||||
onSave={(host) => {
|
||||
const latestHost = hosts.find(
|
||||
(entry: { id: string }) => entry.id === host.id,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildHostConnectionTestPlan,
|
||||
formatConnectionTestProgressLog,
|
||||
resolveHostConnectionTestDeadlineMs,
|
||||
} from "./hostConnectionTest";
|
||||
import type { Host, SSHKey } from "./models";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const key = (overrides: Partial<SSHKey> = {}): SSHKey => ({
|
||||
id: "key-1",
|
||||
label: "id_ed25519",
|
||||
type: "ED25519",
|
||||
privateKey: "PRIVATE KEY MATERIAL",
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const base = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
sessionId: "test-session",
|
||||
bootEpoch: 7,
|
||||
};
|
||||
|
||||
test("builds password-auth options without proxy or chain", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ password: "secret", authMethod: "password" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.needsCredentialReentry, false);
|
||||
assert.equal(plan.options.hostname, "target.example.test");
|
||||
assert.equal(plan.options.port, 22);
|
||||
assert.equal(plan.options.username, "alice");
|
||||
assert.equal(plan.options.password, "secret");
|
||||
assert.equal(plan.options.authMethod, "password");
|
||||
assert.equal(plan.options.proxy, undefined);
|
||||
assert.equal(plan.options.jumpHosts, undefined);
|
||||
assert.equal(plan.options.sessionId, "test-session");
|
||||
assert.equal(plan.options.bootEpoch, 7);
|
||||
assert.equal(plan.options.verifyHostKeys, true);
|
||||
});
|
||||
|
||||
test("builds key-auth options with the resolved private key", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ identityFileId: "key-1" }),
|
||||
keys: [key()],
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.options.privateKey, "PRIVATE KEY MATERIAL");
|
||||
assert.equal(plan.options.keyId, "key-1");
|
||||
assert.equal(plan.options.password, undefined);
|
||||
});
|
||||
|
||||
test("resolves a jump-host chain by id", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [
|
||||
host({
|
||||
id: "jump-1",
|
||||
label: "Bastion",
|
||||
hostname: "jump.example.test",
|
||||
username: "root",
|
||||
password: "jump-pass",
|
||||
authMethod: "password",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.options.jumpHosts?.length, 1);
|
||||
assert.equal(plan.options.jumpHosts?.[0].hostname, "jump.example.test");
|
||||
assert.equal(plan.options.jumpHosts?.[0].username, "root");
|
||||
assert.equal(plan.options.jumpHosts?.[0].password, "jump-pass");
|
||||
});
|
||||
|
||||
test("fails when a configured chain host is missing", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ hostChain: { hostIds: ["missing-1"] } }),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, false);
|
||||
if (plan.ok) return;
|
||||
assert.match(plan.error, /jump host is missing/);
|
||||
});
|
||||
|
||||
test("flags an undecryptable primary password for re-entry and strips it", () => {
|
||||
const encrypted = `enc:v1:${Buffer.from(`v10${"\0".repeat(16)}`).toString("base64")}`;
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ password: encrypted, authMethod: "password" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.needsCredentialReentry, true);
|
||||
assert.equal(plan.options.password, undefined);
|
||||
});
|
||||
|
||||
test("sanitizes an encrypted proxy credential into a failure plan", () => {
|
||||
const encrypted = `enc:v1:${Buffer.from(`v10${"\0".repeat(16)}`).toString("base64")}`;
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.test",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: encrypted,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, false);
|
||||
if (plan.ok) return;
|
||||
assert.match(plan.error, /Proxy credentials cannot be decrypted/);
|
||||
});
|
||||
|
||||
test("pins auto + password to a single password attempt", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ password: "secret", authPolicyVersion: 1 }),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.options.authMethod, "password");
|
||||
assert.equal(plan.options.password, "secret");
|
||||
});
|
||||
|
||||
test("uses short test-only connection timeouts", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ password: "secret", authMethod: "password" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.options.sshTcpConnectTimeoutMs, 8000);
|
||||
assert.equal(plan.options.sshAuthReadyTimeoutMs, 15000);
|
||||
});
|
||||
|
||||
test("formats single-hop progress log lines", () => {
|
||||
assert.equal(
|
||||
formatConnectionTestProgressLog({ hop: 1, total: 1, label: "target.example.test", phase: "connecting" }),
|
||||
"Connecting to target.example.test...",
|
||||
);
|
||||
assert.equal(
|
||||
formatConnectionTestProgressLog({ hop: 1, total: 1, label: "target.example.test", phase: "authenticated" }),
|
||||
"target.example.test - Authenticated",
|
||||
);
|
||||
});
|
||||
|
||||
test("formats jump-host progress log lines with a hop prefix", () => {
|
||||
assert.equal(
|
||||
formatConnectionTestProgressLog({ hop: 1, total: 2, label: "jump.example.test", phase: "tcp-connected" }),
|
||||
"[1/2] jump.example.test - TCP connected",
|
||||
);
|
||||
assert.equal(
|
||||
formatConnectionTestProgressLog({ hop: 2, total: 2, label: "target.example.test", phase: "authenticating" }),
|
||||
"[2/2] target.example.test - Key exchange complete",
|
||||
);
|
||||
assert.equal(
|
||||
formatConnectionTestProgressLog({ hop: 2, total: 2, label: "target.example.test", phase: "error", error: "boom" }),
|
||||
"[2/2] target.example.test - Error: boom",
|
||||
);
|
||||
});
|
||||
|
||||
test("test deadline follows the probe timeouts, not the host's terminal timeouts", () => {
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
...base,
|
||||
host: host({ password: "secret", sshTcpConnectTimeoutSeconds: 3, sshAuthReadyTimeoutSeconds: 5 }),
|
||||
});
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
|
||||
// 8s TCP + 15s auth sent to the bridge, plus grace — never the host's 5s.
|
||||
assert.equal(resolveHostConnectionTestDeadlineMs(plan.options), 8000 + 15000 + 2000);
|
||||
});
|
||||
|
||||
test("test deadline adds every jump hop's dial and auth timeouts", () => {
|
||||
const deadline = resolveHostConnectionTestDeadlineMs({
|
||||
sshTcpConnectTimeoutMs: 8000,
|
||||
sshAuthReadyTimeoutMs: 15000,
|
||||
jumpHosts: [
|
||||
{ sshTcpConnectTimeoutMs: 20000, sshAuthReadyTimeoutMs: 120000 },
|
||||
{ sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 2000 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(deadline, 8000 + 15000 + 20000 + 120000 + 1000 + 2000 + 2000);
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "./models";
|
||||
import { resolveHostAuth, resolveBridgeSshAgentAuth } from "./sshAuth";
|
||||
import { resolveHostKeepalive } from "./host";
|
||||
import { resolveHostSshConnectionTimeouts } from "./sshConnectionTimeouts";
|
||||
import {
|
||||
hasUnreadableProxyCredential,
|
||||
hasUsableProxyConfig,
|
||||
resolveProxyConfigAuth,
|
||||
} from "./proxyProfiles";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "./credentials";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalChainHosts } from "./terminalHostResolution";
|
||||
|
||||
/** Test-mode connection timeouts — a probe should fail fast, not wait out the
|
||||
* terminal's full keep-alive/auth window. */
|
||||
const TEST_TCP_CONNECT_TIMEOUT_MS = 8000;
|
||||
const TEST_AUTH_READY_TIMEOUT_MS = 15000;
|
||||
/** Slack added on top of the bridge timeouts so the main process reports its
|
||||
* own (more specific) timeout error before the renderer safety net fires. */
|
||||
const TEST_DEADLINE_GRACE_MS = 2000;
|
||||
|
||||
type TimeoutCarrier = {
|
||||
sshTcpConnectTimeoutMs?: number;
|
||||
sshAuthReadyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
const positiveMs = (value: unknown, fallback: number): number => (
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
|
||||
);
|
||||
|
||||
/**
|
||||
* Renderer-side deadline for a connection test, derived from the exact
|
||||
* timeouts sent to the bridge (not the edited host's terminal timeouts).
|
||||
* Each hop dials TCP and then waits for auth sequentially, so the worst case
|
||||
* is the sum over every jump host plus the target, plus a small grace.
|
||||
*/
|
||||
export const resolveHostConnectionTestDeadlineMs = (
|
||||
options: TimeoutCarrier & { jumpHosts?: TimeoutCarrier[] },
|
||||
): number => {
|
||||
const hopMs = (hop: TimeoutCarrier) =>
|
||||
positiveMs(hop.sshTcpConnectTimeoutMs, TEST_TCP_CONNECT_TIMEOUT_MS)
|
||||
+ positiveMs(hop.sshAuthReadyTimeoutMs, TEST_AUTH_READY_TIMEOUT_MS);
|
||||
const chainMs = (options.jumpHosts ?? []).reduce((sum, hop) => sum + hopMs(hop), 0);
|
||||
return chainMs + hopMs(options) + TEST_DEADLINE_GRACE_MS;
|
||||
};
|
||||
|
||||
/** Build the human-readable progress log line for one chain-progress event,
|
||||
* mirroring the terminal's per-hop "Connecting to … / TCP connected / Key
|
||||
* exchange complete / Authenticated / …" lines (with the `[hop/total]` prefix
|
||||
* for jump-host chains). */
|
||||
export const formatConnectionTestProgressLog = (input: {
|
||||
hop: number;
|
||||
total: number;
|
||||
label: string;
|
||||
phase: string;
|
||||
error?: string;
|
||||
}): string => {
|
||||
const { hop, total, label, phase, error } = input;
|
||||
const prefix = total > 1 ? `[${hop}/${total}] ` : "";
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return `${prefix}Connecting to ${label}...`;
|
||||
case "tcp-connected":
|
||||
return `${prefix}${label} - TCP connected`;
|
||||
case "authenticating":
|
||||
return `${prefix}${label} - Key exchange complete`;
|
||||
case "auth-attempt":
|
||||
if (error?.endsWith("rejected")) return `${prefix}${label} - ✗ ${error}`;
|
||||
if (error === "all methods exhausted") return `${prefix}${label} - ✗ All authentication methods exhausted`;
|
||||
if (error === "waiting for user input..." || error === "user responded") return `${prefix}${label} - ${error}`;
|
||||
return `${prefix}${label} - Trying ${error ?? "authentication"}...`;
|
||||
case "authenticated":
|
||||
return `${prefix}${label} - Authenticated`;
|
||||
case "connected":
|
||||
return `${prefix}${label} - Connected`;
|
||||
case "forwarding":
|
||||
return `${prefix}${label} - Forwarding...`;
|
||||
case "shell":
|
||||
return `${prefix}Opening shell...`;
|
||||
case "error":
|
||||
return `${prefix}${label} - ${error ? `Error: ${error}` : "Error"}`;
|
||||
default:
|
||||
return `${prefix}${label} - ${phase}${error ? `: ${error}` : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
export type HostConnectionTestSettings = Pick<
|
||||
TerminalSettings,
|
||||
"keepaliveInterval" | "keepaliveCountMax" | "verifyHostKeys"
|
||||
>;
|
||||
|
||||
export type HostConnectionTestAuthOverride = {
|
||||
authMethod?: Host["authMethod"];
|
||||
username?: string;
|
||||
password?: string;
|
||||
keyId?: string;
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export type BuildHostConnectionTestOptionsInput = {
|
||||
/** The (possibly unsaved) host draft to test. */
|
||||
host: Host;
|
||||
/** All vault hosts, used to resolve jump-host chain entries by id. */
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
terminalSettings?: HostConnectionTestSettings;
|
||||
sessionId: string;
|
||||
/** Boot generation for correlating host-key prompts with this attempt. */
|
||||
bootEpoch: number;
|
||||
/** Re-entered credentials after an undecryptable placeholder was detected. */
|
||||
authOverride?: HostConnectionTestAuthOverride | null;
|
||||
};
|
||||
|
||||
export type HostConnectionTestPlan =
|
||||
| {
|
||||
ok: true;
|
||||
options: NetcattySSHOptions;
|
||||
/**
|
||||
* True when the primary credential is an undecryptable placeholder, so
|
||||
* the caller should prompt for re-entry (password/key) before dialing.
|
||||
*/
|
||||
needsCredentialReentry: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
const buildTestJumpHost = (
|
||||
chainHost: Host,
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
settings: HostConnectionTestSettings,
|
||||
): NetcattyJumpHost => {
|
||||
const jumpAuth = resolveHostAuth({ host: chainHost, keys, identities });
|
||||
const jumpKey = jumpAuth.key;
|
||||
const jumpAllowsLocalIdentityFallback = !jumpAuth.keyId;
|
||||
const jumpReferenceKeyPath = jumpAuth.authMethod === "password"
|
||||
? undefined
|
||||
: jumpKey?.source === "reference"
|
||||
? jumpKey.filePath
|
||||
: undefined;
|
||||
const jumpIdentityFilePaths = jumpAuth.authMethod === "password"
|
||||
? undefined
|
||||
: jumpReferenceKeyPath
|
||||
? [jumpReferenceKeyPath]
|
||||
: jumpAllowsLocalIdentityFallback
|
||||
? chainHost.identityFilePaths
|
||||
: undefined;
|
||||
const hopKeepalive = resolveHostKeepalive(chainHost, settings);
|
||||
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(chainHost);
|
||||
|
||||
return {
|
||||
hostname: chainHost.hostname,
|
||||
hostId: chainHost.id,
|
||||
port: chainHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!chainHost.requiresMfa,
|
||||
password: sanitizeCredentialValue(jumpAuth.password),
|
||||
privateKey: jumpKey?.source === "reference" ? undefined : sanitizeCredentialValue(jumpKey?.privateKey),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: sanitizeCredentialValue(jumpAuth.passphrase || jumpKey?.passphrase),
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: chainHost.label,
|
||||
proxy: hasUsableProxyConfig(chainHost.proxyConfig)
|
||||
? resolveProxyConfigAuth(chainHost.proxyConfig, identities)
|
||||
: undefined,
|
||||
identityFilePaths: jumpIdentityFilePaths,
|
||||
...resolveBridgeSshAgentAuth(chainHost, jumpKey, jumpAuth.authMethod),
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
verifyHostKeys: settings.verifyHostKeys,
|
||||
legacyAlgorithms: chainHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: chainHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: chainHost.algorithms,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the SSH connection options for a host-editor "test connection".
|
||||
*
|
||||
* Faithfully reuses the terminal's resolution stack (effective host with group
|
||||
* defaults + materialized proxy profile, auth resolution, jump-host chain,
|
||||
* keepalive, timeouts, and algorithm settings) but omits the shell-only fields
|
||||
* (PTY, session log, X11, sudo autofill, startup command). The caller dials
|
||||
* through `netcatty:test-connection`, which authenticates and tears down
|
||||
* without opening a shell.
|
||||
*/
|
||||
export const buildHostConnectionTestPlan = (
|
||||
input: BuildHostConnectionTestOptionsInput,
|
||||
): HostConnectionTestPlan => {
|
||||
const {
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities = [],
|
||||
knownHosts,
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
sessionId,
|
||||
bootEpoch,
|
||||
authOverride = null,
|
||||
} = input;
|
||||
const settings: HostConnectionTestSettings = {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
...(input.terminalSettings ?? {}),
|
||||
};
|
||||
|
||||
const effectiveHost = resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
});
|
||||
|
||||
const resolvedAuth = resolveHostAuth({
|
||||
host: effectiveHost,
|
||||
keys,
|
||||
identities,
|
||||
override: authOverride ?? null,
|
||||
});
|
||||
|
||||
const chainHosts = resolveTerminalChainHosts({
|
||||
host: effectiveHost,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
});
|
||||
const requestedChainIds = effectiveHost.hostChain?.hostIds ?? [];
|
||||
if (requestedChainIds.length > 0 && chainHosts.length !== requestedChainIds.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "A configured jump host is missing. Open host settings and repair the jump host chain.",
|
||||
};
|
||||
}
|
||||
|
||||
// A proxy whose saved credential cannot be decrypted cannot be dialed; fail
|
||||
// before opening the test dialog's progress rather than timing out mid-dial.
|
||||
if (hasUnreadableProxyCredential(effectiveHost.proxyConfig, identities)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
};
|
||||
}
|
||||
|
||||
const key = resolvedAuth.key;
|
||||
const effectiveUsername = resolvedAuth.username || "root";
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
|
||||
|
||||
// A connection test should exercise the specific credential the user
|
||||
// configured, not the full "auto" discovery (agent + every ~/.ssh key +
|
||||
// password + keyboard-interactive fallback). When the host resolved to
|
||||
// "auto" and a password is present without a selected key, pin it to
|
||||
// "password" so the bridge tries exactly one method.
|
||||
const authMethod: Host["authMethod"] =
|
||||
resolvedAuth.authMethod === "auto" && effectivePassword && !key
|
||||
? "password"
|
||||
: resolvedAuth.authMethod;
|
||||
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
|
||||
const targetReferenceKeyPath = key?.source === "reference" ? key.filePath : undefined;
|
||||
const targetIdentityFilePaths = authMethod === "password"
|
||||
? undefined
|
||||
: targetReferenceKeyPath
|
||||
? [targetReferenceKeyPath]
|
||||
: allowsLocalIdentityFallback
|
||||
? effectiveHost.identityFilePaths
|
||||
: undefined;
|
||||
|
||||
const agentAuth = resolveBridgeSshAgentAuth(effectiveHost, key, authMethod);
|
||||
const usesSystemAgent = agentAuth.useSshAgent === true;
|
||||
const hasKeyMaterial = usesSystemAgent || Boolean(
|
||||
(sanitizeCredentialValue(key?.privateKey) || targetIdentityFilePaths?.length)
|
||||
&& (authMethod !== "password" || effectiveHost.useSshAgent === true),
|
||||
);
|
||||
const hasPassword = Boolean(effectivePassword);
|
||||
|
||||
const needsCredentialReentry =
|
||||
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword)
|
||||
|| (
|
||||
authMethod !== "password"
|
||||
&& authMethod !== "auto"
|
||||
&& hasEncryptedPrimaryKey
|
||||
&& !hasKeyMaterial
|
||||
&& !hasPassword
|
||||
);
|
||||
|
||||
const keepalive = resolveHostKeepalive(effectiveHost, settings);
|
||||
const proxyConfig = hasUsableProxyConfig(effectiveHost.proxyConfig)
|
||||
? resolveProxyConfigAuth(effectiveHost.proxyConfig, identities)
|
||||
: undefined;
|
||||
|
||||
const options: NetcattySSHOptions = {
|
||||
sessionId,
|
||||
hostLabel: effectiveHost.label,
|
||||
hostname: effectiveHost.hostname,
|
||||
hostId: effectiveHost.id,
|
||||
username: effectiveUsername,
|
||||
authMethod,
|
||||
requiresMfa: !!effectiveHost.requiresMfa,
|
||||
port: effectiveHost.port || 22,
|
||||
password: effectivePassword,
|
||||
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
|
||||
certificate: key?.certificate,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: key?.id,
|
||||
keySource: key?.source,
|
||||
passphrase: key ? (effectivePassphrase || sanitizeCredentialValue(key.passphrase)) : undefined,
|
||||
legacyAlgorithms: effectiveHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveHost.algorithms,
|
||||
charset: effectiveHost.charset,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: chainHosts.length > 0
|
||||
? chainHosts.map((chainHost) => buildTestJumpHost(chainHost, keys, identities, settings))
|
||||
: undefined,
|
||||
keepaliveInterval: keepalive.interval,
|
||||
keepaliveCountMax: keepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: TEST_TCP_CONNECT_TIMEOUT_MS,
|
||||
sshAuthReadyTimeoutMs: TEST_AUTH_READY_TIMEOUT_MS,
|
||||
verifyHostKeys: settings.verifyHostKeys,
|
||||
bootEpoch,
|
||||
identityFilePaths: targetIdentityFilePaths,
|
||||
...agentAuth,
|
||||
knownHosts,
|
||||
};
|
||||
|
||||
return { ok: true, options, needsCredentialReentry };
|
||||
};
|
||||
@@ -467,6 +467,30 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (eventName === "jump-mfa-prompt") {
|
||||
// Bastion that demands a human-entered OTP via keyboard-interactive.
|
||||
this.keyboardInteractiveResponses = [];
|
||||
this.emit("connect");
|
||||
this.emit("handshake");
|
||||
this.emit(
|
||||
"keyboard-interactive",
|
||||
"Bastion MFA",
|
||||
"Enter the one-time code from your authenticator.",
|
||||
"",
|
||||
[{ prompt: "Verification code:", echo: true }],
|
||||
(responses) => {
|
||||
this.keyboardInteractiveResponses.push(responses);
|
||||
if (responses?.[0] === "123456") {
|
||||
this.emit("ready");
|
||||
return;
|
||||
}
|
||||
const err = new Error("All configured authentication methods failed");
|
||||
err.level = "client-authentication";
|
||||
this.emit("error", err);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (eventName === "auth-error") {
|
||||
const err = new Error("All configured authentication methods failed");
|
||||
err.level = "client-authentication";
|
||||
@@ -549,6 +573,12 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) {
|
||||
utils: { parseKey: () => options.parseKeyResult || new Error("no key parse needed") },
|
||||
};
|
||||
}
|
||||
if (options.macLocalNetworkAccess && request.endsWith("macLocalNetworkAccess.cjs")) {
|
||||
return {
|
||||
...originalLoad.call(this, request, parent, isMain),
|
||||
...options.macLocalNetworkAccess,
|
||||
};
|
||||
}
|
||||
if (request === "./netcattyAgent.cjs" || request.endsWith("/netcattyAgent.cjs")) {
|
||||
return { NetcattyAgent: class MockNetcattyAgent {} };
|
||||
}
|
||||
@@ -2035,3 +2065,151 @@ test("cancelled encrypted-key retry emits one final exit", async (t) => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirrors the renderer gate in application/app/useAppStartupEffects.ts
|
||||
* (shouldQueueKeyboardInteractiveRequest): terminal-scoped prompts whose
|
||||
* session is not in the renderer session list are auto-cancelled with an
|
||||
* empty answer; other prompts are shown and answered by the user.
|
||||
*/
|
||||
function makeRendererEmulatingSender(rendererSessionIds, answer) {
|
||||
const sender = makeSender();
|
||||
const send = sender.send.bind(sender);
|
||||
sender.send = (channel, payload) => {
|
||||
send(channel, payload);
|
||||
if (channel !== "netcatty:keyboard-interactive") return;
|
||||
const rejected = payload.scope === "terminal" && !rendererSessionIds.has(payload.sessionId);
|
||||
keyboardInteractiveHandler.handleResponse(
|
||||
{ sender },
|
||||
rejected
|
||||
? { requestId: payload.requestId, responses: [], cancelled: true }
|
||||
: { requestId: payload.requestId, responses: [answer], cancelled: false },
|
||||
);
|
||||
};
|
||||
return sender;
|
||||
}
|
||||
|
||||
function findBastionClient(MockSSHClient) {
|
||||
return MockSSHClient.instances.find((client) => Array.isArray(client.keyboardInteractiveResponses));
|
||||
}
|
||||
|
||||
function jumpMfaPayload(sessionId) {
|
||||
return {
|
||||
sessionId,
|
||||
hostname: "target.example",
|
||||
username: "target-user",
|
||||
authMethod: "password",
|
||||
password: "target-password",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
jumpHosts: [{
|
||||
hostname: "bastion.example",
|
||||
username: "jump-user",
|
||||
port: 22,
|
||||
label: "Bastion",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
test("connection test shows and answers jump-host MFA prompts, then connects", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["jump-mfa-prompt", "ready"],
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
// A connection test never registers a renderer terminal session.
|
||||
const sender = makeRendererEmulatingSender(new Set(), "123456");
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:test-connection")(
|
||||
{ sender },
|
||||
jumpMfaPayload("jump-mfa-test-session"),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { sessionId: "jump-mfa-test-session", testResult: "connected" });
|
||||
const prompts = sender.sent.filter((message) => message.channel === "netcatty:keyboard-interactive");
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.equal(prompts[0].payload.scope, "external");
|
||||
assert.equal(prompts[0].payload.prompts[0].prompt, "Verification code:");
|
||||
assert.deepEqual(findBastionClient(MockSSHClient).keyboardInteractiveResponses, [["123456"]]);
|
||||
assert.equal(
|
||||
sender.sent.some((message) => (
|
||||
message.channel === "netcatty:test:result"
|
||||
&& message.payload.sessionId === "jump-mfa-test-session"
|
||||
&& message.payload.ok === true
|
||||
)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal SSH still answers jump-host MFA prompts through the terminal scope", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["jump-mfa-prompt", "ready"],
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const sender = makeRendererEmulatingSender(new Set(["jump-mfa-terminal-session"]), "123456");
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:start")(
|
||||
{ sender },
|
||||
jumpMfaPayload("jump-mfa-terminal-session"),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { sessionId: "jump-mfa-terminal-session" });
|
||||
const prompts = sender.sent.filter((message) => message.channel === "netcatty:keyboard-interactive");
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.equal(prompts[0].payload.scope, "terminal");
|
||||
assert.deepEqual(findBastionClient(MockSSHClient).keyboardInteractiveResponses, [["123456"]]);
|
||||
});
|
||||
|
||||
test("connection test runs the macOS Local Network preflight before dialing", async (t) => {
|
||||
const order = [];
|
||||
const probedPayloads = [];
|
||||
const attachedProbeResults = [];
|
||||
const originalMacAccess = require("./macLocalNetworkAccess.cjs");
|
||||
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["ready"],
|
||||
macLocalNetworkAccess: {
|
||||
ensureMacLocalNetworkAccess: async (payload) => {
|
||||
order.push("probe");
|
||||
probedPayloads.push(payload);
|
||||
return { hostname: "192.168.1.20" };
|
||||
},
|
||||
attachMacLocalNetworkProbeResult: (options, probeResult) => {
|
||||
attachedProbeResults.push(probeResult);
|
||||
return originalMacAccess.attachMacLocalNetworkProbeResult(options, probeResult);
|
||||
},
|
||||
},
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const sender = makeSender();
|
||||
const send = sender.send.bind(sender);
|
||||
sender.send = (channel, payload) => {
|
||||
if (channel === "netcatty:chain:progress" && payload.status === "connecting") order.push("dial");
|
||||
send(channel, payload);
|
||||
};
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:test-connection")(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "mac-preflight-test-session",
|
||||
hostname: "nas.local",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { sessionId: "mac-preflight-test-session", testResult: "connected" });
|
||||
assert.equal(probedPayloads.length, 1);
|
||||
assert.equal(probedPayloads[0].hostname, "nas.local");
|
||||
assert.deepEqual(attachedProbeResults, [{ hostname: "192.168.1.20" }]);
|
||||
assert.equal(order[0], "probe", "the Local Network preflight must run before the SSH dial");
|
||||
});
|
||||
@@ -991,7 +991,7 @@ const startSessionApi = createStartSessionApi({
|
||||
get removeRemoteFiles() { return removeRemoteFiles; },
|
||||
get restoreRemoteModes() { return restoreRemoteModes; },
|
||||
});
|
||||
const { startSSHSession } = startSessionApi;
|
||||
const { startSSHSession, cancelTestConnection } = startSessionApi;
|
||||
const { createExecCommandApi } = require("./sshBridge/execCommand.cjs");
|
||||
const execCommandApi = createExecCommandApi({
|
||||
SSHClient, NetcattyAgent, randomUUID, console, setTimeout, clearTimeout, Error,
|
||||
@@ -1361,6 +1361,31 @@ async function startSSHSessionWrapper(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless "test connection" for the host editor. Establishes a fresh SSH
|
||||
* connection through the same dial/auth/host-key/jump-host/proxy machinery as
|
||||
* a terminal session (`startSSHSession`), reports success the moment
|
||||
* authentication completes, and tears down without ever opening a shell.
|
||||
* Connection reuse and retry wrappers are deliberately skipped so a test is
|
||||
* always an honest fresh dial. The renderer consumes `netcatty:test:result`
|
||||
* plus the shared `netcatty:chain:progress` / host-key / keyboard-interactive
|
||||
* channels.
|
||||
*/
|
||||
async function testConnection(event, options) {
|
||||
const payload = options && typeof options === "object" ? options : {};
|
||||
const sessionId = payload.sessionId || require("node:crypto").randomUUID();
|
||||
// Same macOS Local Network preflight as terminal starts (#2663 / #2673):
|
||||
// trigger the TCC prompt from the main process and carry the resolved
|
||||
// first-hop address so private-LAN / split-DNS failures get annotated.
|
||||
const probeResult = await ensureMacLocalNetworkAccess(payload);
|
||||
return startSSHSession(event, {
|
||||
...attachMacLocalNetworkProbeResult(payload, probeResult),
|
||||
sessionId,
|
||||
testMode: true,
|
||||
reuseTransport: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current working directory from an active SSH session
|
||||
* This sends 'pwd' to the existing shell stream and captures the output
|
||||
@@ -1517,6 +1542,8 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
hostKeyVerifier.registerHandler(ipcMain);
|
||||
}
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:test-connection", testConnection);
|
||||
ipcMain.handle("netcatty:test-connection:cancel", (_event, sessionId) => cancelTestConnection(sessionId));
|
||||
ipcMain.handle("netcatty:sshDebugLog:info", getSshDebugLogInfo);
|
||||
ipcMain.handle("netcatty:sshDebugLog:openDir", openSshDebugLogDir);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async (_event, options = {}) => {
|
||||
|
||||
@@ -47,6 +47,36 @@ function buildInteractiveShellOptions(options) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* In-flight headless "test connection" transports, keyed by sessionId. A test
|
||||
* connection never enters the terminal `sessions` registry (it opens no shell),
|
||||
* so a dedicated registry is needed so the renderer can cancel an in-flight
|
||||
* dial. Entries are removed when the connection closes.
|
||||
*/
|
||||
const testConnectionTransports = new Map();
|
||||
|
||||
function registerTestConnectionTransport(sessionId, transport) {
|
||||
testConnectionTransports.set(sessionId, transport);
|
||||
}
|
||||
|
||||
function unregisterTestConnectionTransport(sessionId) {
|
||||
testConnectionTransports.delete(sessionId);
|
||||
}
|
||||
|
||||
function cancelTestConnection(sessionId) {
|
||||
const transport = testConnectionTransports.get(sessionId);
|
||||
if (!transport) return { success: false, error: "Test connection not found" };
|
||||
const end = (item) => {
|
||||
try { item?.end?.(); } catch { /* ignore */ }
|
||||
try { item?.destroy?.(); } catch { /* ignore */ }
|
||||
};
|
||||
for (const chain of transport.chainConnections || []) end(chain);
|
||||
end(transport.pendingConn);
|
||||
end(transport.socket);
|
||||
end(transport.conn);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out netcatty:exit to the primary contents plus any attach-home owner
|
||||
* (AI observe popup rebind) so neither side is left stale.
|
||||
@@ -208,6 +238,31 @@ async function prepareAgentForwardingOptions(options, resolveForwardingAgentSock
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the one-shot outcome for a headless "test connection" attempt.
|
||||
*
|
||||
* A connection test authenticates (and, for Telnet, completes auto-login)
|
||||
* and then tears down without ever opening a shell channel. There is no
|
||||
* persistent terminal session to carry progress, so success/failure is
|
||||
* reported through a dedicated `netcatty:test:result` event consumed by the
|
||||
* host-editor test dialog. The same `netcatty:chain:progress`,
|
||||
* `netcatty:host-key:verify`, `netcatty:keyboard-interactive`, and
|
||||
* `netcatty:passphrase-auth-failed` channels keep flowing unchanged so the
|
||||
* dialog reuses the terminal's progress/log/auth/host-key UI verbatim.
|
||||
*/
|
||||
function sendConnectionTestResult(sender, sessionId, ok, error) {
|
||||
if (!sender || sender.isDestroyed?.()) return;
|
||||
try {
|
||||
sender.send("netcatty:test:result", {
|
||||
sessionId,
|
||||
ok: Boolean(ok),
|
||||
...(error ? { error: String(error) } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Renderer was destroyed mid-send; nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function createStartSessionApi(ctx) {
|
||||
with (ctx) {
|
||||
const listInteractiveShellPids = (conn) => listInteractiveShellPidsShared(conn, {
|
||||
@@ -1233,6 +1288,7 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
async function startSSHSession(event, options) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
const testMode = options.testMode === true;
|
||||
const sender = event.sender;
|
||||
const log = createSshDiagnosticLogger(
|
||||
!!options.sshDebugLogEnabled || process.env.NETCATTY_SSH_DEBUG === "1",
|
||||
@@ -1449,6 +1505,16 @@ function createStartSessionApi(ctx) {
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
// Register the test transport BEFORE the chain/proxy dial so an
|
||||
// in-flight bastion/proxy attempt can be cancelled. `connectThroughChain`
|
||||
// updates `pendingConn` and `chainConnections` via `options._tunnelRef`
|
||||
// as each hop/proxy socket is established.
|
||||
let testTransport = null;
|
||||
if (testMode) {
|
||||
testTransport = { conn, socket: null, pendingConn: null, chainConnections };
|
||||
registerTestConnectionTransport(sessionId, testTransport);
|
||||
}
|
||||
|
||||
// Determine if we have jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
@@ -2114,6 +2180,17 @@ function createStartSessionApi(ctx) {
|
||||
// Pass fetched keys to chain connection to avoid re-reading files
|
||||
options._defaultKeys = discoveredDefaultKeys;
|
||||
options._sshDiagnosticLogger = log;
|
||||
if (testTransport) {
|
||||
options._tunnelRef = testTransport;
|
||||
options._connectionsRef = testTransport.chainConnections;
|
||||
}
|
||||
if (testMode) {
|
||||
// A test has no terminal session in the renderer's session list, so
|
||||
// jump-host keyboard-interactive/MFA prompts must use the external
|
||||
// scope (same as the target host below); the terminal scope default
|
||||
// in connectThroughChain would be rejected by the renderer queue.
|
||||
options._keyboardInteractiveScope = "external";
|
||||
}
|
||||
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
@@ -2137,11 +2214,20 @@ function createStartSessionApi(ctx) {
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.port || 22,
|
||||
{ timeoutMs: tcpConnectTimeoutMs }
|
||||
{
|
||||
timeoutMs: tcpConnectTimeoutMs,
|
||||
...(testTransport
|
||||
? { onSocket: (socket) => { testTransport.pendingConn = socket; } }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
if (testTransport) {
|
||||
testTransport.socket = connectionSocket;
|
||||
testTransport.pendingConn = null;
|
||||
}
|
||||
} else {
|
||||
// Direct connection (no jump hosts, no proxy)
|
||||
sendProgress(1, 1, options.hostname, 'connecting');
|
||||
@@ -2245,6 +2331,23 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
|
||||
|
||||
if (testMode) {
|
||||
// Headless connection test: authentication succeeded. Report the
|
||||
// outcome and tear down without opening a shell channel. The
|
||||
// auth-method cache write above is intentionally kept so a
|
||||
// successful test warms the same cache a real connection uses.
|
||||
clearAuthReadyTimer();
|
||||
sendConnectionTestResult(sender, sessionId, true);
|
||||
settled = true;
|
||||
try { conn.end(); } catch { /* ignore */ }
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { /* ignore */ }
|
||||
}
|
||||
resolve({ sessionId, testResult: "connected" });
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'shell');
|
||||
|
||||
let establishedOwnerSession = null;
|
||||
@@ -2463,6 +2566,9 @@ function createStartSessionApi(ctx) {
|
||||
// Destroy the connection to prevent further socket errors from leaking
|
||||
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
||||
try { conn.destroy(); } catch { }
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(sender, sessionId, false, visibleError);
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -2491,12 +2597,16 @@ function createStartSessionApi(ctx) {
|
||||
sessionDecoders.delete(sessionId);
|
||||
teardownTransport();
|
||||
try { conn.destroy(); } catch { }
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(sender, sessionId, false, err.message);
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.once("close", () => {
|
||||
clearAuthReadyTimer();
|
||||
unregisterTestConnectionTransport(sessionId);
|
||||
const contents = event.sender;
|
||||
const currentSession = sessions.get(sessionId);
|
||||
const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef);
|
||||
@@ -2559,6 +2669,14 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
if (!settled) {
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(
|
||||
sender,
|
||||
sessionId,
|
||||
false,
|
||||
`Connection to ${options.hostname} closed unexpectedly`,
|
||||
);
|
||||
}
|
||||
settled = true;
|
||||
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
|
||||
}
|
||||
@@ -2575,7 +2693,11 @@ function createStartSessionApi(ctx) {
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
logPrefix,
|
||||
scope: "terminal",
|
||||
// A test has no terminal session registered in the renderer's
|
||||
// session list, so the terminal-scoped keyboard-interactive queue
|
||||
// would auto-reject the prompt. Use the external scope so the
|
||||
// host-editor test dialog can still render password/2FA prompts.
|
||||
scope: testMode ? "external" : "terminal",
|
||||
bootEpoch: options.bootEpoch,
|
||||
getAuthBanner: () => authBanner,
|
||||
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
|
||||
@@ -2627,6 +2749,31 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
// If authHandler is a function, it already handles keyboard-interactive
|
||||
|
||||
if (testMode) {
|
||||
// A connection test exercises the single configured credential,
|
||||
// skipping the full agent / ~/.ssh key / method fallback chain.
|
||||
// Password credentials still allow keyboard-interactive after
|
||||
// "password" so PAM/2FA hosts (which reject the raw "password"
|
||||
// method) are covered without re-prompting the user. Hosts marked
|
||||
// `requiresMfa` always get keyboard-interactive, including key /
|
||||
// certificate-only credentials where the server accepts the key as
|
||||
// the first factor and then asks for an OTP (matching the terminal
|
||||
// auth handler, which always appends keyboard-interactive).
|
||||
const order = [];
|
||||
if (connectOpts.privateKey) {
|
||||
order.push("publickey");
|
||||
} else if (connectOpts.agent) {
|
||||
order.push("agent");
|
||||
}
|
||||
if (connectOpts.password && !options.requiresMfa) {
|
||||
order.push("password");
|
||||
}
|
||||
if (connectOpts.password || options.requiresMfa || order.length === 0) {
|
||||
order.push("keyboard-interactive");
|
||||
}
|
||||
connectOpts.authHandler = order;
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
log("connect options prepared", {
|
||||
sessionId,
|
||||
@@ -2647,6 +2794,8 @@ function createStartSessionApi(ctx) {
|
||||
: undefined,
|
||||
});
|
||||
conn.connect(connectOpts);
|
||||
// The test transport was already registered before the dial; nothing
|
||||
// to re-register here.
|
||||
}).catch((err) => {
|
||||
if (pendingDialCoordination && !options._deferPendingDialFailure) {
|
||||
failTransportDial(pendingDialCoordination, err);
|
||||
@@ -2669,6 +2818,10 @@ function createStartSessionApi(ctx) {
|
||||
{ sessionId, exitCode: 1, error: userVisibleSshErrorMessage(err, options) },
|
||||
);
|
||||
}
|
||||
if (testMode) {
|
||||
unregisterTestConnectionTransport(sessionId);
|
||||
sendConnectionTestResult(sender, sessionId, false, userVisibleSshErrorMessage(err, options));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -2687,4 +2840,6 @@ module.exports = {
|
||||
shouldPromoteCachedAuthMethod,
|
||||
applyAgentForwarding,
|
||||
prepareAgentForwardingOptions,
|
||||
sendConnectionTestResult,
|
||||
cancelTestConnection,
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const { createStartSessionApi, sendConnectionTestResult, cancelTestConnection } = require("./startSession.cjs");
|
||||
|
||||
const MUTED_CONSOLE = {
|
||||
log() {},
|
||||
error() {},
|
||||
warn() {},
|
||||
info() {},
|
||||
};
|
||||
|
||||
function createHarness({ onConnect, chainDial, ctxOverrides } = {}) {
|
||||
const sent = [];
|
||||
const calls = {
|
||||
connectOptions: null,
|
||||
ends: 0,
|
||||
destroys: 0,
|
||||
defaultKeyScans: 0,
|
||||
};
|
||||
const sender = {
|
||||
send: (channel, payload) => {
|
||||
sent.push({ channel, payload });
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
|
||||
class MockSSHClient extends EventEmitter {
|
||||
connect(options) {
|
||||
calls.connectOptions = options;
|
||||
if (onConnect) onConnect(this);
|
||||
}
|
||||
|
||||
end() {
|
||||
calls.ends += 1;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
calls.destroys += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map();
|
||||
const ctx = {
|
||||
randomUUID: () => "session-test",
|
||||
SSHClient: MockSSHClient,
|
||||
createSshDiagnosticLogger: () => () => {},
|
||||
buildConnectionReuseEndpoint: () => "endpoint",
|
||||
resolveConnectionKeepalivePolicy: () => ({ keepaliveIntervalMs: 0, keepaliveCountMax: 0 }),
|
||||
buildAlgorithms: () => undefined,
|
||||
attachSshDebugLogger: () => {},
|
||||
logSshAlgorithms: () => {},
|
||||
hostKeyVerifier: { createHostVerifier: () => (_rawKey, callback) => callback(true) },
|
||||
findAllDefaultPrivateKeys: async () => {
|
||||
calls.defaultKeyScans += 1;
|
||||
return [];
|
||||
},
|
||||
isPasswordProvided: () => true,
|
||||
hasUserConfiguredKey: () => false,
|
||||
getCachedAuthMethod: () => null,
|
||||
setCachedAuthMethod: () => {},
|
||||
clearCachedAuthMethod: () => {},
|
||||
createAuthPhase: () => ({}),
|
||||
markAuthPhasePartialSuccess: () => {},
|
||||
shouldSkipKiPasswordAutoFill: () => false,
|
||||
createKeyboardInteractiveHandler: () => () => {},
|
||||
createOrderedStringAuthHandler: () => () => {},
|
||||
getAvailableAgentSocket: async () => null,
|
||||
getAvailableForwardingAgentSocket: async () => null,
|
||||
prepareSystemSshAgentForAuth: async () => null,
|
||||
loadFirstIdentityFileForAuth: async () => null,
|
||||
preparePrivateKeyForAuth: async () => null,
|
||||
connectThroughChain: chainDial ?? (async () => {
|
||||
throw new Error("unexpected chain connect");
|
||||
}),
|
||||
createProxySocket: async () => {
|
||||
throw new Error("unexpected proxy connect");
|
||||
},
|
||||
enableSshNoDelay: () => {},
|
||||
enableTcpNoDelay: () => {},
|
||||
quoteShellArg: (value) => value,
|
||||
console: MUTED_CONSOLE,
|
||||
sessions,
|
||||
safeSend: () => {},
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessionLogStreamManager: { stopStream: () => {} },
|
||||
closeTerminalOutputSession: () => {},
|
||||
sessionEncodings: new Map(),
|
||||
sessionDecoders: new Map(),
|
||||
...(ctxOverrides || {}),
|
||||
};
|
||||
|
||||
const api = createStartSessionApi(ctx);
|
||||
return { api, sent, calls, sender };
|
||||
}
|
||||
|
||||
function baseOptions() {
|
||||
return {
|
||||
sessionId: "session-test",
|
||||
hostname: "example.test",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
testMode: true,
|
||||
reuseTransport: false,
|
||||
};
|
||||
}
|
||||
|
||||
test("sendConnectionTestResult emits a one-shot success payload", () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
|
||||
sendConnectionTestResult(sender, "session-1", true);
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
{ channel: "netcatty:test:result", payload: { sessionId: "session-1", ok: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sendConnectionTestResult ignores destroyed senders", () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
isDestroyed: () => true,
|
||||
send: () => sent.push("should-not-fire"),
|
||||
};
|
||||
|
||||
sendConnectionTestResult(sender, "session-1", false, "boom");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("test mode resolves success on authentication without opening a shell", async () => {
|
||||
const { api, sent, calls, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.startSSHSession({ sender }, baseOptions());
|
||||
|
||||
assert.equal(result.sessionId, "session-test");
|
||||
assert.equal(result.testResult, "connected");
|
||||
|
||||
const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result");
|
||||
assert.ok(resultEvent, "netcatty:test:result should be emitted");
|
||||
assert.deepEqual(resultEvent.payload, { sessionId: "session-test", ok: true });
|
||||
assert.equal(calls.ends, 1, "connection should be torn down after success");
|
||||
});
|
||||
|
||||
test("test mode does not emit a terminal exit on success", async () => {
|
||||
const { api, sent, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
});
|
||||
|
||||
await api.startSSHSession({ sender }, baseOptions());
|
||||
|
||||
assert.equal(
|
||||
sent.some((entry) => entry.channel === "netcatty:exit"),
|
||||
false,
|
||||
"test success must not emit a terminal session exit",
|
||||
);
|
||||
});
|
||||
|
||||
test("test mode reports failure through netcatty:test:result", async () => {
|
||||
const { api, sent, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("error", new Error("boom")));
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.startSSHSession({ sender }, baseOptions()),
|
||||
/boom/,
|
||||
);
|
||||
|
||||
const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result");
|
||||
assert.ok(resultEvent, "netcatty:test:result should be emitted on failure");
|
||||
assert.equal(resultEvent.payload.sessionId, "session-test");
|
||||
assert.equal(resultEvent.payload.ok, false);
|
||||
assert.equal(typeof resultEvent.payload.error, "string");
|
||||
assert.ok(resultEvent.payload.error.length > 0);
|
||||
});
|
||||
|
||||
test("test mode reports an unexpected close as failure", async () => {
|
||||
const { api, sent, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("close"));
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.startSSHSession({ sender }, baseOptions()),
|
||||
/closed unexpectedly/,
|
||||
);
|
||||
|
||||
const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result");
|
||||
assert.ok(resultEvent);
|
||||
assert.equal(resultEvent.payload.ok, false);
|
||||
});
|
||||
|
||||
test("cancelTestConnection ends an in-flight test transport", async () => {
|
||||
const { api, calls, sender } = createHarness({
|
||||
onConnect: () => {
|
||||
// Leave the dial pending (no ready/error/close).
|
||||
},
|
||||
});
|
||||
|
||||
void api.startSSHSession({ sender }, baseOptions());
|
||||
// Let the async body reach conn.connect() + transport registration.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const result = cancelTestConnection("session-test");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(calls.ends >= 1, "cancelTestConnection should end the SSH client");
|
||||
});
|
||||
|
||||
test("cancelTestConnection reports unknown session ids", () => {
|
||||
assert.deepEqual(cancelTestConnection("nope"), {
|
||||
success: false,
|
||||
error: "Test connection not found",
|
||||
});
|
||||
});
|
||||
|
||||
test("cancelTestConnection ends an in-flight chain hop registered before the dial", async () => {
|
||||
const hopConns = [];
|
||||
const { api, sender } = createHarness({
|
||||
chainDial: (_event, options) => {
|
||||
const conn = new EventEmitter();
|
||||
conn.ended = false;
|
||||
conn.destroyed = false;
|
||||
conn.end = () => { conn.ended = true; };
|
||||
conn.destroy = () => { conn.destroyed = true; };
|
||||
hopConns.push(conn);
|
||||
if (options._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = conn;
|
||||
options._tunnelRef.chainConnections = options._connectionsRef || [];
|
||||
(options._connectionsRef || []).push(conn);
|
||||
}
|
||||
// Never resolves — simulates a slow/unreachable bastion.
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
void api.startSSHSession({ sender }, {
|
||||
...baseOptions(),
|
||||
jumpHosts: [
|
||||
{ hostname: "jump.example.test", port: 22, username: "root", password: "jump-pass", authMethod: "password" },
|
||||
],
|
||||
});
|
||||
|
||||
// Let the async body reach connectThroughChain and register the hop.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const result = cancelTestConnection("session-test");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(hopConns.length >= 1, "chain dial should have registered a hop conn");
|
||||
assert.ok(hopConns[0].ended || hopConns[0].destroyed, "cancel should end the in-flight hop conn");
|
||||
});
|
||||
|
||||
test("test mode pins authHandler to a minimal method list", async () => {
|
||||
const { api, calls, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
});
|
||||
|
||||
await api.startSSHSession({ sender }, baseOptions());
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["password", "keyboard-interactive"]);
|
||||
});
|
||||
|
||||
function keyOnlyHarness() {
|
||||
return createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
ctxOverrides: {
|
||||
isPasswordProvided: (value) => typeof value === "string" && value.length > 0,
|
||||
preparePrivateKeyForAuth: async ({ privateKey }) => ({ privateKey }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function keyOnlyOptions(extra = {}) {
|
||||
const { password: _password, ...rest } = baseOptions();
|
||||
return { ...rest, authMethod: "key", privateKey: "PRIVATE KEY", ...extra };
|
||||
}
|
||||
|
||||
test("test mode includes keyboard-interactive for key-only MFA hosts", async () => {
|
||||
const { api, calls, sender } = keyOnlyHarness();
|
||||
|
||||
await api.startSSHSession({ sender }, keyOnlyOptions({ requiresMfa: true }));
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["publickey", "keyboard-interactive"]);
|
||||
});
|
||||
|
||||
test("test mode keeps key-only non-MFA hosts on the single key credential", async () => {
|
||||
const { api, calls, sender } = keyOnlyHarness();
|
||||
|
||||
await api.startSSHSession({ sender }, keyOnlyOptions());
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]);
|
||||
});
|
||||
|
||||
test("test mode skips the raw password method for password MFA hosts", async () => {
|
||||
const { api, calls, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
});
|
||||
|
||||
await api.startSSHSession({ sender }, { ...baseOptions(), requiresMfa: true });
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["keyboard-interactive"]);
|
||||
});
|
||||
|
||||
function jumpOptions(extra = {}) {
|
||||
return {
|
||||
...baseOptions(),
|
||||
jumpHosts: [
|
||||
{ hostname: "jump.example.test", port: 22, username: "root", authMethod: "password" },
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function capturingChainDial(captured) {
|
||||
return async (_event, options) => {
|
||||
captured.scope = options._keyboardInteractiveScope;
|
||||
return { socket: new EventEmitter(), connections: [] };
|
||||
};
|
||||
}
|
||||
|
||||
test("test mode routes jump-host keyboard-interactive prompts through the external scope", async () => {
|
||||
const captured = {};
|
||||
const { api, sender } = createHarness({
|
||||
chainDial: capturingChainDial(captured),
|
||||
onConnect: (conn) => {
|
||||
queueMicrotask(() => conn.emit("ready"));
|
||||
},
|
||||
});
|
||||
|
||||
await api.startSSHSession({ sender }, jumpOptions());
|
||||
|
||||
assert.equal(captured.scope, "external");
|
||||
});
|
||||
|
||||
test("terminal starts keep the default terminal scope for jump-host prompts", async () => {
|
||||
const captured = {};
|
||||
const { api, sender } = createHarness({
|
||||
chainDial: capturingChainDial(captured),
|
||||
onConnect: () => {
|
||||
// Terminal path would open a shell next; the chain options are all we need.
|
||||
},
|
||||
});
|
||||
|
||||
void api.startSSHSession({ sender }, jumpOptions({ testMode: false, sessionId: "terminal-session" }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(captured.scope, undefined);
|
||||
});
|
||||
@@ -293,6 +293,13 @@ function createPreloadApi(ctx) {
|
||||
markTerminalDataSessionOpen(result?.sessionId);
|
||||
return result.sessionId;
|
||||
},
|
||||
testConnection: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:test-connection", options);
|
||||
return result.sessionId;
|
||||
},
|
||||
cancelTestConnection: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:test-connection:cancel", sessionId);
|
||||
},
|
||||
startTelnetSession: async (options) => {
|
||||
markRequestedTerminalDataSessionOpen(options);
|
||||
const result = await ipcRenderer.invoke("netcatty:telnet:start", options);
|
||||
|
||||
+2
@@ -39,6 +39,8 @@ declare global {
|
||||
interface NetcattyBridge {
|
||||
getWindowsPtyInfo?(): NetcattyWindowsPtyInfo | null;
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
testConnection?(options: NetcattySSHOptions): Promise<string>;
|
||||
cancelTestConnection?(sessionId: string): Promise<{ success: boolean; error?: string }>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
|
||||
Reference in new issue
Block a user