diff --git a/apps/console/sidecar/main.mjs b/apps/console/sidecar/main.mjs index a46d38d..5e439fa 100644 --- a/apps/console/sidecar/main.mjs +++ b/apps/console/sidecar/main.mjs @@ -13,8 +13,11 @@ * {"jsonrpc":"2.0","id":2,"method":"disconnect"} * {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}} * {"jsonrpc":"2.0","id":4,"method":"state"} - * {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?}} - * {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}} + * {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?,timeout_ms?}} + * {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest,timeout_ms?}} + * ⚠️ `timeout_ms` 由 Rust 侧下发,**必须小于**它在 `sidecar.rs::request` 上的 RPC 超时, + * 否则永远看到的是「RPC 超时」这种假失败(拿不到真正的原因)。两侧口径在 + * apps/console/src-tauri/src/lib.rs 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_RPC_TIMEOUT_SECS` 里成对定义。 * 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}} * 通知(无 id,本进程主动推): * {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}} @@ -31,6 +34,7 @@ import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises'; import { dirname } from 'node:path'; import { NodeAgentClient } from '@nodeagent/client'; +import { httpGet, resolveProxy } from './proxy.mjs'; /** @type {NodeAgentClient | null} */ let client = null; @@ -94,6 +98,72 @@ function cmpVersion(a, b) { return 0; } +/** + * 出网超时(毫秒)的裁剪。参数由 Rust 侧下发,**默认值只作兜底** —— 真实口径以 + * `lib.rs` 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_DOWNLOAD_TIMEOUT_MS` 为准。 + */ +function clampTimeoutMs(raw, dflt, min = 5_000, max = 1_800_000) { + const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : dflt; + return Math.max(min, Math.min(max, n)); +} + +/** + * 决议代理是否可用;**任何异常都退化成「直连」**。 + * + * 代理只是为了「本来连不上时能连上」,它本身绝不该成为新的失败点: + * 探测系统代理要跑 `reg`/`scutil`,被安全软件拦、被超时都是可能的, + * 那种情况下退回直连(与改动前行为一致)比让「检查更新」直接报错要好。 + */ +async function resolveProxyOrDirect(targetUrl) { + try { + return await resolveProxy({ targetUrl }); + } catch (err) { + return { proxy: null, source: null, notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`] }; + } +} + +/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */ +function outboundHint({ proxy, source, notes, timeoutMs }) { + return { + via: proxy ? `经代理 ${proxy.href}` : '直连', + proxy: proxy ? proxy.href : null, + proxy_source: source, + timeout_ms: timeoutMs, + notes, + hint: proxy + ? '代理已生效但仍失败:请确认代理本身能访问 GitHub(浏览器里试同一个地址)' + : '未使用代理。若本机需要代理才能访问 GitHub,请在系统里开启「系统代理」,或设置环境变量 HTTPS_PROXY=http://127.0.0.1:<端口> 后重启控制台', + }; +} + +/** + * 是否为「主动中止」类错误 —— **只认真正的 abort**。 + * + * ⚠️ 别把 `ECONNRESET` 之类也塞进来(第一版就这么写的,真机上立刻现形): + * 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成 + * 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。 + */ +function isAbortLike(err) { + const name = err?.name ?? ''; + const code = err?.code ?? ''; + const msg = String(err?.message ?? ''); + return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg); +} + +/** 把底层网络错误翻译成「用户能据以行动」的一句话。 */ +function describeNetError(err, timeoutMs) { + const msg = err instanceof Error ? err.message : String(err); + const code = err?.code ?? ''; + if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`; + if (code === 'ECONNRESET' || code === 'EPIPE') { + return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`; + } + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') { + return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`; + } + return msg; +} + const methods = { /** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */ async connect(params) { @@ -189,26 +259,30 @@ const methods = { */ async check_update(params) { const consoleVersion = String(params?.console_version ?? '0.0.0'); - const url = - params?.url ?? - 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json'; + const url = String( + params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json', + ); + const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000); - const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), 20_000); + const out = await resolveProxyOrDirect(url); + log('info', `检查更新:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`); + for (const n of out.notes) log('warn', `检查更新:${n}`); + + const t0 = Date.now(); let meta; try { - const res = await fetch(url, { signal: ac.signal, redirect: 'follow' }); - if (!res.ok) { + const res = await httpGet(url, { proxy: out.proxy, timeoutMs }); + if (res.status < 200 || res.status >= 300) { throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`); } - meta = await res.json(); + meta = JSON.parse(res.body.toString('utf8')); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), { - data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' }, - }); - } finally { - clearTimeout(timer); + const msg = describeNetError(err, timeoutMs); + const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连'; + throw Object.assign( + new Error(`检查更新失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), + { data: { url, ...outboundHint({ ...out, timeoutMs }) } }, + ); } const latest = String(meta?.version ?? ''); @@ -230,6 +304,9 @@ const methods = { }, release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`, source: url, + // 让界面/日志能一眼看出这次是直连还是走了代理(真机复验与用户自查都需要) + via_proxy: out.source, + via: out.proxy ? `经代理 ${out.proxy.href}` : '直连', checked_at: Date.now(), }; }, @@ -252,17 +329,25 @@ const methods = { ); } - const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), 300_000); + const timeoutMs = clampTimeoutMs(params?.timeout_ms, 300_000); + + const out = await resolveProxyOrDirect(url); + log('info', `下载更新包:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`); + + const t0 = Date.now(); let buf; try { - const res = await fetch(url, { signal: ac.signal, redirect: 'follow' }); - if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); - buf = Buffer.from(await res.arrayBuffer()); + const res = await httpGet(url, { proxy: out.proxy, timeoutMs }); + if (res.status < 200 || res.status >= 300) { + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + buf = res.body; } catch (err) { - throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`); - } finally { - clearTimeout(timer); + const msg = describeNetError(err, timeoutMs); + const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连'; + throw Object.assign(new Error(`下载失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), { + data: { url, ...outboundHint({ ...out, timeoutMs }) }, + }); } const got = createHash('sha256').update(buf).digest('hex'); diff --git a/apps/console/sidecar/proxy.mjs b/apps/console/sidecar/proxy.mjs new file mode 100644 index 0000000..f604d23 --- /dev/null +++ b/apps/console/sidecar/proxy.mjs @@ -0,0 +1,523 @@ +/** + * 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。 + * + * ## 为什么必须自己实现(这是缺陷,不是优化) + * + * Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY` + * 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式), + * 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理 + * —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。 + * 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。 + * (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器 + * 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。) + * + * 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例, + * 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar + * 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。 + * + * ## 做法 + * + * 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求: + * - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS + * (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`, + * 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手); + * - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`); + * - **无代理**:与改动前一致,直连(走 `https.request`)。 + * + * ## 代理来源优先级 + * + * ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项) + * ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …) + * ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`) + * + * `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)—— + * HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note + * 而不是静默直连(静默正是本次缺陷最恶劣的地方)。 + * + * ## 安全 + * + * 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。 + */ + +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import { execFile } from 'node:child_process'; + +/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */ +const ENV_ORDER = { + 'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'], + 'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'], +}; +const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy']; +const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); +const UA = 'nodeagent-console-sidecar'; + +/** + * 把环境里/配置里写的各种代理形态归一成可用的 URL。 + * + * 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL; + * 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」 + * 明确报出来,**不许静默直连**。 + * + * @param {unknown} raw + * @returns {URL | null} + */ +export function normalizeProxyUrl(raw) { + if (typeof raw !== 'string') return null; + const s = raw.trim(); + if (!s) return null; + let u; + try { + u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`); + } catch { + return null; + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; + if (!u.hostname) return null; + return u; +} + +/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */ +function proxyEndpoint(u) { + return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 }; +} + +/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */ +function proxyAuthHeader(u) { + if (!u.username) return null; + const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`; + return 'Basic ' + Buffer.from(raw).toString('base64'); +} + +/** + * `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配 + * `a.example.com`)、`host:port`、`*`。大小写不敏感。 + * + * ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名, + * 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目 + * 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。 + * + * @param {string} targetUrl + * @param {string} noProxy + * @returns {boolean} + */ +export function isBypassed(targetUrl, noProxy) { + if (!noProxy) return false; + let host; + let port; + try { + const u = new URL(targetUrl); + host = u.hostname.toLowerCase(); + port = u.port || (u.protocol === 'https:' ? '443' : '80'); + } catch { + return false; + } + for (const rawEntry of String(noProxy).split(',')) { + const entry = rawEntry.trim().toLowerCase(); + if (!entry) continue; + if (entry === '*') return true; + let eHost = entry; + let ePort = null; + const i = entry.lastIndexOf(':'); + // 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判 + if (i > 0 && !entry.slice(0, i).includes(':')) { + eHost = entry.slice(0, i); + ePort = entry.slice(i + 1); + } + if (ePort && ePort !== port) continue; + const bare = eHost.replace(/^\./, ''); + if (!bare) continue; + if (host === bare || host.endsWith(`.${bare}`)) return true; + } + return false; +} + +/** + * 从环境变量里挑代理(纯函数,便于单测)。 + * + * @param {Record} env + * @param {string} targetUrl + * @returns {{ url: URL | null, source: string | null, notes: string[] }} + */ +export function pickEnvProxy(env, targetUrl) { + let proto = 'https:'; + try { + proto = new URL(targetUrl).protocol; + } catch { + /* 保持默认 */ + } + const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:']; + const notes = []; + for (const k of keys) { + const v = env?.[k]; + if (!v) continue; + const u = normalizeProxyUrl(v); + if (u) return { url: u, source: k, notes }; + notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`); + } + return { url: null, source: null, notes }; +} + +/** + * 解析 macOS `scutil --proxy` 的输出。 + * + * 形如: + * { + * HTTPSEnable : 1 + * HTTPSPort : 7890 + * HTTPSProxy : 127.0.0.1 + * ExceptionsList : { 0 : *.local 1 : 169.254/16 } + * } + * + * 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。 + * `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明, + * 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。 + * + * @param {string} text + * @returns {{ url: string | null, bypass: string, notes: string[] } | null} + */ +export function parseScutilProxy(text) { + if (typeof text !== 'string' || !text.includes('{')) return null; + const scalar = (key) => { + const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text); + return m ? m[1].trim() : null; + }; + const isOn = (v) => v === '1' || v === 'true'; + const notes = []; + + // ExceptionsList 是 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表 + const bypass = []; + const arrIdx = text.indexOf('ExceptionsList'); + if (arrIdx >= 0) { + const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1); + const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm; + let m; + while ((m = re.exec(body)) !== null) bypass.push(m[1].trim()); + } + + for (const [flag, hostKey, portKey, dflt] of [ + ['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'], + ['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'], + ]) { + if (!isOn(scalar(flag))) continue; + const host = scalar(hostKey); + if (!host) continue; + const port = scalar(portKey) ?? dflt; + return { url: `${host}:${port}`, bypass: bypass.join(','), notes }; + } + + if (isOn(scalar('ProxyAutoConfigEnable'))) { + notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理'); + } + return { url: null, bypass: bypass.join(','), notes }; +} + +/** + * 解析 Windows `reg query …\Internet Settings` 的输出。 + * + * 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析): + * ProxyEnable REG_DWORD 0x1 + * ProxyOverride REG_SZ *.local; + * ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态) + * + * `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。 + * `` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。 + * + * @param {string} text + * @returns {{ url: string | null, bypass: string, notes: string[] } | null} + */ +export function parseRegQueryProxy(text) { + if (typeof text !== 'string' || !text.includes('REG_')) return null; + const value = (name) => { + const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text); + return m ? m[1].trim() : null; + }; + const notes = []; + + const enable = value('ProxyEnable'); + if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes }; + + const server = value('ProxyServer'); + if (!server) return { url: null, bypass: '', notes }; + + const override = value('ProxyOverride'); + const bypass = override + ? override + .split(';') + .map((s) => s.trim()) + .filter((s) => s && !/^$/i.test(s)) + .join(',') + : ''; + + // 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先 + let url = server; + if (server.includes('=')) { + const map = new Map(); + for (const part of server.split(';')) { + const i = part.indexOf('='); + if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim()); + } + url = map.get('https') ?? map.get('http') ?? null; + if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`); + } + return { url, bypass, notes }; +} + +/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */ +function runQuiet(file, args) { + return new Promise((resolve) => { + execFile( + file, + args, + // windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程, + // 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。 + { windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 }, + (err, stdout) => resolve(err ? null : String(stdout)), + ); + }); +} + +/** + * 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。 + * + * @param {NodeJS.Platform} [platform] + * @param {(file: string, args: string[]) => Promise} [run] + * @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>} + */ +export async function detectSystemProxy(platform = process.platform, run = runQuiet) { + try { + if (platform === 'win32') { + const out = await run('reg', [ + 'query', + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', + ]); + return out ? parseRegQueryProxy(out) : null; + } + if (platform === 'darwin') { + const out = await run('scutil', ['--proxy']); + return out ? parseScutilProxy(out) : null; + } + // Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量 + return null; + } catch { + return null; + } +} + +/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */ +let systemProxyPromise; +export function systemProxy() { + if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null); + return systemProxyPromise; +} + +/** 单测用:清掉系统代理缓存。 */ +export function __resetSystemProxyCache() { + systemProxyPromise = undefined; +} + +/** + * 决议本次请求该用哪个代理。 + * + * @param {{ targetUrl: string, env?: Record, system?: unknown }} opts + * `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。 + * @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>} + */ +export async function resolveProxy({ targetUrl, env = process.env, system } = {}) { + const notes = []; + const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? ''; + if (noProxy && isBypassed(targetUrl, noProxy)) { + return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] }; + } + + const fromEnv = pickEnvProxy(env ?? {}, targetUrl); + notes.push(...fromEnv.notes); + if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes }; + + const sys = system === undefined ? await systemProxy() : system; + if (sys?.url) { + notes.push(...(sys.notes ?? [])); + if (sys.bypass && isBypassed(targetUrl, sys.bypass)) { + notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`); + return { proxy: null, source: null, notes }; + } + const u = normalizeProxyUrl(String(sys.url)); + if (u) return { proxy: u, source: 'system', notes }; + notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`); + return { proxy: null, source: null, notes }; + } + notes.push(...(sys?.notes ?? [])); + return { proxy: null, source: null, notes }; +} + +/** + * 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。 + * + * 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。 + * + * ## ⚠️ 为什么必须自己管 `signal`(真机踩过) + * `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时 + * 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对 + * CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。 + * 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**, + * 失败/成功时都要摘掉监听,避免泄漏与误伤。 + */ +export function tunnelConnection(proxy, target, signal) { + const pe = proxyEndpoint(proxy); + return (_opts, cb) => { + let settled = false; + let tunnelSock = null; + let tlsSock = null; + + const detach = () => { + if (signal) signal.removeEventListener('abort', onAbort); + if (tunnelSock) tunnelSock.removeListener('error', onSockError); + }; + const fail = (err) => { + if (settled) return; + settled = true; + detach(); + // 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆) + try { + tlsSock?.destroy(); + tunnelSock?.destroy(); + } catch { + /* 已经没了 */ + } + cb(err); + }; + const onSockError = (e) => fail(e); + const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止')); + + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener('abort', onAbort, { once: true }); + + const port = target.port || (target.protocol === 'https:' ? 443 : 80); + const headers = { host: `${target.hostname}:${port}` }; + const auth = proxyAuthHeader(proxy); + if (auth) headers['proxy-authorization'] = auth; + + const req = http.request({ + host: pe.host, + port: pe.port, + method: 'CONNECT', + path: `${target.hostname}:${port}`, + headers, + agent: false, + signal, + }); + req.once('connect', (res, socket, head) => { + tunnelSock = socket; + if (res.statusCode !== 200) { + fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`)); + return; + } + if (head && head.length) socket.unshift(head); + if (target.protocol !== 'https:') { + settled = true; + detach(); + cb(null, socket); + return; + } + socket.once('error', onSockError); + tlsSock = tls.connect({ socket, servername: target.hostname }); + tlsSock.once('secureConnect', () => { + if (settled) return; + settled = true; + detach(); + cb(null, tlsSock); + }); + tlsSock.once('error', onSockError); + }); + req.once('error', onSockError); + req.end(); + }; +} + +/** 单跳请求(不跟随重定向)。 */ +function once(urlStr, { proxy, timeoutMs, signal }) { + return new Promise((resolve, reject) => { + const u = new URL(urlStr); + const isHttps = u.protocol === 'https:'; + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + reject(new Error(`不支持的协议:${u.protocol}`)); + return; + } + // ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用 + // URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。 + const opts = { + method: 'GET', + signal, + timeout: timeoutMs, + host: u.hostname, + port: u.port || (isHttps ? 443 : 80), + path: `${u.pathname}${u.search}`, + headers: { accept: '*/*', 'user-agent': UA, host: u.host }, + }; + + let req; + if (proxy && !isHttps) { + // http 目标经代理 → 绝对形态请求行,且请求行交给代理 + const pe = proxyEndpoint(proxy); + opts.host = pe.host; + opts.port = pe.port; + opts.path = u.href; + const auth = proxyAuthHeader(proxy); + if (auth) opts.headers['proxy-authorization'] = auth; + req = http.request(opts); + } else if (proxy) { + opts.createConnection = tunnelConnection(proxy, u, signal); + req = http.request(opts); + } else { + req = (isHttps ? https : http).request(opts); + } + + req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`))); + req.once('response', (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.once('error', reject); + res.once('end', () => + resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }), + ); + }); + req.once('error', reject); + req.end(); + }); +} + +/** + * GET 一个 URL(跟随重定向)。 + * + * 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`, + * 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。 + * + * 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时), + * 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。 + * + * @param {string} url + * @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts] + * @returns {Promise<{ status: number, statusText: string, headers: Record, body: Buffer, url: string }>} + */ +export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + let current = String(url); + for (let hop = 0; ; hop += 1) { + const res = await once(current, { proxy, timeoutMs, signal: ac.signal }); + const loc = res.headers?.location; + if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) { + current = new URL(loc, current).href; + continue; + } + return { ...res, url: current }; + } + } finally { + clearTimeout(timer); + } +} diff --git a/apps/console/src-tauri/src/lib.rs b/apps/console/src-tauri/src/lib.rs index 20b43ba..862fe18 100644 --- a/apps/console/src-tauri/src/lib.rs +++ b/apps/console/src-tauri/src/lib.rs @@ -362,11 +362,17 @@ fn autostart_set(enabled: bool) -> Result { #[tauri::command] async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result { let sc = ensure_sidecar(&app, &state).await?; + // 超时**成对**下发:内层(sidecar 出网)必须小于外层(RPC), + // 否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败。 + // 见 sidecar.rs 的 UPDATE_CHECK_TIMEOUT_MS / UPDATE_CHECK_RPC_TIMEOUT_SECS。 let mut snap = sc .request( "check_update", - json!({ "console_version": state.app_version }), - 45, + json!({ + "console_version": state.app_version, + "timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS, + }), + sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS, ) .await?; @@ -410,8 +416,9 @@ async fn apply_update( "url": url, "sha256": sha256, "dest": staged.to_string_lossy(), + "timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS, }), - 320, + sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS, ) .await?; diff --git a/apps/console/src-tauri/src/sidecar.rs b/apps/console/src-tauri/src/sidecar.rs index 82452dc..8e47def 100644 --- a/apps/console/src-tauri/src/sidecar.rs +++ b/apps/console/src-tauri/src/sidecar.rs @@ -25,6 +25,68 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::oneshot; +// --------------------------------------------------------------------------- +// 更新链路的超时口径(「检查更新 / 下载更新包」) +// --------------------------------------------------------------------------- +// +// 两层超时必须**成对**看:内层是 sidecar 自己的出网超时(下发给它), +// 外层是本壳等 RPC 的超时。**内层必须小于外层**,否则本壳先放弃, +// 用户只会看到「check_update 超时」——一条**丢掉真实原因**的假失败 +// (2026-10-13 真机排查就被这种假失败带偏过一轮)。 +// +// 下面那条单测断言的就是这层关系:改一侧忘了另一侧,测试直接红。 + +/// 「检查更新」下发给 sidecar 的**出网超时**(毫秒)。 +/// +/// ## 为什么从 20s 抬到 120s(2026-10-13 实测) +/// 取 `latest.json` 要**跟随两次重定向**(`releases/latest/download/…` → 版本化 URL → +/// 资产 CDN)。整条链的耗时波动极大,真机连续多次实测: +/// **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同一台机器、同一个代理)。 +/// 原来的 20s 是「假定能直连 GitHub」时定的值 —— 对真实链路**必然**超时, +/// 于是用户看到的失败原因永远是「超时」,而不是「到底为什么连不上」。 +/// +/// 为什么敢给到 2 分钟:这是**用户手动点**的动作,界面上按钮有 loading 态 +/// (`LocalServiceView.vue` 的 `:loading="busy"`),等待是可见的; +/// 而「假失败」会让用户以为功能坏了。宁可慢,不要假失败。 +pub(crate) const UPDATE_CHECK_TIMEOUT_MS: u64 = 120_000; + +/// `check_update` 的 RPC 超时(秒)。必须 > `UPDATE_CHECK_TIMEOUT_MS`(120s)。 +pub(crate) const UPDATE_CHECK_RPC_TIMEOUT_SECS: u64 = 150; + +/// 「下载更新包」下发给 sidecar 的出网超时(毫秒)。被控端约 1MB,300s 足够。 +pub(crate) const UPDATE_DOWNLOAD_TIMEOUT_MS: u64 = 300_000; + +/// `download` 的 RPC 超时(秒)。必须 > `UPDATE_DOWNLOAD_TIMEOUT_MS`(300s)。 +pub(crate) const UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS: u64 = 330; + +/// **编译期不变式**:内层出网超时必须小于外层 RPC 超时。 +/// +/// ## 为什么放在 `const _` 里而不是写成 `#[test]` +/// ① 这层关系一旦破坏,症状是「用户看到假失败、且失败原因指向别处」—— +/// 让**构建**直接失败,比等某条单测被跑到更早、更不可能被绕过 +/// (CI 的 `cargo clippy` / `cargo build` 都是闸门,而单测可能被 filter 掉)。 +/// ② 两侧都是常量,写成断言函数会被 `clippy::assertions_on_constants` 拦下 +/// (`-D warnings` 下即失败)—— clippy 给的正是本写法。 +/// +/// 注入验证(2026-10-13):把 `UPDATE_CHECK_RPC_TIMEOUT_SECS` 改成 50、 +/// 或把 `UPDATE_CHECK_TIMEOUT_MS` 改回 20_000,**构建**即失败并打印下面的文案。 +const _: () = { + assert!( + UPDATE_CHECK_TIMEOUT_MS < UPDATE_CHECK_RPC_TIMEOUT_SECS * 1000, + "check_update:出网超时必须小于 RPC 超时(否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败)" + ); + assert!( + UPDATE_DOWNLOAD_TIMEOUT_MS < UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS * 1000, + "download:出网超时必须小于 RPC 超时" + ); + // 60s 下限:真机上取 latest.json 的重定向链实测出现过 68.6s(三次为 2.0 / 10.0 / 68.6s), + // 回落到「假定能直连」时代的 20s 必然超时。 + assert!( + UPDATE_CHECK_TIMEOUT_MS >= 60_000, + "检查更新的出网超时不得低于 60s" + ); +}; + // --------------------------------------------------------------------------- // 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀 // --------------------------------------------------------------------------- @@ -65,7 +127,7 @@ use tokio::sync::oneshot; /// `\\?\` 路径 **0.5 秒**即退出、exit 1、**零 JS 执行**(连自带启动日志都没写出来)。 /// /// 控制台侧前后两种表现都由这一条派生: -/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等 45 秒 → 「check_update 超时」; +/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等到 RPC 超时(当时 45 秒) → 「check_update 超时」; /// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。 /// /// ## 为什么单测 / CI / 开发模式全都测不出来 @@ -163,7 +225,7 @@ pub struct Sidecar { /// ⚠️ 为什么不能只靠 `child.try_wait()`:往**已退出进程的管道**里写在 Windows 上 /// 未必立刻报错(管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。 /// 真机实证(2026-10-13,Windows 11 ARM64):sidecar 早已退出,控制台却仍复用缓存里的 - /// `Arc`,点「检查更新」等满 45 秒 → 界面显示「检查更新失败:check_update 超时」; + /// `Arc`,点「检查更新」等满 RPC 超时(当时 45 秒) → 界面显示「检查更新失败:check_update 超时」; /// 而同一台机器上**手工拉起 sidecar 跑同一个请求只要 7 秒**。 /// 所以判定「它死了」要多路取证:进程状态 ∨ stdout EOF ∨ 写入失败。 dead: Arc, @@ -252,7 +314,7 @@ impl Sidecar { // 走到这里 = stdout 关闭(EOF 或读取出错)= sidecar 进程已结束。 dead.store(true, Ordering::SeqCst); // 顺手唤醒**所有还在等响应**的请求,让它们立刻失败, - // 而不是干等各自的 timeout(check_update 45s / download 320s)。 + // 而不是干等各自的 timeout(见 UPDATE_CHECK_RPC_TIMEOUT_SECS / UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS)。 // 没有这一步,sidecar 死掉的那一刻就会有请求白等好几分钟。 let mut waiting = pending.lock().unwrap(); for (_, tx) in waiting.drain() { @@ -298,7 +360,7 @@ impl Sidecar { pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result { // 入口先判存活 —— 这是「秒失败」的那一道。 // 只靠写入报错是不够的:Windows 上往已关闭的管道写可能被缓冲区吞掉而"成功", - // 之后就会一路干等到 timeout(真机形态:check_update 45s)。宁可在入口直接拒绝。 + // 之后就会一路干等到 timeout(真机形态:check_update 的 RPC 超时,当时 45s)。宁可在入口直接拒绝。 if !self.is_alive() { return Err(format!("sidecar 不可用(进程已退出),{method} 已放弃")); } @@ -450,7 +512,7 @@ mod tests { // // 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、 // 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报 - // 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 45 秒超时」)。 + // 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 RPC 超时」,当时 45 秒)。 // 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后, // node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。 // 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。 @@ -600,7 +662,7 @@ mod tests { // ----------------------------------------------------------------------- // 「sidecar 退出后不得被复用」—— 2026-10-13 真机缺陷 // - // 现象:控制台点「检查更新」等满 45 秒,界面报「check_update 超时」; + // 现象:控制台点「检查更新」等满 RPC 超时(当时 45 秒),界面报「check_update 超时」; // 而同一台机器上手工拉起 sidecar 跑同一个请求只要 7 秒。 // 根因:控制台缓存里的 Arc 指向早已退出的子进程, // ensure_sidecar 只判「缓存里有没有」,不判「那进程还活着没」。 diff --git a/apps/console/src/types/index.ts b/apps/console/src/types/index.ts index 7eecad9..e4daad7 100644 --- a/apps/console/src/types/index.ts +++ b/apps/console/src/types/index.ts @@ -355,6 +355,11 @@ export interface UpdateInfo { agent: { url?: string | null; sha256?: string | null; bytes?: number | null }; release_url: string; source: string; + /** 本次出网走的代理来源(`HTTPS_PROXY` / `system` / `NA_PROXY`…),直连为 null。 + * 2026-10-13 加:境内「检查更新」失败的排查线索,必须能在界面上看到。 */ + via_proxy?: string | null; + /** 人类可读的出网路径描述(「直连」/「经代理 http://…」)。 */ + via?: string | null; checked_at: number; } diff --git a/apps/console/src/views/LocalServiceView.vue b/apps/console/src/views/LocalServiceView.vue index 6256986..df95814 100644 --- a/apps/console/src/views/LocalServiceView.vue +++ b/apps/console/src/views/LocalServiceView.vue @@ -72,16 +72,27 @@ async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_age async function doCheckUpdate() { const u = await agent.checkUpdate(); - if (agent.error) msg.error(`检查更新失败:${agent.error}`); + if (agent.error) msg.error(prefixOnce(agent.error, "检查更新失败:")); else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本"); } async function doApplyUpdate() { const r = await agent.applyUpdate(); - if (agent.error) msg.error(`更新失败:${agent.error}`); + if (agent.error) msg.error(prefixOnce(agent.error, "更新失败:")); else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`); } +/** + * 错误信息里若已带同一前缀就不再叠加。 + * + * ⚠️ 2026-10-13:sidecar 侧把失败原因写成「检查更新失败:<真实原因>(经代理 …, 耗时 …)」, + * 而这里原本又拼一层 → 用户看到「检查更新失败:检查更新失败:请求超时…」。 + * 但 `ensure_sidecar` 之类来自 Rust 壳的错误**没有**前缀,所以前缀本身要保留,只是不重复。 + */ +function prefixOnce(text: string, prefix: string) { + return text.startsWith(prefix) ? text : prefix + text; +} + async function doConnectLocal() { try { await agent.connectLocal(); @@ -290,6 +301,11 @@ function openRelease() {
发布时间{{ fmtTime(Date.parse(agent.update.published_at)) }}
+ +
+ 出网 + {{ agent.update.via }} +
检查更新 diff --git a/tests/unit/sidecar-proxy.test.mjs b/tests/unit/sidecar-proxy.test.mjs new file mode 100644 index 0000000..f4f52cd --- /dev/null +++ b/tests/unit/sidecar-proxy.test.mjs @@ -0,0 +1,614 @@ +/** + * 出站代理守卫(`apps/console/sidecar/proxy.mjs`)。 + * + * ⚠️ 背景(真机缺陷,2026-10-13) + * 控制台「检查更新」在境内**必然**失败,报 `检查更新失败:fetch failed`。 + * 根因不是网络,而是代码:Node 内置 `fetch`(undici)**默认不读代理环境变量**, + * 也读不到 Windows 系统代理(WinINET)/ macOS 系统代理 —— 而中文用户配的恰恰是 + * 「系统代理」。真机取证:VM 上 DNS 与 TCP 443 都通、却 `fetch failed`; + * 同机经代理取 `latest.json` 返回 302。 + * + * 因此本文件守四件事: + * ① **决议逻辑**(纯函数):代理来源优先级、`NO_PROXY`、系统代理形态解析; + * ② **真发请求**(本地服务器,离线可跑):直连 / 绝对形态 / CONNECT 隧道; + * ③ **端到端**:真 spawn sidecar 进程,用 JSON-RPC 跑 `check_update`,**确实经代理**; + * ④ **形态守卫**:sidecar 源码不得再出现裸 `fetch(`(防止修复被无声改回去)。 + * + * 夹具都取自真机:macOS 的是本机 `scutil --proxy` 原样输出;Windows 的是 VM 上 + * `reg query …\Internet Settings` 的原样输出(含「值不存在时 reg 走 stderr」的真形态)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + detectSystemProxy, + httpGet, + isBypassed, + normalizeProxyUrl, + parseRegQueryProxy, + parseScutilProxy, + pickEnvProxy, + resolveProxy, + tunnelConnection, +} from '../../apps/console/sidecar/proxy.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '../..'); +const SIDECAR_SRC = join(repoRoot, 'apps/console/sidecar/main.mjs'); + +// --------------------------------------------------------------------------- +// 夹具(真机原样) +// --------------------------------------------------------------------------- + +/** 本机(macOS)真实输出形态:透明代理扩展接管 → PAC 模式,HTTP/HTTPS 开关都是 0。 */ +const SCUTIL_PAC = ` { + ExceptionsList : { + 0 : localhost + 1 : 127.0.0.0/8 + 2 : ::1 + } + FTPPassive : 1 + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://localhost:51431/proxy.pac + ProxyAutoDiscoveryEnable : 0 + SOCKSEnable : 0 +} +`; + +/** 手工开系统代理时的形态(字段顺序与本机实测输出一致)。 */ +const SCUTIL_ON = ` { + ExceptionsList : { + 0 : *.local + 1 : 169.254/16 + } + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 7891 + HTTPSProxy : 127.0.0.1 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 0 +} +`; + +/** + * 真机(Windows 11 ARM64 虚拟机)上 `reg query … /v ProxyEnable` 的真实输出形态(值为 0x0)。 + * + * 注:本文件里的地址一律用**文档保留段**(RFC 5737 的 `192.0.2.0/24`)或回环, + * 不写任何真机的内网地址 —— 公开仓库的隐私审查会扫内网 IP(见 `security-reports/`)。 + */ +const REG_OFF = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x0 + +`; + +/** 开启系统代理的形态(与真机同一列宽:4 空格缩进 + 类型名 + 值)。 */ +const REG_ON = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyOverride REG_SZ *.local; + ProxyServer REG_SZ 192.0.2.10:7890 +`; + +const REG_PER_PROTOCOL = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyServer REG_SZ http=127.0.0.1:7890;https=127.0.0.1:7891 +`; + +// --------------------------------------------------------------------------- +// ① 纯函数 +// --------------------------------------------------------------------------- + +test('代理 URL 归一化:补 scheme、拒 socks、拒垃圾', () => { + assert.equal(normalizeProxyUrl('http://127.0.0.1:7890')?.href, 'http://127.0.0.1:7890/'); + assert.equal(normalizeProxyUrl('127.0.0.1:7890')?.hostname, '127.0.0.1', '缺 scheme 应补 http://'); + assert.equal(normalizeProxyUrl(' https://proxy.local:8443 ')?.protocol, 'https:'); + assert.equal(normalizeProxyUrl('socks5://127.0.0.1:1080'), null, 'SOCKS 尚不支持 → 必须返回 null(上层据此出声)'); + assert.equal(normalizeProxyUrl(''), null); + assert.equal(normalizeProxyUrl(' '), null); + assert.equal(normalizeProxyUrl(undefined), null); + assert.equal(normalizeProxyUrl('http://'), null, '没有主机名不算代理'); +}); + +test('NO_PROXY 命中判定:精确 / 后缀 / 端口 / 通配 / 不误伤', () => { + const U = 'https://github.com/x'; + assert.equal(isBypassed(U, ''), false); + assert.equal(isBypassed(U, ' '), false); + assert.equal(isBypassed(U, 'github.com'), true); + assert.equal(isBypassed(U, '.github.com'), true, '前导点等价于后缀匹配'); + assert.equal(isBypassed('https://api.github.com/x', 'github.com'), true, '条目应覆盖其子域'); + assert.equal(isBypassed(U, 'api.github.com'), false, '条目比目标更窄时不得反向豁免父域'); + assert.equal(isBypassed(U, 'github.io'), false, '不同后缀不得误伤'); + assert.equal(isBypassed(U, 'evilgithub.com'), false, '拼尾不得误伤'); + assert.equal(isBypassed(U, 'github.com:443'), true, '端口相符应命中'); + assert.equal(isBypassed(U, 'github.com:8080'), false, '端口不符不命中'); + assert.equal(isBypassed(U, ' example.com , github.com '), true, '空白与多条目应容错'); + assert.equal(isBypassed(U, '*'), true); +}); + +test('代理来源优先级:NA_PROXY > 协议环境变量 > ALL_PROXY', () => { + const U = 'https://github.com/x'; + assert.equal(pickEnvProxy({ NA_PROXY: 'http://a:1', HTTPS_PROXY: 'http://b:2' }, U).source, 'NA_PROXY'); + assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2', ALL_PROXY: 'http://c:3' }, U).source, 'HTTPS_PROXY'); + assert.equal(pickEnvProxy({ https_proxy: 'http://b:2' }, U).source, 'https_proxy', '小写形态同样生效'); + assert.equal(pickEnvProxy({ ALL_PROXY: 'http://c:3' }, U).source, 'ALL_PROXY'); + assert.equal(pickEnvProxy({}, U).url, null); + // 只看 http 目标时不认 HTTPS_PROXY + assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2' }, 'http://x/y').url, null); + assert.equal(pickEnvProxy({ HTTP_PROXY: 'http://d:4' }, 'http://x/y').source, 'HTTP_PROXY'); +}); + +test('环境变量写了 SOCKS 时必须出声,而不是静默直连', () => { + const r = pickEnvProxy({ HTTPS_PROXY: 'socks5://127.0.0.1:1080' }, 'https://github.com/x'); + assert.equal(r.url, null); + assert.equal(r.notes.length, 1); + assert.match(r.notes[0], /socks5/, 'note 要带上原值,便于用户自查'); + assert.match(r.notes[0], /已忽略/); + // 下一个可用来源仍应被采纳(不能因为前一个没看懂就整个放弃) + const r2 = pickEnvProxy({ HTTPS_PROXY: 'socks5://x:1', ALL_PROXY: 'http://ok:8080' }, 'https://github.com/x'); + assert.equal(r2.source, 'ALL_PROXY'); +}); + +test('解析 scutil:HTTPS 优先、PAC 模式出声、全关则无代理', () => { + const on = parseScutilProxy(SCUTIL_ON); + assert.equal(on.url, '127.0.0.1:7891', 'HTTPS 优先于 HTTP'); + assert.equal(on.bypass, '*.local,169.254/16', 'ExceptionsList 应被收集成绕过列表'); + assert.deepEqual(on.notes, []); + + const pac = parseScutilProxy(SCUTIL_PAC); + assert.equal(pac.url, null); + assert.match(pac.notes.join(''), /PAC/, 'PAC 不可解析必须明说,否则用户只会看到 fetch failed'); + + const off = parseScutilProxy(' {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n'); + assert.equal(off.url, null); + assert.deepEqual(off.notes, [], '既没开代理也不是 PAC,就不该报噪音 note'); + + assert.equal(parseScutilProxy('not a dict'), null); + assert.equal(parseScutilProxy(''), null); +}); + +test('解析 reg query:0x0 即显式关闭、分协议形态取 https、 剔除', () => { + const off = parseRegQueryProxy(REG_OFF); + assert.equal(off.url, null, 'ProxyEnable=0x0 是显式关闭,哪怕别的值有内容也不能启用'); + + const on = parseRegQueryProxy(REG_ON); + assert.equal(on.url, '192.0.2.10:7890'); + assert.equal(on.bypass, '*.local', ' 是占位符,不是域名,必须剔除'); + + const per = parseRegQueryProxy(REG_PER_PROTOCOL); + assert.equal(per.url, '127.0.0.1:7891', '分协议形态应取 https(我们没有明文 http 目标)'); + + // 值不存在时 reg 把错误写在 stderr,stdout 只剩表头 —— 不该崩,也不该瞎猜 + const missing = parseRegQueryProxy( + '\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\n\n', + ); + assert.equal(missing, null); + assert.equal(parseRegQueryProxy('ERROR: The system was unable to find…'), null); +}); + +test('系统代理探测:命令失败/平台不支持一律视为「没有系统代理」', async () => { + // 探测失败绝不能让「检查更新」本身报错 + assert.equal(await detectSystemProxy('win32', async () => null), null); + assert.equal(await detectSystemProxy('darwin', async () => null), null); + assert.equal(await detectSystemProxy('linux', async () => 'whatever'), null, 'Linux 不做猜测'); + const boom = await detectSystemProxy('win32', async () => { + throw new Error('安全软件拦截'); + }); + assert.equal(boom, null, '抛异常也要吞掉'); + + // 真解析路径 + const win = await detectSystemProxy('win32', async (file, args) => { + assert.equal(file, 'reg'); + assert.equal(args[0], 'query'); + assert.match(args[1], /Internet Settings$/, '必须查 HKCU 的 Internet Settings'); + return REG_ON; + }); + assert.equal(win.url, '192.0.2.10:7890'); + assert.equal( + (await detectSystemProxy('darwin', async (f) => (f === 'scutil' ? SCUTIL_ON : null))).url, + '127.0.0.1:7891', + ); +}); + +test('决议代理:NO_PROXY 短路 → 环境变量 → 系统代理兜底(含 note 累积)', async () => { + const U = 'https://github.com/x'; + + // NO_PROXY 直接短路(哪怕环境里有代理) + const bypass = await resolveProxy({ targetUrl: U, env: { HTTPS_PROXY: 'http://a:1', NO_PROXY: 'github.com' }, system: null }); + assert.equal(bypass.proxy, null); + assert.match(bypass.notes.join(''), /NO_PROXY/); + + // 环境变量优先于系统代理 + const envFirst = await resolveProxy({ + targetUrl: U, + env: { HTTPS_PROXY: 'http://env:1' }, + system: { url: 'sys:2', bypass: '', notes: [] }, + }); + assert.equal(envFirst.proxy.href, 'http://env:1/'); + assert.equal(envFirst.source, 'HTTPS_PROXY'); + + // 环境变量空 → 用系统代理,并把来源标成 system + const sys = await resolveProxy({ + targetUrl: U, + env: {}, + system: { url: '127.0.0.1:7890', bypass: '', notes: [] }, + }); + assert.equal(sys.proxy.href, 'http://127.0.0.1:7890/', '缺 scheme 的系统代理值也应能用'); + assert.equal(sys.source, 'system'); + + // 系统代理的绕过列表命中 → 直连 + const sysBypass = await resolveProxy({ + targetUrl: U, + env: {}, + system: { url: '127.0.0.1:7890', bypass: 'github.com', notes: [] }, + }); + assert.equal(sysBypass.proxy, null); + assert.match(sysBypass.notes.join(''), /绕过列表/); + + // 完全没有代理:notes 应带出「为什么没用上」(PAC / socks),便于用户自查 + const none = await resolveProxy({ + targetUrl: U, + env: { HTTPS_PROXY: 'socks5://x:1' }, + system: { url: null, bypass: '', notes: ['PAC 无法解析'] }, + }); + assert.equal(none.proxy, null); + assert.match(none.notes.join(';'), /socks5/); + assert.match(none.notes.join(';'), /PAC/); +}); + +// --------------------------------------------------------------------------- +// ② 真发请求(本地服务器,离线) +// --------------------------------------------------------------------------- + +/** 起一个最小 HTTP 源站,返回 {url, close}。 */ +async function startOrigin(handler) { + const srv = http.createServer(handler); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + return { srv, port: srv.address().port, url: (p) => `http://127.0.0.1:${srv.address().port}${p}` }; +} + +/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(用于判定是否走了绝对形态)。 */ +async function startHttpProxy() { + const seen = []; + const srv = http.createServer((req, res) => { + seen.push(req.url); + let u; + try { + u = new URL(req.url); + } catch { + res.writeHead(400); + res.end('not absolute-form'); + return; + } + const up = http.request( + { host: u.hostname, port: u.port, path: `${u.pathname}${u.search}`, method: req.method, headers: { ...req.headers, host: u.host } }, + (r) => { + res.writeHead(r.statusCode, r.headers); + r.pipe(res); + }, + ); + up.on('error', () => { + res.writeHead(502); + res.end('upstream error'); + }); + req.pipe(up); + }); + // CONNECT 也要支持:https 目标经代理走的就是它 + srv.on('connect', (req, clientSock, head) => { + seen.push(`CONNECT ${req.url}`); + const [host, port] = req.url.split(':'); + const up = net.connect(Number(port), host, () => { + clientSock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head?.length) up.write(head); + up.pipe(clientSock); + clientSock.pipe(up); + }); + up.on('error', () => clientSock.destroy()); + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + return { srv, seen, url: `http://127.0.0.1:${srv.address().port}` }; +} + +test('httpGet 直连:跟随 302 且返回最终响应', async () => { + const origin = await startOrigin((req, res) => { + if (req.url === '/hop') { + res.writeHead(302, { location: '/final' }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ARRIVED'); + }); + try { + const r = await httpGet(origin.url('/hop'), { timeoutMs: 5000 }); + assert.equal(r.status, 200); + assert.equal(r.body.toString(), 'ARRIVED'); + assert.equal(r.url, origin.url('/final'), '应报出最终 URL'); + } finally { + origin.srv.close(); + } +}); + +test('httpGet 经代理:请求行走绝对形态(不是直连)', async () => { + const origin = await startOrigin((_req, res) => { + res.writeHead(200); + res.end('VIA-PROXY'); + }); + const proxy = await startHttpProxy(); + try { + const r = await httpGet(origin.url('/latest.json'), { proxy: new URL(proxy.url), timeoutMs: 5000 }); + assert.equal(r.body.toString(), 'VIA-PROXY'); + assert.match(proxy.seen[0], /^http:\/\/127\.0\.0\.1:\d+\/latest\.json$/, 'HTTP 代理收到的是绝对 URI'); + } finally { + proxy.srv.close(); + origin.srv.close(); + } +}); + +test('httpGet 经代理:代理不通时必须报错(证明真的走了代理而非偷偷直连)', async () => { + const origin = await startOrigin((_req, res) => { + res.writeHead(200); + res.end('SHOULD-NOT-BE-REACHED'); + }); + try { + await assert.rejects( + // 死端口:若实现「代理失败就自动直连」,这里会拿到 200 —— 那正是我们最怕的静默降级 + httpGet(origin.url('/x'), { proxy: new URL('http://127.0.0.1:1'), timeoutMs: 5000 }), + /ECONNREFUSED|代理/, + ); + } finally { + origin.srv.close(); + } +}); + +test('abort 必须覆盖隧道与 TLS 握手:代理收下 CONNECT 却装死时,超时要立即生效', async () => { + // ⚠️ 真机踩过(2026-10-13):配置 75s 超时,实际挂了 **109s** 才失败。 + // 根因:`createConnection` 回调返回之前外层 `http.request` 还没建立, + // 外层的 signal / timeout **无人受理**;若此时卡在 CONNECT 或 TLS 握手上, + // 超时形同虚设。本用例把那个形态固定下来: + // 代理回 200 之后就一个字都不说 → TLS 握手永远完不成。 + const srv = http.createServer(); + srv.on('connect', (_req, sock) => { + sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + // 装死:不再说一个字 + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + const t0 = Date.now(); + let watchdog; + try { + await assert.rejects( + // 看门狗:没有它,一旦 abort 失效本用例会**挂死**(把整个测试套件拖住), + // 而不是干脆地变红。有它则 4s 后必定失败,且失败点落在下面的 dt 断言上。 + Promise.race([ + httpGet('https://github.com/x', { + proxy: new URL(`http://127.0.0.1:${srv.address().port}`), + timeoutMs: 800, + }), + new Promise((_, rej) => { + watchdog = setTimeout(() => rej(new Error('看门狗触发:abort 未生效')), 4000); + }), + ]), + /abort|中止|超时/i, + ); + const dt = Date.now() - t0; + assert.ok(dt < 4000, `超时应立即生效(配置 800ms),实际 ${dt}ms —— 说明 abort 没覆盖隧道/TLS 阶段`); + } finally { + clearTimeout(watchdog); + srv.closeAllConnections(); + srv.close(); + } +}); + +test('CONNECT 隧道:可用,且代理拒绝时给出明确错误', async () => { + const origin = await startOrigin((_req, res) => { + res.writeHead(200); + res.end('TUNNELED'); + }); + const proxy = await startHttpProxy(); + try { + // 直接用隧道 socket 说一句裸 HTTP/1.1(这里刻意用 http 目标:隧道层不带 TLS, + // TLS 叠层由真机端到端验证覆盖 —— 见 CHANGELOG 的「未覆盖」记档) + const socket = await new Promise((resolve, reject) => { + const mk = tunnelConnection(new URL(proxy.url), { protocol: 'http:', hostname: '127.0.0.1', port: String(origin.srv.address().port) }, undefined); + mk({}, (err, s) => (err ? reject(err) : resolve(s))); + }); + const text = await new Promise((resolve) => { + let buf = ''; + socket.on('data', (d) => { + buf += d; + if (buf.includes('TUNNELED')) { + socket.destroy(); + resolve(buf); + } + }); + socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`); + }); + assert.match(text, /200 OK/); + assert.match(proxy.seen.join('|'), /^CONNECT 127\.0\.0\.1:\d+$/, '代理应收到 CONNECT'); + + // 代理拒绝(非 200)→ 必须立刻报错并关掉 socket,而不是挂着 + const badProxy = http.createServer(); + badProxy.on('connect', (_req, sock) => { + sock.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + sock.destroy(); + }); + await new Promise((r) => badProxy.listen(0, '127.0.0.1', r)); + await assert.rejects( + new Promise((resolve, reject) => { + const mk = tunnelConnection( + new URL(`http://127.0.0.1:${badProxy.address().port}`), + { protocol: 'http:', hostname: '127.0.0.1', port: '80' }, + undefined, + ); + mk({}, (err, s) => (err ? reject(err) : resolve(s))); + }), + /代理拒绝 CONNECT/, + ); + badProxy.close(); + } finally { + proxy.srv.close(); + origin.srv.close(); + } +}); + +// --------------------------------------------------------------------------- +// ③ 端到端:真 spawn sidecar,真走 JSON-RPC +// --------------------------------------------------------------------------- + +/** 驱动一个真 sidecar 进程;返回 {call, stderr, kill}。 */ +function spawnSidecar(env) { + const child = spawn(process.execPath, [SIDECAR_SRC], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { PATH: process.env.PATH, HOME: process.env.HOME, ...env }, + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (err += d)); + + const call = (id, method, params) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`等 ${method} 响应超时;stderr=${err.trim()}`)), 30_000); + const onData = () => { + for (const line of out.split('\n')) { + if (!line.trim().startsWith('{')) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg.id !== id) continue; + clearTimeout(timer); + child.stdout.off('data', onData); + resolve(msg); + } + }; + child.stdout.on('data', onData); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + + return { + call, + stderr: () => err, + kill: () => { + try { + child.kill(); + } catch { + /* 已退出 */ + } + }, + }; +} + +test('端到端:check_update 经代理拿到结果,并报出代理来源', async () => { + const meta = { version: '9.9.9', commit: 'deadbeef', built_at: '2026-10-13T00:00:00.000Z', agent: { url: 'http://x/agent.mjs', sha256: 'a'.repeat(64), bytes: 1 } }; + const origin = await startOrigin((req, res) => { + if (req.url === '/latest.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(meta)); + return; + } + res.writeHead(404); + res.end(); + }); + const proxy = await startHttpProxy(); + const sc = spawnSidecar({ HTTP_PROXY: proxy.url }); + try { + const msg = await sc.call(1, 'check_update', { console_version: '1.0.0', url: origin.url('/latest.json'), timeout_ms: 10_000 }); + assert.equal(msg.error, undefined, `不应报错:${JSON.stringify(msg.error)} stderr=${sc.stderr()}`); + assert.equal(msg.result.latest_version, '9.9.9'); + assert.equal(msg.result.has_update, true, '1.0.0 对 9.9.9 应有更新'); + assert.equal(msg.result.via_proxy, 'HTTP_PROXY', '结果里要能看出这次走了哪条出网路径'); + assert.match(proxy.seen[0], /\/latest\.json$/, '代理必须真收到这次请求'); + + // 反面对照:代理指到死端口 → 必须失败(证明它没有偷偷直连源站) + const sc2 = spawnSidecar({ HTTP_PROXY: 'http://127.0.0.1:1' }); + try { + const bad = await sc2.call(1, 'check_update', { console_version: '1.0.0', url: origin.url('/latest.json'), timeout_ms: 5000 }); + assert.ok(bad.error, '代理不可用时必须报错'); + assert.match(bad.error.message, /检查更新失败/); + assert.match(bad.error.message, /经代理/, '错误信息要说清走了代理,否则用户无从下手'); + assert.match(bad.error.message, /ECONNREFUSED|超时/); + assert.equal(bad.error.data.proxy, 'http://127.0.0.1:1/', '失败的线索里要带上代理地址'); + } finally { + sc2.kill(); + } + } finally { + sc.kill(); + proxy.srv.close(); + origin.srv.close(); + } +}); + +test('错误映射:连接被中途重置时不得报成「请求超时」(真机踩过)', async () => { + // ⚠️ 真机形态(2026-10-13):耗时 82.9s(预算 120s),连接是被对端**中途重置**的, + // 却被报成「请求超时(120000ms 内未完成)」—— 一句话把用户引向完全错误的方向。 + // 根因是把 `ECONNRESET` 也归进了「abort 类」。这里用「收到请求立刻 RST」的源站固定该形态。 + const srv = http.createServer((_req, res) => { + res.socket?.destroy(); + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + const sc = spawnSidecar({}); + try { + const msg = await sc.call(1, 'check_update', { + console_version: '1.0.0', + url: `http://127.0.0.1:${srv.address().port}/latest.json`, + timeout_ms: 10_000, // 预算 10s:真超时也要 10s 才会到,故「没到预算就报超时」必是误判 + }); + assert.ok(msg.error, '连接被重置必须报错'); + assert.ok( + !/请求超时/.test(msg.error.message), + `不得把「连接被重置」报成「请求超时」:${msg.error.message}`, + ); + assert.match( + msg.error.message, + /中途断开|ECONNRESET|socket hang up/, + `应说明是连接被对端断开:${msg.error.message}`, + ); + } finally { + sc.kill(); + srv.closeAllConnections(); + srv.close(); + } +}); + +// --------------------------------------------------------------------------- +// ④ 形态守卫 +// --------------------------------------------------------------------------- + +/** + * sidecar 源码不得再出现**裸 `fetch(`**。 + * + * 这是一条**源码形态**守卫(而非产物字节守卫)—— 因为这里要守的规则本身就是 + * 「不许调用这个 API」:`fetch` 在 Node 上不认代理,一旦有人图省事写回去, + * 代理支持会被**静默**废掉,而所有现有测试都会继续绿。 + * 行为层面的守卫是上面的「端到端」用例;两者互补。 + * + * ⚠️ 例外说明:`apps/agent/src/capabilities/selfupdate.ts` 仍在用 `fetch`, + * 那是被控端自更新链路,项目既定策略是「改镜像地址」而不是塞代理隧道 + * (见该文件顶部注释)。本守卫**只覆盖 sidecar**,不越界。 + */ +test('形态守卫:sidecar 不得再用裸 fetch(否则代理支持会被静默废掉)', () => { + const src = readFileSync(SIDECAR_SRC, 'utf8'); + const bad = src + .split('\n') + .map((line, i) => ({ line, no: i + 1 })) + .filter(({ line }) => /(^|[^.\w])fetch\s*\(/.test(line) && !line.trim().startsWith('*') && !line.trim().startsWith('//')); + assert.deepEqual( + bad.map((b) => `${b.no}: ${b.line.trim()}`), + [], + 'sidecar 内所有出网必须走 proxy.mjs 的 httpGet(Node 的 fetch 不读代理设置)', + ); + assert.match(src, /from '\.\/proxy\.mjs'/, '必须真的引入 proxy.mjs'); +});