#!/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 "$@"