701 lines
25 KiB
Bash
701 lines
25 KiB
Bash
#!/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 "$@"
|