《Ubuntu 24.04 配置 Sing-box 代理服务器完整指南》解决的是「流量能出去」,这篇解决它之后的三个问题:规则按什么顺序生效、域名与 IP 规则分别在什么时机才能拿到匹配依据、rule_set 和 DNS 在 1.14 该怎么写。
分流最难的地方在于:规则写错了 sing-box 不会报错,只是安静地走错出口。每一节都给出结论、本机跑出来的日志和一个能自动断言的验证方法。文中所有输出都来自 sing-box 1.14.1(macOS arm64)实跑,贴出的完整配置都过了 sing-box check,只写了 rulesdns 之类的片段也都在拼回完整配置后逐个校验过。

0. 实验环境

分流要回答的是「某个请求最终由哪个出站接管」,所以先把出口做成可辨识的:直连出口指向一个本机 HTTP 服务,它会带回 X-Exit: direct;代理出口指向一个本机 SOCKS5 服务,它接受任何 CONNECT 并回 X-Exit: proxy。请求统一用 curl -x http://127.0.0.1:28802 发出,看响应头就知道落在哪个出口。DNS 用两台假服务器(127.0.0.1:15353127.0.0.1:15354),每台单独记日志,用来分辨某次解析走了哪一台。

1
2
3
4
5
6
7
/tmp/sbrt               实验目录
├── cfg/ 各实验的配置
├── rs/ rule-set(.json 源文件与 .srs 二进制)
├── route_test.py 按用例表断言分流结果
├── direct_http.py 直连出口:HTTP echo,带 X-Exit: direct
├── socks_canned.py 代理出口:SOCKS5,回 X-Exit: proxy
└── dns.py 假 DNS,按后缀映射返回固定 IP 并记日志

1. 规则怎么匹配

1.1 顺序:从上到下,首条命中即止

route.rules 是一个数组,sing-box 按顺序逐条求值,遇到第一条命中的规则就停止,后面的规则不再看。下面这份配置里两条规则故意重叠:domain_keyword: proxy 能匹配 a.proxy.testdomain_suffix: .proxy.test 也能匹配它。

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
{
"log": {
"level": "debug",
"timestamp": true
},
"dns": {
"servers": [
{ "type": "udp", "tag": "dns-cn", "server": "223.5.5.5" }
],
"strategy": "prefer_ipv4"
},
"inbounds": [
{ "type": "mixed", "tag": "in", "listen": "127.0.0.1", "listen_port": 28802 }
],
"outbounds": [
{ "type": "direct", "tag": "direct" },
{ "type": "socks", "tag": "proxy", "server": "127.0.0.1", "server_port": 28803, "version": "5" }
],
"route": {
"rules": [
{ "domain_keyword": "proxy", "action": "route", "outbound": "direct" },
{ "domain_suffix": ".proxy.test", "action": "route", "outbound": "proxy" }
],
"default_domain_resolver": "dns-cn",
"final": "direct"
}
}

请求 http://a.proxy.test:28801/ 的结果:

1
2
3
4
5
exit=direct path=/
--- sing-box log ---
17:02:25 INFO [1080651399 0ms] inbound/mixed[in]: inbound connection to a.proxy.test:28801
17:02:25 DEBUG [1080651399 0ms] router: match[0] domain_keyword=proxy => route(direct)
17:02:25 INFO [1080651399 0ms] outbound/direct[direct]: outbound connection to a.proxy.test:28801

rules 里两条规则的顺序对调,其余一个字不改,同一个请求:

1
2
3
4
5
exit=proxy host=a.proxy.test
--- sing-box log ---
17:02:28 INFO [802114653 0ms] inbound/mixed[in]: inbound connection to a.proxy.test:28801
17:02:28 DEBUG [802114653 0ms] router: match[0] domain_suffix=.proxy.test => route(proxy)
17:02:28 INFO [802114653 0ms] outbound/socks[proxy]: outbound connection to a.proxy.test:28801

match[N] 里的 N 就是 rules 数组的下标(从 0 开始),后面跟着触发命中的那个字段。第一份配置命中的是第 0 条,第二份也是第 0 条,但两条规则语义不同,请求就落到了不同出口。

这里有两条实践含义:

  • 不存在「sing-box 会挑更精确的那条规则」这种事。domain_keyword: proxy 写在前面,domain_suffix: .proxy.test 就永远不会生效。
  • 排查分流问题最省事的办法是把 log.level 设成 debug,直接在日志里看 match[N]N 和它右边的出站名,不用猜。

1.2 action:规则到底做什么

outbound 这个老写法是 action: route 的简写。把规则写成 { "domain_suffix": [".legacy.test"], "outbound": "proxy" }(不写 action),日志里出现的是:

1
2
17:09:40 DEBUG [1472967988 0ms] router: match[2] domain_suffix=.legacy.test => route(proxy)
17:09:40 INFO [1472967988 0ms] outbound/socks[proxy]: outbound connection to a.legacy.test:28801

常用的几种 action 与观察到的行为:

