Windows 浏览器能正常上网,自写的命令行工具却连不上代理
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 下的 ProxyEnable 和 ProxyServer。
而以 reqwest 为代表的绝大多数现代开源网络库(本质上继承了 Unix/POSIX 工具链的习惯),它们所谓的“系统代理支持”,默认仅仅是去读取当前进程的 HTTP_PROXY、HTTPS_PROXY 和 ALL_PROXY 环境变量!
为什么 reqwest 在 Windows 上不默认去读注册表?跨平台网络库为了保持行为一致与高性能,通常只检查环境变量。
- Windows 的 Internet Settings 包含了复杂的 PAC 脚本(Proxy Auto-Config)、WPAD 自动发现协议以及复杂的 Bypass 规则,需要调用重型的 WinINet / WinHTTP API,直接引入会导致二进制体积膨胀和跨平台抽象破裂;
- 很多守护进程或服务器场景根本没有桌面用户 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=...)。在解析时有两大坑:
- 缺少协议头:注册表里通常直接存
127.0.0.1:7890,传给reqwest::Proxy::all会被当成非法 URL 报错,必须手动嗅探并补全http://;- HTTPS 请求不等于 HTTPS 代理:请求目标是
https://github.com,并不代表你要连一个https://开头的代理服务器。本地客户端代理绝大部分都是标准的明文 HTTP 代理,通过CONNECT隧道协议打通上层 TLS。因此代理协议头填http://才是正确的。
“先有鸡还是先有蛋”的升级困境
写好注册表探测代码、在本地跑通后,我突然意识到一个滑稽的悖论:
能够支持读取系统代理的,是尚未发布的 v1.2.0; 而现在卡在 v1.1.0 连不上网的用户,正是因为旧版本不知道怎么读注册表!
如果新版本代码躺在 GitHub 上,旧版本拉不下来,那这个修复对现有用户来说就是“废铁一块”。
为了打通这最后闭环,更新链路必须往前移:
- 我们提供的一键安装脚本(
install.ps1)必须先行进化。PowerShell 原生支持访问注册表,让安装脚本在下载 release 包前,率先嗅探系统代理并自动注入当前 Session; - 为老用户提供简单的引导:“若检测到老版本网络超时,请运行一键升级脚本”,脚本拉取最新二进制直接覆盖,老用户从此获得永久自治能力。
让报错指出真实的路径
解决问题后,我还重构了 CLI 的网络错误提示。以前只会机械地打印底层的 OS Socket 错误码,用户根本不知道发生了什么。
现在,CLI 在发版前会打印清晰的探测诊断:
[INFO] 检测到 Windows 系统代理已启用: http://127.0.0.1:7890 (来自注册表)- 如果连接失败,明确提示:
连接目标超时。当前使用代理: http://127.0.0.1:7890,请确认代理软件运行正常,或使用 --no-proxy 尝试直连。
在操作系统层面,“系统代理”从来不是一个全自动生效的透明通道,而是一份公开的协议契约。你以为理所应当的便利,背后都是上层软件替你默默做完的脏活。