客户端上的 TUN 模式和「代理模式」的差别在于接管范围:代理模式只服务主动配置了代理的应用,TUN 模式把整机流量都交给 sing-box。代价是必须处理路由、DNS 与分流,而且这三个东西经常出问题,所以验证方法比配置本身更重要。
这篇在一台一次性 Ubuntu 虚拟机(arm64)上用 sing-box 1.14.1 跑完整套接管:TUN 接口与路由的变化、DNS 劫持、用一条 reject 规则证明分流生效、退出后的自动回收,以及 1.14 会直接拒绝启动的三处旧写法原文报错。

1. 便宜的说法与真实的代价

服务端那篇讲的是「在一台服务器上暴露代理」;客户端这边解决的是另一个问题:本机有一堆程序,浏览器、终端、Docker、后台服务,逐个配代理既麻烦又会漏。TUN 的做法是造一个虚拟网卡,把默认路由指过去,让内核把流量交给 sing-box 决定怎么走。

听上去只是「接管」,但要同时成立四件事:

  1. 路由要从物理网卡切到 TUN,且不能把 sing-box 自己的出站流量也绕回去(回环)
  2. DNS 要一起接管,否则域名解析仍然暴露在明网,分流规则也无从匹配
  3. 分流规则要能验证:不能只靠「能上网」判断,因为这既可能是直连成功,也可能是代理成功
  4. 退出时要能干净回收,否则你会在没有代理的情况下继续用一条指向已消失网卡的路由

2. 一份能通过 1.14 校验的客户端配置

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
{
"log": { "level": "debug", "timestamp": true },
"dns": {
"servers": [
{ "type": "udp", "tag": "local", "server": "223.5.5.5" },
{ "type": "https", "tag": "remote", "server": "1.1.1.1" }
],
"rules": [
{ "rule_set": "geosite-cn", "action": "route", "server": "local" }
],
"final": "remote",
"strategy": "ipv4_only"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"address": ["172.19.0.1/30"],
"auto_route": true,
"strict_route": true
}
],
"outbounds": [
{ "type": "direct", "tag": "direct" }
],
"route": {
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "domain_suffix": ["example.org"], "action": "reject" },
{ "rule_set": "geosite-cn", "outbound": "direct" }
],
"rule_set": [
{ "type": "local", "tag": "geosite-cn", "format": "binary", "path": "/etc/sing-box/geosite-cn.srs" }
],
"final": "direct",
"auto_detect_interface": true,
"default_domain_resolver": { "server": "local" }
}
}
1
2
$ sing-box check -c /etc/sing-box/config.json
(无输出即通过)

几个要点:

  • DNS 服务端写法换了。1.12 起是 {"type": "...", "tag": "...", "server": "..."}address: "https://1.1.1.1/dns-query" 那种老格式在 1.14 已经被移除(下一节有原文报错)。规则里也不再写 "server" 直接匹配,而是 "action": "route", "server": "local"
  • route.default_domain_resolver 是必填项。不写它,1.14 会直接拒绝启动;它的值就是上面某个 DNS 服务端的 tag。这个字段决定「出站需要解析域名时用哪个 DNS」,不写等于让 sing-box 猜。
  • strategy: "ipv4_only" 会主动拒绝 AAAA 查询。日志里会看到 dns: strategy rejected,这是预期行为:客户端通常不需要 IPv6 出口,主动拒绝能省掉一类「解析出 IPv6 但没有出口」的故障。
  • 规则集先落到本地。远端 rule_set 要在启动阶段完成首次下载,而那一刻 DNS 还没接管完成,容易卡在自己身上(实测:远端规则集 + 本地 DNS 服务端不可用时,启动直接失败在 initialize rule-set[0])。把 .srs 下到本地、用 "type": "local",启动就不受网络影响;要动态更新再换回远端。

3. 接管之后系统里发生了什么

启动前后的差异如下(同一台机器,只启动 sing-box):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=== 接口 ===
tun0 UNKNOWN 172.19.0.1/30 fe80::799e:fa46:9429:2229/64

=== ip rule ===
9000: from all to 172.19.0.0/30 lookup 2022
9001: from all lookup 2022 suppress_prefixlength 0
9002: not from all dport 53 lookup main suppress_prefixlength 0
9002: from all iif tun0 goto 9010

=== 表 2022 的默认路由 ===
default via 172.19.0.2 dev tun0

=== 日志 ===
inbound/tun[tun-in]: inbound DNS packet from 172.19.0.1:40603

