事情的起因,只是想拉一个Docker镜像
软路由这东西,图的就是一个省心:局域网里随便哪台设备联网,不用配代理、不用装客户端,直接就能访问外网,该走代理的域名自动走代理,该直连的自动直连。家里这套用了很久,一直挺稳。
直到这次在Windows里的WSL2上跑一条再普通不过的命令:
docker run --rm --gpus all nvidia/cuda:12.3.0-base-ubuntu22.04 nvidia-smi
报错了:
docker: Error response from daemon: Get "https://registry-1.docker.io/v2/": EOF.
第一反应是"这有什么好排查的,肯定是网络又抽风了",结果这一查,从晚上四点多查到快五点,最后发现的事,远比"Docker Hub连不上"要大得多——这台电脑上的WSL2,压根就没有真正被软路由的透明代理接管过,所有出海流量,一直在裸奔。
要看懂这事,得先搞清楚透明代理是怎么"认出"该代理谁的
很多人对透明代理的理解停留在"它很智能,能自动分辨该走代理的网站",其实这套"智能"一点都不玄乎,靠的是一个很朴素的手段:偷看DNS查询。
打个比方,你可以把软路由想象成小区门口的保安亭。保安要判断"这辆车该不该被拦下检查",靠的不是车本身长什么样,而是提前有人跟保安打过招呼:"等会儿有辆车要去XX栋,麻烦重点看一下"。这个"打招呼"的动作,对应到网络世界,就是DNS查询——你的设备问"docker.io的IP地址是什么",这条问题如果被软路由看到了,软路由就记一笔:"哦,接下来这几个IP,都是docker.io,按规则这个域名要走代理,那这几个IP的所有流量,都得进代理隧道。"
这套机制唯一的前提是:DNS查询必须真的路过保安亭。保安要是压根没看到那辆车经过、也没人跟他打招呼,那这辆车爱走哪走哪,保安一点办法都没有,不是保安渎职,是这辆车压根没经过他的岗位。
而这次真正的问题就出在这——WSL2这台"车",压根没有经过软路由这个"保安亭"。
排查过程:一路走偏,最后才摸到根子上
第一步:以为是GFW又在搞事,结果只是个巧合的表象
先查DNS解析对不对:
nslookup registry-1.docker.io
解析很正常,返回了一堆AWS的IP。那再往下测TLS握手:
curl -v https://registry-1.docker.io/v2/
卡在了这一步:
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (OUT), TLS alert, decode error (562):
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
TCP握手成功了,ClientHello发出去了,然后对面直接扔回一个奇怪的alert把连接掐断——这个特征太像经典的"运营商/GFW针对SNI关键字做的干扰"了。为了验证,对比测了百度和Cloudflare:
curl -v https://www.baidu.com
curl -v https://cloudflare.com
这两个全部正常,只有Docker Hub这样,当时几乎认定"这就是GFW在针对性阻断",方向感觉找对了。但这其实是个巧合的假象——真正的原因跟GFW关系不大,只是干扰和"没走代理"这两件事凑巧长得很像,容易先入为主走偏。
第二步:查代理端口,顺手踩了个协议不对的坑
既然怀疑是干扰,那走软路由自己的代理应该能绕过去。软路由上暴露了一个代理端口 10.0.0.1:1070,先用HTTP代理协议测:
curl -v -x http://10.0.0.1:1070 https://registry-1.docker.io/v2/
结果连Google都连不上:
* Recv failure: Connection reset by peer
先用 nc 确认端口本身是通的:
nc -zv 10.0.0.1 1070
Connection to 10.0.0.1 1070 port [tcp/*] succeeded!
端口通,但HTTP协议一发请求就被reset,说明这个端口压根不是HTTP代理,换成SOCKS5协议试试:
curl -v -x socks5h://10.0.0.1:1070 https://registry-1.docker.io/v2/
这次顺利拿到了:
< HTTP/2 401
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
这个 401 是Docker Hub对匿名请求的标准回应,说明TLS握手、HTTP通信全都走通了。这一步解决了一个真实存在的坑(端口协议搞混了),但这不是这次连不上的根本原因——因为软路由的透明代理,本来就不需要任何设备手动指定代理地址,"手动指定代理能不能通"跟"透明代理有没有生效"是两码事。
第三步:拿Windows主机一对比,问题范围一下就锁死了
既然软路由的代理本身没毛病,那就该换个问法:"为什么WSL2走透明代理(什么都不手动配)会失败?"先在Windows主机(不是WSL)里直接测:
curl.exe -v https://registry-1.docker.io/v2/
结果非常干脆:
< HTTP/1.1 401 Unauthorized
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
Windows主机,什么都没配,直接就通了。再回WSL里跑同样的命令,还是时通时断。这一下问题范围彻底锁定:同一台物理机器、同一个软路由、同一根网线,Windows能被透明代理正常接管,WSL2不能。这已经不是"网络不稳定"能解释的了,一定是WSL2这个"设备"本身,在网络层面跟Windows主机不是一回事。
第四步:直接抓包,眼见为实
既然怀疑WSL2的流量路径有鬼,就别再猜了,直接在软路由上抓包,看流量到底有没有真的路过。先锁定WSL对应的源IP和443端口:
tcpdump -i any -n 'host 10.0.0.112 and port 443'
抓包挂起来,同时在WSL里发起请求。第一次结果——啥都没抓到,空的。这时候第一反应是怀疑抓包命令本身是不是有问题,于是先验证一下抓包能力本身没毛病:
tcpdump -i br-lan -n -c 20
这次抓到了一堆其他设备的流量,说明抓包工具没问题。回头再核对时间点才反应过来:之前那次WSL访问其实走的是纯直连(因为对Docker Hub的干扰不是100%必然,偶尔直连能蒙混过关),软路由自然什么都看不到——不是抓漏了,是这条流量真的没被拦截处理,从头到尾裸奔出去了。重新精确对时间点抓一次:
tcpdump -i any -n port 443 and host 98.91.123.139
这次真抓到了,能看到完整的TCP三次握手,客户端发出TLS ClientHello(tcpdump误判成了RTSP协议,纯粹是因为源端口8554刚好是RTSP的常用端口,实际内容就是TLS数据),然后对端没有正常回一个Server Hello,过了一会儿直接发FIN把连接关了。这条连接确实经过了软路由,也确实是从软路由直连出的国,中途被干扰掐断的——证明"流量到了软路由"这件事,跟"有没有被识别成需要代理",是两个完全独立的问题。
第五步:把PassWall的规则一条条翻出来,发现规则本身完全没错
先确认PassWall到底用的是老的iptables还是新的nftables:
iptables-save | grep -i "PSW\|passwall\|REDIRECT\|TPROXY"
结果是空的,换nftables查:
nft list ruleset | grep -i "psw\|passwall\|redirect\|tproxy"
这次翻出了一整套完整的规则链,可以看到透明代理用的是TPROXY机制,而且流量计数器一直在实时增长:
ip protocol tcp ip daddr @psw_gfw counter packets 20076227 bytes 1126987744 jump PSW_RULE comment "默认"
这条规则活得好好的,一直在工作。再去网页后台"规则列表"→"代理列表"里确认,docker.io 这个域名早就写在代理名单里了,一个字都没漏。
规则完全正确,机制也在正常运转,可WSL就是走不进代理。这时候基本可以排除"规则写错"这个可能,问题一定出在更前面一环——"识别这条流量该不该走代理"这个动作,压根就没被触发。
第六步:真相浮出水面——WSL2的DNS查询,压根没走出这台电脑
回头去看WSL自己的DNS配置:
cat /etc/resolv.conf
nameserver 10.255.255.254
这地址看着挺正常,像个网关。但查一下这个地址到底绑在哪张网卡上:
ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
inet 10.255.255.254/32 brd 10.255.255.254 scope global lo
这个地址绑在WSL自己的回环接口 lo 上,根本不是一个会真的出现在网线上的外部地址。换句话说,WSL2查DNS的时候,问的是"自己家里的一个信箱",这个信箱压根不通向外面的邮局(软路由)。为了实锤这一点,直接在WSL对外的虚拟网卡上抓DNS流量:
sudo tcpdump -i eth0 -n port 53 -c 10
同时另开一个窗口跑:
nslookup registry-1.docker.io
DNS查询确实成功了,能拿到解析结果,但抓包这边——一个包都没有。这次查询从头到尾没有产生任何一个经过 eth0 网卡的网络包,就像这条查询根本没发生过一样。
查了下资料,这才对上号:这是Windows 11 22H2之后WSL2新增的一个功能,官方叫DNS Tunneling(DNS隧道)。它的设计初衷是给VPN用户提供便利——很多企业VPN客户端有一套复杂的域名解析策略(NRPT规则),如果WSL2走常规的网络路径查DNS,很容易在这类环境下解析失败。于是微软干脆让WSL2的DNS请求走一条完全独立的内部通道:不经过物理网卡、不经过任何虚拟交换机、直接在Windows主机内部通过进程间通信被接住处理。这套设计对企业VPN用户是好事,但对我这种指望软路由"透明接管一切"的场景,等于是所有DNS查询直接绕开了软路由这个岗哨,而且这不是针对Docker Hub一个域名,是WSL2里发出的每一条DNS查询,不管你查的是github.com、google.com还是随便什么域名,全都走了这条外面看不见的内部通道。
打个比方,这就相当于你家小区门口有个非常尽职的保安,专门登记进出的快递员,好提前通知里面的住户"注意,这是要送到你家的包裹"。但你家里偷偷装了台传送门,快递直接从传送门送进来,压根不路过小区大门。保安不是失职,他从始至终不知道你家有这么个装置存在。这台传送门管的不是某一件快递,是你家收发的所有包裹。
解决方案:两条路,一条治标,一条治本
治标:给单个服务硬加白名单IP段
既然问题是"DNS查询没被看到",那干脆绕开DNS这一环,直接按目标IP硬拦截。PassWall有个静态IP名单叫 psw_black_static,可以手动往里塞IP段:
nft add element inet passwall psw_black_static { 54.80.0.0/12 }
nft add element inet passwall psw_black_static { 98.80.0.0/12 }
nft add element inet passwall psw_black_static { 44.192.0.0/10 }
# ...(还有其他几个Docker Hub常用的AWS网段)
加完之后立刻测试:
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{http_code}\n" https://registry-1.docker.io/v2/; sleep 1; done
401
401
401
401
401
确实稳定了。但这条路有两个明显的短板:一是命令行 nft add element 改的只是内核里正在跑的运行时状态,不会写回配置文件,网页后台面板刷新后完全看不到这几条,软路由一重启大概率就丢了;二是——这次的问题根本不是Docker Hub一个服务的事,而是WSL2所有出站DNS查询的通病,按这个思路,等于要把每一个访问不了的境外服务都挨个抓IP段、挨个手动加,IP池还会变、覆盖也不可能完整,是个没有尽头的体力活。
治本:把WSL2的这条内部暗道直接关掉
真正一次性解决问题的办法,是关掉WSL2的这个DNS隧道功能,让它老老实实把DNS查询交给正常的网络路径处理。在Windows侧编辑(如果没有就新建)这个文件:%UserProfile%\.wslconfig,加上:
[wsl2]
dnsTunneling=false
保存后在PowerShell里重启WSL:
wsl --shutdown
重新进WSL,确认一下DNS配置:
cat /etc/resolv.conf
nameserver 172.28.112.1
这次的地址变成了 172.28.112.1——这是Windows这边WSL虚拟网卡的真实网关地址,不再是那个只存在于本机内部的回环地址。也就是说,DNS查询这次真的要"出门"了,会真正经过Windows的网络栈、经过物理网卡、最终摆在软路由这个岗哨面前。
回软路由确认一下,这次的查询有没有被正常识别、对应的IP有没有被自动记进代理名单:
nft list set inet passwall psw_gfw | grep -E "44\.|54\.|98\."
能看到一大串带着过期时间的IP出现在了这个动态名单里,说明PassWall这次真的"看到"了WSL2发出的DNS查询,并且正确识别出了这些域名该走代理。再测一遍连通性:
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{http_code}\n" https://registry-1.docker.io/v2/; sleep 1; done
401
401
401
401
401
连续五次全部稳定通过,而且这次解决的不只是Docker Hub,是WSL2里所有依赖透明代理判断的境外访问,一次性全部恢复正常。
顺带确认了一下这个改动有没有副作用:不会影响WSL2的IP地址分配,eth0 这张网卡依然由Hyper-V正常管理,每次重启拿到的IP该是什么还是什么。唯一需要注意的是,如果以后要接一些依赖NRPT规则做域名解析的企业VPN,可能需要重新评估是否开启这个功能——但对绝大多数只是想让WSL2老老实实走软路由的场景来说,这个代价几乎可以忽略。
一句话总结
"规则配得对" 跟 "规则真的在起作用",中间可能隔着一整套你压根不知道存在的隐藏机制。这次一路走偏:先怀疑是GFW作祟,其实只是巧合;又发现代理端口协议用错了,是个真问题但不是主线;翻遍PassWall配置,规则一个字都没写错。最后才发现,WSL2悄悄给自己接了根DNS暗管,让所有DNS查询全程绕开了软路由这个本该路过的岗哨——而且这事从来不是针对Docker Hub一个域名,是WSL2这台"设备"访问任何境外服务时都在犯的同一个错。每一层排查都得靠最原始的证据说话——抓包、nftables计数器、DNS解析出来的真实地址——一步步交叉验证,不能光凭"看起来像"某个熟悉的问题就下判断。
附:排查时用到的核心命令
# 测试TLS握手是否正常,能大致看出是DNS问题还是连接被干扰
curl -v https://registry-1.docker.io/v2/
# 确认代理端口用的是HTTP还是SOCKS5协议,别搞混
curl -v -x socks5h://<软路由IP>:<端口> https://registry-1.docker.io/v2/
# 确认端口本身通不通,排除端口没监听的可能
nc -zv <软路由IP> <端口>
# 在软路由上抓包,确认某台设备的流量是否真的路过了网关
tcpdump -i any -n 'host <设备IP> and port 443'
# 查看PassWall/nftables透明代理规则是否在正常生效
nft list ruleset | grep -i "psw\|passwall\|redirect\|tproxy"
# 查看某个动态IP集合当前收录了哪些IP(域名被识别后自动写入)
nft list set inet passwall psw_gfw
# 手动把固定IP段塞进静态代理名单,临时绕开DNS识别这一步
nft add element inet passwall psw_black_static { <IP段> }
# 查看WSL2当前实际用的DNS服务器地址
cat /etc/resolv.conf
# 确认某个DNS地址是不是只存在于本机回环接口,压根没通向外部网络
ip a
# 在WSL内部抓包,验证DNS查询到底有没有经过对外的虚拟网卡
sudo tcpdump -i eth0 -n port 53 -c 10
# 彻底关闭WSL2的DNS隧道功能,让所有DNS查询老老实实经过网关(写入Windows侧.wslconfig后生效)
# [wsl2]
# dnsTunneling=false
wsl --shutdown
# 验证效果:连续测试几次,确认稳定性而不是碰运气
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{http_code}\n" https://registry-1.docker.io/v2/; sleep 1; done
Comments NOTHING