最后验证:2026-10-02T15:08:38Z

踩坑与经验

这篇是整份记录里最该先看的:结论会过时,踩过的方式不会。分四类 ——
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、local tc、shared packet_rewrite、shared socket_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 晚追注(本节保留原文):结论已复核为正确,四条数据面至今仍是
local cgroup ✅ / local tc ❌ / shared packet_rewrite ✅ / shared socket_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 —— 所以”被非 root check 改成 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。

三、测量方法(我在这里犯过错,所以单独一节)

  1. 结论方向对 ≠ 机制对。我把 REFUSED 当成”拦截成功”,方向没错(应用确实拿不到解析),
    但机制错(不是”安静地拒绝”,而是”卡 5 秒”)。如果不深挖,就永远不知道体验代价。
  2. 要留下”能证伪”的判据。比如”eBPF 生效”用「不设代理出口 == 代理出口」——任何一方不成立就会红;
    而”服务 active”这种判据什么都证明不了。
  3. 警惕缓存:DNS 缓存、resolved 缓存、订阅缓存都会让”刚才明明改好了”变成假象。用随机化输入破缓存。
  4. 对照必不可少:验证广告拦截要同时问一个”必须能解析”的域名;
    验证分流要同时问国内与国外各一个;验证”节点死”要换更长的超时再下结论。
  5. 分清”历史噪声”与”当前问题”:日志里 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 有问题”翻转到”客户端根本没发包”。

我踩的四个坑(全是”测试工具破绽伪装成被测对象问题”)

  1. netns 里 127.0.0.53 是它自己的 loopback → 宿主 resolved 不可达,DNS 全挂。
    修:给 netns 单独写 /etc/netns/<ns>/resolv.conf。
  2. netns 只配 IPv4,双栈域名会先试 IPv6 → ENETUNREACH,看起来像”境外连不通”。
    修:探测时强制 curl -4 并打印 curl -6 对照。
  3. 双重接管(最难查、也最重要):netns 里的客户端是宿主上的一个进程,
    它的 socket 仍在宿主 cgroup 里 → 同时被 local cgroup 钩子和 shared 钩子看到。
    开着 local.bypass_rule_set 时,国内目标绕过 local(所以能从 shared 正常出去),
    境外目标被 local 先截走 → 那条路径失败 → 表现出来就是”shared 直连能通、走代理不通“。
    → 要验证 shared,必须临时只启用 shared(local.enabled=false),或直接用真实 VM。
  4. 抓包没 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 的流量不是宿主进程,不受影响;这是很窄的场景,但记录下来。