三件事同时发生了:多出一个 tun0auto_route 把默认路由写进独立的表 2022,再用 ip rule 把普通流量导过去;DNS 的 53 端口被单独放行(dport 53 那条规则),交给 sing-box 的 DNS 模块处理。

规则表逐条看更清楚,这是接管态的完整输出:

1
2
3
4
5
6
7
8
9
10
11
0:      from all lookup local
9000: from all to 172.19.0.0/30 lookup 2022 # 目标是 TUN 网段,直接走表 2022
9001: from all lookup 2022 suppress_prefixlength 0 # 其余流量先查表 2022(该表只有默认路由)
9002: not from all dport 53 lookup main suppress_prefixlength 0 # 非 53 端口跳过 main 表具体路由
9002: from all iif tun0 goto 9010 # 从 tun0 出来的(即 sing-box 自己)跳到 9010,避免回环
9003: not from all iif lo lookup 2022
9003: from 0.0.0.0 iif lo lookup 2022
9003: from 172.19.0.0/30 iif lo lookup 2022
9010: from all nop # 循环的终点:什么都不做
32766: from all lookup main
32767: from all lookup default

9002 ... iif tun0 goto 9010 那一条就是防回环的关键:sing-box 自己发出去的包不会再被导回表 2022,否则代理的上游连接会绕回自己。

strict_route: true 的作用在服务端也常见:它会让 sing-box 补上更严格的规则,避免「一部分流量没被接管」的灰区;关掉它时,某些从本机其它网卡进出的流量会绕过 TUN。

退出后的回收同样重要,实测是干净的:

1
2
3
tun0 接口: 已移除
ip rule 残留: 0
连通性恢复: 200

异常退出是另一个结果。用 kill -9 打死进程之后:

1
2
3
4
tun0: 已移除                     # 设备随进程的文件描述符关闭而消失
ip rule 90xx 残留数: 8 # 规则全部留下
表 2022: (空) # 路由被一起收回
连通性: 200 # 因为表 2022 已空,残留规则形同虚设

也就是说,SIGKILL 之后系统还能正常上网(残留规则指向一张空表,会继续落到 main 表),但那些规则不会自己消失。反复崩溃几次就会累积一堆;更麻烦的是它们引用的表号下次可能被新进程用上。所以:别用 kill -9 停 sing-box,正常退出(SIGTERM)才会自动回收;已经留下的用 ip rule delete 逐条删掉即可。

4. 分流到底生效了没有:用 reject 做探针

「能不能上网」证明不了任何事。要在客户端验证分流,最省事的办法是故意写一条会拒绝的规则,再拿一个真实域名去撞它:

1
{ "domain_suffix": ["example.org"], "action": "reject" }
1
2
3
4
$ curl -s -o /dev/null -w "%{http_code}\n" https://example.org
000
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://example.com
200 0.738400s

而 sing-box 的 debug 日志会把你需要的信息全部交代清楚:

1
2
router: sniffed protocol: tls, domain: example.org
router: match[2] domain_suffix=example.org => reject

match[2] 里的数字是 route.rules 里的下标(从 0 开始),sniffed 说明 TLS 的 SNI 被读出来用于匹配。把探针域名换成你自己的业务域名,就能确认规则集与分流是否按预想命中——这比看流量图可靠得多。

5. 1.14 直接拒绝启动的三处旧写法

客户端配置最容易过期的就是这三处,报错原文如下(sing-box 1.14.1):

1
2
3
4
5
6
7
8
# ① 路由规则里的 geoip(1.12 移除)
FATAL initialize router: parse rule[0]: geoip database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0

# ② inbound 里的 sniff 字段(1.13 移除)
FATAL initialize inbound[0]: legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0

# ③ DNS 服务端的旧格式(1.14 移除)
FATAL decode config at /tmp/current.json: dns.servers[0]: legacy DNS server formats are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0

三处的替代写法:geoip → 用 rule_set(二进制 .srs 更小,加载也快);sniff / sniff_override_destination → 用 {"action": "sniff"} 路由规则;DNS → 上面第 2 节的新格式。

两条警告级的变化同样值得先改:download_detour 在 1.14 弃用、1.16 移除;TUN 的 stack 字段在 1.15 弃用、1.17 移除(新版本用自有协议栈,删掉即可)。

还有一个容易忽略的必填项错误,1.14 的报错如下:

1
2
ERROR missing `route.default_domain_resolver` or `domain_resolver` in dial fields is deprecated in sing-box 1.12.0 and will be removed in sing-box 1.14.0
FATAL to continuing using this feature, set environment variable ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER=true