action 作用 命中时的日志
sniff 从流量里读出协议与域名,不决定出口,继续往下匹配 router: match[0] => sniff 加一行 router: sniffed protocol: http, domain: x.sniff.test
route 交给指定出站(outbound 字段是它的简写) router: match[2] domain_suffix=.legacy.test => route(proxy)
reject 直接拒绝,不产生连接 router: connection closed: rejected
hijack-dns 把 DNS 查询交给 sing-box 内部的 DNS 模块处理 router: match[1] protocol=dns => hijack-dns

reject 的完整效果,请求 http://a.blocked.test:28801/

1
2
3
4
5
6
=== a.blocked.test: http=502
17:09:40 DEBUG [2044589702 0ms] router: match[0] => sniff
17:09:40 DEBUG [2044589702 0ms] router: sniffed protocol: http, domain: a.blocked.test
17:09:40 DEBUG [2044589702 0ms] router: match[1] domain_suffix=.blocked.test => reject
17:09:40 DEBUG [2044589702 0ms] router: connection closed: rejected
17:09:40 DEBUG [2044589702 0ms] inbound/mixed[in]: connection closed: (rejected | Get "http://a.blocked.test:28801/": EOF)

curl 在建 CONNECT 隧道被拒绝时会把结果渲染成 HTTP 502,这不是 sing-box 返回了一个 502 响应:1.14.1 里没有这样的代码路径,官方对 reject 的说明是非 tun 连接被直接关闭。sing-box 没有向 a.blocked.test 发起连接,日志里的 connection closed: rejected 就是这条证据。

规则是「遇到第一条命中的就执行对应动作」,而 sniff 命中后并不终结匹配,它只是把域名补上,然后继续往下。这一点是第 2 节全部问题的来源。

所有规则都不命中时用 route.final 指定的出站,没写 final 就用 outbounds 里的第一个(官方文档口径)。

1.3 check 通过不等于规则可用

sing-box check 只检查配置能不能解析、内建字段能不能对上,出站是否存在、规则动作是否完整它都不管。三个真实例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 出站名拼错:check 通过,请求命中这条规则时才报错
$ sing-box check -c err-unknown-outbound.json ; echo $?
0
17:09:33 DEBUG [673589556 0ms] router: match[5] domain_suffix=.x.test => route(typo-outbound)
17:09:33 ERROR [673589556 0ms] router: outbound not found: typo-outbound

# 写了 action: route 却漏了 outbound:check 也通过,报错里的出站名是空的
17:10:40 DEBUG [333923840 0ms] router: match[5] domain_suffix=.y.test => route()
17:10:40 ERROR [333923840 0ms] router: outbound not found:

# 本地 rule-set 文件路径不存在:这个 check 会拦住
$ sing-box check -c err-missing-rs.json
FATAL[0000] initialize router: parse rule-set[0]: open /tmp/sbrt/rs/does-not-exist.json: no such file or directory

所以改完规则至少要把带 -ccheck 和一次真实请求都跑一遍,第 5 节给的是把这件事自动化的做法。

2. 域名规则与 IP 规则的时机

2.1 域名是 sniff 出来的

入站连接里能直接拿到域名的只有两种情况:客户端自己带了域名(SOCKS5 的目标地址、HTTP 代理的 Host 头)。其他情况下 sing-box 手里只有一个 IP,域名要靠 action: sniff 从流量内容里读出来,读出来之前在日志里长这样:

1
router: sniffed protocol: http, domain: x.sniff.test

2.2 域名规则写在 sniff 之前,等于没写

同一份配置,只把 action: sniff 和域名规则的次序换一下,请求 http://127.0.0.1:8880/ 并且带上 Host: x.sniff.test(目标故意用 IP,逼 sniff 去读 Host)。

域名规则在前:

1
2
3
4
"rules": [
{ "domain_suffix": ".sniff.test", "action": "route", "outbound": "proxy" },
{ "action": "sniff" }
]
1
2
3
4
5
exit=direct path=/
17:02:40 INFO [155265685 0ms] inbound/mixed[in]: inbound connection to 127.0.0.1:8880
17:02:40 DEBUG [155265685 0ms] router: match[1] => sniff
17:02:40 DEBUG [155265685 1ms] router: sniffed protocol: http, domain: x.sniff.test
17:02:40 INFO [155265685 1ms] outbound/direct[direct]: outbound connection to 127.0.0.1:8880

域名规则被跳过了(match[1] 是 sniff 那条),域名读出来的时候规则已经走完,请求落到了 final 的直连。域名规则在后:

1
2
3
4
"rules": [
{ "action": "sniff" },
{ "domain_suffix": ".sniff.test", "action": "route", "outbound": "proxy" }
]
1
2
3
4
5
6
exit=proxy host=127.0.0.1
17:02:43 INFO [324281045 0ms] inbound/mixed[in]: inbound connection to 127.0.0.1:8880
17:02:43 DEBUG [324281045 0ms] router: match[0] => sniff
17:02:43 DEBUG [324281045 0ms] router: sniffed protocol: http, domain: x.sniff.test
17:02:43 DEBUG [324281045 0ms] router: match[1] domain_suffix=.sniff.test => route(proxy)
17:02:43 INFO [324281045 0ms] outbound/socks[proxy]: outbound connection to 127.0.0.1:8880

