Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 ece2703a4a fix(sftp): fall back once when a disposable upload channel closes
Owner, borrowed, and sudo channels still fail with SFTP channel closed
and do not open another dial. An isolated upload subsystem can close
while the shared browse channel is healthy; that close no longer sets
noTransferFallback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 15:21:45 +00:00
Cursor Agentand陈大猫 081c6dc0a7 fix(sftp): do not redial after the transfer channel closes
SFTP channel closed was treated as a transient blip, so the file retry
opened a new session and waited on keyboard-interactive while the row
stayed active. A dead channel now fails the transfer once.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 15:09:25 +00:00
Cursor Agentand陈大猫 1b5d183599 fix(sftp): recheck jump-host pins when the target skips verification
A jump host can require host-key verification while the target disables
it. The known-host digest and channel adoption now include that hop, so
a replaced or removed bastion pin does not keep the old single-channel
session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:53:36 +00:00
Cursor Agentand陈大猫 c47d4e17b4 fix(sftp): trust current known hosts and fail a silent channel close
Transfer opens send the known-host list from the latest session acquire,
including a key trusted after the transfer callback was created. Every
adoption path rechecks those records, so a key removed from the vault
is not borrowed when both endpoint digests are empty. Closing the
borrowed SFTP stream rejects pending READ/WRITE even when ssh2 never
invokes the write callback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:49:36 +00:00
Cursor Agentand陈大猫 bd6d6bf7d5 fix(sftp): fail a transfer that subscribes after the channel ended
end/close is not replayed. If WRITE installs its watcher only after the
channel is already unreadable, fail that waiter instead of waiting on a
callback ssh2 will never send.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:05:38 +00:00
Cursor Agentand陈大猫 57cb5e3192 fix(sftp): keep the owner when a reconnect reuses its session id
A same-id adopt stored the alias over the SSH client and leased that id
to itself, so closeSftp could only soft-close. The reconnect alias now
uses its own id and releases the original client after the last borrower.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:54:36 +00:00
Cursor Agentand陈大猫 58978566bb fix(sftp): refuse dead channels and revoked host keys on adopt
A closed SFTP stream is no longer treated as live just because its methods
remain. Adoption that ignores the known-host digest now also requires the
connection's accepted host key to still be trusted by the caller's records.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:43:10 +00:00
Cursor Agentand陈大猫 b4ed76c648 fix(sftp): match borrowed channels and fail a dead upload
Adoption requires the requested file protocol and sudo mode, so sudo plus
SCP still takes the rejecting connect path. Channel lookup ignores only the
known-host trust digest. An unattended transfer dial fails immediately when
the host key is untrusted. A killed SFTP channel or TCP reset fails the
in-flight upload with SFTP channel closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:30:56 +00:00
Cursor Agentand陈大猫 5facc87597 fix(sftp): hold the owner channel until borrowed uploads release it
Closing the SFTP page only checked leases on the login id, so client.end()
killed the single-channel session while an alias upload was still writing.
Borrowers now lease the owner. If that channel dies anyway, in-flight
READ/WRITE fail with a visible error instead of waiting forever.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:14 +00:00
Cursor Agentand陈大猫 48f2c2fd82 fix(sftp): reuse the authenticated channel when the login cannot join the transport pool
Single-channel bastions and sudo SFTP stay out of the shared SSH registry.
The SFTP-page login was the dial leader and never settled that dial, so the
next upload waited forever with no keyboard-interactive prompt. Borrow the
live channel instead, and surface OTP when a new connection is required.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:45:04 +00:00
17 changed files with 2096 additions and 53 deletions

No files matched your search

