#!/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