OpenVPN 与 v2rayN 共存排查实录:DNS 黑洞、hosts 失效与代理作用域的三重坑

需求:一边连着公司 OpenVPN 访问内网代码仓库,一边用 v2rayN 访问 ChatGPT 和 Codex。
结果:两个一起开,内网仓库就打不开了。

这篇文章记录了完整的排查过程——包括两个我一开始判断错的方向、三个真正的坑,以及最终稳定运行的配置方案。


〇、TL;DR(赶时间的看这里)

如果你也遇到类似场景,先直接对照这几条:

  1. 别开 TUN 模式。 TUN 网卡的 interface metric 最低,Windows 会优先使用它上面配的 DNS,
    而代理软件的 DNS 上游是公网 DNS/DoH——在公司 VPN 环境下全部不通,导致所有域名解析超时。
  2. 像 Codex / ChatGPT 这类桌面应用,不读 Windows 系统代理。 它们需要环境变量或专属配置文件。
  3. 系统代理一开,hosts 文件就等于”失效”了。 浏览器不会自己解析域名,而是把请求直接甩给代理,
    你 hosts 里指向 127.0.0.1 的本地开发域名会被代理走掉,访问到真实的公网站点。
  4. 最终方案:v2rayN 用 PAC 模式(自定义 PAC 识别本地域名),Codex 用 ~/.codex/.env,
    清空全局环境变量。三条通道互相独立,谁也不干扰谁。

一、环境与现象

1.1 环境

组件 说明
公司 VPN OpenVPN Connect,分流模式(split tunnel)
代理客户端 v2rayN(Windows),混合入站端口 20808
代理内核 sing-box
需要代理的工具 浏览器访问 ChatGPT、Codex 桌面版
需要直连的资源 公司内网 Git 仓库、本地 PHP 开发项目

1.2 现象

  • 只开 OpenVPN:内网仓库正常,ChatGPT 打不开。
  • 只开 v2rayN(TUN 模式):ChatGPT 正常,内网仓库打不开。
  • 两个同时开:内网仓库打不开,且几乎所有域名都解析失败。

最迷惑的是第三点:不是”内网访问慢”,而是 DNS 全面瘫痪,连公网域名都解析不出来。


二、坑 1:TUN 模式把 DNS 打成了黑洞

2.1 最初(错误)的判断

第一反应是:”TUN 开起来后会劫持 53 端口,把发往公司 DNS 的查询也吃掉了。”

这个判断听起来合理,但是错的。实际测试数据推翻了它。

2.2 实测对照

在两种状态下分别执行 nslookup、curl、route print,得到关键差异:

检查项 TUN 关闭 TUN 开启
nslookup 默认服务器 100.100.2.136(公司 VPN 网卡) 172.18.0.2(TUN 网卡)
内网域名解析 ✅ 返回 172.16.10.20 ❌ 超时
curl -I https://git.corp.example.com ✅ 302 Found ❌ Could not resolve host
公网域名解析 ✅ 正常 ❌ 超时

2.3 真正的根因

问题不在”端口劫持”,而在 Windows 的 DNS 服务器选择机制。

  • sing-box 创建的 TUN 网卡,其 interface metric 被设为 0(最低)。
  • Windows 在选择 DNS 服务器时,优先使用 metric 最低的网卡上配置的 DNS。
  • 于是 nslookup 的默认服务器变成了 TUN 网卡的 172.18.0.2。
  • 而 sing-box 收到的所有查询,都会被转发到它的 DNS 上游——公网 DNS 或 DoH。
  • 在公司 VPN 环境下,公网 DNS 全部不通 → 全线超时。

关键推论:这不是“53 端口被劫持”。因为去往 100.100.2.136 的查询按路由表本来就会走 OpenVPN
(100.64.0.0/10 比 TUN 的 0.0.0.0/0 更具体,最长前缀匹配优先),根本不会进 TUN。

2.4 教训

“TUN 抢 DNS” 和 “TUN 劫持 53 端口” 是两件事。
前者是 Windows 的网卡优先级问题,后者是代理软件的 DNS 劫持功能。
混为一谈会让你在错误的方向上排查很久。

顺便记一下当时的路由表关键条目:

1
2
3
172.16.0.0/16     -> 172.31.0.1     (公司网段,注意是 /16 不是 /12)
100.64.0.0/10 -> 172.31.0.1 (公司 DNS 所在网段)
<某公网IP>/32 -> 192.168.1.1 (本机网关)

三、坑 2:Codex 根本不读系统代理

3.1 现象