同样的请求、同样的规则内容,次序不同,出口就不同。规律:

  • sniff 必须排在所有域名规则之前,实践里放在 rules 数组的第一条。
  • 域名规则写在 sniff 前面时,sing-box 不会报错,只是那条规则对 IP 目标的请求永远不生效。用 SOCKS5/HTTP 代理且客户端自己带域名时它照常命中,所以这个错误在很多环境里藏得很深。

2.3 IP 规则不会为域名目标做解析

ip_cidr 放在域名规则之前,用一个域名请求(a.direct.test,假 DNS 会把它解析到 127.0.0.1)和一个 IP 请求去测:

1
2
3
4
5
"rules": [
{ "action": "sniff" },
{ "ip_cidr": ["127.0.0.0/8"], "action": "route", "outbound": "proxy" },
{ "rule_set": "lab-direct", "action": "route", "outbound": "direct" }
]
1
2
3
4
5
6
7
request                            expect    got       result
http://a.direct.test:28801/ direct direct PASS
http://127.0.0.1:28801/ proxy proxy PASS

17:11:33 DEBUG [1860326653 0ms] router: match[2] rule_set=lab-direct => route(direct)
17:11:33 DEBUG [1860326653 0ms] dns: lookup domain a.direct.test
17:11:33 DEBUG [3667518793 0ms] router: match[1] ip_cidr=127.0.0.0/8 => route(proxy)

域名请求跳过了第 1 条 ip_cidr 规则(日志里它直接命中了 match[2]),只有 IP 字面量的请求才命中它。sing-box 没有为了匹配这条 IP 规则先去把域名解析出来。所以对域名请求来说,IP 规则放在前面还是后面结果一样;想让域名请求也命中地址规则,得在它前面加一条 action: resolve 先解析出 IP。稳妥的排列顺序是:sniffhijack-dns → 域名规则 → IP 规则 → final

3. rule_set

3.1 有哪些子命令

sing-box rule-set 的实际子命令(--help 输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ sing-box rule-set --help
Manage rule-sets

Usage:
sing-box rule-set [command]

Available Commands:
compile Compile rule-set json to binary
convert Convert adguard DNS filter to rule-set
decompile Decompile rule-set binary to json
format Format rule-set json
match Check if an IP address or a domain matches the rule-set
merge Merge rule-set source files
upgrade Upgrade rule-set json

日常只用得到四个:compile(源文件转二进制)、match(手工验证一条规则集匹配什么)、decompile(二进制转回 JSON)、merge(合并多个源文件)。

3.2 编译与匹配

规则集的源文件就是一个 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"version": 5,
"rules": [
{
"domain_suffix": [
".direct.test",
".cn"
]
},
{
"ip_cidr": [
"127.0.0.0/8"
]
}
]
}

编译成二进制,体积从 182 B 降到 73 B(加载耗时本文没有单独测):

1
2
3
4
5
6
$ ls -l lab-direct.json
-rw-r--r-- 1 liangliang.liu wheel 182 Sep 15 16:52 lab-direct.json
$ sing-box rule-set compile lab-direct.json
$ ls -l lab-direct.json lab-direct.srs
-rw-r--r-- 1 liangliang.liu wheel 182 Sep 15 16:52 lab-direct.json
-rw-r--r-- 1 liangliang.liu wheel 73 Sep 15 17:03 lab-direct.srs

match 用来确认某条规则集到底匹配什么,排查「为什么这个域名没走我写的规则集」时就靠它:

1
2
3
4
5
6
7
8
9
10
11
$ sing-box rule-set match lab-direct.json a.direct.test
match rules.[0]: domain_suffix=[.direct.test .cn]
$ sing-box rule-set match lab-direct.json foo.cn
match rules.[0]: domain_suffix=[.direct.test .cn]
$ sing-box rule-set match lab-direct.json bar.com
$ sing-box rule-set match lab-direct.json 127.0.0.1
match rules.[1]: ip_cidr=127.0.0.0/8
$ sing-box rule-set match lab-direct.srs a.direct.test -f binary
match rules.[0]: domain/domain_suffix=<binary>
$ sing-box rule-set match lab-direct.srs a.direct.test
FATAL[0000] invalid character 'S' looking for beginning of value: row 1, column 1
  • 二进制规则集必须加 -f binary,否则 sing-box 会把它当 JSON 解析,报上面那个 invalid character 'S'
  • 未命中时 match 没有任何输出,退出码仍然是 0,脚本里不能靠退出码判断,得看输出内容。
  • .srs 里的匹配内容已经是编译后的结构,match 只回 <binary>,看不到具体值,要核对规则集内容还是拿源文件跑。

3.3 decompile 会覆盖源文件

