踩坑与经验
这篇是整份记录里最该先看的:结论会过时,踩过的方式不会。分四类 ——
sing-box 与内核、工具链、测量方法(我在这里犯过错)、以及流程上的经验。
一、sing-box / 内核
1. ebpf 入站只存在于某个 fork,上游没有
上游 SagerNet 的 sing-box 没有 ebpf 入站(文档 404):这个入站在 reF1nd/sing-box 分支里
(with_ebpf 构建标签,Linux/Android)。而那个 fork 0 个 release → 想要就得自己编。
判断依据不是”文档说有”,而是:拿一个带 ebpf 入站的配置去 check,上游二进制直接报unknown inbound type: ebpf。
2. 内核数据面要逐条探测,不能互相推断(我在这里推断错过一次)
先看结论:官方文档写得很清楚 ——
这四条路径(local
cgroup、localtc、sharedpacket_rewrite、sharedsocket_assign)
不是按内核版本递进的能力层级。厂商内核可能支持其中一条,却在另一条的对象加载或挂载阶段拒绝。
实测结果(root 权限,逐条单独探测):local cgroup ✅ / local tc ❌ /
shared packet_rewrite ✅(PASS 33)/ shared socket_assign ✅(PASS 31)。
即本机只是 local tc 这一条不可用,两条 shared 都可用 —— 容器/虚拟机在 eBPF 下能被接管。
我犯的错:local tc 实测失败(register TC eBPF TCP listener: operation not supported)之后,
我推断“shared 也是 TC 路径,所以同样不可用”,甚至据此给脚本加了守卫把这个方案挡掉。
但事实上 —— shared 我从来没真跑过(脚本卡在”找不到 podman 网桥”就退出了),
而且按上面的文档,这个推断本身就不成立。
正确的做法(也是文档给的做法):用要用的那条路径单独做只读探测,并且
用与服务相同的权限(普通用户跑会得到一堆 unknown,因为建不了 BPF map):
sudo sing-box tools ebpf status --shared-data-plane socket_assign --interface wlan0 --json
sudo sing-box tools ebpf status --shared-data-plane packet_rewrite --interface wlan0 --json
sudo sing-box tools ebpf status --local-data-plane cgroup --json
result: passed 才是通过;inconclusive 表示无法判定(多半是权限),fail 才是真的缺能力。
最终判据仍是真实启动成功(探测不挂载 hook,也不证明能挂上去)。
📌 2026-10-02 晚追注(本节保留原文):结论已复核为正确,四条数据面至今仍是
localcgroup✅ / localtc❌ / sharedpacket_rewrite✅ / sharedsocket_assign✅。
只补一处边界:上面那句”容器/虚拟机在 eBPF 下能被接管”要限定成桥接进来的——
VM 与桥接的容器可以,rootless podman(pasta)不行(见 §6)。
2b. register TC eBPF TCP listener: operation not supported 的语境
同一条错误出现在 local tc 上。文档说明 TCP listener 有两条路:内核能建BPF_MAP_TYPE_SOCKMAP 时优先用它,否则回退到不引用 SOCKMAP 的 legacy 段、直接调bpf_skc_lookup_tcp。且 shared socket_assign 需要的是 socket lookup/assignment helper
(bpf_skc_lookup_tcp / bpf_sk_lookup_udp / bpf_sk_assign / bpf_sk_release),
不需要 local TC 才要的那些(bpf_get_socket_uid、bpf_redirect、bpf_skb_store_bytes…)。
所以这两条路径的可用性必须分开看。
FATAL start service: start inbound/ebpf[ebpf-in]:
register TC eBPF TCP listener: operation not supported
本机 7.2.8-1-cachyos-bore-lto。而 shared 的 data_plane = packet_rewrite 走的是同一条 TC 路径
→ 一起不可用。
📌 2026-10-02 晚追注:这半句推断已被实测推翻(原文保留)。
shared.packet_rewrite在virbr0上
挂载成功并端到端跑通:api ebpf显示role=shared mechanism=tcx framing=ethernet,
直连与走代理两条路都通过(详见 §6)。local tc失败不影响 shared;因此本节末尾那条
“非passed就先拦下来”的守卫,其前提不成立。
最有价值的教训在这里:预检 --mode local 报 preflight_passed(PASS 37 / FAIL 0 / UNKNOWN 0),
看起来”全都支持”;但换成 --mode all 立刻变成 inconclusive(pass 13 / warn 2 / unknown 36,
其中 required_issues 28)。也就是说:
预检全绿 ≠ 能用。
local模式只覆盖 cgroup 路径;只有实跑才知道 TC 行不行。
✅ 2026-10-02 晚复核:非 root 跑
sing-box tools ebpf status --mode all --json仍得result: inconclusive、pass 13 / warn 2 / fail 0 / unknown 36(required_issues 28),与原文数字完全一致;
所以这一条是权限造成的,不是内核能力不足。(上文--mode local的preflight_passed/ PASS 37 那次是 root 跑的,非 root 复现不出来。)
所以我给脚本加了守卫:要动 tc/shared 之前先跑 --mode all,非 passed 就先拦下来,
别白重启一轮(--force 可以强行试)。
3. DNS 广告拦截别用 reject:会卡 5 秒
- sing-box 的 DNS
action: reject回的是 REFUSED; systemd-resolved收到上游 REFUSED 后不转告客户端(它认为”上游坏了”,去重试其他上游),
→ 客户端(应用)一直等到超时,约 5 秒;- 换成
action: predefined+rcode: NXDOMAIN后是正常应答,应用立刻失败并做负缓存。
实测:doubleclick.net从”约 5 秒”变成 11 ms。
诊断口径也要注意:resolvectl query 会明说 server or network returned error: REFUSED —— 这是
识破”到底是超时还是被拒”的关键工具。
4. 域名规则优先于 IP 规则(所以有些”误判”其实是设计)
日志里看到 74.125.250.129:443(Google 的 IP)走 direct 然后 i/o timeout,很容易以为是 geoip 误判。
查下来:三份 CN 规则集都不含这个 IP,google.cn / gstatic.com 也都正确走 Proxy。
最可能的解释是某个 geosite/cn 里的域名解析到了 Google 的 IP —— 域名命中直连规则,IP 再”国外”也走直连。
这是设计如此,代价是那条连接超时。
5. 官方 geoip/cn 会偏旧,需要第三份独立来源
实测本地 sing-geoip-rule-set 的 geoip/cn 只有 8045 条,上游 MetaCubeX 已经是 9648 条
(多出 8.130.0.0/15 等段)→ 阿里云 IP 8.130.222.175 被误判成国外、走了代理。
补一份新表(外加 mihomo_yamls 的 cncidr,17958 条)之后正确直连。
教训:IP 归属是”数据问题”,不是”配置问题”;官方包更新慢,值得自己滚动。
6. rootless 容器(podman + pasta)抓不到 —— 但原因不是原来写的那样
修正(2026-10-02 晚):原先这里写的是「pasta 不创建宿主 socket,所以 cgroup 钩子看不见」。
这句已被实测推翻:
$ ss -tnp | grep passt
ESTAB [fd00:6868:6868::71b]:34344 [<remote-ip>]:443 users:(("passt.avx2",pid=157862,fd=155))
pasta(进程名是 passt.avx2,不是 pasta)确实创建宿主 socket:DNS 转发与 TCP 转发都走宿主 socket。
这也解释了为什么容器内 DNS 能被劫持、国内直连能正常 —— 「钩子看不见」这个解释不成立。
仍然成立的是现象与处置(2026-10-02 晚复测):
| 容器网络 | 出口 | 结论 |
|---|---|---|
| rootless 默认(pasta) | http=000 失败;Clash API 里也没有这条连接 |
❌ 境外走代理这条路不通 |
| 容器内国内直连 | <home-ip> 电信 |
✅ 正常 |
| 容器内广告 DNS | NXDOMAIN |
✅ 劫持生效 |
--network=host |
<proxy-exit-ip> |
✅ 可行 |
✅ 2026-10-02 晚本机复测(轻量):
podman run起一个 alpine(rootless 默认 pasta)——容器内nc -w6 -z 104.26.13.205 443→ 失败(rc=1);nslookup doubleclick.net→NXDOMAIN(劫持生效);
对照域名example.com正常解析。同时宿主ss -tnp仍能看到passt.avx2的宿主 socket
(本次是 IPv4:192.168.31.29:58850 → <home-ip>:80)。结论与上表一致。
eBPF 的覆盖边界(修正后):local 管本机;shared 管桥接进来的包(VM / 桥接的 rootful 容器,
实测直连与代理都通过);pasta 属用户态转发 —— 既不是本机 socket 的常规路径,也不是桥上的包,
所以两条路都不覆盖它。具体卡在哪一环尚未确证。
一条待验线索:pasta 的宿主 socket 用的是宿主 ULA 私网源地址(fd00:6868:6868::71b),
所以值得临时关掉 bypass_private_address 再复测一次 —— 若这样就能通,根因就是
「私网地址绕过」的判定把 pasta 的流一起绕过了(而绕过的结果是走直连,境外自然被墙)。
📌 2026-10-02 晚补充证据:宿主 socket 的源地址也可能是宿主局域网 IPv4,不只是 ULA
(实测192.168.31.29:58850 → <home-ip>:80 users:(("passt.avx2",…))),所以”源地址是私网”这条线索
对 v4/v6 都成立。根因仍未确证(没有实测关掉bypass_private_address)。
处置不变:eBPF 模式下容器要么 --network=host(实测出口 = 代理出口 ✓),要么切回 TUN。
7. 切换/重启有 2–3 秒 DNS 空窗,而且缓存会骗过你的检查
旧模式拆掉、新模式还没挂上的那几秒,查询可能漏出去并被 systemd-resolved 缓存。
我第一次切 eBPF 就是被这个骗了:健康检查查 doubleclick.net 拿到缓存里的阳性答案,
判定”DNS 没生效”→ 自动回滚。实际 eBPF 是好的。
修法(都写进脚本了):
- 切换后
resolvectl flush-caches; - 检查用随机子域(
<hex>.doubleclick.net)—— 任何缓存都命不中; - 同时问一个对照域名(必须能解析),否则”空结果”可能只是查询失败(假通过)。
8. 非 root 跑 sing-box check 会污染 cache.db 权限
配置里若写死了 experimental.cache_file.path = /var/lib/sing-box/cache.db,以普通用户跑 check
会创建一个属于该用户的 cache.db → 之后 root 的服务启动时:
FATAL start service: initialize cache-file: open /var/lib/sing-box/cache.db: permission denied
修法:脚本里 check 时把 cache_file.path 指到临时目录;出过事就 chown sing-box:sing-box。
(我这台机器上这个坑留下了 19 条 FATAL,全在 08:55 —— 修完之后再没出现。)
📌 2026-10-02 晚追注:现行单元是
User=root(自建sing-box-ebpf包),/var/lib/sing-box/cache.db
现为root:root 644—— 所以”被非 rootcheck改成 emo 属主后服务写不了”这个具体故障在当前单元下
不会复现。另外本次没能复现”check会创建 cache.db”这一步:把cache_file.path指到可写临时目录后
跑sing-box check,exit=0 且临时目录里没有生成任何文件(带ebpf入站时则在更早的map create: operation not permitted就失败了)——所以那一环的确切机制存疑,但”check要隔离cache_file.path“这条保守做法建议保留。
9. 健康判据必须跟着模式走
TUN 看 tun0 是否出现;eBPF 没有 tun0。eBPF 最硬的判据是:
不设任何代理的请求,出口必须等于经本地代理口的出口。
这一条同时证明”内核接管生效”。辅助判据是 sing-box api ebpf(密钥与 clash_api.secret 相同):
{ "inbounds": [ { "tag": "ebpf-in", "state": "normal",
"localEnabled": true, "localDataPlane": "cgroup",
"sharedEnabled": true, "sharedDataPlane": "packet_rewrite",
"attachments": [
{ "role": "local", "mechanism": "cgroup", "interfaceName": "/sys/fs/cgroup" },
{ "role": "shared", "mechanism": "tcx", "interfaceName": "virbr0", "framing": "ethernet" } ] } ],
"kernelRuntime": { "programs": [ { "name": "sb_ebpf_conn4", "type": "CGroupSockAddr" },
{ "name": "sb_share_in", "type": "SchedCLS" },
{ "name": "sb_share_out", "type": "SchedCLS" } ] } }
(上为 2026-10-02 晚实测的精简结果:local 与 shared 两个附件都在,共用 11 个 kernel program。)
10. 规则集是”按需加载”的,内存数字要等热了再看
刚重启时量到 74 MB,跑一会儿变成 150 MB —— 不是泄漏,是规则集(尤其 anti-AD 那份 770 KB 二进制)
加载进内存。教训:比较内存要在同一”热度”下比。
📌 2026-10-02 晚复核:运行约 1 小时 20 分时
systemctl show -p MemoryCurrent≈ 101–105 MB、ps -o rss≈ 118 MB。量级与”热度”规律相符,但绝对值随规则集版本与运行时长变,别拿固定数字当判据。
二、工具链
11. fs.protected_regular=1:脚本里别写死 /tmp/<固定名>
内核这个开关的意思是:在 sticky 的世界可写目录里,即使 root 也不能写属于别人的已存在文件(防符号链接攻击)。
我干跑时留下了一个 emo 属主的 /tmp/xxx.json,之后 root 跑脚本就 EACCES:
PermissionError: [Errno 13] Permission denied: '/tmp/sing-box-audit-new.json'
修法:一律 mktemp -d / tempfile.mkstemp。这一条同时咬到了脚本、面板后端、试跑套件三个地方 ——
凡是”普通用户也可能先创建同名文件”的路径,都是隐患。
12. 丢掉 dig 的 stderr,会把”超时”看成”拦截”
我早期所有”DNS 拦截 ✅”的判定,用的是 dig +short <域名> 无输出,而我把 stderr 丢进了 /dev/null。
问题是:被 reject(REFUSED) 和 超时 在 stdout 上都是空。带完整输出复测才发现真实语义是 REFUSED。
教训:判定”被拦截”时,必须区分”拿到否定应答”和”什么都没拿到”。
13. re.sub 的替换串会自己处理反斜杠
re.sub(pat, "printf 'x\\n'", text) # ✗ 写进文件的是真实换行
re.sub(pat, lambda m: "printf 'x\\n'", text) # ✓ 用函数当替换器
正则引擎会把替换串里的 \n 转成真实换行。我在这上面栽了两次(还以为是 shell heredoc 的锅)。
教训:re.sub 的替换串里出现反斜杠,就用 lambda。
14. 自提权要换算绝对路径(sudo 的 secure_path)
脚本里写 exec sudo -- "$0" "$@",当用户在 shell 里敲的是裸命令名时 $0 就是裸名,
而 sudo 默认用 secure_path(/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)——
里面没有 ~/.local/bin → sudo 那一层报”找不到命令”。
修法:
self="$0"
[[ $self == */* ]] || self="$(command -v -- "$self")"
exec sudo -- "$self" "$@"
15. 杂项(每个都咬过一次)
makepkg不接受子目录里的本地 source:UI 文件要摊平到包目录根。install -D多源语义奇怪:目标存在时用普通install -m更稳。- namcap 要求
LicenseRef-*有对应许可证文件:/usr/share/licenses/<pkg>/下得放一份。 namcap的”依赖可能不需要”是误报:它看不出运行期会 exec 那个命令。- 验证 TUI 要用
tmux capture-pane:伪终端尺寸是 0×0 时 TUI 根本不渲染(script里先stty rows/cols或直接用 tmux)。 - 内置动作不允许构建出空 argv:那是防”忘写
base_argv导致光跑pacman打印用法”的策略测试,
合法无参要显式allow_empty = true。
三、测量方法(我在这里犯过错,所以单独一节)
- 结论方向对 ≠ 机制对。我把 REFUSED 当成”拦截成功”,方向没错(应用确实拿不到解析),
但机制错(不是”安静地拒绝”,而是”卡 5 秒”)。如果不深挖,就永远不知道体验代价。 - 要留下”能证伪”的判据。比如”eBPF 生效”用「不设代理出口 == 代理出口」——任何一方不成立就会红;
而”服务 active”这种判据什么都证明不了。 - 警惕缓存:DNS 缓存、resolved 缓存、订阅缓存都会让”刚才明明改好了”变成假象。用随机化输入破缓存。
- 对照必不可少:验证广告拦截要同时问一个”必须能解析”的域名;
验证分流要同时问国内与国外各一个;验证”节点死”要换更长的超时再下结论。 - 分清”历史噪声”与”当前问题”:日志里 58 行告警,全在 12:23 之前;当前进程 0 行。
只看总数会误判成”一直有问题”。
四、流程
- 改配置的脚本一律走同一条管线:
check→ 时间戳备份 → 原子替换 → 重启 → 健康检查 → 失败自动回滚。
这台机器上它已经自动救过一次(tc 数据面试错)。 - 备份要带语义:
config.<时间戳>.pre-ebpf.json/pre-dataplane.json/pre-tun.json——
出问题时一眼知道回到哪个状态。 - 把”手工验证步骤”固化成脚本:我手工跑了十几轮同样的检查,后来写成了
sing-box-status
(一屏出结果),再后来集成进 TUI 当动作。验证脚本化之后,每次改动都敢动了。
五、外部资料评估(2026-10-02)
看过的东西与结论(都是”是否真的产生不同判定”,不是”看起来更全”):
| 资料 | 结论 |
|---|---|
| 217heidai/adblockfilters | ✅ 采用:215k 条域名后缀,与 anti-AD 只重叠 35%,独有 13.9 万条,对照组 0 误伤 → 作为第二层广告表 |
| lingqiqi5211/sing-box-rules | ❌ 只有 steam-cn(22 条 Steam 国服 CDN)值得考虑;geoip-cn 与已装文件同源,!cn/google 因 final = Proxy 而冗余 |
| lyc8503/sing-box-rules(v2ray-rules-dat 谱系) | ❌ 它的 geosite-cn 是 111,304 条,与 MetaCubeX 的 111,224 条重合 98% —— 同一批数据,换个名字而已 |
| 12 倍大的 CN 域名表(两家都一样) | ❌ 陷阱:抽样 24 个”只有它收录”的域名,14 个是国内 IP(现有 geoip/cn 早就直连了,加表零变化)、5 个是国外 IP(加表反而强制直连)、5 个已废域名 |
store_rdrc(社区文章常推荐) |
❌ 1.14 已废弃、1.16 移除;而且它缓存的是 legacy 地址过滤结果 |
download_detour + 远程规则集(博客推荐写法) |
➖ 不适用:这里全是本地规则集,没有运行期网络依赖(这是更优的设计) |
eBPF local.bypass_rule_set |
✅ 已启用([geoip/cn, geoip/cn-fresh, cncidr-mihomo])。逐句核对:• 「命中 CN IP 的流量绕过 eBPF、不进 sing-box」对 —— 规则集必须是 IP-CIDR(域名规则会被忽略),绕过发生在路由之前; • 「DNS 仍被劫持」对(策略顺序里 DNS 模式/来源筛选早于规则集绕过;实测 dig doubleclick.net 仍 NXDOMAIN);• 「省 CPU」方向对但量级易被高估:按域名数只有 0.8%(抽样 150 个走代理的域名 → 127 个解析成功 → 仅 1 个落在 CN IP),日常浏览≈0;真正的收益在大流量直连(Steam 下载、国内镜像、视频)——按体积而非按域名算才看得出来; • 「本该走代理却解析到 CN IP 的域名会变直连」对(按 IP 且先于路由,会盖掉按域名的 Proxy 判定),但实测代价仅 0.8%,且那例 gvt1-cn.com → 113.108.239.171 本身更像”本该直连”。另:它与 shared.bypass_rule_set 互相独立(只作用于 local) |
eBPF local.cgroup_path / exclude_uid / bypass_port |
🟡 可选:把接管范围收窄到用户会话、或按 UID/端口绕过(仍未评估) |
eBPF shared.data_plane |
✅ 已采用 packet_rewrite(virbr0)并实测:下游直连与走代理两条路都通过(api ebpf 可见 role=shared mechanism=tcx)。备用 socket_assign 探测也通过,可一条命令切换:switch-to-ebpf.sh --shared-plane socket_assign |
参考:博客 singbox.ygjc.cc(工程价值有限,但给出了 lyc8503 与 download_detour 两条线索)、
官方 eBPF 内核要求、
官方 eBPF 排查。
六、怎么排查 eBPF 接管问题(实战流程,含我踩的四个测量坑)
最终结论(2026-10-02 验证):本机 shared(packet_rewrite on virbr0)完全可用 ——
直连与走代理两条路径都实测通过。证据:
| 证据 | 内容 |
|---|---|
| Clash API | 客户端连接命中 规则=final 链路=Proxy → Auto → 🇸🇬 新加坡Z02 |
| 客户端实测 | curl -4 https://api.ipify.org → http=200;google → http=200 |
| 抓包(virbr0) | 11 SYN / 11 SYN-ACK,含 104.26.13.205:443(32 包)与 <ip-A>:443(74 包 / 100 kB)双向完整 |
| 出口 | 客户端出口 = 代理出口(<proxy-exit-ip>) |
🔒 脱敏说明(2026-10-02 晚):上表原来直接写了两个真实公网地址(抓包里的对端、以及本机代理出口),
公开仓库已分别替换为<ip-A>与<proxy-exit-ip>。(此处不再复述原值 —— 第一版说明把原值写进注解里,
等于没脱敏,已改。)104.26.13.205是 Cloudflare
的公开任意播地址,保留。
排查顺序(照这个顺序,能少走很多弯路)
1) api ebpf → 附件挂上没有?(role=local/shared、mechanism=cgroup/tcx…)
2) Clash API /connections → 下游的流进没进路由?命中的 rule 与 chains 是什么?
3) 抓包(服务端接口) → 报文有没有出去?双向都有吗?有没有 SYN-ACK/RST?
4) netns 内 curl -v → 客户端卡在哪一步(解析/Trying/refused/timeout)
5) debug 日志 → 只有前三步都指向 sing-box 时才有必要开
关键:这五步里任何一步单独看都可能得出错误结论 —— 我们这次就是靠”抓包里境外零报文“
才把范围从”shared 有问题”翻转到”客户端根本没发包”。
我踩的四个坑(全是”测试工具破绽伪装成被测对象问题”)
- netns 里
127.0.0.53是它自己的 loopback → 宿主 resolved 不可达,DNS 全挂。
修:给 netns 单独写/etc/netns/<ns>/resolv.conf。 - netns 只配 IPv4,双栈域名会先试 IPv6 →
ENETUNREACH,看起来像”境外连不通”。
修:探测时强制curl -4并打印curl -6对照。 - 双重接管(最难查、也最重要):netns 里的客户端是宿主上的一个进程,
它的 socket 仍在宿主 cgroup 里 → 同时被 local cgroup 钩子和 shared 钩子看到。
开着local.bypass_rule_set时,国内目标绕过 local(所以能从 shared 正常出去),
境外目标被 local 先截走 → 那条路径失败 → 表现出来就是”shared 直连能通、走代理不通“。
→ 要验证 shared,必须临时只启用 shared(local.enabled=false),或直接用真实 VM。 - 抓包没 flush 就读文件 → 分析出来是空的(分析前必须先停掉 tcpdump)。
顺带发现的一个 fork 校验细节
local.enabled=false 时不能再留着 local.data_plane,否则启动直接失败:
FATAL create service: initialize inbound[1]: local.data_plane requires local interception
必须精简成 "local": { "enabled": false }。并且:改完配置必须先 sing-box check 再重启 ——
我的测试脚本当时没 check 就重启,把服务弄停了约 20 秒(已修)。这条对所有运维脚本都成立。
一条值得上报上游的边角情况
宿主上的进程如果源地址是 veth/网桥地址(即进程在 netns 里,而流量经 virbr0),
开着 local 钩子时它的境外连接会被 local 路径截走并以 ECONNREFUSED 失败(1 ms、零报文、token 地址显示为 127.0.0.1)。
真实 VM 的流量不是宿主进程,不受影响;这是很窄的场景,但记录下来。










