mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece2703a4a | ||
|
|
081c6dc0a7 | ||
|
|
1b5d183599 | ||
|
|
c47d4e17b4 | ||
|
|
bd6d6bf7d5 | ||
|
|
57cb5e3192 | ||
|
|
58978566bb | ||
|
|
b4ed76c648 | ||
|
|
5facc87597 | ||
|
|
48f2c2fd82 |
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(
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user