Junie's Blog

Windows 浏览器能正常上网,自写的命令行工具却连不上代理

全文共 1833预计阅读 7 分钟

codex-notify 是我写的一个跨平台 CLI,用来把本地大模型或批处理任务的完成通知推送到飞书、Telegram。为了省去每次手动去 Release 页下载二进制的麻烦,工具内建了自更新能力:启动时拉取 GitHub Releases,校验 Hash,无缝替换当前可执行文件。

在 macOS 上这套逻辑跑得丝滑无比。但在一次给 Windows 用户做升级测试时,诡异的事情发生了:

版本号检查正常,一到下载 tarball 包阶段,终端就卡住不动,几十秒后直接抛出 os error 10060: A connection attempt failed because the connected party did not properly respond(连接超时)。

我第一时间怀疑用户的网络: “你是不是没开代理?” “开了啊,你看我 Edge 浏览器刷 GitHub 飞快,Clash Verge 开着『系统代理』呢!”

屏幕共享一看,浏览器确实秒开 release 页面。这就离谱了:同一台机器、同一个局域网、同一个 GitHub URL,浏览器畅通无阻,CLI 却死活连不上?

两个各说各话的“系统代理”

在网络库的文档里,我们经常会看到“默认启用系统代理”这样的描述。比如 Rust 的 reqwest 默认开启了 .system_proxy() 特性。

很多开发者(包括最开始的我)会理所当然地认为:只要开了操作系统设置里的代理开关,底层网络库就能自动路由过去。

大错特错。

在 Windows 的世界里,“系统代理”存在着巨大的概念割裂:

【用户 / 桌面应用视角】
控制面板 / Windows 设置 ──> 注册表 Internet Settings ──> WinINet / WinHTTP ──> 浏览器 / 桌面软件
 
【开发者 / CLI 视角】
PowerShell / CMD 终端 ──> 进程环境变量 (HTTP_PROXY / HTTPS_PROXY) ──> libc / reqwest / curl
通俗比喻:两个各玩各的“代理账本”

简单来说,Windows 里面其实有两本互不相通的账本:

  • 第一本是“桌面系统账本”:平时在系统设置或代理软件(如 Clash)里打开“系统代理”,改的是 Windows 注册表。Edge、Chrome 浏览器都是盯着这本账本走的,所以网页刷刷秒开。
  • 第二本是“终端小本子”:用 Rust、Python、Go 写出来的命令行工具,默认只认系统环境里的环境变量(比如 HTTP_PROXY)。

代理软件打开时,只把“系统账本”改了,但根本没权限去改终端里的“小本子”。命令行工具一出门,看到小本子干干净净什么也没写,就傻乎乎跑去直接联网,在国内网络下自然当场超时。

Windows 桌面用户常说的“开启系统代理”,是指在系统设置或通过代理软件修改了当前用户的注册表项: HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings 下的 ProxyEnableProxyServer

而以 reqwest 为代表的绝大多数现代开源网络库(本质上继承了 Unix/POSIX 工具链的习惯),它们所谓的“系统代理支持”,默认仅仅是去读取当前进程的 HTTP_PROXYHTTPS_PROXYALL_PROXY 环境变量!

为什么 reqwest 在 Windows 上不默认去读注册表?

跨平台网络库为了保持行为一致与高性能,通常只检查环境变量。

  1. Windows 的 Internet Settings 包含了复杂的 PAC 脚本(Proxy Auto-Config)、WPAD 自动发现协议以及复杂的 Bypass 规则,需要调用重型的 WinINet / WinHTTP API,直接引入会导致二进制体积膨胀和跨平台抽象破裂;
  2. 很多守护进程或服务器场景根本没有桌面用户 Session,强读注册表可能拿到脏配置。所以网络库把这个球踢回给了应用层。

桌面代理软件(如 Clash、v2rayN)在打开“系统代理”开关时,只是帮你在注册表里写下了 127.0.0.1:7890。它根本不可能、也没有权限去遍历操作系统中所有已经打开的 cmd.exe 或 powershell.exe 进程,并把环境变量注入进去!

环境变量其实全空

为了验证这个猜想,我让用户在报错的 PowerShell 里敲下:

Get-ChildItem Env: | Where-Object { $_.Name -match "PROXY" }

终端回车后一片死寂——三个代理环境变量全部为空

