From d9b19b404ad5c7cece25de02254d7b18273d5a29 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 7 Aug 2026 11:47:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=96=B0=E5=A2=9E=E8=AF=B4?= =?UTF-8?q?=E6=98=8Erestart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-06-xray-restart-schedule.md | 1186 +++++++++++++++++ ...2026-08-06-xray-restart-schedule-design.md | 182 +++ vps-xray/README.md | 74 +- vps-xray/tests/verify-restart-on-vps.sh | 161 +++ 4 files changed, 1593 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-06-xray-restart-schedule.md create mode 100644 docs/superpowers/specs/2026-08-06-xray-restart-schedule-design.md create mode 100644 vps-xray/tests/verify-restart-on-vps.sh diff --git a/docs/superpowers/plans/2026-08-06-xray-restart-schedule.md b/docs/superpowers/plans/2026-08-06-xray-restart-schedule.md new file mode 100644 index 0000000..540bd41 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-xray-restart-schedule.md @@ -0,0 +1,1186 @@ +# 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