Fix the vaultwarden backup failing silently for nine days
Every nightly run had been failing since 20 Sep, writing the reason only to
a log file nobody reads. The last backup on the share was from 19 Sep.
Root cause: a sudoers rule that names arguments matches only that exact
argument list. The rule allowed
sudo /usr/bin/sqlite3 /var/.../db.sqlite3
but the script ran
sudo sqlite3 /var/.../db.sqlite3 ".backup '/tmp/.../db'"
The second argument was not in the rule, so it did not match, sudo fell
through to the password-required bdm ALL=(ALL) ALL, and cron has no
terminal to prompt on. Reproduced with -n, so neither call could prompt:
sudo -n sqlite3 <db> # 1 arg, matches -> exit 0
sudo -n sqlite3 <db> "SELECT 1" # 2 args, no match -> password required
Two further problems were hidden behind that early exit:
The cleanup phase called sudo mount/umount/find inline, but sudoers only
whitelisted helper scripts that existed and were never invoked. The 60-day
retention was dead too. The old cleanup also could not distinguish "share
not mounted" from "nothing to delete": find matched nothing and the script
exited 0 reporting a healthy run.
The helpers that *were* whitelisted lived under /home/bdm and are owned by
bdm, so the grant was equivalent to unrestricted root.
Changes:
Move each privileged step into a root-owned helper in /usr/local/sbin,
deployed from the repo as source of truth. This avoids depending on fragile
argument globs and lets the rule constrain the one argument that varies.
vw-backup-db re-validates the destination path itself rather than trusting
the sudoers pattern.
Call the helpers from the script and drop the inline sudo. Verify the
snapshot with PRAGMA integrity_check before uploading, and abort rather
than shipping a copy that does not verify. Check smbclient's exit status
directly instead of piping into grep, which would have let SIGPIPE mask a
failed upload.
Add a systemd staleness watchdog so this cannot hide again. It mounts the
share read-only and separates STALE (nothing recent) from CANNOT VERIFY
(could not mount), which mean different things.
Also chmod 0440 /etc/sudoers.d/bdm, which visudo was warning about.
Tested with a cold sudo cache, since timestamp_type=global means an
interactive sudo -v otherwise masks the failure. A real cron invocation
succeeded end to end: snapshot, integrity check, upload, and prune.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,15 +41,111 @@ Use `export-to-json.sh` and `import-from-json.sh` for individual user vaults:
|
||||
| `restore-sqlite.sh` | Restore entire instance from SQLite backup | No |
|
||||
| `export-to-json.sh` | Export single user vault to JSON | **Yes** |
|
||||
| `import-from-json.sh` | Import JSON vault to user account | **Yes** |
|
||||
| `mount-smb.sh` | Mount SMB share for cleanup (internal) | No |
|
||||
| `umount-smb.sh` | Unmount SMB share (internal) | No |
|
||||
| `cleanup-old-backups.sh` | Delete backups older than 60 days (internal) | No |
|
||||
| `vw-backup-db` | Take the consistent SQLite snapshot (root helper) | No |
|
||||
| `mount-smb.sh` | Mount SMB share for cleanup (root helper) | No |
|
||||
| `umount-smb.sh` | Unmount SMB share (root helper) | No |
|
||||
| `cleanup-old-backups.sh` | Delete backups older than 60 days (root helper) | No |
|
||||
| `vw-backup-check` | Staleness watchdog (root helper, run by systemd) | No |
|
||||
|
||||
## Privileged Helpers
|
||||
|
||||
The four helpers that need root are kept in this directory as the **source of
|
||||
truth** and deployed to `/usr/local/sbin/`, owned by `root:root`, mode 755:
|
||||
|
||||
| Source (here) | Deployed to |
|
||||
|---|---|
|
||||
| `vw-backup-db` | `/usr/local/sbin/vw-backup-db` |
|
||||
| `mount-smb.sh` | `/usr/local/sbin/vw-mount-smb` |
|
||||
| `umount-smb.sh` | `/usr/local/sbin/vw-umount-smb` |
|
||||
| `cleanup-old-backups.sh` | `/usr/local/sbin/vw-cleanup-old` |
|
||||
| `vw-backup-check` | `/usr/local/sbin/vw-backup-check` |
|
||||
|
||||
They must stay root-owned. sudoers runs a script *as root*, so a NOPASSWD grant
|
||||
pointing at a file that `bdm` can rewrite is equivalent to granting unrestricted
|
||||
root. The previous sudoers file pointed at scripts under `/home/bdm`, which
|
||||
`bdm` owns.
|
||||
|
||||
To deploy after editing:
|
||||
|
||||
```bash
|
||||
cd /home/bdm/scripts/vaultwarden-backup-helpers
|
||||
sudo install -o root -g root -m 0755 vw-backup-db /usr/local/sbin/vw-backup-db
|
||||
sudo install -o root -g root -m 0755 mount-smb.sh /usr/local/sbin/vw-mount-smb
|
||||
sudo install -o root -g root -m 0755 umount-smb.sh /usr/local/sbin/vw-umount-smb
|
||||
sudo install -o root -g root -m 0755 cleanup-old-backups.sh /usr/local/sbin/vw-cleanup-old
|
||||
sudo install -o root -g root -m 0755 vw-backup-check /usr/local/sbin/vw-backup-check
|
||||
```
|
||||
|
||||
## Why the work is done in helpers, not inline sudo
|
||||
|
||||
A sudoers rule that names arguments matches **only that exact argument list**.
|
||||
|
||||
This was the cause of a silent nine-day backup outage. The rule was:
|
||||
|
||||
```
|
||||
bdm ALL=(ALL) NOPASSWD: /usr/bin/sqlite3 /var/.../db.sqlite3
|
||||
```
|
||||
|
||||
but the script ran `sudo sqlite3 /var/.../db.sqlite3 ".backup '/tmp/...db'"` -
|
||||
a second argument the rule did not name. The rule did not match, sudo fell
|
||||
through to the password-required `bdm ALL=(ALL) ALL`, and cron has no terminal,
|
||||
so every nightly run failed with `sudo: a password is required` and wrote the
|
||||
failure only to a log nobody read.
|
||||
|
||||
Demonstrating it, both with `-n` so neither can prompt:
|
||||
|
||||
```bash
|
||||
sudo -n /usr/bin/sqlite3 /var/.../db.sqlite3 # 1 arg, matches -> works
|
||||
sudo -n /usr/bin/sqlite3 /var/.../db.sqlite3 "SELECT 1;" # 2 args, no match -> password required
|
||||
```
|
||||
|
||||
Putting each privileged step in a root-owned helper avoids depending on
|
||||
fragile argument globs, and lets the sudoers rule constrain the one argument
|
||||
that varies. The `vw-backup-db` rule is still argument-constrained, so it cannot
|
||||
be used to write files outside the backup directory:
|
||||
|
||||
```
|
||||
bdm ALL=(ALL) NOPASSWD: /usr/local/sbin/vw-backup-db /tmp/vaultwarden-backup/vaultwarden-*.db
|
||||
```
|
||||
|
||||
Note the filename must match `vaultwarden-*.db` or sudo will not match the rule
|
||||
and will fall back to asking for a password.
|
||||
|
||||
**Never replace these helper calls with inline `sudo` in the script.** That is
|
||||
what broke it.
|
||||
|
||||
## Staleness Watchdog
|
||||
|
||||
A backup that fails silently is worse than one that fails loudly, so a systemd
|
||||
timer checks the share every day and fails visibly.
|
||||
|
||||
- **Timer:** `vw-backup-check.timer`, daily at 10:30 + up to 10 min jitter, `Persistent=true` so a missed run catches up
|
||||
- **Service:** `vw-backup-check.service`, runs as root, no sudo involved
|
||||
- **Alarm threshold:** 2 days
|
||||
|
||||
It mounts the share **read-only** and reports the three states separately, which
|
||||
matters because they mean different things:
|
||||
|
||||
| Exit | State | Meaning |
|
||||
|---|---|---|
|
||||
| 0 | `OK` | Newest backup is under 2 days old |
|
||||
| 1 | `STALE` | Newest backup is 2+ days old, or there are no backups at all |
|
||||
| 2 | `CANNOT VERIFY` | The share could not be mounted, so freshness is unknown |
|
||||
|
||||
Check it by hand:
|
||||
|
||||
```bash
|
||||
systemctl status vw-backup-check.service
|
||||
journalctl -t vaultwarden-backup-check -n 20
|
||||
sudo systemctl start vw-backup-check.service # run it now
|
||||
```
|
||||
|
||||
## Backup Location
|
||||
|
||||
- **SMB Share:** `//192.168.123.2/NetBackup/Vault/`
|
||||
- **Local Temp:** `/tmp/vaultwarden-backup/` (cleaned up after upload)
|
||||
- **Log File:** `/var/log/vaultwarden-backup.log`
|
||||
- **Journal:** `journalctl -t vaultwarden-backup` (the log file is append-only and nobody reads it; the journal is the one to check)
|
||||
|
||||
## Cron Schedule
|
||||
|
||||
@@ -67,10 +163,38 @@ To run a manual backup:
|
||||
|
||||
## Sudo Configuration
|
||||
|
||||
The backup script uses sudo for specific commands. Sudoers rules are in `/etc/sudoers.d/vaultwarden-backup`:
|
||||
- `sqlite3` backup command
|
||||
- `chown` for temp files
|
||||
- SMB mount/umount/cleanup wrapper scripts
|
||||
Sudoers rules are in `/etc/sudoers.d/vaultwarden-backup` and grant passwordless
|
||||
root for exactly four root-owned helpers in `/usr/local/sbin/`: the SQLite
|
||||
snapshot, the SMB mount, the SMB unmount, and the retention prune.
|
||||
|
||||
The old rules for a bare `sqlite3` call and for `chown` are gone. The sqlite3
|
||||
rule could not match the two-argument call the script actually makes, and
|
||||
`chown` is no longer needed because `vw-backup-db` hands the file back to the
|
||||
calling user using `SUDO_UID`/`SUDO_GID`.
|
||||
|
||||
Validate after any edit - `visudo -c` catches a bad rule before it can lock you
|
||||
out, and `/etc/sudoers.d/*.bak` is not read by sudo, so a broken file can simply
|
||||
be removed:
|
||||
|
||||
```bash
|
||||
sudo visudo -c -f /path/to/newfile # check before installing
|
||||
sudo install -o root -g root -m 0440 /path/to/newfile /etc/sudoers.d/vaultwarden-backup
|
||||
sudo visudo -c # then check the whole config
|
||||
```
|
||||
|
||||
Two pre-existing quirks in this system's sudo setup are worth knowing about,
|
||||
because they explain confusing behaviour:
|
||||
|
||||
- `/etc/sudoers.d/010_pi-nopasswd` contains `bdm ALL=(ALL) NOPASSWD: ALL`, which
|
||||
looks like blanket passwordless root. It is **inert**: sudo applies the *last*
|
||||
matching rule, and `bdm ALL=(ALL) ALL` in `sudoers.d/bdm` sorts after it and
|
||||
wins. Left alone deliberately - reordering would actually grant the blanket
|
||||
access that the file only appears to.
|
||||
- `Defaults timestamp_type=global` in `010_global-tty` means a `sudo -v` in any
|
||||
terminal is reused by *all* of `bdm`'s sessions, including cron, for 15
|
||||
minutes. So `sudo` can appear to work from a script when it would actually
|
||||
fail on an unattended run. `sudo -k` clears it, which is the only honest way
|
||||
to test a NOPASSWD path.
|
||||
|
||||
## Disaster Recovery Procedure
|
||||
|
||||
@@ -105,6 +229,37 @@ The backup script uses sudo for specific commands. Sudoers rules are in `/etc/su
|
||||
- Check volume path: `ls -la /var/lib/docker/volumes/caddy_vaultwarden_data/_data/`
|
||||
- Run manually to see errors: `/home/bdm/scripts/vaultwarden-backup.sh`
|
||||
|
||||
### Backup fails with "sudo: a password is required"
|
||||
The NOPASSWD rule did not match. Almost always an argument mismatch - see
|
||||
[Why the work is done in helpers](#why-the-work-is-done-in-helpers-not-inline-sudo).
|
||||
Check what the rule expects against what the script passes:
|
||||
|
||||
```bash
|
||||
sudo -n -l # effective rules for bdm
|
||||
sudo -k # clear the cached credential first
|
||||
sudo -n /usr/local/sbin/vw-backup-db /tmp/vaultwarden-backup/vaultwarden-probe.db
|
||||
```
|
||||
|
||||
If that probe works with a cold cache, the grant is fine and the problem is in
|
||||
the script's arguments. If it fails, the sudoers file is wrong or was not
|
||||
installed.
|
||||
|
||||
### Backup succeeds but nothing is on the share
|
||||
Compare the two sides. The helper verifies integrity before uploading, so a
|
||||
successful run with no file on the NAS points at the upload rather than the
|
||||
snapshot:
|
||||
|
||||
```bash
|
||||
tail -20 /var/log/vaultwarden-backup.log
|
||||
smbclient //192.168.123.2/NetBackup -A "$HOME/.smbcredentials" -c 'cd Vault; ls'
|
||||
```
|
||||
|
||||
### The watchdog alarms but backups look fine
|
||||
Exit 2 (`CANNOT VERIFY`) means the share could not be mounted, so the check
|
||||
could not read anything - that is a NAS or credential problem, not a backup
|
||||
problem. Exit 1 (`STALE`) means it mounted the share and genuinely found nothing
|
||||
recent.
|
||||
|
||||
### SMB upload fails
|
||||
- Check network connectivity: `ping 192.168.123.2`
|
||||
- Check the SMB credentials file is present and readable: `ls -l ~/.smbcredentials`
|
||||
@@ -142,4 +297,14 @@ mount -t cifs` calls, which run as root. The two are independent.
|
||||
- SMB credentials are kept in `~/.smbcredentials` (mode 600), not in the scripts
|
||||
- JSON exports contain plaintext passwords - encrypt them if storing long-term
|
||||
- SQLite backups contain encrypted data - safe to store but treat as sensitive
|
||||
- Master passwords are never stored - only used interactively or via env var
|
||||
- Master passwords are never stored - only used interactively or via env var
|
||||
- Every NOPASSWD grant points at a root-owned helper in `/usr/local/sbin/`, never
|
||||
at a file the `bdm` account can edit. A sudoers grant to a user-writable script
|
||||
is unrestricted root in practice.
|
||||
- `vw-backup-db` re-checks the destination path itself rather than trusting the
|
||||
sudoers argument glob, so the grant stays constrained even if the rule is
|
||||
broadened later
|
||||
- The database is in **WAL mode** with a large uncheckpointed WAL. `cp` would
|
||||
capture stale data; only `sqlite3 .backup` produces a consistent snapshot. The
|
||||
backup is also `PRAGMA integrity_check`ed before upload, and a failure there
|
||||
aborts rather than shipping a bad copy.
|
||||
@@ -1,3 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Clean up old vaultwarden backups from SMB share
|
||||
find "/mnt/netbackup/Vault" -name "vaultwarden-*.db" -mtime +60 -type f -print -delete 2>/dev/null | wc -l
|
||||
# Delete vaultwarden backups older than the retention window.
|
||||
#
|
||||
# Run as root via a NOPASSWD sudoers rule, because the share is mounted as root.
|
||||
# Install to /usr/local/sbin/vw-cleanup-old owned by root, mode 755.
|
||||
#
|
||||
# The retention window is fixed here rather than taken as an argument, so this
|
||||
# grant cannot be used to delete anything outside the named pattern.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MNT="/mnt/netbackup"
|
||||
KEEP_DAYS=60
|
||||
|
||||
# Refuse to delete anything if the share is not mounted. Without this check the
|
||||
# path is a plain empty directory, find matches nothing, and the script exits 0
|
||||
# reporting "deleted 0" - which is indistinguishable from a healthy run. That
|
||||
# is how the retention policy could fail silently.
|
||||
if ! mountpoint -q "$MNT"; then
|
||||
echo "vw-cleanup-old: ${MNT} is not mounted, refusing to run" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DIR="${MNT}/Vault"
|
||||
|
||||
if [ ! -d "$DIR" ]; then
|
||||
echo "vw-cleanup-old: ${DIR} does not exist on the share" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -delete implies -depth, so directory contents are handled before the
|
||||
# directory itself. The pattern is pinned to this share and this filename
|
||||
# shape; nothing else on the share can match.
|
||||
find "$DIR" -maxdepth 1 -type f -name 'vaultwarden-*.db' -mtime "+${KEEP_DAYS}" -print -delete
|
||||
|
||||
echo "vw-cleanup-old: retention ${KEEP_DAYS} days enforced"
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Mount SMB share for vaultwarden backup cleanup
|
||||
mkdir -p /mnt/netbackup
|
||||
if ! mountpoint -q /mnt/netbackup; then
|
||||
mount -t cifs "//192.168.123.2/NetBackup" /mnt/netbackup \
|
||||
-o credentials=/root/.smbcredentials,iocharset=utf8,vers=3.0,uid=1000,gid=1000,file_mode=0666,dir_mode=0777 2>/dev/null
|
||||
fi
|
||||
# Mount the NetBackup SMB share so old backups can be pruned.
|
||||
#
|
||||
# Run as root via a NOPASSWD sudoers rule. The mount has to be done as root
|
||||
# because /root/.smbcredentials is root-only.
|
||||
#
|
||||
# Install to /usr/local/sbin/vw-mount-smb owned by root, mode 755.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MNT="/mnt/netbackup"
|
||||
SMB_HOST="192.168.123.2"
|
||||
SMB_SHARE="NetBackup"
|
||||
CRED="/root/.smbcredentials"
|
||||
|
||||
if [ ! -f "$CRED" ]; then
|
||||
echo "vw-mount-smb: credentials missing at ${CRED}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$MNT"
|
||||
|
||||
if mountpoint -q "$MNT"; then
|
||||
echo "vw-mount-smb: already mounted"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Default to the invoking user's ids rather than hardcoding 1000.
|
||||
uid="${SUDO_UID:-0}"
|
||||
gid="${SUDO_GID:-0}"
|
||||
|
||||
mount -t cifs "//${SMB_HOST}/${SMB_SHARE}" "$MNT" \
|
||||
-o "credentials=${CRED},iocharset=utf8,vers=3.0,uid=${uid},gid=${gid},file_mode=0666,dir_mode=0777"
|
||||
|
||||
if ! mountpoint -q "$MNT"; then
|
||||
echo "vw-mount-smb: mount did not take effect" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Unmount SMB share for vaultwarden backup cleanup
|
||||
umount /mnt/netbackup 2>/dev/null
|
||||
# Unmount the NetBackup SMB share.
|
||||
#
|
||||
# Run as root via a NOPASSWD sudoers rule. Install to /usr/local/sbin/vw-umount-smb
|
||||
# owned by root, mode 755.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MNT="/mnt/netbackup"
|
||||
|
||||
if ! mountpoint -q "$MNT"; then
|
||||
echo "vw-umount-smb: not mounted, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umount "$MNT"
|
||||
|
||||
65
vaultwarden-backup-helpers/vw-backup-check
Executable file
65
vaultwarden-backup-helpers/vw-backup-check
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Fail loudly when the vaultwarden backup has gone stale.
|
||||
#
|
||||
# The backup job itself is only visible by reading a log file, which is how a
|
||||
# nine-day outage went unnoticed. This runs from a systemd timer as root, and
|
||||
# exits non-zero when the newest backup on the share is older than the
|
||||
# threshold, so the failure shows up in "systemctl status" and the journal.
|
||||
#
|
||||
# It covers both ways the backup can stop: the job failing, and the job not
|
||||
# running at all.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
THRESHOLD_DAYS=2
|
||||
MNT="/mnt/netbackup-check"
|
||||
SMB_HOST="192.168.123.2"
|
||||
SMB_SHARE="NetBackup"
|
||||
CRED="/root/.smbcredentials"
|
||||
TAG="vaultwarden-backup-check"
|
||||
|
||||
note() { logger -t "$TAG" -- "$*"; echo "$*"; }
|
||||
|
||||
cleanup() {
|
||||
if mountpoint -q "$MNT"; then
|
||||
umount "$MNT" 2>/dev/null || true
|
||||
fi
|
||||
rmdir "$MNT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ ! -f "$CRED" ]; then
|
||||
note "STALE CHECK FAILED - credentials missing at ${CRED}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$MNT"
|
||||
|
||||
# Read-only, and not a system mount, so an unreachable NAS cannot wedge the
|
||||
# system. A failure to mount is reported as "cannot verify", not as "stale",
|
||||
# because those mean different things.
|
||||
if ! mount -t cifs "//${SMB_HOST}/${SMB_SHARE}" "$MNT" \
|
||||
-o "credentials=${CRED},ro,iocharset=utf8,vers=3.0" 2>/dev/null; then
|
||||
note "CANNOT VERIFY - could not mount //${SMB_HOST}/${SMB_SHARE} to check backup freshness"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
newest=$(find "$MNT/Vault" -maxdepth 1 -type f -name 'vaultwarden-*.db' -printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
|
||||
if [ -z "$newest" ]; then
|
||||
note "STALE - no vaultwarden backups found on the share at all"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Age in days, rounded down.
|
||||
age_days=$(( ( $(date +%s) - $(stat -c %Y "$newest") ) / 86400 ))
|
||||
newest_name=$(basename "$newest")
|
||||
|
||||
if [ "$age_days" -ge "$THRESHOLD_DAYS" ]; then
|
||||
note "STALE - newest backup is ${newest_name}, ${age_days} day(s) old (threshold ${THRESHOLD_DAYS})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
note "OK - newest backup is ${newest_name}, ${age_days} day(s) old"
|
||||
exit 0
|
||||
17
vaultwarden-backup-helpers/vw-backup-check.service
Normal file
17
vaultwarden-backup-helpers/vw-backup-check.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Check that the vaultwarden backup is not stale
|
||||
Documentation=file:/home/bdm/scripts/vaultwarden-backup-helpers/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Runs as root so it can mount the share itself; no sudo is involved.
|
||||
ExecStart=/usr/local/sbin/vw-backup-check
|
||||
# Exit 1 means stale, exit 2 means it could not verify. Both are failures, and
|
||||
# both leave a failed unit behind so "systemctl status" shows the problem.
|
||||
SuccessExitStatus=0
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
14
vaultwarden-backup-helpers/vw-backup-check.timer
Normal file
14
vaultwarden-backup-helpers/vw-backup-check.timer
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Daily vaultwarden backup staleness check
|
||||
|
||||
[Timer]
|
||||
# Mid-morning, so it runs well after the 03:00 backup and would catch a
|
||||
# missed or failed run from the previous night.
|
||||
OnCalendar=*-*-* 10:30:00
|
||||
# Catch up after the machine has been off, rather than silently skipping a day.
|
||||
Persistent=true
|
||||
# Spread the check a little so it is not always at the same instant.
|
||||
RandomizedDelaySec=10m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
63
vaultwarden-backup-helpers/vw-backup-db
Executable file
63
vaultwarden-backup-helpers/vw-backup-db
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Create a consistent SQLite backup of the vaultwarden database.
|
||||
#
|
||||
# Run as root via a NOPASSWD sudoers rule, because the database lives in a
|
||||
# root-owned Docker volume. The destination is constrained to the backup
|
||||
# directory so this grant cannot be used to write a file anywhere else.
|
||||
#
|
||||
# Install to /usr/local/sbin/vw-backup-db owned by root, mode 755. The copy
|
||||
# under scripts/ is the source of truth; do not sudo this one directly, or the
|
||||
# grant becomes a way to run arbitrary code as root.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB="/var/lib/docker/volumes/caddy_vaultwarden_data/_data/db.sqlite3"
|
||||
BACKUP_DIR="/tmp/vaultwarden-backup"
|
||||
OUT="${1:-}"
|
||||
|
||||
if [ -z "$OUT" ]; then
|
||||
echo "vw-backup-db: no destination given" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only ever write a backup file into the backup directory. This is the
|
||||
# constraint that makes the sudoers grant safe, so it is checked here too and
|
||||
# not just in sudoers.
|
||||
case "$OUT" in
|
||||
"${BACKUP_DIR}"/vaultwarden-*.db) ;;
|
||||
*)
|
||||
echo "vw-backup-db: refusing to write outside ${BACKUP_DIR}: ${OUT}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$DB" ]; then
|
||||
echo "vw-backup-db: database not found at ${DB}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
# Remove a stale file so a failed backup cannot be mistaken for a good one.
|
||||
rm -f "$OUT"
|
||||
|
||||
# .backup takes a read lock and produces a consistent snapshot. The database
|
||||
# runs in WAL mode with an uncheckpointed WAL, so copying the file with cp
|
||||
# would capture stale data; .backup is required.
|
||||
if ! /usr/bin/sqlite3 "$DB" ".backup '${OUT}'"; then
|
||||
echo "vw-backup-db: sqlite3 .backup failed" >&2
|
||||
rm -f "$OUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$OUT" ]; then
|
||||
echo "vw-backup-db: backup file is missing or empty" >&2
|
||||
rm -f "$OUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hand the file back to the calling user so it can be uploaded without sudo.
|
||||
if [ -n "${SUDO_UID:-}" ] && [ -n "${SUDO_GID:-}" ]; then
|
||||
chown "${SUDO_UID}:${SUDO_GID}" "$OUT"
|
||||
fi
|
||||
|
||||
echo "vw-backup-db: wrote ${OUT} ($(stat -c %s "$OUT") bytes)"
|
||||
@@ -1,65 +1,106 @@
|
||||
#!/bin/bash
|
||||
# Vaultwarden daily backup to SMB share via smbclient
|
||||
# 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
|
||||
|
||||
VAULT_DATA="/var/lib/docker/volumes/caddy_vaultwarden_data/_data"
|
||||
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"
|
||||
KEEP_DAYS=60
|
||||
BACKUP_NAME="vaultwarden-${TIMESTAMP}.db"
|
||||
|
||||
# SMB credentials are stored in an external file (mode 600) rather than inline
|
||||
# here, so the password never lands in version control or in the process list.
|
||||
# See vaultwarden-backup-helpers/README.md for setup.
|
||||
# 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
|
||||
echo "$(date): ERROR - SMB credentials file not readable: ${SMB_CRED_FILE}"
|
||||
echo "$(date): Create it with: printf 'username = %s\\npassword = %s\\n' <user> <pass> | install -m 600 /dev/stdin ${SMB_CRED_FILE}"
|
||||
exit 1
|
||||
die "SMB credentials file not readable: ${SMB_CRED_FILE}"
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
mkdir -p "$BACKUP_DIR" || die "could not create ${BACKUP_DIR}"
|
||||
|
||||
echo "$(date): Starting vaultwarden backup..."
|
||||
sudo sqlite3 "${VAULT_DATA}/db.sqlite3" ".backup '${BACKUP_FILE}'"
|
||||
sudo chown "$(id -u):$(id -g)" "$BACKUP_FILE"
|
||||
log "Starting vaultwarden backup..."
|
||||
|
||||
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "$(date): Backup created ($SIZE), uploading to SMB..."
|
||||
# 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
|
||||
|
||||
smbclient "//${SMB_HOST}/${SMB_SHARE}" -A "$SMB_CRED_FILE" \
|
||||
-c "cd ${SMB_DIR}; put ${BACKUP_FILE} vaultwarden-${TIMESTAMP}.db"
|
||||
if [ ! -s "$BACKUP_FILE" ]; then
|
||||
die "backup file missing or empty: ${BACKUP_FILE}"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "$(date): Backup finished successfully."
|
||||
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
|
||||
echo "$(date): ERROR - SMB upload failed!"
|
||||
exit 1
|
||||
log "WARNING - pruning failed; retention may not be enforced."
|
||||
fi
|
||||
|
||||
rm -f "$BACKUP_FILE"
|
||||
sudo -n /usr/local/sbin/vw-umount-smb || log "WARNING - could not unmount ${BACKUP_DIR}"
|
||||
else
|
||||
echo "$(date): ERROR - sqlite3 backup failed or file is empty!"
|
||||
rm -f "$BACKUP_FILE"
|
||||
exit 1
|
||||
log "WARNING - could not mount SMB share, skipping retention prune."
|
||||
fi
|
||||
|
||||
# Clean up old backups (older than KEEP_DAYS)
|
||||
echo "$(date): Cleaning up backups older than ${KEEP_DAYS} days..."
|
||||
sudo mkdir -p /mnt/netbackup
|
||||
if ! mountpoint -q /mnt/netbackup; then
|
||||
sudo mount -t cifs "//${SMB_HOST}/${SMB_SHARE}" /mnt/netbackup \
|
||||
-o credentials=/root/.smbcredentials,iocharset=utf8,vers=3.0,uid=$(id -u),gid=$(id -g),file_mode=0666,dir_mode=0777 2>/dev/null
|
||||
fi
|
||||
|
||||
if mountpoint -q /mnt/netbackup; then
|
||||
DELETED=$(find "/mnt/netbackup/${SMB_DIR}" -name "vaultwarden-*.db" -mtime +${KEEP_DAYS} -type f -print -delete 2>/dev/null | wc -l)
|
||||
sudo umount /mnt/netbackup 2>/dev/null
|
||||
echo "$(date): Deleted ${DELETED} old backup(s)."
|
||||
else
|
||||
echo "$(date): WARNING - Could not mount SMB for cleanup, skipping."
|
||||
fi
|
||||
log "Backup finished successfully."
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user