Compare commits

...
19 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
joywayer 2afc2cade6 优化部署,增加lisa dmit 2026-08-05 09:51:12 +08:00
joywayer 81928c6d33 Merge branch 'master' of ssh://git.joywaygames.cn:2222/joywaygamess/server-deploy 2026-08-05 08:10:38 +08:00
joywayer 1bfd657f99 vaultwarden的升级和部署修改为1.37版本 2026-07-29 16:41:58 +08:00
28 changed files with 5796 additions and 249 deletions
-27
View File
@@ -1,27 +0,0 @@
<!-- rtk-instructions v2 -->
# RTK — Token-Optimized CLI
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
## Rule
Always prefix shell commands with `rtk`:
```bash
# Instead of: Use:
git status rtk git status
git log -10 rtk git log -10
cargo test rtk cargo test
docker ps rtk docker ps
kubectl get pods rtk kubectl pods
```
## Meta commands (use directly)
```bash
rtk gain # Token savings dashboard
rtk gain --history # Per-command savings history
rtk discover # Find missed rtk opportunities
rtk proxy <cmd> # Run raw (no filtering) but track usage
```
<!-- /rtk-instructions -->
-12
View File
@@ -1,12 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "rtk hook copilot",
"cwd": ".",
"timeout": 5
}
]
}
}
+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/ data/
backups/ backups/
.env .env
.env.bak-*
.upgrade-state
+83 -30
View File
@@ -326,42 +326,93 @@ docker compose up -d # 启动
#### 升级 Gitea #### 升级 Gitea
Gitea 以 Docker 容器运行,升级 = 拉取新镜像 + 重启容器。数据库结构变更会在启动时自动迁移 使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)
```bash ```bash
cd /opt/gitea cd /opt/gitea
# 1. 备份(必须!) bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash backup.sh bash upgrade.sh # 正式升级到 GitHub 最新 release
# 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
``` ```
**注意事项:** 脚本做的事:
- 务必查阅 [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 #### 升级 MySQL
@@ -749,6 +800,8 @@ docker compose exec server gitea admin user change-password -u 管理员用户
├── .env # 运行时配置(自动生成) ├── .env # 运行时配置(自动生成)
├── deploy.sh # 全新服务器一键部署脚本 ├── deploy.sh # 全新服务器一键部署脚本
├── backup.sh # MySQL + 数据备份脚本 ├── backup.sh # MySQL + 数据备份脚本
├── upgrade.sh # 安全升级脚本(备份 → 校验 → 自动回滚)
├── .upgrade-state # 升级回滚锚点(upgrade.sh 自动生成)
├── uninstall.sh # 完全卸载脚本 ├── uninstall.sh # 完全卸载脚本
├── .gitignore ├── .gitignore
├── README.md ├── README.md
+1090
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -25,7 +25,11 @@ SENDS_ALLOWED=true
# ===== 镜像配置 ===== # ===== 镜像配置 =====
# Vaultwarden 镜像 # Vaultwarden 镜像
VAULTWARDEN_IMAGE=vaultwarden/server:latest # 留空或填 latest 时,deploy.sh 会自动查询 GitHub 最新 release 并把具体版本号写回这里。
# 不要长期使用 latest:该标签只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,而且从配置上看不出实际运行的是哪一版。
# 后续升级请用 upgrade.sh,它会自动改写此处的版本号。
VAULTWARDEN_IMAGE=
# ===== 目录与端口 ===== # ===== 目录与端口 =====
+112 -16
View File
@@ -16,7 +16,7 @@ Bitwarden 兼容的自托管密码管理器,轻量、安全、功能完整。
| 组件 | 版本 | 说明 | | 组件 | 版本 | 说明 |
|------|------|------| |------|------|------|
| Vaultwarden | latest | Bitwarden 兼容服务端(Rust 实现) | | Vaultwarden | 固定版本号 | Bitwarden 兼容服务端(Rust 实现),由 `upgrade.sh` 升级 |
| SQLite | 内置 | 轻量数据库,无需额外部署 | | SQLite | 内置 | 轻量数据库,无需额外部署 |
| Nginx | 系统包 | 反向代理 + HTTPSBitwarden 客户端必须 HTTPS | | Nginx | 系统包 | 反向代理 + HTTPSBitwarden 客户端必须 HTTPS |
| Docker | 最新版 | 容器运行环境 | | Docker | 最新版 | 容器运行环境 |
@@ -35,6 +35,7 @@ vaultwarden/
├── docker-compose.yml # 容器编排 ├── docker-compose.yml # 容器编排
├── .env.example # 配置模板 ├── .env.example # 配置模板
├── deploy.sh # 一键部署脚本 ├── deploy.sh # 一键部署脚本
├── upgrade.sh # 安全升级脚本(带备份、校验、自动回滚)
├── backup.sh # 备份脚本 ├── backup.sh # 备份脚本
├── uninstall.sh # 完全卸载脚本 ├── uninstall.sh # 完全卸载脚本
├── nginx/ ├── nginx/
@@ -97,9 +98,12 @@ bash deploy.sh
cd /opt/vaultwarden cd /opt/vaultwarden
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false # 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
vi .env vi .env
docker compose restart docker compose up -d # 注意:不是 restart
``` ```
> **不要用 `docker compose restart`**。环境变量是在容器**创建**时注入的,`restart` 只是重启
> 原容器、不会重建,改了 `.env` 也不会生效。必须用 `up -d` 让 compose 检测到配置变更并重建容器。
### 第五步:安装客户端 ### 第五步:安装客户端
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/ 1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
@@ -224,23 +228,65 @@ docker compose up -d
### 升级 ### 升级
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
```bash ```bash
cd /opt/vaultwarden cd /opt/vaultwarden
# 1. 备份数据 bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash backup.sh bash upgrade.sh # 正式升级到 GitHub 最新 release
# 2. 拉取新镜像
docker compose pull
# 3. 重启
docker compose up -d
# 4. 检查运行状态
docker compose ps
docker compose logs --tail 20
``` ```
脚本执行流程:
| 步骤 | 动作 | 失败时 |
|------|------|--------|
| 1 | 预检:root、依赖命令、数据目录、磁盘空间 | 直接退出,未动服务 |
| 2 | 拉取新镜像 | 直接退出,**服务零影响**,可安全重试 |
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
| 4 | 停止容器 | 自动拉起原服务 |
| 5 | WAL checkpoint + `integrity_check` + 冷备份整个数据目录 | 自动拉起原服务 |
| 6 | 切换镜像并 `--force-recreate` 重建容器 | 自动回滚 |
| 7 | 校验容器实际镜像 == 目标镜像 | 自动回滚 |
| 8 | 校验 `/alive`、新旧 prelogin 路由、Nginx 反代 | 自动回滚 |
| 9 | 比对升级前后各表行数,任一表减少即判定数据丢失 | 自动回滚 |
常用选项:
```bash
bash upgrade.sh --check # 只检查,零改动
bash upgrade.sh --version 1.37.0 # 升级到指定版本
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
bash upgrade.sh --rollback # 回滚到上次升级前的状态
```
升级完成后请**登录 Web 端确认数据无误**,再清理回滚镜像:
```bash
docker image rm vaultwarden/server:pre-upgrade-<时间戳>
```
### 回滚
```bash
cd /opt/vaultwarden
bash upgrade.sh --rollback
```
回滚会读取 `.upgrade-state` 里记录的备份和镜像锚点,恢复数据目录并切回旧镜像。
> **注意**:回滚会丢弃升级后新增/修改的密码条目。
> 回滚**不会删除**升级后的数据,而是把它改名保留为 `<数据目录>.failed-<时间戳>`,确认无需后再自行删除。
### 为什么不要手动升级
手动 `docker compose pull && docker compose up -d` 有两个坑,都会导致「看起来升级了,其实没有」:
1. **停止状态的容器不会因镜像变更而重建**。如果先 `stop``up -d`compose 只是把原容器重新 `start`,仍然跑旧镜像。必须 `--force-recreate`
2. **环境变量优先级高于 `.env` 文件**。如果当前 shell 里已经 `export``VAULTWARDEN_IMAGE`(比如脚本里 `source .env` 过),改 `.env` 文件无效,compose 依然用旧值。
`upgrade.sh` 处理了这两点,并在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,不匹配直接回滚。
### 停止 / 启动 ### 停止 / 启动
```bash ```bash
@@ -316,10 +362,24 @@ rm -rf /opt/vaultwarden
注册好所有需要的账号后: 注册好所有需要的账号后:
```bash ```bash
# .env 中设置 cd /opt/vaultwarden
SIGNUPS_ALLOWED=false sed -i 's/^SIGNUPS_ALLOWED=.*/SIGNUPS_ALLOWED=false/' .env
docker compose up -d # 必须 up -drestart 不生效
``` ```
验证是否真的关闭了(应返回 `Registration not allowed or user already exists`):
```bash
curl -s -X POST http://127.0.0.1:8080/identity/accounts/register/send-verification-email \
-H 'Content-Type: application/json' \
-d '{"email":"probe@example.invalid","name":"probe"}'
```
> **不要用 `/api/config` 里的 `disableUserRegistration` 判断**。该字段来自
> `is_signup_disabled()`,含义是「是否隐藏 UI 上的注册链接」,而非「注册是否被拒绝」。
> 当 `INVITATIONS_ALLOWED=true` 且未配置 SMTP 时,即使 `SIGNUPS_ALLOWED=false`
> 它也会显示为 `false`(因为管理员邀请的用户仍需走注册流程)。真正的拦截在注册接口里。
### 2. 限制管理面板访问 ### 2. 限制管理面板访问
`nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。 `nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
@@ -347,6 +407,42 @@ docker compose ps
curl http://127.0.0.1:8080/alive curl http://127.0.0.1:8080/alive
``` ```
### 客户端登录报错,控制台 404 /identity/accounts/prelogin/password
**症状**:已登录的会话正常,但重新登录失败,浏览器控制台显示:
```
POST https://vault.example.com/identity/accounts/prelogin/password 404 (Not Found)
```
**原因**:服务端版本过旧。Bitwarden 客户端从 v2026.4.0 起改用 `/identity/accounts/prelogin/password`
这个新路由,而 Vaultwarden 在 **1.36.0** 才实现它。客户端会自动更新,服务端不会。
**确认**
```bash
# 看服务端实到底跑的是哪个版本
curl -s https://vault.example.com/api/config | grep -o '"gitHash":"[^"]*"'
# 直接探测新旧两个路由
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"email":"x@example.com"}' https://vault.example.com/identity/accounts/prelogin/password
```
旧路由返回 200、新路由返回 404,即可确诊。
**修复**:升级到 1.36.0 以上。
```bash
cd /opt/vaultwarden && bash upgrade.sh
```
如果暂时无法升级,可在 Nginx 的 443 server 块内加一条重写作为临时过渡:
```nginx
rewrite ^/identity/accounts/prelogin/password/?$ /identity/accounts/prelogin last;
```
### 502 Bad Gateway ### 502 Bad Gateway
```bash ```bash
+84 -3
View File
@@ -24,11 +24,72 @@ source "$BASE_DIR/setup.sh"
# Vaultwarden 专用函数 # Vaultwarden 专用函数
# ============================================================= # =============================================================
GITHUB_REPO="dani-garcia/vaultwarden"
# 生成随机密码 # 生成随机密码
generate_password() { generate_password() {
openssl rand -base64 32 | tr -d '/+=' | head -c 32 openssl rand -base64 32 | tr -d '/+=' | head -c 32
} }
# 查询 GitHub 上的最新 release 版本号(不含前缀 v),失败返回非零
resolve_latest_version() {
local ver
ver=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
[ -n "$ver" ] || return 1
echo "$ver"
}
# 把镜像固定到具体版本号。
# 必须同时更新 .env 和已导出的环境变量:init_env 里 `set -a; source .env` 已把旧值
# 导出到本进程,而 docker compose 对环境变量的优先级高于 .env 文件,只改文件不生效。
pin_image_version() {
local ref="$1"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$ref" >> .env
fi
export VAULTWARDEN_IMAGE="$ref"
}
# 确定本次部署使用的镜像版本。
# 不使用 latest 标签的原因:它只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,且从配置上看不出实际运行的是哪一版。
setup_image_version() {
local current="${VAULTWARDEN_IMAGE:-}"
# 已固定到具体版本 → 尊重现有配置,升级交给 upgrade.sh
if [ -n "$current" ] && [[ "$current" != *":latest" ]]; then
log " 镜像: ${current}"
return 0
fi
# 已有实例却仍在用 latest:此时固定版本等同于一次升级,
# 而升级必须走 upgrade.sh(带冷备份、完整性校验、自动回滚),这里不擅自处理。
if docker compose ps -a -q vaultwarden 2>/dev/null | grep -q .; then
warn "检测到已存在的实例,且 .env 仍在使用 latest 标签"
warn "latest 不会自动更新,也看不出实际运行版本,建议固定到具体版本号:"
warn " bash upgrade.sh # 安全升级到最新版,并自动写回版本号"
warn "本次部署不改动镜像配置"
return 0
fi
log "正在查询 Vaultwarden 最新版本..."
local ver
if ! ver="$(resolve_latest_version)"; then
warn "无法从 GitHub 获取最新版本号(网络问题?),本次回退使用 latest 标签"
warn "部署完成后建议手动固定: VAULTWARDEN_IMAGE=vaultwarden/server:<版本号>"
[ -n "$current" ] || pin_image_version "vaultwarden/server:latest"
return 0
fi
pin_image_version "vaultwarden/server:${ver}"
log " 镜像: vaultwarden/server:${ver}(当前最新 release,已写入 .env"
}
init_env() { init_env() {
step "初始化 Vaultwarden 配置" step "初始化 Vaultwarden 配置"
@@ -83,6 +144,8 @@ init_env() {
log " 域名: ${VAULTWARDEN_DOMAIN}" log " 域名: ${VAULTWARDEN_DOMAIN}"
log " 邮箱: ${CERTBOT_EMAIL}" log " 邮箱: ${CERTBOT_EMAIL}"
log " 注册: ${SIGNUPS_ALLOWED:-true}" log " 注册: ${SIGNUPS_ALLOWED:-true}"
setup_image_version
} }
create_dirs() { create_dirs() {
@@ -95,21 +158,39 @@ create_dirs() {
log "备份目录: $backup_dir" log "备份目录: $backup_dir"
} }
# 确认容器真的跑在配置指定的镜像上,避免「配置改了但容器没重建」这类静默失败
verify_running_version() {
local cid actual expect="${VAULTWARDEN_IMAGE:-}"
cid=$(docker compose ps -a -q vaultwarden 2>/dev/null | head -1)
[ -n "$cid" ] || return 0
actual=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
if [ -n "$expect" ] && [ "$actual" != "$expect" ]; then
warn "容器实际镜像 (${actual}) 与配置 (${expect}) 不一致"
warn "请检查: docker compose up -d --force-recreate"
return 0
fi
log " 运行镜像: ${actual}"
}
start_services() { start_services() {
step "启动 Vaultwarden 服务" step "启动 Vaultwarden 服务"
log "正在拉取镜像..." log "正在拉取镜像..."
docker compose pull docker compose pull
# --force-recreate:容器处于 stopped 状态时,up -d 只会把原容器重新 start,
# 不会因镜像或环境变量变更而重建,导致「配置改了但没生效」。
log "正在启动容器..." log "正在启动容器..."
docker compose up -d docker compose up -d --force-recreate
local port="${VAULTWARDEN_PORT:-8080}" local port="${VAULTWARDEN_PORT:-8080}"
log "等待 Vaultwarden 就绪..." log "等待 Vaultwarden 就绪..."
local max_wait=30 local max_wait=30
for i in $(seq 1 "$max_wait"); do for _ in $(seq 1 "$max_wait"); do
if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then
log "Vaultwarden 启动成功!" log "Vaultwarden 启动成功!"
verify_running_version
return return
fi fi
sleep 2 sleep 2
@@ -144,7 +225,7 @@ show_info() {
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
echo -e "${GREEN}${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}" echo -e "${GREEN}${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}" echo -e "${GREEN}${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose restart${NC}" echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose up -d(不是 restart${NC}"
fi fi
echo -e "${GREEN}${NC}" echo -e "${GREEN}${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env bash
# -E 让 ERR trap 能在函数内部生效,保证不可逆区间的异常一定触发回滚
set -Eeuo pipefail
# ============================================
# Vaultwarden 安全升级脚本
#
# 设计原则:
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
# 2. 冷备份(停容器 + WAL checkpoint)—— 保证快照一致
# 3. 升级前后比对数据指纹 —— 数据异常自动回滚
# 4. 回滚不删数据 —— 旧数据目录改名保留,不 rm
#
# 用法:
# bash upgrade.sh 升级到 GitHub 最新 release
# bash upgrade.sh --check 只检查,不做任何改动
# bash upgrade.sh --version 1.37.0
# bash upgrade.sh --rollback 回滚到上次升级前的状态
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ===== 输出样式 =====
if [ -t 1 ]; then
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
log() { echo -e "${GREEN}[✓]${NC} $*"; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
# ===== 全局状态 =====
GITHUB_REPO="dani-garcia/vaultwarden"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
MODE="upgrade" # upgrade | check | rollback
TARGET_VERSION=""
ASSUME_YES=0
SAFETY_STAGE="none" # none → stopped → armed → done,决定异常退出时如何兜底
BACKUP_PATH=""
ROLLBACK_TAG=""
OLD_IMAGE_REF=""
COUNTS_BEFORE=""
# =============================================================
# 参数解析
# =============================================================
usage() {
cat <<'EOF'
Vaultwarden 安全升级脚本
用法: bash upgrade.sh [选项]
选项:
-c, --check 只做预检和版本对比,不做任何改动
-v, --version <ver> 指定目标版本(如 1.37.0),默认取 GitHub 最新 release
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state
-y, --yes 跳过交互确认
-h, --help 显示本帮助
示例:
bash upgrade.sh --check # 先看看会发生什么
bash upgrade.sh # 正式升级
bash upgrade.sh --rollback # 升级后发现问题,回滚
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
-c|--check) MODE="check" ;;
-r|--rollback) MODE="rollback" ;;
-y|--yes) ASSUME_YES=1 ;;
-v|--version)
shift
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
TARGET_VERSION="${1#v}"
;;
-h|--help) usage; exit 0 ;;
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
esac
shift
done
}
confirm() {
[ "$ASSUME_YES" -eq 1 ] && return 0
local prompt="$1" answer
echo ""
read -r -p "${prompt} [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# =============================================================
# 环境加载与预检
# =============================================================
load_env() {
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
sed -i 's/\r$//' .env
set -a; source .env; set +a
DATA_DIR="${VAULTWARDEN_DATA_DIR:-/var/lib/vaultwarden}"
BACKUP_BASE="${BACKUP_DIR:-/var/backups/vaultwarden}"
PORT="${VAULTWARDEN_PORT:-8080}"
DOMAIN="${VAULTWARDEN_DOMAIN:-}"
CURRENT_IMAGE="${VAULTWARDEN_IMAGE:-vaultwarden/server:latest}"
DB_FILE="$DATA_DIR/db.sqlite3"
}
preflight() {
step "环境预检"
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
local missing=0
for cmd in docker curl tar; do
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
done
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
[ "$missing" -eq 0 ] || exit 1
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
[ -f "$DB_FILE" ] || { error "数据库不存在: $DB_FILE"; exit 1; }
# 磁盘空间:要求可用空间 ≥ 数据目录的 3 倍(备份 + 解压余量),至少 500MB
mkdir -p "$BACKUP_BASE"
local data_kb avail_kb need_kb
data_kb=$(du -sk "$DATA_DIR" | cut -f1)
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
need_kb=$(( data_kb * 3 ))
[ "$need_kb" -lt 512000 ] && need_kb=512000
if [ "$avail_kb" -lt "$need_kb" ]; then
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,需要 $((need_kb/1024))MB"
exit 1
fi
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
info "数据目录 $DATA_DIR ($((data_kb/1024))MB),可用空间 $((avail_kb/1024))MB"
}
ensure_sqlite3() {
command -v sqlite3 >/dev/null 2>&1 && return 0
warn "sqlite3 未安装,正在安装(用于 WAL checkpoint 和完整性校验)..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sqlite3
elif command -v yum >/dev/null 2>&1; then
yum install -y -q sqlite
else
error "无法自动安装 sqlite3,请手动安装后重试"
exit 1
fi
command -v sqlite3 >/dev/null 2>&1 || { error "sqlite3 安装失败"; exit 1; }
log "sqlite3 已安装"
}
# =============================================================
# 版本解析
# =============================================================
resolve_target_version() {
if [ -n "$TARGET_VERSION" ]; then
info "使用指定版本: $TARGET_VERSION"
return 0
fi
info "查询 GitHub 最新 release..."
TARGET_VERSION=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
if [ -z "$TARGET_VERSION" ]; then
error "无法从 GitHub 获取最新版本(网络问题?)"
error "请手动指定: bash upgrade.sh --version 1.37.0"
exit 1
fi
log "GitHub 最新 release: $TARGET_VERSION"
}
# 从 compose 项目推导容器 ID(而非硬编码容器名),停止状态也能拿到
compose_container_id() {
docker compose ps -a -q vaultwarden 2>/dev/null | head -1 || true
}
# 当前容器实际运行的镜像 ID(sha256:...),拿不到则返回空
running_image_id() {
local cid
cid="$(compose_container_id)"
[ -n "$cid" ] || return 0
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
}
get_running_githash() {
curl -sf --max-time 10 "http://127.0.0.1:${PORT}/api/config" 2>/dev/null \
| grep -o '"gitHash"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"\([^"]*\)"$/\1/' || true
}
# =============================================================
# 数据指纹
# =============================================================
# 统计关键表行数,用于升级前后比对。表不存在时输出 "-"
snapshot_counts() {
local db="$1" t out=""
for t in users ciphers folders sends attachments organizations collections; do
local n
n=$(sqlite3 "$db" "SELECT COUNT(*) FROM $t;" 2>/dev/null || echo "-")
out+="${t}=${n} "
done
echo "${out% }"
}
print_counts() {
local label="$1" counts="$2" kv
echo " ${label}:"
for kv in $counts; do
printf " %-16s %s\n" "${kv%%=*}" "${kv##*=}"
done
}
# =============================================================
# 备份
# =============================================================
cold_backup() {
step "冷备份(服务已停止,快照一致)"
mkdir -p "$BACKUP_BASE"
info "执行 WAL checkpoint,将未落盘数据合并进主库..."
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE);" >/dev/null
log "WAL 已合并"
info "校验数据库完整性..."
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "数据库完整性检查未通过: $integrity"
error "升级已中止,服务未改动。请先修复数据库。"
docker compose up -d
exit 1
fi
log "数据库完整性: ok"
COUNTS_BEFORE="$(snapshot_counts "$DB_FILE")"
print_counts "升级前数据统计" "$COUNTS_BEFORE"
BACKUP_PATH="${BACKUP_BASE}/pre-upgrade-${TIMESTAMP}.tar.gz"
info "打包整个数据目录 → $BACKUP_PATH"
tar czf "$BACKUP_PATH" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
# 校验备份包。注意:这里先把清单收进变量再用 here-string 匹配,
# 不能写成 `tar tzf ... | grep -q`grep -q 命中即退出会让 tar 收到
# SIGPIPE,在 pipefail 下整条管道被判为失败,导致好备份被误判成坏的。
info "校验备份包..."
local listing
listing="$(tar tzf "$BACKUP_PATH")" || { error "备份包损坏,无法列出内容"; exit 1; }
grep -q 'db\.sqlite3$' <<<"$listing" || { error "备份包中没有数据库文件"; exit 1; }
grep -q 'rsa_key\.pem$' <<<"$listing" || warn "备份包中没有 rsa_key.pem"
# 同时备份 .env(含 ADMIN_TOKEN
cp .env "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
chmod 600 "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
log "备份完成: $BACKUP_PATH ($(du -h "$BACKUP_PATH" | cut -f1))"
}
# =============================================================
# 镜像与容器
# =============================================================
tag_rollback_image() {
step "锚定当前镜像(用于回滚)"
local image_id
image_id="$(running_image_id)"
if [ -z "$image_id" ]; then
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
fi
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
ROLLBACK_TAG="vaultwarden/server:pre-upgrade-${TIMESTAMP}"
docker tag "$image_id" "$ROLLBACK_TAG"
OLD_IMAGE_REF="$CURRENT_IMAGE"
log "当前镜像已标记为 $ROLLBACK_TAG"
info "镜像 ID: ${image_id#sha256:}"
}
pull_target_image() {
step "拉取目标镜像(此步骤不影响运行中的服务)"
local target="vaultwarden/server:${TARGET_VERSION}"
info "docker pull $target ..."
if ! docker pull "$target"; then
error "镜像拉取失败,服务未做任何改动,可安全重试"
exit 1
fi
log "镜像已就绪: $target"
}
switch_image_in_env() {
local new_ref="$1"
cp .env ".env.bak-${TIMESTAMP}"
chmod 600 ".env.bak-${TIMESTAMP}"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${new_ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$new_ref" >> .env
fi
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已经把旧值导出到
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
export VAULTWARDEN_IMAGE="$new_ref"
info ".env 中 VAULTWARDEN_IMAGE → $new_ref"
}
wait_healthy() {
local max_wait="${1:-60}" i
info "等待服务就绪(最多 ${max_wait}s..."
for i in $(seq 1 "$max_wait"); do
if curl -sf --max-time 3 "http://127.0.0.1:${PORT}/alive" >/dev/null 2>&1; then
log "服务已就绪(${i}s"
return 0
fi
sleep 1
done
return 1
}
# =============================================================
# 验证
# =============================================================
# 确认容器真的跑在目标镜像上,防止「改了配置但容器没重建」这类静默失败
verify_running_image() {
local expect="$1" cid actual_ref actual_id expect_id
cid="$(compose_container_id)"
[ -n "$cid" ] || { error "找不到容器"; return 1; }
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
if [ "$actual_id" != "$expect_id" ]; then
error "容器未运行在目标镜像上"
error " 期望: $expect ($expect_id)"
error " 实际: $actual_ref ($actual_id)"
return 1
fi
log "容器镜像确认: $actual_ref"
return 0
}
# $1: strict(默认) —— 要求新版 prelogin 路由存在(升级后)
# lenient —— 该路由 404 属正常(回滚到 1.36.0 之前的版本后)
verify_endpoints() {
local strict="${1:-strict}"
step "接口验证"
local failed=0
# 1. /alive
if curl -sf --max-time 5 "http://127.0.0.1:${PORT}/alive" >/dev/null; then
log "/alive 正常"
else
error "/alive 无响应"; failed=1
fi
# 2. 新版 prelogin 路由(1.36.0+ 才有,是本次升级的核心目标)
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin/password" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin/password → 200(新版客户端可登录)"
elif [ "$strict" = "lenient" ]; then
info "/identity/accounts/prelogin/password → $code(该版本无此路由,回滚后属预期)"
else
error "/identity/accounts/prelogin/password → $code(期望 200"; failed=1
fi
# 3. 旧版 prelogin 路由(兼容老客户端)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin → 200(旧客户端兼容)"
else
warn "/identity/accounts/prelogin → $code"
fi
# 4. 经 Nginx 的外部访问(失败只告警,属于代理层问题,不触发回滚)
if [ -n "$DOMAIN" ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
"https://${DOMAIN}/api/config" || echo "000")
if [ "$code" = "200" ]; then
log "https://${DOMAIN}/api/config → 200Nginx 反代正常)"
else
warn "https://${DOMAIN}/api/config → $code(检查 Nginx/证书,不影响本次升级判定)"
fi
fi
return $failed
}
verify_data_integrity() {
step "数据完整性验证"
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "升级后数据库完整性检查失败: $integrity"
return 1
fi
log "数据库完整性: ok"
local counts_after
counts_after="$(snapshot_counts "$DB_FILE")"
print_counts "升级后数据统计" "$counts_after"
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
local kv table before after lost=0
for kv in $COUNTS_BEFORE; do
table="${kv%%=*}"; before="${kv##*=}"
after=$(echo "$counts_after" | tr ' ' '\n' | grep "^${table}=" | cut -d= -f2 || true)
# 升级前该表就不存在 / 升级后读不到 → 跳过比对
if [ "$before" = "-" ] || [ -z "$after" ] || [ "$after" = "-" ]; then
continue
fi
if [ "$after" -lt "$before" ]; then
error "$table 行数减少: $before$after"
lost=1
fi
done
if [ "$lost" -eq 1 ]; then
error "检测到数据丢失!"
return 1
fi
log "所有表行数无减少,数据完好"
return 0
}
# =============================================================
# 回滚
# =============================================================
save_state() {
cat > "$STATE_FILE" <<EOF
# Vaultwarden 升级状态记录 —— 由 upgrade.sh 自动生成
UPGRADE_TIMESTAMP=${TIMESTAMP}
BACKUP_PATH=${BACKUP_PATH}
ROLLBACK_TAG=${ROLLBACK_TAG}
OLD_IMAGE_REF=${OLD_IMAGE_REF}
NEW_IMAGE_REF=vaultwarden/server:${TARGET_VERSION}
DATA_DIR=${DATA_DIR}
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
EOF
chmod 600 "$STATE_FILE"
}
# 恢复数据目录:旧目录改名保留,绝不 rm
restore_data_dir() {
local backup_tar="$1" ts="$2"
local parent failed_dir
parent="$(dirname "$DATA_DIR")"
failed_dir="${DATA_DIR}.failed-${ts}"
if [ -d "$DATA_DIR" ]; then
mv "$DATA_DIR" "$failed_dir"
warn "升级后的数据目录已保留为: $failed_dir(未删除)"
fi
tar xzf "$backup_tar" -C "$parent"
[ -f "$DB_FILE" ] || { error "恢复失败:$DB_FILE 不存在"; return 1; }
log "数据目录已从备份恢复"
}
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 未改动,直接拉起原版本即可
emergency_start_old() {
step "紧急恢复"
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
docker compose up -d || true
if wait_healthy 60; then
log "原服务已恢复运行,本次升级未造成任何影响"
else
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
error "查看日志: docker compose logs --tail 100"
fi
}
# 统一的退出兜底:确保任何异常路径都不会把服务丢在停机状态
on_exit() {
local code=$?
trap - EXIT ERR
[ "$code" -eq 0 ] && return 0
case "$SAFETY_STAGE" in
stopped) emergency_start_old ;;
armed) do_rollback "脚本异常中止(退出码 $code" ;;
*) : ;; # 尚未动过服务,无需处理
esac
return 0
}
do_rollback() {
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
SAFETY_STAGE="done"
set +e
local reason="$1"
step "自动回滚"
error "触发原因: $reason"
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$TIMESTAMP" || {
error "自动恢复失败!请手动处理:"
error " tar xzf $BACKUP_PATH -C $(dirname "$DATA_DIR")"
exit 1
}
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成,已恢复到升级前状态"
else
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100"
fi
echo ""
warn "升级失败已回滚。备份保留在: $BACKUP_PATH"
exit 1
}
manual_rollback() {
step "手动回滚"
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
# shellcheck disable=SC1090
source "$STATE_FILE"
DB_FILE="$DATA_DIR/db.sqlite3" # 状态文件里的 DATA_DIR 可能与 .env 不同,重新推导
echo ""
info "将回滚到:"
info " 镜像: $ROLLBACK_TAG"
info " 备份: $BACKUP_PATH"
info " 时间: $UPGRADE_TIMESTAMP"
echo ""
[ -f "$BACKUP_PATH" ] || { error "备份文件不存在: $BACKUP_PATH"; exit 1; }
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
warn "回滚会丢弃升级后产生的所有数据变更(新增/修改的密码条目)"
confirm "确认回滚?" || { info "已取消"; exit 0; }
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$(date +%Y%m%d_%H%M%S)" || exit 1
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成"
verify_endpoints lenient || true
else
error "服务未就绪,请检查: docker compose logs --tail 100"
exit 1
fi
}
# =============================================================
# 主流程
# =============================================================
show_plan() {
local githash
githash="$(get_running_githash)"
echo ""
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
echo " 域名: ${DOMAIN:-未配置}"
echo " 当前镜像: $CURRENT_IMAGE"
echo " 当前 gitHash: ${githash:-未知(服务未响应?)}"
echo " 目标版本: vaultwarden/server:${TARGET_VERSION}"
echo " 数据目录: $DATA_DIR"
echo " 备份目录: $BACKUP_BASE"
echo ""
echo " 执行步骤:"
echo " 1. 锚定当前镜像为回滚点"
echo " 2. 拉取新镜像(失败则零影响退出)"
echo " 3. 停止容器"
echo " 4. WAL checkpoint + 完整性校验 + 冷备份"
echo " 5. 切换镜像并启动"
echo " 6. 验证接口 + 比对数据行数"
echo " 7. 任一验证失败 → 自动回滚"
echo -e "${BOLD}═════════════════════════════${NC}"
}
main() {
parse_args "$@"
echo -e "${CYAN}${BOLD}"
echo " Vaultwarden 安全升级"
echo -e "${NC}"
load_env
if [ "$MODE" = "rollback" ]; then
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
manual_rollback
exit 0
fi
preflight
resolve_target_version
show_plan
# 已是目标版本?
local cur_image_id target_image_id
cur_image_id="$(running_image_id)"
target_image_id=$(docker image inspect "vaultwarden/server:${TARGET_VERSION}" \
--format '{{.Id}}' 2>/dev/null || true)
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
echo ""
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
if [ "$MODE" = "check" ]; then
exit 0
fi
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
fi
if [ "$MODE" = "check" ]; then
echo ""
info "--check 模式,未做任何改动"
exit 0
fi
confirm "确认开始升级?" || { info "已取消"; exit 0; }
ensure_sqlite3
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
pull_target_image
tag_rollback_image
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
trap on_exit EXIT
trap 'exit 1' ERR
step "停止容器"
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
docker compose stop
log "容器已停止"
cold_backup
save_state
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
step "启动新版本"
switch_image_in_env "vaultwarden/server:${TARGET_VERSION}"
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
docker compose up -d --force-recreate
if ! wait_healthy 90; then
docker compose logs --tail 40 || true
do_rollback "服务在 90 秒内未就绪"
fi
if ! verify_running_image "vaultwarden/server:${TARGET_VERSION}"; then
do_rollback "容器未运行在目标镜像上"
fi
if ! verify_endpoints; then
do_rollback "接口验证失败"
fi
if ! verify_data_integrity; then
do_rollback "数据完整性验证失败"
fi
# ===== 成功 =====
SAFETY_STAGE="done"
trap - EXIT ERR
step "升级成功"
local new_githash
new_githash="$(get_running_githash)"
echo ""
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
echo " 运行版本: vaultwarden/server:${TARGET_VERSION}"
echo " gitHash: ${new_githash:-未知}"
echo " 备份文件: $BACKUP_PATH"
echo " 回滚镜像: $ROLLBACK_TAG"
echo ""
echo " 如需回滚: bash upgrade.sh --rollback"
echo " 查看日志: docker compose logs --tail 50"
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
echo ""
warn "请登录 Web 端确认数据无误后,再考虑清理旧镜像:"
warn " docker image rm $ROLLBACK_TAG"
}
main "$@"
+27 -3
View File
@@ -6,11 +6,35 @@
# XRAY_MODE=reality # XRAY_MODE=reality
# 监听端口(默认 443,建议不改) # 监听端口(默认 443,建议不改)
# NAT / 端口转发的机器请填服务商实际映射给你的端口
# XRAY_PORT=443 # XRAY_PORT=443
# Reality 伪装目标(留空则自动选择延迟最低的) # 分享链接中使用的 IP,留空则自动探测出口 IP
# REALITY_DEST=www.microsoft.com # NAT / 端口转发的机器上出口 IP 与入站 IP 可能不同,此时必须手动指定
# REALITY_SNI=www.microsoft.com # 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 # BACKUP_DIR=/var/backups/xray
+193 -6
View File
@@ -52,14 +52,20 @@
``` ```
vps-xray/ vps-xray/
├── deploy.sh # 一键部署脚本 ├── deploy.sh # 一键部署脚本
├── backup.sh # 备份脚本
├── uninstall.sh # 完全卸载脚本 ├── uninstall.sh # 完全卸载脚本
├── backup.sh # 备份脚本
├── upload.sh # 上传脚本(仅上传服务端必要文件)
├── .env.example # 配置模板 ├── .env.example # 配置模板
├── README.md # 本文件 ├── README.md # 本文件
├── vps-xray-optimized.md # Reality 方案详细文档 ├── vps-xray-optimized.md # Reality 方案详细文档
── vps-xray-fast.md # Fast TCP 方案详细文档 ── vps-xray-fast.md # Fast TCP 方案详细文档
└── client-config/ # 客户端配置(本地使用,不上传服务器)
├── gen-clash.sh # 由节点信息生成 Clash.Meta 配置
└── client-macos.md # macOS 客户端指南(模板,可提交)
``` ```
> `client-config/` 下含真实 UUID / 服务器 IP 的节点配置(`*-info*.md`、`*.yaml`)已在根 `.gitignore` 中排除,不要提交到版本库。
服务器上的文件位置: 服务器上的文件位置:
``` ```
@@ -73,8 +79,11 @@ vps-xray/
### 第一步:上传文件到 VPS ### 第一步:上传文件到 VPS
使用 `upload.sh` 只上传服务端必要文件(`deploy.sh``uninstall.sh``.env.example`),跳过文档和客户端配置:
```bash ```bash
scp -r vps-xray/ root@<VPS_IP>:/opt/vps-xray # 在本地 vps-xray/ 目录下执行
bash upload.sh <VPS_IP>
``` ```
### 第二步:登录 VPS 执行部署 ### 第二步:登录 VPS 执行部署
@@ -94,7 +103,15 @@ bash deploy.sh --mode fast
- 连接参数(IP、端口、UUID、密钥等) - 连接参数(IP、端口、UUID、密钥等)
- VLESS 分享链接(可直接导入客户端) - 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>` 从私钥反推。
### 第三步:客户端配置 ### 第三步:客户端配置
@@ -107,16 +124,186 @@ bash deploy.sh --mode fast
| iOS | Shadowrocket / Streisand | | iOS | Shadowrocket / Streisand |
| Android | v2rayNG | | 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)。 详细的 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 ```bash
systemctl status xray systemctl status xray
journalctl -u xray -f journalctl -u xray -f
journalctl -u xray --tail 100 journalctl -u xray -n 100
``` ```
### 备份 ### 备份
@@ -235,7 +422,7 @@ systemctl status xray
ss -tlnp | grep 443 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 排除,请勿提交或外传"
@@ -0,0 +1,31 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅
==========================================================
IP : 64.81.25.247
端口 : 443
协议 : VLESS
UUID : 7c7de15f-d408-4534-b367-f6253e850178
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.apple.com
Fingerprint : chrome
PublicKey : AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM
ShortId : fa4235504a09feeb
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
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
==========================================================
@@ -0,0 +1,131 @@
# ============================================================
# 由 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:
- name: "vps-reality-1"
type: vless
server: 45.32.69.133
port: 443
uuid: 5b05151b-a681-46db-a223-95dd45b13589
network: tcp
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
short-id: c6bdf22bcb510f78
- name: "vps-reality-2"
type: vless
server: 45.32.71.199
port: 443
uuid: cbf3836d-517d-4dfb-86d4-a636bca27713
network: tcp
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
short-id: ab115ca2444053d1
proxy-groups:
- name: "Proxy"
type: select
proxies:
- Auto-Select
- "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-2"
rules:
# 代理服务器自身必须直连。否则 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
- GEOIP,CN,DIRECT
# 兜底。MATCH 是终结规则,其后任何规则都不可达
- MATCH,Proxy
@@ -5,26 +5,27 @@
IP : 140.82.23.27 IP : 140.82.23.27
端口 : 443 端口 : 443
协议 : VLESS 协议 : VLESS
UUID : 07b3bbf7-2a86-41dd-9c61-4e84767e68de UUID : 3cac4c5e-c9ae-488f-9cde-0c65b2d23409
流控 : xtls-rprx-vision 流控 : xtls-rprx-vision
传输 : tcp 传输 : tcp
安全 : reality 安全 : reality
SNI : dl.google.com SNI : www.apple.com
Fingerprint : chrome Fingerprint : chrome
PublicKey : xLsb2ZHk5C1r7kGUTyUwgx5nzDd1MrRUttdo15fbplM PublicKey : 5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8
ShortId : ac5038148e675b48 ShortId : 4c5999dd1b15ff3c
========================================================== ==========================================================
>>> VLESS 分享链接(可直接导入客户端): >>> 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 配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env 凭据备份: /opt/vps-xray/.env
再次查看: bash deploy.sh --show
查看日志: journalctl -u xray -f 查看日志: journalctl -u xray -f
重启服务: systemctl restart xray 重启服务: systemctl restart xray
========================================================== ==========================================================
+800 -67
View File
File diff suppressed because it is too large Load Diff
+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 fi
systemctl disable xray 2>/dev/null || true 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 ===== # ===== 2. 卸载 Xray =====
log "正在卸载 Xray..." log "正在卸载 Xray..."
if [ -f /usr/local/bin/xray ]; then 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 -f /usr/local/bin/xray
rm -rf /usr/local/etc/xray rm -rf /usr/local/etc/xray
@@ -128,9 +153,17 @@ fi
echo "" echo ""
read -r -p "是否删除部署目录 ${SCRIPT_DIR}(y/N): " del_deploy read -r -p "是否删除部署目录 ${SCRIPT_DIR}(y/N): " del_deploy
if [[ "$del_deploy" =~ ^[Yy]$ ]]; then 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" rm -rf "$SCRIPT_DIR"
log "已删除部署目录" log "已删除部署目录: ${SCRIPT_DIR}"
;;
esac
else else
warn "保留部署目录: ${SCRIPT_DIR}" warn "保留部署目录: ${SCRIPT_DIR}"
fi fi
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 仅上传服务端所需文件,跳过文档和客户端配置
set -euo pipefail
VPS_IP="${1:-}"
SSH_PORT="${2:-22}"
if [[ -z "$VPS_IP" ]]; then
echo "用法: bash upload.sh <VPS_IP> [SSH_PORT]"
echo "示例: bash upload.sh 1.2.3.4 # 使用默认端口 22"
echo "示例: bash upload.sh 1.2.3.4 2222 # 使用自定义端口 2222"
exit 1
fi
if ! [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || (( SSH_PORT < 1 || SSH_PORT > 65535 )); then
echo "错误: 端口必须是 1-65535 之间的数字,当前为 '${SSH_PORT}'"
exit 1
fi
REMOTE_DIR="/opt/vps-xray"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo ">>> 创建远程目录 ${REMOTE_DIR} (端口 ${SSH_PORT})"
ssh -p "${SSH_PORT}" "root@${VPS_IP}" "mkdir -p ${REMOTE_DIR}"
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}/"
echo ""
echo "上传完成,文件位于 root@${VPS_IP}:${REMOTE_DIR}"
echo ""
echo "下一步:"
if [[ "$SSH_PORT" == "22" ]]; then
echo " ssh root@${VPS_IP}"
else
echo " ssh -p ${SSH_PORT} root@${VPS_IP}"
fi
echo " cd ${REMOTE_DIR} && bash deploy.sh"
-74
View File
@@ -1,74 +0,0 @@
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
nameserver:
- 223.5.5.5
- 119.29.29.29
fallback:
- 8.8.8.8
- 1.1.1.1
fallback-filter:
geoip: true
geoip-code: CN
proxies:
- name: "vps-reality-1"
type: vless
server: 45.32.69.133
port: 443
uuid: 5b05151b-a681-46db-a223-95dd45b13589
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
short-id: c6bdf22bcb510f78
- name: "vps-reality-2"
type: vless
server: 45.32.71.199
port: 443
uuid: cbf3836d-517d-4dfb-86d4-a636bca27713
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
short-id: ab115ca2444053d1
proxy-groups:
- name: "Proxy"
type: select
proxies:
- Auto-Select
- vps-reality-1
- vps-reality-2
- DIRECT
- name: "Auto-Select"
type: url-test
url: "http://www.gstatic.com/generate_204"
interval: 300
proxies:
- vps-reality-1
- vps-reality-1
rules:
# 国内直连
- GEOIP,CN,DIRECT
- GEOSITE,cn,DIRECT
# 其余走代理
- MATCH,Proxy
- MATCH,Proxy