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:
Yohannandyangheng authored and GitHub committed 2026-10-08 14:42:18 +08:00
1 parent 6fcd6a3ec3
commit 75c155b748
22 files changed
+2022 -7

No files matched your search

+1
View File
@@ -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',
+10
View File
@@ -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',
+1
View File
@@ -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, чтобы подключиться снова',
+9
View File
@@ -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': 'Общие',
+10
View File
@@ -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': '关闭断连提示',
+10
View File
@@ -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.");
});
+329
View File
@@ -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,
};
}
+16
View File
@@ -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,
+50 -3
View File
@@ -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>
);
};
+9
View File
@@ -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}>
+4
View File
@@ -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,
+221
View File
@@ -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);
});
+337
View File
@@ -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");
});
+28 -1
View File
@@ -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 = {}) => {
+157 -2
View File
@@ -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);
});
+7
View File
@@ -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
View File
@@ -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;