sing-box rule-set decompile-o 默认值是 <file_name>.json。也就是说,当二进制文件 trap.srs 旁边正好有一个手写的 trap.json 时,不加 -o 直接 decompile,会把源文件覆盖掉,命令退出码还是 0,界面上什么提示都没有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ md5 trap.json                                   # 手写源文件,120 字节
MD5 (trap.json) = 96c9c310c068f49bbe28ef5788595856
$ sing-box rule-set decompile trap.srs
$ echo $?
0
$ md5 trap.json # 已被覆盖,164 字节
MD5 (trap.json) = ea88340b5f4e82e3d0c03fa575e13cf8
$ cat trap.json
{
"version": 2,
"rules": [
{
"domain_suffix": [".cn", ".direct.test"]
},
{
"ip_cidr": "127.0.0.0/8"
}
]
}

注意覆盖后的内容会被改写:compile 把 rule-set 版本从 5 降到 2,并把单元素的 ip_cidr 数组序列化成字符串,注释和手工排版全丢。要反编译就用 -o 明确指定输出文件,或者先给源文件改名。

3.4 local 与 remote

route.rule_set 里每个条目就是一条规则集,localremote 的区别只有内容从哪来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"route": {
"rule_set": [
{
"type": "local",
"tag": "lab-direct",
"format": "source",
"path": "/tmp/sbrt/rs/lab-direct.json"
},
{
"type": "remote",
"tag": "lab-remote",
"format": "binary",
"url": "http://127.0.0.1:28804/lab-remote.srs",
"http_client": "rs-client",
"update_interval": "1h"
}
]
}
}
  • local 读本地文件,文件被改动后会自动重载(1.10 起);path 指向 .json.srsformat 可以省略。
  • remote 从 URL 下载,format 必须和实际内容一致(binary 对应 .srs),update_interval 缺省是 1d
  • 远程规则集只有开了 experimental.cache_file 才会落盘缓存,否则每次启动都要重新下载。

本机实验里用 python3 -m http.server 冒充远程源,启动日志和 HTTP 服务端访问日志对得上:

1
2
3
17:03:42 DEBUG router: updating rule-set lab-remote from URL: http://127.0.0.1:28804/lab-remote.srs
17:03:42 INFO router: updated rule-set lab-remote
(HTTP 服务端) 127.0.0.1 - - [15/Sep/2026 16:53:55] "GET /lab-remote.srs HTTP/1.1" 200 -

规则集本身要先验证过再用,match 对源文件跑一遍就够了,能省掉「下载下来的 .srs 到底是哪一版」这种扯不清的排查。

3.5 1.14 之后:http_client 取代 download_detour

远程规则集的下载客户端在 1.14 换成了显式的 http_clients。不配的话启动时会有一条警告,而且下载会走默认出站:

1
WARN[0000] implicit default HTTP client using default outbound for remote rule-sets is deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0.

这台机器上默认出站是代理,代理那头回的不是 .srs,于是规则集初始化直接失败:

1
FATAL[0000] start service: initialize rule-set[1]: initial rule-set: lab-remote: invalid sing-box rule-set file

老写法 download_detour 还能用,但同样会警告:

1
2
3
WARN[0000] legacy `download_detour` remote rule-set option is deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0.
17:09:00 INFO outbound/direct[direct]: outbound connection to 127.0.0.1:28804
17:09:00 INFO router: updated rule-set lab-remote

新写法是顶层的 http_clients 加规则集里的 http_client 引用,detour 决定这次下载走哪个出站:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"route": {
"rule_set": [
{
"type": "remote",
"tag": "geosite-cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
"http_client": "rs-client",
"update_interval": "1d"
}
]
},
"http_clients": [
{ "tag": "rs-client", "detour": "proxy" }
]
}

两个反直觉的地方,都实测过:

  • detour 指向一个「空」的 direct 出站会直接让 sing-box 拒绝启动。下面这段配置 check 能过,一跑就挂:
1
2
$ sing-box run -c cfg/final.json
FATAL[0000] start service: initialize rule-set[1]: initial rule-set: lab-remote: Get "http://127.0.0.1:28804/lab-remote.srs": detour to an empty direct outbound makes no sense

同一个配置把 "detour": "direct" 删掉就能正常下载。这个报错的来源在 sing-box 源码 common/dialer/detour.go:一个没有 bind_interfaceinet4_bind_address 之类任何参数、也没有自己的 detour 的 direct 出站被视为空,代码认为「绕道一个空直连」没有意义。要指定下载走代理就写代理出站的 tag,要直连就别写 detour

  • detour 生效的证据是看得见的。让 detour 指向代理出站,代理那头的日志里立刻多出一条 CONNECT:
1
2
17:03:48 CONNECT 127.0.0.1:28804 cmd=1
17:03:48 REQUEST GET /lab-remote.srs HTTP/1.1

随后 sing-box 报 invalid sing-box rule-set file,因为那个 SOCKS 服务回的是罐头响应而不是 .srs。这恰好证明下载走的是代理。

4. DNS 模块

4.1 新写法:type 取代 address

DNS server 从 1.12 起改成 type 字段描述,旧写法在 1.14 被移除,写错会直接拒绝启动:

1
2
$ sing-box check -c err-legacy-dns.json
FATAL[0000] decode config at cfg/err-legacy-dns.json: dns.servers[0]: legacy DNS server formats are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats

