Files
scripts/vaultwarden-backup.sh
2026-10-18 19:01:07 -07:00

110 lines
3.9 KiB
Bash
Executable File

#!/bin/bash
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
# Vaultwarden daily backup to the NetBackup SMB share.
#
# Run daily from bdm's crontab at 03:00. Logging goes to
# /var/log/vaultwarden-backup.log, and a failure is also reported to the
# journal so it is visible without reading the log file.
#
# The privileged steps are four root-owned helpers in /usr/local/sbin, each
# granted to bdm NOPASSWD in /etc/sudoers.d/vaultwarden-backup. Do not replace
# those calls with inline sudo: a sudoers rule that names arguments matches only
# that exact argument list, so anything that varies the arguments falls through
# to a password-required rule and fails from cron, where there is no terminal.
#
# Source of truth for the helpers is scripts/vaultwarden-backup-helpers/.
set -uo pipefail
BACKUP_DIR="/tmp/vaultwarden-backup"
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_FILE="${BACKUP_DIR}/vaultwarden-${TIMESTAMP}.db"
SMB_HOST="192.168.123.2"
SMB_SHARE="NetBackup"
SMB_DIR="Vault"
BACKUP_NAME="vaultwarden-${TIMESTAMP}.db"
# SMB credentials live in an external file (mode 600) rather than inline here,
# so the password never lands in version control or in the process list.
SMB_CRED_FILE="${SMB_CRED_FILE:-$HOME/.smbcredentials}"
# Report to the journal as well as stdout, so a failure is not invisible.
log() {
echo "$(date): $*"
logger -t vaultwarden-backup -- "$*"
}
die() {
log "FATAL - $*"
exit 1
}
if [ ! -r "$SMB_CRED_FILE" ]; then
die "SMB credentials file not readable: ${SMB_CRED_FILE}"
fi
mkdir -p "$BACKUP_DIR" || die "could not create ${BACKUP_DIR}"
log "Starting vaultwarden backup..."
# Consistent snapshot. The database runs in WAL mode with an uncheckpointed
# WAL, so this must be sqlite3 .backup and not a file copy. The helper also
# chowns the result back to this user.
if ! sudo -n /usr/local/sbin/vw-backup-db "$BACKUP_FILE"; then
die "backup step failed"
fi
if [ ! -s "$BACKUP_FILE" ]; then
die "backup file missing or empty: ${BACKUP_FILE}"
fi
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log "Backup created (${SIZE}), uploading to SMB..."
# The backup is worthless if it cannot be read back, so verify it before
# spending bandwidth and before deleting anything.
INTEGRITY=$(sqlite3 "$BACKUP_FILE" "PRAGMA integrity_check;" 2>&1)
if [ "$INTEGRITY" != "ok" ]; then
log "ERROR - backup failed integrity check: ${INTEGRITY}"
rm -f "$BACKUP_FILE"
die "refusing to upload a backup that does not verify"
fi
log "Backup passed integrity check."
# Capture the output and the exit status separately. Piping smbclient into
# "grep -q" would not work: grep exits at the first match, smbclient takes
# SIGPIPE, and the pipeline status would describe grep rather than the upload.
upload_out=$(smbclient "//${SMB_HOST}/${SMB_SHARE}" -A "$SMB_CRED_FILE" \
-c "cd ${SMB_DIR}; put ${BACKUP_FILE} ${BACKUP_NAME}" 2>&1)
upload_rc=$?
printf '%s\n' "$upload_out"
if [ "$upload_rc" -ne 0 ] || ! printf '%s' "$upload_out" | grep -q 'putting file'; then
rm -f "$BACKUP_FILE"
die "SMB upload failed (smbclient exit ${upload_rc})"
fi
rm -f "$BACKUP_FILE"
log "Backup uploaded successfully to //${SMB_HOST}/${SMB_SHARE}/${SMB_DIR}/${BACKUP_NAME}"
# Retention pruning. This is best-effort: a failure here must not be reported
# as a failed backup, because the backup itself already succeeded. The helpers
# fail loudly if the share is not mounted, which is the case worth knowing
# about, so the warning is logged rather than swallowed.
log "Pruning backups older than 60 days..."
if sudo -n /usr/local/sbin/vw-mount-smb; then
if sudo -n /usr/local/sbin/vw-cleanup-old; then
log "Retention prune complete."
else
log "WARNING - pruning failed; retention may not be enforced."
fi
sudo -n /usr/local/sbin/vw-umount-smb || log "WARNING - could not unmount ${BACKUP_DIR}"
else
log "WARNING - could not mount SMB share, skipping retention prune."
fi
log "Backup finished successfully."
exit 0