Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 3cae060ed8 feat(ssh): opt in legacy algorithms for temporary connections
Saved hosts keep their own setting. External launches and quick connect
with no saved host can use the same legacy algorithm list when the new
global setting is turned on. It stays off by default.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:10:42 +00:00
16 changed files with 268 additions and 6 deletions

No files matched your search

+11 -6
View File
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { prepareConnectionHost } from '../../domain/legacyAlgorithms';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -1450,11 +1451,15 @@ export function AppSideEffects() {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
if (host.ephemeral) {
const connectionHost = prepareConnectionHost(
host,
terminalSettings.temporaryConnectionsAllowLegacyAlgorithms,
);
if (connectionHost.ephemeral) {
setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
if (existingIndex < 0) return [...previous, connectionHost];
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
});
}
const effectiveHostResolver = alreadyEffective
@@ -1464,7 +1469,7 @@ export function AppSideEffects() {
addConnectionLog,
connectToHost,
discoveredShells,
host,
host: connectionHost,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
@@ -1472,7 +1477,7 @@ export function AppSideEffects() {
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
+2
View File
@@ -836,6 +836,8 @@ Highlight the focused split pane:
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
+2
View File
@@ -751,6 +751,8 @@ Resalta el panel dividido enfocado:
'settings.terminal.section.connection': 'Conexión',
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
+2
View File
@@ -791,6 +791,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
@@ -47,6 +47,22 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include temporary-connection legacy algorithm labels", () => {
const keys = [
"settings.terminal.connection.temporaryLegacyAlgorithms",
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
];
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
for (const locale of locales) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
}
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
});
test("localized settings include the global default proxy labels", () => {
const keys = [
"settings.terminal.connection.defaultProxy",
@@ -454,6 +454,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
'settings.terminal.connection.defaultProxy': '默认代理',
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
'settings.terminal.connection.defaultProxy.none': '无',
@@ -454,6 +454,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
'settings.terminal.connection.defaultProxy': '預設代理',
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
'settings.terminal.connection.defaultProxy.none': '無',
@@ -998,6 +998,16 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-temporary-legacy-algorithms"
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
>
<Toggle
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-ssh-auto-reconnect"
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
+1
View File
@@ -486,5 +486,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
pluginConnection,
};
delete (sanitized as { singleChannelSsh?: unknown }).singleChannelSsh;
delete (sanitized as { backedBySavedHost?: boolean }).backedBySavedHost;
return stripBuiltInConnectionFieldsForPluginHost(sanitized);
};
+137
View File
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sanitizeHost } from "./host.ts";
import {
prepareConnectionHost,
resolveConnectionLegacyAlgorithms,
} from "./legacyAlgorithms.ts";
import { normalizeTerminalSettings } from "./models/terminal.ts";
import type { Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "host",
hostname: "bastion.example",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
});
test("a saved host uses its per-host legacy algorithm value when the global setting is on", () => {
const saved = host({
hostname: "127.0.0.1",
port: 2222,
legacyAlgorithms: false,
});
assert.equal(prepareConnectionHost(saved, true), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: false,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), false);
});
test("a saved host keeps legacy algorithms enabled when the global setting is off", () => {
const saved = host({ legacyAlgorithms: true });
assert.equal(prepareConnectionHost(saved, false), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
}), true);
});
test("a saved host with no explicit value does not inherit the global temporary setting", () => {
const saved = host({ hostname: "127.0.0.1", legacyAlgorithms: undefined });
assert.equal(prepareConnectionHost(saved, true), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: undefined,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), false);
});
test("a temporary connection uses the global legacy algorithm value", () => {
const temporary = host({
id: "ephemeral-1",
ephemeral: true,
hostname: "127.0.0.1",
port: 54321,
});
assert.equal(prepareConnectionHost(temporary, true).legacyAlgorithms, true);
assert.equal(prepareConnectionHost(temporary, false).legacyAlgorithms, undefined);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), true);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
hostLegacyAlgorithms: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
}), false);
});
test("an ephemeral clone of a saved host keeps that host's legacy algorithm value", () => {
const cloned = host({
id: "ephemeral-from-saved",
ephemeral: true,
backedBySavedHost: true,
hostname: "127.0.0.1",
port: 61000,
legacyAlgorithms: false,
});
assert.equal(prepareConnectionHost(cloned, true), cloned);
assert.equal(prepareConnectionHost(host({
ephemeral: true,
backedBySavedHost: true,
legacyAlgorithms: true,
}), false).legacyAlgorithms, true);
});
test("temporary connection legacy algorithms default to off", () => {
const temporary = host({ ephemeral: true, hostname: "127.0.0.1" });
assert.equal(resolveConnectionLegacyAlgorithms({ hasSavedHost: false }), false);
assert.equal(prepareConnectionHost(temporary).legacyAlgorithms, undefined);
assert.equal(normalizeTerminalSettings().temporaryConnectionsAllowLegacyAlgorithms, false);
assert.equal(
normalizeTerminalSettings({ temporaryConnectionsAllowLegacyAlgorithms: true })
.temporaryConnectionsAllowLegacyAlgorithms,
true,
);
assert.equal(
normalizeTerminalSettings({
temporaryConnectionsAllowLegacyAlgorithms: "yes" as never,
}).temporaryConnectionsAllowLegacyAlgorithms,
false,
);
});
test("saved-host clones do not persist the runtime backing marker", () => {
const sanitized = sanitizeHost(host({
ephemeral: true,
backedBySavedHost: true,
legacyAlgorithms: true,
}));
assert.equal("backedBySavedHost" in sanitized, false);
assert.equal(sanitized.legacyAlgorithms, true);
});
test("temporary telnet connections do not receive the SSH legacy algorithm flag", () => {
const telnet = host({
ephemeral: true,
protocol: "telnet",
hostname: "127.0.0.1",
});
assert.equal(prepareConnectionHost(telnet, true), telnet);
});
+58
View File
@@ -0,0 +1,58 @@
import type { Host } from "./models";
export interface ConnectionLegacyAlgorithmInput {
/** True when a saved vault host owns this connection, including an ephemeral clone of one. */
hasSavedHost: boolean;
/** Per-host value after that host's own settings are applied. */
hostLegacyAlgorithms?: boolean;
/**
* Global opt-in for SSH connections with no saved host.
* Missing or any non-true value stays off.
*/
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
}
/**
* Saved hosts keep their own legacy-algorithm choice. Connections with no
* saved host use the global temporary-connection opt-in, which defaults to
* off. The address is not an input, so 127.0.0.1 alone never opts in.
*/
export function resolveConnectionLegacyAlgorithms(
input: ConnectionLegacyAlgorithmInput,
): boolean {
if (input.hasSavedHost) return input.hostLegacyAlgorithms === true;
return input.temporaryConnectionsAllowLegacyAlgorithms === true;
}
export function connectionHasSavedHost(
host: Pick<Host, "ephemeral" | "backedBySavedHost"> | null | undefined,
): boolean {
if (!host) return false;
if (host.backedBySavedHost === true) return true;
return host.ephemeral !== true;
}
function isTemporarySshHost(
host: Pick<Host, "ephemeral" | "backedBySavedHost" | "protocol">,
): boolean {
if (connectionHasSavedHost(host)) return false;
return host.protocol === undefined || host.protocol === "ssh";
}
/**
* Stamp the resolved legacy-algorithm flag onto unsaved temporary SSH hosts.
* Saved hosts, and ephemeral clones of saved hosts, are returned unchanged so
* their own value (including a later group default) still applies.
*/
export function prepareConnectionHost<T extends Host>(
host: T,
temporaryConnectionsAllowLegacyAlgorithms?: boolean,
): T {
if (!isTemporarySshHost(host)) return host;
const legacyAlgorithms = resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
temporaryConnectionsAllowLegacyAlgorithms,
});
if ((host.legacyAlgorithms === true) === legacyAlgorithms) return host;
return { ...host, legacyAlgorithms };
}
+4
View File
@@ -183,6 +183,10 @@ export interface Host {
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
// Runtime marker for an ephemeral host cloned from a saved vault host.
// Those connections keep the saved host's own SSH settings. Pure temporary
// launches (external command lines, quick connect) leave this unset.
backedBySavedHost?: boolean;
// Runtime hint for deep-link launches that target file transfer (e.g.
// JumpServer sftp payloads): auto-open the SFTP side panel on connect.
autoOpenSftpPanel?: boolean;
+9
View File
@@ -143,6 +143,12 @@ export interface TerminalSettings {
// SSH Connection
verifyHostKeys: boolean; // Verify SSH host keys before authenticating
/**
* Offer the per-host legacy SSH algorithm list for connections that have
* no saved host (external launches and quick connect). Saved hosts ignore
* this and keep their own setting. Off by default.
*/
temporaryConnectionsAllowLegacyAlgorithms: boolean;
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
sshAutoReconnectEnabled: boolean; // Automatically reconnect SSH sessions after unexpected disconnects
@@ -468,6 +474,8 @@ export const normalizeTerminalSettings = (
return {
...mergedSettings,
temporaryConnectionsAllowLegacyAlgorithms:
mergedSettings.temporaryConnectionsAllowLegacyAlgorithms === true,
rendererType,
autocompleteMaxSuggestions,
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
@@ -568,6 +576,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
// reply to keepalive@openssh.com (older routers/switches) should set their
// own per-host keepaliveOverride and dial these values down.
verifyHostKeys: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
+8
View File
@@ -480,6 +480,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.connection.verifyHostKeys.desc",
sectionKey: "settings.terminal.section.connection",
},
{
id: "terminal-temporary-legacy-algorithms",
tab: "terminal",
labelKey: "settings.terminal.connection.temporaryLegacyAlgorithms",
descriptionKey: "settings.terminal.connection.temporaryLegacyAlgorithms.desc",
sectionKey: "settings.terminal.section.connection",
keywords: ["legacy", "kex", "putty", "旧版算法", "演算法", "堡垒机"],
},
{
id: "terminal-ssh-auto-reconnect",
tab: "terminal",
+3
View File
@@ -118,6 +118,7 @@ test("buildSshDeepLinkEphemeralHostFromSaved keeps saved settings but overrides
proxyProfileId: "proxy-1",
hostChain: { hostIds: ["jump-1"] },
charset: "utf8",
legacyAlgorithms: true,
moshEnabled: true,
createdAt: 1,
};
@@ -141,6 +142,8 @@ test("buildSshDeepLinkEphemeralHostFromSaved keeps saved settings but overrides
// Group is cleared so effective-host resolution cannot re-inherit
// group credentials over the one-time password.
assert.equal(ephemeral.group, "");
assert.equal(ephemeral.backedBySavedHost, true);
assert.equal(ephemeral.legacyAlgorithms, true);
assert.equal(ephemeral.proxyProfileId, "proxy-1");
assert.deepEqual(ephemeral.hostChain, { hostIds: ["jump-1"] });
assert.equal(ephemeral.charset, "utf8");
+1
View File
@@ -146,6 +146,7 @@ export const buildSshDeepLinkEphemeralHostFromSaved = (
savePassword: false,
group: "",
ephemeral: true,
backedBySavedHost: true,
protocol: "ssh",
moshEnabled: false,
etEnabled: false,