iOS Surge 加载 wireguard 配置打通 tailscale

Wireguard 是新一代的轻量级、易于配置、简洁优雅的 VPN 协议。 tailscale 是底层利用了 wireguard 协议和 UDP 打洞等方式,把天南地北的主机去中心化地连为虚拟局域网的商业产品。 tailscale 有很大的免费使用额度(3 用户 100 终端,2024.2),在各大操作系统平台上都有配置简单的客户端。但在 iOS 上启动会占用独占性的 VPN 接口,和 Surge 产生了互斥。最贴身的 iPhone 设备不能接入自己的 tailscale 网络是很可惜的,于是 2024 年的春节档就着手准备解决这个问题。

这个问题有很多解决途径,iPhone 开启 tailscale app 之后借助 tailscale 的 exit node 功能把所有流量交给别的设备分流处理算一种(落选原因:5G 速度大于家宽上传),Surge 等 app 除了 wireguard 外还支持的 socks 、 ssh 等协议也算一种(落选原因:不想暴露任何 TCP 端口)。家里有一台常开的 N100 低功耗处理器小主机,装好了 Linux 操作系统,当然要充分利用这个资源,顺便还能把该主机上挂载的移动硬盘以 smb 协议共享为 “网盘” 来使用。

由于 Surge 支持 “分离配置” 特性,就算是遇到不能修改的订阅的配置也可以通过 “自定义主配置文件”加载 “订阅配置的某些段落”来实现自由定制某些规则。正是在这种情况下,只要再写一个 wireguard 专用的配置文件,变成 “自定义主配置文件”+“订阅配置”+“wireguard 专用配置” 即可,关键段落如下:

[Proxy]
#!include subscription.conf, wireguard.conf
[WireGuard name]
#!include wireguard.conf
[Rule]
IP-CIDR,10.0.0.1/32,wireguard,no-resolve #内网主机 wireguard 的对端地址
IP-CIDR,192.168.1.0/24,wireguard,no-resolve #内网主机所在的家庭内网 LAN 段,可访问家庭局域网
IP-CIDR,100.64.0.0/10,wireguard,no-resolve #tailscale 段,如果嫌大可以精确到主机/32

Surge 中 wireguard 配置和官方配置没什么差别,section-name 所指定的名字要和主配置、 wireguard 配置中 [WireGuard name]里的 name 相对应;peerallow-ips 的值如需填多个地址段要用双引号包裹,如 allow-ips = "0.0.0.0/0, ::0/0"

由于该内网小主机一开始经考虑被设置为内网设备而非软路由,所以在(有公网 IPv4 地址的)主路由上通过转发规则把公网来的 wireguard 的 UDP 包转发到内网小主机。主机上通过 wg 自带的命令生成公私密钥对,填入 Surge 的 wireguard 配置中,wg-quick up wg0 启动服务后 iPhone 即可访问主机上的服务,连接成功。

2024.3.8 更新方案:通过主路由器端口转发的方式把公网来的 UDP 转发到小主机,更新为直连小主机的 ipv6 地址(在主路由中用 ip6tables 放行对应端口)。使用 ddns 工具同时更新主机的 ipv6 、 ipv4 地址,ipv4 则是主路由器的公网 ipv4 地址并 fallback 到上述端口转发方案。

到这里只实现了 wireguard 的两个对端互连,要让 iPhone 访问内网主机所在的家庭局域网 192.168.1.0/24,除了上文中提到的在 Surge 主配置文件 [Rule]中将指定网段流量导向 wireguard,还要在内网主机上通过 iptables 让内网主机把 wireguard 流量转发到家庭局域网。在网上的很多 wireguard 教程里,内网主机端的 PostUp(也就 wireguard 服务启动后执行的命令)是这样写的:

iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT;
iptables -A FORWARD -i eth0 -o wg0 -j ACCEPT;
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;

这三句话可能以不同的形式(网卡名称,地址段等)出现,但实质相同,实现了内网主机 wireguard 网卡 wg0 、物理网卡 eth0 互联互通和 NAT 的规则。如无特殊,PostDown 则是写入相对应的 iptables 删除命令(把-A 换成-D)。重要的是开启 Linux 内核包转发,编辑 /etc/sysctl.conf, 将 net.ipv4.ip_forward 的值从 0  改为 1 。然后 sysctl -p  生效。

