Files
vpsetup/rotate-hy2-cf-token.sh
eli 3b19271b6a feat(rotate): hy2 的 CF 令牌轮换脚本(含一次真实事故的教训)
只换 hysteria 那一路:/etc/hysteria/config.yaml 与 setup.env 里的令牌,重启
hysteria-server。明确不碰 Caddy 的 Caddyfile、docker acme-companion 的 CF_Token、
路由器的 ddns、以及历史备份——它们各自可能用别的令牌,用途拆分前不动。

第一版在这里翻过车,10 台 hysteria 全挂约 4 分钟:
  远端脚本走 stdin 喂给 `bash -s`,脚本里又用 `read` 从 stdin 读令牌,两者抢同一个
  stdin,read 读到了脚本自己的下一行,把源码片段写进了 YAML,解析失败服务起不来。
现在改成:远端脚本先 scp 落地成文件,stdin 只留给令牌。

另外补了两道保险:
  · 重启前先 yaml.safe_load 校验,坏了不重启
  · 备份之后任何一步失败(校验/重启/active/内容核对)都自动回滚回备份再重启

令牌经 stdin 传输,不出现在任何一端的 argv 里。改完是幂等的:再跑一次只会说"已是新令牌"。
2026-11-10 10:42:45 +08:00

172 lines
7.2 KiB
Bash
Executable File
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
#
# rotate-hy2-cf-token.sh — 换掉 hysteria(hy2)的 Cloudflare DNS-01 令牌(默认 dry-run)
#
# 用法:
# ./rotate-hy2-cf-token.sh --hosts-file ~/cloud/hy2-hosts.txt # dry-run
# ./rotate-hy2-cf-token.sh --hosts-file ~/cloud/hy2-hosts.txt --apply
# ./rotate-hy2-cf-token.sh --host root@a.example --host ubuntu@b.example:2222 --apply
#
# 选项:
# --token-file PATH 新令牌文件,默认 ~/cloud/.cf-token(读第一行,永不打印)
# --host SPEC user@host 或 user@host:port,可重复
# --hosts-file PATH 从文件读主机,每行一个,# 开头为注释
# --apply 实际执行
#
# 只改这两个文件里跟 hy2 有关的那一行,改完重启 hysteria-server:
# /etc/hysteria/config.yaml cloudflare_api_token: <TOKEN>
# /etc/caddy-xray-hy2/setup.env CF_API_TOKEN=<TOKEN>
#
# **明确不碰**(它们可能各有各的令牌,用途未拆分前别动):
# · Caddy 的 Caddyfile(`dns cloudflare ...`)——例如 tencent
# · docker 容器 acme-companion 的 CF_Token ——例如 git
# · 路由器的 ddns 配置
# · 历史备份(migration-backup-*、dry-run-output)里的旧值
#
# 安全教育(这个脚本的第一版踩过):
# 第一版把「远端脚本」通过 stdin 管道喂给 `bash -s`,而脚本里又用 `read` 从 stdin
# 读令牌——两者抢同一个 stdin,结果 `read` 读到了脚本自己的下一行,把垃圾写进了
# YAML,10 台 hysteria 全挂(约 4 分钟)。现在的做法是:**脚本落地成文件再执行,
# stdin 只留给令牌**。另外重启前先验 YAML、任何一步失败自动回滚。
set -uo pipefail
TOKEN_FILE="${TOKEN_FILE:-$HOME/cloud/.cf-token}"
KNOWN_HOSTS="${KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
APPLY=0
HOSTS=()
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; }
usage() { sed -n '2,19p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--token-file) shift; TOKEN_FILE="${1:?--token-file 需要参数}" ;;
--host) shift; HOSTS+=("${1:?--host 需要参数}") ;;
--hosts-file) shift; HF="${1:?--hosts-file 需要参数}"
[ -r "$HF" ] || die "读不到主机清单:$HF"
while IFS= read -r l; do
l="${l%%#*}"; l="${l//[[:space:]]/}"
[ -n "$l" ] && HOSTS+=("$l")
done < "$HF" ;;
--apply) APPLY=1 ;;
--dry-run) APPLY=0 ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数:$1(--help 查看用法)" ;;
esac
shift
done
[ -r "$TOKEN_FILE" ] || die "读不到令牌文件:$TOKEN_FILE"
NEWTOK=$(head -1 "$TOKEN_FILE" | tr -d '\r\n')
[ "${#NEWTOK}" -ge 32 ] || die "令牌长度异常(${#NEWTOK}),检查 $TOKEN_FILE"
case "$NEWTOK" in *[!A-Za-z0-9_-]*) die "令牌含非法字符,拒绝使用" ;; esac
[ "${#HOSTS[@]}" -gt 0 ] || die "至少给一个 --host 或 --hosts-file"
SSH_BASE=(-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$KNOWN_HOSTS" -o LogLevel=ERROR)
echo "════════════════════════════════════════"
echo " 主机数 : ${#HOSTS[@]}"
echo " 令牌 : ${TOKEN_FILE}(长度 ${#NEWTOK},尾4 …${NEWTOK: -4})"
echo " 模式 : $([ "$APPLY" -eq 1 ] && echo 'APPLY(会改文件并重启 hysteria)' || echo 'dry-run(加 --apply 才动)')"
echo "════════════════════════════════════════"
# 远端执行体:写到本地临时文件 → scp 到主机 → 执行。stdin 只给令牌。
REMOTE=$(mktemp) || die "mktemp 失败"
trap 'rm -f "$REMOTE"' EXIT
cat > "$REMOTE" <<'REMOTE_SCRIPT'
#!/bin/bash
set -u
DRY=$1
read -r NEWTOK
[ -n "$NEWTOK" ] || { echo " [x] 没收到新令牌"; exit 2; }
case "$NEWTOK" in *[!A-Za-z0-9_-]*) echo " [x] 令牌含非法字符"; exit 3 ;; esac
[ ${#NEWTOK} -ge 32 ] || { echo " [x] 令牌长度异常"; exit 3; }
CFG=/etc/hysteria/config.yaml
ENVF=/etc/caddy-xray-hy2/setup.env
[ -f "$CFG" ] || { echo " [x] 没有 $CFG"; exit 4; }
OLD=$(sed -n 's/^[[:space:]]*cloudflare_api_token:[[:space:]]*\([A-Za-z0-9_-]*\).*/\1/p' "$CFG" | head -1)
[ -n "$OLD" ] || { echo " [x] $CFG 里没有 cloudflare_api_token"; exit 5; }
echo " 现有令牌尾4: …${OLD: -4}"
[ "$OLD" = "$NEWTOK" ] && { echo " 已是新令牌,跳过"; exit 0; }
if [ "$DRY" = "1" ]; then
echo " [dry-run] 将替换 $CFG 与 $ENVF,然后重启 hysteria-server"
exit 0
fi
TS=$(date -u +%Y%m%d-%H%M%S)
cp -a "$CFG" "$CFG.bak-$TS" || { echo " [x] 备份失败"; exit 6; }
[ -f "$ENVF" ] && cp -a "$ENVF" "$ENVF.bak-$TS"
rollback() {
cp -a "$CFG.bak-$TS" "$CFG"
[ -f "$ENVF.bak-$TS" ] && cp -a "$ENVF.bak-$TS" "$ENVF"
systemctl restart hysteria-server
echo " 已回滚到备份 $TS"
}
export OLD NEWTOK
python3 - <<'PY'
import os, re
old, new = os.environ['OLD'], os.environ['NEWTOK']
for path, pat in (('/etc/hysteria/config.yaml', r'(cloudflare_api_token:[ \t]*)\S+'),
('/etc/caddy-xray-hy2/setup.env', r'(CF_API_TOKEN=)\S+')):
try: s = open(path).read()
except FileNotFoundError:
print(' %s: 不存在,跳过' % path); continue
s2, n = re.subn(pat, lambda m: m.group(1) + new, s)
if n: open(path, 'w').write(s2)
print(' %s: 替换 %d 处' % (path, n))
PY
python3 -c "import yaml; yaml.safe_load(open('$CFG'))" 2>/dev/null || {
echo " [x] YAML 校验失败"; rollback; exit 7; }
echo " YAML 校验通过"
systemctl restart hysteria-server || { echo " [x] 重启失败"; rollback; exit 8; }
sleep 2
systemctl is-active --quiet hysteria-server || { echo " [x] 没起来"; rollback; exit 9; }
if ! NEWTOK="$NEWTOK" OLD="$OLD" python3 -c "
import os, sys
s = open('/etc/hysteria/config.yaml').read()
sys.exit(0 if (os.environ['NEWTOK'] in s and os.environ['OLD'] not in s) else 1)
"; then
echo " [x] 校验失败(新令牌缺失或旧令牌残留)"; rollback; exit 10
fi
echo " ✅ 已切换,hysteria active,8443 在听=$(ss -uln 2>/dev/null | grep -c ':8443 ')"
exit 0
REMOTE_SCRIPT
bash -n "$REMOTE" || die "远端脚本语法错误"
rc_all=0
for h in "${HOSTS[@]}"; do
tgt="$h"; port=""
case "$tgt" in *:*) port="${tgt##*:}"; tgt="${tgt%:*}" ;; esac
opts=("${SSH_BASE[@]}")
[ -n "$port" ] && opts+=(-p "$port")
echo
echo "── $h"
scp -q "${opts[@]}" "$REMOTE" "$tgt:/tmp/.rot-hy2.sh" 2>/dev/null \
|| { warn " 上传失败"; rc_all=1; continue; }
# $([ "$APPLY" ... ]) 是【本机】展开,故意如此:远端据此决定 dry-run 还是真改
# shellcheck disable=SC2029
printf '%s\n' "$NEWTOK" \
| ssh "${opts[@]}" "$tgt" "sudo -n bash /tmp/.rot-hy2.sh $([ "$APPLY" -eq 1 ] && echo 0 || echo 1); rc=\$?; sudo -n rm -f /tmp/.rot-hy2.sh; exit \$rc" 2>&1 \
| sed 's/^/ /'
[ "${PIPESTATUS[1]}" -eq 0 ] || { warn " $h 失败"; rc_all=1; }
done
echo
if [ "$APPLY" -eq 0 ]; then
warn "以上是 dry-run。确认后加 --apply。"
exit 0
fi
if [ "$rc_all" -eq 0 ]; then info "全部完成"; else die "有主机失败,见上面的输出"; fi