Files
vpsetup/setup_cdn_bypass_tproxy.sh
eli ca5d8ee84a refactor: CDN 清单生成器移交 gc-toolkit/cdn-list
这份清单存在的唯一原因是 GCP 把 CDN 出站单独计价(Carrier Peering,$0.08/GiB 从
第一字节起算、无 200 GiB 免费额度),而消费它的 VPC 拒绝规则 cdn-egress-deny 本身
也是 GCP 资源。所以生成器归 gc-toolkit 更合理。

- 删掉本仓库的 tools/refresh-cdn-lists.sh(功能已合并进 gc-toolkit,避免两份分叉)
- 脚本头部与 docs/cdn-list-audit.md 改成指路 gc-toolkit
- 清单内容本次无变化:v4 271 条 / v6 193 条,与 gc-toolkit 生成的 canonical 文件一致

迁移后 gc-toolkit 那边多了两件事:生成 canonical 的 cdn-cidrs-v4/v6.txt,
以及 --vpsetup 参数(一次运行同时同步本仓库的内嵌清单与 v6 文件)。
2026-11-08 13:12:01 +08:00

999 lines
40 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# setup_cdn_bypass_tproxy.sh — CDN 绕行透明代理(为 Google Cloud 等对 Cloudflare/Fastly/Akamai
# 出口被封锁或计费的 VPS 设计)
#
# 原理:本机发往 CDN 网段的流量在 nft mangle OUTPUT 被打 mark,策略路由把它送进 lo,
# 由 xray 的 dokodemo-door (tproxy) 入站接管,经一个"不限流"上游节点出站。
#
# 脚本只做四件事(均为幂等、可重复执行):
# 1. 解析粘贴的 vless:// 节点链接 → 生成【独立完整】的 xray 配置 cdn-bypass.json,
# 并用专用 systemd unit(xray-cdn-bypass.service)作为【第二个 xray 进程】运行它。
# —— 不再给主 xray.service 加 -confdir:xray 的 confdir 合并语义有两个坑
# (① 片段的 outbounds 会被【前插】成默认出站,普通客户端流量被劫走;
# ② 片段里的 routing 整体【替换】主配置的 routing,所有路由规则与 domainStrategy 被抹掉),
# 单进程 -confdir 无法表达“只把这一个入站路由到新出站”。
# 独立实例与主实例互不影响,**不改动用户原有 config.json 一个字节,也不改主 unit**
# 2. 生成 /etc/nftables/cdn-bypass.nft(打标表 + tproxy 表),并向 nftables.conf 追加 include
# —— 与 setup_caddy_xray_hysteria.sh 同一约定:运行时只 nft -f 自己的文件,
# 绝不执行整份 nftables.conf(避免其中的 flush ruleset 清掉主机防火墙)
# 3. 安装 cdn-bypass-route.service(oneshot):开机补 ip rule/ip route 策略路由
# —— ip 命令是纯内核运行时状态,必须由 service 持久化
# 4. 全部装完后实测 https://www.cloudflare.com/cdn-cgi/trace 验证绕行生效
#
# 防回环:注入的 vless 出站带 sockopt.mark=255,nft 第一条规则豁免 mark 255 ——
# 即使上游节点 IP 落在 CDN 段内也不会被重新劫回 xray 自身,因此 nft 里无需再豁免上游 IP。
#
# 端口策略:nft 只对 TCP/UDP 53,80,443 打标,其他端口直接直连不抓(省上游流量)。
# xray 侧无需再过滤 —— nft 已按端口预筛。tproxy 不到的包永远不会出现在 dokodemo。
# 如需全端口代理,在 nft OUTPUT 链把那条 `"ip daddr @cdn_ranges meta l4proto ... mark set"` 的端口语删掉。
#
# 客户端入站流量同样受益:客户端 → 本机主 xray 入站 → direct 出站(本机发出)→ 一样过
# nft OUTPUT 被筛选打标 → tproxy → bypass 实例 → 上游。即客户端访问 CDN 域名会自动走绕行,
# 无需在主 config.json 里加任何 routing 规则。前提:主配置的 direct 出站【不得】带 sockopt.mark=255
# (setup_caddy_xray_hysteria.sh 模板不带;若你手写的 direct 带了 255,客户端 CDN 流量会
# 被豁免直连——在 GCP 这类机器上就是"本机 curl 通、客户端不通"的典型症状)。
#
# 多上游 + 健康检查:支持 N 个上游节点(VLESS_LINKS,换行/空白分隔)。出站 tag 依次为
# ${UPSTREAM_TAG_PREFIX}1、2、…,xray 用 balancer cdn-pool(random 策略,selector=前缀)在它们之间分流,
# 并由 observatory 每 30s 探测 https://www.gstatic.com/generate_204,只在健康节点间随机。
# 注意 fallbackTag 必须非空(固定为第 1 个节点):Xray 的 random/roundRobin 策略只有在 fallbackTag
# 非空时才会去查 observatory,否则会盲目轮转(见 render_bypass_config 注释)。
#
# IPv6:同时生成镜像的 `table ip6`(cdn_bypass_mark / cdn_bypass_tproxy),v6 CDN 段取自数据文件
# cdn-cidrs-v6.txt(CDN_CIDRS_V6_FILE 可改路径),bypass 实例加监听 [::1],route unit 补 v6 策略路由。
# 本机没有/禁用 IPv6 时 ENABLE_IPV6=auto 自动退化为只做 IPv4。
#
# 用法:
# sudo VLESS_LINKS='vless://uuid1@host1:443?security=reality&pbk=...&sid=...&sni=...&fp=chrome&flow=xtls-rprx-vision#n1
# vless://uuid2@host2:443?...#n2' \
# bash setup_cdn_bypass_tproxy.sh
# 向后兼容:只设旧变量 VLESS_LINK(单条)等价于只有一个元素的 VLESS_LINKS
# 两者都不设时会从 /dev/tty 交互读取(每行一条,空行结束;wget|bash 管道场景也能粘贴)
# sudo ACTION=uninstall bash setup_cdn_bypass_tproxy.sh # 全部卸载
# DRY_RUN=1 bash setup_cdn_bypass_tproxy.sh # 只渲染到 ./dry-run-output/,不动系统
#
# 可选环境变量:TPROXY_PORT(54443) ROUTE_TABLE(100) MARK(1) XRAY_SERVICE(xray/v2ray,默认自动探测)
# UPSTREAM_TAG_PREFIX(oracle-,上游出站 tag 前缀)
# ENABLE_IPV6(auto|1|0) CDN_CIDRS_V6_FILE(默认脚本同目录 cdn-cidrs-v6.txt)
# BYPASS_SERVICE(xray-cdn-bypass,独立实例的 unit 名)
# EXTRA_EXEMPT_CIDRS("8.8.8.8 119.29.29.29" 这类额外不打标的段,空格分隔)
#
# 已知边界:v6 CDN 段目前只收 Cloudflare / Fastly(官方公布);Akamai 的 v6 段尚未收录(见
# cdn-cidrs-v6.txt 里的 TODO),所以 Akamai 的 IPv6 流量暂不经过本代理。
set -euo pipefail
# ---------- 可配项 ----------
ACTION="${ACTION:-install}"
TPROXY_PORT="${TPROXY_PORT:-54443}"
ROUTE_TABLE="${ROUTE_TABLE:-100}"
MARK="${MARK:-1}"
XRAY_SERVICE="${XRAY_SERVICE:-}"
BYPASS_SERVICE="${BYPASS_SERVICE:-xray-cdn-bypass}"
UPSTREAM_TAG_PREFIX="${UPSTREAM_TAG_PREFIX:-oracle-}"
BALANCER_TAG="cdn-pool"
ENABLE_IPV6="${ENABLE_IPV6:-auto}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CDN_CIDRS_V6_FILE="${CDN_CIDRS_V6_FILE:-$SCRIPT_DIR/cdn-cidrs-v6.txt}"
XRAY_CONFDIR="${XRAY_CONFDIR:-/etc/xray-cdn-bypass}"
NFT_DIR="${NFT_DIR:-/etc/nftables}"
NFT_FILE="${NFT_FILE:-$NFT_DIR/cdn-bypass.nft}"
NFTABLES_CONF="${NFTABLES_CONF:-/etc/nftables.conf}"
ROUTE_UNIT="${ROUTE_UNIT:-cdn-bypass-route.service}"
NETWORKD_DROPIN="${NETWORKD_DROPIN:-/etc/systemd/networkd.conf.d/90-cdn-bypass.conf}"
EXTRA_EXEMPT_CIDRS="${EXTRA_EXEMPT_CIDRS:-}"
DRY_RUN="${DRY_RUN:-}"
# ---------- 输出 ----------
info() { printf '\033[32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
[ -n "$DRY_RUN" ] && { DRY_RUN_DIR="./dry-run-output"; mkdir -p "$DRY_RUN_DIR"; info "DRY_RUN:只渲染到 $DRY_RUN_DIR"; }
[ -z "$DRY_RUN" ] && [ "$(id -u)" -ne 0 ] && die "需要 root 运行"
# tag 前缀会被原样写进 JSON 和 balancer selector:只允许安全字符
[[ "$UPSTREAM_TAG_PREFIX" =~ ^[A-Za-z0-9_.-]+$ ]] || die "UPSTREAM_TAG_PREFIX 只能含字母数字 _ . -,收到:$UPSTREAM_TAG_PREFIX"
# 解析 ENABLE_IPV6:auto = DRY_RUN 恒为 1(便于渲染/测试);真实安装时看本机 lo 上 IPv6 是否启用
resolve_ipv6() {
case "$ENABLE_IPV6" in
1|0) ;;
auto)
if [ -n "$DRY_RUN" ]; then ENABLE_IPV6=1
elif [ "$(cat /proc/sys/net/ipv6/conf/lo/disable_ipv6 2>/dev/null || echo 1)" = 0 ]; then ENABLE_IPV6=1
else ENABLE_IPV6=0; warn "本机 IPv6 未启用,ENABLE_IPV6=auto → 0(只处理 IPv4)"; fi;;
*) die "ENABLE_IPV6 只支持 auto/1/0";;
esac
}
# ---------- CDN 网段清单(269 条;抓取日期见 CDN_LIST_AUDIT_DATE) ----------
#
# 【怎么更新这份清单】—— 不要手动改,用 gc-toolkit 的 cdn-list 子工具:
# cd ../gc-toolkit
# ./cdn-list/refresh.sh --apply --vpsetup ../vpsetup
# 它会拉三个上游、自己 collapse、交叉校验(把 Akamai 原始 9462 行自己合一遍,
# 与仓库给的合并版逐条比对,不一致就拒绝落盘),然后同步这里的清单体和 CDN_LIST_AUDIT_DATE。
# 不放心可以先不加 --apply 只报告差异。
#
# 为什么生成器在 gc-toolkit 而不在这里:这份清单存在的唯一原因是 GCP 的 CDN 计费科目,
# 消费它的 VPC 拒绝规则(cdn-egress-deny)本身就是 GCP 资源。
#
# 三个上游:Cloudflare ips-v4 / Fastly public-ip-list / Akamai
# (SecOps-Institute/Akamai-ASN-and-IPs-List,按 40 个 ASN 拉 BGP,每小时更新)。
# 注意:仓库的 akamai_ip_list.lst 是【按注册原样】的 9462 行、互相重叠,
# 不要拿它自己合——同一仓库的 akamai_ip_cidr_blocks.lst 就是它的正确合并结果。
#
# 【依据是什么】GCP 判定"是否 CDN"看的是目的地属于哪个网络(CDN Interconnect 对已接入的
# CDN 厂商自动生效),不公开 IP 清单;Akamai 也不提供公开的边缘网段清单(官方 TechDocs 的
# CIDR 列表是按客户合同定制的,官方 GitHub 207 个仓库里也没有网段仓库)。所以业界唯一可行的
# 依据就是按 ASN 查 BGP 宣告(AS20940/AS16625 等),上面那个仓库做的就是这件事,每小时更新。
# 它【不是官方承诺】,会随时间漂移,所以必须定期刷新——2026-11-08 复核时发现旧快照已过时
# 1~2 年,有 20 万地址 Akamai 已不再宣告、1.3 万新地址漏绕。
#
# 【Linode】ASN 清单里的 AS63949 是 Akamai Connected Cloud(Linode)的客户虚拟机,严格说不是
# CDN 边缘。2026-11-08 评估过精确挖掉:368 → 381 条(减法切出更多碎片),且需同步改 GCP 拒
# 规则否则那些站点会被 VPC 直接丢包——净负收益,故保留。若将来 Linode 误绕不可接受再处理。
#
# 【与 GCP 侧的关系】VPC 的 egress 拒绝规则(cf-no-cdn / fastly-no-cdn / no-akamai-0 /
# no-akamai-1,新项目里是 cdn-egress-deny)必须用同一份网段。不一致会导致"漏绕(按 CDN 计费)"
# 或"误丢包(拒绝规则挡住本应经代理绕出去的流量)"。
#
# 免在线获取:被封锁的机器在代理起来之前根本访问不了 cloudflare.com,所以必须内嵌。
CDN_LIST_AUDIT_DATE=2026-11-08
read -r -d '' CDN_RANGES <<'RANGES_EOF' || true
2.16.0.0/13
23.0.0.0/12
23.32.0.0/11
23.64.0.0/14
23.72.0.0/13
23.92.16.0/20
23.192.0.0/11
23.235.32.0/20
23.239.0.0/19
43.249.72.0/22
43.254.120.0/22
45.33.0.0/17
45.56.64.0/18
45.79.0.0/18
45.79.64.0/19
45.79.96.0/20
45.79.112.0/22
45.79.116.0/23
45.79.118.0/24
45.79.120.0/21
45.79.128.0/18
45.79.192.0/19
45.79.224.0/21
45.79.234.0/24
45.79.236.0/22
45.79.240.0/20
45.118.132.0/22
49.231.112.0/24
49.231.116.0/23
50.116.0.0/18
59.151.128.0/18
60.254.128.0/18
61.19.12.0/24
64.86.217.0/24
64.89.224.0/20
66.175.208.0/20
66.198.8.0/24
66.228.32.0/19
66.246.75.0/24
69.22.150.0/23
69.31.112.0/23
69.31.118.0/24
69.31.122.0/24
69.164.192.0/19
69.192.0.0/16
72.14.176.0/20
72.200.254.0/23
72.246.0.0/15
74.121.124.0/22
74.207.224.0/19
80.67.64.0/19
80.85.84.0/22
81.200.66.0/23
81.200.69.0/24
84.53.128.0/18
85.90.244.0/22
85.159.208.0/21
88.80.184.0/21
88.221.0.0/16
90.98.0.0/17
90.98.128.0/18
92.122.0.0/15
93.191.168.0/24
93.191.170.0/24
95.100.0.0/15
96.6.0.0/15
96.16.0.0/15
96.126.96.0/19
97.107.128.0/20
103.3.60.0/22
103.21.244.0/22
103.22.200.0/22
103.29.68.0/22
103.31.4.0/22
103.95.84.0/22
103.238.148.0/22
103.244.50.0/24
103.245.222.0/23
103.245.224.0/24
104.16.0.0/13
104.24.0.0/14
104.64.0.0/10
104.156.80.0/20
104.200.16.0/20
104.237.128.0/19
108.162.192.0/18
109.74.192.0/20
109.207.109.0/24
109.230.116.0/22
109.230.127.0/24
109.237.24.0/22
115.69.232.0/22
118.214.0.0/16
118.215.0.0/17
118.215.128.0/18
122.2.208.0/24
122.252.32.0/19
122.252.128.0/20
124.40.52.208/28
124.106.8.0/24
124.106.175.0/24
125.56.191.0/24
125.56.192.0/18
125.252.192.0/18
131.0.72.0/22
139.144.0.0/18
139.144.64.0/20
139.144.96.0/19
139.144.128.0/21
139.144.144.0/20
139.144.160.0/19
139.144.192.0/18
139.162.0.0/16
139.177.176.0/20
139.177.192.0/20
140.248.64.0/18
140.248.128.0/17
141.101.64.0/18
143.42.0.0/18
143.42.64.0/20
143.42.96.0/19
143.42.128.0/17
146.75.0.0/17
151.101.0.0/16
151.236.216.0/21
157.52.64.0/18
162.158.0.0/15
162.216.16.0/22
165.254.0.0/24
165.254.2.0/24
165.254.26.128/26
165.254.40.0/23
165.254.44.0/23
165.254.52.0/24
165.254.58.0/25
165.254.81.0/24
165.254.110.0/23
165.254.119.128/25
165.254.123.0/24
165.254.136.0/24
165.254.142.0/24
165.254.145.64/26
165.254.156.0/23
165.254.203.192/26
165.254.237.128/25
165.254.238.128/25
165.254.247.128/25
167.82.0.0/17
167.82.128.0/20
167.82.160.0/20
167.82.224.0/20
168.143.242.0/23
168.143.254.0/23
170.187.128.0/23
170.187.131.0/24
170.187.132.0/24
170.187.134.0/23
170.187.136.0/21
170.187.144.0/20
170.187.160.0/19
170.187.192.0/18
172.64.0.0/13
172.104.4.0/22
172.104.8.0/21
172.104.16.0/20
172.104.32.0/19
172.104.64.0/18
172.104.128.0/18
172.104.192.0/21
172.104.202.0/23
172.104.205.0/24
172.104.206.0/23
172.104.208.0/20
172.104.224.0/19
172.105.0.0/18
172.105.64.0/19
172.105.112.0/20
172.105.128.0/21
172.105.136.0/22
172.105.140.0/23
172.105.146.0/23
172.105.148.0/22
172.105.152.0/21
172.105.160.0/19
172.105.192.0/18
172.111.64.0/18
172.224.0.0/12
173.205.69.0/24
173.205.76.0/23
173.222.0.0/15
173.230.128.0/19
173.245.48.0/20
173.255.192.0/18
176.58.96.0/19
178.79.128.0/18
184.24.0.0/13
184.50.0.0/15
184.84.0.0/14
185.3.92.0/22
185.31.16.0/22
185.225.250.0/24
187.210.208.0/24
188.114.96.0/20
189.247.167.0/24
189.247.204.0/22
189.247.210.0/24
189.247.212.0/24
189.247.214.0/24
189.247.216.0/24
189.247.220.0/24
190.93.240.0/20
190.94.188.0/24
190.98.153.0/24
190.98.160.0/24
192.46.208.0/20
192.46.224.0/20
192.53.112.0/20
192.53.160.0/20
192.80.13.0/24
192.81.128.0/21
192.155.80.0/20
192.204.5.0/24
193.108.88.0/21
193.108.152.0/22
194.195.112.0/20
194.195.208.0/20
194.195.240.0/20
194.233.160.0/20
195.245.124.0/22
197.234.240.0/22
198.41.128.0/17
198.58.96.0/19
198.74.48.0/20
198.87.182.0/25
198.173.160.0/23
199.27.72.0/21
199.101.28.0/22
199.119.220.0/22
199.232.0.0/16
200.60.190.0/24
202.138.164.0/22
202.138.183.0/24
203.69.138.0/24
203.69.141.0/24
203.74.95.0/24
203.144.1.0/24
203.198.20.0/24
204.1.141.0/24
204.2.139.0/24
204.2.145.0/24
204.2.158.0/23
204.2.162.0/24
204.2.164.0/23
204.2.196.0/24
204.8.48.0/22
204.10.28.0/22
204.93.48.0/24
204.201.160.0/24
204.237.186.0/23
204.237.188.0/24
210.61.248.0/23
212.71.232.0/21
212.71.244.0/22
212.71.248.0/21
212.111.40.0/22
213.52.128.0/23
213.52.130.0/24
213.168.248.0/22
213.219.36.0/22
218.231.251.0/24
219.76.11.0/24
RANGES_EOF
# ---------- 解析 vless:// 链接(多节点:结果写入按下标的数组,各节点互相独立) ----------
# 用法:parse_vless_link <link> <下标>。每个节点各自拥有 uuid/host/reality 公钥等,
# 不假设多条链接共享凭据。
V_UUID=(); V_HOST=(); V_PORT=(); V_SECURITY=(); V_PBK=(); V_SID=(); V_SNI=()
V_FP=(); V_FLOW=(); V_NETWORK=(); V_PATH=(); V_HOSTHDR=()
parse_vless_link() {
local link="$1" idx="$2"
[[ "$link" == vless://* ]] || die "只支持 vless:// 链接(reality/tls/tcp/ws),收到:${link:0:20}..."
link="${link#vless://}"
V_UUID[idx]="${link%%@*}"; link="${link#*@}"
local hostport="${link%%\?*}"
hostport="${hostport%%#*}" # 无 query 时 #name 片段会粘在 hostport 后面
V_HOST[idx]="${hostport%%:*}"
V_PORT[idx]="${hostport##*:}"
local query=""
[[ "$link" == *\?* ]] && { query="${link#*\?}"; query="${query%%#*}"; }
V_SECURITY[idx]=""; V_PBK[idx]=""; V_SID[idx]=""; V_SNI[idx]=""; V_FP[idx]="chrome"
V_FLOW[idx]=""; V_NETWORK[idx]="tcp"; V_PATH[idx]=""; V_HOSTHDR[idx]=""
local kv k v
IFS='&' read -ra _pairs <<< "$query"
for kv in "${_pairs[@]}"; do
k="${kv%%=*}"; v="${kv#*=}"
v="${v//%2F//}"; v="${v//%3A/:}"; v="${v//%40/@}"; v="${v//%23/#}" # 常见 URL 转义
case "$k" in
security) V_SECURITY[idx]="$v";;
pbk|publicKey) V_PBK[idx]="$v";;
sid|shortId) V_SID[idx]="$v";;
sni) V_SNI[idx]="$v";;
fp) V_FP[idx]="$v";;
flow) V_FLOW[idx]="$v";;
type) V_NETWORK[idx]="$v";;
path) V_PATH[idx]="$v";;
host) V_HOSTHDR[idx]="$v";;
esac
done
if [ -z "${V_UUID[idx]}" ] || [ -z "${V_HOST[idx]}" ] || [ -z "${V_PORT[idx]}" ]; then
die "链接 #$((idx + 1)) 解析失败(缺 uuid/host/port)"
fi
if [ "${V_SECURITY[idx]}" = "reality" ] && [ -z "${V_PBK[idx]}" ]; then
die "链接 #$((idx + 1)) 是 reality 但缺 pbk(publicKey)参数"
fi
[ -z "${V_SNI[idx]}" ] && V_SNI[idx]="${V_HOST[idx]}"
info "节点 ${UPSTREAM_TAG_PREFIX}$((idx + 1)):${V_HOST[idx]}:${V_PORT[idx]} security=${V_SECURITY[idx]:-none} network=${V_NETWORK[idx]} flow=${V_FLOW[idx]:-无}"
}
# 收集链接:VLESS_LINKS(换行/空白分隔,多条)优先;未设则回退旧变量 VLESS_LINK(单条);
# 都没有则从 /dev/tty 交互读取(每行一条,空行结束)。结果放进数组 LINKS。
LINKS=()
collect_links() {
local raw="${VLESS_LINKS:-}"
[ -n "$raw" ] || raw="${VLESS_LINK:-}"
if [ -z "$raw" ] && { : < /dev/tty; } 2>/dev/null; then
printf '请粘贴 vless:// 节点链接(可多条,每行一条,空行结束):\n' > /dev/tty
local line
while IFS= read -r line < /dev/tty; do
[ -n "$line" ] || break
raw+="$line"$'\n'
done
fi
# 按任意空白拆成一行一条;不用 word-splitting,避免链接里的 ? * 被 glob 展开
mapfile -t LINKS < <(printf '%s\n' "$raw" | tr -s '[:space:]' '\n' | awk 'NF')
[ "${#LINKS[@]}" -ge 1 ] || die "至少需要 1 条节点链接(VLESS_LINKS / VLESS_LINK 环境变量,或交互粘贴)"
[ "${#LINKS[@]}" -ge 2 ] || warn "只有 1 个上游节点:balancer 合法但没有冗余(建议至少 2 条,健康检查才有节点可切换)"
}
# ---------- 生成单个上游 vless 出站(tag = ${UPSTREAM_TAG_PREFIX}<序号>) ----------
render_outbound() {
local i="$1" stream
case "${V_SECURITY[i]}" in
reality)
stream=$(cat <<EOS
"network": "${V_NETWORK[i]}",
"security": "reality",
"realitySettings": {
"serverName": "${V_SNI[i]}",
"fingerprint": "${V_FP[i]}",
"publicKey": "${V_PBK[i]}",
"shortId": "${V_SID[i]}"
},
"sockopt": { "mark": 255 }
EOS
);;
tls)
stream=$(cat <<EOS
"network": "${V_NETWORK[i]}",
"security": "tls",
"tlsSettings": { "serverName": "${V_SNI[i]}", "fingerprint": "${V_FP[i]}" },
"sockopt": { "mark": 255 }
EOS
);;
*)
stream=$(cat <<EOS
"network": "${V_NETWORK[i]}",
"sockopt": { "mark": 255 }
EOS
);;
esac
local flow_field=""
[ -n "${V_FLOW[i]}" ] && flow_field=",
\"flow\": \"${V_FLOW[i]}\""
local ws_field=""
if [ "${V_NETWORK[i]}" = "ws" ]; then
ws_field=",
\"wsSettings\": { \"path\": \"${V_PATH[i]:-/}\", \"headers\": { \"Host\": \"${V_HOSTHDR[i]:-${V_HOST[i]}}\" } }"
fi
cat <<EOS
{
"tag": "${UPSTREAM_TAG_PREFIX}$((i + 1))",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "${V_HOST[i]}",
"port": ${V_PORT[i]},
"users": [
{
"id": "${V_UUID[i]}",
"encryption": "none"$flow_field
}
]
}
]
},
"streamSettings": {
$stream$ws_field
}
}
EOS
}
# ---------- 生成 bypass 实例的【完整独立】xray 配置 ----------
# 由独立 unit 以 `xray run -config` 加载(不使用 -confdir,不与主配置合并)。
#
# balancer / observatory 的两条硬约束(均已对照 Xray 源码核实):
# * fallbackTag 必须非空(这里固定为第一个节点)。app/router/strategy_random.go 与 balancing.go 里
# RandomStrategy / RoundRobinStrategy 的 InjectContext 只在 `len(s.FallbackTag) > 0` 时才注入
# observatory;不设 fallbackTag 则策略【根本不查询健康状态】,会盲目轮转到已宕的节点。
# fallbackTag 同时是"全部节点都不健康"时的兜底出站。
# * selector 用【前缀】${UPSTREAM_TAG_PREFIX}:以后新增的节点只要 tag 以该前缀开头就自动入池;
# observatory.subjectSelector 用同一前缀,保证入池的节点都被探测。
render_bypass_config() {
local n="${#LINKS[@]}" i outbounds="" ob
for ((i = 0; i < n; i++)); do
ob="$(render_outbound "$i")"
outbounds+="${outbounds:+,
}$ob"
done
# 监听地址:127.0.0.1 与 ::1 各一个入站(仅回环,不暴露公网)。
# 2026-11 在 gcoregon(内核 6.8、xray 26.10.31、bindv6only=0)实测:单个 listen "::" 双栈也能同时
# 接住 v4/v6 的 TCP+UDP tproxy,但会把 tproxy 端口暴露到所有网卡;127.0.0.1 + ::1 两个入站同样
# 全部通过且只开回环,故选后者。只有 v4 入站时,v6 打标流量会被 RST(实测 connection refused)。
local inbound_v6="" intags='"cdn-bypass-in"'
if [ "$ENABLE_IPV6" = 1 ]; then
inbound_v6=",
{
\"tag\": \"cdn-bypass-in6\",
\"listen\": \"::1\",
\"port\": $TPROXY_PORT,
\"protocol\": \"dokodemo-door\",
\"settings\": { \"network\": \"tcp,udp\", \"followRedirect\": true },
\"streamSettings\": { \"sockopt\": { \"tproxy\": \"tproxy\" } }
}"
intags='"cdn-bypass-in", "cdn-bypass-in6"'
fi
cat <<EOF
{
"//": "cdn-bypass: 由 setup_cdn_bypass_tproxy.sh 生成的独立 xray 配置(xray-cdn-bypass.service 以 -config 加载),重复运行会整体覆盖本文件,请勿手改",
"inbounds": [
{
"tag": "cdn-bypass-in",
"listen": "127.0.0.1",
"port": $TPROXY_PORT,
"protocol": "dokodemo-door",
"settings": { "network": "tcp,udp", "followRedirect": true },
"streamSettings": { "sockopt": { "tproxy": "tproxy" } }
}$inbound_v6
],
"outbounds": [
$outbounds
],
"observatory": {
"subjectSelector": ["$UPSTREAM_TAG_PREFIX"],
"probeURL": "https://www.gstatic.com/generate_204",
"probeInterval": "30s",
"enableConcurrency": true
},
"routing": {
"balancers": [
{
"tag": "$BALANCER_TAG",
"selector": ["$UPSTREAM_TAG_PREFIX"],
"strategy": { "type": "random" },
"fallbackTag": "${UPSTREAM_TAG_PREFIX}1"
}
],
"rules": [
{
"type": "field",
"inboundTag": [$intags],
"balancerTag": "$BALANCER_TAG"
}
]
}
}
EOF
}
# ---------- 读取 IPv6 CDN 网段清单(数据文件,一行一个 CIDR,# 注释) ----------
# 缺文件直接报错(不静默降级成"只代理 v4");不想处理 v6 可显式设 ENABLE_IPV6=0。
load_cdn_v6() {
[ -f "$CDN_CIDRS_V6_FILE" ] || die "找不到 IPv6 CDN 网段文件:$CDN_CIDRS_V6_FILE(默认放在脚本同目录的 cdn-cidrs-v6.txt;可用 CDN_CIDRS_V6_FILE 指定路径,或设 ENABLE_IPV6=0 跳过 IPv6)"
local line n=0
CDN_RANGES_V6=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="${line//[[:space:]]/}"
[ -n "$line" ] || continue
[[ "$line" =~ ^[0-9a-fA-F:]+/[0-9]{1,3}$ && "$line" == *:* ]] || die "$CDN_CIDRS_V6_FILE 含非法 IPv6 CIDR:$line"
CDN_RANGES_V6+="$line"$'\n'
n=$((n + 1))
done < "$CDN_CIDRS_V6_FILE"
[ "$n" -ge 1 ] || die "$CDN_CIDRS_V6_FILE 里没有任何 CIDR(全是注释/空行);不想处理 IPv6 请设 ENABLE_IPV6=0"
info "已读取 IPv6 CDN 网段 $n 条:$CDN_CIDRS_V6_FILE"
}
# ---------- 生成 nft 文件 ----------
render_nft() {
local extra="" extra6=""
local c
for c in $EXTRA_EXEMPT_CIDRS; do
if [[ "$c" == *:* ]]; then extra6+=" ip6 daddr $c accept\n"
else extra+=" ip daddr $c accept\n"; fi
done
# CDN 段组装成 nft 集合(每行 4 段)
local set_lines
set_lines=$(echo "$CDN_RANGES" | awk 'NF' | sort -t. -k1,1n -k2,2n -k3,3n -u | \
awk 'BEGIN{n=0} {a[n++]=$1} END{for(i=0;i<n;i++){printf "%s%s", a[i], (i==n-1?"":((i+1)%4?", ":",\n "))}}')
cat <<EOF
# cdn-bypass:CDN 绕行透明代理(由 setup_cdn_bypass_tproxy.sh 生成,重复运行整体覆盖,勿手改)
table ip cdn_bypass_mark {
chain output {
type route hook output priority mangle; policy accept;
# xray 自身出站(sockopt.mark=255)豁免 —— 防回环的关键
meta mark 0xff ip protocol { tcp, udp } accept
# 私网/本机/链路本地豁免
ip daddr { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10 } accept
$(printf '%b' "$extra") # CDN 段 53/80/443 端口打标(DNS、HTTP、HTTPS;QUIC 走 udp/443 自动覆盖)
ip daddr {
$set_lines
} meta l4proto { tcp, udp } th dport { 53, 80, 443 } meta mark set $MARK
}
}
table ip cdn_bypass_tproxy {
chain prerouting {
type filter hook prerouting priority mangle + 1; policy accept;
meta mark $MARK ip protocol { tcp, udp } tproxy to :$TPROXY_PORT
}
}
EOF
[ "$ENABLE_IPV6" = 1 ] || return 0
# IPv6:与 v4 镜像的两张表(独立的 ip6 family 表,不碰任何现有 ip6 filter / rules.v6)
local set6_lines
set6_lines=$(echo "$CDN_RANGES_V6" | awk 'NF' | sort -u | \
awk 'BEGIN{n=0} {a[n++]=$1} END{for(i=0;i<n;i++){printf "%s%s", a[i], (i==n-1?"":((i+1)%4?", ":",\n "))}}')
cat <<EOF
table ip6 cdn_bypass_mark {
chain output {
type route hook output priority mangle; policy accept;
# xray 自身出站(sockopt.mark=255)豁免 —— 防回环
meta mark 0xff meta l4proto { tcp, udp } accept
# 本机/链路本地/ULA/组播豁免
ip6 daddr { ::1/128, fe80::/10, fc00::/7, ff00::/8 } accept
$(printf '%b' "$extra6") # CDN v6 段 53/80/443 端口打标
ip6 daddr {
$set6_lines
} meta l4proto { tcp, udp } th dport { 53, 80, 443 } meta mark set $MARK
}
}
table ip6 cdn_bypass_tproxy {
chain prerouting {
type filter hook prerouting priority mangle + 1; policy accept;
# IPv6 写死 [::1]:不带地址的 tproxy to :PORT 内核同样接受,但监听地址由路由推导;
# 实测 [::1] 才与 inbound 的 listen 确定对应(v4 保持原有不带地址写法不动)。
meta mark $MARK meta l4proto { tcp, udp } tproxy to [::1]:$TPROXY_PORT
}
}
EOF
}
# ---------- 生成 networkd drop-in ----------
# 防 systemd-networkd 把本脚本加的 fwmark 策略路由当"外源规则"清掉:
# networkd 默认 ManageForeignRoutingPolicyRules=yes,它每次重启/重配(如 unattended-upgrade
# 升级 systemd 触发)都会删掉 ip rule fwmark $MARK —— 而 route unit 是 oneshot,不重启不会补回,
# 绕行就此静默失效(2026-10-16 gcoregon 实战事故)。文件落盘即对下一次 networkd 重启生效,
# 安装时无需重启 networkd。
render_networkd_dropin() {
cat <<EOF
[Network]
# cdn-bypass:不许 networkd 在重启/重配时清掉 $ROUTE_UNIT 加的 fwmark 策略路由
ManageForeignRoutingPolicyRules=no
EOF
}
# ---------- 生成策略路由 unit ----------
render_route_unit() {
# IPv6 同理需要 fwmark 策略路由 + `local ::/0 dev lo`(2026-11 gcoregon 用 table 199 实测可行)
local route_v6_start="" route_v6_stop=""
if [ "$ENABLE_IPV6" = 1 ]; then
route_v6_start="
ExecStart=/bin/sh -c 'ip -6 rule add fwmark $MARK lookup $ROUTE_TABLE 2>/dev/null || true; ip -6 route add local ::/0 dev lo table $ROUTE_TABLE 2>/dev/null || true'"
route_v6_stop="
ExecStop=/bin/sh -c 'ip -6 rule del fwmark $MARK lookup $ROUTE_TABLE 2>/dev/null || true; ip -6 route del local ::/0 dev lo table $ROUTE_TABLE 2>/dev/null || true'"
fi
cat <<EOF
[Unit]
Description=CDN bypass tproxy policy routing (fwmark $MARK -> table $ROUTE_TABLE)
# ip rule/ip route 是纯内核运行时状态,必须由本 unit 在每次开机时补回
After=network-pre.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'ip rule add fwmark $MARK lookup $ROUTE_TABLE 2>/dev/null || true; ip route add local 0.0.0.0/0 dev lo table $ROUTE_TABLE 2>/dev/null || true'$route_v6_start
ExecStop=/bin/sh -c 'ip rule del fwmark $MARK lookup $ROUTE_TABLE 2>/dev/null || true; ip route del local 0.0.0.0/0 dev lo table $ROUTE_TABLE 2>/dev/null || true'$route_v6_stop
[Install]
WantedBy=multi-user.target
EOF
}
# ---------- 探测 xray ----------
detect_xray() {
if [ -z "$XRAY_SERVICE" ]; then
if systemctl cat xray.service >/dev/null 2>&1; then XRAY_SERVICE=xray
elif systemctl cat v2ray.service >/dev/null 2>&1; then XRAY_SERVICE=v2ray
else die "找不到 xray.service / v2ray.service(本脚本要求已装好 xray;bypass 将作为独立实例运行)"; fi
fi
XRAY_EXECSTART="$(systemctl show "$XRAY_SERVICE" -p ExecStart --value)"
[ -n "$XRAY_EXECSTART" ] || die "读不到 $XRAY_SERVICE 的 ExecStart"
# 新版 systemd 输出 "{ path=/usr/local/bin/xray ; argv[]=... }",老格式直接是路径
if [[ "$XRAY_EXECSTART" =~ path=([^[:space:]]+) ]]; then
XRAY_BIN="${BASH_REMATCH[1]}"
else
XRAY_BIN="$(echo "$XRAY_EXECSTART" | awk '{print $1}')"
fi
[ -x "$XRAY_BIN" ] || die "从 ExecStart 解析不到 xray 二进制路径:$XRAY_EXECSTART"
# bypass 实例沿用主服务的运行身份(空 = unit 未显式设置 = root)
XRAY_USER="$(systemctl show "$XRAY_SERVICE" -p User --value 2>/dev/null || true)"
XRAY_GROUP="$(systemctl show "$XRAY_SERVICE" -p Group --value 2>/dev/null || true)"
[ -n "$XRAY_USER" ] || XRAY_USER=root
# Group= 未显式设置时 systemctl 返回空,此时应取该用户的 primary group
# (官方 unit 常写 User=nobody 而省略 Group=,回退成 root 会给出 gid=0)
[ -n "$XRAY_GROUP" ] || XRAY_GROUP="$(id -gn "$XRAY_USER" 2>/dev/null || echo root)"
info "xray 服务:$XRAY_SERVICE ExecStart:$XRAY_EXECSTART 运行身份:$XRAY_USER:$XRAY_GROUP"
}
# ---------- 生成 bypass 独立实例的 systemd unit ----------
# DRY_RUN 不调用 detect_xray,XRAY_BIN/XRAY_USER/XRAY_GROUP 取占位默认值。
render_bypass_unit() {
local main="${XRAY_SERVICE:-xray}" bin="${XRAY_BIN:-/usr/local/bin/xray}"
local user="${XRAY_USER:-root}" group="${XRAY_GROUP:-root}"
cat <<EOF
[Unit]
Description=CDN bypass tproxy (xray standalone)
After=network.target nss-lookup.target $main.service
Wants=network-online.target
# PartOf 是有意为之:官方 install-release.sh 升级只重启 xray.service,
# 借此让 bypass 实例随之重启、换上新二进制;而它是单向的 —— 单独重启 bypass 不会碰主服务。
PartOf=$main.service
[Service]
Type=simple
User=$user
Group=$group
# CAP_NET_ADMIN:IP_TRANSPARENT(tproxy)与 SO_MARK(出站 mark 255)都需要它
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
NoNewPrivileges=yes
ExecStart=$bin run -config $XRAY_CONFDIR/cdn-bypass.json
Restart=on-failure
RestartPreventExitStatus=23
LimitNOFILE=1000000
[Install]
# 同时挂到主服务:PartOf 只传播 stop/restart,【不传播 start】。
# 没有这一行,traffic-guard 次月 systemctl start 只拉起主服务,bypass 仍是 dead,
# 而 nft 仍在打标 —— CDN 流量会全部被 TPROXY 到已关闭的端口(黑洞,连 ACME 续期都会挂)。
WantedBy=multi-user.target $main.service
EOF
}
# 清理旧版(-confdir drop-in 方案)残留;有残留时返回 0,否则 1。只删文件,不重启。
remove_legacy_dropins() {
local f found=1
for f in "/etc/systemd/system/$XRAY_SERVICE.service.d/10-cdn-bypass-confdir.conf" \
"/etc/systemd/system/$XRAY_SERVICE.service.d/20-cdn-bypass-confdir.conf"; do
if [ -e "$f" ]; then rm -f "$f"; found=0; fi
done
rmdir "/etc/systemd/system/$XRAY_SERVICE.service.d" 2>/dev/null || true
return "$found"
}
# ---------- 安装 ----------
do_install() {
# 1. 读链接(N 条)并逐条解析成各自独立的节点
collect_links
local li
for li in "${!LINKS[@]}"; do parse_vless_link "${LINKS[li]}" "$li"; done
resolve_ipv6
[ "$ENABLE_IPV6" = 1 ] && load_cdn_v6
# 2. 渲染
local xray_json nft_conf route_unit bypass_unit networkd_dropin
nft_conf="$(render_nft)"
route_unit="$(render_route_unit)"
networkd_dropin="$(render_networkd_dropin)"
if [ -n "$DRY_RUN" ]; then
xray_json="$(render_bypass_config)"
bypass_unit="$(render_bypass_unit)"
printf '%s\n' "$xray_json" > "$DRY_RUN_DIR/cdn-bypass.json"
chmod 600 "$DRY_RUN_DIR/cdn-bypass.json"
printf '%s\n' "$bypass_unit" > "$DRY_RUN_DIR/$BYPASS_SERVICE.service"
printf '%s\n' "$nft_conf" > "$DRY_RUN_DIR/cdn-bypass.nft"
printf '%s\n' "$route_unit" > "$DRY_RUN_DIR/$ROUTE_UNIT"
printf '%s\n' "$networkd_dropin" > "$DRY_RUN_DIR/90-cdn-bypass-networkd.conf"
printf 'include "%s"\n' "$NFT_FILE" >> "$DRY_RUN_DIR/nftables.conf"
info "已渲染:cdn-bypass.json / $BYPASS_SERVICE.service / cdn-bypass.nft / $ROUTE_UNIT / networkd drop-in / nftables.conf(include)"
info "(真实安装时还会移除旧版 {10,20}-cdn-bypass-confdir.conf drop-in,如存在则重启一次主 ${XRAY_SERVICE:-xray};DRY_RUN 不触碰系统)"
return 0
fi
detect_xray
xray_json="$(render_bypass_config)"
bypass_unit="$(render_bypass_unit)"
# 3. 先验证独立配置(不过则直接中止,系统一字节未动)
local tmp_conf
# 必须带 .json 后缀:xray 靠扩展名判断配置格式,无后缀会报 "Failed to get format of ..."
tmp_conf="$(mktemp --suffix=.json)"
printf '%s\n' "$xray_json" > "$tmp_conf"
if ! "$XRAY_BIN" run -test -config "$tmp_conf" > /dev/null 2>&1; then
"$XRAY_BIN" run -test -config "$tmp_conf" 2>&1 | tail -5
rm -f "$tmp_conf"
die "独立配置自检失败,未改动系统"
fi
rm -f "$tmp_conf"
info "bypass 独立配置自检通过"
# 4. 旧版迁移:先移除 -confdir drop-in 并重启主服务【一次】,让主实例立刻摆脱 -confdir、
# 恢复完整 routing,同时释放 TPROXY 端口;必须发生在启动新 unit 之前。
if remove_legacy_dropins; then
systemctl daemon-reload
systemctl restart "$XRAY_SERVICE"
info "已移除旧版 -confdir drop-in 并重启 $XRAY_SERVICE(主实例恢复完整 routing)"
fi
# 5. 写配置 + 独立 unit,启动 bypass 实例
mkdir -p "$XRAY_CONFDIR"
# 含 VLESS UUID:600,属主为 bypass 实例的运行身份
( umask 077; printf '%s\n' "$xray_json" > "$XRAY_CONFDIR/cdn-bypass.json" )
chown "$XRAY_USER:$XRAY_GROUP" "$XRAY_CONFDIR/cdn-bypass.json"
chmod 600 "$XRAY_CONFDIR/cdn-bypass.json"
info "已写入 $XRAY_CONFDIR/cdn-bypass.json(600)"
printf '%s\n' "$bypass_unit" > "/etc/systemd/system/$BYPASS_SERVICE.service"
systemctl daemon-reload
systemctl enable --now "$BYPASS_SERVICE"
systemctl restart "$BYPASS_SERVICE" # 幂等重跑时让新配置生效
info "$BYPASS_SERVICE 已启动(独立 xray 实例,主 $XRAY_SERVICE 未被改动)"
# 6. nft
mkdir -p "$NFT_DIR"
printf '%s\n' "$nft_conf" > "$NFT_FILE"
chmod 644 "$NFT_FILE"
touch "$NFTABLES_CONF"
local include_line="include \"$NFT_FILE\""
if ! grep -qF "$include_line" "$NFTABLES_CONF"; then
printf '%s\n' "$include_line" >> "$NFTABLES_CONF"
info "已向 $NFTABLES_CONF 追加 include 行"
fi
# 只 enable,绝不 --now/start:nftables.service 若此前未激活,启动会执行
# /etc/nftables.conf 首行的 `flush ruleset`,瞬间清空主机防火墙(含他人维护的 ip6 filter)。
systemctl enable nftables
# 运行时只加载自己的两张表(先删后加,nft -f 是合并语义),绝不执行整份 nftables.conf
nft delete table ip cdn_bypass_tproxy 2>/dev/null || true
nft delete table ip cdn_bypass_mark 2>/dev/null || true
nft delete table ip6 cdn_bypass_tproxy 2>/dev/null || true
nft delete table ip6 cdn_bypass_mark 2>/dev/null || true
nft -f "$NFT_FILE"
info "nft 打标表 + tproxy 表已加载(IPv6 表:$([ "$ENABLE_IPV6" = 1 ] && echo 已加载 || echo 未启用))"
# 7. 策略路由(运行时 + 开机 unit)
printf '%s\n' "$route_unit" > "/etc/systemd/system/$ROUTE_UNIT"
systemctl daemon-reload
systemctl enable "$ROUTE_UNIT"
# 必须 restart 而不是只 enable/start:oneshot + RemainAfterExit 的 unit 若已处于 active(旧版本装的),
# 单纯 start 不会重新执行 ExecStart,新写入的 v6 规则就永远不会生效(实测踩过)。
systemctl restart "$ROUTE_UNIT"
# 报告实际内核状态,而不是只看变量(避免「显示 v6:是,实际没加」的假绿灯)
local v4_ok=0 v6_ok=0
ip rule show | grep -q "fwmark 0x$MARK lookup" && v4_ok=1
ip -6 rule show 2>/dev/null | grep -q "fwmark 0x$MARK lookup" && v6_ok=1
info "策略路由:v4=$([ "$v4_ok" = 1 ] && echo 已生效 || echo 缺失) v6=$([ "$v6_ok" = 1 ] && echo 已生效 || echo 缺失)"
[ "$v4_ok" = 1 ] || warn "v4 fwmark 规则未生效,绕行不会工作"
[ "$ENABLE_IPV6" = 1 ] && [ "$v6_ok" = 0 ] && warn "v6 fwmark 规则未生效,v6 CDN 流量不会被绕行"
# 7b. networkd drop-in:防 networkd 重启清掉上面的 fwmark 规则(落盘即对下次重启生效,无需现在重启 networkd)
mkdir -p "$(dirname "$NETWORKD_DROPIN")"
printf '%s\n' "$networkd_dropin" > "$NETWORKD_DROPIN"
info "已写入 $NETWORKD_DROPIN(ManageForeignRoutingPolicyRules=no,下次 networkd 重启起生效)"
# 8. 实测
info "实测绕行链路(直连必超时,通=必然走了代理)..."
local code
code="$(curl -4 -m 12 -sS -o /dev/null -w '%{http_code}' https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || echo FAIL)"
if [ "$code" = "200" ]; then
info "cdn-cgi/trace 200 ✅"
curl -4 -m 12 -sS https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | grep -E '^ip=' | sed 's/^/ 出口身份: /'
else
warn "cdn-cgi/trace 未通($code)—— 请检查上游节点可用性:curl -4 -m 8 https://www.cloudflare.com/cdn-cgi/trace"
fi
info "完成。xray 原 config.json 与主 unit 均未被改动;卸载:sudo ACTION=uninstall bash $0"
}
# ---------- 卸载 ----------
do_uninstall() {
if [ -n "$DRY_RUN" ]; then info "DRY_RUN:卸载只列动作"; return 0; fi
[ -n "$XRAY_SERVICE" ] || { systemctl cat xray.service >/dev/null 2>&1 && XRAY_SERVICE=xray; }
[ -n "$XRAY_SERVICE" ] || { systemctl cat v2ray.service >/dev/null 2>&1 && XRAY_SERVICE=v2ray; }
# 顺序很重要:【先】撤 nft 打标与策略路由,【再】停实例。
# 反过来会让"仍在打标 + 端口已关"同时成立,数秒内 CDN 流量全部被 tproxy 到死端口(RST/黑洞)。
rm -f "$NFT_FILE"
[ -f "$NFTABLES_CONF" ] && sed -i "\|include \"$NFT_FILE\"|d" "$NFTABLES_CONF"
nft delete table ip cdn_bypass_tproxy 2>/dev/null || true
nft delete table ip cdn_bypass_mark 2>/dev/null || true
nft delete table ip6 cdn_bypass_tproxy 2>/dev/null || true
nft delete table ip6 cdn_bypass_mark 2>/dev/null || true
ip rule del fwmark "$MARK" lookup "$ROUTE_TABLE" 2>/dev/null || true
ip route del local 0.0.0.0/0 dev lo table "$ROUTE_TABLE" 2>/dev/null || true
ip -6 rule del fwmark "$MARK" lookup "$ROUTE_TABLE" 2>/dev/null || true
ip -6 route del local ::/0 dev lo table "$ROUTE_TABLE" 2>/dev/null || true
systemctl disable --now "$BYPASS_SERVICE" 2>/dev/null || true
rm -f "/etc/systemd/system/$BYPASS_SERVICE.service"
rm -f "$XRAY_CONFDIR/cdn-bypass.json"
rmdir "$XRAY_CONFDIR" 2>/dev/null || true
# 旧版 drop-in 残留:仅当确实存在时才重启主服务
local legacy=0
if [ -n "$XRAY_SERVICE" ] && remove_legacy_dropins; then legacy=1; fi
systemctl daemon-reload
if [ "$legacy" = 1 ]; then
systemctl restart "$XRAY_SERVICE" && info "已移除旧版 -confdir drop-in,$XRAY_SERVICE 已重启"
fi
systemctl disable --now "$ROUTE_UNIT" 2>/dev/null || true
rm -f "/etc/systemd/system/$ROUTE_UNIT"
rm -f "$NETWORKD_DROPIN"
systemctl daemon-reload
info "卸载完成"
}
# 清单过时提醒(Akamai 段来自 BGP,漂移快;只是提醒,不阻塞)
cdn_list_staleness_hint() {
local stamp days
stamp=$(date -u -d "$CDN_LIST_AUDIT_DATE" +%s 2>/dev/null) || return 0
days=$(( ( $(date -u +%s) - stamp ) / 86400 ))
if [ "$days" -gt 180 ]; then
warn "CDN 清单已 $days 天未刷新(审计日 $CDN_LIST_AUDIT_DATE)。Akamai 段来自 ASN/BGP,"
warn "漂移很快,过时会导致「白绕已不宣告的段」和「漏绕新宣告的段」。刷新方法见脚本头部。"
fi
return 0
}
case "$ACTION" in
install) cdn_list_staleness_hint; do_install;;
uninstall) do_uninstall;;
*) die "ACTION 只支持 install/uninstall";;
esac