这下因果关系彻底闭环了:

  • 浏览器走的是 Windows Internet Settings 注册表通道,自动将流量转给了本地的 127.0.0.1:7890
  • CLI 打开网络连接,查了查环境变量全是空,于是信誓旦旦地发起直连。而直连 GitHub 在国内网络环境下的下场,自然只有超时。

临时解法当然很简单,告诉用户在终端里敲一行: $env:HTTPS_PROXY="http://127.0.0.1:7890"

但这太反直觉了!对于一个做桌面体验的工具,让用户在代理软件明明显示“系统代理已启动”的情况下,还要去背环境变量命令,属于极其糟糕的工程妥协。

代理选择优先级矩阵

要做真正的开箱即用,CLI 就必须在 Windows 平台上具备主动探测注册表的能力。但这又引出了新问题:如果用户在 CI 脚本里显式指定了环境变量,或者命令行加了参数,该听谁的?

最终设计了如下四级优先级决策树:

┌────────────────────────────────────────┐
│ 1. 显式 CLI 参数: --proxy <URL>         │ (最高:覆盖一切)
└──────────────────┬─────────────────────┘
                   ▼ 未指定
┌────────────────────────────────────────┐
│ 2. 进程环境变量: HTTPS_PROXY / ALL_PROXY│ (优先照顾自动化脚本/容器环境)
└──────────────────┬─────────────────────┘
                   ▼ 为空
┌────────────────────────────────────────┐
│ 3. Windows 注册表 Internet Settings     │ (仅在 Windows 且 ProxyEnable=1 时生效)
└──────────────────┬─────────────────────┘
                   ▼ 未启用
┌────────────────────────────────────────┐
│ 4. 直连网络 (Direct Connection)         │ (最低:走默认网关)
└────────────────────────────────────────┘

同时提供 --no-proxy 开关,允许用户在代理配置错乱时强行跳过所有自动发现,回归裸连。

细节魔鬼:解析 Windows 注册表代理字符串

Windows 注册表里的 ProxyServer 字符串格式极其古怪,它可能是单地址(如 127.0.0.1:7890),也可能是协议分立格式(如 http=127.0.0.1:7890;https=127.0.0.1:7890;ftp=...)。

在解析时有两大坑:

  1. 缺少协议头:注册表里通常直接存 127.0.0.1:7890,传给 reqwest::Proxy::all 会被当成非法 URL 报错,必须手动嗅探并补全 http://
  2. HTTPS 请求不等于 HTTPS 代理:请求目标是 https://github.com,并不代表你要连一个 https:// 开头的代理服务器。本地客户端代理绝大部分都是标准的明文 HTTP 代理,通过 CONNECT 隧道协议打通上层 TLS。因此代理协议头填 http:// 才是正确的。

“先有鸡还是先有蛋”的升级困境

写好注册表探测代码、在本地跑通后,我突然意识到一个滑稽的悖论:

能够支持读取系统代理的,是尚未发布的 v1.2.0; 而现在卡在 v1.1.0 连不上网的用户,正是因为旧版本不知道怎么读注册表!

如果新版本代码躺在 GitHub 上,旧版本拉不下来,那这个修复对现有用户来说就是“废铁一块”。

为了打通这最后闭环,更新链路必须往前移:

  1. 我们提供的一键安装脚本(install.ps1)必须先行进化。PowerShell 原生支持访问注册表,让安装脚本在下载 release 包前,率先嗅探系统代理并自动注入当前 Session;
  2. 为老用户提供简单的引导:“若检测到老版本网络超时,请运行一键升级脚本”,脚本拉取最新二进制直接覆盖,老用户从此获得永久自治能力。

让报错指出真实的路径

解决问题后,我还重构了 CLI 的网络错误提示。以前只会机械地打印底层的 OS Socket 错误码,用户根本不知道发生了什么。

现在,CLI 在发版前会打印清晰的探测诊断:

  • [INFO] 检测到 Windows 系统代理已启用: http://127.0.0.1:7890 (来自注册表)
  • 如果连接失败,明确提示:连接目标超时。当前使用代理: http://127.0.0.1:7890,请确认代理软件运行正常,或使用 --no-proxy 尝试直连。

在操作系统层面,“系统代理”从来不是一个全自动生效的透明通道,而是一份公开的协议契约。你以为理所应当的便利,背后都是上层软件替你默默做完的脏活。

评论