在解决了访问内网主机物理局域网段的访问问题后,我想进一步访问内网主机所在的 tailscale 虚拟局域网,实现方法也很简单并可以按照以上步骤如法炮制:把上述 iptables 命令中的物理网卡 eth0 改为 tailscale 的虚拟网卡名 tailscale0 ,再写三句命令追加在 PostUp 的后面,PostDown 也同样追加,重启 wg0 即可生效。最后 systemctl enable wg-quick@wg0wg0 加入开机自启动服务。

最后,幸亏路由器支持 NAT Loopback(又称 NAT hair pinning),也就是 IPv4 的网关会自动识别并修正 “目标写着路由器公网 IP,实际上得往内网去” 的请求,使得以上所有基于 “iPhone 在公网” 的设定到了内网也能正常运作。(如不支持 Loopback 可能需要内网自定义 DNS,使得目标服务器在公网解析出公网 IP,在内网解析出内网 IP)最终结构如下:

由 Python 偶现 SSL 错误引发的 TLS cipher 问题探究

近期在用 Python (v3.12.0) 对某 API 接口持续性地调用测试过程中,发现会偶现 SSL 错误:

raise SslHandShakeException("ssl error", reason=e)
somesdk.exception.exceptions.SslHandShakeException: SslHandShakeException - ssl error
reason: SSLError(SSLError(1, '[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1002)'))

由于拨测是在 Windows 云主机上进行,把脚本拉到本地后在本地重新安装 SDK 等相关 py 环境,发现问题依旧。

此前从同事那里得知 ipv6 网络环境曾出现过不稳定的情况,初步怀疑是是 ipv6 引起此问题,是否在走与不走 ipv6 链路的区别中产生了 “不稳定” 现象。把 Windows 系统的 ipv4/ipv6 分别禁用,排除了这种可能性。祭出 Wireshark 在本地抓包,同时不停地运行 Python 调用 API 脚本发送请求,终于在一段时间测试后发现了问题:API 所在主机有 A 、 B 两个 ipv4 解析结果,在解析结果为 B 时稳定复现此问题,解析结果为 A 时则稳定不出现问题。

由于 Python 的底层运行库提示为 SSL 错误,因此这两个 server 的 TLS 支持情况成为关键。此前从一些网络技术文章可知,对于客户端 TLS 指纹(包括但不限于 client cipher suite 的组合特征)、甚至 HTTP2 指纹的识别早已经应用到脚本、爬虫等 “非人” 客户端的识别封禁等。

调查 Python 客户端与 A 主机 TLS 握手失败的包,发现以下现象:在服务端返回握手失败包之前客户端所发送的 Client Hello 包中,客户端(Python3.12.0 及相关网络库)向服务端提供了以下 18 个加密套件:

Pyhton 3.12.0 提供的 18 个 cipher suite(点击展开)
1TLS_AES_256_GCM_SHA384(0x1302)
2TLS_CHACHA20_POLY1305_SHA256(0x1303)
3TLS_AES_128_GCM_SHA256(0x1301)
4TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384(0xc02c)
5TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384(0xc030)
6TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256(0xc02b)
7TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(0xc02f)
8TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256(0xcca9)
9TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xcca8)
10TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384(0xc024)
11TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384(0xc028)
12TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256(0xc023)
13TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256(0xc027)
14TLS_DHE_RSA_WITH_AES_256_GCM_SHA384(0x009f)
15TLS_DHE_RSA_WITH_AES_128_GCM_SHA256(0x009e)
16TLS_DHE_RSA_WITH_AES_256_CBC_SHA256(0x006b)
17TLS_DHE_RSA_WITH_AES_128_CBC_SHA256(0x0067)
18TLS_EMPTY_RENEGOTIATION_INFO_SCSV(0x00ff)
前三个属于 TLS1.3 的加密套件在本次实际交互中不被支持,最后一个 SCSV 不是真正的加密套件,只有剩下的 14 个可用,而这 14 个套件皆为 DHE 或 ECDHE 。
使用 sslscan 工具分别扫描 A 、 B 两台服务端所支持的 cipher 情况(点击展开)
A 支持的 cipher
TLSv1.2128 bits0xC013ECDHE-RSA-AES128-SHA
TLSv1.2256 bits0xC028ECDHE-RSA-AES256-SHA384
TLSv1.2256 bits0x0035AES256-SHA
TLSv1.2128 bits0x002FAES128-SHA
B 支持的 cipher
TLSv1.2256 bits0x0035AES256-SHA
TLSv1.2128 bits0x002FAES128-SHA

可见 Python 3.12.0 这样的 Client 在 TLS 握手时向服务端提供的加密套件中,和 B 没有交集,和 A 有交集 TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,HEX 代码为 (0xc028),因此只能和 A TLS 握手成功。

