vaultwarden的升级和部署修改为1.37版本

This commit is contained in:
2026-07-29 16:41:58 +08:00
parent 99e9ec1434
commit 1bfd657f99
6 changed files with 901 additions and 59 deletions
-27
View File
@@ -1,27 +0,0 @@
<!-- rtk-instructions v2 -->
# RTK — Token-Optimized CLI
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
## Rule
Always prefix shell commands with `rtk`:
```bash
# Instead of: Use:
git status rtk git status
git log -10 rtk git log -10
cargo test rtk cargo test
docker ps rtk docker ps
kubectl get pods rtk kubectl pods
```
## Meta commands (use directly)
```bash
rtk gain # Token savings dashboard
rtk gain --history # Per-command savings history
rtk discover # Find missed rtk opportunities
rtk proxy <cmd> # Run raw (no filtering) but track usage
```
<!-- /rtk-instructions -->
-12
View File
@@ -1,12 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "rtk hook copilot",
"cwd": ".",
"timeout": 5
}
]
}
}
+5 -1
View File
@@ -25,7 +25,11 @@ SENDS_ALLOWED=true
# ===== 镜像配置 ===== # ===== 镜像配置 =====
# Vaultwarden 镜像 # Vaultwarden 镜像
VAULTWARDEN_IMAGE=vaultwarden/server:latest # 留空或填 latest 时,deploy.sh 会自动查询 GitHub 最新 release 并把具体版本号写回这里。
# 不要长期使用 latest:该标签只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,而且从配置上看不出实际运行的是哪一版。
# 后续升级请用 upgrade.sh,它会自动改写此处的版本号。
VAULTWARDEN_IMAGE=
# ===== 目录与端口 ===== # ===== 目录与端口 =====
+112 -16
View File
@@ -16,7 +16,7 @@ Bitwarden 兼容的自托管密码管理器,轻量、安全、功能完整。
| 组件 | 版本 | 说明 | | 组件 | 版本 | 说明 |
|------|------|------| |------|------|------|
| Vaultwarden | latest | Bitwarden 兼容服务端(Rust 实现) | | Vaultwarden | 固定版本号 | Bitwarden 兼容服务端(Rust 实现),由 `upgrade.sh` 升级 |
| SQLite | 内置 | 轻量数据库,无需额外部署 | | SQLite | 内置 | 轻量数据库,无需额外部署 |
| Nginx | 系统包 | 反向代理 + HTTPSBitwarden 客户端必须 HTTPS | | Nginx | 系统包 | 反向代理 + HTTPSBitwarden 客户端必须 HTTPS |
| Docker | 最新版 | 容器运行环境 | | Docker | 最新版 | 容器运行环境 |
@@ -35,6 +35,7 @@ vaultwarden/
├── docker-compose.yml # 容器编排 ├── docker-compose.yml # 容器编排
├── .env.example # 配置模板 ├── .env.example # 配置模板
├── deploy.sh # 一键部署脚本 ├── deploy.sh # 一键部署脚本
├── upgrade.sh # 安全升级脚本(带备份、校验、自动回滚)
├── backup.sh # 备份脚本 ├── backup.sh # 备份脚本
├── uninstall.sh # 完全卸载脚本 ├── uninstall.sh # 完全卸载脚本
├── nginx/ ├── nginx/
@@ -97,9 +98,12 @@ bash deploy.sh
cd /opt/vaultwarden cd /opt/vaultwarden
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false # 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
vi .env vi .env
docker compose restart docker compose up -d # 注意:不是 restart
``` ```
> **不要用 `docker compose restart`**。环境变量是在容器**创建**时注入的,`restart` 只是重启
> 原容器、不会重建,改了 `.env` 也不会生效。必须用 `up -d` 让 compose 检测到配置变更并重建容器。
### 第五步:安装客户端 ### 第五步:安装客户端
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/ 1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
@@ -224,23 +228,65 @@ docker compose up -d
### 升级 ### 升级
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
```bash ```bash
cd /opt/vaultwarden cd /opt/vaultwarden
# 1. 备份数据 bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash backup.sh bash upgrade.sh # 正式升级到 GitHub 最新 release
# 2. 拉取新镜像
docker compose pull
# 3. 重启
docker compose up -d
# 4. 检查运行状态
docker compose ps
docker compose logs --tail 20
``` ```
脚本执行流程:
| 步骤 | 动作 | 失败时 |
|------|------|--------|
| 1 | 预检:root、依赖命令、数据目录、磁盘空间 | 直接退出,未动服务 |
| 2 | 拉取新镜像 | 直接退出,**服务零影响**,可安全重试 |
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
| 4 | 停止容器 | 自动拉起原服务 |
| 5 | WAL checkpoint + `integrity_check` + 冷备份整个数据目录 | 自动拉起原服务 |
| 6 | 切换镜像并 `--force-recreate` 重建容器 | 自动回滚 |
| 7 | 校验容器实际镜像 == 目标镜像 | 自动回滚 |
| 8 | 校验 `/alive`、新旧 prelogin 路由、Nginx 反代 | 自动回滚 |
| 9 | 比对升级前后各表行数,任一表减少即判定数据丢失 | 自动回滚 |
常用选项:
```bash
bash upgrade.sh --check # 只检查,零改动
bash upgrade.sh --version 1.37.0 # 升级到指定版本
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
bash upgrade.sh --rollback # 回滚到上次升级前的状态
```
升级完成后请**登录 Web 端确认数据无误**,再清理回滚镜像:
```bash
docker image rm vaultwarden/server:pre-upgrade-<时间戳>
```
### 回滚
```bash
cd /opt/vaultwarden
bash upgrade.sh --rollback
```
回滚会读取 `.upgrade-state` 里记录的备份和镜像锚点,恢复数据目录并切回旧镜像。
> **注意**:回滚会丢弃升级后新增/修改的密码条目。
> 回滚**不会删除**升级后的数据,而是把它改名保留为 `<数据目录>.failed-<时间戳>`,确认无需后再自行删除。
### 为什么不要手动升级
手动 `docker compose pull && docker compose up -d` 有两个坑,都会导致「看起来升级了,其实没有」:
1. **停止状态的容器不会因镜像变更而重建**。如果先 `stop``up -d`compose 只是把原容器重新 `start`,仍然跑旧镜像。必须 `--force-recreate`
2. **环境变量优先级高于 `.env` 文件**。如果当前 shell 里已经 `export``VAULTWARDEN_IMAGE`(比如脚本里 `source .env` 过),改 `.env` 文件无效,compose 依然用旧值。
`upgrade.sh` 处理了这两点,并在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,不匹配直接回滚。
### 停止 / 启动 ### 停止 / 启动
```bash ```bash
@@ -316,10 +362,24 @@ rm -rf /opt/vaultwarden
注册好所有需要的账号后: 注册好所有需要的账号后:
```bash ```bash
# .env 中设置 cd /opt/vaultwarden
SIGNUPS_ALLOWED=false sed -i 's/^SIGNUPS_ALLOWED=.*/SIGNUPS_ALLOWED=false/' .env
docker compose up -d # 必须 up -drestart 不生效
``` ```
验证是否真的关闭了(应返回 `Registration not allowed or user already exists`):
```bash
curl -s -X POST http://127.0.0.1:8080/identity/accounts/register/send-verification-email \
-H 'Content-Type: application/json' \
-d '{"email":"probe@example.invalid","name":"probe"}'
```
> **不要用 `/api/config` 里的 `disableUserRegistration` 判断**。该字段来自
> `is_signup_disabled()`,含义是「是否隐藏 UI 上的注册链接」,而非「注册是否被拒绝」。
> 当 `INVITATIONS_ALLOWED=true` 且未配置 SMTP 时,即使 `SIGNUPS_ALLOWED=false`
> 它也会显示为 `false`(因为管理员邀请的用户仍需走注册流程)。真正的拦截在注册接口里。
### 2. 限制管理面板访问 ### 2. 限制管理面板访问
`nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。 `nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
@@ -347,6 +407,42 @@ docker compose ps
curl http://127.0.0.1:8080/alive curl http://127.0.0.1:8080/alive
``` ```
### 客户端登录报错,控制台 404 /identity/accounts/prelogin/password
**症状**:已登录的会话正常,但重新登录失败,浏览器控制台显示:
```
POST https://vault.example.com/identity/accounts/prelogin/password 404 (Not Found)
```
**原因**:服务端版本过旧。Bitwarden 客户端从 v2026.4.0 起改用 `/identity/accounts/prelogin/password`
这个新路由,而 Vaultwarden 在 **1.36.0** 才实现它。客户端会自动更新,服务端不会。
**确认**
```bash
# 看服务端实到底跑的是哪个版本
curl -s https://vault.example.com/api/config | grep -o '"gitHash":"[^"]*"'
# 直接探测新旧两个路由
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"email":"x@example.com"}' https://vault.example.com/identity/accounts/prelogin/password
```
旧路由返回 200、新路由返回 404,即可确诊。
**修复**:升级到 1.36.0 以上。
```bash
cd /opt/vaultwarden && bash upgrade.sh
```
如果暂时无法升级,可在 Nginx 的 443 server 块内加一条重写作为临时过渡:
```nginx
rewrite ^/identity/accounts/prelogin/password/?$ /identity/accounts/prelogin last;
```
### 502 Bad Gateway ### 502 Bad Gateway
```bash ```bash
+84 -3
View File
@@ -24,11 +24,72 @@ source "$BASE_DIR/setup.sh"
# Vaultwarden 专用函数 # Vaultwarden 专用函数
# ============================================================= # =============================================================
GITHUB_REPO="dani-garcia/vaultwarden"
# 生成随机密码 # 生成随机密码
generate_password() { generate_password() {
openssl rand -base64 32 | tr -d '/+=' | head -c 32 openssl rand -base64 32 | tr -d '/+=' | head -c 32
} }
# 查询 GitHub 上的最新 release 版本号(不含前缀 v),失败返回非零
resolve_latest_version() {
local ver
ver=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
[ -n "$ver" ] || return 1
echo "$ver"
}
# 把镜像固定到具体版本号。
# 必须同时更新 .env 和已导出的环境变量:init_env 里 `set -a; source .env` 已把旧值
# 导出到本进程,而 docker compose 对环境变量的优先级高于 .env 文件,只改文件不生效。
pin_image_version() {
local ref="$1"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$ref" >> .env
fi
export VAULTWARDEN_IMAGE="$ref"
}
# 确定本次部署使用的镜像版本。
# 不使用 latest 标签的原因:它只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,且从配置上看不出实际运行的是哪一版。
setup_image_version() {
local current="${VAULTWARDEN_IMAGE:-}"
# 已固定到具体版本 → 尊重现有配置,升级交给 upgrade.sh
if [ -n "$current" ] && [[ "$current" != *":latest" ]]; then
log " 镜像: ${current}"
return 0
fi
# 已有实例却仍在用 latest:此时固定版本等同于一次升级,
# 而升级必须走 upgrade.sh(带冷备份、完整性校验、自动回滚),这里不擅自处理。
if docker compose ps -a -q vaultwarden 2>/dev/null | grep -q .; then
warn "检测到已存在的实例,且 .env 仍在使用 latest 标签"
warn "latest 不会自动更新,也看不出实际运行版本,建议固定到具体版本号:"
warn " bash upgrade.sh # 安全升级到最新版,并自动写回版本号"
warn "本次部署不改动镜像配置"
return 0
fi
log "正在查询 Vaultwarden 最新版本..."
local ver
if ! ver="$(resolve_latest_version)"; then
warn "无法从 GitHub 获取最新版本号(网络问题?),本次回退使用 latest 标签"
warn "部署完成后建议手动固定: VAULTWARDEN_IMAGE=vaultwarden/server:<版本号>"
[ -n "$current" ] || pin_image_version "vaultwarden/server:latest"
return 0
fi
pin_image_version "vaultwarden/server:${ver}"
log " 镜像: vaultwarden/server:${ver}(当前最新 release,已写入 .env"
}
init_env() { init_env() {
step "初始化 Vaultwarden 配置" step "初始化 Vaultwarden 配置"
@@ -83,6 +144,8 @@ init_env() {
log " 域名: ${VAULTWARDEN_DOMAIN}" log " 域名: ${VAULTWARDEN_DOMAIN}"
log " 邮箱: ${CERTBOT_EMAIL}" log " 邮箱: ${CERTBOT_EMAIL}"
log " 注册: ${SIGNUPS_ALLOWED:-true}" log " 注册: ${SIGNUPS_ALLOWED:-true}"
setup_image_version
} }
create_dirs() { create_dirs() {
@@ -95,21 +158,39 @@ create_dirs() {
log "备份目录: $backup_dir" log "备份目录: $backup_dir"
} }
# 确认容器真的跑在配置指定的镜像上,避免「配置改了但容器没重建」这类静默失败
verify_running_version() {
local cid actual expect="${VAULTWARDEN_IMAGE:-}"
cid=$(docker compose ps -a -q vaultwarden 2>/dev/null | head -1)
[ -n "$cid" ] || return 0
actual=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
if [ -n "$expect" ] && [ "$actual" != "$expect" ]; then
warn "容器实际镜像 (${actual}) 与配置 (${expect}) 不一致"
warn "请检查: docker compose up -d --force-recreate"
return 0
fi
log " 运行镜像: ${actual}"
}
start_services() { start_services() {
step "启动 Vaultwarden 服务" step "启动 Vaultwarden 服务"
log "正在拉取镜像..." log "正在拉取镜像..."
docker compose pull docker compose pull
# --force-recreate:容器处于 stopped 状态时,up -d 只会把原容器重新 start,
# 不会因镜像或环境变量变更而重建,导致「配置改了但没生效」。
log "正在启动容器..." log "正在启动容器..."
docker compose up -d docker compose up -d --force-recreate
local port="${VAULTWARDEN_PORT:-8080}" local port="${VAULTWARDEN_PORT:-8080}"
log "等待 Vaultwarden 就绪..." log "等待 Vaultwarden 就绪..."
local max_wait=30 local max_wait=30
for i in $(seq 1 "$max_wait"); do for _ in $(seq 1 "$max_wait"); do
if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then
log "Vaultwarden 启动成功!" log "Vaultwarden 启动成功!"
verify_running_version
return return
fi fi
sleep 2 sleep 2
@@ -144,7 +225,7 @@ show_info() {
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
echo -e "${GREEN}${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}" echo -e "${GREEN}${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}" echo -e "${GREEN}${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose restart${NC}" echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose up -d(不是 restart${NC}"
fi fi
echo -e "${GREEN}${NC}" echo -e "${GREEN}${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env bash
# -E 让 ERR trap 能在函数内部生效,保证不可逆区间的异常一定触发回滚
set -Eeuo pipefail
# ============================================
# Vaultwarden 安全升级脚本
#
# 设计原则:
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
# 2. 冷备份(停容器 + WAL checkpoint)—— 保证快照一致
# 3. 升级前后比对数据指纹 —— 数据异常自动回滚
# 4. 回滚不删数据 —— 旧数据目录改名保留,不 rm
#
# 用法:
# bash upgrade.sh 升级到 GitHub 最新 release
# bash upgrade.sh --check 只检查,不做任何改动
# bash upgrade.sh --version 1.37.0
# bash upgrade.sh --rollback 回滚到上次升级前的状态
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ===== 输出样式 =====
if [ -t 1 ]; then
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
log() { echo -e "${GREEN}[✓]${NC} $*"; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
# ===== 全局状态 =====
GITHUB_REPO="dani-garcia/vaultwarden"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
MODE="upgrade" # upgrade | check | rollback
TARGET_VERSION=""
ASSUME_YES=0
SAFETY_STAGE="none" # none → stopped → armed → done,决定异常退出时如何兜底
BACKUP_PATH=""
ROLLBACK_TAG=""
OLD_IMAGE_REF=""
COUNTS_BEFORE=""
# =============================================================
# 参数解析
# =============================================================
usage() {
cat <<'EOF'
Vaultwarden 安全升级脚本
用法: bash upgrade.sh [选项]
选项:
-c, --check 只做预检和版本对比,不做任何改动
-v, --version <ver> 指定目标版本(如 1.37.0),默认取 GitHub 最新 release
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state
-y, --yes 跳过交互确认
-h, --help 显示本帮助
示例:
bash upgrade.sh --check # 先看看会发生什么
bash upgrade.sh # 正式升级
bash upgrade.sh --rollback # 升级后发现问题,回滚
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
-c|--check) MODE="check" ;;
-r|--rollback) MODE="rollback" ;;
-y|--yes) ASSUME_YES=1 ;;
-v|--version)
shift
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
TARGET_VERSION="${1#v}"
;;
-h|--help) usage; exit 0 ;;
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
esac
shift
done
}
confirm() {
[ "$ASSUME_YES" -eq 1 ] && return 0
local prompt="$1" answer
echo ""
read -r -p "${prompt} [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# =============================================================
# 环境加载与预检
# =============================================================
load_env() {
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
sed -i 's/\r$//' .env
set -a; source .env; set +a
DATA_DIR="${VAULTWARDEN_DATA_DIR:-/var/lib/vaultwarden}"
BACKUP_BASE="${BACKUP_DIR:-/var/backups/vaultwarden}"
PORT="${VAULTWARDEN_PORT:-8080}"
DOMAIN="${VAULTWARDEN_DOMAIN:-}"
CURRENT_IMAGE="${VAULTWARDEN_IMAGE:-vaultwarden/server:latest}"
DB_FILE="$DATA_DIR/db.sqlite3"
}
preflight() {
step "环境预检"
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
local missing=0
for cmd in docker curl tar; do
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
done
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
[ "$missing" -eq 0 ] || exit 1
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
[ -f "$DB_FILE" ] || { error "数据库不存在: $DB_FILE"; exit 1; }
# 磁盘空间:要求可用空间 ≥ 数据目录的 3 倍(备份 + 解压余量),至少 500MB
mkdir -p "$BACKUP_BASE"
local data_kb avail_kb need_kb
data_kb=$(du -sk "$DATA_DIR" | cut -f1)
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
need_kb=$(( data_kb * 3 ))
[ "$need_kb" -lt 512000 ] && need_kb=512000
if [ "$avail_kb" -lt "$need_kb" ]; then
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,需要 $((need_kb/1024))MB"
exit 1
fi
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
info "数据目录 $DATA_DIR ($((data_kb/1024))MB),可用空间 $((avail_kb/1024))MB"
}
ensure_sqlite3() {
command -v sqlite3 >/dev/null 2>&1 && return 0
warn "sqlite3 未安装,正在安装(用于 WAL checkpoint 和完整性校验)..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sqlite3
elif command -v yum >/dev/null 2>&1; then
yum install -y -q sqlite
else
error "无法自动安装 sqlite3,请手动安装后重试"
exit 1
fi
command -v sqlite3 >/dev/null 2>&1 || { error "sqlite3 安装失败"; exit 1; }
log "sqlite3 已安装"
}
# =============================================================
# 版本解析
# =============================================================
resolve_target_version() {
if [ -n "$TARGET_VERSION" ]; then
info "使用指定版本: $TARGET_VERSION"
return 0
fi
info "查询 GitHub 最新 release..."
TARGET_VERSION=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
if [ -z "$TARGET_VERSION" ]; then
error "无法从 GitHub 获取最新版本(网络问题?)"
error "请手动指定: bash upgrade.sh --version 1.37.0"
exit 1
fi
log "GitHub 最新 release: $TARGET_VERSION"
}
# 从 compose 项目推导容器 ID(而非硬编码容器名),停止状态也能拿到
compose_container_id() {
docker compose ps -a -q vaultwarden 2>/dev/null | head -1 || true
}
# 当前容器实际运行的镜像 ID(sha256:...),拿不到则返回空
running_image_id() {
local cid
cid="$(compose_container_id)"
[ -n "$cid" ] || return 0
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
}
get_running_githash() {
curl -sf --max-time 10 "http://127.0.0.1:${PORT}/api/config" 2>/dev/null \
| grep -o '"gitHash"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"\([^"]*\)"$/\1/' || true
}
# =============================================================
# 数据指纹
# =============================================================
# 统计关键表行数,用于升级前后比对。表不存在时输出 "-"
snapshot_counts() {
local db="$1" t out=""
for t in users ciphers folders sends attachments organizations collections; do
local n
n=$(sqlite3 "$db" "SELECT COUNT(*) FROM $t;" 2>/dev/null || echo "-")
out+="${t}=${n} "
done
echo "${out% }"
}
print_counts() {
local label="$1" counts="$2" kv
echo " ${label}:"
for kv in $counts; do
printf " %-16s %s\n" "${kv%%=*}" "${kv##*=}"
done
}
# =============================================================
# 备份
# =============================================================
cold_backup() {
step "冷备份(服务已停止,快照一致)"
mkdir -p "$BACKUP_BASE"
info "执行 WAL checkpoint,将未落盘数据合并进主库..."
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE);" >/dev/null
log "WAL 已合并"
info "校验数据库完整性..."
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "数据库完整性检查未通过: $integrity"
error "升级已中止,服务未改动。请先修复数据库。"
docker compose up -d
exit 1
fi
log "数据库完整性: ok"
COUNTS_BEFORE="$(snapshot_counts "$DB_FILE")"
print_counts "升级前数据统计" "$COUNTS_BEFORE"
BACKUP_PATH="${BACKUP_BASE}/pre-upgrade-${TIMESTAMP}.tar.gz"
info "打包整个数据目录 → $BACKUP_PATH"
tar czf "$BACKUP_PATH" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
# 校验备份包。注意:这里先把清单收进变量再用 here-string 匹配,
# 不能写成 `tar tzf ... | grep -q`grep -q 命中即退出会让 tar 收到
# SIGPIPE,在 pipefail 下整条管道被判为失败,导致好备份被误判成坏的。
info "校验备份包..."
local listing
listing="$(tar tzf "$BACKUP_PATH")" || { error "备份包损坏,无法列出内容"; exit 1; }
grep -q 'db\.sqlite3$' <<<"$listing" || { error "备份包中没有数据库文件"; exit 1; }
grep -q 'rsa_key\.pem$' <<<"$listing" || warn "备份包中没有 rsa_key.pem"
# 同时备份 .env(含 ADMIN_TOKEN
cp .env "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
chmod 600 "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
log "备份完成: $BACKUP_PATH ($(du -h "$BACKUP_PATH" | cut -f1))"
}
# =============================================================
# 镜像与容器
# =============================================================
tag_rollback_image() {
step "锚定当前镜像(用于回滚)"
local image_id
image_id="$(running_image_id)"
if [ -z "$image_id" ]; then
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
fi
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
ROLLBACK_TAG="vaultwarden/server:pre-upgrade-${TIMESTAMP}"
docker tag "$image_id" "$ROLLBACK_TAG"
OLD_IMAGE_REF="$CURRENT_IMAGE"
log "当前镜像已标记为 $ROLLBACK_TAG"
info "镜像 ID: ${image_id#sha256:}"
}
pull_target_image() {
step "拉取目标镜像(此步骤不影响运行中的服务)"
local target="vaultwarden/server:${TARGET_VERSION}"
info "docker pull $target ..."
if ! docker pull "$target"; then
error "镜像拉取失败,服务未做任何改动,可安全重试"
exit 1
fi
log "镜像已就绪: $target"
}
switch_image_in_env() {
local new_ref="$1"
cp .env ".env.bak-${TIMESTAMP}"
chmod 600 ".env.bak-${TIMESTAMP}"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${new_ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$new_ref" >> .env
fi
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已经把旧值导出到
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
export VAULTWARDEN_IMAGE="$new_ref"
info ".env 中 VAULTWARDEN_IMAGE → $new_ref"
}
wait_healthy() {
local max_wait="${1:-60}" i
info "等待服务就绪(最多 ${max_wait}s..."
for i in $(seq 1 "$max_wait"); do
if curl -sf --max-time 3 "http://127.0.0.1:${PORT}/alive" >/dev/null 2>&1; then
log "服务已就绪(${i}s"
return 0
fi
sleep 1
done
return 1
}
# =============================================================
# 验证
# =============================================================
# 确认容器真的跑在目标镜像上,防止「改了配置但容器没重建」这类静默失败
verify_running_image() {
local expect="$1" cid actual_ref actual_id expect_id
cid="$(compose_container_id)"
[ -n "$cid" ] || { error "找不到容器"; return 1; }
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
if [ "$actual_id" != "$expect_id" ]; then
error "容器未运行在目标镜像上"
error " 期望: $expect ($expect_id)"
error " 实际: $actual_ref ($actual_id)"
return 1
fi
log "容器镜像确认: $actual_ref"
return 0
}
# $1: strict(默认) —— 要求新版 prelogin 路由存在(升级后)
# lenient —— 该路由 404 属正常(回滚到 1.36.0 之前的版本后)
verify_endpoints() {
local strict="${1:-strict}"
step "接口验证"
local failed=0
# 1. /alive
if curl -sf --max-time 5 "http://127.0.0.1:${PORT}/alive" >/dev/null; then
log "/alive 正常"
else
error "/alive 无响应"; failed=1
fi
# 2. 新版 prelogin 路由(1.36.0+ 才有,是本次升级的核心目标)
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin/password" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin/password → 200(新版客户端可登录)"
elif [ "$strict" = "lenient" ]; then
info "/identity/accounts/prelogin/password → $code(该版本无此路由,回滚后属预期)"
else
error "/identity/accounts/prelogin/password → $code(期望 200"; failed=1
fi
# 3. 旧版 prelogin 路由(兼容老客户端)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin → 200(旧客户端兼容)"
else
warn "/identity/accounts/prelogin → $code"
fi
# 4. 经 Nginx 的外部访问(失败只告警,属于代理层问题,不触发回滚)
if [ -n "$DOMAIN" ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
"https://${DOMAIN}/api/config" || echo "000")
if [ "$code" = "200" ]; then
log "https://${DOMAIN}/api/config → 200Nginx 反代正常)"
else
warn "https://${DOMAIN}/api/config → $code(检查 Nginx/证书,不影响本次升级判定)"
fi
fi
return $failed
}
verify_data_integrity() {
step "数据完整性验证"
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "升级后数据库完整性检查失败: $integrity"
return 1
fi
log "数据库完整性: ok"
local counts_after
counts_after="$(snapshot_counts "$DB_FILE")"
print_counts "升级后数据统计" "$counts_after"
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
local kv table before after lost=0
for kv in $COUNTS_BEFORE; do
table="${kv%%=*}"; before="${kv##*=}"
after=$(echo "$counts_after" | tr ' ' '\n' | grep "^${table}=" | cut -d= -f2 || true)
# 升级前该表就不存在 / 升级后读不到 → 跳过比对
if [ "$before" = "-" ] || [ -z "$after" ] || [ "$after" = "-" ]; then
continue
fi
if [ "$after" -lt "$before" ]; then
error "$table 行数减少: $before$after"
lost=1
fi
done
if [ "$lost" -eq 1 ]; then
error "检测到数据丢失!"
return 1
fi
log "所有表行数无减少,数据完好"
return 0
}
# =============================================================
# 回滚
# =============================================================
save_state() {
cat > "$STATE_FILE" <<EOF
# Vaultwarden 升级状态记录 —— 由 upgrade.sh 自动生成
UPGRADE_TIMESTAMP=${TIMESTAMP}
BACKUP_PATH=${BACKUP_PATH}
ROLLBACK_TAG=${ROLLBACK_TAG}
OLD_IMAGE_REF=${OLD_IMAGE_REF}
NEW_IMAGE_REF=vaultwarden/server:${TARGET_VERSION}
DATA_DIR=${DATA_DIR}
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
EOF
chmod 600 "$STATE_FILE"
}
# 恢复数据目录:旧目录改名保留,绝不 rm
restore_data_dir() {
local backup_tar="$1" ts="$2"
local parent failed_dir
parent="$(dirname "$DATA_DIR")"
failed_dir="${DATA_DIR}.failed-${ts}"
if [ -d "$DATA_DIR" ]; then
mv "$DATA_DIR" "$failed_dir"
warn "升级后的数据目录已保留为: $failed_dir(未删除)"
fi
tar xzf "$backup_tar" -C "$parent"
[ -f "$DB_FILE" ] || { error "恢复失败:$DB_FILE 不存在"; return 1; }
log "数据目录已从备份恢复"
}
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 未改动,直接拉起原版本即可
emergency_start_old() {
step "紧急恢复"
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
docker compose up -d || true
if wait_healthy 60; then
log "原服务已恢复运行,本次升级未造成任何影响"
else
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
error "查看日志: docker compose logs --tail 100"
fi
}
# 统一的退出兜底:确保任何异常路径都不会把服务丢在停机状态
on_exit() {
local code=$?
trap - EXIT ERR
[ "$code" -eq 0 ] && return 0
case "$SAFETY_STAGE" in
stopped) emergency_start_old ;;
armed) do_rollback "脚本异常中止(退出码 $code" ;;
*) : ;; # 尚未动过服务,无需处理
esac
return 0
}
do_rollback() {
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
SAFETY_STAGE="done"
set +e
local reason="$1"
step "自动回滚"
error "触发原因: $reason"
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$TIMESTAMP" || {
error "自动恢复失败!请手动处理:"
error " tar xzf $BACKUP_PATH -C $(dirname "$DATA_DIR")"
exit 1
}
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成,已恢复到升级前状态"
else
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100"
fi
echo ""
warn "升级失败已回滚。备份保留在: $BACKUP_PATH"
exit 1
}
manual_rollback() {
step "手动回滚"
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
# shellcheck disable=SC1090
source "$STATE_FILE"
DB_FILE="$DATA_DIR/db.sqlite3" # 状态文件里的 DATA_DIR 可能与 .env 不同,重新推导
echo ""
info "将回滚到:"
info " 镜像: $ROLLBACK_TAG"
info " 备份: $BACKUP_PATH"
info " 时间: $UPGRADE_TIMESTAMP"
echo ""
[ -f "$BACKUP_PATH" ] || { error "备份文件不存在: $BACKUP_PATH"; exit 1; }
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
warn "回滚会丢弃升级后产生的所有数据变更(新增/修改的密码条目)"
confirm "确认回滚?" || { info "已取消"; exit 0; }
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$(date +%Y%m%d_%H%M%S)" || exit 1
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成"
verify_endpoints lenient || true
else
error "服务未就绪,请检查: docker compose logs --tail 100"
exit 1
fi
}
# =============================================================
# 主流程
# =============================================================
show_plan() {
local githash
githash="$(get_running_githash)"
echo ""
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
echo " 域名: ${DOMAIN:-未配置}"
echo " 当前镜像: $CURRENT_IMAGE"
echo " 当前 gitHash: ${githash:-未知(服务未响应?)}"
echo " 目标版本: vaultwarden/server:${TARGET_VERSION}"
echo " 数据目录: $DATA_DIR"
echo " 备份目录: $BACKUP_BASE"
echo ""
echo " 执行步骤:"
echo " 1. 锚定当前镜像为回滚点"
echo " 2. 拉取新镜像(失败则零影响退出)"
echo " 3. 停止容器"
echo " 4. WAL checkpoint + 完整性校验 + 冷备份"
echo " 5. 切换镜像并启动"
echo " 6. 验证接口 + 比对数据行数"
echo " 7. 任一验证失败 → 自动回滚"
echo -e "${BOLD}═════════════════════════════${NC}"
}
main() {
parse_args "$@"
echo -e "${CYAN}${BOLD}"
echo " Vaultwarden 安全升级"
echo -e "${NC}"
load_env
if [ "$MODE" = "rollback" ]; then
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
manual_rollback
exit 0
fi
preflight
resolve_target_version
show_plan
# 已是目标版本?
local cur_image_id target_image_id
cur_image_id="$(running_image_id)"
target_image_id=$(docker image inspect "vaultwarden/server:${TARGET_VERSION}" \
--format '{{.Id}}' 2>/dev/null || true)
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
echo ""
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
if [ "$MODE" = "check" ]; then
exit 0
fi
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
fi
if [ "$MODE" = "check" ]; then
echo ""
info "--check 模式,未做任何改动"
exit 0
fi
confirm "确认开始升级?" || { info "已取消"; exit 0; }
ensure_sqlite3
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
pull_target_image
tag_rollback_image
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
trap on_exit EXIT
trap 'exit 1' ERR
step "停止容器"
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
docker compose stop
log "容器已停止"
cold_backup
save_state
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
step "启动新版本"
switch_image_in_env "vaultwarden/server:${TARGET_VERSION}"
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
docker compose up -d --force-recreate
if ! wait_healthy 90; then
docker compose logs --tail 40 || true
do_rollback "服务在 90 秒内未就绪"
fi
if ! verify_running_image "vaultwarden/server:${TARGET_VERSION}"; then
do_rollback "容器未运行在目标镜像上"
fi
if ! verify_endpoints; then
do_rollback "接口验证失败"
fi
if ! verify_data_integrity; then
do_rollback "数据完整性验证失败"
fi
# ===== 成功 =====
SAFETY_STAGE="done"
trap - EXIT ERR
step "升级成功"
local new_githash
new_githash="$(get_running_githash)"
echo ""
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
echo " 运行版本: vaultwarden/server:${TARGET_VERSION}"
echo " gitHash: ${new_githash:-未知}"
echo " 备份文件: $BACKUP_PATH"
echo " 回滚镜像: $ROLLBACK_TAG"
echo ""
echo " 如需回滚: bash upgrade.sh --rollback"
echo " 查看日志: docker compose logs --tail 50"
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
echo ""
warn "请登录 Web 端确认数据无误后,再考虑清理旧镜像:"
warn " docker image rm $ROLLBACK_TAG"
}
main "$@"