新旧对应关系(官方 migration 页面的表格):

1
2
3
4
5
6
{ "address": "local" }              →  { "type": "local" }
{ "address": "1.1.1.1" } → { "type": "udp", "server": "1.1.1.1" }
{ "address": "tls://1.1.1.1" } → { "type": "tls", "server": "1.1.1.1" }
{ "address": "https://1.1.1.1/dns-query" }
→ { "type": "https", "server": "1.1.1.1" }
{ "address": "quic://1.1.1.1" } → { "type": "quic", "server": "1.1.1.1" }

type 还多了 hostsdhcpmdnsfakeiptailscale 等几种。本机实验用的是 "type": "udp"server_port,指向假 DNS。

4.2 dns.rules 管的是哪一类查询

dns.rules 只在查询经过 DNS 模块时生效:客户端发来的查询(DNS inbound,或者被 hijack-dns 截获的查询)。它不决定 sing-box 自己发起解析时用哪台服务器。

已经指定了解析器的解析不走它。官方 migration 页面的原话是:通过 dial 字段的 domain_resolver、route 选项里的 default_domain_resolver,或者 DNS 规则动作与 resolve 动作里显式写的 server 发起的解析,不经过 DNS 规则匹配。所以「国内域名走国内 DNS 解析」这件事,光在 dns.rules 里写 domain_suffix: [".cn"] 是不够的,直连出站那次解析得靠 outbound.domain_resolver 指定(见 4.3)。

另外两处 1.14 的变化值得记一下(均为官方文档口径):

  • DNS 规则里的 ip_versionquery_type 现在对每次 DNS 规则求值都生效,不再只对客户端查询生效。
  • DNS 规则里挂一个只含 ip_cidr 的 rule-set(GeoIP 规则集就是这种)现在属于 legacy 模式,1.16 会移除,替代写法是先 action: evaluate 拿响应,再用 match_response 去匹配。实测在 DNS 规则里引用这种规则集,运行时会出现:
1
WARN[0000] Legacy Address Filter Fields in DNS rules is deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0, checkout documentation for migration: https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching

4.3 解析器挂在哪:default_domain_resolver 与 outbound.domain_resolver

route.default_domain_resolver 和出站上的 domain_resolver 是配套的两个字段,文档里的作用范围也不一样:direct 出站上写 domain_resolver 影响的是「请求里的域名」,其他类型出站影响的是「server 地址里的域名」(例如代理服务器的域名怎么解析)。

这里有个容易踩的坑:domain_resolver 不能写在 route 规则里,即使字段名拼对了也不行。

1
2
$ sing-box check -c route-rule-domain-resolver.json
FATAL[0000] decode config at cfg/route-rule-domain-resolver.json: route.rules[2].domain_resolver: json: unknown field "domain_resolver"

所以「按域名决定用哪个 DNS 解析」的可行做法是:把域名规则按用途分开,然后给对应的出站配 domain_resolver。本机实验里 direct 出站配了 "domain_resolver": "dns-cn",请求 a.direct.test 时日志顺序是(这次跑用的配置里该规则排第 4 位,所以下标是 match[3];下标以各自配置的 rules 数组为准):

1
2
3
4
17:09:41 DEBUG [4094631046 0ms] router: match[3] rule_set=lab-direct => route(direct)
17:09:41 INFO [4094631046 0ms] outbound/direct[direct]: outbound connection to a.direct.test:28801
17:09:41 DEBUG [4094631046 0ms] dns: lookup domain a.direct.test
17:09:41 DEBUG [4094631046 1ms] dns: exchanged A a.direct.test. 60 IN A 127.0.0.1

假 DNS 服务器那边的日志确认这次解析走的是 dns-cn 那台(下面这条来自同一份配置的另一次运行):

1
(dns-cn, 127.0.0.1:15353)    17:08:26 a.direct.test A -> 127.0.0.1

4.4 hijack-dns:让 sing-box 处理客户端发来的查询

1.12 之后把客户端查询交给内部 DNS 模块只用一条规则:{"protocol": "dns", "action": "hijack-dns"},官方 migration 页面用它替代了旧的 type: dns 出站加 {"protocol": "dns", "outbound": "dns"} 写法。

要让局域网设备(或者本机 dig)把查询送进来,落地的做法是开一个 direct 入站,用 override_address/override_port 把目的地改写成上游 DNS,查询进来之后由前面那条 hijack-dns 规则接管:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"inbounds": [
{
"type": "direct",
"tag": "dns-in",
"listen": "127.0.0.1",
"listen_port": 15355,
"network": "udp",
"override_address": "8.8.8.8",
"override_port": 53
}
],
"route": {
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" }
]
}
}

