Files
vpsetup/setup_warp_zerotrust.sh
eli 35e3a5e06c fix: DNS takeover mode by resolv.conf disposition, in-place restore
Two bugs in the bootstrap DNS64 handling:

- Restore used whole-file cp, replacing the inode and silently
  killing systemd-resolved's inotify watch (resolv.conf contents
  correct but resolvectl shows no global DNS until restart).
  Restore now writes back in place (cat > file).
- On resolved-managed systems (resolv.conf symlinked into
  /run/systemd/resolve/*, the Ubuntu default), writing DNS64
  through the symlink clobbered resolved's stub file without
  taking effect, and restore hardcoded the wrong symlink target.
  These systems now get a resolved.conf.d drop-in (global DNS64 +
  Domains=~.) instead; resolv.conf and provider DNS stay untouched.

Also handles dangling-symlink/missing-file edge cases and updates
CLAUDE.md invariant #1 accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:04:17 +08:00

369 lines
18 KiB
Bash

#!/bin/bash
set -e
# ============================================================
# setup_warp_zerotrust.sh
# IPv6-only VPS 用纯 WireGuard 接入 Cloudflare WARP(不装官方客户端)
#
# 模式一(默认): 免费版 WARP —— 匿名注册,获得 IPv4 出口
# bash setup_warp_zerotrust.sh
#
# 模式二: Zero Trust 团队设备 —— 设备显示在 Zero Trust 仪表盘,
# 受 Gateway 策略管控。仪表盘一次性准备(官方文档: Deploy the
# Cloudflare One Client on headless Linux machines):
# a. Zero Trust -> Access controls -> Service credentials
# -> Service Tokens -> Create Service Token,记下 Client ID/Secret
# b. Team & Resources -> Devices -> Management 标签页
# -> Device enrollment permissions -> Manage -> Create new policy:
# Action = Service Auth, Include: Service Token = <token 名>
# 保存后把该 policy 加入 enrollment permissions 并 Save
# 然后运行:
# CF_TEAM=团队名 CF_ACCESS_CLIENT_ID=xxxx.access \
# CF_ACCESS_CLIENT_SECRET=yyyy bash setup_warp_zerotrust.sh
# (已持有注册 JWT 时可用 WARP_JWT=eyJ... 代替 service token 两个变量)
#
# 交互模式: 不带环境变量直接运行时,会依次询问团队名/凭据(从 /dev/tty
# 读取,兼容 wget|bash 管道方式);团队名直接回车即为免费版,凭据输入
# 不完整自动回退免费版。环境变量优先于交互输入,全部给齐则不询问。
#
# 可选环境变量:
# ALLOWED_IPS=0.0.0.0/0 进隧道的网段。仪表盘的 split tunnel 设置对纯
# WireGuard 不生效,以这里为准;只做 Tunnel 私网
# 访问可填私网段如 10.0.0.0/8
# MTU=1280 NAT64 + WireGuard 双重开销下的安全值
# ACTION=delete 反向操作: 注销本设备(释放团队设备席位)并卸载 wg0
#
# 关键实现依据(2026-08 核实, 参考 wgcf v2.2.32 / Aether / fscarmen api.sh):
# - 注册接口返回平铺 JSON,没有 .result 包裹:
# .id / .token / .config.interface.addresses.{v4,v6} / .config.peers[0]...
# - 请求必须带 CF-Client-Version 头,否则被拒;可用组合:
# v0a4471 + a-6.35-4471 (Aether), v0a1922 + a-6.3-1922 (wgcf)
# - TLS 指纹需接近官方 App: 锁 TLS1.2 + HTTP/1.1,否则 error 1020
# - 团队注册: 先用 service token 向 https://<team>.cloudflareaccess.com/warp
# 换 JWT(在 CF_Authorization cookie 或页面 token= 参数里),注册时带
# CF-Access-Jwt-Assertion: <jwt> 头
# - 403 = 本机 IP 被 Cloudflare 标记, 429 = 注册过于频繁,换 IP/稍后重试
# - 内核无 wireguard 模块时(LXC/老内核)自动回退 wireguard-go 用户态实现
# (wg-quick 内置该回退机制, 自启同样生效)
#
# 安全说明(供应链审计要点):
# - 仅经 apt 官方源装包(wireguard-tools/curl/jq/wireguard-go), 不添加任何
# 第三方软件源, 不下载/执行任何远程二进制或脚本
# - 外联仅 Cloudflare 官方域名; 连通性测试优先用官方 cdn-cgi/trace,
# 第三方 ip.sb 仅作兜底
# - 安装阶段临时用公共 DNS64(nat64.net/Trex), 可用 DNS64_SERVERS 覆盖;
# apt 有官方签名校验, API 全程 TLS, 结束后自动还原 DNS
# - 私钥/凭据仅存本机 /etc/wireguard/(600), 只有公钥提交给注册接口
# ============================================================
# 0. 确保 root 权限
if [ "$EUID" -ne 0 ]; then
echo "Error: Please run as root"
exit 1
fi
umask 077
CF_TEAM="${CF_TEAM:-}"
CF_ACCESS_CLIENT_ID="${CF_ACCESS_CLIENT_ID:-}"
CF_ACCESS_CLIENT_SECRET="${CF_ACCESS_CLIENT_SECRET:-}"
WARP_JWT="${WARP_JWT:-}"
ALLOWED_IPS="${ALLOWED_IPS:-0.0.0.0/0}"
MTU="${MTU:-1280}"
ACTION="${ACTION:-setup}"
# 安装阶段临时使用的 DNS64 服务器(公共 NAT64 提供商无"官方"可言,可自行换成自建的)
DNS64_SERVERS="${DNS64_SERVERS:-2a01:4f8:c2c:123f::1 2001:67c:2b0::4}"
ACCOUNT_FILE=/etc/wireguard/warp-account.json
WG_CONF=/etc/wireguard/wg0.conf
API_HOST=https://api.cloudflareclient.com
# 依次尝试的 API 版本组合: "api版本|client版本"
API_VERSIONS=("v0a4471|a-6.35-4471" "v0a1922|a-6.3-1922")
TMPD=$(mktemp -d)
# ACTION=delete: 注销设备并卸载,不需要动 DNS
if [ "$ACTION" = "delete" ]; then
if [ ! -s "$ACCOUNT_FILE" ]; then
echo "No saved device ($ACCOUNT_FILE not found), nothing to delete."
else
command -v jq >/dev/null || { echo "Error: jq is required for delete"; exit 1; }
DEL_ID=$(jq -r '.id' "$ACCOUNT_FILE")
DEL_TOKEN=$(jq -r '.token' "$ACCOUNT_FILE")
CODE=$(curl -sS -6 -m 20 -o /dev/null -w '%{http_code}' -X DELETE \
-H 'User-Agent: okhttp/3.12.1' -H 'CF-Client-Version: a-6.35-4471' \
-H "Authorization: Bearer $DEL_TOKEN" "$API_HOST/v0a4471/reg/$DEL_ID" || true)
[ "$CODE" = "204" ] && echo "Device $DEL_ID deleted." || echo "Delete returned HTTP $CODE (device may already be gone)."
rm -f "$ACCOUNT_FILE"
fi
systemctl disable --now wg-quick@wg0 2>/dev/null || true
rm -f "$WG_CONF"
echo "Uninstall done."
exit 0
fi
# 交互式收集配置: 环境变量已提供的项不询问;无终端(非交互)时全部跳过
# 注意: 必须用"能否打开 /dev/tty"判断交互性, [ -r /dev/tty ] 在无控制终端时
# 也返回真,真正打开才报 ENXIO
interactive() { (: < /dev/tty) 2>/dev/null; }
ask() {
# ask <变量名> <提示语> [1=密文输入]
local var="$1" prompt="$2" secret="${3:-}"
[ -n "${!var}" ] && return 0
interactive || return 0
if [ "$secret" = "1" ]; then
read -rsp "$prompt" "$var" < /dev/tty || true
echo > /dev/tty || true
else
read -rp "$prompt" "$var" < /dev/tty || true
fi
}
if [ -z "$CF_TEAM" ] && interactive; then
echo "------------------------------------------------------------"
echo "直接回车 = 免费版 WARP;或输入 Zero Trust 团队名接入团队设备"
echo "(团队模式需先在仪表盘创建 Service Token 并配置 Service Auth"
echo " 注册策略,步骤见脚本头部注释)"
echo "------------------------------------------------------------"
fi
ask CF_TEAM "Zero Trust 团队名(回车跳过): "
# 团队名归一化: 允许填 myteam / myteam.cloudflareaccess.com / https://myteam.cloudflareaccess.com/warp
CF_TEAM=$(echo "$CF_TEAM" | tr 'A-Z' 'a-z' | sed -E 's#^https?://##; s#\.cloudflareaccess\.com.*$##; s#/.*$##; s#[./]$##')
if [ -n "$CF_TEAM" ]; then
ask CF_ACCESS_CLIENT_ID "Service Token Client ID (形如 xxxx.access): "
ask CF_ACCESS_CLIENT_SECRET "Service Token Client Secret (输入不显示): " 1
if [ -z "$WARP_JWT" ] && { [ -z "$CF_ACCESS_CLIENT_ID" ] || [ -z "$CF_ACCESS_CLIENT_SECRET" ]; }; then
echo "*** 提示: 团队凭据不完整,本次按免费版 WARP 注册 ***"
CF_TEAM=""
fi
fi
# 1. 接管 DNS,并设置退出/报错自动恢复机制。按 resolv.conf 的实际形态分两种方式:
# a) resolv.conf 是指向 /run/systemd/resolve/* 的符号链接(Ubuntu 默认 stub 模式):
# 文件内容对 resolved 没有作用,硬写只会踩坏 /run 里的 stub 文件。
# 改用 resolved.conf.d drop-in 注入全局 DNS64 + 路由域 ~.,恢复时删除并重启 resolved。
# 供应商/netplan 下发的原始 DNS 全程不动,也不存在快照过期问题。
# b) 其余情况(foreign 普通文件 / NM / resolvconf / 无 resolved):
# 备份文件内容后改写,恢复时原位写回(cat > 保留 inode 与符号链接)。
# 禁止整文件 cp 替换: 换掉 inode 后 systemd-resolved 的 inotify 监控失效,
# 会出现 resolv.conf 内容正确但 resolvectl 看不到任何 DNS 的假象。
DNS_MODE="file"
RESOLV_BAK="$TMPD/resolv.conf.bak"
RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/zz-warp-dns64.conf
RESOLV_REAL=$(readlink -f /etc/resolv.conf 2>/dev/null || echo /etc/resolv.conf)
if [ "${RESOLV_REAL#/run/systemd/resolve/}" != "$RESOLV_REAL" ] \
&& systemctl is-active --quiet systemd-resolved 2>/dev/null; then
DNS_MODE=resolved
fi
set_dns64() {
if [ "$DNS_MODE" = resolved ]; then
mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNS=%s\nDomains=~.\n' "$DNS64_SERVERS" > "$RESOLVED_DROPIN"
systemctl restart systemd-resolved
else
# -L 穿透符号链接备份内容(NM/resolvconf 管理时备份的是目标文件内容);
# 备份失败(如悬空链接)按"原本不存在"处理,恢复时删除
if [ -e /etc/resolv.conf ] || [ -L /etc/resolv.conf ]; then
cp -L /etc/resolv.conf "$RESOLV_BAK" 2>/dev/null || : > "$RESOLV_BAK.missing"
else
: > "$RESOLV_BAK.missing"
fi
for ns in $DNS64_SERVERS; do echo "nameserver $ns"; done > /etc/resolv.conf
fi
}
restore_dns() {
echo "Restoring default DNS configuration..."
if [ "$DNS_MODE" = resolved ]; then
rm -f "$RESOLVED_DROPIN"
systemctl restart systemd-resolved 2>/dev/null || true
elif [ -f "$RESOLV_BAK.missing" ]; then
rm -f /etc/resolv.conf "$RESOLV_BAK.missing"
elif [ -f "$RESOLV_BAK" ]; then
cat "$RESOLV_BAK" > /etc/resolv.conf 2>/dev/null || cp "$RESOLV_BAK" /etc/resolv.conf || true
fi
}
cleanup() { restore_dns; rm -rf "$TMPD"; }
trap cleanup EXIT INT TERM
# 2. 临时启用 DNS64 保证初始化访问(apt 源大多只有 IPv4)
set_dns64
# 3. 安装轻量依赖(仅约 2MB)
APT_UPDATED=0
if ! command -v wg >/dev/null || ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
apt update -y && APT_UPDATED=1
apt install -y wireguard-tools curl jq
fi
# 4. 团队模式: 用 service token 换注册 JWT
if [ -n "$CF_TEAM" ] && [ -z "$WARP_JWT" ]; then
echo "Fetching enrollment JWT from https://${CF_TEAM}.cloudflareaccess.com/warp ..."
JWT_CODE=$(curl -sS -6 -m 20 --max-redirs 0 -D "$TMPD/hdrs" -o "$TMPD/body" -w '%{http_code}' \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
"https://${CF_TEAM}.cloudflareaccess.com/warp" || true)
WARP_JWT=$(tr -d '\r' < "$TMPD/hdrs" | sed -n 's/^[Ss]et-[Cc]ookie:.*CF_Authorization=\([^;]*\).*/\1/p' | head -1)
[ -z "$WARP_JWT" ] && WARP_JWT=$(grep -oE 'token=eyJ[A-Za-z0-9_.-]+' "$TMPD/body" | head -1 | cut -d= -f2-)
if [ -z "$WARP_JWT" ]; then
JWT_LOC=$(tr -d '\r' < "$TMPD/hdrs" | sed -n 's/^[Ll]ocation: *//p' | head -1)
echo "Error: no enrollment JWT returned (HTTP ${JWT_CODE:-000}${JWT_LOC:+, Location: $JWT_LOC})"
[ "$JWT_CODE" = "404" ] && echo "!! HTTP 404 = 团队域名不存在,团队名拼写有误(本次请求: ${CF_TEAM}.cloudflareaccess.com)"
echo "排查清单:"
echo " 1. 注册策略的 Action 必须是 Service Auth(选成 Allow 会出现"
echo " 302 跳登录页,永远拿不到 JWT)"
echo " 2. 策略建好后要回到 Device enrollment permissions 添加进列表并 Save"
echo " 3. 检查 Client ID(xxx.access)/Secret 是否复制完整、token 是否过期"
exit 1
fi
echo "JWT acquired."
fi
# 5. 生成 WireGuard 密钥
PRIVKEY=$(wg genkey)
PUBKEY=$(echo "$PRIVKEY" | wg pubkey)
# 5.5 探测内核 WireGuard 支持; 无模块时回退用户态 wireguard-go
# (wg-quick 内置该回退: ip link add 失败且装有 wireguard-go 时自动以用户态运行,
# 开机自启同样生效; LXC/自定义老内核 VPS 常见此场景)
USERSPACE_WG=0
if ! ip link add wgprobe type wireguard 2>/dev/null; then
modprobe wireguard 2>/dev/null || true
fi
if ip link add wgprobe type wireguard 2>/dev/null; then
ip link delete wgprobe 2>/dev/null || true
else
echo "No kernel WireGuard module, falling back to userspace wireguard-go..."
if [ ! -c /dev/net/tun ]; then
echo "Error: /dev/net/tun not available (LXC 容器请先在面板/宿主机开启 TUN)"
exit 1
fi
if ! command -v wireguard-go >/dev/null; then
[ "$APT_UPDATED" = "0" ] && { apt update -y && APT_UPDATED=1; }
apt install -y wireguard-go
fi
USERSPACE_WG=1
fi
RESP="$TMPD/reg.json"
# 6a. 已有设备凭据且模式一致时,用 PATCH 换新密钥(避免重复注册占用团队设备席位)
if [ -s "$ACCOUNT_FILE" ]; then
OLD_ID=$(jq -r '.id' "$ACCOUNT_FILE")
OLD_TOKEN=$(jq -r '.token' "$ACCOUNT_FILE")
SAVED_TEAM=$(jq -r '.team // empty' "$ACCOUNT_FILE")
if [ "$SAVED_TEAM" != "$CF_TEAM" ]; then
echo "Mode changed (saved: '${SAVED_TEAM:-free}' -> current: '${CF_TEAM:-free}'), re-registering..."
rm -f "$ACCOUNT_FILE"
else
echo "Existing device $OLD_ID found, updating its key instead of re-registering..."
CODE=$(curl -sS -6 -m 25 --tlsv1.2 --tls-max 1.2 --http1.1 -X PATCH \
-H 'User-Agent: okhttp/3.12.1' -H 'CF-Client-Version: a-6.35-4471' -H 'Content-Type: application/json' \
-H "Authorization: Bearer $OLD_TOKEN" \
-d "{\"key\":\"$PUBKEY\",\"key_type\":\"curve25519\",\"tunnel_type\":\"wireguard\"}" \
-o "$RESP" -w '%{http_code}' "$API_HOST/v0a4471/reg/$OLD_ID" || true)
if [ "$CODE" = "200" ] && jq -e '.config.interface.addresses.v4' "$RESP" >/dev/null 2>&1; then
DEVICE_ID="$OLD_ID"
DEVICE_TOKEN=$(jq -r '.token // empty' "$RESP")
[ -z "$DEVICE_TOKEN" ] && DEVICE_TOKEN="$OLD_TOKEN"
echo "Device key updated."
else
echo "Update failed (HTTP $CODE), falling back to fresh registration: $(head -c 200 "$RESP" 2>/dev/null)"
rm -f "$ACCOUNT_FILE"
fi
fi
fi
# 6b. 全新注册
if [ ! -s "$ACCOUNT_FILE" ]; then
if [ -n "$CF_TEAM" ]; then
# 团队注册字段(参照 Aether TeamRegistration)
INSTALL_ID=$(head -c 100 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 22)
FCM_TOKEN="${INSTALL_ID}:APA91b$(head -c 300 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 134)"
BODY=$(jq -nc --arg key "$PUBKEY" --arg tos "$(date -u +%FT%T.000Z)" --arg iid "$INSTALL_ID" \
--arg fcm "$FCM_TOKEN" --arg name "$(hostname)" \
'{key:$key, install_id:$iid, fcm_token:$fcm, tos:$tos, model:"PC", name:$name, serial_number:$iid, locale:"en_US"}')
else
# 免费版注册字段(参照 wgcf + Aether)
SERIAL=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')
BODY=$(jq -nc --arg key "$PUBKEY" --arg tos "$(date -u +%FT%T.000Z)" --arg sn "$SERIAL" \
'{key:$key, install_id:"", fcm_token:"", tos:$tos, model:"PC", serial_number:$sn, os_version:"", key_type:"curve25519", tunnel_type:"wireguard", locale:"en_US", type:"Android"}')
fi
OK=""
for combo in "${API_VERSIONS[@]}"; do
VER=${combo%%|*}; CVER=${combo##*|}
echo "Registering via $API_HOST/$VER/reg (CF-Client-Version: $CVER) ..."
CODE=$(curl -sS -6 -m 25 --tlsv1.2 --tls-max 1.2 --http1.1 -X POST \
-H 'User-Agent: okhttp/3.12.1' -H "CF-Client-Version: $CVER" -H 'Content-Type: application/json' \
${WARP_JWT:+-H "CF-Access-Jwt-Assertion: $WARP_JWT"} \
-d "$BODY" -o "$RESP" -w '%{http_code}' "$API_HOST/$VER/reg" || true)
if [ "$CODE" = "200" ] && jq -e '.id and .config.interface.addresses.v4' "$RESP" >/dev/null 2>&1; then
OK=1
break
fi
echo " failed, HTTP ${CODE:-000}: $(head -c 300 "$RESP" 2>/dev/null)"
done
if [ -z "$OK" ]; then
echo "Error: Cloudflare WARP registration failed on all API versions."
echo "Hint: 403 = VPS IP flagged by Cloudflare; 429 = rate limited. Try later or from another IP."
exit 1
fi
DEVICE_ID=$(jq -r '.id' "$RESP")
DEVICE_TOKEN=$(jq -r '.token' "$RESP")
jq -nc --arg id "$DEVICE_ID" --arg token "$DEVICE_TOKEN" --arg team "$CF_TEAM" \
'{id:$id, token:$token, team:$team}' > "$ACCOUNT_FILE"
echo "Registered device $DEVICE_ID."
fi
# 7. 解析配置(注意: 返回是平铺 JSON,没有 .result 包裹)
CF_IPV4=$(jq -r '.config.interface.addresses.v4 // empty' "$RESP")
CF_IPV6=$(jq -r '.config.interface.addresses.v6 // empty' "$RESP")
PEER_KEY=$(jq -r '.config.peers[0].public_key // empty' "$RESP")
[ -z "$PEER_KEY" ] && PEER_KEY="bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo="
EP6=$(jq -r '.config.peers[0].endpoint.v6 // empty' "$RESP")
# endpoint.v6 可能是 [addr]:port 或裸地址, 统一剥成裸地址
case "$EP6" in
\[*\]*) EP6="${EP6#[}"; EP6="${EP6%%\]*}" ;;
esac
[ -z "$EP6" ] && EP6="2606:4700:d0::a29f:c001" # engage.cloudflareclient.com 的 IPv6
ORG=$(jq -r '.account.organization // empty' "$RESP")
# 8. 写入 wg0.conf(默认纯 IPv4 出口,不碰 IPv6 路由)
mkdir -p /etc/wireguard
cat << EOF > "$WG_CONF"
[Interface]
PrivateKey = $PRIVKEY
Address = ${CF_IPV4}/32${CF_IPV6:+, ${CF_IPV6}/128}
MTU = $MTU
[Peer]
PublicKey = $PEER_KEY
AllowedIPs = $ALLOWED_IPS
Endpoint = [$EP6]:2408
PersistentKeepalive = 25
EOF
chmod 600 "$WG_CONF"
# 9. 启动接口并开启开机自启
systemctl enable wg-quick@wg0
systemctl restart wg-quick@wg0
# 触发 trap 还原 VPS 默认 DNS 后进行网络验证
restore_dns
trap - EXIT INT TERM
rm -rf "$TMPD"
echo "--------------------------------------------------"
echo "Setup complete!${ORG:+ Organization: $ORG}${CF_TEAM:+ (team: $CF_TEAM)}"
echo "Testing connectivity with default DNS:"
# 优先用 Cloudflare 官方 trace 取出口 IP(第一方),第三方 ip.sb 仅作兜底
V4EXIT=$(curl -4 -s --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace | sed -n 's/^ip=//p')
[ -z "$V4EXIT" ] && V4EXIT=$(curl -4 -s --connect-timeout 5 ip.sb 2>/dev/null || echo "Failed")
V6EXIT=$(curl -6 -s --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace | sed -n 's/^ip=//p')
[ -z "$V6EXIT" ] && V6EXIT=$(curl -6 -s --connect-timeout 5 ip.sb 2>/dev/null || echo "Failed")
echo "IPv4 Exit: $V4EXIT"
echo "IPv6 Exit: $V6EXIT"
echo "WARP status: $(curl -4 -s --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace | grep '^warp=' || echo 'unknown')"
[ "$USERSPACE_WG" = "1" ] && echo "Note: 当前为用户态 wireguard-go(无内核模块),速度略慢;如需内核级性能请换 >=5.6 内核的系统镜像"
echo "--------------------------------------------------"