#!/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 <权限> <内容> # DRY_RUN=1 时改写到 $DRY_RUN_DIR/,不碰真实系统 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 </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=域名+(换端点/协议) 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 </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 "$@"