这套写法配上下面的 DNS 分片,查国内域名和国外域名会落到不同的 DNS 服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"dns": {
"servers": [
{ "type": "udp", "tag": "dns-cn", "server": "127.0.0.1", "server_port": 15353 },
{ "type": "udp", "tag": "dns-remote", "server": "127.0.0.1", "server_port": 15354 }
],
"rules": [
{ "domain_suffix": [".cn"], "action": "route", "server": "dns-cn" },
{ "action": "route", "server": "dns-remote" }
],
"strategy": "prefer_ipv4"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ dig +short @127.0.0.1 -p 15355 www.baidu.cn A
127.0.0.1
$ dig +short @127.0.0.1 -p 15355 www.google.com A
127.0.0.1

--- sing-box log ---
17:08:33 DEBUG [1624505344 1ms] router: match[1] protocol=dns => hijack-dns
17:08:33 DEBUG [1624505344 2ms] dns: exchange www.baidu.cn. IN A
17:08:33 DEBUG [1624505344 2ms] dns: match[0] domain_suffix=.cn => route(dns-cn)
17:08:33 INFO [1624505344 4ms] dns: exchanged A www.baidu.cn. 60 IN A 127.0.0.1
17:08:33 DEBUG [792839220 0ms] router: match[1] protocol=dns => hijack-dns
17:08:33 DEBUG [792839220 0ms] dns: exchange www.google.com. IN A
17:08:33 DEBUG [792839220 1ms] dns: match[1] => route(dns-remote)
17:08:33 INFO [792839220 1ms] dns: exchanged A www.google.com. 60 IN A 127.0.0.1

--- 两台 DNS 服务器各自的日志 ---
(dns-cn) 17:08:33 www.baidu.cn A -> 127.0.0.1
(dns-remote) 17:08:33 www.google.com A -> 127.0.0.1

两个域名返回了同样的 IP(假 DNS 的固定行为),但落到哪台 DNS 服务器上一目了然。真机上把 dns-remote 换成 { "type": "https", "server": "1.1.1.1", "detour": "proxy" },让远程解析走代理,就是常见的「国内直连解析 + 国外走代理解析」组合。

4.5 dns.strategy

strategyprefer_ipv4prefer_ipv6ipv4_onlyipv6_only。差别直接体现在发出去的查询上,同一个请求分别用两种策略,假 DNS 的日志完全不同:

1
2
3
(strategy: ipv4_only)  17:08:46 a.direct.test A -> 127.0.0.1
(strategy: prefer_ipv4) 17:08:49 a.direct.test AAAA -> 127.0.0.1
17:08:49 a.direct.test A -> 127.0.0.1

prefer_ipv4 会同时发 A 和 AAAA,拿到之后再按偏好选,ipv4_only 只发 A。纯 IPv4 环境里用 ipv4_only 能少一半查询;如果上游 DNS 对 AAAA 不响应,prefer_ipv4 会白等一轮超时。

4.6 一份能直接用的骨架

把前面几节拼起来,去掉了实验用的假 DNS 与本地 rule-set 路径,换成真实地址:

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
{
"log": {
"level": "info",
"timestamp": true
},
"dns": {
"servers": [
{ "type": "udp", "tag": "dns-cn", "server": "223.5.5.5" },
{ "type": "https", "tag": "dns-remote", "server": "1.1.1.1", "detour": "proxy", "domain_resolver": "dns-cn" }
],
"rules": [
{ "rule_set": "geosite-cn", "action": "route", "server": "dns-cn" },
{ "action": "route", "server": "dns-remote" }
],
"strategy": "prefer_ipv4"
},
"inbounds": [
{ "type": "mixed", "tag": "in", "listen": "127.0.0.1", "listen_port": 2080 },
{ "type": "direct", "tag": "dns-in", "listen": "127.0.0.1", "listen_port": 5353, "network": "udp", "override_address": "8.8.8.8", "override_port": 53 }
],
"outbounds": [
{ "type": "direct", "tag": "direct", "domain_resolver": "dns-cn" },
{ "type": "shadowsocks", "tag": "proxy", "server": "example.com", "server_port": 8388, "method": "2022-blake3-aes-128-gcm", "password": "MDEyMzQ1Njc4OWFiY2RlZg==", "domain_resolver": "dns-cn" }
],
"route": {
"rule_set": [
{
"type": "remote",
"tag": "geosite-cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
"http_client": "rs-client",
"update_interval": "1d"
}
],
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "rule_set": "geosite-cn", "action": "route", "outbound": "direct" },
{ "ip_is_private": true, "action": "route", "outbound": "direct" }
],
"default_domain_resolver": "dns-cn",
"final": "proxy"
},
"http_clients": [
{ "tag": "rs-client", "detour": "proxy" }
]
}
1
2
$ sing-box check -c prod-skeleton.json && echo "check OK"
check OK

这份骨架只过了 check(本机没有真实代理服务端可以连),行为验证用的是第 5 节的实验配置。几个字段的解释:

  • dns-remote 上的 domain_resolver: dns-cn 只在服务器地址写成域名时才起作用(例如 dns.alidns.com)。上游直接写 1.1.1.1 这类 IP 时不需要它,写了也不生效。
  • proxy 出站上的 domain_resolver: dns-cn 指的是「解析 example.com 这个代理服务器域名时用国内 DNS」。
  • geosite-cn 命中走直连,其余走代理,DNS 侧同一套规则集把国内域名交给国内 DNS。

5. 可测试的配置

