一、概述 #
在软路由上同时运行 OpenClash 和 Tailscale,会遇到一个问题:Tailscale 无法与官方DERP直连,导致打洞失败,进而远端设备无法与软路由直连,软路由上执行tailscale netcheck检查:IPV4 (no addr found)、UDP false。
根因不在 Tailscale 本身,而在于 OpenClash 的透明代理接管了路由器自身的出站流量,把 Tailscale 用于 NAT 打洞的探测包一并送进了代理通道,导致它拿不到正确的公网端点。
本文的方案是:定时从 Tailscale 官方公开接口拉取 DERP 服务器 IP 列表,自动生成直连规则并写入 OpenClash 自定义规则文件,使Tailscale打洞探测流量正常。部署完成后全自动运行,无需人工干预。
本方案由AI完成,本文也是在AI辅助下编写。原创方案,转载请注明出处,谢谢。
二、适用环境与故障现状 #
2.1 适用环境 #
本方案适用于软路由(OpenWrt / ImmortalWrt)上同时部署 OpenClash 与 Tailscale,且OpenClash 代理本机流量、软路由自身也需走代理的情况(例如它被Tailscale远端设备当作 Exit Node 使用)。
我的实际环境是:
软路由固件版本:ImmortalWrt 25.12.1 r37978
Openclash插件版本:v0.47.156,内核版本:alpha-ge183c58,开启“路由本机代理”
Tailscale版本:1.98.3-1 (OpenWrt),开启“通告出口节点”我期望达成的效果:Tailscale能自动打洞成功,但不能影响软路由自身、局域网设备、Tailscale exit node出口流量的正常代理。
2.2 故障状态 #
Tailscale 打洞失败,连接状态 —— 远端节点在线但走中继:
100.xxx.xxx.100 pc-20260625 xxxxx@ windows active; relay "sin"关键判据是 relay "sin",正常应为 direct x.x.x.x:port。
自检结果 —— tailscale netcheck 反映 Tailscale 能否探测到自身公网端点:
Report:
* UDP: false
* IPv4: (no addr found)IPv4: (no addr found) 说明完全没拿到公网 IP:端口映射,NAT 类型判定失败。
OpenClash 日志 —— 这是破案的关键:
[信息] [UDP] 192.168.x.x:40856 --> 172.237.61.190:3478 match 使用 机场节点
[信息] [TCP] 192.168.x.x:33274 --> 45.159.98.196:80 match 使用 机场节点
[信息] [TCP] 192.168.x.x:37364 --> 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)。
快速确证方法:停掉 OpenClash 后重跑 tailscale netcheck,若 IPv4 立刻正常即可确认。
/etc/init.d/openclash stop && sleep 5 && tailscale netcheck三、根因:打洞成功需要达成的条件 #
Tailscale 打洞成功需满足两个条件:
| 条件 | 依赖流量 | 被代理后的后果 |
|---|---|---|
| 探测到自身公网端点 | 向 STUN 发 UDP 包(默认 3478) |
拿到的是代理节点出口地址,源端口被重写,映射信息错误 |
| 维持信令通道 | 与控制面/DERP 通信(TCP 443/80) |
通道虽通,但协商出的端点不可用 |
第一个环节被破坏后后续全部失效 —— 这就是网络正常、账号也正常,却永远只能走中继的原因。
四、需求边界 #
- 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,规则不会被命中。二、该网段解决的是"访问 Tailscale 网段时不要走代理",属业务流量分流;而我们面对的是"路由器自身的探测流量不要走代理",属控制面问题。只有打洞成功之后才会产生以该网段为目的地址的流量,现在恰恰卡在这之前。 |
| 自定义规则 + 定时更新 | 定时拉取官方 DERP IP 列表,生成 IP-CIDR 直连规则写入自定义规则文件 |
✅ 仅针对 DERP 地址直连,不影响其余流量,全自动跟进变动 |
六、核心思路 #
Tailscale 官方提供公开的 DERP 清单接口,返回全部区域与节点信息:
主用: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 启动时会将其前置到整个规则链最前面,优先于后续各类策略与兜底规则命中。
七、脚本实现 #
完整源码见附录 A,以下说明关键设计点。脚本针对 BusyBox 环境编写,全程不依赖任何非默认组件。
7.1 数据源设计 #
主用与备用两个源都抓取、合并去重,而非"主用失败才切备用":覆盖最全,单源故障不影响整体,全部源失败才报错退出并保留旧文件。实测两源各 88 条,合并 176 条,去重后 88 条。
for url in $DERPMAP_URLS; do
json=$(fetch_one "$url")
printf '%s' "$json" | extract_ips | add_prefix | validate >> "$TMP_IPS"
done7.2 提取管线 #
下载工具按可用性降级(curl → wget → uclient-fetch),解析同理(jq 优先,无则降级为 grep 管线):
extract_ips() {
if command -v jq >/dev/null 2>&1; then
jq -r '.Regions[].Nodes[].IPv4 // empty' 2>/dev/null
else
grep -o '"IPv4":"[0-9.]*"' | cut -d'"' -f4
fi
}这里刻意不用 grep -oE —— BusyBox grep 在部分精简固件中对扩展正则与 -o 的组合支持有问题,grep -o + 基础正则 + cut 兼容性最好。随后为不带掩码的 IPv4 补 /32、做合法性校验,输出按数值排序的纯 CIDR 列表。
7.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 区域的真实节点。
7.5 幂等性 #
只比对块内的实际规则内容,当列表无变化时,不写规则文件、不重启Openclash,避免即使 IP 毫无变化也会每天写文件并重启 OpenClash:
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
index($0, b) == 1 { inside = 1; next }
index($0, e) == 1 { inside = 0; next }
inside && /^- IP-CIDR,/ { print }
' "$CUSTOM_RULES" | sed 's/^- IP-CIDR,//; s/,DIRECT$//' > "$TMP_OLD"
cmp -s "$TMP_OLD" "$TMP_BODY" && { log "列表无变化,不写文件、不重启"; exit 0; }7.6 自定义规则文件格式约定 #
目标文件 /etc/openclash/custom/openclash_custom_rules.list 有三条约定:首行必须是 rules:;##- 前缀的行是注释示例,生效规则要写成 - 规则,...;该文件末行没有换行符,直接追加会粘连到注释行后面。
因此脚本采取标记块策略:用 ##BEGIN tailscale-derp / ##END tailscale-derp 圈定自动维护区域,每次先剥离旧块再写新块,用户原有规则完整保留(awk 输出必然换行结尾,顺带修掉了末行无换行的问题):
##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)。
8.1 生成脚本 #
按照附录 A:完整脚本的内容保存为 `tailscale-derp-update.sh。
8.2 上传脚本 #
Windows主机向 OpenWrt 传文件:使用 cmd 的原生文件重定向,文件句柄直接交给 ssh,字节级透传:
cmd /c 'ssh root@192.168.x.x "cat > /usr/bin/tailscale-derp-update.sh" < "C:\path\to\tailscale-derp-update.sh"'8.3 校验文件一致性 #
这一步不能省,务必确认文件完好:
ssh root@192.168.x.x "chmod +x /usr/bin/tailscale-derp-update.sh && md5sum /usr/bin/tailscale-derp-update.sh && wc -c /usr/bin/tailscale-derp-update.sh && sh -n /usr/bin/tailscale-derp-update.sh && echo TRANSFER_OK"成功时输出 TRANSFER_OK,且应为:
af996f170aae7f30f2fdea0b9cea5885 /usr/bin/tailscale-derp-update.sh
12938 /usr/bin/tailscale-derp-update.shmd5 对不上说明文件在传输中被损坏,重做 8.2或者采用其他方法上传。
8.4 试运行 #
dry-run 只抓取解析,不写任何文件:
ssh root@192.168.x.x "/usr/bin/tailscale-derp-update.sh -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 "/usr/bin/tailscale-derp-update.sh"脚本完成:写入规则块 → 备份原文件 → 重启 OpenClash 生效,输出 已更新自定义规则文件 ...(88 条,备份于 ...bak) 即成功。
8.6 验证规则生效 #
ssh root@192.168.x.x "grep -n 'BEGIN tailscale-derp' /etc/openclash/custom/openclash_custom_rules.list; grep -c '^- IP-CIDR,' /etc/openclash/custom/openclash_custom_rules.list"
ssh root@192.168.x.x "tailscale netcheck"8.7 配置定时自动更新 #
每日凌晨4:10静默执行(-q 仅写系统日志):
ssh root@192.168.x.x "echo '10 4 * * * /usr/bin/tailscale-derp-update.sh -q' >> /etc/crontabs/root && /etc/init.d/cron restart && echo CRON_OK"得益于幂等设计,IP 列表无变化时不写文件、不重启 OpenClash。
8.8 防止刷机丢失 #
OpenWrt 的 sysupgrade 默认不保留 /usr/bin 下自定义文件,加入保留清单:
ssh root@192.168.x.x "echo '/usr/bin/tailscale-derp-update.sh' >> /etc/sysupgrade.conf && echo OK"规则块本身位于 /etc/openclash/custom/,属 sysupgrade 保留区,重启与升级均不丢失;只有脚本本体需要上述保护。
九、达成效果 #
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/ 目录。
#!/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="https://controlplane.tailscale.com/derpmap/default https://login.tailscale.com/derpmap/default"
OUTPUT_MODE="custom" # custom | provider
CUSTOM_RULES="/etc/openclash/custom/openclash_custom_rules.list"
PROVIDER_DIR="/etc/openclash/rule_provider"
PROVIDER_NAME="tailscale-derp"
PROVIDER_FILE="${PROVIDER_DIR}/${PROVIDER_NAME}.list"
MIN_ENTRIES=40 # 去重后条数低于此值视为数据异常,保留旧文件不动
CONNECT_TIMEOUT=10
MAX_TIME=25
RELOAD_MODE="restart" # restart=重启 OpenClash | api=调 mihomo API | none=不动作
# 注:custom 模式下规则在启动时烘焙进配置,必须靠重启生效
MIHOMO_PORT="9090" # 仅作兜底;实际端口会自动探测
MIHOMO_SECRET="" # 留空则自动从 UCI 探测
LOG_TAG="derp-update"
# 自定义规则模式下的块标记(## 是该文件既有的注释前缀,不会被当作规则)
MARK_BEGIN="##BEGIN tailscale-derp"
MARK_END="##END tailscale-derp"
# ---------------------------------------------------------------------------
DRY_RUN=0
QUIET=0
# 临时文件(BusyBox 可能没有 mktemp,做兜底)
TMPDIR_D="${TMPDIR:-/tmp}"
PID_D=$$
TMP_IPS="${TMPDIR_D}/derp-ips.${PID_D}"
TMP_BODY="${TMPDIR_D}/derp-body.${PID_D}"
TMP_STRIP="${TMPDIR_D}/derp-strip.${PID_D}"
TMP_OLD="${TMPDIR_D}/derp-old.${PID_D}"
cleanup() { rm -f "$TMP_IPS" "$TMP_BODY" "$TMP_STRIP" "$TMP_OLD"; }
trap 'cleanup' EXIT INT TERM
say() { [ "$QUIET" -eq 1 ] || echo "[$(date '+%H:%M:%S')] $*"; }
log() { say "$*"; command -v logger >/dev/null 2>&1 && logger -t "$LOG_TAG" "$*"; return 0; }
# 统计文件中的有效行数(grep 无匹配时返回 1,故补 true)
count_lines() { grep -c '[0-9]' "$1" 2>/dev/null || true; }
usage() {
echo "用法: $0 [-n] [-f] [-q]"
echo " -n dry-run,只打印不写文件"
echo " -f 写文件后强制重启 OpenClash"
echo " -q 静默,仅写 syslog"
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
-n|--dry-run) DRY_RUN=1 ;;
-f|--force) RELOAD_MODE="restart" ;;
-q|--quiet) QUIET=1 ;;
-h|--help) usage ;;
*) echo "未知参数: $1"; usage ;;
esac
shift
done
# ------------------------------ 抓取单个源 ------------------------------
# 返回码 0=成功 127=系统无下载工具 其它=该源失败
fetch_one() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" "$url" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q -O - -T "$MAX_TIME" "$url" 2>/dev/null
elif command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -q -O - -T "$MAX_TIME" "$url" 2>/dev/null
else
return 127
fi
}
# ------------------------------ 提取 IPv4 ------------------------------
# 只使用 BusyBox 稳支持的 grep -o + BRE + cut,不用 -E
extract_ips() {
if command -v jq >/dev/null 2>&1; then
jq -r '.Regions[].Nodes[].IPv4 // empty' 2>/dev/null
else
grep -o '"IPv4":"[0-9.]*"' | cut -d'"' -f4
fi
}
# derpmap 的 IPv4 字段不带掩码,统一补 /32
add_prefix() {
awk '{ if ($0 ~ /\//) print $0; else print $0 "/32" }'
}
# 过滤掉明显非法的条目
validate() {
awk '
/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/ {
ip = $0; sub(/\/.*/, "", ip)
n = split(ip, a, ".")
ok = 1
for (i = 1; i <= n; i++) if (a[i] + 0 > 255) ok = 0
if (n == 4 && ok) print
}'
}
# ------------------------------ 探测 mihomo 控制端口 ------------------------------
detect_port() {
p=$(uci -q get openclash.config.dashboard_port 2>/dev/null)
if [ -n "$p" ]; then echo "$p"; return 0; fi
for f in /etc/openclash/config.yaml /etc/openclash/*.yaml; do
[ -f "$f" ] || continue
p=$(sed -n 's/.*external-controller:.*:\([0-9]\{1,5\}\).*/\1/p' "$f" 2>/dev/null | head -1)
if [ -n "$p" ]; then echo "$p"; return 0; fi
done
echo "$MIHOMO_PORT"
}
# ------------------------------ 探测 Dashboard 密钥 ------------------------------
detect_secret() {
if [ -n "$MIHOMO_SECRET" ]; then echo "$MIHOMO_SECRET"; return 0; fi
for k in dashboard_password da_password ui_password; do
s=$(uci -q get "openclash.config.${k}" 2>/dev/null)
if [ -n "$s" ]; then echo "$s"; return 0; fi
done
echo ""
}
# ------------------------------ 触发 OpenClash 重载 ------------------------------
reload_openclash() {
# custom 模式下规则在启动时烘焙进配置,API 热重载不适用
if [ "$OUTPUT_MODE" = "custom" ] && [ "$RELOAD_MODE" = "api" ]; then
say "custom 模式下 API 热重载不适用,改为重启 OpenClash"
RELOAD_MODE="restart"
fi
case "$RELOAD_MODE" in
none)
log "RELOAD_MODE=none:等待下次 OpenClash 重启时生效"
return 0
;;
restart)
log "重启 OpenClash 使规则生效 ..."
if /etc/init.d/openclash restart >/dev/null 2>&1; then
log "OpenClash 已重启"
else
log "警告: OpenClash 重启命令返回非零,请手动确认"
fi
return 0
;;
api) ;;
esac
if ! command -v curl >/dev/null 2>&1; then
log "无 curl,跳过 API 热重载"
return 0
fi
port=$(detect_port)
secret=$(detect_secret)
url="http://127.0.0.1:${port}/providers/rules/${PROVIDER_NAME}"
say "调用 API ${url}"
if [ -n "$secret" ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 10 \
-H "Authorization: Bearer ${secret}" -X PUT "$url" 2>/dev/null)
else
code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 10 \
-X PUT "$url" 2>/dev/null)
fi
case "${code:-000}" in
200|204) log "已通过 API 热重载规则集 ${PROVIDER_NAME} (HTTP ${code})" ;;
000) log "API 不可达(端口 ${port}),规则集将在 mihomo 按 interval 重载或下次重启时生效" ;;
404) log "API 返回 404:规则集 ${PROVIDER_NAME} 尚未注册,请改用 OUTPUT_MODE=custom 或在 OpenClash 里注册 rule provider" ;;
*) log "API 返回 HTTP ${code},未确认生效;必要时改用 -f 重启 OpenClash" ;;
esac
}
# ------------------------------ 构造输出内容 ------------------------------
# 参数: $1=去重后的 CIDR 文件 $2=总条数 $3=合并前条数 $4=输出目标
build_output() {
body="$1"; total="$2"; raw_total="$3"; out="$4"
if [ "$OUTPUT_MODE" = "custom" ]; then
# 目标不存在则先建(首行必须是 rules:)
if [ ! -f "$CUSTOM_RULES" ]; then
mkdir -p "$(dirname "$CUSTOM_RULES")" || return 1
echo "rules:" > "$CUSTOM_RULES" || return 1
fi
# 剥离上一次写入的块;awk 输出必然以换行结尾,
# 顺带解决原文件末行可能没有换行符的问题
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
index($0, b) == 1 { skip = 1; next }
index($0, e) == 1 { skip = 0; next }
skip { next }
{ print }
' "$CUSTOM_RULES" > "$TMP_STRIP" || return 1
{
cat "$TMP_STRIP"
echo "${MARK_BEGIN} ↓ ↓ ↓ 以下由 tailscale-derp-update.sh 自动维护,勿手工编辑 ↓ ↓ ↓"
echo "##updated: $(date '+%Y-%m-%d %H:%M:%S') count: ${total}"
awk '{ print "- IP-CIDR," $0 ",DIRECT" }' "$body"
echo "$MARK_END"
} > "$out" || return 1
else
{
echo "# Tailscale DERP 节点 IPv4 直连规则集 - 由 tailscale-derp-update.sh 自动生成,请勿手工编辑"
echo "# updated: $(date '+%Y-%m-%d %H:%M:%S')"
echo "# sources: controlplane.tailscale.com/derpmap/default + login.tailscale.com/derpmap/default"
echo "# count : ${total}(合并前 ${raw_total},已去重)"
cat "$body"
} > "$out" || return 1
fi
return 0
}
# ------------------------------ 主流程 ------------------------------
main() {
: > "$TMP_IPS" || { log "错误: 无法创建临时文件 ${TMP_IPS}"; exit 1; }
ok_sources=0
failed_sources=0
for url in $DERPMAP_URLS; do
say "拉取 ${url}"
json=$(fetch_one "$url")
rc=$?
if [ "$rc" -eq 127 ]; then
log "错误: 系统里没有 curl / wget / uclient-fetch,无法抓取"
exit 1
fi
if [ "$rc" -ne 0 ] || [ -z "$json" ]; then
say " 该源无响应,跳过"
failed_sources=$((failed_sources + 1))
continue
fi
before=$(count_lines "$TMP_IPS")
printf '%s' "$json" | extract_ips | add_prefix | validate >> "$TMP_IPS"
after=$(count_lines "$TMP_IPS")
n=$((after - before))
if [ "$n" -le 0 ]; then
say " 该源未解析到任何 IPv4,跳过"
failed_sources=$((failed_sources + 1))
continue
fi
say " 解析到 ${n} 条"
ok_sources=$((ok_sources + 1))
done
if [ "$ok_sources" -eq 0 ]; then
log "错误: 全部 derpmap 源均不可用,保持原文件不变"
exit 1
fi
raw_total=$(count_lines "$TMP_IPS")
grep '[0-9]' "$TMP_IPS" | awk '!seen[$0]++' | \
sort -t. -k1,1n -k2,2n -k3,3n -k4,4n > "$TMP_BODY" || exit 1
total=$(count_lines "$TMP_BODY")
say "源可用 ${ok_sources} 个 / 失败 ${failed_sources} 个;合并前 ${raw_total} 条,去重后 ${total} 条"
if [ "$total" -lt "$MIN_ENTRIES" ]; then
log "错误: 去重后仅 ${total} 条(阈值 ${MIN_ENTRIES}),判定为数据异常,保留原文件"
exit 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "# ---- dry-run 输出(未写文件)----"
if [ "$OUTPUT_MODE" = "custom" ]; then
awk '{ print "- IP-CIDR," $0 ",DIRECT" }' "$TMP_BODY"
else
cat "$TMP_BODY"
fi
echo "# ---- 合计 ${total} 条 ----"
exit 0
fi
# custom 模式:先比对"实际规则内容"是否与文件里已有的一致。
# 不能整体 cmp —— 块里的 updated 时间戳每次都变,否则会天天无谓重启。
if [ "$OUTPUT_MODE" = "custom" ] && [ -f "$CUSTOM_RULES" ]; then
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
index($0, b) == 1 { inside = 1; next }
index($0, e) == 1 { inside = 0; next }
inside && /^- IP-CIDR,/ { print }
' "$CUSTOM_RULES" | sed 's/^- IP-CIDR,//; s/,DIRECT$//' > "$TMP_OLD"
if cmp -s "$TMP_OLD" "$TMP_BODY"; then
log "DERP 节点列表无变化(${total} 条),不写文件、不重启 OpenClash"
exit 0
fi
fi
if [ "$OUTPUT_MODE" = "custom" ]; then
target="$CUSTOM_RULES"
desc="自定义规则文件"
else
mkdir -p "$PROVIDER_DIR" || { log "错误: 无法创建目录 ${PROVIDER_DIR}"; exit 1; }
target="$PROVIDER_FILE"
desc="规则集文件"
fi
tmp="${target}.derptmp"
build_output "$TMP_BODY" "$total" "$raw_total" "$tmp" || {
rm -f "$tmp"; log "错误: 构造${desc}内容失败"; exit 1
}
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
rm -f "$tmp"
log "内容无变化(${total} 条),不做任何改动"
exit 0
fi
[ -f "$target" ] && cp "$target" "${target}.bak"
mv "$tmp" "$target" || { log "错误: 写入 ${target} 失败"; exit 1; }
log "已更新${desc} ${target}(${total} 条,备份于 ${target}.bak)"
reload_openclash
exit 0
}
main "$@"附录 B:常用验证命令 #
在软路由上或 ssh 软路由执行:
# 自检:关注 UDP / IPv4 / MappingVariesByDestIP 三项
tailscale netcheck
# 查看连接状态:关注是否 direct
tailscale status
# 测试与远端节点的连通路径
tailscale ping <远端 Tailscale IP>
# 确认规则块位置与条数
grep -n 'BEGIN tailscale-derp' /etc/openclash/custom/openclash_custom_rules.list
grep -c '^- IP-CIDR,' /etc/openclash/custom/openclash_custom_rules.list
# 确认 OpenClash 日志中已无 STUN/DERP 代理条目
logread -e openclash | grep -E '3478|:80' | tail -5