From 2514e509c6c4e9a29f238a127e977302fbe21e11 Mon Sep 17 00:00:00 2001 From: zhaolei <256168157@qq.com> Date: Wed, 21 Oct 2026 11:50:00 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix:=20portable=20=E7=BB=BF=E8=89=B2?= =?UTF-8?q?=E5=8D=95=E6=9C=BA=E7=89=88=20SSH=20=E8=BF=9E=E6=8E=A5=20+=20?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E6=95=B0=E6=8D=AE=20+=20=E6=89=93=E5=8C=85?= =?UTF-8?q?=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSH 连接修复: - main.cjs 顶部在所有 ssh2 require 之前安装 boringSslDhCompat 补丁 - 5 条 SSH 连接路径统一 legacyAlgorithms 默认值为 true portable 版主机数据修复: - main.cjs portable 模式设置 NETCATTY_PORTABLE=1 - preload.cjs 暴露 window.netcatty.isPortable - useVaultState portable 模式跳过 safeStorage DPAPI 加密的 hosts_v1 - inspection saveVaultHosts handler 补 legacyAlgorithms/tags/os 字段映射 打包体积修复: - 恢复 electron-builder.config.cjs 原始 40+ 条排除规则 - package.json 加 pend 运行时依赖 --- application/state/useVaultState.ts | 55 +++++++- .../monitoring/MonitoringTopologyView.tsx | 2 +- electron-builder.config.cjs | 130 +++++++++++++++--- electron/bridges/inspection/index.cjs | 3 + electron/bridges/portForwardingBridge.cjs | 2 +- electron/bridges/sshBridge.cjs | 12 +- electron/bridges/sshBridge/execCommand.cjs | 2 +- electron/bridges/sshBridge/startSession.cjs | 10 +- electron/main.cjs | 11 ++ electron/preload.cjs | 7 +- package.json | 1 + 11 files changed, 206 insertions(+), 29 deletions(-) diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index 924867c..878a378 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -1442,7 +1442,13 @@ export const useVaultState = () => { recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS); }); if (cancelled) return; - const savedHosts = localStorageAdapter.read(STORAGE_KEY_HOSTS); + // portable 版 safeStorage DPAPI 绑定 userData 路径,解不开 dev 复制过来的 + // LS 加密 blob。强制跳过 hosts_v1,直接走 else 分支从 inspection-data.json + // 读明文 _vault_hosts(含 password + legacyAlgorithms)。 + const isPortable = (window as any).netcatty?.isPortable === true; + const savedHosts = isPortable + ? undefined + : localStorageAdapter.read(STORAGE_KEY_HOSTS); if (savedHosts) { // Capture version before the async gap so that any write occurring @@ -1452,6 +1458,17 @@ export const useVaultState = () => { const decrypted = await decryptHosts(savedHosts); if (cancelled) return; if (ver === hostsWriteVersion.current) { + // safeStorage 解密失败时 decryptField 会返回 enc:v1 占位符, + // sanitizeHost 再把它过滤成 undefined → 密码/私钥消失。 + // 检查解密后是否大部分凭证都被清空了,是的话强制 fallback + // 到 inspectionStorage 的明文 _vault_hosts(打包版 portable 场景)。 + const hasAnyPassword = decrypted.some((h: Host) => + h.password && !h.password.startsWith("enc:v1:")); + const hasAnyPrivateKey = decrypted.some((h: Host) => + h.privateKey && !h.privateKey.startsWith("enc:v1:")); + const credentialsLikelyLost = + decrypted.length > 0 && !hasAnyPassword && !hasAnyPrivateKey; + const sanitized = normalizeVaultOrder( migrateHostsFromLegacyLineTimestamps( decrypted.map((host) => sanitizeHost(host)), @@ -1473,6 +1490,41 @@ export const useVaultState = () => { return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc); }); hostsWritePendingRef.current = writePromise; + + // 解密后凭证丢失 → 用 inspectionStorage 的明文覆盖 + if (credentialsLikelyLost) { + const nc = (window as any).netcatty; + if (nc?.inspection?.getVaultHosts) { + try { + const vaultHosts = await nc.inspection.getVaultHosts(); + if (Array.isArray(vaultHosts) && vaultHosts.length > 0) { + const fallback: Host[] = vaultHosts.map((v: any) => ({ + id: v.id, + label: v.label || "", + hostname: v.hostname || "", + port: v.port || 22, + username: v.username || "", + password: v.password || undefined, + identityFileId: v.identityFileId || undefined, + privateKey: v.privateKey || "", + passphrase: v.passphrase || "", + tags: v.tags || [], + os: v.os || "linux", + type: v.type || "", + distro: v.distro || "", + group: v.group || "", + legacyAlgorithms: v.legacyAlgorithms, + })); + const fbSanitized = normalizeVaultOrder( + fallback.map((h) => sanitizeHost(h)), + ); + if (cancelled) return; + hostsRef.current = fbSanitized; + setHosts(fbSanitized); + } + } catch {} + } + } } } else { // 本地 Local Storage 为空(可能是 origin 隔离导致的打包版环境) @@ -1499,6 +1551,7 @@ export const useVaultState = () => { type: v.type || "", distro: v.distro || "", group: v.group || "", + legacyAlgorithms: v.legacyAlgorithms, })); if (cancelled) return; const sanitized = normalizeVaultOrder( diff --git a/components/monitoring/MonitoringTopologyView.tsx b/components/monitoring/MonitoringTopologyView.tsx index 836f99b..e9373eb 100644 --- a/components/monitoring/MonitoringTopologyView.tsx +++ b/components/monitoring/MonitoringTopologyView.tsx @@ -1,4 +1,4 @@ -/** +/** * 运行监控 - 3D 拓扑大屏 * * 移植自 ITYUNWEI TopologyView.vue diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 61c5c5f..6d79e39 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -1,10 +1,6 @@ -const path = require('node:path'); +const path = require('node:path'); const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs'); const { etExtraResources } = require('./scripts/et-extra-resources.cjs'); -// 说明:electron-builder 依赖收集器在 monorepo/workspace 项目里会漏掉 -// workspace 子包引用但根 package.json 没声明的运行时依赖(hoist 上来的), -// 且追踪传递依赖时容易漏包。这里直接把整个 node_modules 目录纳入 files, -// 确保运行时 require 什么都能找到。用排除列表去掉前端/构建工具/文档/测试代码。 /** * @type {import('electron-builder').Configuration} @@ -58,19 +54,117 @@ module.exports = { output: 'release' }, files: [ - // electron-builder 收集器只读根 package.json,但 workspace 子包的运行时依赖 - // 会 hoist 到根 node_modules 却没在根 package.json 里声明。 - // 收集器会漏掉这些包,所以把漏的补进根 package.json 后它就能追踪完整传递依赖树。 - "dist/**/*", - "electron/**/*", - "!electron/bridges/windowsHelloHelper/build/**/*", - "!electron/plugins/fixtures/**/*", - "infrastructure/config/terminalFlowConstants.cjs", - "infrastructure/config/terminalFlowConstants.json", - "domain/terminalPromptSecurity.shared.cjs", - "lib/**/*.cjs", - "lib/**/*.json", - "skills/**/*" + 'dist/**/*', + 'electron/**/*', + // The Windows Hello helper is shipped once through win.extraResources. + // Exclude local per-arch build output from app.asar on every platform. + '!electron/bridges/windowsHelloHelper/build/**/*', + // Runtime smoke fixtures are built test packages, not host resources. + // Keep them out of production ASARs so no example plugin can be + // mistaken for an installed or host-trusted package. + '!electron/plugins/fixtures/**/*', + // Main-process terminal flow control reads shared thresholds from here + // (terminalFlowAck.cjs). Must ship beside electron/ in app.asar. + 'infrastructure/config/terminalFlowConstants.cjs', + 'infrastructure/config/terminalFlowConstants.json', + // Renderer and Mosh's main-process bootstrap share the same + // fail-closed prompt classifier at runtime. + 'domain/terminalPromptSecurity.shared.cjs', + 'lib/**/*.cjs', + 'lib/**/*.json', + 'skills/**/*', + '!public/**/*', + '!**/*.map', + '!**/*.d.ts', + '!**/*.d.mts', + '!**/*.d.cts', + '!**/*.ts', + '!**/*.tsx', + '!**/*.test.*', + '!**/*.spec.*', + '!**/__tests__/**/*', + '!**/test/**/*', + '!**/tests/**/*', + '!**/example/**/*', + '!**/examples/**/*', + '!node_modules/**/docs/**/*', + '!node_modules/**/doc/**/*', + '!node_modules/**/benchmark/**/*', + '!node_modules/**/benchmarks/**/*', + // Renderer-only packages are compiled into dist by Vite. Keep them + // installed for npm run dev/build, but do not ship the duplicate source + // packages in release artifacts. + '!node_modules/@fontsource/**/*', + '!node_modules/@monaco-editor/**/*', + '!node_modules/@radix-ui/**/*', + '!node_modules/@xterm/**/*', + '!node_modules/lucide-react/**/*', + '!node_modules/monaco-editor/**/*', + '!node_modules/react/**/*', + '!node_modules/react-dom/**/*', + '!node_modules/ai/**/*', + '!node_modules/@ai-sdk/**/*', + '!node_modules/@mdxeditor/**/*', + '!node_modules/streamdown/**/*', + '!node_modules/@streamdown/**/*', + '!node_modules/@tanstack/react-virtual/**/*', + '!node_modules/pinyin-pro/**/*', + '!node_modules/re2js/**/*', + '!node_modules/@eslint-community/regexpp/**/*', + '!node_modules/clsx/**/*', + '!node_modules/tailwind-merge/**/*', + '!node_modules/use-stick-to-bottom/**/*', + '!node_modules/lexical/**/*', + '!node_modules/@lexical/**/*', + '!node_modules/@codemirror/**/*', + '!node_modules/katex/**/*', + '!node_modules/shiki/**/*', + '!node_modules/@shiki/**/*', + // Heavy cloud completion specs are intentionally not bundled. The main + // process filters the same prefixes so dev and packaged builds behave + // consistently. + '!node_modules/@withfig/autocomplete/build/aws.js', + '!node_modules/@withfig/autocomplete/build/aws/**/*', + '!node_modules/@withfig/autocomplete/build/gcloud.js', + '!node_modules/@withfig/autocomplete/build/gcloud/**/*', + '!node_modules/@withfig/autocomplete/build/az/**/*', + // Fig specs are already compiled JavaScript; TypeScript is only pulled + // in by Fig helper packages as build tooling and is not needed at app + // runtime. + '!node_modules/typescript/**/*', + // ── Exclude per-platform native agent binaries (~100s of MB each). ── + // Netcatty is "bring your own CLI": each SDK is pointed at the user's + // system-installed CLI via an absolute path override (claude + // pathToClaudeCodeExecutable / codex codexPathOverride / copilot cliPath). + // Only the SDKs' JS is bundled; the heavy per-arch binaries are dropped. + // NOTE: claude-agent-sdk vendors the `claude` binary as a NESTED package + // (claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-), + // so this exclusion must match at any depth (**/), not just top-level. + // The codex/copilot exclusions target only the per-arch binary packages + // (codex- / copilot-) — NOT @openai/codex-sdk / copilot-sdk, + // whose JS we DO bundle. @github/copilot is the full ~288MB CLI (with + // per-platform prebuilds); netcatty uses the user's copilot via cliPath, + // so it is excluded entirely. + '!**/@anthropic-ai/claude-agent-sdk-*/**/*', + '!node_modules/@anthropic-ai/claude-code-*/**/*', + '!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*', + '!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*', + '!node_modules/@github/copilot/**/*', + '!node_modules/opencode-{darwin,linux,linuxmusl,windows}-*/**/*', + '!node_modules/opencode-ai/**/*', + // CodeBuddy follows the same first-party integration model as the + // other coding agents: Netcatty discovers and passes the user's + // installed CLI path to the SDK. Keep the small SDK wrapper, but do not + // bundle the full CodeBuddy CLI payload (rg vendors + web UI). + '!node_modules/@tencent-ai/agent-sdk/cli/**/*', + // Netcatty loads Cursor SDK through ESM dynamic import, so the duplicate + // CommonJS build and type metadata are not needed at runtime. + '!node_modules/@cursor/sdk/dist/cjs/**/*', + '!node_modules/@cursor/sdk/dist/**/*.d.ts', + '!node_modules/@cursor/sdk/dist/**/*.d.ts.map', + // sqlite3 rebuilds a native module for Electron; its upstream source + // tarball is build-time payload only. + '!node_modules/sqlite3/deps/**/*' ], asarUnpack: [ 'node_modules/node-pty/**/*', diff --git a/electron/bridges/inspection/index.cjs b/electron/bridges/inspection/index.cjs index 66f1d44..de755a2 100644 --- a/electron/bridges/inspection/index.cjs +++ b/electron/bridges/inspection/index.cjs @@ -1620,9 +1620,12 @@ function registerInspectionHandlers(ipcMain, options) { identityFileId: h.identityFileId, privateKey: h.privateKey, passphrase: h.passphrase, + tags: h.tags || [], + os: h.os || "linux", type: h.type || "", distro: h.distro || h.catalogId || "", group: h.group || "", + legacyAlgorithms: h.legacyAlgorithms, })); writeStorage("_vault_hosts", mapped); return { ok: true, count: mapped.length }; diff --git a/electron/bridges/portForwardingBridge.cjs b/electron/bridges/portForwardingBridge.cjs index 49fe04b..677022b 100644 --- a/electron/bridges/portForwardingBridge.cjs +++ b/electron/bridges/portForwardingBridge.cjs @@ -1388,7 +1388,7 @@ async function startPortForward(event, payload) { keepaliveCountMax: keepalivePolicy.keepaliveCountMax, // Enable keyboard-interactive authentication (required for 2FA/MFA) tryKeyboard: true, - algorithms: buildAlgorithms(legacyAlgorithms, { skipEcdsaHostKey, algorithmOverrides }), + algorithms: buildAlgorithms(legacyAlgorithms !== false, { skipEcdsaHostKey, algorithmOverrides }), }; connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({ sender, diff --git a/electron/bridges/sshBridge.cjs b/electron/bridges/sshBridge.cjs index db896a0..636531a 100644 --- a/electron/bridges/sshBridge.cjs +++ b/electron/bridges/sshBridge.cjs @@ -346,7 +346,7 @@ function createSshDiagnosticLogger(enabled) { function shouldLogSshDebugMessage(msg) { if (typeof msg !== "string") return false; - return /auth|publickey|keyboard|handshake|kex|newkeys|dh gex/i.test(msg); + return /auth|publickey|keyboard|handshake|kex|newkeys|dh gex|Inbound|Outbound/i.test(msg); } function attachSshDebugLogger(connectOpts, logger = log) { @@ -586,8 +586,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target // when the leaf's ECDSA skip is applied to it. Treat both as // strictly per-host: an unset hop value uses the hop's default // offer, not the leaf's narrower setting. + // Default legacyAlgorithms to true so NetMesh can connect to older + // network devices (Huawei, H3C, Cisco, etc.) that only support + // legacy KEX algorithms like diffie-hellman-group1-sha1. Modern + // devices still negotiate the strongest mutually supported algorithm + // first — offering legacy algorithms alongside modern ones does not + // weaken security on capable servers. algorithms: buildAlgorithms( - jump.legacyAlgorithms ?? options.legacyAlgorithms, + (jump.legacyAlgorithms ?? options.legacyAlgorithms) !== false, { skipEcdsaHostKey: jump.skipEcdsaHostKey, algorithmOverrides: jump.algorithmOverrides, @@ -607,7 +613,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target logSshAlgorithms("Jump host", connOpts.algorithms, { hostname: jump.hostname, port: jump.port || 22, - legacyAlgorithms: !!(jump.legacyAlgorithms ?? options.legacyAlgorithms), + legacyAlgorithms: ((jump.legacyAlgorithms ?? options.legacyAlgorithms) !== false), skipEcdsaHostKey: !!jump.skipEcdsaHostKey, hasAlgorithmOverrides: !!jump.algorithmOverrides, }, sshDiagnosticLogger); diff --git a/electron/bridges/sshBridge/execCommand.cjs b/electron/bridges/sshBridge/execCommand.cjs index 639f45b..07ae221 100644 --- a/electron/bridges/sshBridge/execCommand.cjs +++ b/electron/bridges/sshBridge/execCommand.cjs @@ -135,7 +135,7 @@ function createExecCommandApi(ctx) { // KEX / cipher / host-key set as the interactive terminal. Without // this, a host that needs the ECDSA skip or legacy algorithms would // connect in the terminal but still fail the same handshake here. - algorithms: buildAlgorithms(payload.legacyAlgorithms, { + algorithms: buildAlgorithms(payload.legacyAlgorithms !== false, { skipEcdsaHostKey: payload.skipEcdsaHostKey, algorithmOverrides: payload.algorithmOverrides, }), diff --git a/electron/bridges/sshBridge/startSession.cjs b/electron/bridges/sshBridge/startSession.cjs index b4cd133..df70ef3 100644 --- a/electron/bridges/sshBridge/startSession.cjs +++ b/electron/bridges/sshBridge/startSession.cjs @@ -362,7 +362,7 @@ function createStartSessionApi(ctx) { authMethod: options.authMethod || undefined, knownHosts: Array.isArray(options.knownHosts) ? options.knownHosts : undefined, verifyHostKeys: options.verifyHostKeys !== false, - legacyAlgorithms: !!options.legacyAlgorithms, + legacyAlgorithms: options.legacyAlgorithms !== false, skipEcdsaHostKey: !!options.skipEcdsaHostKey, algorithmOverrides: options.algorithmOverrides, useSshAgent: options.useSshAgent, @@ -1470,7 +1470,11 @@ function createStartSessionApi(ctx) { keepaliveCountMax: keepalivePolicy.keepaliveCountMax, // Enable keyboard-interactive authentication (required for 2FA/MFA) tryKeyboard: true, - algorithms: buildAlgorithms(options.legacyAlgorithms, { + // Default legacyAlgorithms to true so older network devices (Huawei, + // H3C, Cisco, etc.) that only support legacy KEX algorithms like + // diffie-hellman-group1-sha1 can still connect. Modern devices still + // negotiate the strongest mutually supported algorithm first. + algorithms: buildAlgorithms(options.legacyAlgorithms !== false, { skipEcdsaHostKey: options.skipEcdsaHostKey, algorithmOverrides: options.algorithmOverrides, }), @@ -1479,7 +1483,7 @@ function createStartSessionApi(ctx) { logSshAlgorithms("Target host", connectOpts.algorithms, { hostname: options.hostname, port: options.port || 22, - legacyAlgorithms: !!options.legacyAlgorithms, + legacyAlgorithms: options.legacyAlgorithms !== false, skipEcdsaHostKey: !!options.skipEcdsaHostKey, hasAlgorithmOverrides: !!options.algorithmOverrides, }, log); diff --git a/electron/main.cjs b/electron/main.cjs index 6c6ee9e..f5b7c6a 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -18,6 +18,14 @@ if (process.env.ELECTRON_RUN_AS_NODE) { delete process.env.ELECTRON_RUN_AS_NODE; } +// BoringSSL DH compatibility shim — MUST be installed before ANY ssh2 require. +// Multiple bridges (sshBridge, sftpBridge, hostKeyVerifier, privateKeyNormalizer, +// sshAuthHelper, portForwardingBridge, inspectionExec, systemSshAgent...) each require +// ssh2 at module load. ssh2 destructes crypto.createDiffieHellmanGroup at load time, +// so the monkey-patch has to run before even the first ssh2 module load. +// Installing here (top of main, before any bridge requires) guarantees ordering. +require("./bridges/boringSslDhCompat.cjs").installBoringSslDhCompat(); + // Load crash log bridge early so process-level error handlers can use it const crashLogBridge = require("./bridges/crashLogBridge.cjs"); const { @@ -267,6 +275,9 @@ const { applyPortableDataDirectory } = require("./portableData.cjs"); const portableData = applyPortableDataDirectory({ app }); if (portableData) { console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`); + // portable 版 safeStorage DPAPI 绑定 userData 路径,解不开 dev 版 LS 加密 blob。 + // 设置环境变量让 useVaultState 跳过 LS hosts_v1,直接从 inspection-data.json 读明文。 + process.env.NETCATTY_PORTABLE = "1"; } const appPath = path.join(__dirname, ".."); const preload = path.join(__dirname, "preload.cjs"); diff --git a/electron/preload.cjs b/electron/preload.cjs index a3deac5..3db68f5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -945,7 +945,12 @@ function isTrustedRendererLocation(allowedOrigins) { const allowedOrigins = getAllowedRendererOrigins(); if (isTrustedRendererLocation(allowedOrigins)) { - contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api, ...figSpecApi }); + contextBridge.exposeInMainWorld("netcatty", { + ...existing, ...api, ...figSpecApi, + // portable 版标志:renderer 据此跳过 LS hosts_v1 加密读取, + // 直接走 inspection-data.json 明文(DPAPI 绑定 userData 路径解不开 dev 加密 blob)。 + isPortable: process.env.NETCATTY_PORTABLE === "1", + }); } else { // If a window navigates to an untrusted origin, do NOT expose the bridge. try { diff --git a/package.json b/package.json index ed4e539..ee091b9 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "micromark-extension-gfm-strikethrough": "^2.1.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "pend": "^1.2.0", "pinyin-pro": "^3.28.1", "re2js": "2.8.6", "react": "^19.2.1",