改分流规则最大的风险是「改完看起来还能用,但某个域名悄悄换了出口」。一个不依赖人眼的方法是:把出口做成可辨识的,把用例写成表,每次改完配置跑一遍断言。

出口 1 是直连侧的本机 HTTP 服务,关键就是响应头里带上自己的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/bin/env python3
"""Lab 'direct' target: plain HTTP server that labels itself."""
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 18080
LABEL = sys.argv[2] if len(sys.argv) > 2 else "direct"

class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"

def do_GET(self):
body = ("exit=%s path=%s\n" % (LABEL, self.path)).encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("X-Exit", LABEL)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, *a):
pass

ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

出口 2 是代理侧的本机 SOCKS5 服务,接受任何 CONNECT 后回一份带 X-Exit: proxy 的罐头响应,同时把自己的 CONNECT 目标写进日志(这样「请求有没有真的走代理」也能查)。核心是握手这段(read_exactLOGLABEL 是同一脚本前半部分定义的):

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
def handle(c, addr):
hdr = read_exact(c, 2)
ver, n = hdr
read_exact(c, n) # methods
c.sendall(b"\x05\x00") # no auth
ver, cmd, rsv, atyp = read_exact(c, 4)
if atyp == 1:
dst = socket.inet_ntoa(read_exact(c, 4))
elif atyp == 3:
ln = read_exact(c, 1)[0]
dst = read_exact(c, ln).decode()
port = struct.unpack("!H", read_exact(c, 2))[0]
open(LOG, "a").write("%s CONNECT %s:%d cmd=%d\n" % (time.strftime("%H:%M:%S"), dst, port, cmd))
c.sendall(b"\x05\x00\x00\x01" + socket.inet_aton("0.0.0.0") + b"\x00\x00")
# 吞掉客户端的 HTTP 请求,然后回罐头响应
data = b""
c.settimeout(3)
while b"\r\n\r\n" not in data:
chunk = c.recv(4096)
if not chunk:
break
data += chunk
body = ("exit=%s host=%s\n" % (LABEL, dst)).encode()
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nX-Exit: " + LABEL.encode()
+ b"\r\nContent-Length: " + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)

用例表是一行一个用例,最后一列写预期出口:

1
2
3
4
5
# host port path expect
a.direct.test 28801 / direct
a.remote.test 28801 / proxy
127.0.0.1 28801 / direct
bar.com 28801 / proxy

跑测试的脚本负责启动 sing-box、把每个用例通过 mixed 入站发出去、读响应头 X-Exit 与预期比对:

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
#!/usr/bin/env python3
"""Start sing-box with a config, fire a table of requests through the mixed inbound,
and assert which outbound each one lands on by reading the X-Exit response header.

Usage: route_test.py <config.json> <cases.tsv>
cases.tsv lines: <host> <port> <path> <expected-exit>
"""
import http.client, os, signal, subprocess, sys, time

SB = "/tmp/sbcheck/sing-box-1.14.1-darwin-arm64/sing-box"
PROXY_HOST, PROXY_PORT = "127.0.0.1", 28802

cfg, cases_file = sys.argv[1], sys.argv[2]
cases = []
for line in open(cases_file):
line = line.strip()
if line and not line.startswith("#"):
cases.append(line.split())

log = open("/tmp/sbrt/last-run.log", "wb")
proc = subprocess.Popen([SB, "run", "-c", cfg], stdout=log, stderr=subprocess.STDOUT)
time.sleep(1.5)

ok = True
print("%-34s %-9s %-9s %s" % ("request", "expect", "got", "result"))
for host, port, path, expect in cases:
try:
c = http.client.HTTPConnection(PROXY_HOST, PROXY_PORT, timeout=8)
c.request("GET", "http://%s:%s%s" % (host, port, path), headers={"Host": host})
r = c.getresponse()
got = r.getheader("X-Exit", "-")
r.read()
c.close()
except Exception as e:
got = "ERROR:%s" % type(e).__name__
verdict = "PASS" if got == expect else "FAIL"
if got != expect:
ok = False
print("%-34s %-9s %-9s %s" % ("http://%s:%s%s" % (host, port, path), expect, got, verdict))

proc.send_signal(signal.SIGTERM)
proc.wait(timeout=5)
log.close()
print("\n%s" % ("ALL PASS" if ok else "SOME FAILED"))
sys.exit(0 if ok else 1)

(脚本里的 SB 换成自己的 sing-box 路径。代理出站如果不是本机的 SOCKS 服务,换成自己真实的出站即可,只要出口那头能回一个可辨识的响应头。)

