3b19271b6a
只换 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 里。改完是幂等的:再跑一次只会说"已是新令牌"。
172 lines
7.2 KiB
Bash
Executable File
172 lines
7.2 KiB
Bash
Executable File
#!/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
|