在和其他部门同事复现、调试的过程中,据称同事的 Python3.6 没有这个问题,因此使用了自己另一台电脑的 Python3.9.2 环境进行复测,Python3.9.2 的相关 SSL 库在 TLS 握手 Client Hello 时提供了 43 个 cipher,最终服务端 B 在 Server Hello 中回复商定了 TLS_RSA_WITH_AES_256_CBC_SHA (0x0035) 作为加密套件。

Pyhton 3.9.2 提供的 43 个 cipher suite(点击展开)
1TLS_AES_256_GCM_SHA384(0x1302)
2TLS_CHACHA20_POLY1305_SHA256(0x1303)
3TLS_AES_128_GCM_SHA256(0x1301)
4TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384(0xc02c)
5TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384(0xc030)
6TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256(0xc02b)
7TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(0xc02f)
8TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256(0xcca9)
9TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xcca8)
10TLS_DHE_RSA_WITH_AES_256_GCM_SHA384(0x009f)
11TLS_DHE_RSA_WITH_AES_128_GCM_SHA256(0x009e)
12TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256(0xccaa)
13TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8(0xc0af)
14TLS_ECDHE_ECDSA_WITH_AES_256_CCM(0xc0ad)
15TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8(0xc0ae)
16TLS_ECDHE_ECDSA_WITH_AES_128_CCM(0xc0ac)
17TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384(0xc024)
18TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384(0xc028)
19TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256(0xc023)
20TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256(0xc027)
21TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA(0xc00a)
22TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA(0xc014)
23TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA(0xc009)
24TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA(0xc013)
25TLS_DHE_RSA_WITH_AES_256_CCM_8(0xc0a3)
26TLS_DHE_RSA_WITH_AES_256_CCM(0xc09f)
27TLS_DHE_RSA_WITH_AES_128_CCM_8(0xc0a2)
28TLS_DHE_RSA_WITH_AES_128_CCM(0xc09e)
29TLS_DHE_RSA_WITH_AES_256_CBC_SHA256(0x006b)
30TLS_DHE_RSA_WITH_AES_128_CBC_SHA256(0x0067)
31TLS_DHE_RSA_WITH_AES_256_CBC_SHA(0x0039)
32TLS_DHE_RSA_WITH_AES_128_CBC_SHA(0x0033)
33TLS_RSA_WITH_AES_256_GCM_SHA384(0x009d)
34TLS_RSA_WITH_AES_128_GCM_SHA256(0x009c)
35TLS_RSA_WITH_AES_256_CCM_8(0xc0a1)
36TLS_RSA_WITH_AES_256_CCM(0xc09d)
37TLS_RSA_WITH_AES_128_CCM_8(0xc0a0)
38TLS_RSA_WITH_AES_128_CCM(0xc09c)
39TLS_RSA_WITH_AES_256_CBC_SHA256(0x003d)
40TLS_RSA_WITH_AES_128_CBC_SHA256(0x003c)
41TLS_RSA_WITH_AES_256_CBC_SHA(0x0035)
42TLS_RSA_WITH_AES_128_CBC_SHA(0x002f)
43TLS_EMPTY_RENEGOTIATION_INFO_SCSV(0x00ff)

那么 Python3.12.0 和 Python3.9.2 的 Clinet cipher 到底有什么区别?略一对比可以看出 Python3.12.0 抛弃了相当多的 RSA 相关加密套件,而保留了和 DHE 、 ECDHE 相关的。

TLS1.3 一项重要的改进就是抛弃了 RSA 相关算法而保留了 Diffie–Hellman(DH)作为唯一的密钥交换算法。因为前者不具有前向安全性(Forward Secrecy,简称 FS),简单说,把 https 密文保存下来后,即使在未来私钥泄露的情况下,也无法解开所保存的密文。

Python3.10 的更新日志 中有一条:
The ssl module now has more secure default settings. Ciphers without forward secrecy or SHA-1 MAC are disabled by default. Security level 2 prohibits weak RSA, DH, and ECC keys with less than 112 bits of security. SSLContext defaults to minimum protocol version TLS 1.2. Settings are based on Hynek Schlawack’s research. (Contributed by Christian Heimes in bpo-43998.)
其思路和 TLS1.3 抛弃 RSA 比较一致,把不支持前向安全的加密套件废除以提高安全性。

最终服务端 B 包括域名的 v6 地址所接受的 cipher 与 A 拉齐,问题得到解决。一个看似简单又有点奇怪的 “SSL 偶现报错” 问题原来与 DNS 解析负载均衡、新版 Python 严格的 TLS 加密套件策略等一系列原因有关。