《Ubuntu 24.04 配置 Sing-box 代理服务器完整指南》解决的是「流量能出去」,这篇解决它之后的三个问题:规则按什么顺序生效、域名与 IP 规则分别在什么时机才能拿到匹配依据、rule_set 和 DNS 在 1.14 该怎么写。 分流最难的地方在于:规则写错了 sing-box 不会报错,只是安静地走错出口。每一节都给出结论、本机跑出来的日志和一个能自动断言的验证方法。文中所有输出都来自 sing-box 1.14.1(macOS arm64)实跑,贴出的完整配置都过了 sing-box check,只写了 rules、dns 之类的片段也都在拼回完整配置后逐个校验过。
0. 实验环境 分流要回答的是「某个请求最终由哪个出站接管」,所以先把出口做成可辨识的:直连出口指向一个本机 HTTP 服务,它会带回 X-Exit: direct;代理出口指向一个本机 SOCKS5 服务,它接受任何 CONNECT 并回 X-Exit: proxy。请求统一用 curl -x http://127.0.0.1:28802 发出,看响应头就知道落在哪个出口。DNS 用两台假服务器(127.0.0.1:15353 与 127.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.test,domain_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
所以改完规则至少要把带 -c 的 check 和一次真实请求都跑一遍,第 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。稳妥的排列顺序是:sniff → hijack-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 里每个条目就是一条规则集,local 与 remote 的区别只有内容从哪来:
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 或 .srs 时 format 可以省略。
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_interface、inet4_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 还多了 hosts、dhcp、mdns、fakeip、tailscale 等几种。本机实验用的是 "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_version、query_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 strategy 取 prefer_ipv4、prefer_ipv6、ipv4_only、ipv6_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 """Lab 'direct' target: plain HTTP server that labels itself.""" import sysfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerPORT = 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_exact、LOG、LABEL 是同一脚本前半部分定义的):
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) c.sendall(b"\x05\x00" ) 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" ) 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 """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, timeSB = "/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_clients 的 detour;没配 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 节的用例表跑一遍,就能确认出口没有发生变化。
系列索引:网络与自建服务