Files
vpsetup/migrate_existing_stack.sh
eli 7550a65c61 feat(migrate): 额外站点块直接装进站点目录——迁移后不再需要人工合并
配合 setup_caddy_xray_hysteria.sh 的 /etc/caddy/sites 扩展点:迁移抽出的非主域站点块
现在直接安装为 CADDY_SITES_DIR/migrated-extra-sites.caddy(干跑落 DRY_RUN_DIR/sites/),
升级后由新 Caddyfile 末尾的 import 自动加载,install/upgrade 永不覆盖该目录;
参考件仍留一份在迁移输出目录。

刻意保留两道「只留参考文件、不自动安装」的保护(装进去会让 caddy validate 失败、
反而挡住升级):① 块里含 dns <provider> 等定制插件指令(官方 apt caddy 不含该插件);
② 装了 caddy 时 `caddy adapt` 预检不通过(只解析适配,不做 TLS 签发)。
另外:缺 bind 127.0.0.1 的块会告警(443 回落流量到不了它)、目标文件已存在时先备份
.bak-<时间戳>、结束语按是否真装分别提示。

测试 +6(共 194):dns 插件块不安装、干净块安装且原文保留、重复迁移先备份、缺 bind 告警。
文档:README 迁移细则 + AGENTS 脚本 4 描述 + HANDOFF §7.2/§7.4。
2026-10-10 11:05:37 +08:00

