Compare commits

..
16 Commits
Author SHA1 Message Date
joywayer d9b19b404a 文档新增说明restart 2026-08-07 11:47:52 +08:00
joywayer 3fd2c89020 fix(vps-xray): 修复定时重启的四类静默失效并补齐安全不变式测试
Critical-1 timer 时区:[Timer] 段没有 TimeZone= 这个 key,systemd 只会记一条
"Unknown key name" 然后按系统本地时区解析 OnCalendar。VPS 出厂多为 UTC,
用户设「04:00 Asia/Shanghai」会在北京时间 12:00 触发且毫无提示。
改为把时区写进日历表达式本身:OnCalendar=*-*-* HH:MM:00 <TZ>(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 项,全部通过。
2026-08-07 09:37:46 +08:00
joywayer 0dc80ff144 fix(vps-xray): 删除旧的每日重启小节,补充新小节中的缺失理由;更新uninstall日志 2026-08-06 17:17:01 +08:00
joywayer 34f698e5fb docs(vps-xray): 定时重启配置说明;uninstall 清理 guard 与状态文件 2026-08-06 17:13:12 +08:00
joywayer 9f1e293d4c feat(vps-xray): 新增 --restart 轻量入口
只改定时重启计划并立即生效,不重装 Xray、不重写配置、不重启服务,
与 --mode/--show/--redetect 互斥。
顺带修正非法参数以退出码 0 结束的问题。
2026-08-06 17:02:15 +08:00
joywayer 682b79852d refactor(vps-xray): 完整部署改用 configure_restart_timer
harden_system 与 start_service 里的旧 DAILY_RESTART 分支删除,
save_env 改写三个新字段。
2026-08-06 16:49:57 +08:00
joywayer 22d1aa6a93 test(vps-xray): 精确锚定 grep 匹配、补充容错验证断言 2026-08-06 16:39:23 +08:00
joywayer 354ac8a58c test(vps-xray): 覆盖 guard 脚本的间隔判断、容差与状态文件容错 2026-08-06 16:25:53 +08:00
joywayer d19d337bf2 feat(vps-xray): 生成 timer/service/guard 三个单元文件
timer 每天唤醒,N 天间隔判断交给 guard 脚本,
绕开 OnCalendar 无法表达任意 N 天间隔的限制。
2026-08-06 16:15:03 +08:00
joywayer dfcaf7e4d0 fix(vps-xray): update_env_field 追加时处理缺尾换行的文件,防止字段黏连 2026-08-06 16:08:42 +08:00
joywayer 39b0aee2c9 feat(vps-xray): 新增 update_env_field 就地更新 .env 单个字段 2026-08-06 16:02:22 +08:00
joywayer 920774d7d7 fix(vps-xray): 添加过渡兼容赋值 DAILY_RESTART
harden_system / start_service / save_env 仍引用 DAILY_RESTART,
下游清理要到后续任务进行。本任务在 resolve_restart_config 之后
添加兼容赋值,确保 set -u 下脚本完整可运行。
2026-08-06 15:57:07 +08:00
joywayer 29236e8e1c feat(vps-xray): 重启计划配置解析与旧字段迁移
新增 normalize_restart_time / resolve_restart_config,
XRAY_RESTART_EVERY_DAYS 缺失时才回退读 XRAY_DAILY_RESTART。
附单元测试,不需要 root 与 systemd。
2026-08-06 15:48:43 +08:00
joywayer 938df26cbb www.apple.com 为伪装地址,前端警告消失 2026-08-06 13:02:46 +08:00
joywayer e8cc0162af 升级gitea 2026-08-05 21:08:48 +08:00
joywayer 5195986fc4 vps部署优化 2026-08-05 17:12:18 +08:00
18 changed files with 4686 additions and 130 deletions
+20
View File
@@ -0,0 +1,20 @@
# 环境变量 / 凭据(.env.example 模板除外)
.env
*.env
!.env.example
!*.env.example
# vps-xray:部署脚本生成的真实节点配置,含 UUID / 私钥 / 服务器 IP
# vps-xray/client-config/*-info*.md
# vps-xray/client-config/*.yaml
# vps-xray/client-config/*.yml
# 备份产物
*.bak
*.bak.*
backups/
# 系统 / 编辑器
.DS_Store
Thumbs.db
*.swp
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` 报错——自行维护时区白名单成本高且易过期。
+2
View File
@@ -1,3 +1,5 @@
data/
backups/
.env
.env.bak-*
.upgrade-state
+83 -30
View File
@@ -326,42 +326,93 @@ docker compose up -d # 启动
#### 升级 Gitea
Gitea 以 Docker 容器运行,升级 = 拉取新镜像 + 重启容器。数据库结构变更会在启动时自动迁移
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)
```bash
cd /opt/gitea
# 1. 备份(必须!)
bash backup.sh
# 2. 查看当前版本
curl -s http://127.0.0.1:3000/api/v1/version
# 3. 修改目标版本(编辑 .env 中的 GITEA_IMAGE
# ● 查看最新版本号: https://github.com/go-gitea/gitea/releases
# ● 或访问: https://hub.docker.com/r/gitea/gitea/tags
vi .env
# 修改: GITEA_IMAGE=gitea/gitea:1.25.5 ← 替换为目标版本号
# 4. 拉取新镜像
docker compose pull server
# 5. 重启(自动执行数据库迁移)
docker compose up -d server
# 6. 检查日志确认启动成功
docker compose logs -f server
# 看到 "Starting new Web server: tcp:0.0.0.0:3000" 表示成功
# Ctrl+C 退出日志
# 7. 验证新版本
curl -s http://127.0.0.1:3000/api/v1/version
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash upgrade.sh # 正式升级到 GitHub 最新 release
```
**注意事项:**
- 务必查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
- 不支持版本降级,升级前务必备份
- 跨多个大版本建议逐版本升级(如 1.21 → 1.22 → 1.23
脚本做的事:
| 步骤 | 内容 | 失败时 |
|------|------|--------|
| 1 | 预检:root、依赖命令、MySQL 连通性、数据表数量、磁盘空间 | 直接退出,服务零影响 |
| 2 | 拉取目标镜像 | 直接退出,服务零影响 |
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
| 4 | 停止 Gitea 容器(**MySQL 保持运行**) | 自动拉起原版本 |
| 5 | mysqldump + 打包数据目录 + 校验备份完整性 | 自动拉起原版本 |
| 6 | 切换镜像启动,Gitea 自动执行数据库迁移 | 自动回滚 |
| 7 | 校验镜像 ID / 运行版本 / 接口 / 表行数 / 迁移版本 | 自动回滚 |
常用参数:
```bash
bash upgrade.sh --check # 只检查,零改动
bash upgrade.sh --version 1.26.4 # 升级到指定版本
bash upgrade.sh --full-backup # 连仓库和 LFS 一起备份(小实例推荐)
bash upgrade.sh --timeout 600 # 大库迁移慢,放宽就绪超时(默认 300 秒)
bash upgrade.sh --doctor # 升级后跑一次 gitea doctor 一致性检查
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
bash upgrade.sh --rollback # 回滚到上次升级前的状态
```
**备份范围说明**
默认只打包会被升级影响的部分:`gitea/conf`app.ini)、`gitea/jwt`OAuth2 签名密钥)、
`ssh`SSH host key)、`gitea/indexers``gitea/queues`、头像目录。
仓库(`git/repositories`)、LFS、软件包**默认不打包** —— 它们是内容寻址的追加式存储,
版本迁移只改数据库结构,不会改写这些文件。上百 GB 的实例每次升级都全量打包并不现实。
数据库则**每次都完整 mysqldump**,因为那才是升级真正会动的东西。
小实例想要更保险,加 `--full-backup` 连仓库一起打包。
**回滚**
```bash
bash upgrade.sh --rollback
```
读取 `.upgrade-state` 里记录的备份与镜像锚点,**回灌 SQL 备份** + 恢复配置目录 + 切回旧镜像。
> 回滚会把数据库整体退回到升级前的状态,升级之后新产生的 issue、PR、评论、仓库记录都会丢失。
> 数据目录用的是合并解压,**不会删除任何仓库或 LFS 文件**。
#### 为什么不要手动升级
**一、跨小版本升级不可逆,只切镜像回滚是无效的**
按 [Gitea 官方文档](https://docs.gitea.com/installation/upgrade-from-gitea/),只有
`a.b.x → a.b.y`(补丁级)才保证数据库结构不变、可以来回切。跨小版本(如 1.25 → 1.27)
新版会在启动时改写数据库结构,之后旧版二进制看到新库会直接拒绝启动:
```
Your database (migration version: 286) is for a newer Gitea,
newer database for this old Gitea release (280).
Gitea will exit to keep your database safe and unchanged.
```
所以想退回旧版本,**唯一正确的办法是回灌升级前的 SQL 备份**。手动改 `version` 表是官方
明确警告会丢数据的操作。`upgrade.sh` 的回滚就是按这个路径做的,并且会拒绝执行跨小版本降级。
**二、`docker compose up -d` 不会因为镜像变了就重建容器**
容器处于 stopped 状态时,`up -d` 只是把原容器重新 start,结果是「改了配置但还跑着旧镜像」。
必须 `--force-recreate`。脚本在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,
不匹配直接回滚。
**三、`.env` 里的变量已经被 export 到当前 shell**
部署脚本用 `set -a; source .env` 导出过 `GITEA_IMAGE`,而 docker compose 对环境变量的
优先级高于 `.env` 文件 —— 只改文件不改环境变量,compose 仍会读到旧镜像。
**其它注意事项:**
- 升级前查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
- 自定义模板与新版不兼容会导致 5xx 或页面错乱,脚本的首页检查能发现这类问题
- MySQL 容器不在 `upgrade.sh` 的升级范围内,需要单独处理(见下)
#### 升级 MySQL
@@ -749,6 +800,8 @@ docker compose exec server gitea admin user change-password -u 管理员用户
├── .env # 运行时配置(自动生成)
├── deploy.sh # 全新服务器一键部署脚本
├── backup.sh # MySQL + 数据备份脚本
├── upgrade.sh # 安全升级脚本(备份 → 校验 → 自动回滚)
├── .upgrade-state # 升级回滚锚点(upgrade.sh 自动生成)
├── uninstall.sh # 完全卸载脚本
├── .gitignore
├── README.md
+1090
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -6,11 +6,35 @@
# XRAY_MODE=reality
# 监听端口(默认 443,建议不改)
# NAT / 端口转发的机器请填服务商实际映射给你的端口
# XRAY_PORT=443
# Reality 伪装目标(留空则自动选择延迟最低的)
# REALITY_DEST=www.microsoft.com
# REALITY_SNI=www.microsoft.com
# 分享链接中使用的 IP,留空则自动探测出口 IP
# NAT / 端口转发的机器上出口 IP 与入站 IP 可能不同,此时必须手动指定
# SERVER_IP=1.2.3.4
# 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
# ===== 定时重启 =====
# 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
+187 -4
View File
@@ -53,14 +53,19 @@
vps-xray/
├── deploy.sh # 一键部署脚本
├── uninstall.sh # 完全卸载脚本
├── backup.sh # 备份脚本
├── upload.sh # 上传脚本(仅上传服务端必要文件)
├── .env.example # 配置模板
├── README.md # 本文件
├── vps-xray-optimized.md # Reality 方案详细文档
├── vps-xray-fast.md # Fast TCP 方案详细文档
└── client-config/ # 客户端配置示例(本地使用,不上传服务器)
└── client-config/ # 客户端配置(本地使用,不上传服务器)
├── gen-clash.sh # 由节点信息生成 Clash.Meta 配置
└── client-macos.md # macOS 客户端指南(模板,可提交)
```
> `client-config/` 下含真实 UUID / 服务器 IP 的节点配置(`*-info*.md`、`*.yaml`)已在根 `.gitignore` 中排除,不要提交到版本库。
服务器上的文件位置:
```
@@ -98,7 +103,15 @@ bash deploy.sh --mode fast
- 连接参数(IP、端口、UUID、密钥等)
- VLESS 分享链接(可直接导入客户端)
> **⚠️ 请妥善保存输出的连接信息!密钥仅显示一次。**凭据同时保存在 `/opt/vps-xray/.env` 中。
凭据保存在 `/opt/vps-xray/.env`(权限 600)。**信息不会丢** —— 随时可以重新打印:
```bash
cd /opt/vps-xray && bash deploy.sh --show
```
`--show` 只读取 `.env` 输出连接信息和分享链接,不安装、不改配置、不重启服务。
> 即使 `.env` 被误删,只要 `/usr/local/etc/xray/config.json` 还在,UUID、shortId、SNI 都能从中读出,公钥可用 `xray x25519 -i <privateKey>` 从私钥反推。
### 第三步:客户端配置
@@ -111,16 +124,186 @@ bash deploy.sh --mode fast
| iOS | Shadowrocket / Streisand |
| Android | v2rayNG |
#### 生成 Clash 配置
把每台 VPS 的 `bash deploy.sh --show` 输出保存成 `client-config/xxx-info.md`,然后:
```bash
cd client-config
bash gen-clash.sh # 交互式勾选要写入的节点
bash gen-clash.sh --all # 全部节点
```
脚本解析文件里的 `vless://` 链接,生成 `vpn-vps-xray-optimized.yaml`(多节点自动组成 url-test 自动选择组,原文件会先备份为 `.bak`)。
> 生成的配置需要 **Clash.Meta / mihomo** 内核(Clash Verge Rev、ClashX Meta、FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
详细的 Clash Meta / Sing-Box 配置参见 [vps-xray-optimized.md](vps-xray-optimized.md)。
## 日常运维
### 查看连接信息 / 分享链接
```bash
cd /opt/vps-xray
bash deploy.sh --show
```
只读操作,不会改动任何服务。
### 更换 Reality 伪装目标
伪装目标在首次部署时按握手延迟自动挑选,之后固定写入 `.env` 不再变动。后续每次部署会检查它是否仍然可达,不可达时告警但**不会自动更换**——因为更换会改变 SNI,导致所有客户端链接静默失效。
确认要换时显式执行:
```bash
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 系域名。
### 定时重启
默认**关闭**。原因是:
- 崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),不必等到凌晨
- Xray 无已知需要定期重启的缺陷(内存、句柄上限均已配置)
- 而硬重启会切断全部活动连接——跨夜下载、备份、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` |
#### 查看当前配置与重启记录
**一条命令看全** —— 不带参数的 `--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` 同样准确。
具体怎么查看配置与执行记录,见上面的「查看当前配置与重启记录」。
#### 已知取舍
- **间隔锚点是「上次由守卫执行的重启」,不是任何一次 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` 会自动换写成新字段。
### 查看状态 / 日志
```bash
systemctl status xray
journalctl -u xray -f
journalctl -u xray --tail 100
journalctl -u xray -n 100
```
### 备份
@@ -239,7 +422,7 @@ systemctl status xray
ss -tlnp | grep 443
# 查看错误日志
journalctl -u xray --tail 50
journalctl -u xray -n 50
```
### 速度慢
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================
# Xray 备份脚本
# 备份 Xray 配置 / 部署配置 / 网络调优参数
# 输出目录默认 /var/backups/xray,自动清理 30 天前的旧备份
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [ "$(id -u)" -ne 0 ]; then
error "请使用 root 用户运行: sudo bash backup.sh"
exit 1
fi
if [ -f .env ]; then
sed -i 's/\r$//' .env
set -a; source .env; set +a
fi
BACKUP_DIR="${BACKUP_DIR:-/var/backups/xray}"
KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}"
STAMP="$(date '+%Y%m%d%H%M%S')"
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
backed_up=0
# ===== 1. Xray 配置 =====
# 打包 /usr/local/etc/xray 整个目录,恢复时 tar xzf ... -C /usr/local/etc/
if [ -d /usr/local/etc/xray ]; then
tar czf "${BACKUP_DIR}/xray_config_${STAMP}.tar.gz" -C /usr/local/etc xray
log "Xray 配置已备份: ${BACKUP_DIR}/xray_config_${STAMP}.tar.gz"
backed_up=$((backed_up + 1))
else
warn "/usr/local/etc/xray 不存在,跳过"
fi
# ===== 2. 部署配置(.env + 脚本)=====
# .env 含私钥,打包后权限收紧到 600
deploy_files=()
for f in .env deploy.sh uninstall.sh backup.sh upload.sh .env.example; do
[ -f "$f" ] && deploy_files+=("$f")
done
if [ "${#deploy_files[@]}" -gt 0 ]; then
tar czf "${BACKUP_DIR}/deploy_${STAMP}.tar.gz" -C "$SCRIPT_DIR" "${deploy_files[@]}"
chmod 600 "${BACKUP_DIR}/deploy_${STAMP}.tar.gz"
log "部署配置已备份: ${BACKUP_DIR}/deploy_${STAMP}.tar.gz(权限 600,含私钥)"
backed_up=$((backed_up + 1))
else
warn "未找到可备份的部署文件,跳过"
fi
# ===== 3. 网络调优参数 =====
if [ -f /etc/sysctl.d/99-xray-turbo.conf ]; then
cp /etc/sysctl.d/99-xray-turbo.conf "${BACKUP_DIR}/sysctl_${STAMP}.conf"
log "网络调优参数已备份: ${BACKUP_DIR}/sysctl_${STAMP}.conf"
backed_up=$((backed_up + 1))
else
warn "/etc/sysctl.d/99-xray-turbo.conf 不存在,跳过"
fi
if [ "$backed_up" -eq 0 ]; then
error "没有任何内容被备份,请确认 Xray 是否已部署"
exit 1
fi
# ===== 4. 清理过期备份 =====
old_count=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
-mtime "+${KEEP_DAYS}" | wc -l)
if [ "$old_count" -gt 0 ]; then
find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
-mtime "+${KEEP_DAYS}" -delete
log "已清理 ${old_count} 个超过 ${KEEP_DAYS} 天的旧备份"
fi
echo ""
log "备份完成,目录: ${BACKUP_DIR}"
ls -lh "$BACKUP_DIR" | tail -n +2 | tail -10
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================
# Clash.Meta 配置生成器
#
# 从 deploy.sh 输出的节点信息文件(*info*.md,内含 vless:// 链接)
# 生成 Clash.Meta / mihomo 可用的 YAML 配置。
#
# 用法:
# bash gen-clash.sh # 交互式选择节点文件
# bash gen-clash.sh a.md b.md # 直接指定
# bash gen-clash.sh -o my.yaml a.md # 指定输出文件
# bash gen-clash.sh --all # 使用全部 *info*.md
#
# 本脚本仅在本地运行,不会上传到 VPS。
# ============================================
# 不切换工作目录:候选文件在脚本所在目录里找,
# 但命令行显式传入的路径仍相对调用者的当前目录解析
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
OUTPUT="${SCRIPT_DIR}/vpn-vps-xray-optimized.yaml"
USE_ALL=0
FILES=()
usage() {
sed -n '4,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) OUTPUT="${2:?-o 需要一个文件名}"; shift 2 ;;
--all) USE_ALL=1; shift ;;
-h|--help) usage ;;
-*) error "未知参数: $1"; usage ;;
*) FILES+=("$1"); shift ;;
esac
done
# ===== 收集候选文件 =====
mapfile -t CANDIDATES < <(ls -1 "${SCRIPT_DIR}"/*info*.md 2>/dev/null || true)
if [ "${#FILES[@]}" -eq 0 ]; then
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
error "当前目录下找不到任何 *info*.md 节点信息文件"
error "请先在 VPS 上执行 bash deploy.sh --show,把输出保存为 xxx-info.md 放到这里"
exit 1
fi
if [ "$USE_ALL" -eq 1 ]; then
FILES=("${CANDIDATES[@]}")
else
echo ""
echo -e "${CYAN}可用的节点信息文件:${NC}"
for i in "${!CANDIDATES[@]}"; do
f="${CANDIDATES[$i]}"
# 顺带把链接里的备注名显示出来,方便辨认
tag=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null | sed 's/.*#//' || true)
printf " %2d) %-45s %s\n" "$((i + 1))" "${f#"${SCRIPT_DIR}"/}" "${tag:+[${tag}]}"
done
echo ""
read -r -p "选择要写入配置的节点(空格分隔序号,回车=全选): " picks
if [ -z "${picks// /}" ]; then
FILES=("${CANDIDATES[@]}")
else
for n in $picks; do
if ! [[ "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ] || [ "$n" -gt "${#CANDIDATES[@]}" ]; then
error "无效序号: $n"
exit 1
fi
FILES+=("${CANDIDATES[$((n - 1))]}")
done
fi
fi
fi
# ===== 工具函数 =====
urldecode() {
local s="${1//+/ }"
printf '%b' "${s//%/\\x}"
}
# 从 query string 中取出指定参数
qs_get() {
local query="$1" key="$2" kv
local IFS='&'
for kv in $query; do
if [ "${kv%%=*}" = "$key" ]; then
urldecode "${kv#*=}"
return
fi
done
}
# ===== 解析所有节点 =====
NAMES=(); SERVERS=(); PORTS=(); UUIDS=(); SNIS=(); PBKS=(); SIDS=(); FPS=(); FLOWS=(); SECS=()
declare -A name_seen=()
for f in "${FILES[@]}"; do
[ -f "$f" ] || { error "文件不存在: $f"; exit 1; }
link=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null || true)
if [ -z "$link" ]; then
warn "跳过 ${f#"${SCRIPT_DIR}"/}:未找到 vless:// 链接"
continue
fi
rest="${link#vless://}"
# 备注名(# 之后)
if [[ "$rest" == *"#"* ]]; then
name=$(urldecode "${rest##*#}")
rest="${rest%%#*}"
else
name=""
fi
uuid="${rest%%@*}"
hpq="${rest#*@}"
hostport="${hpq%%\?*}"
query=""
[[ "$hpq" == *"?"* ]] && query="${hpq#*\?}"
# 兼容 IPv6 字面量 [::1]:443
if [[ "$hostport" == \[* ]]; then
server="${hostport%%]*}]"
port="${hostport##*]:}"
else
server="${hostport%%:*}"
port="${hostport##*:}"
fi
sec=$(qs_get "$query" security); sec="${sec:-none}"
sni=$(qs_get "$query" sni)
pbk=$(qs_get "$query" pbk)
sid=$(qs_get "$query" sid)
fp=$(qs_get "$query" fp); fp="${fp:-chrome}"
flow=$(qs_get "$query" flow)
net=$(qs_get "$query" type); net="${net:-tcp}"
if [ -z "$uuid" ] || [ -z "$server" ] || [ -z "$port" ]; then
error "${f#"${SCRIPT_DIR}"/} 的链接缺少 uuid/server/port,无法解析"
exit 1
fi
if [ "$sec" = "reality" ] && { [ -z "$pbk" ] || [ -z "$sni" ]; }; then
error "${f#"${SCRIPT_DIR}"/} 是 reality 节点但缺少 pbk 或 sni"
exit 1
fi
if [ "$net" != "tcp" ]; then
warn "${f#"${SCRIPT_DIR}"/}: transport=${net},本生成器目前只支持 tcp,已跳过"
continue
fi
[ -z "$name" ] && name="${server}"
# 名称去重,Clash 要求 proxy name 唯一
base="$name"; n=2
while [ -n "${name_seen[$name]:-}" ]; do
name="${base}-${n}"; n=$((n + 1))
done
name_seen["$name"]=1
NAMES+=("$name"); SERVERS+=("$server"); PORTS+=("$port"); UUIDS+=("$uuid")
SNIS+=("$sni"); PBKS+=("$pbk"); SIDS+=("$sid")
FPS+=("$fp"); FLOWS+=("$flow"); SECS+=("$sec")
log "解析成功: ${name} (${server}:${port}, ${sec})"
done
if [ "${#NAMES[@]}" -eq 0 ]; then
error "没有解析出任何可用节点"
exit 1
fi
# ===== 备份既有输出 =====
if [ -f "$OUTPUT" ]; then
cp "$OUTPUT" "${OUTPUT}.bak"
log "已备份原配置: ${OUTPUT}.bak"
fi
# ===== 生成 YAML =====
{
cat <<'HEADER'
# ============================================================
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
#
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
#
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
# ============================================================
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
skip-domain:
- "+.push.apple.com"
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
# 网络连通性检测等会出问题
fake-ip-filter:
- "*.lan"
- "*.local"
- "*.localdomain"
- "*.home.arpa"
- "+.msftconnecttest.com"
- "+.msftncsi.com"
- "time.*.com"
- "ntp.*.com"
- "+.pool.ntp.org"
- "localhost.ptlogin2.qq.com"
# 用于解析下面 fallback 里 DoH 服务器自身的域名
default-nameserver:
- 223.5.5.5
- 119.29.29.29
nameserver:
- 223.5.5.5
- 119.29.29.29
# fallback 的意义是绕过投毒,必须用加密 DNS——
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
fallback:
- https://1.1.1.1/dns-query
- https://dns.google/dns-query
fallback-filter:
geoip: true
geoip-code: CN
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
proxies:
HEADER
for i in "${!NAMES[@]}"; do
echo " - name: \"${NAMES[$i]}\""
echo " type: vless"
echo " server: ${SERVERS[$i]}"
echo " port: ${PORTS[$i]}"
echo " uuid: ${UUIDS[$i]}"
echo " network: tcp"
echo " udp: true"
if [ "${SECS[$i]}" = "reality" ]; then
echo " tls: true"
[ -n "${FLOWS[$i]}" ] && echo " flow: ${FLOWS[$i]}"
echo " servername: ${SNIS[$i]}"
echo " client-fingerprint: ${FPS[$i]}"
echo " reality-opts:"
echo " public-key: ${PBKS[$i]}"
[ -n "${SIDS[$i]}" ] && echo " short-id: ${SIDS[$i]}"
else
echo " tls: false"
fi
echo ""
done
cat <<'GROUPS_HEAD'
proxy-groups:
- name: "Proxy"
type: select
proxies:
- Auto-Select
GROUPS_HEAD
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
echo " - DIRECT"
echo ""
cat <<'AUTO_HEAD'
- name: "Auto-Select"
type: url-test
url: "http://www.gstatic.com/generate_204"
interval: 300
tolerance: 50
proxies:
AUTO_HEAD
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
echo ""
echo "rules:"
echo " # 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」"
echo " # 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。"
declare -A srv_seen=()
for s in "${SERVERS[@]}"; do
[ -n "${srv_seen[$s]:-}" ] && continue
srv_seen["$s"]=1
if [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo " - IP-CIDR,${s}/32,DIRECT,no-resolve"
elif [[ "$s" == \[*\] ]]; then
echo " - IP-CIDR6,${s//[\[\]]/}/128,DIRECT,no-resolve"
else
echo " - DOMAIN,${s},DIRECT"
fi
done
cat <<'RULES'
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT
# 兜底。MATCH 是终结规则,其后任何规则都不可达
- MATCH,Proxy
RULES
} > "$OUTPUT"
echo ""
log "已生成: ${OUTPUT}${#NAMES[@]} 个节点)"
for n in "${NAMES[@]}"; do echo " - ${n}"; done
echo ""
warn "该文件含真实凭据,已被根 .gitignore 排除,请勿提交或外传"
@@ -5,26 +5,27 @@
IP : 64.81.25.247
端口 : 443
协议 : VLESS
UUID : 5455974f-a22a-4039-82b4-5dc0a30f06ad
UUID : 7c7de15f-d408-4534-b367-f6253e850178
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : dl.google.com
SNI : www.apple.com
Fingerprint : chrome
PublicKey : cu-tvQ0PmpJDF5WtkgXgMmyhas2ZYdW5rpUiaAuekmo
ShortId : 88992d098ccc5622
PublicKey : AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM
ShortId : fa4235504a09feeb
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://5455974f-a22a-4039-82b4-5dc0a30f06ad@64.81.25.247:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=cu-tvQ0PmpJDF5WtkgXgMmyhas2ZYdW5rpUiaAuekmo&sid=88992d098ccc5622&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
==========================================================
⚠️ 请妥善保存以上信息!
配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env
再次查看: bash deploy.sh --show
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
@@ -1,21 +1,67 @@
# ============================================================
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
#
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
#
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
# ============================================================
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
skip-domain:
- "+.push.apple.com"
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
# 网络连通性检测等会出问题
fake-ip-filter:
- "*.lan"
- "*.local"
- "*.localdomain"
- "*.home.arpa"
- "+.msftconnecttest.com"
- "+.msftncsi.com"
- "time.*.com"
- "ntp.*.com"
- "+.pool.ntp.org"
- "localhost.ptlogin2.qq.com"
# 用于解析下面 fallback 里 DoH 服务器自身的域名
default-nameserver:
- 223.5.5.5
- 119.29.29.29
nameserver:
- 223.5.5.5
- 119.29.29.29
# fallback 的意义是绕过投毒,必须用加密 DNS——
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
fallback:
- 8.8.8.8
- 1.1.1.1
- https://1.1.1.1/dns-query
- https://dns.google/dns-query
fallback-filter:
geoip: true
geoip-code: CN
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
proxies:
- name: "vps-reality-1"
@@ -24,8 +70,8 @@ proxies:
port: 443
uuid: 5b05151b-a681-46db-a223-95dd45b13589
network: tcp
tls: true
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
@@ -39,8 +85,8 @@ proxies:
port: 443
uuid: cbf3836d-517d-4dfb-86d4-a636bca27713
network: tcp
tls: true
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
@@ -53,22 +99,33 @@ proxy-groups:
type: select
proxies:
- Auto-Select
- vps-reality-1
- vps-reality-2
- "vps-reality-1"
- "vps-reality-2"
- DIRECT
- name: "Auto-Select"
type: url-test
url: "http://www.gstatic.com/generate_204"
interval: 300
tolerance: 50
proxies:
- vps-reality-1
- vps-reality-1
- "vps-reality-1"
- "vps-reality-2"
rules:
# 国内直连
- GEOIP,CN,DIRECT
# 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」
# 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。
- IP-CIDR,45.32.69.133/32,DIRECT,no-resolve
- IP-CIDR,45.32.71.199/32,DIRECT,no-resolve
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
- GEOSITE,cn,DIRECT
# 其余走代理
- MATCH,Proxy
- GEOIP,CN,DIRECT
# 兜底。MATCH 是终结规则,其后任何规则都不可达
- MATCH,Proxy
@@ -5,26 +5,27 @@
IP : 140.82.23.27
端口 : 443
协议 : VLESS
UUID : 07b3bbf7-2a86-41dd-9c61-4e84767e68de
UUID : 3cac4c5e-c9ae-488f-9cde-0c65b2d23409
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : dl.google.com
SNI : www.apple.com
Fingerprint : chrome
PublicKey : xLsb2ZHk5C1r7kGUTyUwgx5nzDd1MrRUttdo15fbplM
ShortId : ac5038148e675b48
PublicKey : 5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8
ShortId : 4c5999dd1b15ff3c
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://07b3bbf7-2a86-41dd-9c61-4e84767e68de@140.82.23.27:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=xLsb2ZHk5C1r7kGUTyUwgx5nzDd1MrRUttdo15fbplM&sid=ac5038148e675b48&type=tcp#vps-reality-3
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
==========================================================
+726 -66
View File
@@ -28,26 +28,59 @@ usage() {
echo "选项:"
echo " --mode reality VLESS-Reality 模式(默认,推荐,抗封锁)"
echo " --mode fast VLESS TCP 模式(无 TLS,极速但易被检测)"
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}"
}
# ===== 解析参数 =====
MODE="reality"
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"; shift 2 ;;
--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
if [[ "$MODE" != "reality" && "$MODE" != "fast" ]]; then
error "无效模式: $MODE(支持 reality / fast"
# --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
error "请使用 root 用户运行: sudo bash deploy.sh"
exit 1
@@ -59,12 +92,328 @@ if [ -f .env ]; then
set -a; source .env; set +a
fi
# 未显式指定 --mode 时沿用 .env 里记录的模式,
# 避免在 fast 模式的机器上重跑脚本被静默切换成 reality
if [ "$MODE_EXPLICIT" -eq 0 ]; then
MODE="${XRAY_MODE:-reality}"
fi
if [[ "$MODE" != "reality" && "$MODE" != "fast" ]]; then
error "无效模式: $MODE(支持 reality / fast"
exit 1
fi
# ===== 定时重启配置 =====
# 可覆盖路径,测试时指向临时目录
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
# 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
error "XRAY_RESTART_TIME 必须是 HH:MM00: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
}
# 就地更新 .env 中的单个字段:存在则替换,不存在则追加。
# 刻意不复用 save_env——后者是 cat > 全量重写,会抹掉用户自己加的注释与字段;
# 轻量入口只该改自己那一行。
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" || return 1
else
# 文件末尾可能缺换行(用户手工编辑过),直接追加会把新字段黏到上一行
if [ -n "$(tail -c 1 "$file")" ]; then
printf '\n' >> "$file" || return 1
fi
printf '%s=%s\n' "$key" "$value" >> "$file" || return 1
fi
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
# 时间戳落在未来(RTC 错乱或 NTP 大幅向后校正时写下的)同样按 0 处理。
# 否则时钟校回之后 now - last 恒为负,间隔判断恒成立,此后永远跳过,
# 而状态文件只在真正重启时才被覆盖——没有任何自愈路径。
[ "\$last" -gt "\$now" ] && 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
# 必须先写时间戳:restart 会中断本脚本所在的 systemd 事务,
# 顺序反了会丢记录,退化成每天都重启。
# 代价是重启失败时本轮仍被记为已执行——这比陷入每日重启循环可接受。
#
# 写不进去(/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
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]
# 时区必须写在日历表达式里(systemd >= 242 支持)。
# [Timer] 段没有 TimeZone= 这个 key——写了只会被忽略并留下一行
# "Unknown key name 'TimeZone'",然后按系统本地时区解析。
# VPS 出厂多为 UTC,那样「04:00 Asia/Shanghai」会在北京时间 12:00 触发。
OnCalendar=*-*-* ${time}:00 ${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
# 失败不阻断删除(单元可能本就没被 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"
return 0
}
# 依据 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
if ! systemctl daemon-reload; then
warn "systemctl daemon-reload 失败,已删除的单元可能仍残留在 systemd 内存中"
fi
log "定时重启: 关闭(崩溃恢复已由 Restart=always 秒级覆盖)"
return 0
fi
render_restart_units "$RESTART_EVERY_DAYS" "$RESTART_TIME" "$RESTART_TZ"
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:只改重启计划 =====
# 明确不做:装依赖、升级 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
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
if ! update_env_field "$env_file" XRAY_RESTART_TIME "$RESTART_TIME"; then
error "写入 ${env_file} 失败,未做任何改动"
exit 1
fi
fi
configure_restart_timer
echo ""
echo "=========================================================="
if [ "$RESTART_EVERY_DAYS" -eq 0 ]; then
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
}
resolve_restart_config || exit 1
XRAY_PORT="${XRAY_PORT:-443}"
# ===== 0. 检查并安装依赖 =====
# 精简版系统镜像(如部分 VPS 商的 Debian/Ubuntu minimal)常缺 curl / unzip
ensure_deps() {
step "0/5 检查依赖"
step "1/8 检查依赖"
local missing=()
local cmd
@@ -107,7 +456,7 @@ ensure_deps() {
# ===== 1. 安装 Xray =====
install_xray() {
step "1/5 安装 Xray"
step "2/8 安装 Xray"
if [ -x /usr/local/bin/xray ]; then
log "Xray 已安装: $(/usr/local/bin/xray version | head -1)"
@@ -147,8 +496,48 @@ install_xray() {
}
# ===== 2. 生成密钥 =====
# ===== 凭据回收 =====
# 部署目录被删除、或 .env 丢失后重跑脚本,若直接生成新密钥,
# 所有客户端的旧链接会立刻失效——而失效表现与「握手被中间人干扰」
# 完全相同(REALITY: received real certificate),极难排查。
# 因此只要 config.json 还在,就以它为准恢复凭据。
recover_credentials() {
[ -f "$XRAY_CONFIG" ] || return 0
[ -n "${XRAY_UUID:-}" ] && return 0 # .env 已提供,无需回收
local flat uuid priv sid sni dest port
flat=$(tr -d '\n\r\t ' < "$XRAY_CONFIG")
uuid=$(echo "$flat" | grep -oP '"id":"\K[^"]+' | head -1 || true)
[ -z "$uuid" ] && return 0 # 多半是官方安装脚本留下的空壳配置
priv=$(echo "$flat" | grep -oP '"privateKey":"\K[^"]+' | head -1 || true)
sid=$(echo "$flat" | grep -oP '"shortIds":\["\K[^"]+' | head -1 || true)
sni=$(echo "$flat" | grep -oP '"serverNames":\["\K[^"]+' | head -1 || true)
dest=$(echo "$flat" | grep -oP '"dest":"\K[^"]+' | head -1 || true)
port=$(echo "$flat" | grep -oP '"port":\K[0-9]+' | head -1 || true)
warn ".env 缺失,正在从现有的 ${XRAY_CONFIG} 恢复凭据"
warn "(这样客户端已有的分享链接依然可用,不会因重装而失效)"
XRAY_UUID="$uuid"
[ -n "$port" ] && XRAY_PORT="$port"
if [ -n "$priv" ]; then
XRAY_PRIVATE_KEY="$priv"
XRAY_PUBLIC_KEY=$(/usr/local/bin/xray x25519 -i "$priv" 2>/dev/null \
| grep -iE 'public|password' | awk -F': ' '{print $2}' | tr -d '[:space:]' || true)
[ -n "$sid" ] && XRAY_SHORT_ID="$sid"
[ -n "$sni" ] && REALITY_SNI="$sni"
[ -n "$dest" ] && REALITY_DEST="${dest%%:*}"
fi
log "已恢复 UUID: ${XRAY_UUID}"
}
generate_keys() {
step "2/5 生成密钥"
step "3/8 生成密钥"
recover_credentials
XRAY_UUID="${XRAY_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
log "UUID: ${XRAY_UUID}"
@@ -172,54 +561,227 @@ 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#6356www.microsoft.com 的证书记录 8273 字节即触发)
[ "$len" -lt 8192 ] || return 1
REALITY_DEST_CERT_LEN="$len"
return 0
}
# ===== 3. 选择 Reality 伪装目标 =====
select_reality_dest() {
if [ "$MODE" != "reality" ]; then
return
fi
step "3/5 选择 Reality 伪装目标"
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:-}"
if [ -n "$REALITY_DEST" ] && [ -n "$REALITY_SNI" ]; then
log "使用 .env 中定的伪装目标: ${REALITY_DEST}"
if [ -n "$REALITY_DEST" ] && [ -n "$REALITY_SNI" ] && [ "$REDETECT" -eq 0 ]; then
log "使用 .env 中已固定的伪装目标: ${REALITY_DEST}"
# 固定下来的伪装站可能哪天失效。这里只做可用性检查并告警,
# 绝不自动更换——更换会改变 SNI,导致所有客户端链接静默失效。
local ms
ms=$(curl -so /dev/null -w '%{time_connect}' --max-time 5 "https://${REALITY_DEST}" 2>/dev/null \
| awk '{printf "%d", $1*1000}') || true
if [ -z "$ms" ] || ! [[ "$ms" =~ ^[0-9]+$ ]] || [ "$ms" -le 0 ]; then
warn "伪装目标 ${REALITY_DEST} 当前不可达!Reality 回落会失败,客户端可能连不上"
warn "如需更换,执行: bash deploy.sh --redetect"
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
local best_dest="www.microsoft.com"
local best_ms=9999
if [ "$REDETECT" -eq 1 ] && [ -n "$REALITY_DEST" ]; then
warn "--redetect:将重新挑选伪装目标,原目标为 ${REALITY_DEST}"
fi
# 按列表顺序优先,只有前面的不可用才往后顺延——不按延迟排序。
# 候选现在都是 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 [ -n "$ms" ] && [ "$ms" -lt "$best_ms" ] 2>/dev/null; then
best_ms=$ms
best_dest=$site
if [ -z "$ms" ] || ! [[ "$ms" =~ ^[0-9]+$ ]] || [ "$ms" -le 0 ]; then
warn " ${site} 不可达,跳过"
continue
fi
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 "没有候选伪装目标能同时满足「可达」与「REALITY 技术要求」"
error "候选列表: ${candidates[*]}"
error "可在 .env 中手动指定 REALITY_DEST / REALITY_SNI 后重试"
exit 1
fi
local old_dest="$REALITY_DEST"
REALITY_DEST="$best_dest"
REALITY_SNI="$best_dest"
log "伪装目标: ${REALITY_DEST} (延迟 ${best_ms}ms)"
}
# ===== 4. 写入 Xray 配置 =====
write_config() {
if [ "$MODE" = "reality" ]; then
write_config_reality
else
write_config_fast
if [ -n "$old_dest" ] && [ "$old_dest" != "$best_dest" ]; then
echo ""
warn "════════════════════════════════════════════════════════"
warn "伪装目标已从 ${old_dest} 更换为 ${best_dest}"
warn "SNI 随之改变,所有客户端的旧链接将立即失效!"
warn "部署完成后请用新的分享链接重新配置全部客户端。"
warn "════════════════════════════════════════════════════════"
echo ""
fi
}
write_config_reality() {
step "3/5 写入 Xray 配置 (Reality)"
# ===== 4. 写入 Xray 配置 =====
XRAY_CONFIG="/usr/local/etc/xray/config.json"
cat > /usr/local/etc/xray/config.json << EOF
# 对外 IP:.env 指定优先,否则探测出口 IP
SERVER_IP_EFFECTIVE=""
resolve_server_ip() {
if [ -n "${SERVER_IP:-}" ]; then
SERVER_IP_EFFECTIVE="$SERVER_IP"
return
fi
SERVER_IP_EFFECTIVE=$(curl -s --max-time 5 ipv4.ip.sb 2>/dev/null \
|| curl -s --max-time 5 ifconfig.me 2>/dev/null || true)
}
# 阻断规则:
# 1) 本机公网 IP 的「代理端口」—— 客户端若因路由回环把「连接本代理」这件事
# 也丢进隧道,服务器就成了自我中继与流量放大器。只掐这一个端口,
# 不影响经隧道 SSH 回本机等正常用法(那是很常见的运维操作)。
# 2) geoip:private —— 私网与回环。代理用户没有任何理由经本机访问服务器内网,
# 放开等于把内网暴露给所有客户端。
build_block_rules() {
SELF_LOOP_RULE=""
if [[ "$SERVER_IP_EFFECTIVE" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
SELF_LOOP_RULE=$(cat <<EOF
{
"type": "field",
"outboundTag": "block",
"ip": ["${SERVER_IP_EFFECTIVE}/32"],
"port": "${XRAY_PORT}"
},
EOF
)
fi
}
write_config() {
resolve_server_ip
build_block_rules
mkdir -p "$(dirname "$XRAY_CONFIG")"
# 先写临时文件并校验,通过后再原子替换正式配置,
# 保证校验失败时磁盘上的配置和正在运行的服务都不受影响
# xray 靠文件扩展名判断配置格式,临时文件必须叫 *.json,
# 否则会报 "Failed to get format"。所以用临时目录而非 mktemp 裸文件。
local tmpdir tmp
tmpdir="$(mktemp -d)"
tmp="${tmpdir}/config.json"
if [ "$MODE" = "reality" ]; then
write_config_reality "$tmp"
else
write_config_fast "$tmp"
fi
if ! /usr/local/bin/xray run -test -config "$tmp" >/dev/null 2>&1; then
error "配置校验失败,磁盘配置与运行中的服务均未改动。详细输出:"
/usr/local/bin/xray run -test -config "$tmp" || true
rm -rf "$tmpdir"
exit 1
fi
log "配置语法校验通过"
# 覆盖前备份旧配置,重跑脚本不至于丢掉手工改动(例如新增的 clients)
if [ -f "$XRAY_CONFIG" ]; then
local backup="${XRAY_CONFIG}.bak.$(date '+%Y%m%d%H%M%S')"
cp "$XRAY_CONFIG" "$backup"
log "旧配置已备份: ${backup}"
# 只保留最近 5 份,避免反复部署后无限堆积
ls -1t "${XRAY_CONFIG}".bak.* 2>/dev/null | tail -n +6 | xargs -r rm -f
fi
mv "$tmp" "$XRAY_CONFIG"
rm -rf "$tmpdir"
# xray 服务以 nobody 运行,配置必须对其可读
chmod 644 "$XRAY_CONFIG"
log "配置已写入: ${XRAY_CONFIG}"
}
write_config_reality() {
step "5/8 写入 Xray 配置 (Reality)"
local out="$1"
cat > "$out" << EOF
{
"log": {
"loglevel": "warning"
@@ -273,6 +835,12 @@ write_config_reality() {
],
"routing": {
"rules": [
${SELF_LOOP_RULE}
{
"type": "field",
"outboundTag": "block",
"ip": ["geoip:private"]
},
{
"type": "field",
"outboundTag": "block",
@@ -282,14 +850,13 @@ write_config_reality() {
}
}
EOF
log "配置已写入: /usr/local/etc/xray/config.json"
}
write_config_fast() {
step "3/5 写入 Xray 配置 (Fast TCP)"
step "5/8 写入 Xray 配置 (Fast TCP)"
local out="$1"
cat > /usr/local/etc/xray/config.json << EOF
cat > "$out" << EOF
{
"log": {
"loglevel": "warning"
@@ -330,6 +897,12 @@ write_config_fast() {
],
"routing": {
"rules": [
${SELF_LOOP_RULE}
{
"type": "field",
"outboundTag": "block",
"ip": ["geoip:private"]
},
{
"type": "field",
"outboundTag": "block",
@@ -339,23 +912,50 @@ write_config_fast() {
}
}
EOF
}
log "配置已写入: /usr/local/etc/xray/config.json"
# ===== 检测当前生效的 SSH 端口 =====
# 防火墙一旦 enable,若没放行真实的 SSH 端口就会把自己锁在门外
detect_ssh_ports() {
local ports=""
if command -v sshd &>/dev/null; then
ports=$(sshd -T 2>/dev/null | awk '/^port /{print $2}' || true)
fi
if [ -z "$ports" ]; then
ports=$(ss -tlnH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -u | grep -x 22 || true)
fi
if [ -z "$ports" ]; then
ports=$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/{print $2}' /etc/ssh/sshd_config 2>/dev/null || true)
fi
# 兜底:至少放行 22,避免完全无法登录
[ -z "$ports" ] && ports="22"
echo "$ports"
}
# ===== 5. 防火墙 =====
configure_firewall() {
step "4/5 配置防火墙"
step "6/8 配置防火墙"
local ssh_ports
ssh_ports=$(detect_ssh_ports)
log "检测到 SSH 端口: $(echo "$ssh_ports" | tr '\n' ' ')"
if command -v ufw &>/dev/null; then
ufw allow "${XRAY_PORT}/tcp"
ufw allow 22/tcp
local p
for p in $ssh_ports; do
ufw allow "${p}/tcp"
done
ufw --force enable
log "ufw 已放行端口 ${XRAY_PORT}/tcp"
log "ufw 已放行端口 ${XRAY_PORT}/tcp 及 SSH 端口"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-port="${XRAY_PORT}/tcp"
local p
for p in $ssh_ports; do
firewall-cmd --permanent --add-port="${p}/tcp"
done
firewall-cmd --reload
log "firewalld 已放行端口 ${XRAY_PORT}/tcp"
log "firewalld 已放行端口 ${XRAY_PORT}/tcp 及 SSH 端口"
else
warn "未检测到防火墙工具,请手动放行端口 ${XRAY_PORT}/tcp"
fi
@@ -363,7 +963,7 @@ configure_firewall() {
# ===== 6. BBR + 网络调优 =====
tune_network() {
step "5/5 BBR + 网络调优"
step "7/8 BBR + 网络调优"
cat > /etc/sysctl.d/99-xray-turbo.conf << 'SYSEOF'
# BBR 拥塞控制
@@ -396,7 +996,7 @@ SYSEOF
# ===== 新增:系统与服务加固 =====
harden_system() {
step "加固系统与服务"
step "8/8 加固系统与服务"
# 文件描述符上限
if ! grep -q '99-xray-nofile' /etc/security/limits.conf 2>/dev/null; then
@@ -418,29 +1018,7 @@ Restart=always
RestartSec=3
EOF
# 定时重启 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
configure_restart_timer
}
# ===== 7. 启动服务 =====
@@ -448,7 +1026,6 @@ start_service() {
systemctl daemon-reload
systemctl restart xray
systemctl enable xray
systemctl enable --now xray-restart.timer
sleep 2
if systemctl is-active --quiet xray; then
@@ -468,6 +1045,18 @@ save_env() {
XRAY_MODE=${MODE}
XRAY_PORT=${XRAY_PORT}
XRAY_UUID=${XRAY_UUID}
# 分享链接中使用的 IP。留空则自动探测出口 IP;
# NAT / 端口转发的机器请填写服务商给你的实际入站 IP
SERVER_IP=${SERVER_IP:-}
# 定时重启。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
@@ -487,13 +1076,35 @@ EOF
# ===== 9. 输出连接信息 =====
print_result() {
local server_ip
server_ip=$(curl -s --max-time 5 ipv4.ip.sb || curl -s --max-time 5 ifconfig.me || echo "获取失败")
[ -z "$SERVER_IP_EFFECTIVE" ] && resolve_server_ip
if [ -n "${SERVER_IP:-}" ]; then
server_ip="$SERVER_IP"
log "使用 .env 中指定的 SERVER_IP: ${server_ip}"
else
server_ip="${SERVER_IP_EFFECTIVE:-获取失败}"
# 注意:这里拿到的是「出口」IP。NAT / 端口转发的机器上,
# 它与外部实际可连接的「入站」IP 可能不是同一个
local nic_ip
nic_ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
if [ -n "$nic_ip" ] && [ "$nic_ip" != "$server_ip" ]; then
warn "网卡地址 ${nic_ip} 与出口 IP ${server_ip} 不一致,本机可能位于 NAT / 端口转发之后"
warn "若客户端连不上,请向服务商确认实际映射的入站 IP 与端口,"
warn "然后在 .env 中设置 SERVER_IP= 和 XRAY_PORT= 后重新运行本脚本"
fi
fi
local title
if [ "${SHOW_ONLY:-0}" -eq 1 ]; then
title="连接信息"
else
title="部署完成 ✅"
fi
echo ""
echo "=========================================================="
if [ "$MODE" = "reality" ]; then
echo " Xray VLESS-Reality 部署完成 ✅"
echo " Xray VLESS-Reality ${title}"
echo "=========================================================="
echo ""
echo " IP : ${server_ip}"
@@ -514,7 +1125,7 @@ print_result() {
echo ""
echo "vless://${XRAY_UUID}@${server_ip}:${XRAY_PORT}?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${REALITY_SNI}&fp=chrome&pbk=${XRAY_PUBLIC_KEY}&sid=${XRAY_SHORT_ID}&type=tcp#vps-reality"
else
echo " Xray VLESS TCP 部署完成 ✅"
echo " Xray VLESS TCP ${title}"
echo "=========================================================="
echo ""
echo " IP : ${server_ip}"
@@ -523,6 +1134,12 @@ print_result() {
echo " UUID : ${XRAY_UUID}"
echo " 传输 : tcp"
echo " 安全 : none"
echo ""
echo "=========================================================="
echo ""
echo ">>> VLESS 分享链接(可直接导入客户端):"
echo ""
echo "vless://${XRAY_UUID}@${server_ip}:${XRAY_PORT}?encryption=none&security=none&type=tcp&headerType=none#vps-fast"
fi
echo ""
@@ -531,13 +1148,56 @@ print_result() {
echo "⚠️ 请妥善保存以上信息!"
echo " 配置文件: /usr/local/etc/xray/config.json"
echo " 凭据备份: ${SCRIPT_DIR}/.env"
echo " 再次查看: bash deploy.sh --show"
echo " 查看日志: journalctl -u xray -f"
echo " 重启服务: systemctl restart xray"
echo "=========================================================="
}
# ===== --show:只读打印连接信息,不改动任何服务 =====
show_info() {
if [ ! -f "$SCRIPT_DIR/.env" ]; then
error "未找到 ${SCRIPT_DIR}/.env,无法读取部署信息"
echo ""
echo "若 Xray 配置仍在,可从 /usr/local/etc/xray/config.json 手工恢复:"
echo " priv=\$(grep -oP '\"privateKey\"\\s*:\\s*\"\\K[^\"]+' /usr/local/etc/xray/config.json)"
echo " /usr/local/bin/xray x25519 -i \"\$priv\" # 输出的 Password (PublicKey) 即客户端 pbk"
exit 1
fi
local missing=()
[ -z "${XRAY_UUID:-}" ] && missing+=("XRAY_UUID")
if [ "$MODE" = "reality" ]; then
[ -z "${XRAY_PUBLIC_KEY:-}" ] && missing+=("XRAY_PUBLIC_KEY")
[ -z "${XRAY_SHORT_ID:-}" ] && missing+=("XRAY_SHORT_ID")
[ -z "${REALITY_SNI:-}" ] && missing+=("REALITY_SNI")
fi
if [ "${#missing[@]}" -gt 0 ]; then
error ".env 缺少必要字段: ${missing[*]}"
error "请重新运行 bash deploy.sh 完成部署"
exit 1
fi
if ! systemctl is-active --quiet xray 2>/dev/null; then
warn "Xray 服务当前未运行,以下信息来自 .env,不代表服务可用"
warn "检查: systemctl status xray"
fi
print_result
}
# ===== 主流程 =====
main() {
if [ "$SHOW_ONLY" -eq 1 ]; then
show_info
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"
+468
View File
@@ -0,0 +1,468 @@
#!/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"
# 前导零必须按十进制归一化:这个值会原样写进 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"
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 "== 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"
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 "== 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"
# 时区必须写在日历表达式里:[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" \
"$(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 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"
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 "== 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"
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"
# 时间戳落在未来(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"
: > "$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 "== 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 ]
+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 ]
+37 -4
View File
@@ -83,11 +83,36 @@ else
fi
systemctl disable xray 2>/dev/null || true
# 每日重启 timer(旧版部署可能残留)
if [ -f /etc/systemd/system/xray-restart.timer ] \
|| [ -f /usr/local/bin/xray-restart-guard ]; then
systemctl disable --now xray-restart.timer 2>/dev/null || true
rm -f /etc/systemd/system/xray-restart.timer \
/etc/systemd/system/xray-restart.service \
/usr/local/bin/xray-restart-guard \
/var/lib/xray/last-restart
log "已移除定时重启 timer 与状态文件"
fi
# ===== 2. 卸载 Xray =====
log "正在卸载 Xray..."
if [ -f /usr/local/bin/xray ]; then
# 使用官方卸载方式
bash <(curl -fsSL https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh) --remove 2>/dev/null || true
# 官方卸载:下载到临时文件再执行。
# 不能用 bash <(curl ...)——curl 缺失时进程替换只产生空脚本,
# 外层 bash 仍返回 0,卸载会被静默跳过
if command -v curl &>/dev/null; then
installer="$(mktemp)"
if curl -fsSL --max-time 60 \
https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \
-o "$installer" && [ -s "$installer" ]; then
bash "$installer" remove --purge || warn "官方卸载脚本执行失败,改为手动清理"
else
warn "无法下载官方卸载脚本,改为手动清理"
fi
rm -f "$installer"
else
warn "未安装 curl,跳过官方卸载脚本,改为手动清理"
fi
# 手动清理残留
rm -f /usr/local/bin/xray
rm -rf /usr/local/etc/xray
@@ -128,9 +153,17 @@ fi
echo ""
read -r -p "是否删除部署目录 ${SCRIPT_DIR}(y/N): " del_deploy
if [[ "$del_deploy" =~ ^[Yy]$ ]]; then
cd /opt
# 删除前做路径断言,避免 SCRIPT_DIR 异常时误删根目录或系统目录
case "$SCRIPT_DIR" in
/|/root|/etc|/usr|/var|/home|/opt|"")
error "拒绝删除高危路径: '${SCRIPT_DIR}',请手动处理"
;;
*)
cd /
rm -rf "$SCRIPT_DIR"
log "已删除部署目录"
log "已删除部署目录: ${SCRIPT_DIR}"
;;
esac
else
warn "保留部署目录: ${SCRIPT_DIR}"
fi
+1
View File
@@ -27,6 +27,7 @@ echo ">>> 上传部署文件"
scp -P "${SSH_PORT}" \
"${SCRIPT_DIR}/deploy.sh" \
"${SCRIPT_DIR}/uninstall.sh" \
"${SCRIPT_DIR}/backup.sh" \
"${SCRIPT_DIR}/.env.example" \
"root@${VPS_IP}:${REMOTE_DIR}/"