9ca581c1e9
Co-Authored-By: Claude <noreply@anthropic.com>
1089 lines
42 KiB
Bash
1089 lines
42 KiB
Bash
#!/usr/bin/env bash
|
||
#
|
||
# setup_caddy_xray_hysteria.sh — 一键在 Debian/Ubuntu VPS 部署
|
||
# Caddy + Xray(VLESS+REALITY / WS / XHTTP)+ Hysteria2 + nftables 端口跳跃
|
||
#
|
||
# 架构:
|
||
# - Xray 监听 443(VLESS+REALITY,xtls-rprx-vision),非 REALITY 流量回落(fallback)
|
||
# 到 127.0.0.1:8003 的 Caddy(PROXY protocol 接收,xver 1),由 Caddy 提供伪装站点
|
||
# - Xray 另开 127.0.0.1:54442(VLESS+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=http:hy2 自带 ACME(certmagic)
|
||
# 走 HTTP-01,challenge 监听 127.0.0.1:9180,由 Caddy 的 80 端口反代
|
||
# /.well-known/acme-challenge/ 完成验证——不新开公网端口、不需要 DNS API Token,
|
||
# 证书为非通配 DOMAIN(与 Caddy/REALITY 一致),自动续期且热生效。
|
||
# 备选 HY2_CERT_MODE=dns:Cloudflare 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.env(mode 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_CONFIG(CDN 链接 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)或 dns(Cloudflare 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-01(standalone 模式),
|
||
# 运行时再检测 80 是否空闲
|
||
HY2_HTTP_STANDALONE=1
|
||
[ "${HY2_ACME_PORT:-9180}" = "9180" ] || \
|
||
warn "SKIP_CADDY=1 时 HY2_ACME_PORT 无效:CA 只连 80,hy2 必须直接监听 80"
|
||
info "SKIP_CADDY=1:hy2 将直接监听 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-01:80/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:8003(PROXY 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 流量,h3(QUIC/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 流量,h3(QUIC/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 "安装 Hysteria2(get.hy2.sh)"
|
||
bash <(curl -fsSL https://get.hy2.sh/)
|
||
}
|
||
|
||
# 渲染 /etc/hysteria/config.yaml
|
||
# http 模式:certmagic HTTP-01,challenge 只监听 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_TOKEN(Zone.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
|
||
# 无 Caddy:hy2 直接监听公网 80 应答 HTTP-01(collect_params 已确认端口空闲)
|
||
acme_block="$(cat <<'HY2_HTTP_SA_EOF'
|
||
# HTTP-01(standalone):无 Caddy,hy2 直接监听 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-01:challenge 仅监听本机回环,由 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.conf(include 行)"
|
||
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_FILE(mode 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 无法进行" ;;
|
||
*) : ;; # 404(hy2 拒收未知 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 Quad9:8.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 走内置 DoH(h2+chrome 指纹,最隐蔽),但 8.8.8.8:443 墙内
|
||
# 实测不稳(passwall"本机代理"会把路由器自身流量含 Xray 的 DoH 劫进代理通道,
|
||
# 代理一抖查询就失败超时);DoH 必须指到 /dns-query(RFC8484 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_params:upgrade 要求已安装且完全不交互
|
||
[ "$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 "$@"
|