文档新增说明restart

This commit is contained in:
2026-08-07 11:47:52 +08:00
parent 3fd2c89020
commit d9b19b404a
4 changed files with 1593 additions and 10 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
# Xray 定时重启:可配置的「每 N 天 + 固定时间点」
日期:2026-08-06
涉及文件:`vps-xray/deploy.sh``vps-xray/.env.example``vps-xray/uninstall.sh``vps-xray/README.md`
## 背景
当前 `deploy.sh` 只有一个布尔开关 `XRAY_DAILY_RESTART`,为真时创建 `OnCalendar=*-*-* 04:00:00` 的每日重启 timer,为假时删除该 timer。默认关闭,理由是崩溃恢复已由 `Restart=always` + `RestartSec=3` 覆盖(秒级),而每日硬重启会切断全部活动连接。
这个判断本身没变,但「一天一次」粒度太粗:想要「每 7 天凌晨 4 点重启一次」这种低频维护性重启时,只能在「每天重启」和「完全不重启」之间二选一。
## 目标
1. 支持任意 N 天间隔 + 指定时间点,如「每 7 天 04:00」「每 3 天 05:30」
2. 保持默认关闭,已部署机器重跑脚本时行为不变
3. 提供一个轻量命令行入口,只改重启计划,不触碰 Xray 与现有 vless 链接
## 非目标
- 不做「每月/每季度」等日历语义(真有需要时用户可直接编辑 timer)
- 不做多时间点(如一天重启两次)
- 不改动 `Restart=always` 这一层崩溃恢复逻辑
## 关键约束:systemd 表达不了任意 N 天
`OnCalendar``*-*-1/7 04:00:00` 是「每月的 1、8、15、22、29 号」,**跨月会重置**——29 号到下月 1 号只隔 2~3 天。因此不能直接用 `OnCalendar` 表达「每 N 天」。
`OnUnitActiveSec=7d` 是真实间隔,但无法钉住具体时间点,且会随每次触发缓慢漂移。
本设计采用第三种:**timer 每天到点唤醒,由 service 里的守卫脚本决定这次要不要真的重启。**
## 配置模型
`.env` 三个字段:
```bash
XRAY_RESTART_EVERY_DAYS=0 # 0 或缺失 = 关闭;N = 每 N 天重启一次
XRAY_RESTART_TIME=04:00 # 24 小时制 HH:MM,默认 04:00
XRAY_RESTART_TIMEZONE=Asia/Shanghai # 默认保持现状
```
首次部署默认 `0`(关闭),与当前行为一致。
### 旧字段迁移
只在 `XRAY_RESTART_EVERY_DAYS` **未设置**时才回退去读 `XRAY_DAILY_RESTART`
| 旧值 | 迁移结果 |
|---|---|
| `true` / `yes` / `on` / `1`(大小写不敏感) | `XRAY_RESTART_EVERY_DAYS=1` |
| 其它或缺失 | `XRAY_RESTART_EVERY_DAYS=0` |
`save_env` 之后只写新字段,不再写 `XRAY_DAILY_RESTART``.env` 随重跑自动换代。两个字段同时存在时以新字段为准。
现存四台服务器的 `.env` 都是 `XRAY_DAILY_RESTART=false`,迁移后为 `0`,行为不变。
## 实现架构
```
xray-restart.timer OnCalendar=*-*-* <HH:MM>:00 每天到点唤醒
TimeZone=<tz>
Persistent=true
xray-restart.service Type=oneshot
ExecStart=/usr/local/bin/xray-restart-guard
xray-restart-guard 读 /var/lib/xray/last-restart
距上次不足 N 天 → 打日志后 exit 0
满 N 天 → 写入新时间戳 → systemctl restart xray
```
「每 N 天」因此是**距上次实际重启**的真实间隔,不受跨月重置影响,N=3 与 N=7 同样准确。
### 守卫脚本的两个关键细节
**300 秒容差。** systemd timer 默认 `AccuracySec=1min`,第 N 次触发的实际间隔可能是 `N×86400 - 30s`。严格比较会判定「不满 N 天」从而顺延整整一天,且误差会持续累积。阈值取 `N×86400 - 300` 规避。容差远小于一天,不会造成重复触发。
**先写时间戳,再重启。** `systemctl restart xray` 会中断 guard 所在的 systemd 事务。顺序反了会丢失时间戳记录,导致每天都重启。代价是:重启失败时时间戳仍已更新,本轮被跳过——这比陷入每日重启循环可接受。
### 状态文件
`/var/lib/xray/last-restart`,内容为一个 Unix 时间戳。guard 以 root 运行,读写无权限问题。内容非法(非纯数字)时按 `0` 处理,即立即允许重启。
## 参数校验
在脚本早期(`.env` 载入后)完成,不合法直接报错退出,**不静默回退到默认值**——避免用户以为设了每 7 天、实际在每天重启。
| 字段 | 规则 |
|---|---|
| `XRAY_RESTART_EVERY_DAYS` | `^[0-9]+$` |
| `XRAY_RESTART_TIME` | `^([01]?[0-9]\|2[0-3]):[0-5][0-9]$`,校验通过后**补零归一化**为 `HH:MM` |
| `XRAY_RESTART_TIMEZONE` | 非空即可,交由 systemd 校验并在启动失败时报错 |
时间同时接受 `4:00``04:00`,但写入 `.env` 和 timer 前一律归一化成两位小时(`04:00`),保证 `OnCalendar` 字符串格式统一、`.env` 内容可预测。
## `--restart` 轻量入口
### 用法
```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
```
### 参数解析
`--restart` 后可跟 0~2 个位置参数,按形态判断,不会误吞后续选项:
- 下一个 token 匹配 `^[0-9]+$` → 取作 N
- 再下一个匹配 `^([01]?[0-9]|2[0-3]):[0-5][0-9]$` → 取作 HH:MM
- 不匹配则不消费,沿用 `.env` 现值
`--restart``--mode` / `--show` / `--redetect` 互斥,同时给出直接报错。
### 执行路径
`main()` 中早退,与 `--show` 同一位置:
1. 校验 N 与 HH:MM,时间归一化为 `HH:MM`
2. 就地更新 `.env`:命令行给了 N 就写 `XRAY_RESTART_EVERY_DAYS`,给了时间才写 `XRAY_RESTART_TIME`;两者都没给则不改 `.env`,只按现值重建 timer。字段不存在时追加(`TIME` 追加时用默认值 `04:00`
3. 调用 `configure_restart_timer()`
4. `systemctl daemon-reload`,按需 enable / disable timer
5. 打印结果与 `systemctl list-timers xray-restart.timer`
### 明确不做的事
不装依赖、不升级 Xray、不生成密钥、不选伪装目标、不重写 `config.json`、不动防火墙与 sysctl、**不重启 xray**。因此 vless 链接与现有连接均不受影响。这些约束会以注释形式写在函数头部。
### 前置检查
- `.env` 不存在 → 报错退出(无法确认这是已部署的机器)
- xray 主单元不存在 → 报错退出(避免在未部署的机器上留下孤儿 timer)
## 代码组织
**timer / service / guard 的创建逻辑抽成独立函数 `configure_restart_timer()`**,由 `harden_system()``--restart` 路径共用。两条路径各写一份实现迟早会漂移成不一致。
`.env` 更新走两条不同路径,这是有意的:
- 完整部署走既有的 `save_env()``cat >` 全量重写)
- `--restart``sed` 就地替换 —— 轻量入口只应改自己那两行,不该抹掉用户在 `.env` 里加的注释和自定义字段
## 关闭与清理
`XRAY_RESTART_EVERY_DAYS=0` 时:
- `systemctl disable --now xray-restart.timer`
- 删除 `xray-restart.timer``xray-restart.service``/usr/local/bin/xray-restart-guard`
- **保留** `/var/lib/xray/last-restart`
保留状态文件的理由:以后重新启用时,若距上次已超过 N 天则首次触发即重启,符合直觉;若刚重启过则不会立刻再来一次。
`uninstall.sh` 需补上 guard 脚本与状态文件的清理(当前只删了 timer 和 service)。
## 受影响文件
| 文件 | 改动 |
|---|---|
| `vps-xray/deploy.sh` | 参数解析、配置校验与迁移、`configure_restart_timer()``--restart` 路径、`save_env``usage` |
| `vps-xray/.env.example` | 三个新字段与说明 |
| `vps-xray/uninstall.sh` | 清理 guard 脚本与状态文件 |
| `vps-xray/README.md` | 定时重启一节 |
## 验证方案
在真实 VPS 上执行,不接受仅靠阅读代码判断:
1. **语法** —— `bash -n deploy.sh`
2. **启用** —— `bash deploy.sh --restart 7 04:00`,确认:`.env` 两字段已更新;`systemctl list-timers xray-restart.timer` 有下次触发时间;xray 主服务的 `ActiveEnterTimestamp` **未变**(证明没重启)
3. **守卫生效** —— 手动连跑 `xray-restart-guard` 两次,第一次应重启并写入时间戳,第二次应打印「跳过」且 xray 未重启
4. **关闭** —— `bash deploy.sh --restart 0`,确认 timer / service / guard 三个文件均被删除,状态文件仍在
5. **迁移** —— 构造一份只含 `XRAY_DAILY_RESTART=true``.env`,跑完整部署,确认得到 `XRAY_RESTART_EVERY_DAYS=1` 且 timer 已建立
6. **校验** —— `bash deploy.sh --restart 7 25:00``--restart abc` 均应报错退出,且不留下任何单元文件
7. **互斥** —— `bash deploy.sh --restart 7 --redetect` 应报错退出
## 已知取舍
- **间隔锚点是「上次实际重启时间」,不是固定日历日期。** 若某次因机器关机错过了触发点,下次开机后的第一个触发点会补上(`Persistent=true`),此后间隔重新从那一刻起算,会缓慢偏移触发日期。这是「真实间隔」语义的必然结果,也是用户选择的语义。
- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常。理由见上文「先写时间戳」。
- **时区错误要到 timer 启动时才暴露**。脚本不预校验时区字符串,靠 `systemctl` 报错——自行维护时区白名单成本高且易过期。
+64 -10
View File
@@ -220,22 +220,76 @@ bash deploy.sh --restart # 按 .env 现有值重建 timer
| `XRAY_RESTART_TIME` | 24 小时制 `HH:MM` | `04:00` |
| `XRAY_RESTART_TIMEZONE` | 时区 | `Asia/Shanghai` |
#### 查看当前配置与重启记录
**一条命令看全** —— 不带参数的 `--restart` 只按 `.env` 现值重建 timer,不改配置、不重启服务:
```bash
bash deploy.sh --restart
```
输出里包含当前间隔与时间、**上次实际重启**、**下次实际重启**。
分项查看:
```bash
# 1. 配置值
grep '^XRAY_RESTART_' /opt/vps-xray/.env
# 2. timer 是否启用、下次唤醒时间
systemctl list-timers xray-restart.timer
# 3. 确认时区真的生效(最容易出错的一项)
systemctl show xray-restart.timer -p TimersCalendar
# 4. 上次实际重启的时间戳
cat /var/lib/xray/last-restart # Unix 时间戳
date -d "@$(cat /var/lib/xray/last-restart)" # 可读格式
# 5. 守卫每次唤醒后的判断记录(跳过还是执行)
journalctl -u xray-restart.service --no-pager -n 50
# 6. xray 进程最近一次启动的时刻
systemctl show xray -p ActiveEnterTimestamp --value
```
第 3 条的输出应形如:
```
{ OnCalendar=*-*-* 04:00:00 Asia/Shanghai ; next_elapse=Fri 2026-08-07 20:00:00 UTC }
```
**里面必须看得到时区。** 若只有 `OnCalendar=*-*-* 04:00:00` 而没有时区,说明时区没生效,systemd 会按服务器的系统时区解析——多数 VPS 是 UTC,那样设的「04:00 北京时间」实际会在**中午 12 点**触发,偏移 8 小时且没有任何提示。
> 早期实现把时区写成 `[Timer]` 段的 `TimeZone=`,而 `systemd.timer` 根本没有这个 key——写了只会在 journal 里留一行 `Unknown key name 'TimeZone' in section 'Timer', ignoring.` 然后按本地时区解析。排查时可以顺手确认一下:
>
> ```bash
> journalctl -b | grep -i "unknown key name"
> systemd-analyze verify /etc/systemd/system/xray-restart.timer
> ```
第 5 条的日志长这样:
```
xray-restart-guard[1234]: 距上次重启不足 7 天(已过 3 天),跳过
xray-restart-guard[5678]: 距上次重启已满 7 天,执行重启
```
出现下面这行说明状态文件写不进去(磁盘满或只读挂载),守卫会主动跳过本轮——宁可不重启,也不让保护失效后退化成每天硬重启:
```
xray-restart-guard[...]: 无法写入 /var/lib/xray/last-restart,跳过本轮重启以免退化成每日重启
```
> ⚠️ `list-timers` 的 `NEXT` 是 **timer 下次「唤醒」的时间,不是下次重启的时间**。timer 每天唤醒一次,NEXT 因此恒为次日的 `XRAY_RESTART_TIME`;是否真的重启由守卫按间隔判断。设了「每 7 天」且 5 天前刚重启过时,NEXT 显示明天凌晨,实际还要再等 2 天。想看真实的下次重启时间,用上面那条不带参数的 `bash deploy.sh --restart`。
#### 为什么不是纯 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` 同样准确。
查看唤醒计划与执行记录:
```bash
systemctl list-timers xray-restart.timer
journalctl -u xray-restart.service -n 20
```
> ⚠️ `list-timers` 的 `NEXT` 是 **timer 下次「唤醒」的时间**,不是下次重启的时间。timer 每天唤醒一次,NEXT 因此恒为次日的 `XRAY_RESTART_TIME`;是否真的重启由守卫按间隔判断。设了「每 7 天」且 5 天前刚重启过时,NEXT 显示明天凌晨,但实际还要再等 2 天。
>
> 想直接看「下次实际重启时间」,跑一次不带参数的 `bash deploy.sh --restart`(不改任何配置、不重启服务),输出里会算好打印出来。
具体怎么查看配置与执行记录,见上面的「查看当前配置与重启记录」。
#### 已知取舍
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# ============================================================
# 定时重启功能的真实环境验收脚本
#
# 用法(在 VPS 上,root:
# cd /opt/vps-xray && bash tests/verify-restart-on-vps.sh
#
# 为什么必须在真机上跑:本地的 62→101 项单元测试都是把生成的文件写进临时目录
# 再比对内容——它们能验证「我们写出了预期的字节」,但验证不了「systemd 认不认
# 这些字节」。TimeZone= 那个缺陷就是这么漏过去的:文件里确实有那一行,断言通过,
# 而 systemd 加载时只会记一条 Unknown key name 然后按系统本地时区解析。
#
# 本脚本只做安全操作:不升级 Xray、不重写 config.json、不重启 xray。
# 会临时改动 .env 的重启字段与三个 systemd 单元文件,结束时恢复原状。
# ============================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
bad() { FAIL=$((FAIL+1)); echo "$1"; [ -n "${2:-}" ] && echo " 实际: $2"; }
sec() { echo ""; echo "===== $* ====="; }
[ "$(id -u)" -eq 0 ] || { echo "需要 root"; exit 1; }
[ -f .env ] || { echo "找不到 .env,这台机器还没部署过"; exit 1; }
ENV_BAK="$(mktemp)"; cp .env "$ENV_BAK"
restore() {
echo ""
echo "===== 恢复原状 ====="
cp "$ENV_BAK" .env && echo " .env 已还原"
rm -f "$ENV_BAK"
bash deploy.sh --restart >/dev/null 2>&1 && echo " timer 已按还原后的 .env 重建" \
|| echo " timer 重建失败,请手工检查 systemctl list-timers xray-restart.timer"
}
trap restore EXIT
sec "0. 环境"
systemctl --version | head -1
timedatectl 2>/dev/null | grep -i "time zone" || cat /etc/timezone 2>/dev/null
sec "1. Critical-1OnCalendar 尾随时区是否被 systemd 真正接受"
# 这是全套验证里最关键的一条。旧实现用 [Timer] 段的 TimeZone=
# 而 systemd.timer 根本没有这个 key——写了会被忽略并按系统本地时区解析。
if out=$(systemd-analyze calendar "*-*-* 04:00:00 Asia/Shanghai" 2>&1); then
ok "systemd 接受带时区的日历表达式"
echo "$out" | sed 's/^/ /'
else
bad "systemd 不接受带时区的日历表达式(该 systemd 版本可能低于 242" "$out"
fi
sec "2. 启用:--restart 7 04:00"
BEFORE_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
bash deploy.sh --restart 7 04:00
AFTER_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
[ "$BEFORE_TS" = "$AFTER_TS" ] \
&& ok "xray 未被重启(ActiveEnterTimestamp 前后一致)" \
|| bad "xray 被重启了!--restart 不该碰服务" "before=$BEFORE_TS after=$AFTER_TS"
grep -q '^XRAY_RESTART_EVERY_DAYS=7$' .env && ok ".env 天数已写入" || bad ".env 天数未写入" "$(grep '^XRAY_RESTART_EVERY_DAYS=' .env)"
grep -q '^XRAY_RESTART_TIME=04:00$' .env && ok ".env 时间已写入" || bad ".env 时间未写入" "$(grep '^XRAY_RESTART_TIME=' .env)"
sec "3. 生成的 timer 是否被 systemd 正确加载"
systemd-analyze verify /etc/systemd/system/xray-restart.timer 2>&1 | sed 's/^/ /'
if journalctl -b --no-pager 2>/dev/null | grep -q "Unknown key name.*xray-restart"; then
bad "journal 里有 Unknown key name 告警,说明 timer 里仍有非法指令"
else
ok "journal 无 Unknown key name 告警"
fi
CAL=$(systemctl show xray-restart.timer -p TimersCalendar --value 2>/dev/null)
echo " TimersCalendar: $CAL"
case "$CAL" in
*Asia/Shanghai*) ok "解析后的日历规格里含时区" ;;
*) bad "解析后的日历规格里没有时区——时区没生效" "$CAL" ;;
esac
grep -q '^TimeZone=' /etc/systemd/system/xray-restart.timer \
&& bad "timer 里仍有非法的 TimeZone= 指令" \
|| ok "timer 里无 TimeZone= 指令"
sec "4. 守卫脚本行为"
rm -f /var/lib/xray/last-restart
echo " -- 首次(无状态文件)应执行重启 --"
/usr/local/bin/xray-restart-guard 2>&1 | sed 's/^/ /'
[ -s /var/lib/xray/last-restart ] && ok "时间戳已写入" || bad "时间戳未写入"
echo " -- 第二次(刚重启过)应跳过 --"
G2=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G2" | sed 's/^/ /'
echo "$G2" | grep -q "跳过" && ok "正确跳过" || bad "未跳过" "$G2"
echo " -- Important-4:时间戳落在未来时应视为 0 并执行重启 --"
TS_BEFORE_FUTURE=$(systemctl show xray -p ActiveEnterTimestamp --value)
echo "$(( $(date +%s) + 86400*365 ))" > /var/lib/xray/last-restart
G3=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G3" | sed 's/^/ /'
if echo "$G3" | grep -q "跳过"; then
bad "未来时间戳导致永久跳过——保护未生效"
else
ok "未来时间戳被正确当成 0"
fi
echo " (这一步会真的重启一次 xray,是预期内的)"
sleep 2
systemctl is-active --quiet xray && ok "重启后 xray 仍然存活" || bad "xray 未能重新启动!"
sec "5. Important-6:下次实际重启时间是否被正确显示"
echo "$(( $(date +%s) - 3*86400 ))" > /var/lib/xray/last-restart
OUT=$(bash deploy.sh --restart 2>&1); echo "$OUT" | sed 's/^/ /'
echo " (N=7、距上次 3 天,所以真实的下次重启应在 4 天后,而非次日)"
sec "6. 关闭:--restart 0"
bash deploy.sh --restart 0 >/dev/null 2>&1
for f in /etc/systemd/system/xray-restart.timer /etc/systemd/system/xray-restart.service /usr/local/bin/xray-restart-guard; do
[ -f "$f" ] && bad "未删除: $f" || ok "已删除: $(basename "$f")"
done
[ -f /var/lib/xray/last-restart ] && ok "状态文件按设计保留" || bad "状态文件被误删(关闭≠卸载)"
sec "7. Critical-2:前导零天数"
bash deploy.sh --restart 09 >/dev/null 2>&1
D=$(grep '^XRAY_RESTART_EVERY_DAYS=' .env | cut -d= -f2)
[ "$D" = "9" ] && ok "09 被归一化成 9(未被当八进制)" || bad "09 未被正确归一化" "$D"
if [ -f /usr/local/bin/xray-restart-guard ]; then
grep -q '^DAYS=9$' /usr/local/bin/xray-restart-guard \
&& ok "guard 里 DAYS=9" || bad "guard 里天数不对" "$(grep '^DAYS=' /usr/local/bin/xray-restart-guard)"
OUT=$(/usr/local/bin/xray-restart-guard 2>&1)
echo "$OUT" | grep -qi "value too great\|error token" \
&& bad "guard 仍有八进制算术错误" "$OUT" \
|| ok "guard 无八进制算术错误"
fi
sec "8. 参数校验与互斥"
bash deploy.sh --restart 7 25:00 >/dev/null 2>&1; [ $? -eq 1 ] && ok "非法时间退出码 1" || bad "非法时间退出码不是 1"
bash deploy.sh --badarg >/dev/null 2>&1; [ $? -eq 1 ] && ok "未知参数退出码 1" || bad "未知参数退出码不是 1"
bash deploy.sh --help >/dev/null 2>&1; [ $? -eq 0 ] && ok "--help 退出码 0" || bad "--help 退出码不是 0"
# 注意:不能写成 `bash deploy.sh ... | grep -q ...`。本脚本开了 pipefail
# 而 deploy.sh 在这里本就该以退出码 1 结束,管道会因此被判为失败——
# 于是断言恒为「未生效」,与被测行为无关。必须先取输出再匹配。
MX_OUT=$(bash deploy.sh --restart 7 --redetect 2>&1 || true)
echo "$MX_OUT" | grep -q "不能与" && ok "互斥校验生效" || bad "互斥校验未生效" "$MX_OUT"
echo ""
echo "============================================================"
echo " 通过 ${PASS},失败 ${FAIL}"
echo "============================================================"
echo ""
echo "还需要你手动做一步(本脚本刻意不做,因为它会重启服务、断掉所有连接):"
echo ""
echo " # 旧字段迁移验证 —— 建议挑一台当前没在用的机器"
echo " cp .env /tmp/env.bak"
echo " sed -i '/^XRAY_RESTART_/d' .env"
echo " echo 'XRAY_DAILY_RESTART=true' >> .env"
echo " bash deploy.sh # 完整部署,会重启 xray"
echo " grep -E '^XRAY_RESTART_|^XRAY_DAILY' .env"
echo " # 预期:出现 XRAY_RESTART_EVERY_DAYS=1,且 XRAY_DAILY_RESTART 已消失"
echo " cp /tmp/env.bak .env && bash deploy.sh --restart"
echo ""
[ "$FAIL" -eq 0 ]