6. 容器流量会不会跟着走

如果你在跑 Docker,TUN 接管之后容器出网走不走代理?

在 Linux 上实测(同一台机器,宿主 TUN 已开启,规则里有 example.org => reject):

1
2
3
4
5
6
7
8
host       https://example.org : 000(被拒)
container https://example.org : 失败
container https://example.com : 成功

sing-box 日志:
inbound/tun[tun-in]: inbound connection from 172.19.0.1:35432
router: sniffed protocol: tls, domain: example.org
router: match[2] domain_suffix=example.org => reject

结论:会跟着走。容器的转发流量同样命中 ip rule 与表 2022,被送进 TUN,因此规则对容器内也生效。日志里看不到 172.17.0.x,是因为 Docker 自己的 POSTROUTING ... -j MASQUERADE 在流量进入 TUN 前改了源地址,sing-box 侧只能看到 tun 的地址——这一点在排查「谁发起的请求」时会误导你,值得记住。

7. 客户端形态:GUI 与 CLI

macOS 上的 GUI 客户端(SFM 这类,用 NetworkExtension 实现)和你自己用 CLI 起 TUN 的区别只在「谁来创建网卡与路由」:GUI 把 tun inbound 与路由接管做进了系统扩展,因此不需要 root,退出时由系统回收;CLI 模式下 auto_route 就是上面那套 ip rule 操作,需要 root,退出时自己回收。

配置本身是同一套字段,所以上面的配置可以整段搬进 GUI 客户端。一个迁移注意点来自官方 migration 文档:macOS 独立客户端在 1.14 换了开发者账号,bundle 从 …io.nekohasekai.sfavt 变成 …io.nekohasekai.sfamt配置不会自动继承,需要按文档手动迁移 Group Container 目录。升级客户端版本前,先确认自己在用的是哪一个应用。

macOS 上连接前后实测到的三件事

在同一台 Mac 上用 GUI 客户端(NetworkExtension 形态)连接前后各取一次,得到三条可复现的观察:

1
2
3
4
5
6
7
8
9
10
11
# 1. 多出一个 utun 接口
连接前:utun 接口 5 个 连接后:6 个(新增 utun5)

# 2. DNS 解析器被换成隧道地址
resolver #1
nameserver[0] : 172.19.0.2
if_index : 27 (utun5)

# 3. 路由表里出现隧道的默认路由
default link#27 UCSIg utun5
1.0.0.1 link#27 UHWIig utun5

第二条尤其值得对照配置看:172.19.0.2 正是配置里 address: ["172.19.0.1/30"]下一个地址——官方文档描述的 dns_address 默认推导规则(取第一个 IPv4 条目的下一个 IP)在这里被完整复现了。也就是说 GUI 客户端连 DNS 都不需要你手配,它按同一套规则生成。

容器侧在同一时间窗内的出口 IP 与宿主同步变化(宿主与容器同为同网段的出口地址,断开后一起复原),说明容器的出网经由宿主网络栈;但 macOS 上做不出 Linux 那种 reject 探针的决定性证据,所以「macOS 容器是否被隧道接管」这一条只能算观察一致、未证伪。

8. 坑清单

  1. 远端规则集卡启动:首次启动要下载 .srs,而 DNS 可能还没接管好,先落地到本地更稳。
  2. detour 指向空 direct 出站:只有 direct 出站时给 DNS 写 detour 会被拒——start service: start dns/udp[local]: detour to an empty direct outbound makes no sense
  3. DNS 服务端不可用会以「启动失败」的形式出现,而不是「解析慢」:initialize rule-set[0]: ... lookup ... connection refused
  4. rejectblock 不是一回事:新写法用 actionblock 出站属于被淘汰的旧写法。
  5. 用日志验证,不要用连通性验证:看 match[n]sniffed 两行。

总结

  • TUN 接管 = 虚拟网卡 + 独立路由表(auto_route 用 2022 表 + 9000 段规则)+ DNS 劫持,退出时自动回收
  • 1.14 拒收三处旧写法:geoip 路由规则、inbound 的 sniff 字段、DNS 服务端旧格式;route.default_domain_resolver 已成必填
  • 分流验证的正确姿势是写一条 reject 探针规则,看 match[n] ... => rejectsniffed protocol 日志
  • Linux 上容器流量会被宿主 TUN 一并接管(Docker 的 MASQUERADE 会改写源地址,日志里看不到容器网段)

参考资料

系列索引:网络与自建服务