开了系统代理之后:

  • ✅ 浏览器能打开 ChatGPT 网页版
  • ❌ ping api.openai.com 一直超时
  • ❌ Codex 桌面版连不上

一开始以为是 DNS 污染(解析出来的是 Bitly / Facebook 网段的 IP,是典型污染特征),
但后来发现:用 curl 走代理访问 api.openai.com,返回的是 OpenAI 真实的错误响应
(http_unsupported,说明链路是通的)。

所以问题不在网络,而在有些程序压根不读系统代理。

3.2 根因

Windows 上的”代理”其实有两条完全独立的通道:

通道 机制 谁在用
系统代理 WinINET 设置(注册表 ProxyEnable / AutoConfigURL) 浏览器、部分桌面程序
环境变量 HTTP_PROXY / HTTPS_PROXY Node.js、Rust、Go 写的命令行工具

Codex 属于后者。

而且更麻烦的是——Codex 桌面版是 MSIX 打包应用(装在 C:\Program Files\WindowsApps\),
这意味着:

  • 它没有开始菜单 .lnk,你没法改快捷方式
  • 用 .bat 包一层启动也不可靠——MSIX 应用激活时,环境块由系统重新生成,不继承调用者的自定义变量

所以”写个启动脚本给它单独设变量”这条常规思路,在这里是走不通的。

3.3 解法:.codex/.env

Codex 官方提供了一个专门解决这个问题的机制。官方规范原文:

At startup, Codex attempts to load ${CODEX_HOME}/.env if it exists.
Variables whose names start with CODEX_ are ignored when loading .env.
Other variables (e.g., OPENAI_API_KEY, HTTP_PROXY, etc.) may be loaded.

也就是说:Codex 启动时会自己去读 ~/.codex/.env,里面的代理变量会被加载进 Codex 自己的进程。

其他程序完全不知道这个文件存在。

这比设全局环境变量干净得多:

全局环境变量 .codex/.env
Codex ✅ 走代理 ✅ 走代理
PHP / curl / Guzzle ⚠️ 也被牵连 ✅ 不受影响
git / npm / pip ⚠️ 也被牵连 ✅ 不受影响
作用域 整个用户会话 只有 Codex 进程树

💡 小提示:.env 里以 CODEX_ 开头的变量会被 Codex 忽略(安全设计),
所以别试图在那里设 CODEX_HOME 之类的配置。


四、坑 3:系统代理一开,hosts 就”失效”了

4.1 现象

本地开发环境里,hosts 文件挂了一批域名指向 127.0.0.1:

1
2
3
4
127.0.0.1  laravel.io
127.0.0.1 shop.local
127.0.0.1 admin.myapp.test
...

开了系统代理之后,这些本地项目全部访问不了——浏览器打开 http://laravel.io,
看到的居然是真实的公网 laravel.io。

4.2 根因

系统代理模式下,hosts 文件等于不存在。

原因在于流程:

1
2
3
浏览器  →  系统代理已开启  →  不解析域名,直接把请求甩给 127.0.0.1:20808
→ 代理客户端一看 "laravel.io 是个国外域名" → 走节点出去了
→ 你摸到的是真实的公网站点,而不是本地项目 ❌

而当时系统代理的例外列表是这样的:

1
localhost;127.*;10.*;172.16.*;...;172.31.*;192.168.*

清一色是 IP 段,一个域名都没有。

所以:请求根本没走到”解析域名”这一步,就被代理接管了。hosts 完全没机会生效。

4.3 为什么 PAC 模式能解决

因为「PAC 模式」和「自动配置系统代理」是同一个通道的两种写法(都写进 Windows 系统代理),
唯一的区别是——走不走代理由谁决定:

自动配置系统代理 PAC 模式
判定依据 系统代理例外列表(只认 IP/网段) PAC 脚本(认域名,可以写逻辑)
本地域名 ❌ 只能逐个写死 ✅ 可以写规则自动判定

PAC 脚本在浏览器发出请求之前运行,返回 DIRECT 时浏览器就会自己去解析域名——
这一解析就会读到 hosts,问题解决。

4.4 顺带一提:PHP 的 curl 也会读环境变量

排查过程中还发现一条隐藏的坑:

PHP 的 curl 扩展(以及 Guzzle)会主动读取 http_proxy 环境变量。
而 Windows 的环境变量不区分大小写——你设的 HTTP_PROXY 对 PHP 来说就是 http_proxy。

也就是说,如果用 php artisan serve / PHP-FPM 跑项目,项目里任何一次 curl 请求都会被送进代理。
解法是完善 NO_PROXY,把公司域名和本地开发域名都包含进去。


五、最终方案:三条通道各司其职

5.1 架构