@@ -30,6 +30,7 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { unwrapSftpIpcError } from "./errors";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
// leave reuseTransport enabled so the main-process registry can reuse an
// authenticated endpoint even when no terminal tab exists.
// No SFTP-page host-key dialog owns this session id. An untrusted key must
// fail the transfer immediately instead of sitting on the verifier TTL.
const openOpts = wantDedicated
? { ...credentials, reuseTransport: false as const }
: credentials;
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
: { ...credentials, hostKeyUnattended: true as const };
// The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return {
success: false,
error: err instanceof Error ? err.message : String(error),
error: unwrapSftpIpcError(error),
needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint,
};
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
assert.equal(attempts, 1);
});
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
let attempts = 0;
await assert.rejects(
() => runWithTransferRetry(async () => {
attempts += 1;
throw new Error("SFTP channel closed");
}, { retries: 1, delayMs: 0 }),
/SFTP channel closed/,
);
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
});
test("runWithTransferRetry does not retry cancel", async () => {
let attempts = 0;
await assert.rejects(
+3
View File
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
* path) should not be retried.
*/
export function isTransientTransferError(err: unknown): boolean {
// The channel is gone. Retrying opens a new dial, and an OTP host then
// sits on keyboard-interactive while the transfer stays active.
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
if (isSessionError(err)) return true;
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
+4 -3
View File
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
if (!isTargetConnectionAtPath(startPath)) return;
updateTargetTab((prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
error: unwrapSftpIpcError(err) || "Failed to list directory",
loading: false,
reconnecting: false,
}));
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
error: unwrapSftpIpcError(err) || "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
: (unwrapSftpIpcError(err) || "Connection failed"),
loading: false,
reconnecting: false,
}));
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
});
}
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
const seen: string[] = [];
let fingerprint = "";
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
const restore = installGlobals({});
function Probe() {
const captured = fingerprint;
const acquireTransferSession = React.useCallback(async () => {
seen.push(captured);
throw new Error(`known-hosts:${captured || "empty"}`);
}, [captured]);
ops = useSftpTransfers({
ownerId: "known-hosts-after-trust-3310",
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession,
});
if (!firstStart) firstStart = ops.startTransfer;
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
fingerprint = "trusted-this-session";
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => {
await firstStart!(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.deepEqual(seen, ["trusted-this-session"]);
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
assert.equal(task?.error, "known-hosts:trusted-this-session");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("channel death fails the upload without a second session open (#3310)", async () => {
let acquires = 0;
let streams = 0;
const restore = installGlobals({
statLocal: async () => ({ size: 48, lastModified: 1 }),
statSftp: async () => null,
startStreamTransfer: async () => {
streams += 1;
return { error: "SFTP channel closed" };
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "channel-death-no-reconnect-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
acquires += 1;
return {
sftpId: "sftp-borrowed",
release: () => undefined,
discard: () => undefined,
};
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.equal(streams, 1, "channel death must not start another transfer dial");
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.status, "failed");
assert.equal(task?.error, "SFTP channel closed");
assert.equal(
sftpTransferCenterStore.getOwnerTasks(ownerId)
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
.length,
0,
);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
const restore = installGlobals({});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "ipc-prefix-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
type StartOptions = {
transferId: string;
sourcePath: string;
+30 -12
View File
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
// startTransfer keeps the processTransfer closure from its first render.
// Read the latest acquire fn so a host key trusted after that render is
// included in the transfer open, instead of the empty list from mount.
const acquireTransferSessionRef = useRef(acquireTransferSession);
acquireTransferSessionRef.current = acquireTransferSession;
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
(hostId, transferId, connectHost) => {
const acquire = acquireTransferSessionRef.current;
if (!acquire) {
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
}
return acquire(hostId, transferId, connectHost);
},
[],
);
// Ref so progress flushes always see the latest visibility without rebinding.
const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible;
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
setTransfers,
listLocalFiles,
listRemoteFiles,
acquireTransferSession,
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
});
const processTransfer = async (
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
// survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
const acquireSourceSession = acquireTransferSessionRef.current;
if (acquireSourceSession && sourceHostId) {
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
const acquireTargetSession = acquireTransferSessionRef.current;
if (acquireTargetSession && targetHostId) {
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -1122,7 +1139,7 @@ export const useSftpTransfers = ({
updateTask({
status: "failed",
error: err instanceof Error ? err.message : "Transfer failed",
error: unwrapSftpIpcError(err) || "Transfer failed",
endTime: Date.now(),
speed: 0,
});
@@ -1507,8 +1524,8 @@ export const useSftpTransfers = ({
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
acquireTransferSession: acquireTransferSessionRef.current
? (hostId, id) => acquireTransferSessionLive(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
@@ -2000,8 +2017,9 @@ export const useSftpTransfers = ({
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
const acquireDownloadSession = acquireTransferSessionRef.current;
if (acquireDownloadSession && params.sourceHostId) {
sourceWorkLease = await acquireDownloadSession(
params.sourceHostId,
`${task.id}:work-source`,
);
@@ -2067,7 +2085,7 @@ export const useSftpTransfers = ({
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
const errMsg = unwrapSftpIpcError(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
+27 -1
View File
@@ -209,13 +209,32 @@ const createHostVerifier = ({
knownHosts = [],
verifyHostKeys = true,
bootEpoch,
// Transfer dials have no SFTP-page prompt owner. Waiting out the request
// TTL leaves the upload on "Waiting..." with no dialog and no error.
unattended = false,
onAccepted,
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const keyInfo = describeHostKey(rawKey);
const decision = classifyHostKey({
knownHosts,
hostname,
@@ -225,10 +244,16 @@ const createHostVerifier = ({
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
@@ -241,6 +266,7 @@ const createHostVerifier = ({
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
+25 -8
View File
@@ -1576,17 +1576,32 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
}
const cleanupStage = async () => {
// The owner channel is already gone. unlink/delete would wait forever for
// a callback ssh2 never sends, and the transfer would stay on screen.
const channel = client?.sftp;
if (channel && (channel.readable === false || channel.destroyed === true)) return;
const removeStage = scpMode
? getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
})
: typeof client.delete === "function"
? client.delete(encodedStagedPath)
: null;
if (!removeStage) return;
let timer = null;
try {
if (scpMode) {
await getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
});
} else if (typeof client.delete === "function") {
await client.delete(encodedStagedPath);
}
await Promise.race([
Promise.resolve(removeStage),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("SFTP channel closed")), 2000);
if (typeof timer.unref === "function") timer.unref();
}),
]);
} catch {
// Best-effort cleanup of a partial stage.
} finally {
if (timer) clearTimeout(timer);
}
};
@@ -2385,6 +2400,7 @@ const {
completeTransportDial,
failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
endpointAllowsReuse,
@@ -2407,6 +2423,7 @@ const openConnectionApi = createOpenConnectionApi({
createTransport, borrowTransport, findTransportByEndpoint,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
});
File diff suppressed because it is too large. Load diff
+45 -2
View File
@@ -555,6 +555,41 @@ function createFileOpsApi(ctx) {
return true;
}
/**
* Drop the alias's lease on the login client. If the SFTP page already
* asked to close that client, end it only after the last borrower leaves.
*/
function releaseSharedChannelOwner(client) {
const ownerId = client?.__netcattySharedChannelOwnerId;
const aliasId = client?.__netcattySharedChannelAliasId;
if (!ownerId || !aliasId || client.__netcattyOwnerLeaseReleased) return;
client.__netcattyOwnerLeaseReleased = true;
let store;
let result;
try {
const lease = require("../sftpTransferSessionLease.cjs");
store = lease.sftpTransferSessionLeaseStore;
result = store.release(ownerId, lease.channelAliasLeaseId(aliasId));
} catch {
return;
}
if (!result?.shouldHardClose) return;
const closeToken = store.getPendingHardCloseToken(ownerId);
setImmediate(() => {
if (store.isHeld(ownerId)) {
store.markSoftClosed(ownerId);
return;
}
if (!store.commitHardClose(ownerId, closeToken)) return;
void closeSftp(null, { sftpId: ownerId, force: true }).catch((err) => {
console.warn(
`[SFTP] Deferred close of borrowed owner ${ownerId} failed:`,
err?.message || err,
);
});
});
}
/**
* Close an SFTP connection.
* Also cleans up any jump host connections and file watchers if present.
@@ -666,8 +701,16 @@ function createFileOpsApi(ctx) {
};
}
} catch { /* optional */ }
// Transport-managed / session-backed: close channel + return lease only.
if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
// Alias handles share the login channel. Closing the transfer id must
// not end that channel or the SFTP page listing dies with the upload.
// Releasing the owner lease lets a deferred closeSftp(owner) finish
// only after every borrower is gone.
if (client.__netcattySharedChannelAlias) {
client.__netcattyDisposed = true;
client.sftp = null;
try { await client.end?.(); } catch { /* alias end only detaches */ }
releaseSharedChannelOwner(client);
} else if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
try {
if (client.sftp && typeof client.sftp.end === "function") client.sftp.end();
else if (client.sftp && typeof client.sftp.close === "function") client.sftp.close();
@@ -77,6 +77,17 @@ function shouldRegisterFreshSftpTransport(options) {
return allowsSharedSftpTransport(options);
}
function sftpChannelIsLive(sftp) {
// A closed ssh2 SFTP stream still has readdir/stat/open. readable/destroyed
// are what change when the subsystem or the socket has already ended.
return !!sftp
&& sftp.readable !== false
&& sftp.destroyed !== true
&& typeof sftp.readdir === "function"
&& typeof sftp.stat === "function"
&& typeof sftp.open === "function";
}
/** Sudo is part of the requested connection contract; never downgrade it. */
async function openRequiredSudoSftp({
connectSudoSftp,
@@ -239,6 +250,12 @@ function createOpenConnectionApi(ctx) {
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Auth - support agent (certificate), key, and password fallback
@@ -767,6 +784,142 @@ function createOpenConnectionApi(ctx) {
});
}
/**
* A live SFTP channel that was not parked in the SSH transport registry
* (single-channel bastion, or sudo elevation). Another open must borrow
* that channel. A second subsystem drops TERM-SSHD / similar logins, and
* a fresh SSH dial would ask for OTP again.
*/
function hopsRequiringHostKeyCheck(request) {
const hops = [];
const add = (hostname, port) => {
const normalized = String(hostname || "").trim().toLowerCase();
if (!normalized) return;
hops.push({ hostname: normalized, port: Number(port) || 22 });
};
if (request?.verifyHostKeys !== false) add(request?.hostname, request?.port);
for (const hop of Array.isArray(request?.jumpHosts) ? request.jumpHosts : []) {
if ((hop?.verifyHostKeys ?? request?.verifyHostKeys) === false) continue;
add(hop?.hostname, hop?.port);
}
return hops;
}
function acceptedHostKeysStillTrusted(source, request) {
// Every hop whose effective policy still verifies must have an accepted
// key that classifies as trusted. Disabling the target does not skip a
// jump host that explicitly verifies.
const required = hopsRequiringHostKeyCheck(request);
if (required.length === 0) return true;
const keys = source?.__netcattyAcceptedHostKeys;
if (!Array.isArray(keys) || keys.length === 0) return false;
const knownHosts = Array.isArray(request?.knownHosts) ? request.knownHosts : [];
return required.every((hop) => keys.some((key) => {
const hostname = String(key?.hostname || "").trim().toLowerCase();
const port = Number(key?.port) || 22;
if (hostname !== hop.hostname || port !== hop.port) return false;
return hostKeyVerifier.classifyHostKey({
knownHosts,
hostname: key.hostname,
port: key.port || 22,
keyType: key.keyType,
fingerprint: key.fingerprint,
}).status === "trusted";
}));
}
function authenticatedChannelMatches(client, endpoint, request) {
const exact = buildEndpointKey(endpoint);
const exactMatch = !!(exact && client.__netcattyEndpointKey === exact);
if (!exactMatch) {
// The known-host digest can change when a still-valid key is saved, or
// when an unrelated record is added. That may reuse the live channel.
if (typeof buildChannelAdoptionKey !== "function") return false;
const adoption = buildChannelAdoptionKey(endpoint);
if (!adoption || client.__netcattyChannelAdoptionKey !== adoption) return false;
}
// Exact endpoint keys are not enough. A session trusted from an empty
// list and a later open that is also empty (the key was removed) share
// a digest, and must still be checked against the caller's records.
return acceptedHostKeysStillTrusted(client, request);
}
function channelContractMatches(options, source) {
if (Boolean(options?.sudo) !== Boolean(source?.__netcattySudoMode)) return false;
const requested = normalizeFileProtocol(options?.fileProtocol);
const owner = source?.__netcattyFileProtocol === "scp" ? "scp" : "sftp";
// auto follows the live channel. A forced protocol must be the same one
// already open, or SCP would silently come back as SFTP.
if (requested === "auto") return true;
return requested === owner;
}
function findAuthenticatedChannelOwner(endpoint, request) {
if (!endpoint || !sftpClients || typeof sftpClients.entries !== "function") return null;
for (const [sftpId, client] of sftpClients.entries()) {
if (!sftpId || !client || client.__netcattyDisposed || client.__netcattySharedChannelAlias) continue;
if (client.__netcattyTransportManaged) continue;
if (!authenticatedChannelMatches(client, endpoint, request)) continue;
if (!client.__netcattySingleChannelSsh && !client.__netcattySudoMode) continue;
if (!sftpChannelIsLive(client.sftp)) continue;
return { sftpId, client };
}
return null;
}
function adoptAuthenticatedSftpChannel(endpoint, connId, options) {
const found = findAuthenticatedChannelOwner(endpoint, options);
if (!found || typeof createSessionBackedSftpClient !== "function") return null;
const { sftpId: ownerId, client: source } = found;
if (!channelContractMatches(options, source)) return null;
// A pane reconnect can ask for the owner's own id while a transfer still
// holds that soft-closed session. Storing the alias under that id would
// drop the SSH client and lease the id to itself, so closeSftp could
// never hard-close it.
const aliasId = connId === ownerId ? `${ownerId}:reconnect:${randomUUID()}` : connId;
let leaseStore = null;
let leaseId = null;
try {
const lease = require("../sftpTransferSessionLease.cjs");
leaseStore = lease.sftpTransferSessionLeaseStore;
leaseId = lease.channelAliasLeaseId(aliasId);
// Hold the owner until this alias is torn down. closeSftp(owner) then
// soft-closes instead of ending the only SFTP channel mid-transfer.
if (!leaseStore.acquire(ownerId, leaseId)) return null;
} catch {
return null;
}
try {
const alias = createSessionBackedSftpClient(aliasId, source.client, {
singleChannelSsh: true,
});
alias.sftp = source.sftp;
alias.__netcattyFileProtocol = source.__netcattyFileProtocol || "sftp";
alias.__netcattySudoMode = !!source.__netcattySudoMode;
// Keep uploads on this channel. A disposable subsystem would either drop
// the single-channel login or bypass sudo elevation.
alias.__netcattySingleChannelSsh = true;
alias.__netcattySharedChannelAlias = true;
alias.__netcattySharedChannelOwnerId = ownerId;
alias.__netcattySharedChannelAliasId = aliasId;
alias.__netcattyEndpointKey = source.__netcattyEndpointKey;
alias.__netcattyChannelAdoptionKey = source.__netcattyChannelAdoptionKey;
alias.__netcattyTransportManaged = false;
alias.end = async () => {
alias.__netcattyDisposed = true;
alias.sftp = null;
};
sftpClients.set(aliasId, alias);
return {
sftpId: aliasId,
fileProtocol: alias.__netcattyFileProtocol === "scp" ? "scp" : "sftp",
};
} catch (err) {
try { leaseStore?.release(ownerId, leaseId); } catch { /* ignore */ }
throw err;
}
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -778,6 +931,32 @@ function createOpenConnectionApi(ctx) {
});
let pendingDialCoordination = options._pendingDialCoordination || null;
// Same rejection as openSftpForSession / the post-ready fresh path.
// Doing it before adoption stops {sudo, fileProtocol:'scp'} from
// borrowing an elevated SFTP channel and reporting success.
if (Boolean(options.sudo) && normalizeFileProtocol(options.fileProtocol) === "scp") {
throw new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
);
}
// Do this before dial coordination. An in-flight single-channel login is
// the dial leader and never registers a transport; joining it waits
// forever and never shows keyboard-interactive.
if (options.reuseTransport !== false && !pendingDialCoordination) {
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
}
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
if (
options.singleChannelSsh
@@ -870,6 +1049,20 @@ function createOpenConnectionApi(ctx) {
: await waitForTransportDial(coordination);
return await openOnSharedTransport(transport, "reused coordinated transport");
} catch (coordinationErr) {
// The leader may have published a live channel and then rejected
// this dial (single-channel bastion, or sudo which is not parked).
// Adopt that channel before failing the transfer or dialing again.
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
if (
coordination.role === "join"
&& coordinationErr?.code !== "ERR_SFTP_SINGLE_CHANNEL_BASTION"
@@ -897,6 +1090,9 @@ function createOpenConnectionApi(ctx) {
const client = new SftpClient();
let peerSingleChannelSsh = options.singleChannelSsh === true;
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
if (typeof buildChannelAdoptionKey === "function") {
client.__netcattyChannelAdoptionKey = buildChannelAdoptionKey(reuseEndpoint);
}
let freshClientClosed = false;
const closeFreshClient = () => {
if (freshClientClosed) return;
@@ -993,6 +1189,12 @@ function createOpenConnectionApi(ctx) {
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Use the tunneled socket if we have one
@@ -1398,7 +1600,25 @@ function createOpenConnectionApi(ctx) {
if (client.client) {
client.client.__netcattySingleChannelSsh = peerSingleChannelSsh;
}
if (Array.isArray(options._acceptedHostKeys) && options._acceptedHostKeys.length > 0) {
client.__netcattyAcceptedHostKeys = options._acceptedHostKeys.slice();
}
sftpClients.set(connId, client);
// Publish the live channel before rejecting waiters. A single-channel
// login cannot join the transport registry; leaving the dial pending
// makes the next SFTP-page transfer wait forever with no OTP prompt.
if (pendingDialCoordination && !client.__netcattyTransportManaged) {
const unshared = new Error(
peerSingleChannelSsh || options.singleChannelSsh === true
? "This host allows one SSH channel. Reuse the authenticated SFTP session instead of opening another."
: "SFTP connection was not added to the shared SSH transport registry",
);
if (peerSingleChannelSsh || options.singleChannelSsh === true) {
unshared.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
}
failTransportDial(pendingDialCoordination, unshared);
}
// Store jump connections for cleanup when SFTP is closed (legacy path
// only — transport-managed connections park chain on the registry).
@@ -1415,6 +1635,16 @@ function createOpenConnectionApi(ctx) {
// Cleanup jump connections on error
cleanupPendingConnection();
closeFreshClient();
if (
options.hostKeyUnattended === true
&& /Host denied \(verification failed\)|Host key rejected/i.test(String(err?.message || ""))
) {
const denied = new Error(`Host key verification required for ${options.hostname || "host"}`);
denied.code = "ERR_HOST_KEY_UNVERIFIED";
denied.cause = err;
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, denied);
throw denied;
}
if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) {
console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...");
return openSftp(event, {
@@ -16,8 +16,18 @@
*
* This is intentionally independent of UI panels. Browse can go away; transfers
* keep their leased sessions until they complete.
*
* A borrowed SFTP handle (single-channel / sudo alias) also acquires a lease
* on the owner sftpId for the alias lifetime. closeSftp(owner) then soft-closes
* until the last borrower releases, instead of ending the shared channel.
*/
const CHANNEL_ALIAS_LEASE_PREFIX = "sftp-channel-alias:";
function channelAliasLeaseId(aliasId) {
return `${CHANNEL_ALIAS_LEASE_PREFIX}${aliasId}`;
}
/**
* @typedef {object} SftpTransferSessionLeaseStore
* @property {(sftpId: string, transferId: string) => boolean} acquire
@@ -196,4 +206,5 @@ const sftpTransferSessionLeaseStore = createSftpTransferSessionLeaseStore();
module.exports = {
createSftpTransferSessionLeaseStore,
sftpTransferSessionLeaseStore,
channelAliasLeaseId,
};
+25 -3
View File
@@ -369,7 +369,13 @@ function parseKnownHostTarget(value) {
* text or the full known-hosts vault.
*/
function fingerprintKnownHostTrust(endpoint) {
if (!endpoint || endpoint.verifyHostKeys === false) return "disabled";
if (!endpoint) return "disabled";
const jumps = Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : [];
// A jump host can force verification while the target turns it off. The
// target flag alone must not drop that hop's pin from the digest.
const anyVerification = endpoint.verifyHostKeys !== false
|| jumps.some((hop) => (hop?.verifyHostKeys ?? endpoint.verifyHostKeys) !== false);
if (!anyVerification) return "disabled";
const targets = [];
const addTarget = (hostname, port, verifyHostKeys = true) => {
if (verifyHostKeys === false) return;
@@ -378,7 +384,7 @@ function fingerprintKnownHostTrust(endpoint) {
targets.push(`${normalizedHostname}|${Number(port) || 22}`);
};
addTarget(endpoint.hostname, endpoint.port, endpoint.verifyHostKeys);
for (const hop of Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : []) {
for (const hop of jumps) {
addTarget(
hop?.hostname,
hop?.port,
@@ -481,7 +487,12 @@ function fingerprintAuth(endpoint) {
certificate: endpoint.certificate ? secureDigest(endpoint.certificate) : "none",
requiresMfa: Boolean(endpoint.requiresMfa),
verifyHostKeys: endpoint.verifyHostKeys !== false,
knownHostTrust: fingerprintKnownHostTrust(endpoint),
// Channel adoption may compare a sibling key that ignores this digest, then
// checks the connection's accepted host key against the caller's records.
// Parked transport reuse still uses the full key.
knownHostTrust: endpoint.ignoreKnownHostTrustForReuse
? "ignored"
: fingerprintKnownHostTrust(endpoint),
authPolicyVersion: endpoint.authPolicyVersion ?? null,
useSshAgent: endpoint.useSshAgent ?? null,
identityAgent: endpoint.identityAgent ?? null,
@@ -628,6 +639,16 @@ function buildEndpointKey(endpoint) {
].join("|");
}
/**
* Same route as buildEndpointKey, but the known-host trust digest is ignored.
* Used only to find an already-authenticated SFTP channel after the renderer
* known-host list changes (trust during this app session).
*/
function buildChannelAdoptionKey(endpoint) {
if (!endpoint || typeof endpoint !== "object") return null;
return buildEndpointKey({ ...endpoint, ignoreKnownHostTrustForReuse: true });
}
function sameEndpoint(a, b) {
const left = normalizeEndpoint(a);
const right = normalizeEndpoint(b);
@@ -1544,6 +1565,7 @@ module.exports = {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
normalizeEndpoint,
@@ -27,6 +27,7 @@ const {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
normalizeEndpoint,
endpointAllowsReuse,
@@ -905,6 +906,46 @@ test("endpoint key changes when known-host trust content changes", () => {
);
});
test("a jump host pin changes the endpoint key when the target disables verification", () => {
const base = {
hostId: "jump-pin-target-disabled",
hostname: "target.example",
port: 22,
username: "alice",
authMethod: "password",
password: "secret",
verifyHostKeys: false,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: true }],
};
const pinned = (publicKey) => buildConnectionReuseEndpoint({
...base,
knownHosts: [{
hostname: "[bastion.example]:2222",
port: 2222,
keyType: "ssh-ed25519",
publicKey,
}],
});
const first = pinned("ssh-ed25519 AAAA_JUMP_FIRST");
const rotated = pinned("ssh-ed25519 AAAA_JUMP_ROTATED");
assert.notEqual(buildEndpointKey(rotated), buildEndpointKey(first));
assert.equal(buildChannelAdoptionKey(rotated), buildChannelAdoptionKey(first));
const bothOff = {
...base,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: false }],
};
const offFirst = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_FIRST" }],
}));
const offRotated = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_ROTATED" }],
}));
assert.equal(offRotated, offFirst);
});
test("endpoint key changes when an identity file changes in place", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-pool-key-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+208 -18
View File
@@ -1830,6 +1830,16 @@ function execSshCommandCancellable(sshClient, command, transfer) {
});
}
function markDisposableUploadChannel(channel) {
if (!channel || typeof channel !== "object") return channel;
// This subsystem can die without taking the shared browse connection with it.
try { channel.__netcattyDisposableChannel = true; } catch { /* ignore */ }
if (channel.sftp && typeof channel.sftp === "object" && channel.sftp !== channel) {
try { channel.sftp.__netcattyDisposableChannel = true; } catch { /* ignore */ }
}
return channel;
}
async function openIsolatedSftpChannel(client, signal = null) {
// The dedicated one-channel login already holds its only session channel.
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
@@ -2468,7 +2478,9 @@ async function uploadFile(
let isolated = null;
if (!client.__netcattySudoMode) {
try {
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
);
} catch (err) {
rememberPipelineError(err);
console.warn(
@@ -2534,7 +2546,9 @@ async function uploadFile(
if (!isolated && !client.__netcattySudoMode) {
try {
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
);
} catch (err) {
rememberPipelineError(err);
console.warn(
@@ -3058,10 +3072,8 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
if (!isWriteOpen) {
sftp.removeListener?.("end", onOpenChannelClosed);
sftp.removeListener?.("close", onOpenChannelClosed);
}
sftp.removeListener?.("end", onOpenChannelClosed);
sftp.removeListener?.("close", onOpenChannelClosed);
fn(value);
};
@@ -3119,7 +3131,9 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
};
const onOpenChannelClosed = () => {
onOpenChannelError(new Error("SFTP channel closed before the source file was opened"));
onOpenChannelError(isWriteOpen
? sftpChannelClosedError(null, sftp)
: new Error("SFTP channel closed before the source file was opened"));
};
if (transfer.cancelled) {
@@ -3131,15 +3145,15 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
transfer.abort = abortDuringOpen;
sftp.on?.("error", onOpenChannelError);
if (!isWriteOpen) {
sftp.on?.("end", onOpenChannelClosed);
sftp.on?.("close", onOpenChannelClosed);
// Write OPEN used to skip close/end. Killing the server channel then left
// the OPEN callback unanswered, so the upload stayed active with no error.
sftp.on?.("end", onOpenChannelClosed);
sftp.on?.("close", onOpenChannelClosed);
if (!isWriteOpen && sftp.readable === false) {
// A failed isolated download may fall back after the browse channel's
// end event already fired. ssh2 never answers a new OPEN on that channel.
if (sftp.readable === false) {
onOpenChannelClosed();
return;
}
onOpenChannelClosed();
return;
}
try {
@@ -3398,10 +3412,13 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
let settled = false;
let timer = null;
let unwatchAbort = null;
let unwatchDeath = () => {};
const cleanup = () => {
if (timer) clearTimeout(timer);
unwatchAbort?.();
unwatchAbort = null;
try { unwatchDeath(); } catch { /* ignore */ }
unwatchDeath = () => {};
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, count) => {
@@ -3411,6 +3428,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
if (error) reject(error);
else resolve(Number(count) || 0);
};
unwatchDeath = watchSftpChannelDeath(sftp, (error) => finish(error));
const onAbort = () => {
const error = new Error("Transfer cancelled");
error.code = "ABORT_ERR";
@@ -3440,7 +3458,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
received,
length - received,
position + received,
(error, count) => finish(error, count),
(error, count) => finish(error ? normalizeSftpChannelLoss(sftp, error) : error, count),
);
} catch (error) {
finish(error);
@@ -3680,12 +3698,181 @@ async function readLocalRange(fileHandle, buffer, position, length) {
}
}
/**
* One close/error listener per channel. ssh2 does not invoke pending READ/WRITE
* callbacks when the owner connection is destroyed, so each in-flight request
* must fail itself or the transfer stays at a partial percent forever.
* EOF (`end`) and the parent SSH client/socket are included: killing the
* server SFTP process often ends the channel without a client-side `error`,
* and a TCP reset is delivered on the SSH client rather than the SFTP stream.
*/
const sftpChannelDeathWaiters = new WeakMap();
function disposableUploadChannel(sftp) {
return sftp?.__netcattyDisposableChannel === true
|| sftp?.sftp?.__netcattyDisposableChannel === true;
}
function sftpChannelClosedError(cause, sftp) {
const error = new Error("SFTP channel closed");
error.code = "ERR_SFTP_CHANNEL_CLOSED";
error.sftpChannelClosed = true;
// The owner/borrowed/sudo channel is gone: do not fall through into another
// dial. A disposable upload subsystem can still fail over to the live browse
// channel.
if (!disposableUploadChannel(sftp)) error.noTransferFallback = true;
if (cause) error.cause = cause;
return error;
}
function isSftpChannelLossError(error) {
if (!error || typeof error !== "object") return false;
if (error.sftpChannelClosed) return true;
const code = String(error.code || "");
if (
code === "ECONNRESET"
|| code === "EPIPE"
|| code === "ECONNABORTED"
|| code === "ERR_SFTP_CHANNEL_CLOSED"
) {
return true;
}
const message = String(error.message || "");
return /no response from server/i.test(message)
|| /socket hang up/i.test(message)
|| /connection reset/i.test(message)
|| error.level === "client-socket";
}
function normalizeSftpChannelLoss(sftp, error) {
if (!error) return error;
if (error.sftpChannelClosed && (error.noTransferFallback || disposableUploadChannel(sftp))) {
return error;
}
const dead = !sftp || sftp.readable === false || sftp.destroyed === true;
if (dead || isSftpChannelLossError(error)) return sftpChannelClosedError(error, sftp);
return error;
}
function sftpChannelDeathTargets(sftp) {
const targets = [];
const add = (value) => {
if (!value || typeof value.on !== "function" || targets.includes(value)) return;
targets.push(value);
};
// ssh2-sftp-client `.on` listens on the SSH connection. Channel close/end
// is emitted on the inner stream the borrowed owner actually writes.
if (sftp?.sftp && sftp.sftp !== sftp) add(sftp.sftp);
add(sftp);
return targets;
}
function rejectPendingSftpRequests(sftp, error) {
const requests = sftp?._requests;
if (!requests || typeof requests !== "object") return;
const pending = requests;
sftp._requests = {};
for (const key of Object.keys(pending)) {
const callback = pending[key]?.cb;
if (typeof callback !== "function") continue;
try { callback(error); } catch { /* ignore */ }
}
}
function watchSftpChannelDeath(sftp, onDeath) {
const channels = sftpChannelDeathTargets(sftp);
const primary = channels[0];
if (!primary) return () => {};
let entry = sftpChannelDeathWaiters.get(primary);
if (!entry) {
const waiters = new Set();
const fail = (error, { flushRequests = false } = {}) => {
const wrapped = normalizeSftpChannelLoss(
primary,
error instanceof Error
? error
: new Error(String(error?.message || error || "SFTP channel error")),
);
// CHANNEL_CLOSE emits close/end and leaves READ/WRITE without a reply.
// A normal SFTP status error must not cancel the other in-flight requests.
if (flushRequests) {
for (const channel of channels) rejectPendingSftpRequests(channel, wrapped);
}
for (const waiter of [...waiters]) waiter(wrapped);
};
const client = primary._client || sftp?._client;
entry = {
waiters,
channels,
client,
onError: (error) => fail(error || new Error("SFTP channel error")),
onClose: () => fail(sftpChannelClosedError(null, sftp), { flushRequests: true }),
onClientError: (error) => {
if (isSftpChannelLossError(error)) fail(error || sftpChannelClosedError());
},
};
for (const channel of channels) {
channel.on("error", entry.onError);
channel.on("close", entry.onClose);
channel.on("end", entry.onClose);
}
if (client && typeof client.on === "function" && !channels.includes(client)) {
client.on("close", entry.onClose);
client.on("end", entry.onClose);
client.on("error", entry.onClientError);
}
sftpChannelDeathWaiters.set(primary, entry);
}
entry.waiters.add(onDeath);
// end/close is not replayed. OPEN can finish, the channel can die, and only
// then does WRITE/READ subscribe — readable is already false and the callback
// never comes. Fail this waiter after returning so the caller can unwatch.
const alreadyDead = channels.some((channel) => channel.readable === false || channel.destroyed === true);
if (alreadyDead) {
queueMicrotask(() => {
if (!entry.waiters.has(onDeath)) return;
onDeath(sftpChannelClosedError(null, sftp));
});
}
return () => {
const current = sftpChannelDeathWaiters.get(primary);
if (!current) return;
current.waiters.delete(onDeath);
if (current.waiters.size > 0) return;
for (const channel of current.channels) {
try { channel.removeListener("error", current.onError); } catch { /* ignore */ }
try { channel.removeListener("close", current.onClose); } catch { /* ignore */ }
try { channel.removeListener("end", current.onClose); } catch { /* ignore */ }
}
if (current.client && typeof current.client.removeListener === "function") {
try { current.client.removeListener("close", current.onClose); } catch { /* ignore */ }
try { current.client.removeListener("end", current.onClose); } catch { /* ignore */ }
try { current.client.removeListener("error", current.onClientError); } catch { /* ignore */ }
}
if (sftpChannelDeathWaiters.get(primary) === current) sftpChannelDeathWaiters.delete(primary);
};
}
function writeSftpRange(sftp, handle, buffer, position, length) {
return new Promise((resolve, reject) => {
sftp.write(handle, buffer, 0, length, position, (error) => {
let settled = false;
let unwatch = () => {};
const finish = (error) => {
if (settled) return;
settled = true;
try { unwatch(); } catch { /* ignore */ }
if (error) reject(error);
else resolve();
});
};
unwatch = watchSftpChannelDeath(sftp, finish);
try {
sftp.write(handle, buffer, 0, length, position, (error) => {
if (error) finish(normalizeSftpChannelLoss(sftp, error));
else finish();
});
} catch (error) {
finish(error);
}
});
}
@@ -4729,7 +4916,10 @@ async function uploadFileConcurrent(
// CLOSE request would hang forever with no callback from ssh2.
if (remoteHandle) {
const skipClose = disposeChannel && (failed || transfer.cancelled);
if (!skipClose) {
// A closed channel never answers CLOSE. Skip it; the 2s race is only for
// a live shared session. Falling through to another strategy on a dead
// channel is handled by noTransferFallback on the close error.
if (!skipClose && sftp?.readable !== false) {
let closeTimeout = null;
try {
await Promise.race([
+152
View File
@@ -13092,6 +13092,158 @@ test("single-channel SFTP upload does not open a second subsystem", async (t) =>
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
test("a closed disposable upload channel falls back once to the healthy shared channel", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-disposable-upload-fallback-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.from("disposable-fallback");
await fs.promises.writeFile(localPath, payload);
const targetPath = "/tmp/fresh.bin";
const remoteFiles = new Map();
let isolatedOpens = 0;
let sharedWrites = 0;
const rememberFile = (sftp) => {
sftp.lstat = (remotePath, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
callback(error);
return;
}
callback(null, {
size: remoteFiles.get(key).length,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
});
};
sftp.open = (remotePath, _flags, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
callback(null, Buffer.from(key));
};
sftp.close = (_handle, callback) => callback(null);
};
const shared = createFastSftp({});
rememberFile(shared);
shared.write = (handle, buffer, offset, length, position, callback) => {
sharedWrites += 1;
const key = handle.toString();
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
buffer.copy(next, position, offset, offset + length);
remoteFiles.set(key, next);
setImmediate(() => callback(null));
};
const client = {
sftp: shared,
stat: async (remotePath) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
throw error;
}
return { size: remoteFiles.get(key).length, isDirectory: false };
},
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
async rename(fromPath, toPath) {
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
remoteFiles.delete(String(fromPath));
},
client: {
sftp(callback) {
isolatedOpens += 1;
const isolated = createFastSftp({});
rememberFile(isolated);
isolated.write = () => {
isolated.readable = false;
isolated.emit("close");
};
callback(null, isolated);
},
},
};
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: "disposable-upload-fallback",
sourcePath: localPath,
targetPath,
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.ok(isolatedOpens >= 1);
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
for (const mode of ["single-channel", "sudo"]) {
test(`a closed ${mode} owner channel fails the upload without another dial`, async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-${mode}-owner-close-`));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.from("owner-channel-closed");
await fs.promises.writeFile(localPath, payload);
let secondChannelOpens = 0;
const shared = createFastSftp({
open(_remotePath, _flags, callback) {
callback(null, Buffer.from("remote-handle"));
},
write() {
shared.readable = false;
shared.emit("close");
},
close(_handle, callback) {
callback(null);
},
});
const client = {
__netcattySingleChannelSsh: mode === "single-channel",
__netcattySudoMode: mode === "sudo",
sftp: shared,
stat: async () => ({ size: 0, isDirectory: false }),
delete: async () => {},
rename: async () => {},
client: {
sftp(callback) {
secondChannelOpens += 1;
callback(new Error("second SFTP channel must not open"));
},
},
};
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await Promise.race([
transferBridge.startTransfer({ sender: createSender() }, {
transferId: `${mode}-owner-channel-closed`,
sourcePath: localPath,
targetPath: "/tmp/fresh.bin",
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("upload hung after the owner channel closed")), 3000);
}),
]);
assert.match(result.error || "", /SFTP channel closed/);
assert.equal(secondChannelOpens, 0);
});
}
test("single-channel SFTP download does not open a second subsystem", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-download-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
Vendored
+7
View File
@@ -120,6 +120,13 @@ declare global {
passphrase?: string;
knownHosts?: import("./domain/models").KnownHost[];
verifyHostKeys?: boolean;
/**
* When true, an untrusted host key fails the dial instead of waiting for
* a renderer prompt. Transfer opens set this: their session id is not the
* SFTP page's host-key dialog, so an unanswered prompt leaves the upload
* on "Waiting...".
*/
hostKeyUnattended?: boolean;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration