42 KiB
Xray 定时重启计划 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让 vps-xray/deploy.sh 支持「每 N 天在固定时间点重启 Xray」,并提供一个不触碰 vless 链接的轻量命令行入口 --restart。
Architecture: systemd timer 每天到点唤醒一个 oneshot service,service 调用守卫脚本 xray-restart-guard;守卫读取状态文件里的上次重启时间戳,满 N 天才真正执行 systemctl restart xray。这样绕开了 OnCalendar 无法表达任意 N 天间隔(*-*-1/7 会跨月重置)的限制。timer / service / guard 三个文件的生成逻辑收敛在一个函数里,完整部署与 --restart 共用。
Tech Stack: Bash 4+、systemd、Debian/Ubuntu。无测试框架——本计划新增一个自包含的 bash 断言脚本 vps-xray/tests/test-restart-schedule.sh,可在 Windows 的 Git Bash 或 Linux 上直接跑,不需要 root、不接触真实 systemd。
Global Constraints
- 规格文档:
docs/superpowers/specs/2026-08-06-xray-restart-schedule-design.md,本计划的所有行为以它为准 - 默认关闭:首次部署
XRAY_RESTART_EVERY_DAYS=0,与当前行为一致 - 旧字段
XRAY_DAILY_RESTART仅在新字段完全未设置时才回退读取:true|yes|on|1(大小写不敏感)→1,其它 →0 - 时间接受
4:00与04:00,落盘前一律归一化为两位小时HH:MM - 参数不合法一律报错退出,不得静默回退默认值
- 守卫脚本必须先写时间戳再重启
- 守卫脚本间隔判断使用 300 秒容差:
(now - last) < DAYS*86400 - 300才跳过 - 状态文件
/var/lib/xray/last-restart;关闭定时重启时保留该文件 --restart路径禁止:装依赖、升级 Xray、生成密钥、选伪装目标、重写config.json、改防火墙与 sysctl、重启 xray- 所有可写路径通过可覆盖变量暴露,便于测试:
SYSTEMD_DIR、GUARD_BIN、RESTART_STATE_FILE - 提交信息用中文,与仓库现有风格一致
- 每个中间提交都必须是可部署的。 本工作直接在
master上进行,而master随时可能被upload.sh推到生产服务器。deploy.sh开头是set -euo pipefail,删除某个变量的赋值处而下游仍在引用,会让部署在中途崩溃(Task 1 已踩过一次)。拆任务时若某处的清理要延后,必须留过渡兼容代码,并在做清理的那个任务里显式删除。 set -u推论:任何返回非 0 的函数都必须在条件位置调用(if f; then/f || x/f && x),裸调用会让整个脚本退出
File Structure
| 文件 | 职责 |
|---|---|
vps-xray/deploy.sh |
配置解析与迁移、单元文件生成、--restart 入口。改动集中在三处:顶部参数与配置解析、新增的一组 *_restart_* 函数、main() 早退分支 |
vps-xray/tests/test-restart-schedule.sh |
新建。抽取 deploy.sh 的函数定义做断言,覆盖归一化、迁移、校验、.env 改写、单元文件渲染、守卫脚本行为 |
vps-xray/.env.example |
三个新字段与说明 |
vps-xray/uninstall.sh |
补充清理 guard 脚本与状态文件 |
vps-xray/README.md |
定时重启一节 |
Task 1: 时间归一化与配置解析
Files:
- Create:
vps-xray/tests/test-restart-schedule.sh - Modify:
vps-xray/deploy.sh(替换第 78~82 行附近的XRAY_DAILY_RESTARTcase 块)
Interfaces:
-
Produces:
normalize_restart_time <str>→ stdout 输出HH:MM,不合法返回 1 -
Produces:
resolve_restart_config→ 读环境变量XRAY_RESTART_EVERY_DAYS/XRAY_RESTART_TIME/XRAY_RESTART_TIMEZONE/XRAY_DAILY_RESTART,设置全局RESTART_EVERY_DAYS(整数)、RESTART_TIME(HH:MM)、RESTART_TZ(字符串);任一不合法则error后返回 1 -
Step 1: 写测试脚手架与失败测试
创建 vps-xray/tests/test-restart-schedule.sh:
#!/usr/bin/env bash
# deploy.sh 中重启计划相关逻辑的单元测试
#
# 用法: bash vps-xray/tests/test-restart-schedule.sh
#
# 不需要 root,不接触真实 systemd:所有写入路径都被重定向到临时目录,
# systemctl 用 PATH 打桩拦截。
#
# 加载方式说明:deploy.sh 结尾会直接调用 main,且顶层有 root 检查与参数解析,
# 不能整份 source。这里只抽取「多行函数定义」(形如 `name() {` 开头、`}` 结尾)。
# 单行函数(log/warn/error/step)抽不到,正好由下面的桩覆盖。
set -u
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY="${TEST_DIR}/../deploy.sh"
if [ ! -f "$DEPLOY" ]; then
echo "找不到 $DEPLOY" >&2
exit 1
fi
eval "$(sed -n '/^[a-z_][a-z_]*() {$/,/^}$/p' "$DEPLOY")"
# 日志桩:error 保留输出便于断言,其余静音
log() { :; }
warn() { :; }
step() { :; }
error() { echo "ERROR: $*" >&2; }
PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; echo " 期望: $2"; echo " 实际: $3"; }
# is <描述> <实际值> <期望值>
is() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$3" "$2"; fi; }
echo "== normalize_restart_time =="
is "04:00 原样通过" "$(normalize_restart_time 04:00)" "04:00"
is "4:00 补零" "$(normalize_restart_time 4:00)" "04:00"
is "08:30 不被当八进制" "$(normalize_restart_time 08:30)" "08:30"
is "23:59 边界" "$(normalize_restart_time 23:59)" "23:59"
is "00:00 边界" "$(normalize_restart_time 00:00)" "00:00"
normalize_restart_time "24:00" >/dev/null 2>&1
is "24:00 被拒" "$?" "1"
normalize_restart_time "04:60" >/dev/null 2>&1
is "04:60 被拒" "$?" "1"
normalize_restart_time "abc" >/dev/null 2>&1
is "abc 被拒" "$?" "1"
normalize_restart_time "" >/dev/null 2>&1
is "空串被拒" "$?" "1"
echo "== resolve_restart_config =="
reset_env() {
unset XRAY_RESTART_EVERY_DAYS XRAY_RESTART_TIME XRAY_RESTART_TIMEZONE XRAY_DAILY_RESTART
RESTART_EVERY_DAYS=""; RESTART_TIME=""; RESTART_TZ=""
}
reset_env
resolve_restart_config
is "全空时天数默认 0" "$RESTART_EVERY_DAYS" "0"
is "全空时时间默认" "$RESTART_TIME" "04:00"
is "全空时时区默认" "$RESTART_TZ" "Asia/Shanghai"
reset_env; XRAY_RESTART_EVERY_DAYS=7; XRAY_RESTART_TIME=5:30
resolve_restart_config
is "读取新字段天数" "$RESTART_EVERY_DAYS" "7"
is "读取新字段时间并补零" "$RESTART_TIME" "05:30"
reset_env; XRAY_DAILY_RESTART=true
resolve_restart_config
is "迁移 true → 1" "$RESTART_EVERY_DAYS" "1"
reset_env; XRAY_DAILY_RESTART=TRUE
resolve_restart_config
is "迁移 TRUE → 1" "$RESTART_EVERY_DAYS" "1"
reset_env; XRAY_DAILY_RESTART=false
resolve_restart_config
is "迁移 false → 0" "$RESTART_EVERY_DAYS" "0"
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=3
resolve_restart_config
is "新字段优先于旧字段" "$RESTART_EVERY_DAYS" "3"
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=0
resolve_restart_config
is "新字段显式 0 覆盖旧 true" "$RESTART_EVERY_DAYS" "0"
reset_env; XRAY_RESTART_EVERY_DAYS=abc
resolve_restart_config 2>/dev/null
is "天数非数字报错" "$?" "1"
reset_env; XRAY_RESTART_EVERY_DAYS=-1
resolve_restart_config 2>/dev/null
is "天数负数报错" "$?" "1"
reset_env; XRAY_RESTART_TIME=25:00
resolve_restart_config 2>/dev/null
is "时间非法报错" "$?" "1"
reset_env; XRAY_RESTART_TIMEZONE=""
resolve_restart_config 2>/dev/null
is "时区空串报错" "$?" "1"
echo ""
echo "通过 ${PASS},失败 ${FAIL}"
[ "$FAIL" -eq 0 ]
- Step 2: 运行测试确认失败
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 大量 FAIL,并伴随 normalize_restart_time: command not found —— 因为函数尚未存在。
- Step 3: 实现两个函数
在 vps-xray/deploy.sh 中,把现有的这段:
# 布尔开关归一化:容忍 true/TRUE/True/yes/on/1 等写法,
# 避免用户按直觉写了 TRUE 却被静默当成关闭
case "$(printf '%s' "${XRAY_DAILY_RESTART:-false}" | tr '[:upper:]' '[:lower:]')" in
true|yes|on|1) DAILY_RESTART=1 ;;
*) DAILY_RESTART=0 ;;
esac
整段替换为:
# ===== 定时重启配置 =====
# 可覆盖路径,测试时指向临时目录
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
RESTART_EVERY_DAYS="$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
}
resolve_restart_config || exit 1
# 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART,
# 要到 Task 5 才清理。在那之前必须保证 master 上的脚本能完整跑完——
# deploy.sh 开头是 set -euo pipefail,缺这个变量会在部署中途崩溃。
DAILY_RESTART=$([ "$RESTART_EVERY_DAYS" -ne 0 ] && echo 1 || echo 0)
注意 ${XRAY_RESTART_TIMEZONE-Asia/Shanghai} 用的是 - 而非 :-——显式设成空串时要报错,不能被默认值悄悄补上。
为什么需要那行过渡兼容赋值: 被替换掉的 case 块是 DAILY_RESTART 的唯一赋值处,而下游三处引用要到 Task 5 才删。set -u 下变量缺失会让 harden_system 直接 unbound variable 退出——那时 Xray 已装好、config.json 已写入,但服务不会启动、.env 不会更新。每个中间提交都必须是可部署的。
- Step 4: 运行测试确认通过
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 23,失败 0(normalize 9 项 + resolve 14 项)
Run: bash -n vps-xray/deploy.sh
Expected: 无输出
- Step 5: 提交
git add vps-xray/deploy.sh vps-xray/tests/test-restart-schedule.sh
git commit -m "feat(vps-xray): 重启计划配置解析与旧字段迁移
新增 normalize_restart_time / resolve_restart_config,
XRAY_RESTART_EVERY_DAYS 缺失时才回退读 XRAY_DAILY_RESTART。
附单元测试,不需要 root 与 systemd。"
Task 2: .env 字段就地更新
Files:
- Modify:
vps-xray/deploy.sh(在resolve_restart_config之后新增函数) - Modify:
vps-xray/tests/test-restart-schedule.sh
Interfaces:
-
Consumes: 无
-
Produces:
update_env_field <file> <key> <value>→ 文件中存在^key=则替换该行,否则追加一行;文件不存在返回 1 -
Step 1: 追加失败测试
在 vps-xray/tests/test-restart-schedule.sh 的 echo "" 汇总行之前插入:
echo "== update_env_field =="
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cat > "${TMP}/.env" << 'ENVEOF'
# 这是用户自己加的注释
XRAY_PORT=443
XRAY_RESTART_EVERY_DAYS=0
# 尾部注释
ENVEOF
update_env_field "${TMP}/.env" XRAY_RESTART_EVERY_DAYS 7
is "替换已有字段" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${TMP}/.env")" "XRAY_RESTART_EVERY_DAYS=7"
is "保留用户注释" "$(grep -c '这是用户自己加的注释' "${TMP}/.env")" "1"
is "保留尾部注释" "$(grep -c '尾部注释' "${TMP}/.env")" "1"
is "不影响其它字段" "$(grep '^XRAY_PORT=' "${TMP}/.env")" "XRAY_PORT=443"
update_env_field "${TMP}/.env" XRAY_RESTART_TIME 05:30
is "追加缺失字段" "$(grep '^XRAY_RESTART_TIME=' "${TMP}/.env")" "XRAY_RESTART_TIME=05:30"
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Asia/Shanghai
is "含斜杠的值不被 sed 分隔符干扰" \
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Asia/Shanghai"
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Europe/Berlin
is "含斜杠的值可被再次替换" \
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Europe/Berlin"
is "字段总行数正确" "$(grep -c '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "1"
update_env_field "${TMP}/nonexistent" FOO bar 2>/dev/null
is "文件不存在返回 1" "$?" "1"
- Step 2: 运行测试确认失败
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: update_env_field: command not found,新增断言全部 FAIL
- Step 3: 实现
在 vps-xray/deploy.sh 中 resolve_restart_config 函数定义之后、resolve_restart_config || exit 1 调用之前,插入:
# 就地更新 .env 中的单个字段:存在则替换,不存在则追加。
# 刻意不复用 save_env——后者是 cat > 全量重写,会抹掉用户自己加的注释与字段;
# 轻量入口只该改自己那一行。
update_env_field() {
local file="$1" key="$2" value="$3" esc
[ -f "$file" ] || return 1
if grep -q "^${key}=" "$file"; then
# 用 | 作分隔符,并转义 value 中的 | & \,避免值里的 / 破坏 sed
esc=$(printf '%s' "$value" | sed 's/[|&\\]/\\&/g')
sed -i "s|^${key}=.*|${key}=${esc}|" "$file"
else
printf '%s=%s\n' "$key" "$value" >> "$file"
fi
return 0
}
- Step 4: 运行测试确认通过
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 34,失败 0(新增 9 项 + 复审补的 2 项缺尾换行断言)
- Step 5: 提交
git add vps-xray/deploy.sh vps-xray/tests/test-restart-schedule.sh
git commit -m "feat(vps-xray): 新增 update_env_field 就地更新 .env 单个字段"
Task 3: 渲染 timer / service / guard 三个文件
Files:
- Modify:
vps-xray/deploy.sh - Modify:
vps-xray/tests/test-restart-schedule.sh
Interfaces:
-
Consumes: 全局
SYSTEMD_DIR、GUARD_BIN、RESTART_STATE_FILE -
Produces:
render_restart_units <days> <time> <tz>→ 写出${SYSTEMD_DIR}/xray-restart.timer、${SYSTEMD_DIR}/xray-restart.service、${GUARD_BIN}(可执行) -
Produces:
remove_restart_units→ 删除上述三个文件并 disable timer;原本存在返回 0,本就不存在返回 1 -
Step 1: 追加失败测试
在汇总行之前插入:
echo "== render_restart_units =="
SYSTEMD_DIR="${TMP}/systemd"
GUARD_BIN="${TMP}/bin/xray-restart-guard"
RESTART_STATE_FILE="${TMP}/state/last-restart"
mkdir -p "${TMP}/systemd" "${TMP}/bin" "${TMP}/state"
# systemctl 打桩:记录被调用的参数,避免碰真实 systemd
STUB="${TMP}/stub"
mkdir -p "$STUB"
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
STUBEOF
chmod 755 "${STUB}/systemctl"
export SYSTEMCTL_LOG="${TMP}/systemctl.log"
: > "$SYSTEMCTL_LOG"
export PATH="${STUB}:${PATH}"
render_restart_units 7 04:00 Asia/Shanghai
is "timer 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes)" "yes"
is "service 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes)" "yes"
is "guard 可执行" "$([ -x "$GUARD_BIN" ] && echo yes)" "yes"
is "OnCalendar 每天触发" \
"$(grep -c '^OnCalendar=\*-\*-\* 04:00:00$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
is "TimeZone 写入" \
"$(grep -c '^TimeZone=Asia/Shanghai$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
is "Persistent 开启" \
"$(grep -c '^Persistent=true$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
is "service 指向 guard" \
"$(grep -c "^ExecStart=${GUARD_BIN}\$" "${SYSTEMD_DIR}/xray-restart.service")" "1"
is "guard 里天数正确" \
"$(grep -c '^DAYS=7$' "$GUARD_BIN")" "1"
is "guard 里状态文件路径正确" \
"$(grep -c "^STATE=\"${RESTART_STATE_FILE}\"\$" "$GUARD_BIN")" "1"
is "guard 用 PATH 查找 systemctl 而非绝对路径" \
"$(grep -c '^exec systemctl restart xray$' "$GUARD_BIN")" "1"
render_restart_units 3 23:05 Europe/Berlin
is "重复渲染覆盖天数" "$(grep -c '^DAYS=3$' "$GUARD_BIN")" "1"
is "重复渲染覆盖时间" \
"$(grep -c '^OnCalendar=\*-\*-\* 23:05:00$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
remove_restart_units
is "remove 后 timer 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
is "remove 后 service 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
is "remove 后 guard 消失" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
remove_restart_units
is "重复 remove 返回 1" "$?" "1"
- Step 2: 运行测试确认失败
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: render_restart_units: command not found,新增断言 FAIL
- Step 3: 实现
在 vps-xray/deploy.sh 中 update_env_field 之后插入:
# 生成 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
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
mkdir -p "\$(dirname "\$STATE")"
# 必须先写时间戳:restart 会中断本脚本所在的 systemd 事务,
# 顺序反了会丢记录,退化成每天都重启。
# 代价是重启失败时本轮仍被记为已执行——这比陷入每日重启循环可接受。
echo "\$now" > "\$STATE"
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]
OnCalendar=*-*-* ${time}:00
TimeZone=${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
systemctl disable --now xray-restart.timer >/dev/null 2>&1 || true
rm -f "${SYSTEMD_DIR}/xray-restart.timer" \
"${SYSTEMD_DIR}/xray-restart.service" \
"$GUARD_BIN"
return 0
}
- Step 4: 运行测试确认通过
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 50,失败 0(34 基线 + 新增 16)
Run: bash -n vps-xray/deploy.sh
Expected: 无输出
- Step 5: 提交
git add vps-xray/deploy.sh vps-xray/tests/test-restart-schedule.sh
git commit -m "feat(vps-xray): 生成 timer/service/guard 三个单元文件
timer 每天唤醒,N 天间隔判断交给 guard 脚本,
绕开 OnCalendar 无法表达任意 N 天间隔的限制。"
Task 4: 守卫脚本行为
Files:
- Modify:
vps-xray/tests/test-restart-schedule.sh
Interfaces:
- Consumes: Task 3 的
render_restart_units
本任务只加测试——被测对象是 Task 3 已生成的 guard 脚本。若测试暴露缺陷,回到 Task 3 的实现修改。
- Step 1: 追加行为测试
在汇总行之前插入:
echo "== guard 脚本行为 =="
render_restart_units 7 04:00 Asia/Shanghai
rm -f "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
# 无状态文件 → 首次必须执行
out=$("$GUARD_BIN" 2>&1)
is "首次执行重启" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "1"
is "首次写入时间戳" "$([ -f "$RESTART_STATE_FILE" ] && echo yes)" "yes"
# 刚重启过 → 必须跳过
: > "$SYSTEMCTL_LOG"
out=$("$GUARD_BIN" 2>&1)
is "刚重启过则跳过" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "0"
is "跳过时有提示" "$(echo "$out" | grep -c '跳过')" "1"
# 距上次 6 天 → 仍未满 7 天,跳过
echo "$(( $(date +%s) - 6 * 86400 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "6 天不足 7 天则跳过" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "0"
# 距上次 7 天差 100 秒 → 300 秒容差内,必须执行
echo "$(( $(date +%s) - 7 * 86400 + 100 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "容差内视为已满 7 天" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "1"
# 距上次 7 天差 600 秒 → 超出容差,跳过
echo "$(( $(date +%s) - 7 * 86400 + 600 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "超出容差仍判不足" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "0"
# 状态文件内容被写坏 → 按 0 处理,立即执行
echo "garbage" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "状态文件非法时立即执行" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "1"
is "非法内容被覆盖为数字" \
"$(grep -cE '^[0-9]+$' "$RESTART_STATE_FILE")" "1"
# 每日模式:距上次 1 天即执行
render_restart_units 1 04:00 Asia/Shanghai
echo "$(( $(date +%s) - 86400 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "N=1 时满一天即执行" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "1"
# 时间戳先于重启写入:用一个必定失败的 systemctl 桩验证
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
exit 1
STUBEOF
chmod 755 "${STUB}/systemctl"
rm -f "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "重启失败时时间戳仍已写入" "$([ -s "$RESTART_STATE_FILE" ] && echo yes)" "yes"
# 恢复正常桩,避免影响后续
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
STUBEOF
chmod 755 "${STUB}/systemctl"
- Step 2: 运行测试
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 61,失败 0(50 基线 + 新增 11)。若有 FAIL,说明 Task 3 生成的 guard 逻辑有缺陷——回到 Task 3 的 render_restart_units 修改后重跑。
- Step 3: 提交
git add vps-xray/tests/test-restart-schedule.sh
git commit -m "test(vps-xray): 覆盖 guard 脚本的间隔判断、容差与状态文件容错"
Task 5: 接线进完整部署流程
Files:
- Modify:
vps-xray/deploy.sh(harden_system、start_service、save_env)
Interfaces:
-
Consumes:
resolve_restart_config、render_restart_units、remove_restart_units -
Produces:
configure_restart_timer→ 依据全局RESTART_EVERY_DAYS/RESTART_TIME/RESTART_TZ建立或移除定时重启,并完成daemon-reload与 enable/disable -
Step 1: 新增 configure_restart_timer
在 vps-xray/deploy.sh 中 remove_restart_units 之后插入:
configure_restart_timer() {
if [ "$RESTART_EVERY_DAYS" -eq 0 ]; then
if remove_restart_units; then
warn "已移除定时重启(XRAY_RESTART_EVERY_DAYS=0)"
fi
systemctl daemon-reload
log "定时重启: 关闭(崩溃恢复已由 Restart=always 秒级覆盖)"
return
fi
render_restart_units "$RESTART_EVERY_DAYS" "$RESTART_TIME" "$RESTART_TZ"
systemctl daemon-reload
systemctl enable --now xray-restart.timer >/dev/null 2>&1
log "定时重启: 每 ${RESTART_EVERY_DAYS} 天 ${RESTART_TIME} (${RESTART_TZ})"
warn "重启会切断所有活动连接;崩溃恢复本已由 Restart=always 覆盖,按需启用"
}
- Step 2: 替换 harden_system 里的旧逻辑
在 harden_system() 中,把从 # ===== 每日重启 timer:默认关闭 ===== 注释开始、到函数结尾 warn "该操作会切断所有活动连接,如无必要建议设 XRAY_DAILY_RESTART=false" 为止的全部内容(含 if [ "$DAILY_RESTART" -ne 1 ] 分支、两处 cat > /etc/systemd/system/xray-restart.* 生成块)删除,替换为单行:
configure_restart_timer
保留其上方的文件描述符上限与 xray.service.d/override.conf 生成逻辑,不要动。
- Step 3: 清理 start_service 中的重复 enable
在 start_service() 中删除这三行——timer 的 enable 已由 configure_restart_timer 负责,且 harden_system 在 start_service 之前执行:
if [ "$DAILY_RESTART" -eq 1 ]; then
systemctl enable --now xray-restart.timer
fi
- Step 4: 更新 save_env
在 save_env() 中,把这一段:
# 每日 04:00 自动重启 Xray。默认 false。
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级),而每日硬重启会
# 切断全部活动连接。仅在确实观察到长期运行后性能退化时才改为 true。
XRAY_DAILY_RESTART=$([ "$DAILY_RESTART" -eq 1 ] && echo true || echo false)
替换为:
# 定时重启。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}
- Step 5: 删除 Task 1 留下的过渡兼容赋值
下游三处引用已在 Step 2~4 全部清理,Task 1 加的这行兼容赋值到此完成使命,删掉:
# 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART,
# 要到 Task 5 才清理。在那之前必须保证 master 上的脚本能完整跑完——
# deploy.sh 开头是 set -euo pipefail,缺这个变量会在部署中途崩溃。
DAILY_RESTART=$([ "$RESTART_EVERY_DAYS" -ne 0 ] && echo 1 || echo 0)
留着它就是僵尸代码——没有任何读取方,却会让下一个读代码的人以为还有别处在用。
- Step 6: 确认没有遗留引用
Run: grep -n "DAILY_RESTART" vps-xray/deploy.sh
Expected: 只剩 resolve_restart_config 里那处向后兼容的 ${XRAY_DAILY_RESTART:-false},共 1 行。
Run: bash -n vps-xray/deploy.sh
Expected: 无输出
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 61,失败 0
- Step 7: 提交
git add vps-xray/deploy.sh
git commit -m "refactor(vps-xray): 完整部署改用 configure_restart_timer
harden_system 与 start_service 里的旧 DAILY_RESTART 分支删除,
save_env 改写三个新字段。"
Task 6: --restart 轻量入口
Files:
- Modify:
vps-xray/deploy.sh(usage、参数解析、main,新增apply_restart_only)
Interfaces:
-
Consumes:
resolve_restart_config、update_env_field、configure_restart_timer -
Produces: 全局
RESTART_ONLY、RESTART_ARG_DAYS、RESTART_ARG_TIME;函数apply_restart_only -
Step 1: 更新 usage
在 usage() 中 --redetect 那两行之后、-h, --help 之前插入:
echo " --restart [N] [HH:MM]"
echo " 只修改定时重启计划并立即生效。N=0 关闭。"
echo " 不重装 Xray、不重写配置、不重启服务,"
echo " 现有 vless 链接完全不受影响。"
echo " 例: bash deploy.sh --restart 7 04:00"
- Step 2: 扩展参数解析
在参数解析区,把:
MODE=""
MODE_EXPLICIT=0
SHOW_ONLY=0
REDETECT=0
改为:
MODE=""
MODE_EXPLICIT=0
SHOW_ONLY=0
REDETECT=0
RESTART_ONLY=0
RESTART_ARG_DAYS=""
RESTART_ARG_TIME=""
在 while 循环的 case 中,--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
;;
在 while 循环结束后、root 检查之前插入互斥校验:
# --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
- Step 3: 实现 apply_restart_only
在 configure_restart_timer 之后插入:
# ===== --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
update_env_field "$env_file" XRAY_RESTART_EVERY_DAYS "$RESTART_EVERY_DAYS"
fi
if [ -n "$RESTART_ARG_TIME" ]; then
update_env_field "$env_file" XRAY_RESTART_TIME "$RESTART_TIME"
fi
configure_restart_timer
echo ""
echo "=========================================================="
if [ "$RESTART_EVERY_DAYS" -eq 0 ]; then
echo " 定时重启: 已关闭"
else
echo " 定时重启: 每 ${RESTART_EVERY_DAYS} 天 ${RESTART_TIME} (${RESTART_TZ})"
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
fi
}
注意 resolve_restart_config 在脚本顶层已经跑过一次,这里再跑一次是为了让命令行传入的值参与校验与归一化。
- Step 4: 在 main 中早退
在 main() 里,紧跟现有的 --show 分支之后插入:
if [ "$RESTART_ONLY" -eq 1 ]; then
apply_restart_only
exit 0
fi
- Step 5: 修正非法参数的退出码
现状:usage() 结尾是 exit 0,而参数解析的兜底分支是 *) error "未知参数: $1"; usage ;;——非法参数会以退出码 0 结束。--restart 7 25:00 中的 25:00 不匹配时间形态、不被 --restart 消费,正会落进这个分支,于是「时间写错」表现为「成功退出」。这违反本计划的全局约束。
把 usage() 的结尾:
exit 0
}
改为:
exit "${1:-0}"
}
并把参数解析的兜底分支:
*) error "未知参数: $1"; usage ;;
改为:
*) error "未知参数: $1"; usage 1 ;;
-h|--help) usage ;; 保持不变,仍以 0 退出。
- Step 6: 本地校验
Run: bash -n vps-xray/deploy.sh
Expected: 无输出
Run: bash vps-xray/deploy.sh --badarg > /dev/null 2>&1; echo $?
Expected: 1
Run: bash vps-xray/deploy.sh --help > /dev/null 2>&1; echo $?
Expected: 0
Run: bash vps-xray/tests/test-restart-schedule.sh
Expected: 通过 61,失败 0
Run: bash vps-xray/deploy.sh --help
Expected: 帮助里出现 --restart [N] [HH:MM] 说明,退出码 0
- Step 7: 提交
git add vps-xray/deploy.sh
git commit -m "feat(vps-xray): 新增 --restart 轻量入口
只改定时重启计划并立即生效,不重装 Xray、不重写配置、不重启服务,
与 --mode/--show/--redetect 互斥。
顺带修正非法参数以退出码 0 结束的问题。"
Task 7: 文档与卸载清理
Files:
-
Modify:
vps-xray/.env.example -
Modify:
vps-xray/uninstall.sh -
Modify:
vps-xray/README.md -
Step 1: 更新 .env.example
把现有这段:
# 每日 04:00 自动重启 Xray。默认 false。
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级),而每日硬重启会
# 切断全部活动连接。仅在确实观察到长期运行后性能退化时才改为 true。
# XRAY_DAILY_RESTART=false
替换为:
# ===== 定时重启 =====
# 0 = 关闭(默认);N = 每 N 天重启一次 Xray。
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级响应),而硬重启会切断
# 全部活动连接。仅在确实观察到长期运行后性能退化时才启用。
# XRAY_RESTART_EVERY_DAYS=0
# XRAY_RESTART_TIME=04:00
# XRAY_RESTART_TIMEZONE=Asia/Shanghai
#
# 改这几项无需完整重新部署,用轻量入口即可(不影响 vless 链接):
# bash deploy.sh --restart 7 04:00
# bash deploy.sh --restart 0
#
# 旧字段 XRAY_DAILY_RESTART 已废弃,仅在上面三项都没设时作为兼容回退:
# true → 每 1 天,其它 → 关闭。重跑部署后会自动换写成新字段。
- Step 2: 更新 uninstall.sh
把现有这段:
if [ -f /etc/systemd/system/xray-restart.timer ]; then
systemctl disable --now xray-restart.timer 2>/dev/null || true
rm -f /etc/systemd/system/xray-restart.timer \
/etc/systemd/system/xray-restart.service
替换为:
if [ -f /etc/systemd/system/xray-restart.timer ] \
|| [ -f /usr/local/bin/xray-restart-guard ]; then
systemctl disable --now xray-restart.timer 2>/dev/null || true
rm -f /etc/systemd/system/xray-restart.timer \
/etc/systemd/system/xray-restart.service \
/usr/local/bin/xray-restart-guard \
/var/lib/xray/last-restart
卸载与「关闭定时重启」不同,卸载要连状态文件一起清掉。
- Step 3: 更新 README
在 README.md 的「查看状态 / 日志」一节之前插入:
### 定时重启
默认**关闭**。崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),而硬重启会切断全部活动连接——跨夜下载、备份、CI 都会中断。仅在确实观察到长期运行后性能退化时才启用。
需要时用轻量入口调整,**不会重装 Xray、不会重写配置、不会重启服务,现有 vless 链接完全不受影响**:
```bash
bash deploy.sh --restart 7 04:00 # 每 7 天 04:00 重启
bash deploy.sh --restart 3 # 每 3 天,时间沿用 .env
bash deploy.sh --restart 0 # 关闭
bash deploy.sh --restart # 按 .env 现有值重建 timer
对应 .env 三个字段:
| 字段 | 含义 | 默认 |
|---|---|---|
XRAY_RESTART_EVERY_DAYS |
0 = 关闭;N = 每 N 天 | 0 |
XRAY_RESTART_TIME |
24 小时制 HH:MM |
04:00 |
XRAY_RESTART_TIMEZONE |
时区 | Asia/Shanghai |
为什么不是纯 systemd timer
OnCalendar 表达不了任意 N 天:*-*-1/7 04:00:00 是「每月 1、8、15、22、29 号」,跨月会重置——29 号到下月 1 号只隔 2~3 天。
所以实现是 timer 每天到点唤醒,由 /usr/local/bin/xray-restart-guard 读 /var/lib/xray/last-restart,满 N 天才真正重启。间隔因此是「距上次实际重启」的真实天数,N=3 与 N=7 同样准确。
查看下次触发时间与执行记录:
systemctl list-timers xray-restart.timer
journalctl -u xray-restart.service -n 20
旧字段
XRAY_DAILY_RESTART已废弃。三个新字段都未设置时它仍作为兼容回退(true→ 每 1 天,其它 → 关闭),重跑部署后.env会自动换写成新字段。
- [ ] **Step 4: 校验**
Run: `bash -n vps-xray/uninstall.sh`
Expected: 无输出
Run: `grep -c "XRAY_DAILY_RESTART" vps-xray/README.md`
Expected: `1`(只剩兼容说明那处)
- [ ] **Step 5: 提交**
```bash
git add vps-xray/.env.example vps-xray/uninstall.sh vps-xray/README.md
git commit -m "docs(vps-xray): 定时重启配置说明;uninstall 清理 guard 与状态文件"
Task 8: VPS 端到端验证
Files: 无改动。本任务在真实服务器上执行 spec 的验证方案。
前置: 目标机器已用旧版脚本部署过,.env 存在。先记下当前 vless 链接以便比对。
- Step 1: 上传
bash vps-xray/upload.sh <VPS_IP>
- Step 2: 启用并确认服务未被重启
在 VPS 上:
cd /opt/vps-xray
systemctl show xray -p ActiveEnterTimestamp # 记下改动前的值
bash deploy.sh --restart 7 04:00
systemctl show xray -p ActiveEnterTimestamp # 必须与上面完全相同
grep -E '^XRAY_RESTART_' .env
systemctl list-timers xray-restart.timer --no-pager
Expected:ActiveEnterTimestamp 前后一致(证明没重启 xray);.env 含 XRAY_RESTART_EVERY_DAYS=7 与 XRAY_RESTART_TIME=04:00;list-timers 显示 NEXT 为次日 04:00。
- Step 3: 守卫行为
rm -f /var/lib/xray/last-restart
/usr/local/bin/xray-restart-guard # 应输出「执行重启」
/usr/local/bin/xray-restart-guard # 应输出「跳过」
cat /var/lib/xray/last-restart
Expected:第一次重启并写入时间戳,第二次跳过。
- Step 4: 关闭
bash deploy.sh --restart 0
ls -l /etc/systemd/system/xray-restart.* /usr/local/bin/xray-restart-guard 2>&1
ls -l /var/lib/xray/last-restart
Expected:三个单元/脚本文件均报 No such file;状态文件仍在。
- Step 5: 参数校验与互斥
bash deploy.sh --restart 7 25:00 ; echo "exit=$?"
bash deploy.sh --restart abc ; echo "exit=$?"
bash deploy.sh --restart 7 --redetect ; echo "exit=$?"
bash deploy.sh --restart 4:00 ; echo "exit=$?"
ls /etc/systemd/system/xray-restart.timer 2>&1
逐条预期(形态解析的既定行为,都不是缺陷):
| 命令 | 行为 | 退出码 |
|---|---|---|
--restart 7 25:00 |
25:00 不匹配时间形态、不被消费,落到兜底分支报「未知参数: 25:00」并打印帮助 |
1 |
--restart abc |
同上,报「未知参数: abc」 | 1 |
--restart 7 --redetect |
--redetect 不被消费,正常置位,循环后互斥校验拦下,报「--restart 不能与 --mode / --show / --redetect 同时使用」 |
1 |
--restart 4:00 |
4:00 不匹配数字形态,不作 N 用;落到兜底分支报「未知参数: 4:00」。时间必须跟在天数之后,这是位置参数的固有约束,帮助文本已写明 --restart [N] [HH:MM] |
1 |
最后 ls 应报 No such file——四条命令都不该留下任何 timer 文件。
- Step 6: 旧字段迁移
cp .env /tmp/env.bak
sed -i '/^XRAY_RESTART_/d' .env
echo 'XRAY_DAILY_RESTART=true' >> .env
bash deploy.sh
grep -E '^XRAY_RESTART_|^XRAY_DAILY' .env
systemctl list-timers xray-restart.timer --no-pager
Expected:.env 中出现 XRAY_RESTART_EVERY_DAYS=1,不再有 XRAY_DAILY_RESTART;timer 已建立。
- Step 7: 恢复并确认链接未变
cp /tmp/env.bak .env
bash deploy.sh --restart 0
bash deploy.sh --show
Expected:--show 输出的 vless 链接与 Step 1 之前记录的完全一致。
- Step 8: 记录结果并提交
把实测输出补进 docs/superpowers/plans/2026-08-06-xray-restart-schedule.md 末尾的「验证记录」小节,然后:
git add docs/superpowers/plans/2026-08-06-xray-restart-schedule.md
git commit -m "docs: 补充定时重启功能的 VPS 端到端验证记录"
验证记录
(Task 8 执行后填写实测输出)