整个链路里有三条完全独立的通道:

1
2
3
4
5
6
7
8
9
10
11
  浏览器              Codex            PHP / curl / git
│ │ │
[系统代理] [.codex/.env] (什么都不设)
PAC 或 自动配置 专属配置文件
│ │ │
└─────────┬─────────┘ │
↓ ↓
┌──────────────────────┐ ┌──────────────────┐
│ v2rayN 127.0.0.1 │ │ 直连 │
│ :20808 │ │ 不经过代理 │
└──────────────────────┘ └──────────────────┘

三条通道唯一的共同依赖是:v2rayN 正在运行(入站端口在监听)。

⚠️ 重要认知:v2rayN 的系统代理开关,跟 Codex 没有任何关系。
Codex 走的是 .env 那条线。所以系统代理设成 PAC、自动配置、甚至关闭,
Codex 都照用不误——只要 v2rayN 在跑。

5.2 v2rayN:切换到 PAC 模式

在 v2rayN 里设置:设置 → 参数设置 → 系统代理

  • 系统代理类型:PAC 模式
  • 自定义 PAC 文件路径:指向下面这个文件

PAC 采用黑名单式策略:默认全部走代理(行为跟原来一致),只把本地/内网目标放行直连。

5.3 PAC 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// =====================================================================
// v2rayN custom PAC -- local-development friendly
// Strategy: BLACKLIST-STYLE
// * everything goes through the local proxy by default
// * local / intranet destinations are sent DIRECT
// =====================================================================

var PROXY_HOST = '127.0.0.1';
var PROXY_PORT = 20808; // 与 v2rayN 的入站端口保持一致

var PROXY_CHAIN = 'PROXY ' + PROXY_HOST + ':' + PROXY_PORT + '; DIRECT';
var DIRECT = 'DIRECT';

// 关掉可提升性能(dnsResolve 有开销);开启则新加的 hosts 域名自动生效
var AUTO_DETECT_HOSTS = true;

// 本地开发域名(写根域名即可,子域名自动匹配)
var LOCAL_DOMAINS = [
'myapp.test',
'shop.local',
'admin.local',
'dev.example.com'
];

// 保留 / 不可路由的 TLD
var LOCAL_TLDS = ['test', 'local', 'localhost', 'invalid', 'example'];

function endsWith(str, suffix) {
if (str.length <= suffix.length) return false;
return str.lastIndexOf(suffix) === str.length - suffix.length;
}

function matchDomain(host, domain) {
return host === domain || endsWith(host, '.' + domain);
}

function isPrivateIP(ip) {
if (!ip || ip === null) return false;
try {
return isInNet(ip, '127.0.0.0', '255.0.0.0')
|| isInNet(ip, '10.0.0.0', '255.0.0.0')
|| isInNet(ip, '172.16.0.0', '255.240.0.0')
|| isInNet(ip, '192.168.0.0', '255.255.0.0')
|| isInNet(ip, '100.64.0.0', '255.192.0.0');
} catch (e) {
return false;
}
}

function isIPv4(host) {
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
}

function FindProxyForURL(url, host) {
host = host.toLowerCase();

// 1) 无点主机名 -> 局域网机器名
if (host.indexOf('.') === -1) return DIRECT;

var i;

// 2) 显式本地域名
for (i = 0; i < LOCAL_DOMAINS.length; i++) {
if (matchDomain(host, LOCAL_DOMAINS[i])) return DIRECT;
}

// 3) 保留 TLD
for (i = 0; i < LOCAL_TLDS.length; i++) {
if (matchDomain(host, LOCAL_TLDS[i])) return DIRECT;
}

// 4) 字面量 IP
if (isIPv4(host)) {
return isPrivateIP(host) ? DIRECT : PROXY_CHAIN;
}

// 5) 自动识别:解析结果落在内网/回环 -> 说明它来自 hosts
if (AUTO_DETECT_HOSTS) {
try {
if (isPrivateIP(dnsResolve(host))) return DIRECT;
} catch (e) {
// 解析失败,交给代理
}
}

// 6) 其余全部走代理
return PROXY_CHAIN;
}

第 5 条是这个脚本的精髓:以后你在 hosts 里加任何新域名,不用改 PAC,它会自动识别。

如果发现网页加载变慢,把 AUTO_DETECT_HOSTS 改成 false 即可退回纯列表判断。

5.4 Codex:专属 .env

创建 C:\Users\<用户名>\.codex\.env(Windows)或 ~/.codex/.env(macOS/Linux):

1
2
3
4
5
6
7
8
9
10
11
# Codex loads this file at startup (CODEX_HOME/.env).
# ONLY the Codex process tree sees these variables.

