fix(ssh): per-host keepalive for SFTP + port forwarding too

Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.

Plumbing:
  - useSftpHostCredentials / buildSftpHostCredentials: accept optional
    terminalSettings; call resolveHostKeepalive() for the target and
    each jump entry; emit keepaliveInterval / keepaliveCountMax in the
    returned NetcattySSHOptions.
  - useSftpConnections + useSftpState + SftpStateOptions thread the
    setting down. SftpSidePanel passes the global terminalSettings prop
    it already has from TerminalLayer.
  - portForwardingService.startPortForward: accepts terminalSettings
    as an 8th argument, resolves per-host (target + each jump), and
    populates the bridge payload.
  - usePortForwardingState.startTunnel and usePortForwardingAutoStart
    forward the new parameter; App.tsx supplies terminalSettings (via
    a ref in the once-on-launch auto-start effect so changing global
    keepalive later doesn't re-fire it).

Bridges:
  - sftpBridge.cjs target connect: now also reads keepaliveCountMax
    from options (was hardcoded 3). 10s/3 stays as the bridge-level
    fallback to preserve the #669 protection when the renderer hasn't
    supplied a value.
  - sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
    jump.keepaliveCountMax, then falls back to the target-call options
    (matches the symmetric SSH bridge change).
  - portForwardingBridge.cjs: reads keepaliveInterval /
    keepaliveCountMax from the IPC payload; same 10s/3 fallback.

Types:
  - NetcattyJumpHost already grew keepalive fields earlier; this
    commit also adds them to PortForwardOptions so the IPC contract
    is explicit.

End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
bincxz
2026-05-11 16:01:18 +08:00
parent 3ef8940d36
commit 5c8bc923e9
13 changed files with 91 additions and 16 deletions
+2 -1
View File
@@ -629,7 +629,7 @@ function App({ settings }: { settings: SettingsState }) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
return;
}
@@ -836,6 +836,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
+6
View File
@@ -64,4 +64,10 @@ export interface SftpStateOptions {
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
+3 -1
View File
@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
hosts,
keys,
identities,
terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -65,7 +67,7 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
@@ -1,12 +1,19 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
@@ -14,7 +21,9 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -79,6 +88,7 @@ export const buildSftpHostCredentials = ({
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -101,6 +111,8 @@ export const buildSftpHostCredentials = ({
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -129,6 +141,7 @@ export const buildSftpHostCredentials = ({
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
@@ -144,6 +157,8 @@ export const buildSftpHostCredentials = ({
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
};
};
@@ -151,8 +166,9 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);
@@ -24,6 +24,7 @@ export interface UsePortForwardingAutoStartOptions {
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
@@ -103,6 +104,7 @@ export const usePortForwardingAutoStart = ({
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
@@ -110,6 +112,8 @@ export const usePortForwardingAutoStart = ({
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -238,7 +242,7 @@ export const usePortForwardingAutoStart = ({
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
@@ -304,6 +308,10 @@ export const usePortForwardingAutoStart = ({
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};
+2 -1
View File
@@ -387,11 +387,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error?: string,
) => void,
enableReconnect = false,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
}, enableReconnect, terminalSettings);
},
[setRuleStatus],
);
+1
View File
@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
+4 -1
View File
@@ -71,6 +71,7 @@ interface SftpSidePanelProps {
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -98,6 +99,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
terminalSettings,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
@@ -119,7 +121,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
+1
View File
@@ -2394,6 +2394,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}
terminalSettings={terminalSettings}
/>
);
})}
+8 -1
View File
@@ -82,6 +82,8 @@ async function startPortForward(event, payload) {
jumpHosts = [],
identityFilePaths,
legacyAlgorithms,
keepaliveInterval: resolvedKeepaliveInterval,
keepaliveCountMax: resolvedKeepaliveCountMax,
} = payload;
const conn = new SSHClient();
@@ -110,12 +112,17 @@ async function startPortForward(event, payload) {
}
};
// Keepalive resolved by the renderer (per-host override applied), with a
// 10s/3 fallback — a forwarded TCP tunnel doesn't generate idle data
// flow, so NAT/firewall state tables drop it within ~30-60s without
// keepalive packets.
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
keepaliveInterval: resolvedKeepaliveInterval > 0 ? resolvedKeepaliveInterval * 1000 : 10000,
keepaliveCountMax: resolvedKeepaliveInterval > 0 ? (resolvedKeepaliveCountMax ?? 3) : 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(legacyAlgorithms),
+18 -5
View File
@@ -923,14 +923,25 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Per-hop keepalive. Each jump entry now carries its own resolved
// values when the renderer supplies them; fall back to the target
// call's options for backward compat with older serializers and
// ultimately to a 10s default so an idle SFTP browse over a NAT
// doesn't drop (the original #669 protection).
const hopInterval = jump.keepaliveInterval != null
? jump.keepaliveInterval
: options.keepaliveInterval;
const hopCountMax = jump.keepaliveCountMax != null
? jump.keepaliveCountMax
: (options.keepaliveCountMax ?? 3);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
keepaliveInterval: 10000,
keepaliveCountMax: 3,
keepaliveInterval: hopInterval > 0 ? hopInterval * 1000 : 10000,
keepaliveCountMax: hopInterval > 0 ? hopCountMax : 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
@@ -1435,10 +1446,12 @@ async function openSftp(event, options) {
// keepalive packets the connection sits with zero data flow while the
// user is just browsing files, and NAT/firewall state tables drop the
// idle TCP connection after ~30-60s (the exact symptom of #669).
// Honor an explicitly configured positive keepaliveInterval (seconds);
// otherwise default to 10s, matching the SFTP jump host path below.
// Honor an explicitly configured positive keepaliveInterval (seconds)
// — including per-host overrides resolved by the renderer; otherwise
// default to 10s. countMax is forwarded from the renderer too with a
// backward-compat fallback of 3.
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
keepaliveCountMax: options.keepaliveInterval > 0 ? (options.keepaliveCountMax ?? 3) : 3,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
Vendored
+4
View File
@@ -145,6 +145,10 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
identityFilePaths?: string[];
legacyAlgorithms?: boolean;
// Resolved keepalive for the target connection (caller has already
// applied host override / global fallback). interval in seconds.
keepaliveInterval?: number;
keepaliveCountMax?: number;
}
interface PortForwardResult {
@@ -4,9 +4,14 @@
* for establishing and managing SSH port forwarding tunnels.
*/
import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models';
import { Host, Identity, PortForwardingRule, SSHKey, TerminalSettings } from '../../domain/models';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveBridgeKeyAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
// Fallback matching DEFAULT_TERMINAL_SETTINGS so older call sites that don't
// thread terminalSettings still get the cloud-friendly defaults.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
import { logger } from '../../lib/logger';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import { STORAGE_KEY_PF_RECONNECT_CANCEL } from '../config/storageKeys';
@@ -361,8 +366,10 @@ export const startPortForward = async (
keys: SSHKey[],
identities: Identity[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect = false
enableReconnect = false,
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
): Promise<{ success: boolean; error?: string }> => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
const bridge = netcattyBridge.get();
// Clear any existing reconnect timer
@@ -440,6 +447,7 @@ export const startPortForward = async (
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -462,6 +470,8 @@ export const startPortForward = async (
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -542,6 +552,8 @@ export const startPortForward = async (
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
identityFilePaths: keyAuth.identityFilePaths,
legacyAlgorithms: host.legacyAlgorithms,
keepaliveInterval: resolveHostKeepalive(host, globalKeepalive).interval,
keepaliveCountMax: resolveHostKeepalive(host, globalKeepalive).countMax,
});
if (!result.success) {