#!/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 <下标>。每个节点各自拥有 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 < 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 < 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 </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