HTTPS_PROXY=http://127.0.0.1:20808
HTTP_PROXY=http://127.0.0.1:20808
https_proxy=http://127.0.0.1:20808
http_proxy=http://127.0.0.1:20808

# 直连目标:公司内网 + 本地开发域名
NO_PROXY=localhost,127.0.0.1,::1,corp.example.com,myapp.test,shop.local,admin.local,172.16.0.0/12,100.64.0.0/10,192.168.0.0/16,10.0.0.0/8
no_proxy=localhost,127.0.0.1,::1,corp.example.com,myapp.test,shop.local,admin.local,172.16.0.0/12,100.64.0.0/10,192.168.0.0/16,10.0.0.0/8

改完必须完全退出 Codex 再重启 —— Windows 上是右键托盘图标 → 退出,
光关闭窗口不算。环境变量只在进程启动时读一次。

大小写各写一份是为了兼容不同库的读取习惯(Windows 下大小写不敏感,值一样,无害)。

5.5 清理全局环境变量

如果之前为了应急设过全局的 HTTP_PROXY / HTTPS_PROXY,现在可以清掉了:

1
2
3
4
5
6
7
:: 清除(当前会话生效,不需要管理员)
setx HTTP_PROXY ""
setx HTTPS_PROXY ""
setx NO_PROXY ""

:: 或者用 PowerShell 精确清除用户级变量
powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('HTTP_PROXY',$null,'User'); [Environment]::SetEnvironmentVariable('HTTPS_PROXY',$null,'User'); [Environment]::SetEnvironmentVariable('NO_PROXY',$null,'User')"

清掉之后,PHP、git、npm 之类的工具就完全不受代理影响了。


六、完整配置清单

组件 设置 说明
OpenVPN 保持连接 分流模式,内网路由由它下发
v2rayN 保持运行 + 选好节点 必须
v2rayN 系统代理类型 PAC 模式 + 自定义 PAC 路径 二选一,不要同时开自动配置
Codex ~/.codex/.env 专属,不影响别人
全局环境变量 清空 关键一步
TUN 模式 关闭 它会抢 DNS

七、验证清单

检查项 命令 / 操作 期望结果
内网路由存在 route print -4 有 172.16.0.0/16 -> <VPN网关>
内网域名真实解析 nslookup git.corp.example.com 返回内网 IP,不是 198.18.x.x
内网仓库可达 curl -I https://git.corp.example.com 302

| 全局变量已清 | 新开 cmd,echo %HTTPS_PROXY% | 输出 %HTTPS_PROXY%(未定义) |
| 浏览器上 ChatGPT | 访问 chatgpt.com | ✅ |
| 本地项目可访问 | 浏览器打开 http://myapp.test | 你自己的项目,不是公网站点 |
| Codex 可用 | 打开对话 | ✅ |


八、避坑速查表

症状 大概率原因 处理
开了代理后所有域名都解析不了 TUN 网卡抢了系统 DNS 关掉 TUN;或把 TUN 网卡的 DNS 改成内网 DNS
内网能 ping 通但域名解析失败 同上 同上
浏览器能上 ChatGPT,但某个桌面应用不行 该应用不读系统代理 找它的环境变量注入方式(如 .codex/.env)
命令行工具(curl/git)不走代理 同上,命令行工具只读环境变量 设 HTTP_PROXY / HTTPS_PROXY
本地 hosts 域名被解析成公网站点 系统代理例外列表只有 IP,没有域名 改用 PAC 模式,或把域名加进例外列表
PHP 项目请求异常 PHP 的 curl 读了 http_proxy 完善 NO_PROXY
装了 Clash 但节点跑不起来 — 不必换工具,v2rayN 的 PAC 模式就够了

九、总结

这次排查最大的收获,是把 Windows 上”代理”这件事彻底理清了:

Windows 上的代理不是一个开关,而是三条互不相干的通道:
系统代理(浏览器)、环境变量(命令行 / 部分应用)、以及直连。

搞清楚某个程序属于哪条通道,问题就解决了一半。

至于另外两个坑:

  • TUN 的杀手锏不是劫持,是 metric。 它把网卡优先级设到最高,顺带把系统 DNS 也接管了。
  • hosts 和系统代理是互斥的。 只要请求交给了代理,域名解析这一步就不在你机器上发生了,
    hosts 自然无从生效。PAC 之所以能救回来,是因为它让浏览器自己去解析域名。

最终这套配置跑了很久都很稳定。如果你也在折腾类似的环境,希望这篇能帮你少走几个小时的弯路。