From 1bfd657f99261845c9e0ed08dc366dbedbcd9c87 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 29 Jul 2026 16:41:58 +0800 Subject: [PATCH] =?UTF-8?q?vaultwarden=E7=9A=84=E5=8D=87=E7=BA=A7=E5=92=8C?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E4=BF=AE=E6=94=B9=E4=B8=BA1.37=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 27 -- .github/hooks/rtk-rewrite.json | 12 - vaultwarden/.env.example | 6 +- vaultwarden/README.md | 128 +++++- vaultwarden/deploy.sh | 87 +++- vaultwarden/upgrade.sh | 700 ++++++++++++++++++++++++++++++++ 6 files changed, 901 insertions(+), 59 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/hooks/rtk-rewrite.json create mode 100644 vaultwarden/upgrade.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 54ae7d5..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,27 +0,0 @@ - -# 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 # Run raw (no filtering) but track usage -``` - \ No newline at end of file diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json deleted file mode 100644 index eb2a5a7..0000000 --- a/.github/hooks/rtk-rewrite.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "type": "command", - "command": "rtk hook copilot", - "cwd": ".", - "timeout": 5 - } - ] - } -} diff --git a/vaultwarden/.env.example b/vaultwarden/.env.example index 6029f8e..856c31c 100644 --- a/vaultwarden/.env.example +++ b/vaultwarden/.env.example @@ -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= # ===== 目录与端口 ===== diff --git a/vaultwarden/README.md b/vaultwarden/README.md index 603fb90..af7e61e 100644 --- a/vaultwarden/README.md +++ b/vaultwarden/README.md @@ -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 diff --git a/vaultwarden/deploy.sh b/vaultwarden/deploy.sh index 0e6917b..04ed905 100755 --- a/vaultwarden/deploy.sh +++ b/vaultwarden/deploy.sh @@ -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}" diff --git a/vaultwarden/upgrade.sh b/vaultwarden/upgrade.sh new file mode 100644 index 0000000..c061fc4 --- /dev/null +++ b/vaultwarden/upgrade.sh @@ -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 指定目标版本(如 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" </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 "$@"