Files
server-deploy/vps-xray/deploy.sh
T
joywayer 3fd2c89020 fix(vps-xray): 修复定时重启的四类静默失效并补齐安全不变式测试
Critical-1 timer 时区:[Timer] 段没有 TimeZone= 这个 key,systemd 只会记一条
"Unknown key name" 然后按系统本地时区解析 OnCalendar。VPS 出厂多为 UTC,
用户设「04:00 Asia/Shanghai」会在北京时间 12:00 触发且毫无提示。
改为把时区写进日历表达式本身:OnCalendar=*-*-* HH:MM:00 <TZ>(systemd >= 242)。

Critical-2 前导零:^[0-9]+$ 放行 08/09/010,该值原样写进 guard 的 DAYS=,
而 guard 里是算术展开——010 被当八进制解析成 8 天,08/09 直接报
"value too great for base",使间隔判断求值为假,落进重启分支变成每天重启。
校验通过后统一 $((10#$days)) 归一化。

Important-3 状态文件写入失败:mkdir 与写时间戳都不检查返回值,/var 写满或
只读时写入失败却照样重启,「先写时间戳」的保护完全失效,退化成每天硬重启且
无限持续。改为 fail-safe:写不进去就报错并跳过本轮。
update_env_field 的三处写入同样补上返回值检查,调用方改在条件位置。

Important-4 未来时间戳:RTC 错乱或 NTP 大幅向后校正时会写下未来的时间戳,
时钟校回后 now-last 恒为负,此后永远跳过且无自愈路径。与「内容非法按 0 处理」
同策略,读到 last > now 时按 0 处理。

Important-5 静默中止:configure_restart_timer 被 harden_system、main 一路裸调用,
set -e 下 systemctl enable 失败会让部署在「8/8 加固」之后无声退出——
start_service / save_env / print_result 全不执行,config.json 已换、防火墙已改,
但 xray 没重启、.env 没更新、链接没打印。三处 systemctl 全部改到条件位置,
enable 不再吞 stderr(规格本就要求靠 systemctl 报错暴露非法时区)。

Important-6 NEXT 语义:list-timers 的 NEXT 是 timer 的「下次唤醒」,每天一次,
不是下次重启时间。新增 print_next_restart,--restart 输出里直接算出并显示
「下次实际重启时间」,README 同步说明差异。

Important-7 安全不变式测试:configure_restart_timer 与 apply_restart_only 原本
零覆盖,「不重启 xray、不碰 vless 链接」这个最重要的承诺恰恰没有回归保护。
补上启用/关闭两个方向的断言,以及 .env 逐字段 diff 与 systemctl 调用日志断言。

Minor-9 README 补充取舍:完整部署的 systemctl restart xray 不更新状态文件。
Minor-11 save_env 注释「这两项」→「这几项」。

测试 61 → 101 项,全部通过。
2026-08-07 09:37:46 +08:00

1220 lines
43 KiB
Bash
Raw 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
set -euo pipefail
# ============================================
# Xray VPS 一键部署脚本
# 支持模式:reality (VLESS-Reality) / fast (VLESS TCP 无 TLS)
# 适用于 Debian 11+ / Ubuntu 20.04+
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
step() { echo -e "\n${CYAN}========== $* ==========${NC}"; }
# ===== 使用说明 =====
usage() {
echo "用法: bash deploy.sh [选项]"
echo ""
echo "选项:"
echo " --mode reality VLESS-Reality 模式(默认,推荐,抗封锁)"
echo " --mode fast VLESS TCP 模式(无 TLS,极速但易被检测)"
echo " --show 只读取 .env 打印连接信息和分享链接,不改动任何服务"
echo " --redetect 强制重新挑选 Reality 伪装目标(会改变 SNI"
echo " 所有客户端链接都必须同步更新)"
echo " --restart [N] [HH:MM]"
echo " 只修改定时重启计划并立即生效。N=0 关闭。"
echo " 不重装 Xray、不重写配置、不重启服务,"
echo " 现有 vless 链接完全不受影响。"
echo " 例: bash deploy.sh --restart 7 04:00"
echo " -h, --help 显示帮助"
exit "${1:-0}"
}
# ===== 解析参数 =====
MODE=""
MODE_EXPLICIT=0
SHOW_ONLY=0
REDETECT=0
RESTART_ONLY=0
RESTART_ARG_DAYS=""
RESTART_ARG_TIME=""
while [[ $# -gt 0 ]]; do
case $1 in
--mode) MODE="${2:-}"; MODE_EXPLICIT=1; shift 2 ;;
--show) SHOW_ONLY=1; shift ;;
--redetect) REDETECT=1; shift ;;
--restart)
RESTART_ONLY=1
shift
# 后面可跟 0~2 个位置参数,按形态判断,不会误吞后续选项
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
RESTART_ARG_DAYS="$1"
shift
if [[ "${1:-}" =~ ^([01]?[0-9]|2[0-3]):[0-5][0-9]$ ]]; then
RESTART_ARG_TIME="$1"
shift
fi
fi
;;
-h|--help) usage ;;
*) error "未知参数: $1"; usage 1 ;;
esac
done
# --restart 是「只改重启计划」的轻量入口,与会改动服务的选项互斥,
# 避免用户以为只调了 timer、实际却顺带重装了 Xray
if [ "$RESTART_ONLY" -eq 1 ] \
&& { [ "$MODE_EXPLICIT" -eq 1 ] || [ "$SHOW_ONLY" -eq 1 ] || [ "$REDETECT" -eq 1 ]; }; then
error "--restart 不能与 --mode / --show / --redetect 同时使用"
exit 1
fi
# ===== 检查 root =====
# .env 权限为 600,读取它同样需要 root
if [ "$(id -u)" -ne 0 ]; then
error "请使用 root 用户运行: sudo bash deploy.sh"
exit 1
fi
# ===== 加载现有配置(如存在)=====
if [ -f .env ]; then
sed -i 's/\r$//' .env
set -a; source .env; set +a
fi
# 未显式指定 --mode 时沿用 .env 里记录的模式,
# 避免在 fast 模式的机器上重跑脚本被静默切换成 reality
if [ "$MODE_EXPLICIT" -eq 0 ]; then
MODE="${XRAY_MODE:-reality}"
fi
if [[ "$MODE" != "reality" && "$MODE" != "fast" ]]; then
error "无效模式: $MODE(支持 reality / fast"
exit 1
fi
# ===== 定时重启配置 =====
# 可覆盖路径,测试时指向临时目录
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
GUARD_BIN="${GUARD_BIN:-/usr/local/bin/xray-restart-guard}"
RESTART_STATE_FILE="${RESTART_STATE_FILE:-/var/lib/xray/last-restart}"
RESTART_EVERY_DAYS=0
RESTART_TIME="04:00"
RESTART_TZ="Asia/Shanghai"
# 把 4:00 / 04:00 统一成两位小时的 HH:MM,不合法返回 1。
# 归一化的意义:OnCalendar 字符串格式统一,.env 内容可预测。
normalize_restart_time() {
local t="${1:-}"
[[ "$t" =~ ^([01]?[0-9]|2[0-3]):([0-5][0-9])$ ]] || return 1
# 10# 前缀避免 08/09 被当成八进制
printf '%02d:%s' "$((10#${BASH_REMATCH[1]}))" "${BASH_REMATCH[2]}"
}
resolve_restart_config() {
local days="${XRAY_RESTART_EVERY_DAYS:-}"
# 旧字段迁移:仅当新字段完全未设置时才回退去读,
# 这样 .env 里显式写 0 能覆盖遗留的 XRAY_DAILY_RESTART=true
if [ -z "$days" ]; then
case "$(printf '%s' "${XRAY_DAILY_RESTART:-false}" | tr '[:upper:]' '[:lower:]')" in
true|yes|on|1) days=1 ;;
*) days=0 ;;
esac
fi
if ! [[ "$days" =~ ^[0-9]+$ ]]; then
error "XRAY_RESTART_EVERY_DAYS 必须是非负整数,当前为 '${days}'"
return 1
fi
# 10# 前缀去掉前导零:这个值会原样写进 guard 的 DAYS=
# 而 guard 里 $(( DAYS * 86400 - 300 )) 是算术展开——
# 010 会被当八进制解析成 8 天,08 / 09 直接报 "value too great for base"
# 从而让间隔判断求值为假,退化成每天重启。
RESTART_EVERY_DAYS=$((10#$days))
local t
if ! t=$(normalize_restart_time "${XRAY_RESTART_TIME:-04:00}"); then
error "XRAY_RESTART_TIME 必须是 HH:MM00:00~23:59),当前为 '${XRAY_RESTART_TIME:-}'"
return 1
fi
RESTART_TIME="$t"
RESTART_TZ="${XRAY_RESTART_TIMEZONE-Asia/Shanghai}"
if [ -z "$RESTART_TZ" ]; then
error "XRAY_RESTART_TIMEZONE 不能为空"
return 1
fi
return 0
}
# 就地更新 .env 中的单个字段:存在则替换,不存在则追加。
# 刻意不复用 save_env——后者是 cat > 全量重写,会抹掉用户自己加的注释与字段;
# 轻量入口只该改自己那一行。
update_env_field() {
local file="$1" key="$2" value="$3" esc
[ -f "$file" ] || return 1
# 每一步写入都检查返回值:磁盘满或只读时静默失败会让用户以为改动已落盘,
# 下次完整部署又被 .env 里的旧值覆盖回去
if grep -q "^${key}=" "$file"; then
# 用 | 作分隔符,并转义 value 中的 | & \,避免值里的 / 破坏 sed
esc=$(printf '%s' "$value" | sed 's/[|&\\]/\\&/g')
sed -i "s|^${key}=.*|${key}=${esc}|" "$file" || return 1
else
# 文件末尾可能缺换行(用户手工编辑过),直接追加会把新字段黏到上一行
if [ -n "$(tail -c 1 "$file")" ]; then
printf '\n' >> "$file" || return 1
fi
printf '%s=%s\n' "$key" "$value" >> "$file" || return 1
fi
return 0
}
# 生成 timer / service / guard 三个文件。
# 完整部署与 --restart 共用本函数,避免两条路径各写一份实现后漂移。
render_restart_units() {
local days="$1" time="$2" tz="$3"
mkdir -p "$SYSTEMD_DIR" "$(dirname "$GUARD_BIN")"
# 守卫脚本:systemd 的 OnCalendar 表达不了「每 N 天」——
# *-*-1/7 是「每月 1/8/15/22/29 号」,跨月会重置成 2~3 天。
# 所以 timer 只负责每天到点唤醒,间隔判断放到这里。
cat > "$GUARD_BIN" << EOF
#!/bin/bash
# 由 deploy.sh 生成,请勿手工编辑
STATE="${RESTART_STATE_FILE}"
DAYS=${days}
now=\$(date +%s)
last=0
if [ -f "\$STATE" ]; then
last=\$(cat "\$STATE" 2>/dev/null || echo 0)
# 内容被写坏时按 0 处理,即允许立即重启
[[ "\$last" =~ ^[0-9]+\$ ]] || last=0
# 时间戳落在未来(RTC 错乱或 NTP 大幅向后校正时写下的)同样按 0 处理。
# 否则时钟校回之后 now - last 恒为负,间隔判断恒成立,此后永远跳过,
# 而状态文件只在真正重启时才被覆盖——没有任何自愈路径。
[ "\$last" -gt "\$now" ] && last=0
fi
# 300 秒容差:systemd timer 默认 AccuracySec=1min,第 N 次触发的实际间隔
# 可能是 N*86400-30s。严格比较会判「不满」从而顺延整整一天,且误差会累积。
if [ "\$last" -gt 0 ] && [ \$(( now - last )) -lt \$(( DAYS * 86400 - 300 )) ]; then
echo "距上次重启不足 \${DAYS} 天(已过 \$(( (now - last) / 86400 )) 天),跳过"
exit 0
fi
# 必须先写时间戳:restart 会中断本脚本所在的 systemd 事务,
# 顺序反了会丢记录,退化成每天都重启。
# 代价是重启失败时本轮仍被记为已执行——这比陷入每日重启循环可接受。
#
# 写不进去(/var 满了、只读挂载)时必须跳过本轮:照常重启会让下一轮
# 依旧读不到时间戳,从而变成每天硬重启且无限持续——保护完全失效。
if ! mkdir -p "\$(dirname "\$STATE")" 2>/dev/null; then
echo "无法创建 \$(dirname "\$STATE"),跳过本轮重启以免退化成每日重启" >&2
exit 1
fi
if ! echo "\$now" > "\$STATE" 2>/dev/null; then
echo "无法写入 \$STATE,跳过本轮重启以免退化成每日重启" >&2
exit 1
fi
echo "距上次重启已满 \${DAYS} 天,执行重启"
exec systemctl restart xray
EOF
chmod 755 "$GUARD_BIN"
cat > "${SYSTEMD_DIR}/xray-restart.service" << EOF
[Unit]
Description=Scheduled restart of Xray (every ${days} day(s))
After=network.target
[Service]
Type=oneshot
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=${GUARD_BIN}
EOF
cat > "${SYSTEMD_DIR}/xray-restart.timer" << EOF
[Unit]
Description=Wake xray-restart daily at ${time} ${tz}; the guard enforces the ${days}-day interval
[Timer]
# 时区必须写在日历表达式里(systemd >= 242 支持)。
# [Timer] 段没有 TimeZone= 这个 key——写了只会被忽略并留下一行
# "Unknown key name 'TimeZone'",然后按系统本地时区解析。
# VPS 出厂多为 UTC,那样「04:00 Asia/Shanghai」会在北京时间 12:00 触发。
OnCalendar=*-*-* ${time}:00 ${tz}
Persistent=true
[Install]
WantedBy=timers.target
EOF
}
# 删除 timer / service / guard。状态文件刻意保留:
# 以后重新启用时,距上次已超 N 天则首次触发即重启,符合直觉。
remove_restart_units() {
if [ ! -f "${SYSTEMD_DIR}/xray-restart.timer" ] && [ ! -f "$GUARD_BIN" ]; then
return 1
fi
# 失败不阻断删除(单元可能本就没被 enable 过),但要留一句可见的提示,
# 而不是把结果彻底吞掉
if ! systemctl disable --now xray-restart.timer >/dev/null 2>&1; then
warn "禁用 xray-restart.timer 未成功(可能本就未启用),继续删除单元文件"
fi
rm -f "${SYSTEMD_DIR}/xray-restart.timer" \
"${SYSTEMD_DIR}/xray-restart.service" \
"$GUARD_BIN"
return 0
}
# 依据 RESTART_EVERY_DAYS / RESTART_TIME / RESTART_TZ 建立或移除定时重启,
# 并完成 daemon-reload 与 enable/disable。完整部署与 --restart 共用。
# 本函数被 harden_system 裸调用、harden_system 又被 main 裸调用,
# 全程不在条件位置。deploy.sh 开头是 set -euo pipefail,所以这里任何一条
# systemctl 返回非 0 都会让整份部署在「8/8 加固」之后无声退出——
# start_service / save_env / print_result 全都不执行。
# 因此下面每条 systemctl 都放在条件位置,失败只告警不中断。
configure_restart_timer() {
if [ "$RESTART_EVERY_DAYS" -eq 0 ]; then
if remove_restart_units; then
warn "已移除定时重启(XRAY_RESTART_EVERY_DAYS=0"
fi
if ! systemctl daemon-reload; then
warn "systemctl daemon-reload 失败,已删除的单元可能仍残留在 systemd 内存中"
fi
log "定时重启: 关闭(崩溃恢复已由 Restart=always 秒级覆盖)"
return 0
fi
render_restart_units "$RESTART_EVERY_DAYS" "$RESTART_TIME" "$RESTART_TZ"
if ! systemctl daemon-reload; then
warn "systemctl daemon-reload 失败,新的 timer 可能不会立即生效"
fi
# 不吞 stderr:规格明确「不预校验时区字符串,靠 systemctl 报错」,
# 把报错藏起来就等于既不校验也不提示
if ! systemctl enable --now xray-restart.timer; then
error "定时重启 timer 启用失败,请检查 XRAY_RESTART_TIMEZONE 是否为合法时区"
error "部署的其余部分不受影响,可用 systemd-analyze verify ${SYSTEMD_DIR}/xray-restart.timer 排查"
return 0
fi
log "定时重启: 每 ${RESTART_EVERY_DAYS}${RESTART_TIME} (${RESTART_TZ})"
warn "重启会切断所有活动连接;崩溃恢复本已由 Restart=always 覆盖,按需启用"
return 0
}
# list-timers 里的 NEXT 是 timer 的「下次唤醒」——每天一次,恒为次日的
# ${RESTART_TIME};真正是否重启由 guard 按间隔判断。设了「每 7 天」且 5 天前
# 刚重启过的用户,看到 NEXT 是明天凌晨会以为明天就重启,实际还差 2 天。
# 所以这里直接把「下次实际重启」算出来一并打印。
print_next_restart() {
local last next now
now=$(date +%s)
if [ ! -f "$RESTART_STATE_FILE" ]; then
echo " 下次实际重启: 下次唤醒时即会重启(尚无重启记录)"
return 0
fi
last=$(cat "$RESTART_STATE_FILE" 2>/dev/null || echo "")
if ! [[ "$last" =~ ^[0-9]+$ ]] || [ "$last" -gt "$now" ]; then
echo " 下次实际重启: 下次唤醒时即会重启(状态文件内容不可用,按无记录处理)"
return 0
fi
next=$(( last + RESTART_EVERY_DAYS * 86400 ))
echo " 上次实际重启: $(date -d "@${last}" '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || echo "$last")"
if [ "$next" -le "$now" ]; then
echo " 下次实际重启: 下次唤醒时即会重启(距上次已满 ${RESTART_EVERY_DAYS} 天)"
else
echo " 下次实际重启: $(date -d "@${next}" '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || echo "$next") 之后的首次唤醒"
fi
return 0
}
# ===== --restart:只改重启计划 =====
# 明确不做:装依赖、升级 Xray、生成密钥、选伪装目标、重写 config.json、
# 改防火墙与 sysctl、重启 xray。因此 vless 链接与现有连接均不受影响。
apply_restart_only() {
local env_file="$SCRIPT_DIR/.env"
if [ ! -f "$env_file" ]; then
error "未找到 ${env_file},无法确认这是一台已部署的机器"
error "请先执行完整部署: bash deploy.sh"
exit 1
fi
if ! systemctl cat xray >/dev/null 2>&1; then
error "未找到 xray.service,请先执行完整部署: bash deploy.sh"
exit 1
fi
# 命令行给了值就覆盖 .env 载入的值,没给则沿用
if [ -n "$RESTART_ARG_DAYS" ]; then
XRAY_RESTART_EVERY_DAYS="$RESTART_ARG_DAYS"
fi
if [ -n "$RESTART_ARG_TIME" ]; then
XRAY_RESTART_TIME="$RESTART_ARG_TIME"
fi
resolve_restart_config || exit 1
# 只把命令行显式给过的字段落盘,没给的不动 .env
if [ -n "$RESTART_ARG_DAYS" ]; then
if ! update_env_field "$env_file" XRAY_RESTART_EVERY_DAYS "$RESTART_EVERY_DAYS"; then
error "写入 ${env_file} 失败,未做任何改动"
exit 1
fi
fi
if [ -n "$RESTART_ARG_TIME" ]; then
if ! update_env_field "$env_file" XRAY_RESTART_TIME "$RESTART_TIME"; then
error "写入 ${env_file} 失败,未做任何改动"
exit 1
fi
fi
configure_restart_timer
echo ""
echo "=========================================================="
if [ "$RESTART_EVERY_DAYS" -eq 0 ]; then
echo " 定时重启: 已关闭"
else
echo " 定时重启: 每 ${RESTART_EVERY_DAYS}${RESTART_TIME} (${RESTART_TZ})"
print_next_restart
fi
echo " xray 服务未重启,vless 链接与现有连接均未受影响。"
echo "=========================================================="
if [ "$RESTART_EVERY_DAYS" -ne 0 ]; then
echo ""
systemctl list-timers xray-restart.timer --no-pager 2>/dev/null || true
echo ""
echo "注意:上表的 NEXT 是 timer 的下次「唤醒」时间(每天一次),"
echo " 不是下次重启时间。是否真的重启由守卫脚本按上面的间隔判断。"
fi
}
resolve_restart_config || exit 1
XRAY_PORT="${XRAY_PORT:-443}"
# ===== 0. 检查并安装依赖 =====
# 精简版系统镜像(如部分 VPS 商的 Debian/Ubuntu minimal)常缺 curl / unzip
ensure_deps() {
step "1/8 检查依赖"
local missing=()
local cmd
for cmd in curl unzip openssl; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ! -f /etc/ssl/certs/ca-certificates.crt ]; then
missing+=("ca-certificates")
fi
if [ "${#missing[@]}" -eq 0 ]; then
log "依赖齐全: curl unzip openssl ca-certificates"
return
fi
warn "缺少依赖: ${missing[*]},正在自动安装..."
if command -v apt-get &>/dev/null; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "${missing[@]}"
elif command -v dnf &>/dev/null; then
dnf install -y "${missing[@]}"
elif command -v yum &>/dev/null; then
yum install -y "${missing[@]}"
elif command -v apk &>/dev/null; then
apk add --no-cache "${missing[@]}"
else
error "未识别的包管理器,请手动安装: ${missing[*]}"
exit 1
fi
for cmd in curl unzip openssl; do
if ! command -v "$cmd" &>/dev/null; then
error "依赖安装失败: ${cmd},请手动安装后重试"
exit 1
fi
done
log "依赖安装完成"
}
# ===== 1. 安装 Xray =====
install_xray() {
step "2/8 安装 Xray"
if [ -x /usr/local/bin/xray ]; then
log "Xray 已安装: $(/usr/local/bin/xray version | head -1)"
log "将更新到最新版本..."
fi
# 不用 bash <(curl ...)curl 失败时进程替换只产生空脚本,
# 外层 bash 仍返回 0,会掩盖下载失败
local installer
installer="$(mktemp)"
local url ok=0
for url in \
"https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh" \
"https://github.com/XTLS/Xray-install/raw/main/install-release.sh"
do
if curl -fsSL --max-time 60 "$url" -o "$installer" && [ -s "$installer" ]; then
ok=1
break
fi
warn "下载失败,尝试备用地址: $url"
done
if [ "$ok" -ne 1 ]; then
rm -f "$installer"
error "无法下载 Xray 安装脚本,请检查服务器网络 / DNS"
exit 1
fi
bash "$installer"
rm -f "$installer"
if [ ! -x /usr/local/bin/xray ]; then
error "Xray 安装失败:/usr/local/bin/xray 不存在"
exit 1
fi
log "Xray 安装完成: $(/usr/local/bin/xray version | head -1)"
}
# ===== 2. 生成密钥 =====
# ===== 凭据回收 =====
# 部署目录被删除、或 .env 丢失后重跑脚本,若直接生成新密钥,
# 所有客户端的旧链接会立刻失效——而失效表现与「握手被中间人干扰」
# 完全相同(REALITY: received real certificate),极难排查。
# 因此只要 config.json 还在,就以它为准恢复凭据。
recover_credentials() {
[ -f "$XRAY_CONFIG" ] || return 0
[ -n "${XRAY_UUID:-}" ] && return 0 # .env 已提供,无需回收
local flat uuid priv sid sni dest port
flat=$(tr -d '\n\r\t ' < "$XRAY_CONFIG")
uuid=$(echo "$flat" | grep -oP '"id":"\K[^"]+' | head -1 || true)
[ -z "$uuid" ] && return 0 # 多半是官方安装脚本留下的空壳配置
priv=$(echo "$flat" | grep -oP '"privateKey":"\K[^"]+' | head -1 || true)
sid=$(echo "$flat" | grep -oP '"shortIds":\["\K[^"]+' | head -1 || true)
sni=$(echo "$flat" | grep -oP '"serverNames":\["\K[^"]+' | head -1 || true)
dest=$(echo "$flat" | grep -oP '"dest":"\K[^"]+' | head -1 || true)
port=$(echo "$flat" | grep -oP '"port":\K[0-9]+' | head -1 || true)
warn ".env 缺失,正在从现有的 ${XRAY_CONFIG} 恢复凭据"
warn "(这样客户端已有的分享链接依然可用,不会因重装而失效)"
XRAY_UUID="$uuid"
[ -n "$port" ] && XRAY_PORT="$port"
if [ -n "$priv" ]; then
XRAY_PRIVATE_KEY="$priv"
XRAY_PUBLIC_KEY=$(/usr/local/bin/xray x25519 -i "$priv" 2>/dev/null \
| grep -iE 'public|password' | awk -F': ' '{print $2}' | tr -d '[:space:]' || true)
[ -n "$sid" ] && XRAY_SHORT_ID="$sid"
[ -n "$sni" ] && REALITY_SNI="$sni"
[ -n "$dest" ] && REALITY_DEST="${dest%%:*}"
fi
log "已恢复 UUID: ${XRAY_UUID}"
}
generate_keys() {
step "3/8 生成密钥"
recover_credentials
XRAY_UUID="${XRAY_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
log "UUID: ${XRAY_UUID}"
if [ "$MODE" = "reality" ]; then
if [ -z "${XRAY_PRIVATE_KEY:-}" ] || [ -z "${XRAY_PUBLIC_KEY:-}" ]; then
local keys
keys=$(/usr/local/bin/xray x25519 2>&1)
XRAY_PRIVATE_KEY=$(echo "$keys" | grep -i 'private' | awk -F': ' '{print $2}' | tr -d '[:space:]')
XRAY_PUBLIC_KEY=$(echo "$keys" | grep -i 'public' | awk -F': ' '{print $2}' | tr -d '[:space:]')
fi
XRAY_SHORT_ID="${XRAY_SHORT_ID:-$(openssl rand -hex 8)}"
if [ -z "$XRAY_PRIVATE_KEY" ] || [ -z "$XRAY_PUBLIC_KEY" ]; then
error "密钥生成失败!xray x25519 输出:"
echo "$keys"
exit 1
fi
log "x25519 密钥对已生成或已加载"
log "ShortId: ${XRAY_SHORT_ID}"
fi
}
# ===== 候选伪装站的技术校验 =====
# 只看 xray tls ping 的「Pinging with SNI」那一段:REALITY 实际用的是带 SNI 的
# 握手结果,无 SNI 段返回的往往是另一张证书(如 Google 的 invalid2.invalid
# 才 895 字节),照它判会误判。
REALITY_DEST_CERT_LEN=""
check_reality_dest() {
local site="$1" out len
REALITY_DEST_CERT_LEN=""
local raw
raw=$(/usr/local/bin/xray tls ping "$site" 2>&1)
# 区分「当前 xray 没有 tls ping 子命令」和「这个域名校验不通过」:
# 前者不该阻断部署,后者必须判失败。有 "TLS ping" 头即说明子命令存在。
if ! echo "$raw" | grep -q "TLS ping"; then
warn " ${site}: 当前 xray 不支持 tls ping,跳过技术校验"
return 0
fi
out=$(echo "$raw" | sed -n '/Pinging with SNI/,$p')
echo "$out" | grep -q "Handshake succeeded" || return 1
echo "$out" | grep -q "TLS Version:.*TLS 1\.3" || return 1
len=$(echo "$out" | grep -oE "Certificate chain's total length:[[:space:]]*[0-9]+" \
| grep -oE '[0-9]+$' | head -1)
[ -n "$len" ] || return 1
# xtls/reality 对 target 握手报文有 8192 字节硬编码上限,超出直接握手失败
# XTLS/Xray-core#6356www.microsoft.com 的证书记录 8273 字节即触发)
[ "$len" -lt 8192 ] || return 1
REALITY_DEST_CERT_LEN="$len"
return 0
}
# ===== 3. 选择 Reality 伪装目标 =====
select_reality_dest() {
if [ "$MODE" != "reality" ]; then
return
fi
step "4/8 选择 Reality 伪装目标"
# 候选伪装站必须同时满足两类条件。
#
# 【技术条件】TLS 1.3 + 证书链总长 < 8192 字节。由 check_reality_dest 运行时实测。
#
# 【客户端路由条件】SNI 必须会被客户端判成「直连」——这条不直观,但踩过坑。
# TUN 模式下,客户端有时会把「连接本代理服务器」这件事本身也抓进代理
# v2rayN 的 sing-box+xray 双核心架构靠进程嗅探防回环,Windows 上有竞态,
# 实测约 2843:1584 的失败率)。这种回环连接的去向完全由 SNI 决定:
#
# SNI 被判直连 → 连接真的到达本服务器 → REALITY 握手成功 → 用户无感
# SNI 被判走代理 → 进隧道 → 服务器替客户端连了真正的伪装站
# → 客户端收到真证书 → 刷屏 REALITY: received real certificate
#
# 实测(v2rayN 默认规则模板,geosite:google → proxy 排在 geosite:cn → direct 之前):
# www.apple.com / *.apple.com 命中 geosite:cn 安全
# dl.google.com 命中 geosite:google 不安全(本仓库踩过)
# www.microsoft.com 两者都不命中 → final:proxy 不安全
# www.amazon.com 两者都不命中 → final:proxy 不安全
#
# 故候选只保留 Apple 系域名。更换前请先确认新域名命中客户端的直连规则。
local candidates=("www.apple.com" "swdist.apple.com" "updates.cdn-apple.com" "itunes.apple.com")
REALITY_DEST="${REALITY_DEST:-}"
REALITY_SNI="${REALITY_SNI:-}"
if [ -n "$REALITY_DEST" ] && [ -n "$REALITY_SNI" ] && [ "$REDETECT" -eq 0 ]; then
log "使用 .env 中已固定的伪装目标: ${REALITY_DEST}"
# 固定下来的伪装站可能哪天失效。这里只做可用性检查并告警,
# 绝不自动更换——更换会改变 SNI,导致所有客户端链接静默失效。
local ms
ms=$(curl -so /dev/null -w '%{time_connect}' --max-time 5 "https://${REALITY_DEST}" 2>/dev/null \
| awk '{printf "%d", $1*1000}') || true
if [ -z "$ms" ] || ! [[ "$ms" =~ ^[0-9]+$ ]] || [ "$ms" -le 0 ]; then
warn "伪装目标 ${REALITY_DEST} 当前不可达!Reality 回落会失败,客户端可能连不上"
warn "如需更换,执行: bash deploy.sh --redetect"
warn "注意:更换会改变 SNI,届时所有客户端链接都必须同步更新"
else
log "伪装目标连通性正常 (${ms}ms)"
if ! check_reality_dest "$REALITY_DEST"; then
warn "伪装目标 ${REALITY_DEST} 未通过 REALITY 技术校验"
warn "(要求 TLS 1.3 且证书链 < 8192 字节),客户端可能握手失败"
fi
fi
return
fi
if [ "$REDETECT" -eq 1 ] && [ -n "$REALITY_DEST" ]; then
warn "--redetect:将重新挑选伪装目标,原目标为 ${REALITY_DEST}"
fi
# 按列表顺序优先,只有前面的不可用才往后顺延——不按延迟排序。
# 候选现在都是 Apple 系主机,彼此延迟差在噪声级(实测 1~2ms),
# 按延迟挑会让每台机器随机选出不同 SNI,徒增客户端管理成本;
# 而 SNI 在所有机器上保持一致,运维和排查都省事得多。
local best_dest="" best_ms=""
for site in "${candidates[@]}"; do
local ms
# curl 失败时 %{time_connect} 会输出 0.000000,必须要求 ms > 0
# 否则连不通的站点会被当成可用
ms=$(curl -so /dev/null -w '%{time_connect}' --max-time 3 "https://${site}" 2>/dev/null \
| awk '{printf "%d", $1*1000}') || true
if [ -z "$ms" ] || ! [[ "$ms" =~ ^[0-9]+$ ]] || [ "$ms" -le 0 ]; then
warn " ${site} 不可达,跳过"
continue
fi
if ! check_reality_dest "$site"; then
warn " ${site} (${ms}ms) 未通过技术校验,跳过"
continue
fi
log " ${site}${ms}ms,证书链 ${REALITY_DEST_CERT_LEN:-?} 字节 ✔"
best_dest="$site"
best_ms="$ms"
break
done
if [ -z "$best_dest" ]; then
error "没有候选伪装目标能同时满足「可达」与「REALITY 技术要求」"
error "候选列表: ${candidates[*]}"
error "可在 .env 中手动指定 REALITY_DEST / REALITY_SNI 后重试"
exit 1
fi
local old_dest="$REALITY_DEST"
REALITY_DEST="$best_dest"
REALITY_SNI="$best_dest"
log "伪装目标: ${REALITY_DEST} (延迟 ${best_ms}ms)"
if [ -n "$old_dest" ] && [ "$old_dest" != "$best_dest" ]; then
echo ""
warn "════════════════════════════════════════════════════════"
warn "伪装目标已从 ${old_dest} 更换为 ${best_dest}"
warn "SNI 随之改变,所有客户端的旧链接将立即失效!"
warn "部署完成后请用新的分享链接重新配置全部客户端。"
warn "════════════════════════════════════════════════════════"
echo ""
fi
}
# ===== 4. 写入 Xray 配置 =====
XRAY_CONFIG="/usr/local/etc/xray/config.json"
# 对外 IP:.env 指定优先,否则探测出口 IP
SERVER_IP_EFFECTIVE=""
resolve_server_ip() {
if [ -n "${SERVER_IP:-}" ]; then
SERVER_IP_EFFECTIVE="$SERVER_IP"
return
fi
SERVER_IP_EFFECTIVE=$(curl -s --max-time 5 ipv4.ip.sb 2>/dev/null \
|| curl -s --max-time 5 ifconfig.me 2>/dev/null || true)
}
# 阻断规则:
# 1) 本机公网 IP 的「代理端口」—— 客户端若因路由回环把「连接本代理」这件事
# 也丢进隧道,服务器就成了自我中继与流量放大器。只掐这一个端口,
# 不影响经隧道 SSH 回本机等正常用法(那是很常见的运维操作)。
# 2) geoip:private —— 私网与回环。代理用户没有任何理由经本机访问服务器内网,
# 放开等于把内网暴露给所有客户端。
build_block_rules() {
SELF_LOOP_RULE=""
if [[ "$SERVER_IP_EFFECTIVE" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
SELF_LOOP_RULE=$(cat <<EOF
{
"type": "field",
"outboundTag": "block",
"ip": ["${SERVER_IP_EFFECTIVE}/32"],
"port": "${XRAY_PORT}"
},
EOF
)
fi
}
write_config() {
resolve_server_ip
build_block_rules
mkdir -p "$(dirname "$XRAY_CONFIG")"
# 先写临时文件并校验,通过后再原子替换正式配置,
# 保证校验失败时磁盘上的配置和正在运行的服务都不受影响
# xray 靠文件扩展名判断配置格式,临时文件必须叫 *.json,
# 否则会报 "Failed to get format"。所以用临时目录而非 mktemp 裸文件。
local tmpdir tmp
tmpdir="$(mktemp -d)"
tmp="${tmpdir}/config.json"
if [ "$MODE" = "reality" ]; then
write_config_reality "$tmp"
else
write_config_fast "$tmp"
fi
if ! /usr/local/bin/xray run -test -config "$tmp" >/dev/null 2>&1; then
error "配置校验失败,磁盘配置与运行中的服务均未改动。详细输出:"
/usr/local/bin/xray run -test -config "$tmp" || true
rm -rf "$tmpdir"
exit 1
fi
log "配置语法校验通过"
# 覆盖前备份旧配置,重跑脚本不至于丢掉手工改动(例如新增的 clients)
if [ -f "$XRAY_CONFIG" ]; then
local backup="${XRAY_CONFIG}.bak.$(date '+%Y%m%d%H%M%S')"
cp "$XRAY_CONFIG" "$backup"
log "旧配置已备份: ${backup}"
# 只保留最近 5 份,避免反复部署后无限堆积
ls -1t "${XRAY_CONFIG}".bak.* 2>/dev/null | tail -n +6 | xargs -r rm -f
fi
mv "$tmp" "$XRAY_CONFIG"
rm -rf "$tmpdir"
# xray 服务以 nobody 运行,配置必须对其可读
chmod 644 "$XRAY_CONFIG"
log "配置已写入: ${XRAY_CONFIG}"
}
write_config_reality() {
step "5/8 写入 Xray 配置 (Reality)"
local out="$1"
cat > "$out" << EOF
{
"log": {
"loglevel": "warning"
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": ${XRAY_PORT},
"protocol": "vless",
"settings": {
"clients": [
{
"id": "${XRAY_UUID}",
"flow": "xtls-rprx-vision"
}
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "${REALITY_DEST}:443",
"xver": 0,
"serverNames": [
"${REALITY_SNI}"
],
"privateKey": "${XRAY_PRIVATE_KEY}",
"shortIds": [
"${XRAY_SHORT_ID}"
]
}
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
}
],
"outbounds": [
{
"protocol": "freedom",
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block"
}
],
"routing": {
"rules": [
${SELF_LOOP_RULE}
{
"type": "field",
"outboundTag": "block",
"ip": ["geoip:private"]
},
{
"type": "field",
"outboundTag": "block",
"protocol": ["bittorrent"]
}
]
}
}
EOF
}
write_config_fast() {
step "5/8 写入 Xray 配置 (Fast TCP)"
local out="$1"
cat > "$out" << EOF
{
"log": {
"loglevel": "warning"
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": ${XRAY_PORT},
"protocol": "vless",
"settings": {
"clients": [
{
"id": "${XRAY_UUID}"
}
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "none"
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
}
],
"outbounds": [
{
"protocol": "freedom",
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block"
}
],
"routing": {
"rules": [
${SELF_LOOP_RULE}
{
"type": "field",
"outboundTag": "block",
"ip": ["geoip:private"]
},
{
"type": "field",
"outboundTag": "block",
"protocol": ["bittorrent"]
}
]
}
}
EOF
}
# ===== 检测当前生效的 SSH 端口 =====
# 防火墙一旦 enable,若没放行真实的 SSH 端口就会把自己锁在门外
detect_ssh_ports() {
local ports=""
if command -v sshd &>/dev/null; then
ports=$(sshd -T 2>/dev/null | awk '/^port /{print $2}' || true)
fi
if [ -z "$ports" ]; then
ports=$(ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -u | grep -x 22 || true)
fi
if [ -z "$ports" ]; then
ports=$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/{print $2}' /etc/ssh/sshd_config 2>/dev/null || true)
fi
# 兜底:至少放行 22,避免完全无法登录
[ -z "$ports" ] && ports="22"
echo "$ports"
}
# ===== 5. 防火墙 =====
configure_firewall() {
step "6/8 配置防火墙"
local ssh_ports
ssh_ports=$(detect_ssh_ports)
log "检测到 SSH 端口: $(echo "$ssh_ports" | tr '\n' ' ')"
if command -v ufw &>/dev/null; then
ufw allow "${XRAY_PORT}/tcp"
local p
for p in $ssh_ports; do
ufw allow "${p}/tcp"
done
ufw --force enable
log "ufw 已放行端口 ${XRAY_PORT}/tcp 及 SSH 端口"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-port="${XRAY_PORT}/tcp"
local p
for p in $ssh_ports; do
firewall-cmd --permanent --add-port="${p}/tcp"
done
firewall-cmd --reload
log "firewalld 已放行端口 ${XRAY_PORT}/tcp 及 SSH 端口"
else
warn "未检测到防火墙工具,请手动放行端口 ${XRAY_PORT}/tcp"
fi
}
# ===== 6. BBR + 网络调优 =====
tune_network() {
step "7/8 BBR + 网络调优"
cat > /etc/sysctl.d/99-xray-turbo.conf << 'SYSEOF'
# BBR 拥塞控制
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
# TCP 缓冲区(提升大文件 / 视频流吞吐量)
net.core.rmem_max=16777216
net.core.wmem_max=16777216
net.ipv4.tcp_rmem=4096 87380 16777216
net.ipv4.tcp_wmem=4096 65536 16777216
# 连接优化
net.ipv4.tcp_fastopen=3
net.ipv4.tcp_slow_start_after_idle=0
net.ipv4.tcp_mtu_probing=1
net.ipv4.tcp_fin_timeout=15
net.ipv4.tcp_keepalive_time=300
net.ipv4.tcp_keepalive_intvl=30
net.ipv4.tcp_keepalive_probes=5
net.ipv4.tcp_max_tw_buckets=262144
net.ipv4.tcp_max_syn_backlog=8192
net.core.somaxconn=8192
net.core.netdev_max_backlog=8192
SYSEOF
sysctl --system > /dev/null 2>&1
log "BBR 已启用,网络参数已优化"
}
# ===== 新增:系统与服务加固 =====
harden_system() {
step "8/8 加固系统与服务"
# 文件描述符上限
if ! grep -q '99-xray-nofile' /etc/security/limits.conf 2>/dev/null; then
cat >> /etc/security/limits.conf << 'EOF'
# 99-xray-nofile
* soft nofile 1000000
* hard nofile 1000000
root soft nofile 1000000
root hard nofile 1000000
EOF
fi
# systemd 服务加固
mkdir -p /etc/systemd/system/xray.service.d
cat > /etc/systemd/system/xray.service.d/override.conf << 'EOF'
[Service]
LimitNOFILE=1000000
Restart=always
RestartSec=3
EOF
configure_restart_timer
}
# ===== 7. 启动服务 =====
start_service() {
systemctl daemon-reload
systemctl restart xray
systemctl enable xray
sleep 2
if systemctl is-active --quiet xray; then
log "Xray 服务已启动 ✅"
else
error "Xray 启动失败,请检查: journalctl -u xray -f"
exit 1
fi
}
# ===== 8. 保存配置到 .env =====
save_env() {
cat > "$SCRIPT_DIR/.env" << EOF
# Xray 部署配置(由 deploy.sh 自动生成)
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
XRAY_MODE=${MODE}
XRAY_PORT=${XRAY_PORT}
XRAY_UUID=${XRAY_UUID}
# 分享链接中使用的 IP。留空则自动探测出口 IP;
# NAT / 端口转发的机器请填写服务商给你的实际入站 IP
SERVER_IP=${SERVER_IP:-}
# 定时重启。0 = 关闭(默认);N = 每 N 天在 XRAY_RESTART_TIME 重启一次。
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级),而硬重启会切断
# 全部活动连接。仅在确实观察到长期运行后性能退化时才启用。
# 改这几项无需完整重新部署: bash deploy.sh --restart 7 04:00
XRAY_RESTART_EVERY_DAYS=${RESTART_EVERY_DAYS}
XRAY_RESTART_TIME=${RESTART_TIME}
XRAY_RESTART_TIMEZONE=${RESTART_TZ}
EOF
if [ "$MODE" = "reality" ]; then
cat >> "$SCRIPT_DIR/.env" << EOF
XRAY_PRIVATE_KEY=${XRAY_PRIVATE_KEY}
XRAY_PUBLIC_KEY=${XRAY_PUBLIC_KEY}
XRAY_SHORT_ID=${XRAY_SHORT_ID}
REALITY_DEST=${REALITY_DEST}
REALITY_SNI=${REALITY_SNI}
EOF
fi
chmod 600 "$SCRIPT_DIR/.env"
log "配置已保存到 .env(权限 600"
}
# ===== 9. 输出连接信息 =====
print_result() {
local server_ip
[ -z "$SERVER_IP_EFFECTIVE" ] && resolve_server_ip
if [ -n "${SERVER_IP:-}" ]; then
server_ip="$SERVER_IP"
log "使用 .env 中指定的 SERVER_IP: ${server_ip}"
else
server_ip="${SERVER_IP_EFFECTIVE:-获取失败}"
# 注意:这里拿到的是「出口」IP。NAT / 端口转发的机器上,
# 它与外部实际可连接的「入站」IP 可能不是同一个
local nic_ip
nic_ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
if [ -n "$nic_ip" ] && [ "$nic_ip" != "$server_ip" ]; then
warn "网卡地址 ${nic_ip} 与出口 IP ${server_ip} 不一致,本机可能位于 NAT / 端口转发之后"
warn "若客户端连不上,请向服务商确认实际映射的入站 IP 与端口,"
warn "然后在 .env 中设置 SERVER_IP= 和 XRAY_PORT= 后重新运行本脚本"
fi
fi
local title
if [ "${SHOW_ONLY:-0}" -eq 1 ]; then
title="连接信息"
else
title="部署完成 ✅"
fi
echo ""
echo "=========================================================="
if [ "$MODE" = "reality" ]; then
echo " Xray VLESS-Reality ${title}"
echo "=========================================================="
echo ""
echo " IP : ${server_ip}"
echo " 端口 : ${XRAY_PORT}"
echo " 协议 : VLESS"
echo " UUID : ${XRAY_UUID}"
echo " 流控 : xtls-rprx-vision"
echo " 传输 : tcp"
echo " 安全 : reality"
echo " SNI : ${REALITY_SNI}"
echo " Fingerprint : chrome"
echo " PublicKey : ${XRAY_PUBLIC_KEY}"
echo " ShortId : ${XRAY_SHORT_ID}"
echo ""
echo "=========================================================="
echo ""
echo ">>> VLESS 分享链接(可直接导入客户端):"
echo ""
echo "vless://${XRAY_UUID}@${server_ip}:${XRAY_PORT}?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${REALITY_SNI}&fp=chrome&pbk=${XRAY_PUBLIC_KEY}&sid=${XRAY_SHORT_ID}&type=tcp#vps-reality"
else
echo " Xray VLESS TCP ${title}"
echo "=========================================================="
echo ""
echo " IP : ${server_ip}"
echo " 端口 : ${XRAY_PORT}"
echo " 协议 : VLESS"
echo " UUID : ${XRAY_UUID}"
echo " 传输 : tcp"
echo " 安全 : none"
echo ""
echo "=========================================================="
echo ""
echo ">>> VLESS 分享链接(可直接导入客户端):"
echo ""
echo "vless://${XRAY_UUID}@${server_ip}:${XRAY_PORT}?encryption=none&security=none&type=tcp&headerType=none#vps-fast"
fi
echo ""
echo "=========================================================="
echo ""
echo "⚠️ 请妥善保存以上信息!"
echo " 配置文件: /usr/local/etc/xray/config.json"
echo " 凭据备份: ${SCRIPT_DIR}/.env"
echo " 再次查看: bash deploy.sh --show"
echo " 查看日志: journalctl -u xray -f"
echo " 重启服务: systemctl restart xray"
echo "=========================================================="
}
# ===== --show:只读打印连接信息,不改动任何服务 =====
show_info() {
if [ ! -f "$SCRIPT_DIR/.env" ]; then
error "未找到 ${SCRIPT_DIR}/.env,无法读取部署信息"
echo ""
echo "若 Xray 配置仍在,可从 /usr/local/etc/xray/config.json 手工恢复:"
echo " priv=\$(grep -oP '\"privateKey\"\\s*:\\s*\"\\K[^\"]+' /usr/local/etc/xray/config.json)"
echo " /usr/local/bin/xray x25519 -i \"\$priv\" # 输出的 Password (PublicKey) 即客户端 pbk"
exit 1
fi
local missing=()
[ -z "${XRAY_UUID:-}" ] && missing+=("XRAY_UUID")
if [ "$MODE" = "reality" ]; then
[ -z "${XRAY_PUBLIC_KEY:-}" ] && missing+=("XRAY_PUBLIC_KEY")
[ -z "${XRAY_SHORT_ID:-}" ] && missing+=("XRAY_SHORT_ID")
[ -z "${REALITY_SNI:-}" ] && missing+=("REALITY_SNI")
fi
if [ "${#missing[@]}" -gt 0 ]; then
error ".env 缺少必要字段: ${missing[*]}"
error "请重新运行 bash deploy.sh 完成部署"
exit 1
fi
if ! systemctl is-active --quiet xray 2>/dev/null; then
warn "Xray 服务当前未运行,以下信息来自 .env,不代表服务可用"
warn "检查: systemctl status xray"
fi
print_result
}
# ===== 主流程 =====
main() {
if [ "$SHOW_ONLY" -eq 1 ]; then
show_info
exit 0
fi
if [ "$RESTART_ONLY" -eq 1 ]; then
apply_restart_only
exit 0
fi
echo ""
log "部署模式: ${MODE}"
[ "$MODE" = "reality" ] && log "VLESS-Reality(推荐,抗封锁)" || log "VLESS TCP(极速,无 TLS"
echo ""
ensure_deps
install_xray
generate_keys
select_reality_dest
write_config
configure_firewall
tune_network
harden_system
start_service
save_env
print_result
}
main