#!/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: # /etc/caddy-xray-hy2/setup.env CF_API_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