657 lines
27 KiB
Bash
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
#
# migrate_existing_stack.sh — 把手动部署的 Caddy + Xray + Hysteria2 栈迁移到
# setup_caddy_xray_hysteria.sh 的管理体系(生成 /etc/caddy-xray-hy2/setup.env)
#
# 适用:机器上已有手工/旧脚本部署的 caddy + xray + hysteria2,凭据散落在各配置里,
# 没有 setup.env,无法用 ACTION=upgrade 幂等升级。
#
# 本脚本只做"读取 + 备份 + 生成 setup.env",**不重启/不修改任何运行中的服务**:
# 1. 解析现有 Caddyfile / xray config.json / hysteria config.yaml,
# 提取旧客户端依赖的凭据(uuid / REALITY 密钥 / shortId / WS 路径 / hy2 密码等)
# 2. 交互确认 DOMAIN / EMAIL / WS_PATH 等(默认取解析值)
# 3. nft 体检:hysteria_nat 表干净(不存在或与部署脚本模板完全一致)则报告
# "升级时自动接管";不干净则生成参考配置并提示自行处理,绝不插入 include
# 4. Caddyfile 中主域名之外的站点块整段抽出,并安装进额外站点目录
# (CADDY_SITES_DIR,默认 /etc/caddy/sites)—— 新 Caddyfile 末尾的
# `import <目录>/*.caddy` 会自动加载,install/upgrade 永不覆盖,无需人工合并。
# 两种例外只留参考文件不自动安装:块里含 dns <provider> 等定制插件指令
# (官方 apt caddy 不含该插件,装了会让 caddy validate 失败、反而挡住升级);
# 或 `caddy adapt` 预检不通过。
# 5. 备份三份旧配置到 /etc/caddy-xray-hy2/migration-backup-<时间戳>/
# 6. 写 setup.env(mode 600),格式与部署脚本 save_env 一致
#
# 迁移后运行 sudo ACTION=upgrade bash setup_caddy_xray_hysteria.sh 即可完成全面升级,
# 同协议旧客户端(REALITY / WS / hysteria2)凭据不变、不断线。
#
# 用法:
# sudo bash migrate_existing_stack.sh # 交互式迁移
# DRY_RUN=1 bash migrate_existing_stack.sh # 干跑:读真实配置,输出到 ./dry-run-output/,不动系统
#
# 可选环境变量(测试/非常规路径覆盖):
# CADDYFILE_PATH XRAY_CONFIG_PATH HYSTERIA_CONFIG_PATH NFTABLES_CONF
# NFT_RULESET_FILE(伪造 `nft list table inet hysteria_nat` 输出,供测试)
# ENV_FILE(setup.env 目标路径)DRY_RUN_DIR
#
set -euo pipefail
DRY_RUN="${DRY_RUN:-}"
DRY_RUN_DIR="${DRY_RUN_DIR:-./dry-run-output}"
ENV_FILE="${ENV_FILE:-/etc/caddy-xray-hy2/setup.env}"
CADDYFILE_PATH="${CADDYFILE_PATH:-/etc/caddy/Caddyfile}"
XRAY_CONFIG_PATH="${XRAY_CONFIG_PATH:-/usr/local/etc/xray/config.json}"
HYSTERIA_CONFIG_PATH="${HYSTERIA_CONFIG_PATH:-/etc/hysteria/config.yaml}"
NFTABLES_CONF="${NFTABLES_CONF:-/etc/nftables.conf}"
# 额外站点目录(与 setup_caddy_xray_hysteria.sh 的 CADDY_SITES_DIR 同义):
# 抽出的非主域站点块会被安装到这里,由新 Caddyfile 末尾的 import 自动加载,
# 且 install/upgrade 永不覆盖该目录里的用户文件
CADDY_SITES_DIR="${CADDY_SITES_DIR:-/etc/caddy/sites}"
# 输出目录:生成物(额外站点片段、nft 参考配置)与 setup.env 的落点
if [ -n "$DRY_RUN" ]; then
OUT_DIR="$DRY_RUN_DIR"
else
OUT_DIR="/etc/caddy-xray-hy2/migration"
fi
# 额外站点目录的实际落点:干跑时写进 DRY_RUN_DIR/sites(与主脚本 DRY_RUN 行为一致)
sites_dir() {
if [ -n "$DRY_RUN" ]; then
printf '%s\n' "$DRY_RUN_DIR/sites"
else
printf '%s\n' "$CADDY_SITES_DIR"
fi
}
# 是否有可交互终端(读 /dev/tty 判定,wget|bash 管道场景也可用)
INTERACTIVE=""
if (exec 9<>/dev/tty) 2>/dev/null; then
exec 9>&- 9<&-
INTERACTIVE=1
fi
info() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*" >&2; }
die() { echo "[ERROR] $*" >&2; exit 1; }
# 交互提问:prompt_var VAR_NAME "提示语"(已有值作默认,回车接受;无 tty 跳过)
prompt_var() {
local var="${1:?}" msg="${2:?}"
[ -n "$INTERACTIVE" ] || return 0
local cur input
cur="${!var:-}"
if [ -n "$cur" ]; then
read -r -p "$msg [$cur]: " input </dev/tty || true
else
read -r -p "$msg: " input </dev/tty || true
fi
[ -n "$input" ] && printf -v "$var" '%s' "$input"
return 0
}
# 交互确认:confirm "问题"(无 tty 或非 y 时返回 1)
confirm() {
local msg="${1:?}" input
[ -n "$INTERACTIVE" ] || return 0
read -r -p "$msg [Y/n]: " input </dev/tty || true
case "${input:-y}" in
y|Y|yes|YES) return 0 ;;
*) return 1 ;;
esac
}
# 脱敏显示:前后各留 4 位
mask() {
local s="${1:-}"
if [ "${#s}" -le 10 ]; then printf '%s' "$s"; else printf '%s…%s' "${s:0:4}" "${s: -4}"; fi
}
# ---------------------------------------------------------------------------
# 预检
# ---------------------------------------------------------------------------
preflight() {
if [ -z "$DRY_RUN" ]; then
[ "$(id -u)" -eq 0 ] || die "需要 root 运行(或加 DRY_RUN=1 干跑)"
fi
[ -f "$ENV_FILE" ] && die "$ENV_FILE 已存在:本机已在脚本管理体系内,请直接用 ACTION=upgrade(如需重新迁移请先备份并删除该文件)"
local found=""
for f in "$CADDYFILE_PATH" "$XRAY_CONFIG_PATH" "$HYSTERIA_CONFIG_PATH"; do
[ -f "$f" ] && found=1
done
[ -n "$found" ] || die "未找到任何现有配置($CADDYFILE_PATH / $XRAY_CONFIG_PATH / $HYSTERIA_CONFIG_PATH),无可迁移内容"
command -v python3 >/dev/null 2>&1 || die "需要 python3(apt install python3)"
if [ -n "$DRY_RUN" ]; then
info "DRY_RUN=1:读取真实配置,所有生成物写入 $DRY_RUN_DIR,不做系统变更"
fi
}
# ---------------------------------------------------------------------------
# 解析 Caddyfile(python3:按花括号配对切出顶层块,保留原文)
# 输出 KEY=VALUE 行:CADDY_EMAIL / CADDY_SITES(逗号分隔) / CADDY_WS_HANDLE_PATH / CADDY_WS_PATH_MATCH
# ---------------------------------------------------------------------------
parse_caddy() {
[ -f "$CADDYFILE_PATH" ] || { warn "未找到 $CADDYFILE_PATH,跳过 Caddy 解析"; return 0; }
local out
out="$(python3 - "$CADDYFILE_PATH" <<'PYEOF'
import re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
# 花括号配对扫描:跟踪行注释状态(# 到行尾),在原文上进行以保留偏移可用
blocks = [] # [header_start, brace_start, block_end]
depth = 0
i = 0
header_start = 0
n = len(text)
in_comment = False
while i < n:
c = text[i]
if in_comment:
if c == "\n":
in_comment = False
i += 1
continue
if c == "#":
in_comment = True
i += 1
continue
if c == "{":
if depth == 0:
blocks.append([header_start, i, None])
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
blocks[-1][2] = i
header_start = i + 1
i += 1
email = ""
sites = []
ws_handle = ""
ws_path_match = ""
for hs, bs, be in blocks:
header = re.sub(r"(?m)#.*$", "", text[hs:bs]).strip()
body = text[bs + 1:be]
body_nc = re.sub(r"(?m)#.*$", "", body)
if header == "":
# 全局选项块
m = re.search(r"(?m)^\s*email\s+(\S+)", body_nc)
if m and not email:
email = m.group(1)
continue
# 站点块:header 为地址列表(逗号/空格分隔)
addrs = [a for a in re.split(r"[,\s]+", header) if a]
if not addrs:
continue
sites.extend(addrs)
if "54442" in body_nc:
m = re.search(r"(?m)^\s*handle_path\s+(\S+)", body_nc)
if m and not ws_handle:
ws_handle = m.group(1)
m = re.search(r"(?m)^\s*path\s+(\S+)", body_nc)
if m and not ws_path_match:
ws_path_match = m.group(1)
print("CADDY_EMAIL=" + email)
print("CADDY_SITES=" + ",".join(sites))
print("CADDY_WS_HANDLE_PATH=" + ws_handle)
print("CADDY_WS_PATH_MATCH=" + ws_path_match)
PYEOF
)"
local k v
while IFS='=' read -r k v; do
case "$k" in
CADDY_EMAIL|CADDY_SITES|CADDY_WS_HANDLE_PATH|CADDY_WS_PATH_MATCH) printf -v "$k" '%s' "$v" ;;
esac
done <<< "$out"
info "Caddyfile:站点=${CADDY_SITES:-无} email=${CADDY_EMAIL:-无}"
}
# 抽取非主域站点块原文到 $OUT_DIR/caddy-extra-sites.conf;检测定制 DNS 插件指令与 bind
# 抽出的块随后会被安装进额外站点目录(CADDY_SITES_DIR),由新 Caddyfile 末尾的 import 自动加载。
extract_caddy_extra_sites() {
[ -f "$CADDYFILE_PATH" ] || return 0
[ -n "${CADDY_SITES:-}" ] || return 0
EXTRA_SITES_FILE="$OUT_DIR/caddy-extra-sites.conf"
EXTRA_INSTALLED=""
local res
res="$(python3 - "$CADDYFILE_PATH" "$DOMAIN" "$EXTRA_SITES_FILE" <<'PYEOF'
import re, sys, os
path, domain, outfile = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path, encoding="utf-8", errors="replace").read()
# 与 parse_caddy 相同的注释感知花括号扫描,块文本按原文偏移 verbatim 抽取
blocks = []
depth = 0
i = 0
header_start = 0
n = len(text)
in_comment = False
while i < n:
c = text[i]
if in_comment:
if c == "\n":
in_comment = False
i += 1
continue
if c == "#":
in_comment = True
i += 1
continue
if c == "{":
if depth == 0:
blocks.append([header_start, i, None])
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
blocks[-1][2] = i
header_start = i + 1
i += 1
extra = []
plugins = set()
nobind = 0
for hs, bs, be in blocks:
header = re.sub(r"(?m)#.*$", "", text[hs:bs]).strip()
if not header:
continue
addrs = [a for a in re.split(r"[,\s]+", header) if a]
if not addrs:
continue
# http:// 等协议块是部署脚本自身会生成的,不属于"额外站点"
if all(a.startswith("http://") or a.startswith("https://") for a in addrs):
continue
if domain in addrs:
continue
body_nc = re.sub(r"(?m)#.*$", "", text[bs + 1:be])
for m in re.finditer(r"(?m)^\s*dns\s+(\w+)", body_nc):
plugins.add(m.group(1))
# 没有 bind 127.0.0.1 的块会落到未包装的 :8003 监听器上(443 回落流量到不了它)
if not re.search(r"(?m)^\s*bind\s+127\.0\.0\.1", body_nc):
nobind += 1
extra.append(text[hs:be + 1].strip("\n"))
if extra:
os.makedirs(os.path.dirname(outfile), exist_ok=True)
with open(outfile, "w", encoding="utf-8") as f:
f.write("# 从旧 Caddyfile 抽取的非主域站点块(主域=%s),原文 verbatim\n" % domain)
f.write("# migrate_existing_stack.sh 会把它安装到额外站点目录,由新 Caddyfile 末尾的\n")
f.write("# `import <目录>/*.caddy` 自动加载;install/upgrade 永不覆盖该目录里的用户文件。\n\n")
f.write("\n\n".join(extra))
f.write("\n")
print("EXTRA_COUNT=%d" % len(extra))
print("EXTRA_PLUGINS=" + ",".join(sorted(plugins)))
print("EXTRA_NOBIND=%d" % nobind)
PYEOF
)"
local k v
while IFS='=' read -r k v; do
case "$k" in
EXTRA_COUNT|EXTRA_PLUGINS|EXTRA_NOBIND) printf -v "$k" '%s' "$v" ;;
esac
done <<< "$res"
[ "${EXTRA_COUNT:-0}" -gt 0 ] || return 0
warn "检测到 $EXTRA_COUNT 个非主域站点块,已抽取到 $EXTRA_SITES_FILE"
if [ "${EXTRA_NOBIND:-0}" -gt 0 ]; then
warn " → 其中 $EXTRA_NOBIND 个块没有 bind 127.0.0.1:443 的 REALITY 回落流量到不了它"
warn " (会落到未包装的 :8003 监听器上),建议补上 bind 127.0.0.1 [::1]"
fi
# 定制 dns 插件:装了也没用(caddy validate 会失败,反而挡住升级)→ 只留参考文件
if [ -n "${EXTRA_PLUGINS:-}" ]; then
warn " → 这些块使用了 DNS 插件指令(dns ${EXTRA_PLUGINS//,/, dns }):官方 apt 仓库的 caddy 不含该插件,"
warn " 直接安装会让 caddy validate 失败、从而挡住 ACTION=upgrade,故不自动安装;"
warn " 需保留定制二进制或用 xcaddy 重编后,自行放进 $(sites_dir)/"
return 0
fi
# 装了 caddy 就先 adapt 预检(只解析/适配,不做 TLS provisioning):过不了就不安装
if command -v caddy >/dev/null 2>&1 \
&& ! caddy adapt --config "$EXTRA_SITES_FILE" --adapter caddyfile >/dev/null 2>&1; then
warn " → caddy adapt 预检不通过,未自动安装(只留参考文件;人工核对后放进 $(sites_dir)/)"
return 0
fi
local dir target
dir="$(sites_dir)"
mkdir -p "$dir"
chmod 755 "$dir"
target="$dir/migrated-extra-sites.caddy"
if [ -e "$target" ]; then
cp -a "$target" "$target.bak-$(date +%Y%m%d-%H%M%S)"
warn " → $target 已存在,已备份为 .bak-<时间戳> 后再覆盖"
fi
install -m 644 "$EXTRA_SITES_FILE" "$target"
EXTRA_INSTALLED=1
info " → 已安装到 $target:新 Caddyfile 末尾的 import 会自动加载它,**不再需要人工合并**"
info " (ACTION=upgrade 之后生效;自检:sudo caddy validate --config /etc/caddy/Caddyfile)"
}
# ---------------------------------------------------------------------------
# 解析 xray config.json(剥 // 与 /* */ 注释后按 JSON 解析)
# 输出:XRAY_UUID XRAY_PRIVATE_KEY XRAY_SHORT_ID REALITY_SNI
# XRAY_WS_UUID WS_XRAY_PATH XRAY_XHTTP_UUID XHTTP_PATH_OLD XHTTP_PORT_OLD
# ---------------------------------------------------------------------------
parse_xray() {
[ -f "$XRAY_CONFIG_PATH" ] || { warn "未找到 $XRAY_CONFIG_PATH,跳过 Xray 解析"; return 0; }
local out
out="$(python3 - "$XRAY_CONFIG_PATH" <<'PYEOF'
import json, re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
# 剥注释:状态机避开字符串内部
out = []
i = 0
n = len(text)
in_str = False
while i < n:
c = text[i]
if in_str:
out.append(c)
if c == "\\":
if i + 1 < n:
out.append(text[i + 1])
i += 1
elif c == '"':
in_str = False
i += 1
continue
if c == '"':
in_str = True
out.append(c)
i += 1
elif c == "/" and i + 1 < n and text[i + 1] == "/":
while i < n and text[i] != "\n":
i += 1
elif c == "/" and i + 1 < n and text[i + 1] == "*":
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i += 2
elif c == "#":
while i < n and text[i] != "\n":
i += 1
else:
out.append(c)
i += 1
cfg = json.loads("".join(out))
vals = {}
sids = []
for ib in cfg.get("inbounds", []):
ss = ib.get("streamSettings", {})
net = ss.get("network", "tcp")
sec = ss.get("security", "none")
clients = ib.get("settings", {}).get("clients", [])
cid = clients[0].get("id", "") if clients else ""
if sec == "reality":
rs = ss.get("realitySettings", {})
vals["XRAY_UUID"] = cid
vals["XRAY_PRIVATE_KEY"] = rs.get("privateKey", "")
sids = rs.get("shortIds", [])
vals["XRAY_SHORT_ID"] = sids[0] if sids else ""
sns = rs.get("serverNames", [])
vals["REALITY_SNI"] = sns[0] if sns else ""
vals["SHORTID_COUNT"] = str(len(sids))
elif net == "ws":
vals["XRAY_WS_UUID"] = cid
vals["WS_XRAY_PATH"] = ss.get("wsSettings", {}).get("path", "")
elif net == "xhttp":
vals["XRAY_XHTTP_UUID"] = cid
vals["XHTTP_PATH_OLD"] = ss.get("xhttpSettings", {}).get("path", "")
vals["XHTTP_PORT_OLD"] = str(ib.get("port", ""))
for k, v in vals.items():
print("%s=%s" % (k, v))
PYEOF
)" || die "xray 配置解析失败(JSON 不合法?):$XRAY_CONFIG_PATH"
local k v
while IFS='=' read -r k v; do
case "$k" in
XRAY_UUID|XRAY_PRIVATE_KEY|XRAY_SHORT_ID|REALITY_SNI|SHORTID_COUNT|\
XRAY_WS_UUID|WS_XRAY_PATH|XRAY_XHTTP_UUID|XHTTP_PATH_OLD|XHTTP_PORT_OLD) printf -v "$k" '%s' "$v" ;;
esac
done <<< "$out"
if [ "${SHORTID_COUNT:-0}" -gt 1 ]; then
warn "旧 REALITY 配置有 $SHORTID_COUNT 个 shortId,只迁移第一个($XRAY_SHORT_ID);其余 shortId 的客户端升级后会断,需换新链接"
fi
[ -n "$XRAY_UUID" ] || warn "未在 xray 配置中找到 REALITY 入站(无 REALITY 凭据可迁移,升级时会生成全新凭据)"
info "Xray:REALITY uuid=$(mask "${XRAY_UUID:-}") sni=${REALITY_SNI:-无} WS uuid=$(mask "${XRAY_WS_UUID:-}")"
}
# ---------------------------------------------------------------------------
# 解析 hysteria config.yaml(结构浅,awk 按顶格块划分即可,不依赖 pyyaml)
# 输出:HY2_PASSWORD HY2_ACME_TYPE HY2_ACME_DOMAIN HY2_ACME_EMAIL
# HY2_DNS_PROVIDER CF_API_TOKEN HY2_MASQUERADE_URL
# ---------------------------------------------------------------------------
parse_hysteria() {
[ -f "$HYSTERIA_CONFIG_PATH" ] || { warn "未找到 $HYSTERIA_CONFIG_PATH,跳过 Hysteria2 解析"; return 0; }
HY2_PASSWORD="$(awk '/^auth:/{f=1;next} /^[a-z]/{f=0} f && /password:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH" | tr -d '"' | tr -d "'")"
HY2_ACME_TYPE="$(awk '/^acme:/{f=1;next} /^[a-z]/{f=0} f && /^\s*type:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH")"
HY2_ACME_DOMAIN="$(awk '/^acme:/{f=1;next} /^[a-z]/{f=0} f && /^[[:space:]]*-[[:space:]]*/{sub(/^[[:space:]]*-[[:space:]]*/,""); print; exit}' "$HYSTERIA_CONFIG_PATH" | tr -d "\"'")"
HY2_ACME_EMAIL="$(awk '/^acme:/{f=1;next} /^[a-z]/{f=0} f && /^\s*email:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH")"
HY2_DNS_PROVIDER="$(awk '/^acme:/{f=1;next} /^[a-z]/{f=0} f && /^\s*name:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH")"
if [ "$HY2_ACME_TYPE" = "dns" ]; then
# acme 块内第一个 *_api_token / api_token 值
CF_API_TOKEN="$(awk '/^acme:/{f=1;next} /^[a-z]/{f=0} f && /api_token:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH")"
fi
HY2_MASQUERADE_URL="$(awk '/^masquerade:/{f=1;next} /^[a-z]/{f=0} f && /^\s*url:/{print $2; exit}' "$HYSTERIA_CONFIG_PATH")"
info "Hysteria2:password=$(mask "${HY2_PASSWORD:-}") acme=${HY2_ACME_TYPE:-无} domain=${HY2_ACME_DOMAIN:-无} provider=${HY2_DNS_PROVIDER:-无}"
if [ "$HY2_ACME_TYPE" = "dns" ] && [ "$HY2_DNS_PROVIDER" != "cloudflare" ]; then
warn "hy2 DNS-01 provider 是 $HY2_DNS_PROVIDER,部署脚本仅内置 cloudflare(HY2_CERT_MODE=dns + CF_API_TOKEN)"
warn " → 迁移后若无法沿用 dns 模式,证书形态会变化,通配符 sni 的存量 hy2 客户端可能断线,请人工确认"
fi
}
# ---------------------------------------------------------------------------
# nft 体检(只检测不修改):hysteria_nat 表不存在或与部署脚本模板一致 → 干净
# ---------------------------------------------------------------------------
NFT_CLEAN=""
check_nft() {
local table_out=""
if [ -n "${NFT_RULESET_FILE:-}" ]; then
table_out="$(cat "$NFT_RULESET_FILE")"
elif command -v nft >/dev/null 2>&1; then
table_out="$(nft list table inet hysteria_nat 2>/dev/null || true)"
fi
if [ -z "$table_out" ]; then
NFT_CLEAN=1
info "nft:hysteria_nat 表不存在(干净,升级时会自动创建并接管)"
else
# 统计 nat/redirect 类规则数;恰好一条且是模板那条 → 干净
local rule_count
rule_count="$(printf '%s\n' "$table_out" | grep -cE 'udp dport|redirect|dnat|masquerade' || true)"
if [ "$rule_count" = "1" ] && printf '%s\n' "$table_out" | grep -qF 'udp dport 20000-30000 redirect to :8443'; then
NFT_CLEAN=1
info "nft:hysteria_nat 表与部署脚本模板一致(干净,升级时幂等接管,无重复规则)"
else
NFT_CLEAN=""
warn "nft:hysteria_nat 表存在但与部署脚本模板不一致(含自定义/额外规则)"
warn " → 请自行核对:升级脚本会删除并重建 hysteria_nat 表,自定义规则将丢失"
mkdir -p "$OUT_DIR"
cat > "$OUT_DIR/hysteria-reference.nft" <<'NFT_REF'
# 部署脚本的标准端口跳跃表(参考,不会自动插入 nftables.conf):
table inet hysteria_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
udp dport 20000-30000 redirect to :8443
}
}
NFT_REF
warn " → 参考配置已写入 $OUT_DIR/hysteria-reference.nft(不会插入 $NFTABLES_CONF,请人工合并)"
fi
fi
# include 行重复检测(仅提示)
if [ -f "$NFTABLES_CONF" ]; then
local inc_count
inc_count="$(grep -cF 'include "/etc/nftables/hysteria.nft"' "$NFTABLES_CONF" || true)"
if [ "$inc_count" -gt 1 ]; then
warn "$NFTABLES_CONF 中 hysteria.nft 的 include 行重复 $inc_count 次,请人工去重"
fi
fi
}
# ---------------------------------------------------------------------------
# 备份旧配置
# ---------------------------------------------------------------------------
backup_configs() {
local ts dir
ts="$(date +%Y%m%d-%H%M%S)"
if [ -n "$DRY_RUN" ]; then
dir="$DRY_RUN_DIR/migration-backup-$ts"
else
dir="/etc/caddy-xray-hy2/migration-backup-$ts"
fi
mkdir -p "$dir"
local f
for f in "$CADDYFILE_PATH" "$XRAY_CONFIG_PATH" "$HYSTERIA_CONFIG_PATH"; do
[ -f "$f" ] && cp -a "$f" "$dir/$(basename "$f").$(basename "$(dirname "$f")")"
done
info "旧配置已备份到 $dir"
}
# ---------------------------------------------------------------------------
# 写 setup.env(与部署脚本 save_env 同格式:KEY=%q)
# ---------------------------------------------------------------------------
write_setup_env() {
local target="$ENV_FILE"
if [ -n "$DRY_RUN" ]; then
mkdir -p "$DRY_RUN_DIR"
target="$DRY_RUN_DIR/setup.env"
else
mkdir -p "$(dirname "$ENV_FILE")"
fi
umask 077
{
printf '# setup.env(由 migrate_existing_stack.sh 迁移生成,重跑部署脚本自动读取)\n'
printf '# 迁移时间 %s\n' "$(date '+%F %T')"
local var
for var in DOMAIN EMAIL HY2_CERT_MODE CF_API_TOKEN HY2_PASSWORD HY2_MASQUERADE_URL \
XRAY_UUID XRAY_WS_UUID XRAY_PRIVATE_KEY XRAY_PUBLIC_KEY XRAY_SHORT_ID WS_PATH; do
printf '%s=%q\n' "$var" "${!var:-}"
done
} > "$target"
chmod 600 "$target"
info "setup.env 已写入 $target"
}
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
main() {
preflight
# 解析(只读)
CADDY_EMAIL="" CADDY_SITES="" CADDY_WS_HANDLE_PATH="" CADDY_WS_PATH_MATCH=""
XRAY_UUID="" XRAY_PRIVATE_KEY="" XRAY_SHORT_ID="" REALITY_SNI="" SHORTID_COUNT=0
XRAY_WS_UUID="" WS_XRAY_PATH="" XRAY_XHTTP_UUID="" XHTTP_PATH_OLD="" XHTTP_PORT_OLD=""
HY2_PASSWORD="" HY2_ACME_TYPE="" HY2_ACME_DOMAIN="" HY2_ACME_EMAIL=""
HY2_DNS_PROVIDER="" CF_API_TOKEN="" HY2_MASQUERADE_URL=""
parse_caddy
parse_xray
parse_hysteria
# DOMAIN:默认 REALITY sni → Caddy 站点第一个非特殊地址 → 空
local default_domain="${REALITY_SNI:-}"
if [ -z "$default_domain" ] && [ -n "$CADDY_SITES" ]; then
default_domain="$(printf '%s' "$CADDY_SITES" | tr ',' '\n' | grep -vE '^http://|^https://|\*' | head -n1)"
fi
DOMAIN="${DOMAIN:-$default_domain}"
prompt_var DOMAIN "主域名(须与旧 REALITY sni 一致,否则旧客户端断线)"
[ -n "$DOMAIN" ] || die "缺少 DOMAIN"
[[ "$DOMAIN" =~ ^([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$ ]] || die "DOMAIN 格式不合法:$DOMAIN"
if [ -n "$REALITY_SNI" ] && [ "$REALITY_SNI" != "$DOMAIN" ]; then
warn "DOMAIN=$DOMAIN 与旧 REALITY serverName=$REALITY_SNI 不一致!"
warn " 旧 REALITY 客户端的 sni 钉死 $REALITY_SNI,换域后全部断线(新链接可恢复)"
confirm "确认用新 DOMAIN 继续(旧 REALITY 客户端会断)?" || die "已中止"
fi
EMAIL="${EMAIL:-${CADDY_EMAIL:-${HY2_ACME_EMAIL:-}}}"
prompt_var EMAIL "ACME 注册邮箱"
EMAIL="${EMAIL:-admin@${DOMAIN#*.}}"
# WS_PATH 优先级:Caddy handle_path > xray wsSettings.path(非 / 时)> Caddy path 匹配器 > /scilad
WS_PATH="${WS_PATH:-${CADDY_WS_HANDLE_PATH:-}}"
if [ -z "$WS_PATH" ] && [ -n "$WS_XRAY_PATH" ] && [ "$WS_XRAY_PATH" != "/" ]; then
WS_PATH="$WS_XRAY_PATH"
fi
WS_PATH="${WS_PATH:-${CADDY_WS_PATH_MATCH:-}}"
WS_PATH="${WS_PATH:-/scilad}"
prompt_var WS_PATH "WS 入站路径(旧 WS 客户端使用的路径)"
[[ "$WS_PATH" == /* ]] || WS_PATH="/$WS_PATH"
WS_PATH="${WS_PATH%/}"
[ -n "$WS_PATH" ] || WS_PATH="/"
# hy2 证书模式:dns(仅 cloudflare 可直接沿用)/ http
HY2_CERT_MODE=""
if [ "$HY2_ACME_TYPE" = "dns" ] && [ "$HY2_DNS_PROVIDER" = "cloudflare" ] && [ -n "$CF_API_TOKEN" ]; then
HY2_CERT_MODE="dns"
elif [ "$HY2_ACME_TYPE" = "http" ]; then
HY2_CERT_MODE="http"
fi
if [ -z "$HY2_CERT_MODE" ] && [ -n "$HY2_ACME_TYPE" ]; then
warn "hy2 原证书模式($HY2_ACME_TYPE/${HY2_DNS_PROVIDER:-?})无法自动沿用,将回退为 http 模式(单域名证书)"
HY2_CERT_MODE="http"
fi
# REALITY 公钥:有私钥且本机有 xray 二进制则现推;否则留空(升级时推导)
XRAY_PUBLIC_KEY=""
if [ -n "$XRAY_PRIVATE_KEY" ] && command -v xray >/dev/null 2>&1 && [ -z "$DRY_RUN" ]; then
XRAY_PUBLIC_KEY="$(xray x25519 -i "$XRAY_PRIVATE_KEY" 2>/dev/null | grep -iE 'public|password' | awk -F': ' '{print $2}' | tr -d '[:space:]' || true)"
fi
# 汇总确认
cat <<EOF
========== 迁移解析结果 ==========
DOMAIN : $DOMAIN $([ -n "$REALITY_SNI" ] && [ "$REALITY_SNI" != "$DOMAIN" ] && echo "(旧 sni=$REALITY_SNI ⚠)")
EMAIL : $EMAIL
WS_PATH : $WS_PATH
REALITY uuid : $(mask "$XRAY_UUID")
REALITY 私钥 : $(mask "$XRAY_PRIVATE_KEY")
REALITY shortId : $XRAY_SHORT_ID
REALITY 公钥 : $(mask "$XRAY_PUBLIC_KEY")$([ -z "$XRAY_PUBLIC_KEY" ] && echo "(升级时推导)")
WS uuid : $(mask "$XRAY_WS_UUID")
XHTTP : $([ -n "$XRAY_XHTTP_UUID" ] && echo "旧配置存在(uuid=$(mask "$XRAY_XHTTP_UUID") path=$XHTTP_PATH_OLD port=$XHTTP_PORT_OLD)——部署脚本会生成新 XHTTP 凭据,旧 XHTTP 客户端需换链接" || echo "旧配置无(升级时新建)")
hy2 密码 : $(mask "$HY2_PASSWORD")
hy2 证书模式 : ${HY2_CERT_MODE:-无}$([ "$HY2_CERT_MODE" = "dns" ] && echo "(CF token $(mask "$CF_API_TOKEN"),域名 ${HY2_ACME_DOMAIN:-?})")
hy2 伪装 : ${HY2_MASQUERADE_URL:-默认}
==================================
EOF
confirm "以上参数写入 setup.env?" || die "已中止,未做任何修改"
extract_caddy_extra_sites
check_nft
backup_configs
write_setup_env
cat <<EOF
迁移完成(本脚本未重启/修改任何运行中的服务)。
下一步全面升级:
sudo ACTION=upgrade bash setup_caddy_xray_hysteria.sh
升级将:安装最新官方 Caddy/Xray/Hysteria2,按 setup.env 重写三份配置
(REALITY/WS/hy2 凭据不变,旧客户端不断线),并幂等接管 nft 端口跳跃表。
EOF
# 注意:main 末尾不能用 [ ... ] && warn 形式收尾——条件为假时整条返回 1,
# set -e 会让脚本以非零退出(deploy 脚本 preflight 曾有同款事故)
if [ "${EXTRA_COUNT:-0}" -gt 0 ]; then
if [ -n "${EXTRA_PLUGINS:-}" ] || [ "${EXTRA_INSTALLED:-}" != "1" ]; then
warn "提醒:非主域站点块在 $EXTRA_SITES_FILE(未自动安装),升级后需人工放进 $(sites_dir)/"
else
info "非主域站点块已就位:$(sites_dir)/migrated-extra-sites.caddy(升级后自动生效)"
fi
fi
if [ -z "$NFT_CLEAN" ]; then
warn "提醒:nft 表含自定义规则,升级会重建 hysteria_nat 表,请先人工核对 $OUT_DIR/hysteria-reference.nft"
fi
return 0
}
main "$@"