被测配置就是前面几节拼出来的完整版,本地源规则集与远程规则集各一条,DNS 分片两台:

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
{
"log": {
"level": "debug",
"timestamp": true
},
"dns": {
"servers": [
{ "type": "udp", "tag": "dns-cn", "server": "127.0.0.1", "server_port": 15353 },
{ "type": "udp", "tag": "dns-remote", "server": "127.0.0.1", "server_port": 15354 }
],
"rules": [
{ "domain_suffix": [".cn"], "action": "route", "server": "dns-cn" },
{ "action": "route", "server": "dns-remote" }
],
"strategy": "prefer_ipv4"
},
"inbounds": [
{ "type": "direct", "tag": "dns-in", "listen": "127.0.0.1", "listen_port": 15355, "network": "udp", "override_address": "8.8.8.8", "override_port": 53 },
{ "type": "mixed", "tag": "in", "listen": "127.0.0.1", "listen_port": 28802 }
],
"outbounds": [
{ "type": "direct", "tag": "direct", "domain_resolver": "dns-cn" },
{ "type": "socks", "tag": "proxy", "server": "127.0.0.1", "server_port": 28803, "version": "5" }
],
"route": {
"rule_set": [
{ "type": "local", "tag": "lab-direct", "format": "source", "path": "/tmp/sbrt/rs/lab-direct.json" },
{ "type": "remote", "tag": "lab-remote", "format": "binary", "url": "http://127.0.0.1:28804/lab-remote.srs", "http_client": "rs-client", "update_interval": "1h" }
],
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "rule_set": "lab-direct", "action": "route", "outbound": "direct" },
{ "rule_set": "lab-remote", "action": "route", "outbound": "proxy" },
{ "ip_is_private": true, "action": "route", "outbound": "direct" }
],
"default_domain_resolver": "dns-cn",
"final": "proxy"
},
"http_clients": [
{ "tag": "rs-client" }
],
"experimental": {
"cache_file": {
"enabled": true,
"path": "/tmp/sbrt/cache.db",
"store_dns": true
}
}
}
1
2
3
4
5
6
7
8
9
10
$ sing-box check -c cfg/lab-final.json && echo "CHECK OK"
CHECK OK
$ python3 route_test.py /tmp/sbrt/cfg/lab-final.json /tmp/sbrt/cases.tsv
request expect got result
http://a.direct.test:28801/ direct direct PASS
http://a.remote.test:28801/ proxy proxy PASS
http://127.0.0.1:28801/ direct direct PASS
http://bar.com:28801/ proxy proxy PASS

ALL PASS

这套方法的用处在于改完规则能立刻知道出口有没有变。2.3 节换过规则顺序之后,靠的就是逐条比对用例期望值,而不是肉眼读配置。

写用例时注意 domain_suffix 的语义:.x.test 匹配的是 foo.x.test,不匹配 x.test 本身。这一点在实验室里踩过一次,请求 x.test 时没能命中那条规则,一路落到 final,日志里连 match 都没有。

6. 常见错误清单

现象 原因 修法
启动直接失败:detour to an empty direct outbound makes no sense http_clients[].detour 指向了一个没有任何参数、也没有自己 detour 的 direct 出站 去掉 detour(直连下载),或改成代理出站的 tag
规则集下载失败:invalid sing-box rule-set file detour 或隐式默认客户端走了一个非 HTTP 代理,拿回来的不是 .srs 检查 http_clientsdetour;没配 http_clients 时先补上(隐式默认客户端在 1.16 移除)
日志出现 legacy download_detour remote rule-set option is deprecated 警告 还在用 1.14 之前的 download_detour 换成顶层 http_clients 加规则集里的 http_client
域名规则完全不起作用,请求落到 final 域名规则写在 action: sniff 之前,匹配时还没读出域名 sniff 挪到 rules 第一条
手写的规则集源文件被改成了另一个样子,version 还变小了 sing-box rule-set decompile x.srs 没带 -o,默认输出 x.json,正好覆盖同名的源文件 decompile 一律带 -o,或先备份源文件
启动失败:legacy DNS server formats are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0 DNS server 还写 "address" 改成 "type""server"(见 4.1 对照表)
配置里明明写了 domain_resolver,check 报 json: unknown field "domain_resolver" 写在了 route 规则里,这个字段只存在于 dial 字段(出站、endpoint)与 route.default_domain_resolver 挂到出站上,或用 route.default_domain_resolver 兜底
check 通过,运行时某类请求偶发 router: outbound not found: xxx 规则里的出站 tag 拼错,或 action: route 漏了 outbound(报错里的名字是空的) 对照 outbounds 里实际存在的 tag;action: route 必须带 outbound
规则集命令报 invalid character 'S' looking for beginning of value .srs 执行 rule-set match 忘了 -f binary,sing-box 按 JSON 解析二进制 -f binary;脚本里不要靠 match 的退出码判断命中(未命中也是 0)
启动时报 Legacy Address Filter Fields in DNS rules is deprecated DNS 规则里引用了只含 ip_cidr 的 rule-set(GeoIP 类) 改用 action: evaluate 拉响应再 match_response 匹配,1.16 前完成迁移

小结

规则匹配没有隐藏逻辑:rules 从上往下、首条命中即止;域名规则要排在 sniff 之后;地址规则只在目标是 IP、或前面有一条 action: resolve 把域名解析出来之后才有机会命中。rule_set 与 DNS 在 1.14 都在往显式配置收敛:下载客户端要写明(http_clients),解析器要挂在出站上(domain_resolver),旧写法要么警告要么直接拒绝加载。改完规则把第 5 节的用例表跑一遍,就能确认出口没有发生变化。

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