Compare commits
19
Commits
56343db806
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b19b404a | ||
|
|
3fd2c89020 | ||
|
|
0dc80ff144 | ||
|
|
34f698e5fb | ||
|
|
9f1e293d4c | ||
|
|
682b79852d | ||
|
|
22d1aa6a93 | ||
|
|
354ac8a58c | ||
|
|
d19d337bf2 | ||
|
|
dfcaf7e4d0 | ||
|
|
39b0aee2c9 | ||
|
|
920774d7d7 | ||
|
|
29236e8e1c | ||
|
|
938df26cbb | ||
|
|
e8cc0162af | ||
|
|
5195986fc4 | ||
|
|
2afc2cade6 | ||
|
|
81928c6d33 | ||
|
|
1bfd657f99 |
@@ -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 -->
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "rtk hook copilot",
|
||||
"cwd": ".",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+20
@@ -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` 报错——自行维护时区白名单成本高且易过期。
|
||||
@@ -1,3 +1,5 @@
|
||||
data/
|
||||
backups/
|
||||
.env
|
||||
.env.bak-*
|
||||
.upgrade-state
|
||||
|
||||
+83
-30
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,11 @@ SENDS_ALLOWED=true
|
||||
# ===== 镜像配置 =====
|
||||
|
||||
# Vaultwarden 镜像
|
||||
VAULTWARDEN_IMAGE=vaultwarden/server:latest
|
||||
# 留空或填 latest 时,deploy.sh 会自动查询 GitHub 最新 release 并把具体版本号写回这里。
|
||||
# 不要长期使用 latest:该标签只在 docker compose pull 时才重新解析,容器不重建就会
|
||||
# 一直跑旧版本,而且从配置上看不出实际运行的是哪一版。
|
||||
# 后续升级请用 upgrade.sh,它会自动改写此处的版本号。
|
||||
VAULTWARDEN_IMAGE=
|
||||
|
||||
# ===== 目录与端口 =====
|
||||
|
||||
|
||||
+112
-16
@@ -16,7 +16,7 @@ Bitwarden 兼容的自托管密码管理器,轻量、安全、功能完整。
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Vaultwarden | latest | Bitwarden 兼容服务端(Rust 实现) |
|
||||
| Vaultwarden | 固定版本号 | Bitwarden 兼容服务端(Rust 实现),由 `upgrade.sh` 升级 |
|
||||
| SQLite | 内置 | 轻量数据库,无需额外部署 |
|
||||
| Nginx | 系统包 | 反向代理 + HTTPS(Bitwarden 客户端必须 HTTPS) |
|
||||
| Docker | 最新版 | 容器运行环境 |
|
||||
@@ -35,6 +35,7 @@ vaultwarden/
|
||||
├── docker-compose.yml # 容器编排
|
||||
├── .env.example # 配置模板
|
||||
├── deploy.sh # 一键部署脚本
|
||||
├── upgrade.sh # 安全升级脚本(带备份、校验、自动回滚)
|
||||
├── backup.sh # 备份脚本
|
||||
├── uninstall.sh # 完全卸载脚本
|
||||
├── nginx/
|
||||
@@ -97,9 +98,12 @@ bash deploy.sh
|
||||
cd /opt/vaultwarden
|
||||
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
|
||||
vi .env
|
||||
docker compose restart
|
||||
docker compose up -d # 注意:不是 restart
|
||||
```
|
||||
|
||||
> **不要用 `docker compose restart`**。环境变量是在容器**创建**时注入的,`restart` 只是重启
|
||||
> 原容器、不会重建,改了 `.env` 也不会生效。必须用 `up -d` 让 compose 检测到配置变更并重建容器。
|
||||
|
||||
### 第五步:安装客户端
|
||||
|
||||
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
|
||||
@@ -224,23 +228,65 @@ docker compose up -d
|
||||
|
||||
### 升级
|
||||
|
||||
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
|
||||
|
||||
```bash
|
||||
cd /opt/vaultwarden
|
||||
|
||||
# 1. 备份数据
|
||||
bash backup.sh
|
||||
|
||||
# 2. 拉取新镜像
|
||||
docker compose pull
|
||||
|
||||
# 3. 重启
|
||||
docker compose up -d
|
||||
|
||||
# 4. 检查运行状态
|
||||
docker compose ps
|
||||
docker compose logs --tail 20
|
||||
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
|
||||
bash upgrade.sh # 正式升级到 GitHub 最新 release
|
||||
```
|
||||
|
||||
脚本执行流程:
|
||||
|
||||
| 步骤 | 动作 | 失败时 |
|
||||
|------|------|--------|
|
||||
| 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
|
||||
@@ -316,10 +362,24 @@ rm -rf /opt/vaultwarden
|
||||
注册好所有需要的账号后:
|
||||
|
||||
```bash
|
||||
# .env 中设置
|
||||
SIGNUPS_ALLOWED=false
|
||||
cd /opt/vaultwarden
|
||||
sed -i 's/^SIGNUPS_ALLOWED=.*/SIGNUPS_ALLOWED=false/' .env
|
||||
docker compose up -d # 必须 up -d,restart 不生效
|
||||
```
|
||||
|
||||
验证是否真的关闭了(应返回 `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. 限制管理面板访问
|
||||
|
||||
在 `nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
|
||||
@@ -347,6 +407,42 @@ docker compose ps
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
+84
-3
@@ -24,11 +24,72 @@ source "$BASE_DIR/setup.sh"
|
||||
# Vaultwarden 专用函数
|
||||
# =============================================================
|
||||
|
||||
GITHUB_REPO="dani-garcia/vaultwarden"
|
||||
|
||||
# 生成随机密码
|
||||
generate_password() {
|
||||
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() {
|
||||
step "初始化 Vaultwarden 配置"
|
||||
|
||||
@@ -83,6 +144,8 @@ init_env() {
|
||||
log " 域名: ${VAULTWARDEN_DOMAIN}"
|
||||
log " 邮箱: ${CERTBOT_EMAIL}"
|
||||
log " 注册: ${SIGNUPS_ALLOWED:-true}"
|
||||
|
||||
setup_image_version
|
||||
}
|
||||
|
||||
create_dirs() {
|
||||
@@ -95,21 +158,39 @@ create_dirs() {
|
||||
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() {
|
||||
step "启动 Vaultwarden 服务"
|
||||
|
||||
log "正在拉取镜像..."
|
||||
docker compose pull
|
||||
|
||||
# --force-recreate:容器处于 stopped 状态时,up -d 只会把原容器重新 start,
|
||||
# 不会因镜像或环境变量变更而重建,导致「配置改了但没生效」。
|
||||
log "正在启动容器..."
|
||||
docker compose up -d
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
local port="${VAULTWARDEN_PORT:-8080}"
|
||||
log "等待 Vaultwarden 就绪..."
|
||||
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
|
||||
log "Vaultwarden 启动成功!"
|
||||
verify_running_version
|
||||
return
|
||||
fi
|
||||
sleep 2
|
||||
@@ -144,7 +225,7 @@ show_info() {
|
||||
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
|
||||
echo -e "${GREEN}║${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${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
|
||||
echo -e "${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
@@ -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 → 200(Nginx 反代正常)"
|
||||
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
@@ -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
|
||||
|
||||
+193
-6
@@ -52,14 +52,20 @@
|
||||
```
|
||||
vps-xray/
|
||||
├── deploy.sh # 一键部署脚本
|
||||
├── backup.sh # 备份脚本
|
||||
├── uninstall.sh # 完全卸载脚本
|
||||
├── backup.sh # 备份脚本
|
||||
├── upload.sh # 上传脚本(仅上传服务端必要文件)
|
||||
├── .env.example # 配置模板
|
||||
├── README.md # 本文件
|
||||
├── 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
|
||||
|
||||
使用 `upload.sh` 只上传服务端必要文件(`deploy.sh`、`uninstall.sh`、`.env.example`),跳过文档和客户端配置:
|
||||
|
||||
```bash
|
||||
scp -r vps-xray/ root@<VPS_IP>:/opt/vps-xray
|
||||
# 在本地 vps-xray/ 目录下执行
|
||||
bash upload.sh <VPS_IP>
|
||||
```
|
||||
|
||||
### 第二步:登录 VPS 执行部署
|
||||
@@ -94,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>` 从私钥反推。
|
||||
|
||||
### 第三步:客户端配置
|
||||
|
||||
@@ -107,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
|
||||
```
|
||||
|
||||
### 备份
|
||||
@@ -235,7 +422,7 @@ systemctl status xray
|
||||
ss -tlnp | grep 443
|
||||
|
||||
# 查看错误日志
|
||||
journalctl -u xray --tail 50
|
||||
journalctl -u xray -n 50
|
||||
```
|
||||
|
||||
### 速度慢
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+6
-5
@@ -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
|
||||
==========================================================
|
||||
+800
-67
File diff suppressed because it is too large
Load Diff
@@ -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 ]
|
||||
@@ -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-1:OnCalendar 尾随时区是否被 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 ]
|
||||
+38
-5
@@ -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
|
||||
rm -rf "$SCRIPT_DIR"
|
||||
log "已删除部署目录"
|
||||
# 删除前做路径断言,避免 SCRIPT_DIR 异常时误删根目录或系统目录
|
||||
case "$SCRIPT_DIR" in
|
||||
/|/root|/etc|/usr|/var|/home|/opt|"")
|
||||
error "拒绝删除高危路径: '${SCRIPT_DIR}',请手动处理"
|
||||
;;
|
||||
*)
|
||||
cd /
|
||||
rm -rf "$SCRIPT_DIR"
|
||||
log "已删除部署目录: ${SCRIPT_DIR}"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
warn "保留部署目录: ${SCRIPT_DIR}"
|
||||
fi
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user