mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c300595fb8 | ||
|
|
d5395abc38 | ||
|
|
ade3f0940a |
No files matched your search
@@ -76,8 +76,8 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
|
||||
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";
|
||||
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
remoteSshVersion?: string;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
// before choosing a shared terminal channel, so a one-channel bastion or
|
||||
// network-vendor switch dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
// Independent login: network-device mode, a network-vendor banner, or a
|
||||
// one-channel bastion. Do not open SFTP on the terminal connection.
|
||||
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
});
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
})}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
|
||||
+12
-2
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// The backend pwd probe opens an exec channel on the terminal connection;
|
||||
// network devices (mode, detected distro, or vendor banner such as Huawei
|
||||
// `-`) can drop the whole session for it (#1043, #3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionRef.current) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
}, [host, terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
|
||||
@@ -1594,7 +1594,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// Same rule as the terminal's own SFTP path lookup (#3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionId && host) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const slotSource = readFileSync(
|
||||
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId: reuseTerminalTransport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
const assertBannerPwdGuard = (source: string, fnName: string) => {
|
||||
const start = source.indexOf(`const ${fnName} = useCallback`);
|
||||
assert.ok(start >= 0, fnName);
|
||||
const body = source.slice(start, start + 1600);
|
||||
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
|
||||
assert.match(body, /getSessionRemoteInfo\?\./);
|
||||
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
|
||||
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
|
||||
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
|
||||
assert.match(body, /getSessionPwd: skipBackendPwd/);
|
||||
};
|
||||
|
||||
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
|
||||
assertBannerPwdGuard(layerSource, "getTerminalCwd");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
|
||||
);
|
||||
assert.match(
|
||||
layerSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
|
||||
);
|
||||
});
|
||||
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse the terminal transport only when SSH is connected, or when a strict
|
||||
// pending source id is already pinned. A dedicated single-channel login never
|
||||
// reuses that transport. A linked id may arrive while reconnecting.
|
||||
// pending source id is already pinned. A dedicated login (bastion banner,
|
||||
// network-vendor banner, or network-device mode) never reuses that
|
||||
// transport. A linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
|
||||
|
||||
test("network-device mode forces a dedicated SFTP login before any banner", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
// Huawei `-` must not become a global single-channel bastion token.
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("other network-vendor banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"SSH-2.0-Cisco-1.25",
|
||||
"SSH-2.0-H3C-Comware-7.1.064",
|
||||
"SSH-2.0-ROSSSH",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("one-channel bastion banners still force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"BHostSSH_7.0",
|
||||
"SSH-2.0-TERM-SSHD",
|
||||
"CLOUDBILITY-4.14",
|
||||
"SSH-2.0-CloudLinker",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({}), false);
|
||||
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
false,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
|
||||
import type { Host } from "./models";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
|
||||
export type SftpDedicatedLoginInput = {
|
||||
host?: Pick<Host, "deviceType"> | null;
|
||||
remoteSshVersion?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SFTP must dial its own SSH login when an extra subsystem on the terminal
|
||||
* TCP session would drop the shell (#3631).
|
||||
*
|
||||
* Network-device mode is enough on its own, with or without a banner.
|
||||
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
|
||||
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
|
||||
* bastion software tokens are included here. This does not stamp
|
||||
* session.singleChannelSsh; that flag stays reserved for bastion banners.
|
||||
*/
|
||||
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
|
||||
if (hostRestrictsExtraSshChannels(input.host)) return true;
|
||||
const remoteSshVersion = input.remoteSshVersion || undefined;
|
||||
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|
||||
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
|
||||
}
|
||||
Reference in new issue
Block a user