# 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_RESTART` case 块) **Interfaces:** - Produces: `normalize_restart_time ` → 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`: ```bash #!/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` 中,把现有的这段: ```bash # 布尔开关归一化:容忍 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 ``` 整段替换为: ```bash # ===== 定时重启配置 ===== # 可覆盖路径,测试时指向临时目录 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: 提交** ```bash 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 ` → 文件中存在 `^key=` 则替换该行,否则追加一行;文件不存在返回 1 - [ ] **Step 1: 追加失败测试** 在 `vps-xray/tests/test-restart-schedule.sh` 的 `echo ""` 汇总行**之前**插入: ```bash 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` 调用之前,插入: ```bash # 就地更新 .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: 提交** ```bash 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