From 938df26cbb3baddff9275d08ff59df545809a071 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 13:02:46 +0800 Subject: [PATCH 01/16] =?UTF-8?q?www.apple.com=20=E4=B8=BA=E4=BC=AA?= =?UTF-8?q?=E8=A3=85=E5=9C=B0=E5=9D=80=EF=BC=8C=E5=89=8D=E7=AB=AF=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/.env.example | 10 ++- vps-xray/README.md | 30 +++++++ .../lisa-vps-xray-optimized-info-4.md | 8 +- .../vps-xray-optimized-info-3.md | 31 +++++++ vps-xray/deploy.sh | 83 ++++++++++++++++--- 5 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 vps-xray/client-config/vps-xray-optimized-info-3.md diff --git a/vps-xray/.env.example b/vps-xray/.env.example index 64546fb..6d5a06c 100644 --- a/vps-xray/.env.example +++ b/vps-xray/.env.example @@ -13,9 +13,13 @@ # NAT / 端口转发的机器上出口 IP 与入站 IP 可能不同,此时必须手动指定 # SERVER_IP=1.2.3.4 -# Reality 伪装目标(留空则自动选择延迟最低的) -# REALITY_DEST=www.microsoft.com -# REALITY_SNI=www.microsoft.com +# Reality 伪装目标(留空则自动从候选中挑选,见 deploy.sh 的 select_reality_dest) +# 手动指定时务必确认新域名满足两个条件,否则客户端会刷 REALITY 报错: +# 1) TLS 1.3 且证书链 < 8192 字节(用 xray tls ping <域名> 查「with SNI」那段) +# 2) 该域名会被客户端路由判成「直连」(命中 geosite:cn) +# 已知不可用:dl.google.com(命中 geosite:google)、www.microsoft.com、www.amazon.com +# REALITY_DEST=www.apple.com +# REALITY_SNI=www.apple.com # 每日 04:00 自动重启 Xray(默认 false) # 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级响应), diff --git a/vps-xray/README.md b/vps-xray/README.md index 8006b71..6b393f2 100644 --- a/vps-xray/README.md +++ b/vps-xray/README.md @@ -163,6 +163,36 @@ bash deploy.sh --redetect > ⚠️ 换完后 SNI 变了,**所有客户端都必须用新的分享链接重新配置**。脚本会在更换时打印醒目提示。 +#### 自定义伪装目标时的两个硬条件 + +在 `.env` 里手动指定 `REALITY_DEST` / `REALITY_SNI` 前,必须确认新域名同时满足: + +**1. 技术条件** — TLS 1.3,且证书链总长 < 8192 字节。上限是 `xtls/reality` 里的硬编码值,超出会直接握手失败(见 [XTLS/Xray-core#6356](https://github.com/XTLS/Xray-core/issues/6356),`www.microsoft.com` 的 8273 字节即触发)。在服务器上自查: + +```bash +xray tls ping www.apple.com # 只看「Pinging with SNI」那一段 +``` + +**2. 客户端路由条件** — 该域名必须会被客户端判成「直连」。 + +这条不直观。TUN 模式下客户端偶尔会把「连接本代理服务器」这件事本身也抓进代理(v2rayN 的 sing-box + xray 双核心靠进程嗅探防回环,Windows 上存在竞态)。这种回环连接的去向完全由 SNI 决定: + +| SNI 被判为 | 结果 | +|---|---| +| 直连 | 连接真的到达本服务器,REALITY 握手成功,**用户无感** | +| 走代理 | 进隧道 → 服务器替客户端连了真正的伪装站 → 客户端收到真证书 → 刷屏 `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 系域名。 + ### 查看状态 / 日志 ```bash diff --git a/vps-xray/client-config/lisa-vps-xray-optimized-info-4.md b/vps-xray/client-config/lisa-vps-xray-optimized-info-4.md index f237eb1..8ae181d 100644 --- a/vps-xray/client-config/lisa-vps-xray-optimized-info-4.md +++ b/vps-xray/client-config/lisa-vps-xray-optimized-info-4.md @@ -5,20 +5,20 @@ IP : 64.81.25.247 端口 : 443 协议 : VLESS - UUID : b2f1198d-9475-40af-a2c6-2738a69b4e06 + UUID : 7c7de15f-d408-4534-b367-f6253e850178 流控 : xtls-rprx-vision 传输 : tcp 安全 : reality SNI : www.apple.com Fingerprint : chrome - PublicKey : fKRVRMVlmmedi2rFk-cetAT397V-IzRifStKQcMTilM - ShortId : fca381b1ac6f4d02 + PublicKey : AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM + ShortId : fa4235504a09feeb ========================================================== >>> VLESS 分享链接(可直接导入客户端): -vless://b2f1198d-9475-40af-a2c6-2738a69b4e06@64.81.25.247:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=fKRVRMVlmmedi2rFk-cetAT397V-IzRifStKQcMTilM&sid=fca381b1ac6f4d02&type=tcp#vps-reality-lisa-1 +vless://7c7de15f-d408-4534-b367-f6253e850178@64.81.25.247:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM&sid=fa4235504a09feeb&type=tcp#vps-reality-lisa-1 ========================================================== diff --git a/vps-xray/client-config/vps-xray-optimized-info-3.md b/vps-xray/client-config/vps-xray-optimized-info-3.md new file mode 100644 index 0000000..d5345cd --- /dev/null +++ b/vps-xray/client-config/vps-xray-optimized-info-3.md @@ -0,0 +1,31 @@ +========================================================== + Xray VLESS-Reality 部署完成 ✅ +========================================================== + + IP : 140.82.23.27 + 端口 : 443 + 协议 : VLESS + UUID : 3cac4c5e-c9ae-488f-9cde-0c65b2d23409 + 流控 : xtls-rprx-vision + 传输 : tcp + 安全 : reality + SNI : www.apple.com + Fingerprint : chrome + PublicKey : 5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8 + ShortId : 4c5999dd1b15ff3c + +========================================================== + +>>> VLESS 分享链接(可直接导入客户端): + +vless://3cac4c5e-c9ae-488f-9cde-0c65b2d23409@140.82.23.27:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8&sid=4c5999dd1b15ff3c&type=tcp#vps-reality-3 + +========================================================== + +⚠️ 请妥善保存以上信息! + 配置文件: /usr/local/etc/xray/config.json + 凭据备份: /opt/vps-xray/.env + 再次查看: bash deploy.sh --show + 查看日志: journalctl -u xray -f + 重启服务: systemctl restart xray +========================================================== \ No newline at end of file diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index 03fd97f..3494c55 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -234,6 +234,39 @@ generate_keys() { 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 @@ -242,7 +275,27 @@ select_reality_dest() { step "4/8 选择 Reality 伪装目标" - local candidates=("www.microsoft.com" "dl.google.com" "www.apple.com" "www.amazon.com") + # 候选伪装站必须同时满足两类条件。 + # + # 【技术条件】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:-}" @@ -259,6 +312,10 @@ select_reality_dest() { 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 @@ -267,29 +324,35 @@ select_reality_dest() { warn "--redetect:将重新挑选伪装目标,原目标为 ${REALITY_DEST}" fi - local best_dest="" - local best_ms=9999 - + # 按列表顺序优先,只有前面的不可用才往后顺延——不按延迟排序。 + # 候选现在都是 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 - log " ${site} → ${ms}ms" - if [ "$ms" -lt "$best_ms" ]; then - best_ms=$ms - best_dest=$site + 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 "所有候选伪装目标都无法连接,请检查服务器网络 / DNS" + error "没有候选伪装目标能同时满足「可达」与「REALITY 技术要求」" error "候选列表: ${candidates[*]}" + error "可在 .env 中手动指定 REALITY_DEST / REALITY_SNI 后重试" exit 1 fi From 29236e8e1c8179b6e3732640be80e7d70b7d0c44 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 15:48:43 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat(vps-xray):=20=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E9=85=8D=E7=BD=AE=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E6=97=A7=E5=AD=97=E6=AE=B5=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 normalize_restart_time / resolve_restart_config, XRAY_RESTART_EVERY_DAYS 缺失时才回退读 XRAY_DAILY_RESTART。 附单元测试,不需要 root 与 systemd。 --- vps-xray/deploy.sh | 60 +++++++++++-- vps-xray/tests/test-restart-schedule.sh | 110 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 vps-xray/tests/test-restart-schedule.sh diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index 3494c55..8b90144 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -74,12 +74,60 @@ if [[ "$MODE" != "reality" && "$MODE" != "fast" ]]; then exit 1 fi -# 布尔开关归一化:容忍 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 XRAY_PORT="${XRAY_PORT:-443}" diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh new file mode 100644 index 0000000..1066fc2 --- /dev/null +++ b/vps-xray/tests/test-restart-schedule.sh @@ -0,0 +1,110 @@ +#!/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 ] From 920774d7d719b91fd75ac6271e8d7e0aecc5a5b4 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 15:57:07 +0800 Subject: [PATCH 03/16] =?UTF-8?q?fix(vps-xray):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=87=E6=B8=A1=E5=85=BC=E5=AE=B9=E8=B5=8B=E5=80=BC=20DAILY?= =?UTF-8?q?=5FRESTART?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit harden_system / start_service / save_env 仍引用 DAILY_RESTART, 下游清理要到后续任务进行。本任务在 resolve_restart_config 之后 添加兼容赋值,确保 set -u 下脚本完整可运行。 --- vps-xray/deploy.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index 8b90144..608dbeb 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -129,6 +129,11 @@ resolve_restart_config() { resolve_restart_config || exit 1 +# 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART, +# 要到「接线进完整部署流程」那个任务才会清理。在那之前必须保证 +# master 上的脚本可以完整跑完——set -u 下缺这个变量会中途崩溃。 +DAILY_RESTART=$([ "$RESTART_EVERY_DAYS" -ne 0 ] && echo 1 || echo 0) + XRAY_PORT="${XRAY_PORT:-443}" # ===== 0. 检查并安装依赖 ===== From 39b0aee2c9e056b795030e5de0f2f314563b36b2 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:02:22 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(vps-xray):=20=E6=96=B0=E5=A2=9E=20up?= =?UTF-8?q?date=5Fenv=5Ffield=20=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0=20.en?= =?UTF-8?q?v=20=E5=8D=95=E4=B8=AA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/deploy.sh | 17 +++++++++++++ vps-xray/tests/test-restart-schedule.sh | 34 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index 608dbeb..c8f659d 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -127,6 +127,23 @@ resolve_restart_config() { return 0 } +# 就地更新 .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 +} + resolve_restart_config || exit 1 # 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART, diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index 1066fc2..f8c4b07 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -105,6 +105,40 @@ reset_env; XRAY_RESTART_TIMEZONE="" resolve_restart_config 2>/dev/null is "时区空串报错" "$?" "1" +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" + echo "" echo "通过 ${PASS},失败 ${FAIL}" [ "$FAIL" -eq 0 ] From dfcaf7e4d0f4259dac22c5aa6522d95337b55055 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:08:42 +0800 Subject: [PATCH 05/16] =?UTF-8?q?fix(vps-xray):=20update=5Fenv=5Ffield=20?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E6=97=B6=E5=A4=84=E7=90=86=E7=BC=BA=E5=B0=BE?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E5=AD=97=E6=AE=B5=E9=BB=8F=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/deploy.sh | 4 ++++ vps-xray/tests/test-restart-schedule.sh | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index c8f659d..b759391 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -139,6 +139,10 @@ update_env_field() { esc=$(printf '%s' "$value" | sed 's/[|&\\]/\\&/g') sed -i "s|^${key}=.*|${key}=${esc}|" "$file" else + # 文件末尾可能缺换行(用户手工编辑过),直接追加会把新字段黏到上一行 + if [ -n "$(tail -c 1 "$file")" ]; then + printf '\n' >> "$file" + fi printf '%s=%s\n' "$key" "$value" >> "$file" fi return 0 diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index f8c4b07..4775c37 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -139,6 +139,11 @@ is "字段总行数正确" "$(grep -c '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" update_env_field "${TMP}/nonexistent" FOO bar 2>/dev/null is "文件不存在返回 1" "$?" "1" +printf 'KEY=old' > "${TMP}/.env2" +update_env_field "${TMP}/.env2" NEWKEY val +is "缺尾换行时不黏连" "$(grep -c '^NEWKEY=val$' "${TMP}/.env2")" "1" +is "缺尾换行时原字段完好" "$(grep -c '^KEY=old$' "${TMP}/.env2")" "1" + echo "" echo "通过 ${PASS},失败 ${FAIL}" [ "$FAIL" -eq 0 ] From d19d337bf2eec94ad57aa0a728de8d6e3da3ba57 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:15:03 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat(vps-xray):=20=E7=94=9F=E6=88=90=20ti?= =?UTF-8?q?mer/service/guard=20=E4=B8=89=E4=B8=AA=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit timer 每天唤醒,N 天间隔判断交给 guard 脚本, 绕开 OnCalendar 无法表达任意 N 天间隔的限制。 --- vps-xray/deploy.sh | 79 +++++++++++++++++++++++++ vps-xray/tests/test-restart-schedule.sh | 53 +++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index b759391..d493b87 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -148,6 +148,85 @@ update_env_field() { 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 +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 +} + resolve_restart_config || exit 1 # 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART, diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index 4775c37..f8a557f 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -144,6 +144,59 @@ update_env_field "${TMP}/.env2" NEWKEY val is "缺尾换行时不黏连" "$(grep -c '^NEWKEY=val$' "${TMP}/.env2")" "1" is "缺尾换行时原字段完好" "$(grep -c '^KEY=old$' "${TMP}/.env2")" "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" + echo "" echo "通过 ${PASS},失败 ${FAIL}" [ "$FAIL" -eq 0 ] From 354ac8a58cb0c1257e8f88f18ecc6c1edce6de65 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:25:53 +0800 Subject: [PATCH 07/16] =?UTF-8?q?test(vps-xray):=20=E8=A6=86=E7=9B=96=20gu?= =?UTF-8?q?ard=20=E8=84=9A=E6=9C=AC=E7=9A=84=E9=97=B4=E9=9A=94=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E3=80=81=E5=AE=B9=E5=B7=AE=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/tests/test-restart-schedule.sh | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index f8a557f..928dfaa 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -197,6 +197,75 @@ is "remove 后 guard 消失" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" " remove_restart_units is "重复 remove 返回 1" "$?" "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" + echo "" echo "通过 ${PASS},失败 ${FAIL}" [ "$FAIL" -eq 0 ] From 22d1aa6a93925f6b315e676d53b40761ca9aa1bc Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:39:23 +0800 Subject: [PATCH 08/16] =?UTF-8?q?test(vps-xray):=20=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=20grep=20=E5=8C=B9=E9=85=8D=E3=80=81?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=AE=B9=E9=94=99=E9=AA=8C=E8=AF=81=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/tests/test-restart-schedule.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index 928dfaa..3aca17b 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -205,47 +205,53 @@ rm -f "$RESTART_STATE_FILE" # 无状态文件 → 首次必须执行 out=$("$GUARD_BIN" 2>&1) -is "首次执行重启" "$(grep -c 'systemctl restart xray' "$SYSTEMCTL_LOG")" "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 "刚重启过则跳过" "$(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" +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" +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" +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 -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1" is "非法内容被覆盖为数字" \ "$(grep -cE '^[0-9]+$' "$RESTART_STATE_FILE")" "1" +echo "garbage" > "$RESTART_STATE_FILE" +: > "$SYSTEMCTL_LOG" +guard_err=$("$GUARD_BIN" 2>&1 >/dev/null) +is "状态文件非法时不产生算术求值报错" \ + "$(printf '%s' "$guard_err" | grep -c 'integer expression')" "0" + # 每日模式:距上次 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" +is "N=1 时满一天即执行" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1" # 时间戳先于重启写入:用一个必定失败的 systemctl 桩验证 cat > "${STUB}/systemctl" << 'STUBEOF' From 682b79852d3e1c2762674f36e5a9d23cb1e746a1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 16:49:57 +0800 Subject: [PATCH 09/16] =?UTF-8?q?refactor(vps-xray):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=94=B9=E7=94=A8=20configure=5Frestart=5Fti?= =?UTF-8?q?mer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit harden_system 与 start_service 里的旧 DAILY_RESTART 分支删除, save_env 改写三个新字段。 --- vps-xray/deploy.sh | 80 ++++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 53 deletions(-) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index d493b87..9b966f2 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -227,12 +227,26 @@ remove_restart_units() { return 0 } -resolve_restart_config || exit 1 +# 依据 RESTART_EVERY_DAYS / RESTART_TIME / RESTART_TZ 建立或移除定时重启, +# 并完成 daemon-reload 与 enable/disable。完整部署与 --restart 共用。 +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 -# 过渡兼容:harden_system / start_service / save_env 仍引用 DAILY_RESTART, -# 要到「接线进完整部署流程」那个任务才会清理。在那之前必须保证 -# master 上的脚本可以完整跑完——set -u 下缺这个变量会中途崩溃。 -DAILY_RESTART=$([ "$RESTART_EVERY_DAYS" -ne 0 ] && echo 1 || echo 0) + 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 覆盖,按需启用" +} + +resolve_restart_config || exit 1 XRAY_PORT="${XRAY_PORT:-443}" @@ -844,47 +858,7 @@ Restart=always RestartSec=3 EOF - # ===== 每日重启 timer:默认关闭 ===== - # 崩溃恢复已由上面的 Restart=always + RestartSec=3 覆盖(秒级响应), - # 而每日硬重启会切断全部活动连接(跨夜下载、备份、CI 都会中断), - # 属于为假想问题付出真实代价。仅在确实观察到长期运行后性能退化时才开启。 - if [ "$DAILY_RESTART" -ne 1 ]; then - 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 - warn "已移除旧部署遗留的每日重启 timer" - warn "如需保留,请在 .env 中设置 XRAY_DAILY_RESTART=true 后重新部署" - fi - log "每日自动重启: 关闭(崩溃由 Restart=always 秒级恢复)" - return - fi - - # 定时重启 timer(每日凌晨 4 点) - cat > /etc/systemd/system/xray-restart.service << 'EOF' -[Unit] -Description=Daily restart of Xray -After=network.target - -[Service] -Type=oneshot -ExecStart=/bin/systemctl restart xray -EOF - - cat > /etc/systemd/system/xray-restart.timer << 'EOF' -[Unit] -Description=Daily restart Xray at 04:00 CST - -[Timer] -OnCalendar=*-*-* 04:00:00 -TimeZone=Asia/Shanghai -Persistent=true - -[Install] -WantedBy=timers.target -EOF - warn "每日自动重启: 已启用(04:00 Asia/Shanghai)" - warn "该操作会切断所有活动连接,如无必要建议设 XRAY_DAILY_RESTART=false" + configure_restart_timer } # ===== 7. 启动服务 ===== @@ -892,9 +866,6 @@ start_service() { systemctl daemon-reload systemctl restart xray systemctl enable xray - if [ "$DAILY_RESTART" -eq 1 ]; then - systemctl enable --now xray-restart.timer - fi sleep 2 if systemctl is-active --quiet xray; then @@ -919,10 +890,13 @@ XRAY_UUID=${XRAY_UUID} # NAT / 端口转发的机器请填写服务商给你的实际入站 IP SERVER_IP=${SERVER_IP:-} -# 每日 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} EOF if [ "$MODE" = "reality" ]; then From 9f1e293d4cdf5552eaecf04b4823e1bd254faeb6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 17:02:15 +0800 Subject: [PATCH 10/16] =?UTF-8?q?feat(vps-xray):=20=E6=96=B0=E5=A2=9E=20--?= =?UTF-8?q?restart=20=E8=BD=BB=E9=87=8F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只改定时重启计划并立即生效,不重装 Xray、不重写配置、不重启服务, 与 --mode/--show/--redetect 互斥。 顺带修正非法参数以退出码 0 结束的问题。 --- vps-xray/deploy.sh | 88 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index 9b966f2..ad70a60 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -31,8 +31,13 @@ usage() { 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 0 + exit "${1:-0}" } # ===== 解析参数 ===== @@ -40,16 +45,40 @@ 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 ;; + *) 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 @@ -246,6 +275,56 @@ configure_restart_timer() { warn "重启会切断所有活动连接;崩溃恢复本已由 Restart=always 覆盖,按需启用" } +# ===== --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 || exit 1 XRAY_PORT="${XRAY_PORT:-443}" @@ -1033,6 +1112,11 @@ main() { 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)" From 34f698e5fb6543589833bf23c3b5755026e098e8 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 17:13:12 +0800 Subject: [PATCH 11/16] =?UTF-8?q?docs(vps-xray):=20=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E9=85=8D=E7=BD=AE=E8=AF=B4=E6=98=8E=EF=BC=9B?= =?UTF-8?q?uninstall=20=E6=B8=85=E7=90=86=20guard=20=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/.env.example | 19 ++++++++++++++----- vps-xray/README.md | 36 ++++++++++++++++++++++++++++++++++++ vps-xray/uninstall.sh | 7 +++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/vps-xray/.env.example b/vps-xray/.env.example index 6d5a06c..088d69c 100644 --- a/vps-xray/.env.example +++ b/vps-xray/.env.example @@ -21,11 +21,20 @@ # REALITY_DEST=www.apple.com # REALITY_SNI=www.apple.com -# 每日 04:00 自动重启 Xray(默认 false) -# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级响应), -# 而每日硬重启会切断全部活动连接(跨夜下载、备份、CI 都会中断)。 -# 仅在确实观察到长期运行后性能退化时才改为 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 天,其它 → 关闭。重跑部署后会自动换写成新字段。 # 备份目录 # BACKUP_DIR=/var/backups/xray diff --git a/vps-xray/README.md b/vps-xray/README.md index 6b393f2..4594268 100644 --- a/vps-xray/README.md +++ b/vps-xray/README.md @@ -193,6 +193,42 @@ xray tls ping www.apple.com # 只看「Pinging with SNI」那一段 脚本的候选列表因此只保留 Apple 系域名。 +### 定时重启 + +默认**关闭**。崩溃恢复已由 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` 同样准确。 + +查看下次触发时间与执行记录: + +```bash +systemctl list-timers xray-restart.timer +journalctl -u xray-restart.service -n 20 +``` + +> 旧字段 `XRAY_DAILY_RESTART` 已废弃。三个新字段都未设置时它仍作为兼容回退(`true` → 每 1 天,其它 → 关闭),重跑部署后 `.env` 会自动换写成新字段。 + ### 查看状态 / 日志 ```bash diff --git a/vps-xray/uninstall.sh b/vps-xray/uninstall.sh index 10bdc2a..f8f373b 100644 --- a/vps-xray/uninstall.sh +++ b/vps-xray/uninstall.sh @@ -84,10 +84,13 @@ fi systemctl disable xray 2>/dev/null || true # 每日重启 timer(旧版部署可能残留) -if [ -f /etc/systemd/system/xray-restart.timer ]; then +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 + /etc/systemd/system/xray-restart.service \ + /usr/local/bin/xray-restart-guard \ + /var/lib/xray/last-restart log "已移除每日重启 timer" fi From 0dc80ff14447e54cf9ca32b10841d553833865c1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 6 Aug 2026 17:17:01 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix(vps-xray):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E7=9A=84=E6=AF=8F=E6=97=A5=E9=87=8D=E5=90=AF=E5=B0=8F?= =?UTF-8?q?=E8=8A=82=EF=BC=8C=E8=A1=A5=E5=85=85=E6=96=B0=E5=B0=8F=E8=8A=82?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E7=BC=BA=E5=A4=B1=E7=90=86=E7=94=B1=EF=BC=9B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0uninstall=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-xray/README.md | 24 +++++++----------------- vps-xray/uninstall.sh | 2 +- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/vps-xray/README.md b/vps-xray/README.md index 4594268..05906ea 100644 --- a/vps-xray/README.md +++ b/vps-xray/README.md @@ -195,7 +195,13 @@ xray tls ping www.apple.com # 只看「Pinging with SNI」那一段 ### 定时重启 -默认**关闭**。崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),而硬重启会切断全部活动连接——跨夜下载、备份、CI 都会中断。仅在确实观察到长期运行后性能退化时才启用。 +默认**关闭**。原因是: + +- 崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),不必等到凌晨 +- Xray 无已知需要定期重启的缺陷(内存、句柄上限均已配置) +- 而硬重启会切断全部活动连接——跨夜下载、备份、CI 都会中断 + +即"为假想问题付出真实代价"。仅在确实观察到长期运行后性能退化时才启用。 需要时用轻量入口调整,**不会重装 Xray、不会重写配置、不会重启服务,现有 vless 链接完全不受影响**: @@ -281,22 +287,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/XTLS/Xray-install/main/insta systemctl restart xray ``` -### 每日自动重启(默认关闭) - -早期版本会安装一个每天 04:00 重启 Xray 的 systemd timer,现已**默认关闭**: - -- 崩溃恢复已由 `Restart=always` + `RestartSec=3` 覆盖,响应是秒级,不必等到凌晨 -- Xray 无已知需要定期重启的缺陷(内存、句柄上限均已配置) -- 而每日硬重启会**切断全部活动连接**,跨夜下载、备份、CI 都会中断 - -即"为假想问题付出真实代价"。部署时会自动移除旧版本遗留的 timer。 - -确有需要(例如实测到长期运行后性能退化)时在 `.env` 中开启: - -```bash -XRAY_DAILY_RESTART=true -``` - ### 重启 / 停止 ```bash diff --git a/vps-xray/uninstall.sh b/vps-xray/uninstall.sh index f8f373b..e9191a6 100644 --- a/vps-xray/uninstall.sh +++ b/vps-xray/uninstall.sh @@ -91,7 +91,7 @@ if [ -f /etc/systemd/system/xray-restart.timer ] \ /etc/systemd/system/xray-restart.service \ /usr/local/bin/xray-restart-guard \ /var/lib/xray/last-restart - log "已移除每日重启 timer" + log "已移除定时重启 timer 与状态文件" fi # ===== 2. 卸载 Xray ===== From 3fd2c8902024d0ff1da66d4dff5ae33f7dc93342 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 7 Aug 2026 09:37:46 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix(vps-xray):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E9=87=8D=E5=90=AF=E7=9A=84=E5=9B=9B=E7=B1=BB?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8D=E5=8F=98=E5=BC=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical-1 timer 时区:[Timer] 段没有 TimeZone= 这个 key,systemd 只会记一条 "Unknown key name" 然后按系统本地时区解析 OnCalendar。VPS 出厂多为 UTC, 用户设「04:00 Asia/Shanghai」会在北京时间 12:00 触发且毫无提示。 改为把时区写进日历表达式本身:OnCalendar=*-*-* HH:MM:00 (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 项,全部通过。 --- vps-xray/README.md | 11 +- vps-xray/deploy.sh | 113 +++++++++++-- vps-xray/tests/test-restart-schedule.sh | 203 +++++++++++++++++++++++- 3 files changed, 304 insertions(+), 23 deletions(-) diff --git a/vps-xray/README.md b/vps-xray/README.md index 05906ea..0bf4097 100644 --- a/vps-xray/README.md +++ b/vps-xray/README.md @@ -226,13 +226,22 @@ bash deploy.sh --restart # 按 .env 现有值重建 timer 所以实现是 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`(不改任何配置、不重启服务),输出里会算好打印出来。 + +#### 已知取舍 + +- **间隔锚点是「上次由守卫执行的重启」,不是任何一次 xray 重启。** 完整部署(`bash deploy.sh`)里的 `systemctl restart xray` 不会更新 `/var/lib/xray/last-restart`——设了每 7 天、距上次守卫重启已 6.9 天时跑一次完整部署,次日守卫仍会判定满 7 天而再重启一次。介意的话可在部署后手动执行 `date +%s > /var/lib/xray/last-restart` 把锚点对齐。 +- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常——这是为了避免陷入每天硬重启的循环。但若时间戳**写不进去**(磁盘满、只读挂载),守卫会跳过本轮重启并在日志里报错,宁可不重启也不退化成每日重启。 + > 旧字段 `XRAY_DAILY_RESTART` 已废弃。三个新字段都未设置时它仍作为兼容回退(`true` → 每 1 天,其它 → 关闭),重跑部署后 `.env` 会自动换写成新字段。 ### 查看状态 / 日志 diff --git a/vps-xray/deploy.sh b/vps-xray/deploy.sh index ad70a60..913f434 100644 --- a/vps-xray/deploy.sh +++ b/vps-xray/deploy.sh @@ -138,7 +138,11 @@ resolve_restart_config() { error "XRAY_RESTART_EVERY_DAYS 必须是非负整数,当前为 '${days}'" return 1 fi - RESTART_EVERY_DAYS="$days" + # 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 @@ -163,16 +167,18 @@ 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" + sed -i "s|^${key}=.*|${key}=${esc}|" "$file" || return 1 else # 文件末尾可能缺换行(用户手工编辑过),直接追加会把新字段黏到上一行 if [ -n "$(tail -c 1 "$file")" ]; then - printf '\n' >> "$file" + printf '\n' >> "$file" || return 1 fi - printf '%s=%s\n' "$key" "$value" >> "$file" + printf '%s=%s\n' "$key" "$value" >> "$file" || return 1 fi return 0 } @@ -199,6 +205,10 @@ 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 次触发的实际间隔 @@ -208,11 +218,20 @@ if [ "\$last" -gt 0 ] && [ \$(( now - last )) -lt \$(( DAYS * 86400 - 300 )) ]; exit 0 fi -mkdir -p "\$(dirname "\$STATE")" # 必须先写时间戳:restart 会中断本脚本所在的 systemd 事务, # 顺序反了会丢记录,退化成每天都重启。 # 代价是重启失败时本轮仍被记为已执行——这比陷入每日重启循环可接受。 -echo "\$now" > "\$STATE" +# +# 写不进去(/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 @@ -234,8 +253,11 @@ EOF Description=Wake xray-restart daily at ${time} ${tz}; the guard enforces the ${days}-day interval [Timer] -OnCalendar=*-*-* ${time}:00 -TimeZone=${tz} +# 时区必须写在日历表达式里(systemd >= 242 支持)。 +# [Timer] 段没有 TimeZone= 这个 key——写了只会被忽略并留下一行 +# "Unknown key name 'TimeZone'",然后按系统本地时区解析。 +# VPS 出厂多为 UTC,那样「04:00 Asia/Shanghai」会在北京时间 12:00 触发。 +OnCalendar=*-*-* ${time}:00 ${tz} Persistent=true [Install] @@ -249,7 +271,11 @@ 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 + # 失败不阻断删除(单元可能本就没被 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" @@ -258,21 +284,66 @@ remove_restart_units() { # 依据 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 - systemctl daemon-reload + if ! systemctl daemon-reload; then + warn "systemctl daemon-reload 失败,已删除的单元可能仍残留在 systemd 内存中" + fi log "定时重启: 关闭(崩溃恢复已由 Restart=always 秒级覆盖)" - return + return 0 fi render_restart_units "$RESTART_EVERY_DAYS" "$RESTART_TIME" "$RESTART_TZ" - systemctl daemon-reload - systemctl enable --now xray-restart.timer >/dev/null 2>&1 + 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:只改重启计划 ===== @@ -302,10 +373,16 @@ apply_restart_only() { # 只把命令行显式给过的字段落盘,没给的不动 .env if [ -n "$RESTART_ARG_DAYS" ]; then - update_env_field "$env_file" XRAY_RESTART_EVERY_DAYS "$RESTART_EVERY_DAYS" + 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 - update_env_field "$env_file" XRAY_RESTART_TIME "$RESTART_TIME" + if ! update_env_field "$env_file" XRAY_RESTART_TIME "$RESTART_TIME"; then + error "写入 ${env_file} 失败,未做任何改动" + exit 1 + fi fi configure_restart_timer @@ -316,12 +393,16 @@ apply_restart_only() { 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 } @@ -972,7 +1053,7 @@ SERVER_IP=${SERVER_IP:-} # 定时重启。0 = 关闭(默认);N = 每 N 天在 XRAY_RESTART_TIME 重启一次。 # 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级),而硬重启会切断 # 全部活动连接。仅在确实观察到长期运行后性能退化时才启用。 -# 改这两项无需完整重新部署: bash deploy.sh --restart 7 04:00 +# 改这几项无需完整重新部署: bash deploy.sh --restart 7 04:00 XRAY_RESTART_EVERY_DAYS=${RESTART_EVERY_DAYS} XRAY_RESTART_TIME=${RESTART_TIME} XRAY_RESTART_TIMEZONE=${RESTART_TZ} diff --git a/vps-xray/tests/test-restart-schedule.sh b/vps-xray/tests/test-restart-schedule.sh index 3aca17b..403beea 100644 --- a/vps-xray/tests/test-restart-schedule.sh +++ b/vps-xray/tests/test-restart-schedule.sh @@ -89,6 +89,20 @@ reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=0 resolve_restart_config is "新字段显式 0 覆盖旧 true" "$RESTART_EVERY_DAYS" "0" +# 前导零必须按十进制归一化:这个值会原样写进 guard 的 DAYS=, +# 而 guard 里是算术展开——010 会变成 8 天,08/09 直接报错导致每天重启 +reset_env; XRAY_RESTART_EVERY_DAYS=08 +resolve_restart_config +is "08 归一化为 8(不当八进制)" "$RESTART_EVERY_DAYS" "8" + +reset_env; XRAY_RESTART_EVERY_DAYS=09 +resolve_restart_config +is "09 归一化为 9(不当八进制)" "$RESTART_EVERY_DAYS" "9" + +reset_env; XRAY_RESTART_EVERY_DAYS=010 +resolve_restart_config +is "010 归一化为 10(不当八进制)" "$RESTART_EVERY_DAYS" "10" + reset_env; XRAY_RESTART_EVERY_DAYS=abc resolve_restart_config 2>/dev/null is "天数非数字报错" "$?" "1" @@ -169,10 +183,12 @@ is "timer 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo 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" +# 时区必须写在日历表达式里:[Timer] 段没有 TimeZone= 这个 key, +# 写了会被 systemd 忽略并按系统本地时区(VPS 多为 UTC)解析,静默偏移 8 小时 +is "OnCalendar 每天触发且带时区" \ + "$(grep -c '^OnCalendar=\*-\*-\* 04:00:00 Asia/Shanghai$' "${SYSTEMD_DIR}/xray-restart.timer")" "1" +is "不再使用非法的 TimeZone= 指令" \ + "$(grep -c '^TimeZone=' "${SYSTEMD_DIR}/xray-restart.timer")" "0" is "Persistent 开启" \ "$(grep -c '^Persistent=true$' "${SYSTEMD_DIR}/xray-restart.timer")" "1" is "service 指向 guard" \ @@ -186,8 +202,8 @@ is "guard 用 PATH 查找 systemctl 而非绝对路径" \ 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" +is "重复渲染覆盖时间与时区" \ + "$(grep -c '^OnCalendar=\*-\*-\* 23:05:00 Europe/Berlin$' "${SYSTEMD_DIR}/xray-restart.timer")" "1" remove_restart_units is "remove 后 timer 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no" @@ -246,6 +262,37 @@ guard_err=$("$GUARD_BIN" 2>&1 >/dev/null) is "状态文件非法时不产生算术求值报错" \ "$(printf '%s' "$guard_err" | grep -c 'integer expression')" "0" +# 时间戳落在未来(RTC 错乱 / NTP 大幅向后校正)→ 必须按 0 处理立即执行, +# 否则时钟校回后 now-last 恒为负,此后永远跳过且无自愈路径 +echo "$(( $(date +%s) + 30 * 86400 ))" > "$RESTART_STATE_FILE" +: > "$SYSTEMCTL_LOG" +"$GUARD_BIN" > /dev/null 2>&1 +is "未来时间戳不导致永久跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1" +is "未来时间戳被覆盖为当前时间" \ + "$([ "$(cat "$RESTART_STATE_FILE")" -le "$(date +%s)" ] && echo yes || echo no)" "yes" + +# 状态文件写不进去 → 必须跳过本轮。照常重启会让下一轮依旧读不到时间戳, +# 退化成每天硬重启且无限持续 +guard_saved_state="$RESTART_STATE_FILE" + +# (a) 目录建不出来:拿一个普通文件占住父目录的位置 +: > "${TMP}/blocked" +RESTART_STATE_FILE="${TMP}/blocked/last-restart" +render_restart_units 7 04:00 Asia/Shanghai +: > "$SYSTEMCTL_LOG" +"$GUARD_BIN" > /dev/null 2>&1 +is "状态目录建不出来时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +# (b) 目录能建但文件写不进去:让状态文件路径本身是个目录 +mkdir -p "${TMP}/asdir/last-restart" +RESTART_STATE_FILE="${TMP}/asdir/last-restart" +render_restart_units 7 04:00 Asia/Shanghai +: > "$SYSTEMCTL_LOG" +"$GUARD_BIN" > /dev/null 2>&1 +is "时间戳写不进去时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +RESTART_STATE_FILE="$guard_saved_state" + # 每日模式:距上次 1 天即执行 render_restart_units 1 04:00 Asia/Shanghai echo "$(( $(date +%s) - 86400 ))" > "$RESTART_STATE_FILE" @@ -272,6 +319,150 @@ echo "systemctl $*" >> "${SYSTEMCTL_LOG}" STUBEOF chmod 755 "${STUB}/systemctl" +echo "== configure_restart_timer ==" + +# 启用方向 +RESTART_EVERY_DAYS=7 +RESTART_TIME="04:00" +RESTART_TZ="Asia/Shanghai" +rm -f "${SYSTEMD_DIR}/xray-restart.timer" "${SYSTEMD_DIR}/xray-restart.service" "$GUARD_BIN" +: > "$SYSTEMCTL_LOG" +configure_restart_timer > /dev/null 2>&1 +is "启用时渲染出 timer" \ + "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes" +is "启用时渲染出 guard" "$([ -x "$GUARD_BIN" ] && echo yes || echo no)" "yes" +is "启用时执行 daemon-reload" \ + "$(grep -c '^systemctl daemon-reload$' "$SYSTEMCTL_LOG")" "1" +is "启用时执行 enable --now" \ + "$(grep -c '^systemctl enable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1" +is "启用时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +# enable 失败时不得中断调用方:configure_restart_timer 在部署主干上被裸调用 +cat > "${STUB}/systemctl" << 'STUBEOF' +#!/bin/sh +echo "systemctl $*" >> "${SYSTEMCTL_LOG}" +case "$1" in + enable) exit 1 ;; +esac +STUBEOF +chmod 755 "${STUB}/systemctl" +: > "$SYSTEMCTL_LOG" +configure_restart_timer > /dev/null 2>&1 +is "enable 失败时仍返回 0(不中断部署主干)" "$?" "0" +cat > "${STUB}/systemctl" << 'STUBEOF' +#!/bin/sh +echo "systemctl $*" >> "${SYSTEMCTL_LOG}" +STUBEOF +chmod 755 "${STUB}/systemctl" + +# 关闭方向 +echo "12345" > "$RESTART_STATE_FILE" +RESTART_EVERY_DAYS=0 +: > "$SYSTEMCTL_LOG" +configure_restart_timer > /dev/null 2>&1 +is "关闭时删除 timer" \ + "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no" +is "关闭时删除 service" \ + "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no" +is "关闭时删除 guard" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no" +is "关闭时保留状态文件" "$(cat "$RESTART_STATE_FILE")" "12345" +is "关闭时执行 disable --now" \ + "$(grep -c '^systemctl disable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1" +is "关闭时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +echo "== apply_restart_only 安全不变式 ==" + +# 这一节守护本特性最核心的承诺:--restart 不重启 xray、不碰 vless 链接。 +# 缺了它,谁在 apply_restart_only 里加一行 systemctl restart xray 都不会被发现。 +APP_DIR="${TMP}/app" +mkdir -p "$APP_DIR" +SCRIPT_DIR="$APP_DIR" + +make_app_env() { + cat > "${APP_DIR}/.env" << 'ENVEOF' +# Xray 部署配置(由 deploy.sh 自动生成) + +XRAY_MODE=reality +XRAY_PORT=443 +XRAY_UUID=11111111-2222-3333-4444-555555555555 +# 这是用户自己加的注释,不许动 +SERVER_IP= +XRAY_RESTART_EVERY_DAYS=0 +XRAY_RESTART_TIME=04:00 +XRAY_RESTART_TIMEZONE=Asia/Shanghai +XRAY_PUBLIC_KEY=pubkeyzzz +XRAY_SHORT_ID=abcdef0123456789 +REALITY_SNI=www.apple.com +ENVEOF +} + +# --- 启用:--restart 7 5:30 --- +reset_env +make_app_env +cp "${APP_DIR}/.env" "${TMP}/env.before" +RESTART_ARG_DAYS=7 +RESTART_ARG_TIME=5:30 +: > "$SYSTEMCTL_LOG" +apply_restart_only > /dev/null 2>&1 + +is "apply_restart_only 绝不重启 xray" \ + "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" +is "天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=7" +is "时间归一化后写回" "$(grep '^XRAY_RESTART_TIME=' "${APP_DIR}/.env")" "XRAY_RESTART_TIME=05:30" +is ".env 只有这两行发生变化" \ + "$(diff "${TMP}/env.before" "${APP_DIR}/.env" | grep -c '^[<>]')" "4" +is "用户注释原样保留" "$(grep -c '这是用户自己加的注释,不许动' "${APP_DIR}/.env")" "1" +is "UUID 未被改动" "$(grep -c '^XRAY_UUID=11111111-2222-3333-4444-555555555555$' "${APP_DIR}/.env")" "1" +is "SNI 未被改动" "$(grep -c '^REALITY_SNI=www\.apple\.com$' "${APP_DIR}/.env")" "1" +is "PublicKey 未被改动" "$(grep -c '^XRAY_PUBLIC_KEY=pubkeyzzz$' "${APP_DIR}/.env")" "1" +is "启用后 timer 已就位" \ + "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes" + +# --- 不带位置参数:.env 必须完全不变 --- +reset_env +make_app_env +XRAY_RESTART_EVERY_DAYS=7 +XRAY_RESTART_TIME=04:00 +cp "${APP_DIR}/.env" "${TMP}/env.before2" +RESTART_ARG_DAYS="" +RESTART_ARG_TIME="" +: > "$SYSTEMCTL_LOG" +apply_restart_only > /dev/null 2>&1 +is "不带位置参数时 .env 一字不改" \ + "$(diff "${TMP}/env.before2" "${APP_DIR}/.env" > /dev/null && echo same || echo differ)" "same" +is "不带位置参数时也不重启 xray" \ + "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +# --- 关闭:--restart 0 --- +reset_env +echo "12345" > "$RESTART_STATE_FILE" +RESTART_ARG_DAYS=0 +RESTART_ARG_TIME="" +: > "$SYSTEMCTL_LOG" +apply_restart_only > /dev/null 2>&1 +is "关闭后 timer 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no" +is "关闭后 service 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no" +is "关闭后 guard 被删" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no" +is "关闭后状态文件保留" "$(cat "$RESTART_STATE_FILE")" "12345" +is "关闭时天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=0" +is "关闭时也不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0" + +echo "== print_next_restart ==" + +RESTART_EVERY_DAYS=7 +rm -f "$RESTART_STATE_FILE" +is "无状态文件时提示下次唤醒即重启" \ + "$(print_next_restart | grep -c '下次唤醒时即会重启')" "1" + +now_ts=$(date +%s) +echo "$(( now_ts - 6 * 86400 ))" > "$RESTART_STATE_FILE" +is "未满 N 天时给出的是真实重启日期而非明天的唤醒时间" \ + "$(print_next_restart | grep -c "下次实际重启: $(date -d "@$(( now_ts + 86400 ))" '+%Y-%m-%d')")" "1" + +echo "$(( $(date +%s) - 9 * 86400 ))" > "$RESTART_STATE_FILE" +is "已满 N 天时提示下次唤醒即重启" \ + "$(print_next_restart | grep -c '下次唤醒时即会重启')" "1" + echo "" echo "通过 ${PASS},失败 ${FAIL}" [ "$FAIL" -eq 0 ] From d9b19b404ad5c7cece25de02254d7b18273d5a29 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 7 Aug 2026 11:47:52 +0800 Subject: [PATCH 14/16] =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AF=B4=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