Files
vpsetup/setup_caddy_xray_hysteria.sh

1089 lines
42 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
#
# setup_caddy_xray_hysteria.sh — 一键在 Debian/Ubuntu VPS 部署
# Caddy + XrayVLESS+REALITY / WS / XHTTP+ Hysteria2 + nftables 端口跳跃
#
# 架构:
# - Xray 监听 443VLESS+REALITYxtls-rprx-vision),非 REALITY 流量回落(fallback
# 到 127.0.0.1:8003 的 CaddyPROXY protocol 接收,xver 1),由 Caddy 提供伪装站点
# - Xray 另开 127.0.0.1:54442VLESS+WS,路径 /scilad)与 127.0.0.1:8080
# VLESS+XHTTP,随机路径,mode auto),均经 Caddy 反代暴露
# - Caddy 站点为 DOMAIN(灰云,REALITY 不能过 CDN);可选 CDN_DOMAIN(橙云)独立站点块,
# 仅承载 WS/XHTTP 回源
# - Hysteria2 监听 8443/UDP。证书默认 HY2_CERT_MODE=httphy2 自带 ACMEcertmagic
# 走 HTTP-01challenge 监听 127.0.0.1:9180,由 Caddy 的 80 端口反代
# /.well-known/acme-challenge/ 完成验证——不新开公网端口、不需要 DNS API Token
# 证书为非通配 DOMAIN(与 Caddy/REALITY 一致),自动续期且热生效。
# 备选 HY2_CERT_MODE=dnsCloudflare DNS-01 签 *.BASE_DOMAIN 通配符(需 CF_API_TOKEN
# - nftables 把 UDP 20000-30000 redirect 到 8443(端口跳跃)
#
# 用法:
# sudo bash setup_caddy_xray_hysteria.sh # 安装(交互式收集参数)
# sudo ACTION=upgrade bash setup_caddy_xray_hysteria.sh # 升级全部组件到最新版(要求已安装,不交互)
# sudo ACTION=uninstall bash setup_caddy_xray_hysteria.sh # 卸载
# DRY_RUN=1 bash setup_caddy_xray_hysteria.sh # 干跑:只渲染配置到 ./dry-run-output
#
# 参数通过环境变量传入;缺失且有 tty 时交互询问(读 /dev/tty,支持 wget|bash)。
# 必填:DOMAIN;可选:EMAIL HY2_CERT_MODE(http|dns) CF_API_TOKEN(仅 dns 模式)
# CDN_DOMAIN XRAY_UUID XRAY_WS_UUID XRAY_XHTTP_UUID XRAY_PRIVATE_KEY XRAY_SHORT_ID
# XHTTP_PATH XHTTP_PORT XHTTP_PADDING(extra padding,默认开,=0 关闭;链接带 extra 参数) HY2_PASSWORD HY2_MASQUERADE_URL
# XRAY_VLESS_ENC=1 启用 VLESS 后量子加密(xray vlessenc 生成 ML-KEM-768 密钥对,
# 客户端需 Xray ≥25.8;密钥保存进 setup.env,重跑不重新生成)
# 留空的凭据会自动生成,并保存到 /etc/caddy-xray-hy2/setup.envmode 600),
# 重跑时自动读取作默认值,避免重新生成导致已分发客户端失效。
# 其他开关:SKIP_CADDY SKIP_XRAY SKIP_HYSTERIA SKIP_NFTABLES=1 跳过对应组件)、
# DIST_UPGRADE=1(安装前 apt-get dist-upgrade)、PURGE=1(卸载时连包一起删)、
# KEEP_ENV=1(卸载时保留 setup.env)、
# ECH_CONFIGCDN 链接 ech= 参数:默认内嵌 "域名+udp://9.9.9.9" 动态查询串,
# 客户端拨号时自动刷新;=off 关闭;其他值原样嵌入,如静态 base64 快照)
#
# 供应链说明:与 setup_warp_zerotrust.sh「只用发行版签名源」不同,本脚本按官方文档
# 引入 cloudsmith apt 源(Caddy 官方仓库),并执行 Xray / Hysteria2 官方安装脚本
# XTLS/Xray-install、get.hy2.sh)。除此之外不下载任何二进制或远程代码。
#
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# ---------------------------------------------------------------------------
# 全局变量
# ---------------------------------------------------------------------------
ACTION="${ACTION:-install}"
DRY_RUN="${DRY_RUN:-}"
DRY_RUN_DIR="${DRY_RUN_DIR:-./dry-run-output}"
SKIP_CADDY="${SKIP_CADDY:-}"
SKIP_XRAY="${SKIP_XRAY:-}"
SKIP_HYSTERIA="${SKIP_HYSTERIA:-}"
SKIP_NFTABLES="${SKIP_NFTABLES:-}"
DIST_UPGRADE="${DIST_UPGRADE:-}"
PURGE="${PURGE:-}"
KEEP_ENV="${KEEP_ENV:-}"
ENV_FILE="${ENV_FILE:-/etc/caddy-xray-hy2/setup.env}"
NFTABLES_CONF="${NFTABLES_CONF:-/etc/nftables.conf}"
HYSTERIA_NFT="/etc/nftables/hysteria.nft"
# 是否有可交互终端(尝试打开 /dev/tty 判定,wget|bash 管道场景也有 tty 则可用)
# 注:不能用 [ -t 0 ]wget|bash 时 stdin 是管道但 /dev/tty 仍可交互
INTERACTIVE=""
if (exec 9<>/dev/tty) 2>/dev/null; then
exec 9>&- 9<&-
INTERACTIVE=1
fi
# 渲染时参与替换的变量清单(占位符 __X__ ← $X)
RENDER_VARS=(
DOMAIN BASE_DOMAIN EMAIL CF_API_TOKEN CDN_DOMAIN
XRAY_UUID XRAY_WS_UUID XRAY_XHTTP_UUID XRAY_PRIVATE_KEY XRAY_PUBLIC_KEY XRAY_SHORT_ID
XHTTP_PATH XHTTP_PORT XHTTP_EXTRA
HY2_PASSWORD HY2_MASQUERADE_URL HY2_CERT_MODE HY2_ACME_PORT
XRAY_VLESS_DECRYPTION XRAY_VLESS_ENCRYPTION
)
# 跨函数共享的派生变量
BASE_DOMAIN="${BASE_DOMAIN:-}"
XRAY_PUBLIC_KEY="${XRAY_PUBLIC_KEY:-}"
HY2_HTTP_STANDALONE="" # SKIP_CADDY=1 时 hy2 直接监听 80 做 HTTP-01
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
info() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*" >&2; }
die() { echo "[ERROR] $*" >&2; exit 1; }
# 生成 N 字节随机数的小写 hex(2N 个字符)
gen_hex_bytes() {
local n="${1:?gen_hex_bytes 需要字节数参数}"
od -An -tx1 -v -N "$n" /dev/urandom | tr -d ' \n'
}
# URL 编码(等价 encodeURIComponent):仅保留 unreserved 字符,纯 bash 实现
urlencode() {
local s="${1:?urlencode 需要参数}" i c out=""
for (( i=0; i<${#s}; i++ )); do
c="${s:i:1}"
case "$c" in
[a-zA-Z0-9.~_-]) out+="$c" ;;
*) printf -v c '%%%02X' "'$c"; out+="$c" ;;
esac
done
printf '%s' "$out"
}
# 交互提问: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
[ -n "$input" ] && printf -v "$var" '%s' "$input"
else
read -r -p "$msg: " input </dev/tty || true
[ -n "$input" ] && printf -v "$var" '%s' "$input"
fi
return 0
}
# 渲染:从 stdin 读模板,按 RENDER_VARS 把 __X__ 替换为 $X,输出到 stdout
# 用 bash 参数展开而非 sed,规避密码中特殊字符的转义问题(随机值均为 hex,无风险)
render() {
local content var
content="$(cat)"
for var in "${RENDER_VARS[@]}"; do
content="${content//__${var}__/${!var:-}}"
done
printf '%s\n' "$content"
}
# 写文件:write_file <目标路径> <权限> <内容>
# DRY_RUN=1 时改写到 $DRY_RUN_DIR/<basename>,不碰真实系统
write_file() {
local path="${1:?}" mode="${2:?}" content="${3:?}"
local target="$path"
if [ -n "$DRY_RUN" ]; then
target="$DRY_RUN_DIR/$(basename "$path")"
mkdir -p "$DRY_RUN_DIR"
else
mkdir -p "$(dirname "$path")"
fi
printf '%s\n' "$content" > "$target"
chmod "$mode" "$target"
info "已写入 $target"
}
# ---------------------------------------------------------------------------
# 阶段函数(Task 1 为桩,后续任务逐个实现)
# ---------------------------------------------------------------------------
# 预检:root、发行版
preflight() {
if [ -z "$DRY_RUN" ]; then
[ "$(id -u)" -eq 0 ] || die "需要 root 运行(或加 DRY_RUN=1 干跑)"
fi
if [ -z "$DRY_RUN" ]; then
command -v apt-get >/dev/null 2>&1 || die "仅支持 apt 系发行版(Debian/Ubuntu"
fi
# 注意:函数最后一个命令不能以非零返回,否则 set -e 会把脚本静默杀掉。
# 所以这里必须用 if 块(条件为假时 if 返回 0),不能写成 [ ... ] && info ...
if [ -n "$DRY_RUN" ]; then
info "DRY_RUN=1:所有文件渲染到 $DRY_RUN_DIR,不做系统变更"
fi
}
# 收集参数:环境变量/ENV_FILE 优先,缺失且有 tty 时交互,仍缺失的自动生成
collect_params() {
# 上次保存的参数作为默认值(重跑不重新生成,避免已分发客户端失效)
local sourced_env=""
if [ -f "$ENV_FILE" ]; then
info "读取已保存参数:$ENV_FILE"
# shellcheck disable=SC1090
. "$ENV_FILE"
sourced_env=1
# 兼容旧版 setup.env(无 HY2_CERT_MODE 字段):当时只有 dns 一种模式,
# 有 CF_API_TOKEN 即推断为 dns,避免升级/重跑把通配符证书静默切成单域名
if [ -z "${HY2_CERT_MODE:-}" ] && [ -n "${CF_API_TOKEN:-}" ]; then
HY2_CERT_MODE=dns
info "旧版参数文件:按 CF_API_TOKEN 推断 HY2_CERT_MODE=dns(保持原部署形态)"
fi
fi
# 升级模式:必须有既有安装(参数文件),且完全不交互
if [ "$ACTION" = "upgrade" ]; then
[ -n "$sourced_env" ] || die "未找到 $ENV_FILE:未安装过,无法用 upgrade(请先运行安装)"
INTERACTIVE=""
info "ACTION=upgrade:升级全部组件到最新版并重载配置(参数沿用 $ENV_FILE"
fi
prompt_var DOMAIN "主域名(灰云,用于 Caddy 站点与 REALITY,如 www.example.com"
if [ -z "${DOMAIN:-}" ]; then
die "缺少 DOMAIN(环境变量传入或交互输入)"
fi
[[ "$DOMAIN" =~ ^([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$ ]] || die "DOMAIN 格式不合法:$DOMAIN"
BASE_DOMAIN="${DOMAIN#*.}"
[ "$BASE_DOMAIN" != "$DOMAIN" ] || die "DOMAIN 需要是子域名(至少两段):$DOMAIN"
prompt_var EMAIL "ACME 注册邮箱"
EMAIL="${EMAIL:-admin@$BASE_DOMAIN}"
# hy2 证书模式:http(默认,certmagic HTTP-01,经 Caddy 80 端口反代验证,
# 无需 DNS token)或 dnsCloudflare DNS-01 通配符,需 CF_API_TOKEN
HY2_CERT_MODE="${HY2_CERT_MODE:-http}"
HY2_ACME_PORT="${HY2_ACME_PORT:-9180}"
if [ -z "$SKIP_HYSTERIA" ]; then
case "$HY2_CERT_MODE" in
http)
if [ -n "$SKIP_CADDY" ]; then
# 没有 Caddy 时 hy2 直接监听 80 做 HTTP-01standalone 模式),
# 运行时再检测 80 是否空闲
HY2_HTTP_STANDALONE=1
[ "${HY2_ACME_PORT:-9180}" = "9180" ] || \
warn "SKIP_CADDY=1 时 HY2_ACME_PORT 无效:CA 只连 80hy2 必须直接监听 80"
info "SKIP_CADDY=1hy2 将直接监听 80 端口完成 HTTP-01 验证"
fi
;;
dns)
prompt_var CF_API_TOKEN "Cloudflare API Token(需 Zone.DNS 编辑权限,用于 hy2 泛域名证书)"
[ -n "${CF_API_TOKEN:-}" ] || \
die "HY2_CERT_MODE=dns 需要 CF_API_TOKEN(或设 SKIP_HYSTERIA=1 跳过 Hysteria2"
;;
*) die "未知 HY2_CERT_MODE=$HY2_CERT_MODE(支持 http / dns" ;;
esac
fi
prompt_var CDN_DOMAIN "CDN 域名(橙云,可选,仅承载 WS/XHTTP 回源;留空跳过)"
# 凭据:留空则自动生成
XRAY_UUID="${XRAY_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
XRAY_WS_UUID="${XRAY_WS_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
XRAY_XHTTP_UUID="${XRAY_XHTTP_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
XRAY_SHORT_ID="${XRAY_SHORT_ID:-$(gen_hex_bytes 4)}"
XHTTP_PATH="${XHTTP_PATH:-/$(gen_hex_bytes 4)}"
XHTTP_PORT="${XHTTP_PORT:-8080}"
# XHTTP extra padding(默认开启,XHTTP_PADDING=0 关闭):padding 位置/键名必须
# 两端完全一致,服务端单方面开启会让没带 extra 的客户端全部 400(实测确认),
# 故服务端 extra 块与分享链接的 extra= 参数(分享标准 4.3.19)用同一份随机
# header/key。注意:忽略 extra= 的旧客户端会连不上,存量客户端需全部换新链接
XHTTP_PADDING="${XHTTP_PADDING:-1}"
XHTTP_EXTRA=""
XHTTP_EXTRA_JSON=""
if [ "$XHTTP_PADDING" != "0" ]; then
XPADDING_HEADER="${XPADDING_HEADER:-X-$(gen_hex_bytes 2)}"
XPADDING_KEY="${XPADDING_KEY:-_$(gen_hex_bytes 2)}"
XHTTP_EXTRA_JSON="$(printf '{"xPaddingObfsMode":true,"xPaddingMethod":"tokenish","xPaddingPlacement":"queryInHeader","xPaddingHeader":"%s","xPaddingKey":"%s"}' "$XPADDING_HEADER" "$XPADDING_KEY")"
# shellcheck disable=SC2034 # 经 RENDER_VARS 由 render() 的间接展开使用
XHTTP_EXTRA="$(printf ',\n "extra": {\n "xPaddingObfsMode": true,\n "xPaddingMethod": "tokenish",\n "xPaddingPlacement": "queryInHeader",\n "xPaddingHeader": "%s",\n "xPaddingKey": "%s"\n }' "$XPADDING_HEADER" "$XPADDING_KEY")"
fi
HY2_PASSWORD="${HY2_PASSWORD:-$(openssl rand -hex 16)}"
HY2_MASQUERADE_URL="${HY2_MASQUERADE_URL:-https://engineersblog.net}"
# XRAY_PRIVATE_KEY 留空,待 generate_xray_secrets 用 xray x25519 生成
# VLESS 后量子加密(XRAY_VLESS_ENC=1 开启):服务端 decryption 与客户端
# encryption 字符串由 generate_xray_secrets 用 `xray vlessenc` 生成(ML-KEM-768 段)
if [ -z "${XRAY_VLESS_ENC:-}" ]; then
XRAY_VLESS_DECRYPTION="none"
XRAY_VLESS_ENCRYPTION=""
else
# setup.env 里已生成过则沿用(重跑不换密钥,避免客户端失效);
# 为空或残留 "none" 时由 generate_xray_secrets 生成
case "${XRAY_VLESS_DECRYPTION:-}" in
""|none) XRAY_VLESS_DECRYPTION=""; XRAY_VLESS_ENCRYPTION="" ;;
esac
fi
# standalone HTTP-0180/TCP 必须空闲(CA 从公网连 80,只能 hy2 自己占)
if [ -n "$HY2_HTTP_STANDALONE" ] && [ -z "$DRY_RUN" ]; then
if ss -ltnH "sport = :80" 2>/dev/null | grep -q .; then
die "SKIP_CADDY=1 但 80/TCP 已被占用,hy2 无法完成 HTTP-01 验证(释放 80 端口或改用 HY2_CERT_MODE=dns"
fi
fi
}
# 系统准备:更新 apt 索引,可选 dist-upgrade
system_prepare() {
[ -n "$DRY_RUN" ] && return 0
info "apt-get update"
apt-get update -qq
if [ -n "$DIST_UPGRADE" ]; then
info "apt-get dist-upgrade"
apt-get dist-upgrade -y
fi
}
# 安装 Caddy:官方 cloudsmith apt 源
install_caddy() {
[ -n "$SKIP_CADDY" ] && { info "SKIP_CADDY=1,跳过 Caddy 安装"; return 0; }
[ -n "$DRY_RUN" ] && return 0
info "添加 Caddy 官方 apt 源(cloudsmith"
mkdir -p /etc/apt/keyrings
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
| gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq
apt-get install -y caddy
}
# 渲染 /etc/caddy/Caddyfile
# 监听 127.0.0.1:8003PROXY protocol + tls 双 wrapper,收 Xray REALITY 回落);
# 站点 = DOMAIN(伪装反代 engineersblog.net);/scilad → WS:54442
# __XHTTP_PATH__ → XHTTP:__XHTTP_PORT__;可选 CDN_DOMAIN 站点块(仅 WS/XHTTP/伪装)
render_caddyfile() {
[ -n "$SKIP_CADDY" ] && return 0
# hy2 走 HTTP-01 时,Caddy 在 80 端口把 challenge 请求反代给 hy2 的本机 ACME 端口。
# Caddy 只拦截自己正在签发的 token,hy2 的 token 会落到此路由。
# 关键:redir 在 Caddyfile 默认指令序里排在 handle 之前,且无匹配器的 redir 会
# 通吃所有请求(LE 的 challenge 会被 301 到 443 导致验证失败)——必须给 redir
# 加上"排除 challenge 路径"的匹配器,使命令执行顺序与书写顺序无关
local acme_route=""
if [ -z "$SKIP_HYSTERIA" ] && [ "$HY2_CERT_MODE" = "http" ]; then
acme_route="$(cat <<'ACME_ROUTE_EOF'
@acme_challenge path /.well-known/acme-challenge/*
handle @acme_challenge {
reverse_proxy 127.0.0.1:__HY2_ACME_PORT__
}
@no_acme_challenge not path /.well-known/acme-challenge/*
redir @no_acme_challenge https://{host}:443{uri} permanent
ACME_ROUTE_EOF
)"
fi
local cdn_block=""
if [ -n "${CDN_DOMAIN:-}" ]; then
cdn_block="$(cat <<'CDN_EOF'
# CDN 域名站点块(橙云):CF 回源 443 → Xray REALITY 非 REALITY 流量统一回落到
# Caddy 8003,按 SNI 进入本站;仅承载 WS/XHTTP 与伪装反代
__CDN_DOMAIN__ {
bind 127.0.0.1 [::1]
encode gzip zstd
@xhttppaths {
path __XHTTP_PATH__ __XHTTP_PATH__/*
}
@wspaths {
path /scilad /scilad/*
}
@notpaths {
not path __XHTTP_PATH__ __XHTTP_PATH__/* /scilad /scilad/*
}
tls {
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
curves x25519 secp521r1 secp384r1 secp256r1
# 本监听器只收 Xray REALITY 回落的 TCP 流量,h3QUIC/UDP)永远用不到;
# 通告 h3 反而有毒:客户端 ALPN 若含 h3 会在 TCP 上误协商出 h3 直接挂死
alpn h2 http/1.1
}
reverse_proxy @notpaths https://engineersblog.net {
header_up Host {upstream_hostport}
}
reverse_proxy @xhttppaths 127.0.0.1:__XHTTP_PORT__
# WS 路由不能用 handle_path(会剥掉 /scilad 前缀,xray wsSettings.path 收不到)
reverse_proxy @wspaths 127.0.0.1:54442
root * /usr/share/caddy
file_server
}
CDN_EOF
)"
fi
local content
content="$(cat <<'CADDY_EOF'
{
# General Options
#debug
email __EMAIL__
# 必须关闭自动重定向:Caddy 会为它管理证书的域名(DOMAIN/CDN_DOMAIN)在运行时
# 向 80 端口服务器注入自动 HTTP→HTTPS 重定向路由(静态 adapt 输出里看不到),
# 该路由带 host 匹配器、排在本 catch-all 站点块的路由之前,且不排除 ACME
# challenge 路径——Caddy 自己的 token 由更靠前的拦截器应答,hy2 的 token
# 会被 308 到 443(经 Xray 回落进主站点伪装反代,LE 拿到 404)。
# 80 端口的重定向已由下方 http:// 块手动接管,故禁用自动的
auto_https disable_redirects
servers 127.0.0.1:8003 {
#[::1]:8003
listener_wrappers {
proxy_protocol {
#timeout 2s
allow 127.0.0.1/32 ::1/128
#deny 10.0.0.0/8
#fallback_policy reject
}
tls
}
protocols h1 h2 h3
}
https_port 8003
}
CADDY_EOF
)"
# 80 端口块单独拼接(acme_route 需展开;$(...) 会吞掉尾部换行,此处显式拼接):
# hy2 的 HTTP-01 challenge 反代,其余 HTTP 请求一律跳转到 443
local http_block
if [ -n "$acme_route" ]; then
http_block="http:// {
${acme_route}
}"
else
http_block="http:// {
redir https://{host}:443{uri} permanent
}"
fi
content+="$(printf '\n%s\n' "$http_block")"
content+="$(cat <<'CADDY_EOF'
# 主站点(灰云,REALITY 回落进入):伪装反代 + WS(/scilad) + XHTTP(__XHTTP_PATH__)
__DOMAIN__ {
bind 127.0.0.1 [::1]
encode gzip zstd
php_fastcgi unix//run/php/php7.4-fpm.sock
@proxypaths {
path /portainer /portainer/*
}
@xhttppaths {
path __XHTTP_PATH__ __XHTTP_PATH__/*
}
# 伪装反代的排除列表必须包含 XHTTP 路径,否则 XHTTP 请求被反代走
@notpaths {
not path /portainer /portainer/* /scilad /scilad/* __XHTTP_PATH__ __XHTTP_PATH__/*
}
tls {
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
curves x25519 secp521r1 secp384r1 secp256r1
# 本监听器只收 Xray REALITY 回落的 TCP 流量,h3QUIC/UDP)永远用不到;
# 通告 h3 反而有毒:客户端 ALPN 若含 h3 会在 TCP 上误协商出 h3 直接挂死
alpn h2 http/1.1
}
reverse_proxy @notpaths https://engineersblog.net {
header_up Host {upstream_hostport}
}
# WS 路由:不能用 handle_path——它会剥掉 /scilad 前缀,xray 端
# wsSettings.path=/scilad 收不到路径直接 404(线上实测确认)
@websockets {
path /scilad /scilad/*
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websockets 127.0.0.1:54442
reverse_proxy @xhttppaths 127.0.0.1:__XHTTP_PORT__
reverse_proxy @proxypaths localhost:9000
root * /usr/share/caddy
file_server
}
CADDY_EOF
)"
[ -n "$cdn_block" ] && content+="$cdn_block"
write_file /etc/caddy/Caddyfile 644 "$(printf '%s' "$content" | render)"
}
# 安装 Xray:官方安装脚本
install_xray() {
[ -n "$SKIP_XRAY" ] && { info "SKIP_XRAY=1,跳过 Xray 安装"; return 0; }
[ -n "$DRY_RUN" ] && return 0
info "安装 Xray(官方 install-release.sh"
bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
}
# 生成 REALITY x25519 密钥对;UUID 缺省在 collect_params 已用 /proc uuid 补齐,
# 这里优先用 xray uuid 重新生成(仅当用户完全未提供且非 DRY_RUN)
generate_xray_secrets() {
[ -n "$SKIP_XRAY" ] && return 0
if [ -n "$DRY_RUN" ]; then
XRAY_PUBLIC_KEY="${XRAY_PUBLIC_KEY:-DRYRUN_PUBLIC_KEY}"
if [ -n "${XRAY_VLESS_ENC:-}" ]; then
XRAY_VLESS_DECRYPTION="${XRAY_VLESS_DECRYPTION:-DRYRUN_VLESS_DECRYPTION}"
XRAY_VLESS_ENCRYPTION="${XRAY_VLESS_ENCRYPTION:-DRYRUN_VLESS_ENCRYPTION}"
fi
return 0
fi
command -v xray >/dev/null 2>&1 || die "xray 未安装"
local out
if [ -z "${XRAY_PRIVATE_KEY:-}" ]; then
out="$(xray x25519)"
else
out="$(xray x25519 -i "$XRAY_PRIVATE_KEY")"
fi
# 新版输出 "Private key:" / "Public key:"(旧版公钥行叫 "Password:"
local priv pub
priv="$(printf '%s\n' "$out" | grep -i 'private' | awk -F': ' '{print $2}' | tr -d '[:space:]')"
pub="$(printf '%s\n' "$out" | grep -iE 'public|password' | awk -F': ' '{print $2}' | tr -d '[:space:]')"
[ -n "$priv" ] || die "无法解析 xray x25519 输出的私钥:$out"
[ -n "$pub" ] || die "无法解析 xray x25519 输出的公钥:$out"
XRAY_PRIVATE_KEY="$priv"
XRAY_PUBLIC_KEY="$pub"
info "REALITY 密钥对已生成/推导"
# VLESS 后量子加密:用官方推荐的 `xray vlessenc` 生成服务端 decryption 与
# 客户端 encryption 串(取 ML-KEM-768 后量子认证段,而非 X25519 段)
if [ -n "${XRAY_VLESS_ENC:-}" ] && [ -z "${XRAY_VLESS_DECRYPTION:-}" ]; then
local vout vsec
vout="$(xray vlessenc)" || die "xray vlessenc 执行失败"
# 输出含两段:X25519(非后量子)与 ML-KEM-768(后量子),取后者
vsec="$(printf '%s\n' "$vout" | sed -n '/ML-KEM/,$p')"
XRAY_VLESS_DECRYPTION="$(printf '%s\n' "$vsec" | grep -oP '"decryption": "\K[^"]+' | head -n1)"
XRAY_VLESS_ENCRYPTION="$(printf '%s\n' "$vsec" | grep -oP '"encryption": "\K[^"]+' | head -n1)"
[ -n "$XRAY_VLESS_DECRYPTION" ] && [ -n "$XRAY_VLESS_ENCRYPTION" ] \
|| die "无法解析 xray vlessenc 输出:$vout"
info "VLESS 后量子加密密钥对已生成(ML-KEM-768"
fi
}
# 渲染 /usr/local/etc/xray/config.json
# 注意:产出必须是严格合法 JSON,heredoc 内不写 // 注释
# XHTTP inbound 不要加 extra/xPadding* 块:padding 位置/键名必须与客户端完全一致,
# 而分享链接无法携带这些参数、主流客户端也不支持——服务端单方面开启会让所有
# 客户端收到 400(2026-08-24 本机端到端实测确认,直连/CDN 全挂)
render_xray_config() {
[ -n "$SKIP_XRAY" ] && return 0
local content
content="$(cat <<'XRAY_EOF'
{
"log": {
"loglevel": "warning"
},
"inbounds": [
{
"port": 443,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "__XRAY_UUID__",
"flow": "xtls-rprx-vision"
}
],
"decryption": "__XRAY_VLESS_DECRYPTION__"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "8003",
"xver": 1,
"serverNames": [
"__DOMAIN__"
],
"privateKey": "__XRAY_PRIVATE_KEY__",
"shortIds": [
"__XRAY_SHORT_ID__"
]
}
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"port": 54442,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "__XRAY_WS_UUID__",
"level": 1,
"alterId": 0
}
],
"decryption": "__XRAY_VLESS_DECRYPTION__"
},
"listen": "127.0.0.1",
"streamSettings": {
"network": "ws",
"wsSettings": {
"path": "/scilad"
}
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
},
{
"port": __XHTTP_PORT__,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "__XRAY_XHTTP_UUID__",
"level": 1,
"alterId": 0
}
],
"decryption": "__XRAY_VLESS_DECRYPTION__"
},
"listen": "127.0.0.1",
"streamSettings": {
"network": "xhttp",
"xhttpSettings": {
"path": "__XHTTP_PATH__",
"mode": "auto"__XHTTP_EXTRA__
}
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
},
"tag": "direct"
},
{
"protocol": "blackhole",
"settings": {},
"tag": "blocked"
},
{
"protocol": "freedom",
"settings": {},
"tag": "tg-out"
}
],
"dns": {
"servers": [
"8.8.8.8",
"8.8.4.4",
"localhost"
]
},
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
{
"type": "field",
"ip": [
"geoip:cn",
"geoip:private"
],
"outboundTag": "blocked"
},
{
"type": "field",
"ip": [
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"::1/128",
"fc00::/7",
"fe80::/10"
],
"outboundTag": "blocked"
},
{
"type": "field",
"inboundTag": [
"tg-in"
],
"outboundTag": "tg-out"
},
{
"type": "field",
"domain": [
"domain:epochtimes.com",
"domain:epochtimes.com.tw",
"domain:epochtimes.fr",
"domain:epochtimes.de",
"domain:epochtimes.jp",
"domain:epochtimes.ru",
"domain:epochtimes.co.il",
"domain:epochtimes.co.kr",
"domain:epochtimes-romania.com",
"domain:erabaru.net",
"domain:lagranepoca.com",
"domain:theepochtimes.com",
"domain:ntdtv.com",
"domain:ntd.tv",
"domain:ntdtv-dc.com",
"domain:ntdtv.com.tw",
"domain:minghui.org",
"domain:renminbao.com",
"domain:dafahao.com",
"domain:dongtaiwang.com",
"domain:falundafa.org",
"domain:wujieliulan.com",
"domain:ninecommentaries.com",
"domain:shenyun.com"
],
"outboundTag": "blocked"
},
{
"type": "field",
"protocol": [
"bittorrent"
],
"outboundTag": "blocked"
}
]
}
}
XRAY_EOF
)"
write_file /usr/local/etc/xray/config.json 644 "$(printf '%s' "$content" | render)"
}
# 安装 Hysteria2:官方安装脚本
install_hysteria() {
[ -n "$SKIP_HYSTERIA" ] && { info "SKIP_HYSTERIA=1,跳过 Hysteria2 安装"; return 0; }
[ -n "$DRY_RUN" ] && return 0
info "安装 Hysteria2get.hy2.sh"
bash <(curl -fsSL https://get.hy2.sh/)
}
# 渲染 /etc/hysteria/config.yaml
# http 模式:certmagic HTTP-01challenge 只监听 127.0.0.1:HY2_ACME_PORT
# 由 Caddy 80 端口反代 /.well-known/acme-challenge/ 验证;非通配 DOMAIN 证书,
# 自动续期且对新握手热生效,无需重启
# dns 模式:Cloudflare DNS-01 签 *.BASE_DOMAIN 通配符证书
render_hysteria_config() {
[ -n "$SKIP_HYSTERIA" ] && return 0
local acme_block
if [ "$HY2_CERT_MODE" = "dns" ]; then
acme_block="$(cat <<'HY2_DNS_EOF'
# Cloudflare DNS-01,需 CF_API_TOKENZone.DNS 编辑权限),签通配符证书
acme:
domains:
- "*.__BASE_DOMAIN__"
email: __EMAIL__
type: dns
dns:
name: cloudflare
config:
cloudflare_api_token: __CF_API_TOKEN__
dir: /etc/hysteria/acme_certs
HY2_DNS_EOF
)"
else
if [ -n "$HY2_HTTP_STANDALONE" ]; then
# 无 Caddyhy2 直接监听公网 80 应答 HTTP-01collect_params 已确认端口空闲)
acme_block="$(cat <<'HY2_HTTP_SA_EOF'
# HTTP-01standalone):无 Caddyhy2 直接监听 80 应答验证;非通配 DOMAIN 证书
acme:
domains:
- "__DOMAIN__"
email: __EMAIL__
type: http
http:
altPort: 80
dir: /etc/hysteria/acme_certs
HY2_HTTP_SA_EOF
)"
else
acme_block="$(cat <<'HY2_HTTP_EOF'
# HTTP-01challenge 仅监听本机回环,由 Caddy 80 端口反代验证;非通配 DOMAIN 证书
acme:
domains:
- "__DOMAIN__"
email: __EMAIL__
type: http
listenHost: 127.0.0.1
http:
altPort: __HY2_ACME_PORT__
dir: /etc/hysteria/acme_certs
HY2_HTTP_EOF
)"
fi
fi
local content
content="$(cat <<HY2_EOF
listen: :8443
${acme_block}
auth:
type: password
password: __HY2_PASSWORD__
masquerade:
type: proxy
proxy:
url: __HY2_MASQUERADE_URL__
rewriteHost: true
HY2_EOF
)"
write_file /etc/hysteria/config.yaml 644 "$(printf '%s' "$content" | render)"
# ACME 证书目录需要 hysteria 用户可写
if [ -z "$DRY_RUN" ]; then
chown -R hysteria:hysteria /etc/hysteria/
fi
}
# 渲染全部配置文件
render_all_configs() {
render_caddyfile
render_xray_config
render_hysteria_config
}
# nftables 端口跳跃:UDP 20000-30000 → 8443
# /etc/nftables.conf 只幂等追加 include 行,绝不整体覆盖
setup_nftables() {
[ -n "$SKIP_NFTABLES" ] && { info "SKIP_NFTABLES=1,跳过 nftables"; return 0; }
write_file "$HYSTERIA_NFT" 644 "$(cat <<'NFT_EOF'
table inet hysteria_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
udp dport 20000-30000 redirect to :8443
}
}
NFT_EOF
)"
local include_line="include \"$HYSTERIA_NFT\""
if [ -n "$DRY_RUN" ]; then
# 干跑:渲染一份 nftables.conf 供检查(幂等演示)
if [ -f "$DRY_RUN_DIR/nftables.conf" ] && \
grep -qF "$include_line" "$DRY_RUN_DIR/nftables.conf" 2>/dev/null; then
:
else
printf '%s\n' "$include_line" >> "$DRY_RUN_DIR/nftables.conf"
fi
info "已写入 $DRY_RUN_DIR/nftables.confinclude 行)"
return 0
fi
touch "$NFTABLES_CONF"
if ! grep -qF "$include_line" "$NFTABLES_CONF"; then
printf '%s\n' "$include_line" >> "$NFTABLES_CONF"
info "已向 $NFTABLES_CONF 追加 include 行"
else
info "$NFTABLES_CONF 已含 include 行,跳过"
fi
systemctl enable --now nftables
nft -f "$NFTABLES_CONF"
}
# 启动前校验配置,任一失败即中止(不带病 enable 服务)
validate_configs() {
[ -n "$DRY_RUN" ] && return 0
if [ -z "$SKIP_CADDY" ]; then
info "caddy validate"
caddy validate --config /etc/caddy/Caddyfile
fi
if [ -z "$SKIP_XRAY" ]; then
info "xray run -test"
xray run -test -config /usr/local/etc/xray/config.json
fi
# hy2 无离线校验命令,靠启动后 is-active 兜底
}
# 把最终参数写入 ENV_FILEmode 600),重跑时 source 作默认值
save_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_caddy_xray_hysteria.sh 参数存档(重跑自动读取)\n'
local var
for var in "${RENDER_VARS[@]}"; do
printf '%s=%q\n' "$var" "${!var:-}"
done
# 非渲染变量但也需持久化(重跑时据此重建 XHTTP_EXTRA
printf 'XHTTP_PADDING=%q\n' "${XHTTP_PADDING:-}"
printf 'XPADDING_HEADER=%q\n' "${XPADDING_HEADER:-}"
printf 'XPADDING_KEY=%q\n' "${XPADDING_KEY:-}"
} > "$target"
chmod 600 "$target"
info "参数已保存到 $target"
}
# 启用并检查服务
enable_services() {
[ -n "$DRY_RUN" ] && return 0
local svc
for svc in caddy xray hysteria-server; do
case "$svc" in
caddy) [ -n "$SKIP_CADDY" ] && continue ;;
xray) [ -n "$SKIP_XRAY" ] && continue ;;
hysteria-server) [ -n "$SKIP_HYSTERIA" ] && continue ;;
esac
info "启用 $svc"
systemctl enable "$svc" --quiet
systemctl restart "$svc"
systemctl is-active --quiet "$svc" || die "$svc 启动失败,请用 journalctl -u $svc 排查"
done
}
# 安装后自检(失败只警告,不中断)
self_check() {
[ -n "$DRY_RUN" ] && return 0
info "自检:REALITY 回落站点"
curl -skI --max-time 10 "https://$DOMAIN" | head -n 1 || warn "https://$DOMAIN 未响应(DNS 未指向本机?)"
# 80 端口块必须把 challenge 路径反代给 hy2,而不是 301(redir 通吃曾导致 LE 验证失败)
if [ -z "$SKIP_CADDY" ] && [ -z "$SKIP_HYSTERIA" ] && \
[ "$HY2_CERT_MODE" = "http" ] && [ -z "$HY2_HTTP_STANDALONE" ]; then
info "自检:hy2 HTTP-01 challenge 路由"
local code
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H "Host: $DOMAIN" "http://127.0.0.1/.well-known/acme-challenge/__selfcheck__" || true)"
case "$code" in
301|302|308)
warn "challenge 请求被重定向(HTTP $code)——80 端口块 redir 未排除 challenge 路径,hy2 将签不出证书" ;;
000) warn "本机 80 端口无响应,hy2 HTTP-01 无法进行" ;;
*) : ;; # 404hy2 拒收未知 token)或 502(hy2 未就绪)均属预期
esac
fi
if [ -z "$SKIP_HYSTERIA" ]; then
info "自检:hy2 UDP 8443 监听"
ss -lun | grep -q ':8443 ' || warn "UDP 8443 未监听"
fi
if [ -z "$SKIP_NFTABLES" ]; then
info "自检:nftables 端口跳跃表"
nft list table inet hysteria_nat >/dev/null 2>&1 || warn "hysteria_nat 表不存在"
fi
}
# 输出客户端参数与分享链接
print_summary() {
local ws_path_enc="%2Fscilad"
local xhttp_path_enc="${XHTTP_PATH//\//%2F}"
# VLESS 层加密参数:默认 none;开启后量子加密时换成 vlessenc 生成的客户端串
local venc="none"
[ -n "${XRAY_VLESS_ENCRYPTION:-}" ] && venc="$XRAY_VLESS_ENCRYPTION"
# XHTTP extra padding(默认开,XHTTP_PADDING=0 关):开启时把整段 JSON URL
# 编码进链接(分享标准 4.3.19),客户端支持即生效;忽略 extra 的旧客户端连不上
local extra_param=""
[ -n "${XHTTP_EXTRA_JSON:-}" ] && extra_param="&extra=$(urlencode "$XHTTP_EXTRA_JSON")"
# CDN 链接的 ech= 参数(客户端 Xray ≥25.7.26):CF 轮换 ECH 密钥很勤(静态快照
# 实测很快失效),故默认内嵌动态查询形式 "域名+udp://8.8.8.8"——Xray 拨号时自查
# 最新 ECHConfig 并按 TTL 缓存刷新,轮换无感,客户端无需任何"更新"。
# 查询协议按 scheme 分(源码只有三个分支,tcp:// 不存在别乱写):
# udp:// 明文 UDP53——墙内实测最稳,作默认(9.9.9.9 Quad98.8.8.8 偶发被断,
# 9.9.9.9 不被屏蔽、延迟略高;备选 149.112.112.112;国内 223.5.5.5/119.29.29.29
# 最快最稳但查询记录国内可见——节点域名的 A 查询默认本来也走国内 DNS);
# 报文可被窃听/投毒,但 full 策略下伪造记录只会断连、不会静默降级泄露 SNI(可接受)。
# https://…/dns-query 走内置 DoHh2+chrome 指纹,最隐蔽),但 8.8.8.8:443 墙内
# 实测不稳(passwall"本机代理"会把路由器自身流量含 Xray 的 DoH 劫进代理通道,
# 代理一抖查询就失败超时);DoH 必须指到 /dns-queryRFC8484 wireformat),
# /resolve 是 JSON 调试接口 Xray 不认。
# 注意:查询失败会硬失败(无明文 SNI 回落),收件网络不合适时用
# ECH_CONFIG=off(不带 ech=)、ECH_CONFIG=<静态 base64>(零 DNS 依赖但会过期)
# 或 ECH_CONFIG=域名+<udp://ip | https://…/dns-query>(换端点/协议)
local ech_cfg=""
case "${ECH_CONFIG:-dynamic}" in
off|0) ;;
dynamic) if [ -n "${CDN_DOMAIN:-}" ]; then
ech_cfg="$CDN_DOMAIN+udp://9.9.9.9"
fi ;;
*) ech_cfg="$ECH_CONFIG" ;;
esac
local ech_param=""
[ -n "$ech_cfg" ] && ech_param="&ech=$(urlencode "$ech_cfg")"
cat <<EOF
================ 部署完成 ================
域名(灰云/REALITY: $DOMAIN
$( [ -n "${CDN_DOMAIN:-}" ] && echo "域名(橙云/CDN : $CDN_DOMAIN" )
$( [ -z "$SKIP_HYSTERIA" ] && echo "hy2 证书($HY2_CERT_MODE 模式): $( [ "$HY2_CERT_MODE" = "dns" ] && echo "*.$BASE_DOMAIN" || echo "$DOMAIN" )" )
REALITY 公钥 : $XRAY_PUBLIC_KEY
$( [ -n "$venc" ] && [ "$venc" != "none" ] && echo "VLESS 加密 : 后量子 ML-KEM-768 已启用" )
-----------------------------------------
[vless-reality]
vless://$XRAY_UUID@$DOMAIN:443?encryption=$venc&flow=xtls-rprx-vision&security=reality&sni=$DOMAIN&fp=chrome&pbk=$XRAY_PUBLIC_KEY&sid=$XRAY_SHORT_ID&type=tcp#reality-$DOMAIN
[vless-ws-tls](经 Caddy /scilad
vless://$XRAY_WS_UUID@$DOMAIN:443?encryption=$venc&security=tls&sni=$DOMAIN&fp=chrome&type=ws&path=$ws_path_enc#ws-$DOMAIN
[vless-xhttp](经 Caddy $XHTTP_PATH
vless://$XRAY_XHTTP_UUID@$DOMAIN:443?encryption=$venc&security=tls&sni=$DOMAIN&fp=chrome&type=xhttp&path=$xhttp_path_enc&mode=auto$extra_param#xhttp-$DOMAIN
EOF
if [ -n "${CDN_DOMAIN:-}" ]; then
cat <<EOF
[vless-ws-tls-cdn](橙云回源)
vless://$XRAY_WS_UUID@$CDN_DOMAIN:443?encryption=$venc&security=tls&sni=$CDN_DOMAIN&fp=chrome&type=ws&path=$ws_path_enc$ech_param#ws-cdn-$CDN_DOMAIN
[vless-xhttp-cdn](橙云回源)
vless://$XRAY_XHTTP_UUID@$CDN_DOMAIN:443?encryption=$venc&security=tls&sni=$CDN_DOMAIN&fp=chrome&type=xhttp&path=$xhttp_path_enc&mode=auto$extra_param$ech_param#xhttp-cdn-$CDN_DOMAIN
$( [ -n "$ech_param" ] && echo "(CDN 链接已带 ech= ECH 参数;动态形式由客户端 Xray 拨号时自动刷新)" )
EOF
fi
if [ -z "$SKIP_HYSTERIA" ]; then
local hy2_sni="$DOMAIN"
[ "$HY2_CERT_MODE" = "dns" ] && hy2_sni="www.$BASE_DOMAIN"
cat <<EOF
[hysteria2]UDP 8443,端口跳跃 20000-30000
hysteria2://$HY2_PASSWORD@$DOMAIN:8443/?mport=20000-30000&sni=$hy2_sni#hy2-$DOMAIN
EOF
fi
cat <<EOF
参数已保存:$ENV_FILE(重跑自动读取作默认值)
卸载:sudo ACTION=uninstall bash $(basename "$0")PURGE=1 连包一起删)
==========================================
EOF
}
# 卸载:停服务、删配置、清理 nftables;PURGE=1 连包与 apt 源一起删
uninstall() {
info "卸载 caddy + xray + hysteria2 配置"
if [ -z "$DRY_RUN" ]; then
local svc
for svc in caddy xray hysteria-server; do
systemctl disable --now "$svc" --quiet 2>/dev/null || true
done
fi
rm -f /etc/caddy/Caddyfile /usr/local/etc/xray/config.json /etc/hysteria/config.yaml
rm -f "$HYSTERIA_NFT"
# 从 nftables.conf 精确删除 include 行
if [ -f "$NFTABLES_CONF" ]; then
sed -i '\|include "/etc/nftables/hysteria.nft"|d' "$NFTABLES_CONF"
fi
if [ -z "$DRY_RUN" ]; then
nft delete table inet hysteria_nat 2>/dev/null || true
systemctl reload nftables 2>/dev/null || true
fi
if [ -n "$PURGE" ] && [ -z "$DRY_RUN" ]; then
info "PURGE=1:删除软件包与 apt 源"
apt-get purge -y caddy 2>/dev/null || true
rm -f /etc/apt/sources.list.d/caddy-stable.list \
/usr/share/keyrings/caddy-stable-archive-keyring.gpg
# xray 官方卸载
bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ remove --purge 2>/dev/null || true
# hy2 官方卸载
bash <(curl -fsSL https://get.hy2.sh/) --remove 2>/dev/null || true
fi
if [ -n "$PURGE" ] && [ -z "$KEEP_ENV" ]; then
rm -f "$ENV_FILE"
info "已删除 $ENV_FILE"
else
info "保留 $ENV_FILE(设 PURGE=1 且不设 KEEP_ENV 才会删除)"
fi
info "卸载完成"
}
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
main() {
if [ "$ACTION" = "uninstall" ]; then
preflight
uninstall
exit 0
fi
# install 与 upgrade 共用同一条幂等流水线(安装脚本本身就是升级到最新版),
# 区别只在 collect_paramsupgrade 要求已安装且完全不交互
[ "$ACTION" = "install" ] || [ "$ACTION" = "upgrade" ] || \
die "未知 ACTION=$ACTION(支持 install / upgrade / uninstall"
preflight
collect_params
system_prepare
install_caddy
install_xray
generate_xray_secrets
install_hysteria
render_all_configs
setup_nftables
validate_configs
save_env
enable_services
self_check
print_summary
}
main "$@"