[{"categories":["折腾记录"],"content":"本文记录的是一个特定网络环境下的实测问题：OpenWrt / ImmortalWrt 软路由、OpenClash 开启「代理路由器自身流量」、Tailscale 作为 Exit Node，远端设备与软路由通过Tailscale连接，期望直连并通过OpenClash代理出口流量。\n设备：ImmortalWrt 旁路由 192.168.x.2（单 LAN 口，无 WAN）+ OpenClash（开启「代理路由器自身流量」）+ Tailscale（fw_mode=nftables，advertise_exit_node=1`）\n上游：主路由 192.168.x.1（PPPoE + 出口 NAT，已映射 UDP 41641 → 192.168.x.2:41641）\n故障状态：远端设备与软路由无法Tailscale Direct直连，通过Tailscale DERP 中继，出口流量可以被OpenClash代理\n修复效果：通过一行 nftables 规则修复，远端设备与软路由Tailscale Direct直连，且 Exit Node 出网流量仍经 OpenClash 代理。\n本方案由AI完成，本文也是在AI辅助下编写。原创方案，转载请注明出处，谢谢。\n1. 问题背景 # 我的网络结构如下（旁路由单 LAN，无独立 WAN）：\nWindows PC / Android（100.x.x.x） │ │ 远端设备经 Tailscale 连接 ▼ 主路由 192.168.x.1 (PPPoE + 出口 NAT + 端口映射41641) │ │ LAN ▼ ImmortalWrt 旁路由 192.168.x.2 ├── OpenClash（开启「代理路由器自身流量」） └── Tailscale（Exit Node） 我的需求：\nTailscale 要 Direct —— 远端设备与旁路由之间能自动建立直连 UDP，不要走 DERP 中继。 局域网设备和远端设备的出口流量被 OpenClash代理 —— 远端设备选旁路由为 Exit Node 时，出网流量必须经过 OpenClash 代理，这是旁路由存在的意义。 重启后规则不能丢 —— 软路由、 openclash 与 firewall重启后，自动稳定直连，不能再次手动修复。 因此不能关闭「代理路由器自身流量」，否则 Exit Node 就不走代理了。 此外，OpenClash 自定义规则里放行 41641 端口、放行 100.64.0.0/10、放行Tailscale 域名等方法实测无效。\n2. 故障现象 # 远端设备与软路由无法Tailscale Direct直连，通过Tailscale DERP 中继：\ntailscale status 显示： 100.x.x.x pc-20260625 windows active; relay \u0026#34;sin\u0026#34;; tx 258804 rx 0 tailscale ping 全部回： via DERP(sin)，且抖动极大（817ms / 2.489s / 4.808s） 末尾报 direct connection not established 实测重启OpenClash或防火墙，Tailscale可以Direct直连成功，但远端设备离线后过一段时间重新连接仍然为中继状态，想要直连就需要再次重启。\n3. 排查过程 # 3.1 先排除公网与 NAT 问题 # tailscale netcheck 关键输出： UDP: true IPv4: yes, 171.xx.xx.xx:2xxx8 MappingVariesByDestIP: false PortMapping: 判读：\nUDP: true + IPv4: yes → UDP 可用、公网端点可见，排除 ISP 封 UDP、排除 Tailscale 栈异常。 MappingVariesByDestIP: false → 表示本次 STUN 探测中，公网映射没有随目标地址变化，是有利于 UDP 打洞的信号；但它本身不足以对 NAT 做完整分类，也不能单独据此证明一定不是某种 symmetric NAT。 端点显示 171.xx.xx.xx:2xxx8，外部端口与本机监听的 41641 不同。netcheck 看到的是公网侧可见的 IP:port，外部端口与本地监听端口不同在 NAT 环境中属正常可能，本身并不是故障；本次实测也证明，在当前 NAT 环境下无需修改主路由，修复仍能建立 Direct。 3.2 确认 41641 到底是谁在监听 # ss -lunp | grep 41641 UNCONN 0 0 0.0.0.0:41641 0.0.0.0:* users:((\u0026#34;tailscaled\u0026#34;,pid=32702,fd=13)) UNCONN 0 0 *:41641 *:* users:((\u0026#34;tailscaled\u0026#34;,pid=32702,fd=12)) 监听正常，排除Tailscale 没监听 41641。\n3.3 排查包的路径：发现 OpenClash 正在处理本机 OUTPUT # nft list ruleset | grep -n -E \u0026#39;41641|openclash_mangle_output|tproxy|tailscale\u0026#39; 1766: meta nfproto ipv4 counter packets 180228 bytes 206585569 jump openclash_mangle_output 22: udp dport 41641 counter packets 15636 bytes 3001698 accept 57: udp dport 41641 counter packets 0 bytes 0 accept jump openclash_mangle_output 挂在 IPv4 OUTPUT 上 → 本机产生的流量会进 OpenClash 的 mangle 链。 已有的 udp dport 41641 accept 命中 15636 包，说明入站 41641 是通的——但它是入站方向。Tailscale 主动出站的 direct UDP 是 sport 41641，不是 dport 41641。这条规则保护不到出站。 同样的条件 udp dport 41641 在同一份输出里出现了两次，对比很说明问题：\n所在链 规则 命中 入站链（第 22 行） udp dport 41641 accept 15636 包 出站链（第 57 行） udp dport 41641 accept 0 包 入站命中、出站未命中。原因就是出站包的目的端口是对端的随机端口（20568 之类），只有源端口才是 41641。\n3.4 搞清楚 sport 与 dport # tailscaled 在本机监听 UDP 41641。对同一监听端口而言，入站包匹配 dport 41641，本机发往对端的包则匹配 sport 41641——这里真正重要的是方向决定 sport/dport 角色，而不是具体用了几个 socket：\n方向 包长什么样 该匹配什么 入站（远端 → 本机） src 117.x.x.x:2xxx8 → dst 192.168.x.2:41641 udp dport 41641 出站（本机 → 远端） src 192.168.x.2:41641 → dst 117.x.x.x:2xxx8 udp sport 41641 同一个端口，在两个方向上一个是 dport、一个是 sport。这就是dport 规则明明配了却没用的全部原因。本次修复解决的就是出站这半个方向。\n补充：入站方向为什么本来就是通的？ # 入站通，跟 openclash_mangle_output 毫无关系，有两个独立原因。\n原因一：路径根本不同，入站压根不经过那条链。\nopenclash_mangle_output 挂在 OUTPUT hook 上，只处理本机自己产生的包。远端发进来的包走的是另一条路：\n入站：远端 → PREROUTING → INPUT → tailscaled ↑ 不经过 OUTPUT，碰不到 openclash_mangle_output 出站：tailscaled → OUTPUT → mangle_output → openclash_mangle_output → mark 0x162 OpenClash 的“代理路由器自身流量”作用范围本来就只有路由器自己发出的流量，入站天然不在它的射程内。\n原因二：真正起作用的是 tailscaled 自己装的规则。\n第 22 行 udp dport 41641 ... 15636 包 这部分规则，从所在区域及其周边的 tailscale0、0x400 等特征看，与 Tailscale 自己维护的 nftables 规则一致；Tailscale 文档也说明，在 netfilterMode=on 下会自行创建并管理 firewall rules。判断依据：\n没有 comment \u0026quot;!fw4: ...\u0026quot; 注释 —— fw4 生成的规则一律带这种注释； 带 counter，且周围规则里有 100.x.x.0/23、meta mark \u0026amp; 0xffff04ff | 0x400 这些 Tailscale 私有特征（0x400 是 tailscaled 自己的 mark 位）。 可用 nft list table inet tailscale 确认这部分规则由 Tailscale 管理。\n那入站通了，为什么还是直连不了？ 因为 NAT 打洞是双向的，缺一半就不成立：本机必须主动往外发包，主路由上才会建立 NAT 映射；对端收到后朝这个映射回包，握手才完成。现在的情况是第二半（入站）一直畅通，但第一半（出站）被 mark 0x162 送进了 utun。对端按原 endpoint 回包，本机对不上号 → 握手失败 → 退回 DERP。结论：「入站通」是「能直连」的必要不充分条件。\n3.5 用 ip rule 排查 链内容 # 排查链内容：\nnft -a list chain inet fw4 openclash_mangle_output chain openclash_mangle_output { # handle 529 meta skgid 65534 counter ... return # handle 530 ip daddr @localnetwork counter ... return # handle 531 ct direction reply counter ... return # handle 532 meta l4proto udp ip daddr 198.18.0.0/16 meta mark set 0x162 ... # handle 533 ip daddr @china_ip_route ip daddr != @china_ip_route_pass ... return # handle 534 ip protocol icmp icmp type echo-request meta mark set 0x162 ... # handle 535 meta l4proto udp meta mark set 0x00000162 counter packets 4881 ... # handle 536 ← 元凶 } 末尾这条 meta l4proto udp meta mark set 0x162 等于：凡是走到这里、前面没 return 的本机 UDP，一律打上 fwmark 0x162。\n确认 tailscaled 绕不过前面的 return：\ncat /proc/32702/status | grep -E \u0026#39;Uid|Gid\u0026#39; # Uid: 0 0 0 0 # Gid: 0 0 0 0 tailscaled 以 root(0:0) 运行 → 链首的 meta skgid 65534 return 保护不到它；目标也不是 @localnetwork / @china_ip_route，方向也不是 reply。必然一路走到 mark 0x162。\n确认 0x162 最终去了哪里？\nip rule # 1888: from all fwmark 0x162 lookup 354 ip route show table all # default dev utun table 354 scope link mark 0x162 → ip rule 1888 → table 354 → default dev utun → utun 就是 OpenClash 的 TUN，mark 0x162的流量被OpenClash代理。 至此，故障原因已排查出来，Tailscale出站方向的udp sport 41641流量未被保护，进入了OpenClash utun代理。下面继续复查确认。\n3.6 用计数器确认 # 在链尾部临时加一条纯计数规则：\nnft add rule inet fw4 openclash_mangle_output udp sport 41641 counter tailscale ping 100.x.x.x nft -a list chain inet fw4 openclash_mangle_output meta l4proto udp meta mark set 0x00000162 counter packets 5012 ... # handle 536 udp sport 41641 counter packets 100 bytes 6800 # handle 556 ← 新增 这 100 个包是在 mark 0x162 之后才被计数的，也就是说它们到达计数规则之前已经被打了 mark。可以确认：Tailscale 的 41641 出站 UDP 确实被 OpenClash 的 UDP 全捕获规则处理了。\n3.7 A/B 测试（关键对照） # 在链最前面插入 return，看效果是否立竿见影：\nnft insert rule inet fw4 openclash_mangle_output udp sport 41641 counter return tailscale ping 100.x.x.x pong from wxg-magic6 (100.x.x.x) via DERP(sin) in 3.453s pong from wxg-magic6 (100.x.x.x) via DERP(sin) in 2.489s pong from wxg-magic6 (100.x.x.x) via DERP(sin) in 2.604s pong from wxg-magic6 (100.x.x.x) via DERP(sin) in 1.396s pong from wxg-magic6 (100.x.x.x) via 117.x.x.x:2xxx8 in 45ms ← 直连建立 插入return后直连建立，用3.6章节的计数器确认规则命中 3150 包，因此插入return规则有效解决问题，DERP 中继立即变Direct 直连。\n4. 最终根因 # tailscaled (root, UDP sport 41641) ↓ Linux OUTPUT ↓ fw4: mangle_output (priority mangle = -150) ↓ openclash_mangle_output ├─ meta skgid 65534 return ✗ tailscaled 是 root(0:0) ├─ ip daddr @localnetwork ✗ 目标是公网 peer ├─ ct direction reply ✗ 是主动出站 ├─ ip daddr @china_ip_route ✗ 目标多在境外 ↓ meta l4proto udp meta mark set 0x00000162 ← 全捕获 ↓ ip rule 1888: fwmark 0x162 lookup 354 ↓ table 354: default dev utun ↓ OpenClash TUN 路径 ↓ Direct 建立失败（在链首加 return 跳过打标后，Direct 立即恢复，故可确认该路径为原因） ↓ 回退 DERP(relay \u0026#34;sin\u0026#34;) OpenClash「代理路由器自身流量」在 openclash_mangle_output 链末尾有一条本机 UDP 全捕获规则（meta l4proto udp meta mark set 0x162），而 tailscaled 以 root 运行、前面的绕过条件全不命中；Tailscale 自己维护的 nftables 规则只配了 udp dport 41641（入站方向），没有保护 udp sport 41641（出站方向），于是 Tailscale 的 direct UDP 被打上 fwmark 送进 OpenClash TUN，Direct 建立失败、退回 DERP。\n5. 解决方案 # 5.1 核心规则 # 将以下规则插在 inet fw4 openclash_mangle_output 链的最前面（必须在 meta l4proto udp meta mark set 0x162 之前）。\nudp sport 41641 return 注意这里是 return，不是 accept。 两者的区别：\n问题不是「包被防火墙拒绝」，而是 OpenClash 在 mangle 阶段修改了包的 mark。因此真正需要的是从 openclash_mangle_output 这条普通链中 return，让后面的 mark 0x162 不再执行，而不是在另一个 hook / base chain 里发送一个 accept verdict。 在被 jump 进入的普通链里，return 会返回到调用它的链（即 mangle_output），包继续走正常的后续处理与路由；而 accept 只结束当前 base chain，同一 hook 上 priority 更晚的 base chain 仍会继续处理这个包。 5.2 必须 insert（头插） # nft insert rule 把规则插到链最前面；nft add rule 则是追加到链尾。对于捕获类链，顺序就是一切：如果 return 排在 mark 0x162 之后，包已经被打标，return 也来不及了。\n必须位于 meta l4proto udp meta mark set 0x162 之前 6. 持久化：OpenClash 自定义防火墙钩子 # OpenClash 在 /etc/init.d/openclash 里会主动调用用户的自定义防火墙脚本：\n2898: if [ -f \u0026#34;/etc/openclash/custom/openclash_custom_firewall_rules.sh\u0026#34; ]; then 2899: chmod +x /etc/openclash/custom/openclash_custom_firewall_rules.sh 2900: /etc/openclash/custom/openclash_custom_firewall_rules.sh 这个钩子在 OpenClash 自己建完规则之后执行，正好是插 bypass 的时机。它比自建 priority -151 独立表更符合 OpenClash 的生命周期——OpenClash 重启、防火墙重启都会触发它，规则自动重插。\n6.1 完整脚本（幂等版） # 写入 /etc/openclash/custom/openclash_custom_firewall_rules.sh：\n#!/bin/sh . /usr/share/openclash/log.sh . /lib/functions.sh # This script is called by /etc/init.d/openclash # Add your custom firewall rules here. LOG_TIP \u0026#34;Start Add Custom Firewall Rules...\u0026#34; CHAIN=\u0026#34;inet fw4 openclash_mangle_output\u0026#34; # Tailscale direct UDP bypass. # tailscaled uses UDP source port 41641. # This rule must be inserted before OpenClash\u0026#39;s generic UDP mark rule. if nft list chain $CHAIN 2\u0026gt;/dev/null | grep -q \u0026#39;udp sport 41641.*return\u0026#39;; then LOG_TIP \u0026#34;Tailscale UDP 41641 bypass rule already exists.\u0026#34; else if nft insert rule $CHAIN udp sport 41641 return 2\u0026gt;/dev/null; then LOG_TIP \u0026#34;Added Tailscale UDP 41641 bypass rule.\u0026#34; else LOG_TIP \u0026#34;ERROR: Failed to add Tailscale UDP 41641 bypass rule.\u0026#34; fi fi exit 0 三个设计要点：\n用 nft insert，不能用 nft add。add 会追加到链尾，排在 mark 0x162 之后，那时包已经被打 mark，return 也来不及了。 幂等。先 grep 判断规则是否已存在，避免每次 OpenClash 重启都堆一条。 正式规则不加 counter，保持干净。需要排查时临时加带 counter 的规则，看完删掉。 6.2 部署步骤 # # 1) 备份 cp /etc/openclash/custom/openclash_custom_firewall_rules.sh \\ /etc/openclash/custom/openclash_custom_firewall_rules.sh.bak # 2) 写入脚本（将上面内容 cat \u0026gt; ... \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; 整段粘贴） # 3) 赋权并手动执行一次 chmod +x /etc/openclash/custom/openclash_custom_firewall_rules.sh /etc/openclash/custom/openclash_custom_firewall_rules.sh # 4) 确认规则在最前面 nft -a list chain inet fw4 openclash_mangle_output 期望看到：\nchain openclash_mangle_output { # handle 529 udp sport 41641 return # ← 必须在最前 meta skgid 65534 counter ... return ip daddr @localnetwork counter ... return ct direction reply counter ... return meta l4proto udp ip daddr 198.18.0.0/16 meta mark set 0x162 ... ip daddr @china_ip_route ip daddr != @china_ip_route_pass ... return ip protocol icmp icmp type echo-request meta mark set 0x162 ... meta l4proto udp meta mark set 0x00000162 ... # ← 必须在它之后 } 6.3 回滚 # # 查 handle nft -a list chain inet fw4 openclash_mangle_output # 删除（handle 换成实际值） nft delete rule inet fw4 openclash_mangle_output handle \u0026lt;handle\u0026gt; # 或恢复备份 cp /etc/openclash/custom/openclash_custom_firewall_rules.sh.bak \\ /etc/openclash/custom/openclash_custom_firewall_rules.sh 7. OpenClash 自定义规则 SRC-PORT,41641,DIRECT 为什么不生效 # 明明在自定义规则里写了直连，41641 出站还是被代理。根因是透明代理分两个阶段，clash 规则只管后一个：\n阶段① 拦截（内核 nftables 层）：tailscaled 的出站包走到 OUTPUT 链，被 openclash_mangle_output 里的 meta l4proto udp meta mark set 0x162 无条件打上 mark，再由策略路由送进 OpenClash 的 TUN。这一步只由内核规则决定，clash 的 DIRECT 规则还没出场、也管不到它。 阶段② 路由（mihomo 规则层）：包进了 utun 之后，mihomo 才去匹配 SRC-PORT,41641,DIRECT。 但问题在阶段①就已经发生了——mark 0x162 与策略路由在包进 TUN 的那一刻就已经把它送进了 OpenClash 的处理路径。因此仅在 Clash/mihomo 规则层增加 DIRECT 不是最佳修复点；真正可靠的办法，是在 nftables 捕获规则执行 mark 0x162 之前把 Tailscale UDP 排除掉。\n8. 最终验证（四个勾） # # 验证项 命令 通过标准 1 直连是否恢复 tailscale ping \u0026lt;peer-ip\u0026gt; 输出 via \u0026lt;公网IP:端口\u0026gt; in xx ms，不再出现 via DERP(...) 2 OpenClash 重启后规则是否保住 openclash restart → nft -a list chain ... udp sport 41641 return 仍在链首（handle 会变，属正常） 3 firewall 重启后是否保住 firewall restart → 同上 规则自动重现 4 反向验证 Exit Node 仍走代理 远端 PC 选旁路由为 Exit Node，访问查 IP 站点 显示的公网 IP 是 OpenClash 代理出口 IP，不是 PC 本地宽带 IP 效果验证：\n验证 1 —— 直连恢复\n修复前：pong from wxg-magic6 (100.x.x.x) via DERP(sin) in 3.453s / 2.489s / 4.808s direct connection not established 修复后：pong from wxg-magic6 (100.x.x.x) via 117.x.x.x:2xxx8 in 45ms 验证 2 —— OpenClash restart\n重启前：chain handle 529 / udp sport 41641 return (handle 557) → via 117.x.x.x:2xxx8 in 136ms 重启后：chain handle 588 / udp sport 41641 return (handle 615) → via 117.x.x.x:2xxx8 in 48ms chain/rule handle 全变了（inet fw4 被重建），但规则仍自动出现在链首，直连保持。\n验证 3 —— firewall restart\nSection @zone[1] (tailscale) IPv4 fullcone enabled for zone \u0026#39;tailscale\u0026#39; chain openclash_mangle_output { # handle 103 ← 从 588 变成 103，fw4 确实重建了 udp sport 41641 return # handle 130 ← 规则自动重现 ... } → via 117.x.x.x:2xxx8 in 218ms **验证 4 —— Exit Node 反向验证\n远端 Windows PC 选软路由为 Exit Node，访问查公网 IP 站点 → 显示 OpenClash 代理出口 IP。这一步证明：我们确实只切了该切的，没有把 Exit Node 的代理一起绕掉。\n9. 达成的效果 # 指标 修复前 修复后 连接路径 DERP 中继 relay \u0026quot;sin\u0026quot; Direct UDP 117.x.x.x:2xxx8 ping 延迟 817ms ~ 4.808s，抖动大 45ms / 48ms / 136ms / 218ms tailscale status relay \u0026quot;sin\u0026quot; direct Exit Node 走 OpenClash 正常 仍正常 ✅ 「代理路由器自身流量」 开启 仍开启 ✅ 最终形态：\n┌──────────────┐ │ tailscaled │ └──────┬───────┘ │ UDP sport 41641 ▼ openclash_mangle_output │ ┌────────────┴────────────┐ │ │ sport 41641 其它 UDP │ │ RETURN mark 0x162 │ │ ▼ ▼ normal route utun │ │ ▼ ▼ Internet OpenClash 同时，Exit Node 转发链完全没动：\n远端 PC → tailscale0 → FORWARD → Internet → OpenClash → utun → 代理出口 附录：完整脚本与命令速查 # A. 持久化脚本 # 写入 /etc/openclash/custom/openclash_custom_firewall_rules.sh\n#!/bin/sh . /usr/share/openclash/log.sh . /lib/functions.sh LOG_TIP \u0026#34;Start Add Custom Firewall Rules...\u0026#34; CHAIN=\u0026#34;inet fw4 openclash_mangle_output\u0026#34; if nft list chain $CHAIN 2\u0026gt;/dev/null | grep -q \u0026#39;udp sport 41641.*return\u0026#39;; then LOG_TIP \u0026#34;Tailscale UDP 41641 bypass rule already exists.\u0026#34; else if nft insert rule $CHAIN udp sport 41641 return 2\u0026gt;/dev/null; then LOG_TIP \u0026#34;Added Tailscale UDP 41641 bypass rule.\u0026#34; else LOG_TIP \u0026#34;ERROR: Failed to add Tailscale UDP 41641 bypass rule.\u0026#34; fi fi exit 0 B. 排查命令速查 # # 1. 排除 NAT / 公网 tailscale netcheck # 2. 谁在监听 41641 ss -lunp | grep 41641 # 3. OpenClash 是否在 OUTPUT 处理本机流量 nft list ruleset | grep -n -E \u0026#39;41641|openclash_mangle_output|tproxy|tailscale\u0026#39; # 4. 看链内容（带 handle） nft -a list chain inet fw4 openclash_mangle_output # 5. 追 mark 到路由表 ip rule ip route show table all # 6. 临时计数器（排查用，看完删） nft add rule inet fw4 openclash_mangle_output udp sport 41641 counter # 7. A/B：临时插入 return 验证效果 nft insert rule inet fw4 openclash_mangle_output udp sport 41641 counter return tailscale ping \u0026lt;peer-ip\u0026gt; # 8. 确认 tailscaled 以 root 运行（绕不过 skgid 65534 return） cat /proc/$(pgrep tailscaled)/status | grep -E \u0026#39;Uid|Gid\u0026#39; ","date":"2026-09-29","externalUrl":null,"permalink":"/posts/openclash-tailscale-direct/","section":"Posts","summary":"本文记录的是一个特定网络环境下的实测问题：OpenWrt / ImmortalWrt 软路由、OpenClash 开启「代理路由器自身流量」、Tailscale 作为 Exit Node，远端设备与软路由通过Tailscale连接，期望直连并通过OpenClash代理出口流量。\n","tags":["软路由","OpenWrt","OpenClash","Tailscale","Direct"],"title":"OpenClash 与 Tailscale UDP 41641 冲突：从 DERP 到 Direct 的完整排查与修复","type":"posts"},{"categories":["折腾记录"],"content":" 一、概述 # 在软路由上同时运行 OpenClash 和 Tailscale，会遇到一个问题：Tailscale 无法与官方DERP直连，导致打洞失败，进而远端设备无法与软路由直连，软路由上执行tailscale netcheck检查：IPV4 (no addr found)、UDP false。\n根因不在 Tailscale 本身，而在于 OpenClash 的透明代理接管了路由器自身的出站流量，把 Tailscale 用于 NAT 打洞的探测包一并送进了代理通道，导致它拿不到正确的公网端点。\n本文的方案是：定时从 Tailscale 官方公开接口拉取 DERP 服务器 IP 列表，自动生成直连规则并写入 OpenClash 自定义规则文件，使Tailscale打洞探测流量正常。部署完成后全自动运行，无需人工干预。\n本方案由AI完成，本文也是在AI辅助下编写。原创方案，转载请注明出处，谢谢。\n二、适用环境与故障现状 # 2.1 适用环境 # 本方案适用于软路由（OpenWrt / ImmortalWrt）上同时部署 OpenClash 与 Tailscale，且OpenClash 代理本机流量、软路由自身也需走代理的情况（例如它被Tailscale远端设备当作 Exit Node 使用）。\n我的实际环境是：\n软路由固件版本：ImmortalWrt 25.12.1 r37978 Openclash插件版本：v0.47.156，内核版本：alpha-ge183c58，开启“路由本机代理” Tailscale版本：1.98.3-1 (OpenWrt)，开启“通告出口节点” 我期望达成的效果：Tailscale能自动打洞成功，但不能影响软路由自身、局域网设备、Tailscale exit node出口流量的正常代理。\n2.2 故障状态 # Tailscale 打洞失败，连接状态 —— 远端节点在线但走中继：\n100.xxx.xxx.100 pc-20260625 xxxxx@ windows active; relay \u0026#34;sin\u0026#34; 关键判据是 relay \u0026quot;sin\u0026quot;，正常应为 direct x.x.x.x:port。\n自检结果 —— tailscale netcheck 反映 Tailscale 能否探测到自身公网端点：\nReport: * UDP: false * IPv4: (no addr found) IPv4: (no addr found) 说明完全没拿到公网 IP:端口映射，NAT 类型判定失败。\nOpenClash 日志 —— 这是破案的关键：\n[信息] [UDP] 192.168.x.x:40856 --\u0026gt; 172.237.61.190:3478 match 使用 机场节点 [信息] [TCP] 192.168.x.x:33274 --\u0026gt; 45.159.98.196:80 match 使用 机场节点 [信息] [TCP] 192.168.x.x:37364 --\u0026gt; 162.248.221.215:80 match 使用 机场节点 源地址 192.168.x.x 是软路由本机，目的端口 3478（STUN）与 80（DERP 探测）正是 Tailscale 的探测流量。日志中的目的 IP 逐一比对官方 DERP 列表后全部命中：172.237.61.190（sao/derp11g，兼作 STUN）、162.248.221.199/215/248（tor/21b~21d）、45.159.98.196/145（waw/22b、22d）。\n快速确证方法：停掉 OpenClash 后重跑 tailscale netcheck，若 IPv4 立刻正常即可确认。\n/etc/init.d/openclash stop \u0026amp;\u0026amp; sleep 5 \u0026amp;\u0026amp; tailscale netcheck 三、根因：打洞成功需要达成的条件 # Tailscale 打洞成功需满足两个条件：\n条件 依赖流量 被代理后的后果 探测到自身公网端点 向 STUN 发 UDP 包（默认 3478） 拿到的是代理节点出口地址，源端口被重写，映射信息错误 维持信令通道 与控制面/DERP 通信（TCP 443/80） 通道虽通，但协商出的端点不可用 第一个环节被破坏后后续全部失效 —— 这就是网络正常、账号也正常，却永远只能走中继的原因。\n四、需求边界 # Tailscale打洞的STUN / DERP 探测流量必须直连，不经过代理通道 软路由自身、局域网设备和Tailscale Exit Node出口流量的代理不受影响，仍然经过 OpenClash 代理 DERP 服务器 IP 会变动，必须自动跟进，不靠人工维护 长期稳定运行，更新过程不造成明显断网 五、方案选型 # 方案 做法 结论 关闭路由器本机代理 设置里关闭本机代理，或把本机 IP 加入绕过列表 ❌ 软路由自身失去代理能力，Exit Node 场景失效，违背需求边界 2 进程级规则 添加 PROCESS-NAME,tailscaled,DIRECT ❌ 实测无效。依赖 meta 内核且需开启进程名探测，本机 TPROXY 场景下稳定匹配不到 tailscaled 进程 100.64.0.0/10 加进绕过列表 添加 IP-CIDR,100.64.0.0/10,DIRECT,no-resolve ❌ 实测无效。一、规则匹配看的是数据包的目的地址，而上述探测包的目的地址是 DERP 服务器的公网 IP，源地址是本机内网 IP，不会出现 100.64.0.0/10，规则不会被命中。二、该网段解决的是\u0026quot;访问 Tailscale 网段时不要走代理\u0026quot;，属业务流量分流；而我们面对的是\u0026quot;路由器自身的探测流量不要走代理\u0026quot;，属控制面问题。只有打洞成功之后才会产生以该网段为目的地址的流量，现在恰恰卡在这之前。 自定义规则 + 定时更新 定时拉取官方 DERP IP 列表，生成 IP-CIDR 直连规则写入自定义规则文件 ✅ 仅针对 DERP 地址直连，不影响其余流量，全自动跟进变动 六、核心思路 # Tailscale 官方提供公开的 DERP 清单接口，返回全部区域与节点信息：\n主用：https://controlplane.tailscale.com/derpmap/default 备用：https://login.tailscale.com/derpmap/default 于是方案变得直接：定时拉取 → 提取全部节点 IPv4 → 去重排序 → 生成 IP-CIDR,x.x.x.x/32,DIRECT 规则 → 写入 OpenClash 自定义规则文件 → 重启生效。 通过脚本定时拉取官方 DERP IP 列表，生成 IP-CIDR 直连规则写入自定义规则文件，OpenClash 启动时会将其前置到整个规则链最前面，优先于后续各类策略与兜底规则命中。\n七、脚本实现 # 完整源码见附录 A，以下说明关键设计点。脚本针对 BusyBox 环境编写，全程不依赖任何非默认组件。\n7.1 数据源设计 # 主用与备用两个源都抓取、合并去重，而非\u0026quot;主用失败才切备用\u0026quot;：覆盖最全，单源故障不影响整体，全部源失败才报错退出并保留旧文件。实测两源各 88 条，合并 176 条，去重后 88 条。\nfor url in $DERPMAP_URLS; do json=$(fetch_one \u0026#34;$url\u0026#34;) printf \u0026#39;%s\u0026#39; \u0026#34;$json\u0026#34; | extract_ips | add_prefix | validate \u0026gt;\u0026gt; \u0026#34;$TMP_IPS\u0026#34; done 7.2 提取管线 # 下载工具按可用性降级（curl → wget → uclient-fetch），解析同理（jq 优先，无则降级为 grep 管线）：\nextract_ips() { if command -v jq \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then jq -r \u0026#39;.Regions[].Nodes[].IPv4 // empty\u0026#39; 2\u0026gt;/dev/null else grep -o \u0026#39;\u0026#34;IPv4\u0026#34;:\u0026#34;[0-9.]*\u0026#34;\u0026#39; | cut -d\u0026#39;\u0026#34;\u0026#39; -f4 fi } 这里刻意不用 grep -oE —— BusyBox grep 在部分精简固件中对扩展正则与 -o 的组合支持有问题，grep -o + 基础正则 + cut 兼容性最好。随后为不带掩码的 IPv4 补 /32、做合法性校验，输出按数值排序的纯 CIDR 列表。\n7.3 安全性设计 # 机制 实现 目的 阈值保护 去重后少于 40 条判定异常，保留旧文件 防止接口异常时写坏规则库 原子写入 先写 .derptmp 再 mv 覆盖 避免 Clash 读到半截文件 自动备份 覆盖前 cp 为 .bak 出问题可回滚 临时文件清理 trap 'cleanup' EXIT INT TERM 异常退出不留垃圾 7.4 IPv4 与 IPv6 # 脚本只处理 IPv4，Tailscale实际打洞场景在 v4 下即可解决，输出内容 100% 来自 Tailscale 官方 derpmap —— 例如 172.237.61.190 出现在结果中，是因为它本身就是 Tailscale sao 区域的真实节点。\n7.5 幂等性 # 只比对块内的实际规则内容，当列表无变化时，不写规则文件、不重启Openclash，避免即使 IP 毫无变化也会每天写文件并重启 OpenClash：\nawk -v b=\u0026#34;$MARK_BEGIN\u0026#34; -v e=\u0026#34;$MARK_END\u0026#34; \u0026#39; index($0, b) == 1 { inside = 1; next } index($0, e) == 1 { inside = 0; next } inside \u0026amp;\u0026amp; /^- IP-CIDR,/ { print } \u0026#39; \u0026#34;$CUSTOM_RULES\u0026#34; | sed \u0026#39;s/^- IP-CIDR,//; s/,DIRECT$//\u0026#39; \u0026gt; \u0026#34;$TMP_OLD\u0026#34; cmp -s \u0026#34;$TMP_OLD\u0026#34; \u0026#34;$TMP_BODY\u0026#34; \u0026amp;\u0026amp; { log \u0026#34;列表无变化，不写文件、不重启\u0026#34;; exit 0; } 7.6 自定义规则文件格式约定 # 目标文件 /etc/openclash/custom/openclash_custom_rules.list 有三条约定：首行必须是 rules:；##- 前缀的行是注释示例，生效规则要写成 - 规则,...；该文件末行没有换行符，直接追加会粘连到注释行后面。\n因此脚本采取标记块策略：用 ##BEGIN tailscale-derp / ##END tailscale-derp 圈定自动维护区域，每次先剥离旧块再写新块，用户原有规则完整保留（awk 输出必然换行结尾，顺带修掉了末行无换行的问题）：\n##BEGIN tailscale-derp ↓ ↓ ↓ 以下由 tailscale-derp-update.sh 自动维护，勿手工编辑 ↓ ↓ ↓ ##updated: 2026-09-04 13:20:15 count: 88 - IP-CIDR,5.161.218.233/32,DIRECT - IP-CIDR,45.159.97.61/32,DIRECT ... ##END tailscale-derp 八、部署实战 # 以下在 Windows 主机上执行，SSH 连接软路由（192.168.x.x）。\n8.1 生成脚本 # 按照附录 A：完整脚本的内容保存为 `tailscale-derp-update.sh。\n8.2 上传脚本 # Windows主机向 OpenWrt 传文件：使用 cmd 的原生文件重定向，文件句柄直接交给 ssh，字节级透传：\ncmd /c \u0026#39;ssh root@192.168.x.x \u0026#34;cat \u0026gt; /usr/bin/tailscale-derp-update.sh\u0026#34; \u0026lt; \u0026#34;C:\\path\\to\\tailscale-derp-update.sh\u0026#34;\u0026#39; 8.3 校验文件一致性 # 这一步不能省，务必确认文件完好：\nssh root@192.168.x.x \u0026#34;chmod +x /usr/bin/tailscale-derp-update.sh \u0026amp;\u0026amp; md5sum /usr/bin/tailscale-derp-update.sh \u0026amp;\u0026amp; wc -c /usr/bin/tailscale-derp-update.sh \u0026amp;\u0026amp; sh -n /usr/bin/tailscale-derp-update.sh \u0026amp;\u0026amp; echo TRANSFER_OK\u0026#34; 成功时输出 TRANSFER_OK，且应为：\naf996f170aae7f30f2fdea0b9cea5885 /usr/bin/tailscale-derp-update.sh 12938 /usr/bin/tailscale-derp-update.sh md5 对不上说明文件在传输中被损坏，重做 8.2或者采用其他方法上传。\n8.4 试运行 # dry-run 只抓取解析，不写任何文件：\nssh root@192.168.x.x \u0026#34;/usr/bin/tailscale-derp-update.sh -n\u0026#34; 预期输出：\n[13:20:11] 拉取 https://controlplane.tailscale.com/derpmap/default [13:20:13] 解析到 88 条 [13:20:16] 源可用 2 个 / 失败 0 个；合并前 176 条，去重后 88 条 # ---- 合计 88 条 ---- 8.5 正式运行 # ssh root@192.168.x.x \u0026#34;/usr/bin/tailscale-derp-update.sh\u0026#34; 脚本完成：写入规则块 → 备份原文件 → 重启 OpenClash 生效，输出 已更新自定义规则文件 ...（88 条，备份于 ...bak） 即成功。\n8.6 验证规则生效 # ssh root@192.168.x.x \u0026#34;grep -n \u0026#39;BEGIN tailscale-derp\u0026#39; /etc/openclash/custom/openclash_custom_rules.list; grep -c \u0026#39;^- IP-CIDR,\u0026#39; /etc/openclash/custom/openclash_custom_rules.list\u0026#34; ssh root@192.168.x.x \u0026#34;tailscale netcheck\u0026#34; 8.7 配置定时自动更新 # 每日凌晨4:10静默执行（-q 仅写系统日志）：\nssh root@192.168.x.x \u0026#34;echo \u0026#39;10 4 * * * /usr/bin/tailscale-derp-update.sh -q\u0026#39; \u0026gt;\u0026gt; /etc/crontabs/root \u0026amp;\u0026amp; /etc/init.d/cron restart \u0026amp;\u0026amp; echo CRON_OK\u0026#34; 得益于幂等设计，IP 列表无变化时不写文件、不重启 OpenClash。\n8.8 防止刷机丢失 # OpenWrt 的 sysupgrade 默认不保留 /usr/bin 下自定义文件，加入保留清单：\nssh root@192.168.x.x \u0026#34;echo \u0026#39;/usr/bin/tailscale-derp-update.sh\u0026#39; \u0026gt;\u0026gt; /etc/sysupgrade.conf \u0026amp;\u0026amp; echo OK\u0026#34; 规则块本身位于 /etc/openclash/custom/，属 sysupgrade 保留区，重启与升级均不丢失；只有脚本本体需要上述保护。\n九、达成效果 # 9.1 修复前后对比 # 检查项 修复前 修复后 tailscale netcheck → IPv4 (no addr found) yes, 171.xx.xx.xx:6xxx1 tailscale netcheck → UDP false true 附录 A：完整脚本 # 将以下内容保存为 tailscale-derp-update.sh，放置于软路由 /usr/bin/ 目录。\n#!/bin/sh # ============================================================================== # tailscale-derp-update.sh (v4) # 从 Tailscale 官方 derpmap 的**主用与备用**两个源提取 DERP 节点 IPv4， # 合并去重后写入 OpenClash，使 DERP/STUN 流量走直连。 # # - 仅处理 IPv4，不提取任何 IPv6 地址 # - 不含任何硬编码/静态地址，输出内容 100% 来自 derpmap # - 两个源全部抓取，结果合并去重后写入 # - 全程使用 BusyBox 兼容语法（不依赖 ERE / mktemp / jq / base64） # # 数据源: # 主用 https://controlplane.tailscale.com/derpmap/default # 备用 https://login.tailscale.com/derpmap/default # # 两种输出模式 (OUTPUT_MODE): # custom 写入 /etc/openclash/custom/openclash_custom_rules.list # 以带标记的规则块形式插入，保留文件里已有的其它规则。 # 不需要注册 rule provider，OpenClash 启动时会把自定义规则前置到 # 整个规则链最前面。老版本 OpenClash 推荐此模式。 # provider 写入 /etc/openclash/rule_provider/tailscale-derp.list # CIDR 列表，供 mihomo 的 rule-provider 引用，可 API 热重载。 # 需先在 OpenClash 里注册 rule provider（UI 或覆写模块）。 # # 用法: # tailscale-derp-update.sh 正常运行（写文件 + 触发重载） # tailscale-derp-update.sh -n 只抓取并打印，不写文件（dry-run） # tailscale-derp-update.sh -f 写文件后强制重启 OpenClash # tailscale-derp-update.sh -q 静默模式（只写 syslog，不打印） # ============================================================================== set -u # --------------------------------- 可调参数 --------------------------------- DERPMAP_URLS=\u0026#34;https://controlplane.tailscale.com/derpmap/default https://login.tailscale.com/derpmap/default\u0026#34; OUTPUT_MODE=\u0026#34;custom\u0026#34; # custom | provider CUSTOM_RULES=\u0026#34;/etc/openclash/custom/openclash_custom_rules.list\u0026#34; PROVIDER_DIR=\u0026#34;/etc/openclash/rule_provider\u0026#34; PROVIDER_NAME=\u0026#34;tailscale-derp\u0026#34; PROVIDER_FILE=\u0026#34;${PROVIDER_DIR}/${PROVIDER_NAME}.list\u0026#34; MIN_ENTRIES=40 # 去重后条数低于此值视为数据异常，保留旧文件不动 CONNECT_TIMEOUT=10 MAX_TIME=25 RELOAD_MODE=\u0026#34;restart\u0026#34; # restart=重启 OpenClash | api=调 mihomo API | none=不动作 # 注：custom 模式下规则在启动时烘焙进配置，必须靠重启生效 MIHOMO_PORT=\u0026#34;9090\u0026#34; # 仅作兜底；实际端口会自动探测 MIHOMO_SECRET=\u0026#34;\u0026#34; # 留空则自动从 UCI 探测 LOG_TAG=\u0026#34;derp-update\u0026#34; # 自定义规则模式下的块标记（## 是该文件既有的注释前缀，不会被当作规则） MARK_BEGIN=\u0026#34;##BEGIN tailscale-derp\u0026#34; MARK_END=\u0026#34;##END tailscale-derp\u0026#34; # --------------------------------------------------------------------------- DRY_RUN=0 QUIET=0 # 临时文件（BusyBox 可能没有 mktemp，做兜底） TMPDIR_D=\u0026#34;${TMPDIR:-/tmp}\u0026#34; PID_D=$$ TMP_IPS=\u0026#34;${TMPDIR_D}/derp-ips.${PID_D}\u0026#34; TMP_BODY=\u0026#34;${TMPDIR_D}/derp-body.${PID_D}\u0026#34; TMP_STRIP=\u0026#34;${TMPDIR_D}/derp-strip.${PID_D}\u0026#34; TMP_OLD=\u0026#34;${TMPDIR_D}/derp-old.${PID_D}\u0026#34; cleanup() { rm -f \u0026#34;$TMP_IPS\u0026#34; \u0026#34;$TMP_BODY\u0026#34; \u0026#34;$TMP_STRIP\u0026#34; \u0026#34;$TMP_OLD\u0026#34;; } trap \u0026#39;cleanup\u0026#39; EXIT INT TERM say() { [ \u0026#34;$QUIET\u0026#34; -eq 1 ] || echo \u0026#34;[$(date \u0026#39;+%H:%M:%S\u0026#39;)] $*\u0026#34;; } log() { say \u0026#34;$*\u0026#34;; command -v logger \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; logger -t \u0026#34;$LOG_TAG\u0026#34; \u0026#34;$*\u0026#34;; return 0; } # 统计文件中的有效行数（grep 无匹配时返回 1，故补 true） count_lines() { grep -c \u0026#39;[0-9]\u0026#39; \u0026#34;$1\u0026#34; 2\u0026gt;/dev/null || true; } usage() { echo \u0026#34;用法: $0 [-n] [-f] [-q]\u0026#34; echo \u0026#34; -n dry-run，只打印不写文件\u0026#34; echo \u0026#34; -f 写文件后强制重启 OpenClash\u0026#34; echo \u0026#34; -q 静默，仅写 syslog\u0026#34; exit 0 } while [ $# -gt 0 ]; do case \u0026#34;$1\u0026#34; in -n|--dry-run) DRY_RUN=1 ;; -f|--force) RELOAD_MODE=\u0026#34;restart\u0026#34; ;; -q|--quiet) QUIET=1 ;; -h|--help) usage ;; *) echo \u0026#34;未知参数: $1\u0026#34;; usage ;; esac shift done # ------------------------------ 抓取单个源 ------------------------------ # 返回码 0=成功 127=系统无下载工具 其它=该源失败 fetch_one() { url=\u0026#34;$1\u0026#34; if command -v curl \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then curl -fsSL --connect-timeout \u0026#34;$CONNECT_TIMEOUT\u0026#34; --max-time \u0026#34;$MAX_TIME\u0026#34; \u0026#34;$url\u0026#34; 2\u0026gt;/dev/null elif command -v wget \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then wget -q -O - -T \u0026#34;$MAX_TIME\u0026#34; \u0026#34;$url\u0026#34; 2\u0026gt;/dev/null elif command -v uclient-fetch \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then uclient-fetch -q -O - -T \u0026#34;$MAX_TIME\u0026#34; \u0026#34;$url\u0026#34; 2\u0026gt;/dev/null else return 127 fi } # ------------------------------ 提取 IPv4 ------------------------------ # 只使用 BusyBox 稳支持的 grep -o + BRE + cut，不用 -E extract_ips() { if command -v jq \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then jq -r \u0026#39;.Regions[].Nodes[].IPv4 // empty\u0026#39; 2\u0026gt;/dev/null else grep -o \u0026#39;\u0026#34;IPv4\u0026#34;:\u0026#34;[0-9.]*\u0026#34;\u0026#39; | cut -d\u0026#39;\u0026#34;\u0026#39; -f4 fi } # derpmap 的 IPv4 字段不带掩码，统一补 /32 add_prefix() { awk \u0026#39;{ if ($0 ~ /\\//) print $0; else print $0 \u0026#34;/32\u0026#34; }\u0026#39; } # 过滤掉明显非法的条目 validate() { awk \u0026#39; /^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+(\\/[0-9]+)?$/ { ip = $0; sub(/\\/.*/, \u0026#34;\u0026#34;, ip) n = split(ip, a, \u0026#34;.\u0026#34;) ok = 1 for (i = 1; i \u0026lt;= n; i++) if (a[i] + 0 \u0026gt; 255) ok = 0 if (n == 4 \u0026amp;\u0026amp; ok) print }\u0026#39; } # ------------------------------ 探测 mihomo 控制端口 ------------------------------ detect_port() { p=$(uci -q get openclash.config.dashboard_port 2\u0026gt;/dev/null) if [ -n \u0026#34;$p\u0026#34; ]; then echo \u0026#34;$p\u0026#34;; return 0; fi for f in /etc/openclash/config.yaml /etc/openclash/*.yaml; do [ -f \u0026#34;$f\u0026#34; ] || continue p=$(sed -n \u0026#39;s/.*external-controller:.*:\\([0-9]\\{1,5\\}\\).*/\\1/p\u0026#39; \u0026#34;$f\u0026#34; 2\u0026gt;/dev/null | head -1) if [ -n \u0026#34;$p\u0026#34; ]; then echo \u0026#34;$p\u0026#34;; return 0; fi done echo \u0026#34;$MIHOMO_PORT\u0026#34; } # ------------------------------ 探测 Dashboard 密钥 ------------------------------ detect_secret() { if [ -n \u0026#34;$MIHOMO_SECRET\u0026#34; ]; then echo \u0026#34;$MIHOMO_SECRET\u0026#34;; return 0; fi for k in dashboard_password da_password ui_password; do s=$(uci -q get \u0026#34;openclash.config.${k}\u0026#34; 2\u0026gt;/dev/null) if [ -n \u0026#34;$s\u0026#34; ]; then echo \u0026#34;$s\u0026#34;; return 0; fi done echo \u0026#34;\u0026#34; } # ------------------------------ 触发 OpenClash 重载 ------------------------------ reload_openclash() { # custom 模式下规则在启动时烘焙进配置，API 热重载不适用 if [ \u0026#34;$OUTPUT_MODE\u0026#34; = \u0026#34;custom\u0026#34; ] \u0026amp;\u0026amp; [ \u0026#34;$RELOAD_MODE\u0026#34; = \u0026#34;api\u0026#34; ]; then say \u0026#34;custom 模式下 API 热重载不适用，改为重启 OpenClash\u0026#34; RELOAD_MODE=\u0026#34;restart\u0026#34; fi case \u0026#34;$RELOAD_MODE\u0026#34; in none) log \u0026#34;RELOAD_MODE=none：等待下次 OpenClash 重启时生效\u0026#34; return 0 ;; restart) log \u0026#34;重启 OpenClash 使规则生效 ...\u0026#34; if /etc/init.d/openclash restart \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then log \u0026#34;OpenClash 已重启\u0026#34; else log \u0026#34;警告: OpenClash 重启命令返回非零，请手动确认\u0026#34; fi return 0 ;; api) ;; esac if ! command -v curl \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then log \u0026#34;无 curl，跳过 API 热重载\u0026#34; return 0 fi port=$(detect_port) secret=$(detect_secret) url=\u0026#34;http://127.0.0.1:${port}/providers/rules/${PROVIDER_NAME}\u0026#34; say \u0026#34;调用 API ${url}\u0026#34; if [ -n \u0026#34;$secret\u0026#34; ]; then code=$(curl -s -o /dev/null -w \u0026#39;%{http_code}\u0026#39; --connect-timeout 5 --max-time 10 \\ -H \u0026#34;Authorization: Bearer ${secret}\u0026#34; -X PUT \u0026#34;$url\u0026#34; 2\u0026gt;/dev/null) else code=$(curl -s -o /dev/null -w \u0026#39;%{http_code}\u0026#39; --connect-timeout 5 --max-time 10 \\ -X PUT \u0026#34;$url\u0026#34; 2\u0026gt;/dev/null) fi case \u0026#34;${code:-000}\u0026#34; in 200|204) log \u0026#34;已通过 API 热重载规则集 ${PROVIDER_NAME} (HTTP ${code})\u0026#34; ;; 000) log \u0026#34;API 不可达（端口 ${port}），规则集将在 mihomo 按 interval 重载或下次重启时生效\u0026#34; ;; 404) log \u0026#34;API 返回 404：规则集 ${PROVIDER_NAME} 尚未注册，请改用 OUTPUT_MODE=custom 或在 OpenClash 里注册 rule provider\u0026#34; ;; *) log \u0026#34;API 返回 HTTP ${code}，未确认生效；必要时改用 -f 重启 OpenClash\u0026#34; ;; esac } # ------------------------------ 构造输出内容 ------------------------------ # 参数: $1=去重后的 CIDR 文件 $2=总条数 $3=合并前条数 $4=输出目标 build_output() { body=\u0026#34;$1\u0026#34;; total=\u0026#34;$2\u0026#34;; raw_total=\u0026#34;$3\u0026#34;; out=\u0026#34;$4\u0026#34; if [ \u0026#34;$OUTPUT_MODE\u0026#34; = \u0026#34;custom\u0026#34; ]; then # 目标不存在则先建（首行必须是 rules:） if [ ! -f \u0026#34;$CUSTOM_RULES\u0026#34; ]; then mkdir -p \u0026#34;$(dirname \u0026#34;$CUSTOM_RULES\u0026#34;)\u0026#34; || return 1 echo \u0026#34;rules:\u0026#34; \u0026gt; \u0026#34;$CUSTOM_RULES\u0026#34; || return 1 fi # 剥离上一次写入的块；awk 输出必然以换行结尾， # 顺带解决原文件末行可能没有换行符的问题 awk -v b=\u0026#34;$MARK_BEGIN\u0026#34; -v e=\u0026#34;$MARK_END\u0026#34; \u0026#39; index($0, b) == 1 { skip = 1; next } index($0, e) == 1 { skip = 0; next } skip { next } { print } \u0026#39; \u0026#34;$CUSTOM_RULES\u0026#34; \u0026gt; \u0026#34;$TMP_STRIP\u0026#34; || return 1 { cat \u0026#34;$TMP_STRIP\u0026#34; echo \u0026#34;${MARK_BEGIN} ↓ ↓ ↓ 以下由 tailscale-derp-update.sh 自动维护，勿手工编辑 ↓ ↓ ↓\u0026#34; echo \u0026#34;##updated: $(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) count: ${total}\u0026#34; awk \u0026#39;{ print \u0026#34;- IP-CIDR,\u0026#34; $0 \u0026#34;,DIRECT\u0026#34; }\u0026#39; \u0026#34;$body\u0026#34; echo \u0026#34;$MARK_END\u0026#34; } \u0026gt; \u0026#34;$out\u0026#34; || return 1 else { echo \u0026#34;# Tailscale DERP 节点 IPv4 直连规则集 - 由 tailscale-derp-update.sh 自动生成，请勿手工编辑\u0026#34; echo \u0026#34;# updated: $(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)\u0026#34; echo \u0026#34;# sources: controlplane.tailscale.com/derpmap/default + login.tailscale.com/derpmap/default\u0026#34; echo \u0026#34;# count : ${total}（合并前 ${raw_total}，已去重）\u0026#34; cat \u0026#34;$body\u0026#34; } \u0026gt; \u0026#34;$out\u0026#34; || return 1 fi return 0 } # ------------------------------ 主流程 ------------------------------ main() { : \u0026gt; \u0026#34;$TMP_IPS\u0026#34; || { log \u0026#34;错误: 无法创建临时文件 ${TMP_IPS}\u0026#34;; exit 1; } ok_sources=0 failed_sources=0 for url in $DERPMAP_URLS; do say \u0026#34;拉取 ${url}\u0026#34; json=$(fetch_one \u0026#34;$url\u0026#34;) rc=$? if [ \u0026#34;$rc\u0026#34; -eq 127 ]; then log \u0026#34;错误: 系统里没有 curl / wget / uclient-fetch，无法抓取\u0026#34; exit 1 fi if [ \u0026#34;$rc\u0026#34; -ne 0 ] || [ -z \u0026#34;$json\u0026#34; ]; then say \u0026#34; 该源无响应，跳过\u0026#34; failed_sources=$((failed_sources + 1)) continue fi before=$(count_lines \u0026#34;$TMP_IPS\u0026#34;) printf \u0026#39;%s\u0026#39; \u0026#34;$json\u0026#34; | extract_ips | add_prefix | validate \u0026gt;\u0026gt; \u0026#34;$TMP_IPS\u0026#34; after=$(count_lines \u0026#34;$TMP_IPS\u0026#34;) n=$((after - before)) if [ \u0026#34;$n\u0026#34; -le 0 ]; then say \u0026#34; 该源未解析到任何 IPv4，跳过\u0026#34; failed_sources=$((failed_sources + 1)) continue fi say \u0026#34; 解析到 ${n} 条\u0026#34; ok_sources=$((ok_sources + 1)) done if [ \u0026#34;$ok_sources\u0026#34; -eq 0 ]; then log \u0026#34;错误: 全部 derpmap 源均不可用，保持原文件不变\u0026#34; exit 1 fi raw_total=$(count_lines \u0026#34;$TMP_IPS\u0026#34;) grep \u0026#39;[0-9]\u0026#39; \u0026#34;$TMP_IPS\u0026#34; | awk \u0026#39;!seen[$0]++\u0026#39; | \\ sort -t. -k1,1n -k2,2n -k3,3n -k4,4n \u0026gt; \u0026#34;$TMP_BODY\u0026#34; || exit 1 total=$(count_lines \u0026#34;$TMP_BODY\u0026#34;) say \u0026#34;源可用 ${ok_sources} 个 / 失败 ${failed_sources} 个；合并前 ${raw_total} 条，去重后 ${total} 条\u0026#34; if [ \u0026#34;$total\u0026#34; -lt \u0026#34;$MIN_ENTRIES\u0026#34; ]; then log \u0026#34;错误: 去重后仅 ${total} 条（阈值 ${MIN_ENTRIES}），判定为数据异常，保留原文件\u0026#34; exit 1 fi if [ \u0026#34;$DRY_RUN\u0026#34; -eq 1 ]; then echo \u0026#34;# ---- dry-run 输出（未写文件）----\u0026#34; if [ \u0026#34;$OUTPUT_MODE\u0026#34; = \u0026#34;custom\u0026#34; ]; then awk \u0026#39;{ print \u0026#34;- IP-CIDR,\u0026#34; $0 \u0026#34;,DIRECT\u0026#34; }\u0026#39; \u0026#34;$TMP_BODY\u0026#34; else cat \u0026#34;$TMP_BODY\u0026#34; fi echo \u0026#34;# ---- 合计 ${total} 条 ----\u0026#34; exit 0 fi # custom 模式：先比对\u0026#34;实际规则内容\u0026#34;是否与文件里已有的一致。 # 不能整体 cmp —— 块里的 updated 时间戳每次都变，否则会天天无谓重启。 if [ \u0026#34;$OUTPUT_MODE\u0026#34; = \u0026#34;custom\u0026#34; ] \u0026amp;\u0026amp; [ -f \u0026#34;$CUSTOM_RULES\u0026#34; ]; then awk -v b=\u0026#34;$MARK_BEGIN\u0026#34; -v e=\u0026#34;$MARK_END\u0026#34; \u0026#39; index($0, b) == 1 { inside = 1; next } index($0, e) == 1 { inside = 0; next } inside \u0026amp;\u0026amp; /^- IP-CIDR,/ { print } \u0026#39; \u0026#34;$CUSTOM_RULES\u0026#34; | sed \u0026#39;s/^- IP-CIDR,//; s/,DIRECT$//\u0026#39; \u0026gt; \u0026#34;$TMP_OLD\u0026#34; if cmp -s \u0026#34;$TMP_OLD\u0026#34; \u0026#34;$TMP_BODY\u0026#34;; then log \u0026#34;DERP 节点列表无变化（${total} 条），不写文件、不重启 OpenClash\u0026#34; exit 0 fi fi if [ \u0026#34;$OUTPUT_MODE\u0026#34; = \u0026#34;custom\u0026#34; ]; then target=\u0026#34;$CUSTOM_RULES\u0026#34; desc=\u0026#34;自定义规则文件\u0026#34; else mkdir -p \u0026#34;$PROVIDER_DIR\u0026#34; || { log \u0026#34;错误: 无法创建目录 ${PROVIDER_DIR}\u0026#34;; exit 1; } target=\u0026#34;$PROVIDER_FILE\u0026#34; desc=\u0026#34;规则集文件\u0026#34; fi tmp=\u0026#34;${target}.derptmp\u0026#34; build_output \u0026#34;$TMP_BODY\u0026#34; \u0026#34;$total\u0026#34; \u0026#34;$raw_total\u0026#34; \u0026#34;$tmp\u0026#34; || { rm -f \u0026#34;$tmp\u0026#34;; log \u0026#34;错误: 构造${desc}内容失败\u0026#34;; exit 1 } if [ -f \u0026#34;$target\u0026#34; ] \u0026amp;\u0026amp; cmp -s \u0026#34;$tmp\u0026#34; \u0026#34;$target\u0026#34;; then rm -f \u0026#34;$tmp\u0026#34; log \u0026#34;内容无变化（${total} 条），不做任何改动\u0026#34; exit 0 fi [ -f \u0026#34;$target\u0026#34; ] \u0026amp;\u0026amp; cp \u0026#34;$target\u0026#34; \u0026#34;${target}.bak\u0026#34; mv \u0026#34;$tmp\u0026#34; \u0026#34;$target\u0026#34; || { log \u0026#34;错误: 写入 ${target} 失败\u0026#34;; exit 1; } log \u0026#34;已更新${desc} ${target}（${total} 条，备份于 ${target}.bak）\u0026#34; reload_openclash exit 0 } main \u0026#34;$@\u0026#34; 附录 B：常用验证命令 # 在软路由上或 ssh 软路由执行：\n# 自检：关注 UDP / IPv4 / MappingVariesByDestIP 三项 tailscale netcheck # 查看连接状态：关注是否 direct tailscale status # 测试与远端节点的连通路径 tailscale ping \u0026lt;远端 Tailscale IP\u0026gt; # 确认规则块位置与条数 grep -n \u0026#39;BEGIN tailscale-derp\u0026#39; /etc/openclash/custom/openclash_custom_rules.list grep -c \u0026#39;^- IP-CIDR,\u0026#39; /etc/openclash/custom/openclash_custom_rules.list # 确认 OpenClash 日志中已无 STUN/DERP 代理条目 logread -e openclash | grep -E \u0026#39;3478|:80\u0026#39; | tail -5 ","date":"2026-09-24","externalUrl":null,"permalink":"/posts/openclash-tailscale-utun/","section":"Posts","summary":"一、概述 # 在软路由上同时运行 OpenClash 和 Tailscale，会遇到一个问题：Tailscale 无法与官方DERP直连，导致打洞失败，进而远端设备无法与软路由直连，软路由上执行tailscale netcheck检查：IPV4 (no addr found)、UDP false。\n","tags":["软路由","OpenWrt","OpenClash","Tailscale","打洞"],"title":"Tailscale 在 OpenClash 代理环境下自动打洞成功方案","type":"posts"},{"categories":["折腾记录"],"content":"这是本站的第一篇文章。与其写\u0026quot;Hello World\u0026quot;，不如把搭这个博客的过程记下来——既能帮到想做同样事情的人，也能让我下次重装时不用再搜一遍。\n我想要的是什么 # 需求 原因 用 Markdown 写作 不想为了写篇文章去点富文本编辑器 推送即上线 不想登服务器、不想手动上传 0 成本 个人博客不值得为它付服务器钱 自己的域名 将来换托管平台时链接不作废 中文显示和搜索正常 很多静态博客主题在这两点上是坏的 技术选型 # 静态站点生成器：Hugo。 对比过 Hexo 和 Astro：\nHugo：一个 exe 文件、零依赖、构建毫秒级，主题多且维护规范； Hexo：中文教程最多，但要装 Node + 一堆 npm 插件，插件和新版 Node 偶尔打架； Astro：主题最好看，但依赖多、迭代快，教程容易过期。 主题：Blowfish。 深色模式、分类标签、站内搜索、代码复制按钮都是原生支持，中文文档齐全。\n托管：GitHub Pages + Cloudflare。 静态文件放在 GitHub 免费托管，前面套一层 Cloudflare 做 CDN——因为国内直连 GitHub Pages 的裸地址经常很慢。\n整体链路是这样的：\n本地写 Markdown → git push → GitHub Actions 自动构建 ↓ Cloudflare（CDN/HTTPS） ← GitHub Pages（静态文件） 日常只有两个动作：写 .md 文件、点一下推送。构建在云端完成，本地不需要装编译环境。\n搭建步骤 # 1. 装 Hugo（版本必须锁死） # 这是第一个坑，先说结论：Hugo 不能装最新版。\nBlowfish 在主题配置里声明了它能接受的版本区间（我这份是 min = 0.162.0、max = 0.165.0）。装一个比 max 更新的版本，Hugo 会因为不满足主题声明而直接报错退出——不是\u0026quot;可能有问题\u0026quot;，是构建失败。\n所以做法是：手动下载指定版本的压缩包 + 校验 SHA256 + 解压到固定目录 + 加进 PATH。\n# 本机安装（Windows） # 1) 下载指定版本 # https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_extended_0.165.0_windows-amd64.zip # 2) 校验（下载页会给出 SHA256，务必对一遍） Get-FileHash .\\hugo_extended_0.165.0_windows-amd64.zip -Algorithm SHA256 # 3) 解压出 hugo.exe 放到固定目录，并把该目录加入用户 PATH # 4) 验证：必须带 +extended hugo version # hugo v0.165.0+extended windows/amd64 网页上 \u0026ldquo;extended\u0026rdquo; 版本是必须的——主题配置里写了 extended = true。\n2. 用 submodule 引入主题 # git init -b main git submodule add --depth 1 -b main \\ https://github.com/nunocoracao/blowfish.git themes/blowfish 为什么用 submodule 而不是直接把主题文件复制进来：主仓库里只保存一个\u0026quot;指针\u0026quot;（记录主题的 commit），主题那一大堆文件不会进你的仓库。我这份主题的完整历史接近 90 MB，而我的博客仓库只有几百 KB。升级主题时也只是一条命令。\n3. 配置：中文、搜索、代码复制 # Blowfish 的配置放在 config/_default/ 下的多个文件里，不是根目录一个 hugo.toml（这一点跟着网上旧教程做很容易踩空）。几个关键项：\n# config/_default/hugo.toml baseURL = \u0026#34;https://你的域名/\u0026#34; defaultContentLanguage = \u0026#34;zh-cn\u0026#34; timeZone = \u0026#34;Asia/Shanghai\u0026#34; # 不写的话日期可能按 UTC 走，差 8 小时 hasCJKLanguage = true # 中文按字统计字数与阅读时间 mainSections = [\u0026#34;posts\u0026#34;] # 明确\u0026#34;文章区\u0026#34;，避免关于页混进文章流 summaryLength = 70 # config/_default/params.toml enableSearch = true # 站内搜索 enableCodeCopy = true # 代码块复制按钮 4. 自动部署 # 在仓库里放一个 GitHub Actions 工作流（.github/workflows/deploy.yml），推送到 main 就自动构建并发布。三个关键参数：\n参数 作用 不写的后果 submodules: recursive 把主题 submodule 一起拉下来 themes/blowfish 是空目录，构建失败 fetch-depth: 0 拉完整提交历史 文章的\u0026quot;最后更新\u0026quot;时间不准 --baseURL \u0026quot;${{ steps.pages.outputs.base_url }}/\u0026quot; 让 GitHub 自动给出站点地址 项目站点会因地址带子路径而样式全丢 最后别忘了在仓库 Settings → Pages → Source 里选 GitHub Actions——不选的话工作流会在部署那步报 HttpError: Not Found。\n搭建期间踩到的 8 个坑 # # 现象 原因 解决 1 构建直接失败 Hugo 版本超出主题声明的 max 锁定主题支持的版本，别装最新版 2 CI 里装 Hugo 那步 404 .deb 资产名写成了 Linux-64bit 实际是 linux-amd64.deb（小写） 3 文章页显示「1 词 · 1 分钟阅读」 没开 hasCJKLanguage，整段中文被当成一个\u0026quot;词\u0026quot; 开启后按字统计 4 首页有头像，但整个站点构建失败 头像放进了 static/，而主题用 resources.Get 从 assets/ 取图 头像放 assets/img/ 5 搜标签名搜不到文章 主题的搜索索引不含 tags/categories，而且混进了分类页、标签页这类空页面 覆盖索引模板，只收录真实文章并加上这两个字段 6 线上\u0026quot;页面能打开但样式全丢\u0026quot; baseURL 和真实地址不一致（项目站点带 /仓库名/ 子路径） 让 baseURL 指向真实地址，工作流里用 pages 的输出 7 以为草稿是\u0026quot;私密\u0026quot;的 draft: true 只是不构建，文件本身仍在公开仓库里 敏感内容从一开始就不要写进仓库 8 本地图片正常、线上裂图 Obsidian 默认写 Wiki 链接 ![[图.jpg]]，Hugo 不认 关掉 Wiki 链接，新链接格式改成相对路径 其中两个值得多说一句：\n坑 2 的教训：那个错误的文件名是我从主题自己的 CI 配置里抄来的——\u0026ldquo;别人的 CI 里这么写\u0026quot;不等于\u0026quot;一定能用\u0026rdquo;。最后是把两个候选文件名都请求一遍（一个 404、一个 200）才确定的。凡是涉及具体版本号、文件名的东西，下载一次比读十篇教程都可靠。\n坑 5 的教训：搜索是静态博客最容易\u0026quot;看起来有、其实没有\u0026quot;的功能。判断它有没有用，不能看配置文件里写没写 enableSearch = true，而要实际搜几个中文词：连续词、带空格的多词、标签名、分类名。我是搜\u0026quot;标签名\u0026quot;时发现搜不到的——因为索引里根本没有这个字段。\n上线之后才暴露的 3 个问题 # \u0026ldquo;本地能跑\u0026quot;和\u0026quot;挂到自己的域名上一切正常\u0026quot;是两件事。下面这三个，都是绑定自定义域名之后才冒出来的。\n问题 1：域名绑好了，页面却\u0026quot;没样式\u0026rdquo;（基地址没跟上域名） # 现象：https://blog.wxgg.eu.cc/ 能打开、文字都在，但完全没有样式——SVG 图标撑成巨大的一坨、头像不显示、布局全乱。\n排查：查看页面源代码，两个线索立刻暴露问题：\n\u0026lt;link rel=\u0026quot;canonical\u0026quot;\u0026gt; 指向的是 https://\u0026lt;用户名\u0026gt;.github.io/blog/，而不是我的域名； 所有静态资源都是根相对路径 /blog/css/...——站点现在挂在域名根目录，浏览器于是去请求 https://blog.wxgg.eu.cc/blog/css/...，全部 404。 根因：Hugo 生成资源路径用的是 baseURL，而我在工作流里让它取 GitHub Pages 的自动地址：\nhugo --minify --baseURL \u0026#34;${{ steps.pages.outputs.base_url }}/\u0026#34; 这份 HTML 是在自定义域名生效之前构建的。那时 Pages 给出的地址还是 https://\u0026lt;用户名\u0026gt;.github.io/blog，Hugo 就把 /blog/ 写进了每一个资源路径；域名生效后站点换到根目录，路径全部对不上。\n修法（两条路，我最后选了后者）：\n做法 换域名时要做什么 优缺点 把域名写死在 hugo.toml 的 baseURL 改 1 行配置 + 重新构建 确定、直观，但容易忘 继续用 Pages 的地址，但构建前统一规范成 https 只改 Pages 设置，代码不用动 自适应；地址永远跟着域名走，代价是构建时多一行处理 最关键的一句：不管走哪条路，绑域名/换域名之后都必须重新构建一次。因为 canonical、sitemap、RSS 这些绝对地址本来就是跟着域名走的，不重建就不会更新——我这次就是\u0026quot;域名先生效、构建是上一次的产物\u0026quot;，才出现\u0026quot;能打开但没样式\u0026quot;。\n问题 2：Pages 没启用，工作流直接报 HttpError # 第一次运行 Actions 就红了，报错原文：\nSetup Pages HttpError: Not Found - https://docs.github.com/rest/pages/pages#get-a-apiname-pages-site Get Pages site failed. Please verify that the repository has Pages enabled and configured to build using GitHub Actions 根因：仓库还没启用 Pages。Actions 想替你部署，但 Pages 功能没开，它去查 Pages 站点时拿到 404。\n修法：仓库 Settings → Pages → Build and deployment → Source 选 GitHub Actions。报错其实写得很清楚，但如果没注意到那个 \u0026ldquo;Source\u0026rdquo; 下拉框，很容易误以为是自己工作流写错了。\n问题 3：站点的绝对地址全是 http # 修好前两个问题后，我去检查 sitemap.xml 和 RSS，发现里面的链接全是 http://（sitemap 4 处、RSS 7 处），og:url 也是 http。\n根因：Pages 在没有启用 \u0026ldquo;Enforce HTTPS\u0026rdquo; 时，会把 http://域名 交给构建，Hugo 就照着生成了所有绝对地址。\n有意思的是，那个开关我想勾也勾不上，GitHub 提示：\nUnavailable for your site because your domain is not properly configured to support HTTPS\n因为域名在 Cloudflare 上开着代理（橙色云），GitHub 无法为它签发自己的证书，所以这个开关一直不可用。但这不影响访客：HTTPS 由 Cloudflare 的证书提供（实测签发者是 Google Trust Services，覆盖 *.wxgg.eu.cc，有效期到 2026-10-28）。\n修法：构建前把拿到的地址统一替换成 https：\nBASE=\u0026#34;${BASE/http:\\/\\//https:\\/\\/}\u0026#34; hugo --minify --baseURL \u0026#34;$BASE/\u0026#34; 改完 sitemap / RSS / og:url 全部变成 https。（文件里剩下的 http://www.sitemaps.org/... 是 XML 命名空间，属于正常现象。）\n我给自己加的两道保险 # 这些坑有个共同点：页面\u0026quot;看起来是好的\u0026quot;，靠人眼很容易漏。所以我加了两道自动检查。\n① 构建期断言：构建完成后、上传产物之前，直接检查刚生成的首页里引用的样式表路径，是否与本次构建用的地址一致。这一步纯本地、不碰网络：路径对不上，就让这次运行直接失败。这样一来，\u0026ldquo;地址不一致导致没样式\u0026quot;会变成 CI 上的红叉，而不是等我偶然发现。\n这一条其实是我改过的第二版。最初写的是\u0026rdquo;部署后自检\u0026quot;——部署完成后去请求线上首页和它引用的样式表，任一不是 200 就判失败。它有两个坑，最后被我整个删掉了：\n必须带重试：我第一版只检查一次，结果部署刚结束、CDN 还没切到新内容，它抓到的是上一版 HTML，于是误报失败——站点其实已经好了； 它依赖构建机能不能访问你的站点：我的站点在 Cloudflare 后面，GitHub 的构建机去请求首页，直接返回 403，这个自检在 CI 里根本跑不通（日志里写着\u0026quot;首页返回 403\u0026quot;）。 教训是：能在本地判定的问题，就不要交给\u0026quot;线上探测\u0026quot;去判定。线上还隔着一层 CDN、一层防火墙，构建机能观测到的世界，和你的浏览器并不是同一个。\n② 发布前检查：推送前跑一个脚本，扫内容里有没有真实密钥或内网 IP、有没有引用不存在的图片（坏图）、有没有超过 500KB 的大图。它分两档：.md 文档里出现 sk-、AKIA 这类格式说明只算\u0026quot;提示\u0026quot;（否则每篇教程都会报警），而配置文件里出现同样特征就直接算\u0026quot;高危\u0026quot;。\n顺手的几个小设置 # 文章模板：把 front matter 固化成模板，新建文章时不用手填； 图片规范：统一 WebP、宽度 ≤1600px、和文章放同一目录； 发布前检查：写个脚本，推送前扫一遍\u0026quot;有没有真实密钥、有没有坏图、有没有超过 500KB 的大图\u0026quot;； 版本记录：把 Hugo 版本、主题 commit 记进一个文件，以后样式坏了能快速回退。 小结 # 这套方案真正的好处不是\u0026quot;免费\u0026quot;，而是整条链路都是纯文本 + 可迁移的：文章是 Markdown，仓库是 Git，构建靠云端。哪怕以后想换框架或换托管，content/ 目录里的东西一个字都不用改。\n写这篇的时候，博客刚上线。上线之后我又陆续发现并修掉了几个问题（见上面那节），也算印证了一件事：静态博客的坑大多不在\u0026quot;搭\u0026quot;，而在\u0026quot;域名、协议、缓存\u0026quot;这些边界上。\n接下来就是慢慢往里填内容了——毕竟搭博客只是手段，持续记录才是目的。\n更新记录 # 日期 变更 2026-09-23 初稿发布 2026-09-23 补充「上线之后才暴露的 3 个问题」与「两道保险」——绑定自定义域名后踩到的 2026-09-23 修正「两道保险」小节：部署后自检因构建机被 Cloudflare 拦（403）已删除，实际保留的是构建期断言 本文里的 IP、密钥、订阅链接等一律用占位符表示（如 192.168.x.x、YOUR_TOKEN），请按自己的环境替换。\n","date":"2026-09-23","externalUrl":null,"permalink":"/posts/hugo-blowfish-github-pages-blog/","section":"Posts","summary":"这是本站的第一篇文章。与其写\"Hello World\"，不如把搭这个博客的过程记下来——既能帮到想做同样事情的人，也能让我下次重装时不用再搜一遍。\n","tags":["Hugo","GitHub_Pages"],"title":"用 Hugo + Blowfish 在 GitHub Pages 上搭一个中文博客","type":"posts"}]