7550a65c61
配合 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。
657 lines
27 KiB
Bash
657 lines
27 KiB
Bash
#!/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 "$@"
|