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 项,全部通过。
1220 lines
43 KiB
Bash
1220 lines
43 KiB
Bash
#!/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:MM(00: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#6356:www.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
|