mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a44d62511 |
No files matched your search
@@ -22,7 +22,7 @@ import React, {
|
||||
} from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { classifyDistroId, formatHostPort, sftpRequiresDedicatedConnection } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import {
|
||||
@@ -76,8 +76,6 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -588,9 +586,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setSftpBannerDecision(null);
|
||||
return;
|
||||
}
|
||||
const deviceType = activeHost?.deviceType;
|
||||
// Network Device Mode is known before the banner. Do not wait to borrow
|
||||
// the terminal transport; that extra channel drops Huawei/Ruijie sessions.
|
||||
if (sftpRequiresDedicatedConnection({ deviceType })) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: true });
|
||||
return;
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: sftpRequiresDedicatedConnection({ deviceType }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,17 +606,23 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
singleChannel: sftpRequiresDedicatedConnection({
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
deviceType,
|
||||
}),
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: sftpRequiresDedicatedConnection({ deviceType }),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSessionId, activeSessionStatusForBanner, terminalBackend]);
|
||||
}, [activeHost?.deviceType, activeSessionId, activeSessionStatusForBanner, terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
pruneSftpSidePanelState(
|
||||
|
||||
@@ -57,4 +57,9 @@ test("single-channel SFTP opens a dedicated connection instead of the terminal c
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(sidePanelSource, /sftpRequiresDedicatedConnection\(\{\s*deviceType\s*\}\)/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/singleChannel: sftpRequiresDedicatedConnection\(\{\s*remoteSshVersion: info\?\.remoteSshVersion,\s*deviceType,\s*\}\)/,
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
shouldProbeSessionCwd,
|
||||
shouldSuggestNetworkDeviceMode,
|
||||
sftpRequiresDedicatedConnection,
|
||||
upsertHostById,
|
||||
} from "./host.ts";
|
||||
|
||||
@@ -662,6 +663,58 @@ test("hostRestrictsExtraSshChannels follows network-device mode only", () => {
|
||||
assert.equal(hostRestrictsExtraSshChannels(undefined), false);
|
||||
});
|
||||
|
||||
test("sftpRequiresDedicatedConnection uses a fresh login for Huawei and Ruijie banners", () => {
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "-" }), true);
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0--" }), true);
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "RGOS_SSH" }), true);
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0-RGOS_SSH" }), true);
|
||||
});
|
||||
|
||||
test("sftpRequiresDedicatedConnection still shares a normal OpenSSH terminal", () => {
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "OpenSSH_9.6" }), false);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0-OpenSSH_9.6p1" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "JumpServer" }), false);
|
||||
assert.equal(sftpRequiresDedicatedConnection({}), false);
|
||||
});
|
||||
|
||||
test("sftpRequiresDedicatedConnection uses a fresh login for network-device mode", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
deviceType: "network",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({ deviceType: "network" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
deviceType: "general",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftpRequiresDedicatedConnection keeps one-channel bastion banners on a fresh login", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({ remoteSshVersion: "CLOUDBILITY-4.14" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedConnection({
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
singleChannelSsh: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when extra SSH channels are restricted", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6", restrictExtraSshChannels: true }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { detectVendorFromSshVersion } from './sshServerVendor.mjs';
|
||||
export { detectVendorFromSshVersion } from './sshServerVendor.mjs';
|
||||
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
|
||||
export { sftpRequiresDedicatedConnection } from './sftpDedicatedConnection.mjs';
|
||||
import { Host, Snippet, TerminalSettings } from './models';
|
||||
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
|
||||
import { sanitizeHostIconFields } from './hostIcon';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function sftpRequiresDedicatedConnection(opts?: {
|
||||
remoteSshVersion?: string | null;
|
||||
deviceType?: string | null;
|
||||
singleChannelSsh?: boolean;
|
||||
}): boolean;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { detectVendorFromSshVersion } from "./sshServerVendor.mjs";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
|
||||
/**
|
||||
* True when SFTP must dial its own SSH login.
|
||||
* An extra channel on the terminal transport disconnects Huawei VRP, Ruijie,
|
||||
* and other gear that advertises a network-vendor banner (#3631, #1043), and
|
||||
* the same is true for one-channel bastions and Network Device Mode.
|
||||
*/
|
||||
export function sftpRequiresDedicatedConnection(opts = {}) {
|
||||
if (!opts || typeof opts !== "object") return false;
|
||||
if (opts.singleChannelSsh) return true;
|
||||
if (opts.deviceType === "network") return true;
|
||||
const remoteSshVersion = opts.remoteSshVersion || "";
|
||||
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|
||||
|| Boolean(detectVendorFromSshVersion(remoteSshVersion));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const { pipeline } = require("node:stream/promises");
|
||||
const { TextDecoder } = require("node:util");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const { executeBoundedSshCommand } = require("./boundedSshExec.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../domain/singleChannelSshBanner.shared.cjs");
|
||||
const { sftpRequiresDedicatedConnection } = require("../../domain/sftpDedicatedConnection.mjs");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||||
@@ -1954,10 +1954,14 @@ async function openSftpForSession(_event, payload) {
|
||||
source = { sessionId, ...ensureRemoteSftpSupport(sessionId) };
|
||||
}
|
||||
const { session, sshClient } = source;
|
||||
if (
|
||||
session.singleChannelSsh
|
||||
|| remoteSoftwareRequiresSingleChannel(session.remoteSshVersion || sshClient?._remoteVer)
|
||||
) {
|
||||
const deviceType = session.deviceType === "network" || payload?.deviceType === "network"
|
||||
? "network"
|
||||
: (session.deviceType || payload?.deviceType);
|
||||
if (sftpRequiresDedicatedConnection({
|
||||
singleChannelSsh: !!session.singleChannelSsh,
|
||||
remoteSshVersion: session.remoteSshVersion || sshClient?._remoteVer,
|
||||
deviceType,
|
||||
})) {
|
||||
const err = new Error(
|
||||
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
|
||||
);
|
||||
|
||||
@@ -324,6 +324,125 @@ test("openSftpForSession refuses a one-channel bastion banner without the host f
|
||||
}
|
||||
});
|
||||
|
||||
test("openSftpForSession refuses a network-vendor banner before opening a channel", async () => {
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
_remoteVer: remoteSshVersion,
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, { end() {} });
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion,
|
||||
};
|
||||
const sessions = new Map([["session-vendor", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftpForSession(null, { sessionId: "session-vendor" }),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openSftpForSession refuses network-device mode even when the banner is OpenSSH", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
_remoteVer: "OpenSSH_9.6",
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, { end() {} });
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
deviceType: "network",
|
||||
};
|
||||
const sessions = new Map([["session-network", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftpForSession(null, { sessionId: "session-network" }),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpForSession refuses payload deviceType network before opening a channel", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
_remoteVer: "OpenSSH_9.6",
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, { end() {} });
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
};
|
||||
const sessions = new Map([["session-payload-network", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftpForSession(null, {
|
||||
sessionId: "session-payload-network",
|
||||
deviceType: "network",
|
||||
}),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpForSession still shares the terminal transport for OpenSSH", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const fakeSftp = {
|
||||
readdir() {},
|
||||
stat() {},
|
||||
mkdir() {},
|
||||
unlink() {},
|
||||
end() {},
|
||||
};
|
||||
const conn = {
|
||||
_remoteVer: "SSH-2.0-OpenSSH_9.6p1",
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, fakeSftp);
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion: "SSH-2.0-OpenSSH_9.6p1",
|
||||
deviceType: "general",
|
||||
};
|
||||
const sessions = new Map([["session-openssh", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "session-openssh" });
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(sftpCalls, 1);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("openSftpForSession still shares the terminal transport for JumpServer", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
|
||||
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
|
||||
const { sftpRequiresDedicatedConnection } = require("../../../domain/sftpDedicatedConnection.mjs");
|
||||
|
||||
/** Bound shell/scp probes so a hung remote exec cannot leave the panel connecting forever. */
|
||||
const SCP_PROBE_TIMEOUT_MS = 15_000;
|
||||
@@ -779,11 +780,14 @@ function createOpenConnectionApi(ctx) {
|
||||
let pendingDialCoordination = options._pendingDialCoordination || null;
|
||||
|
||||
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
|
||||
if (
|
||||
options.singleChannelSsh
|
||||
|| transport.endpoint?.singleChannelSsh
|
||||
|| remoteSoftwareRequiresSingleChannel(transport?.conn?._remoteVer)
|
||||
) {
|
||||
const sharedDeviceType = options.deviceType === "network" || transport.endpoint?.deviceType === "network"
|
||||
? "network"
|
||||
: (options.deviceType || transport.endpoint?.deviceType);
|
||||
if (sftpRequiresDedicatedConnection({
|
||||
singleChannelSsh: !!(options.singleChannelSsh || transport.endpoint?.singleChannelSsh),
|
||||
remoteSshVersion: transport?.conn?._remoteVer,
|
||||
deviceType: sharedDeviceType,
|
||||
})) {
|
||||
const err = new Error(
|
||||
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user