Add verified NAS backup automation

This commit is contained in:
umbra audio
2026-09-24 16:13:53 -07:00
parent fc6c36331b
commit 8a61d9298b
4 changed files with 240 additions and 0 deletions

View File

@@ -50,3 +50,21 @@ cd /home/umbra/src/UmbraTrans
```
The tests cover schema and size validation, atomic no-overwrite writes, restrictive modes, path traversal, symlink refusal, secret-pattern redaction, inbox processing, and read-only initialization.
## Backups
`scripts/umbratrans_backup.sh` is installed root-owned as `/usr/local/sbin/umbratrans-backup` and scheduled by the enabled `umbratrans-backup.timer`. It writes only to the confirmed NAS2 `netbackup/UmbraTrans/backups` tree:
- PostgreSQL: `pg_dump` custom-format logical dump, never a live data-directory copy.
- Configuration: PostgreSQL configuration files only.
- Workspace: source/configuration archive excluding `.venv`, Git internals, runtime data, local mailboxes, and secret-bearing files.
Each file is staged outside the share, published with an atomic rename as `umbra`, checksummed, and verified after publication. Retention is 30 days for PostgreSQL/configuration/manifests and 14 days for workspace archives; stale temporary files older than one hour are removed. The timer fails closed if the user GVfs mount is absent and does not mount or broaden the NAS share.
The current first-run backup set is present under:
```text
/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans/backups
```
Check timer status with `systemctl status umbratrans-backup.timer` and the last run with `journalctl -u umbratrans-backup.service`.

196
scripts/umbratrans_backup.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
WORKSPACE='/home/umbra/src/UmbraTrans'
USER_NAME='umbra'
USER_GROUP='users'
NAS_MOUNT='/run/user/1002/gvfs'
NAS_ROOT='/run/user/1002/gvfs/smb-share:server=nas2.local,share=netbackup/UmbraTrans'
BACKUP_ROOT="${NAS_ROOT}/backups"
POSTGRES_DIR="${BACKUP_ROOT}/postgres"
CONFIG_DIR="${BACKUP_ROOT}/config"
WORKSPACE_DIR="${BACKUP_ROOT}/workspace"
MANIFEST_DIR="${BACKUP_ROOT}/manifests"
DB_NAME='umbracontrol'
DB_STAGE='/var/lib/postgresql/umbratrans-backup-stage'
LOCAL_STAGE=''
DB_DUMP=''
log() {
printf 'umbratrans-backup: %s\n' "$*"
}
fail() {
printf 'umbratrans-backup: %s\n' "$*" >&2
exit 1
}
as_user() {
runuser -u "${USER_NAME}" -- "$@"
}
cleanup() {
if [[ -n "${DB_DUMP}" ]]; then
rm -f -- "${DB_DUMP}"
fi
if [[ -n "${LOCAL_STAGE}" ]]; then
rm -rf -- "${LOCAL_STAGE}"
fi
rmdir "${DB_STAGE}" 2>/dev/null || true
}
trap cleanup EXIT
require_private_directory() {
local path="$1"
as_user test -d "${path}" || fail "required directory is unavailable: ${path}"
as_user test ! -L "${path}" || fail "required path is a symlink: ${path}"
local mode
mode="$(as_user stat -c '%a' "${path}")"
(( (8#${mode} & 8#077) == 0 )) || fail "directory is not private: ${path}"
as_user test -r "${path}" || fail "directory is not readable by ${USER_NAME}: ${path}"
as_user test -w "${path}" || fail "directory is not writable by ${USER_NAME}: ${path}"
}
publish_file() {
local source_path="$1"
local destination_dir="$2"
local destination_name="$3"
local user_copy="${LOCAL_STAGE}/publish-${destination_name}"
local temporary_path="${destination_dir}/.${destination_name}.tmp"
local destination_path="${destination_dir}/${destination_name}"
local source_hash published_hash
install -o "${USER_NAME}" -g "${USER_GROUP}" -m 0600 -- "${source_path}" "${user_copy}"
as_user /usr/bin/cp -- "${user_copy}" "${temporary_path}"
as_user /bin/mv -fT -- "${temporary_path}" "${destination_path}"
source_hash="$(sha256sum "${source_path}" | awk '{print $1}')"
published_hash="$(as_user sha256sum "${destination_path}" | awk '{print $1}')"
[[ "${source_hash}" == "${published_hash}" ]] || fail "published checksum mismatch: ${destination_name}"
}
write_checksum_file() {
local source_path="$1"
local destination_name="$2"
local hash
hash="$(sha256sum "${source_path}" | awk '{print $1}')"
printf '%s %s\n' "${hash}" "${destination_name}" > "${LOCAL_STAGE}/${destination_name}.sha256"
}
exec 9>/run/lock/umbratrans-backup.lock
flock -n 9 || fail 'another backup is already running'
as_user mountpoint -q "${NAS_MOUNT}" || {
log 'confirmed NAS GVfs mount is unavailable; no backup was written'
exit 75
}
require_private_directory "${NAS_ROOT}"
require_private_directory "${BACKUP_ROOT}"
require_private_directory "${POSTGRES_DIR}"
require_private_directory "${CONFIG_DIR}"
require_private_directory "${WORKSPACE_DIR}"
require_private_directory "${MANIFEST_DIR}"
as_user test -d "${WORKSPACE}" || fail "workspace is unavailable: ${WORKSPACE}"
as_user test ! -L "${WORKSPACE}" || fail "workspace path is a symlink: ${WORKSPACE}"
[[ -x /usr/bin/pg_dump && -x /usr/bin/pg_restore && -x /usr/sbin/runuser ]] || fail 'required backup tools are unavailable'
LOCAL_STAGE="$(mktemp -d /var/tmp/umbratrans-backup.XXXXXX)"
chmod 0700 "${LOCAL_STAGE}"
chown "${USER_NAME}:${USER_GROUP}" "${LOCAL_STAGE}"
install -d -o postgres -g postgres -m 0700 "${DB_STAGE}"
run_id="$(date -u '+%Y%m%dT%H%M%SZ')-$$"
created_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
db_name="umbracontrol-${run_id}.dump"
config_name="umbracontrol-config-${run_id}.tar.gz"
workspace_name="umbratrans-workspace-${run_id}.tar.gz"
DB_DUMP="${DB_STAGE}/${db_name}"
runuser -u postgres -- /usr/bin/pg_dump \
--format=custom \
--compress=9 \
--no-owner \
--no-privileges \
--file="${DB_DUMP}" \
"${DB_NAME}"
runuser -u postgres -- /usr/bin/pg_restore --list "${DB_DUMP}" >/dev/null
install -o "${USER_NAME}" -g "${USER_GROUP}" -m 0600 -- "${DB_DUMP}" "${LOCAL_STAGE}/${db_name}"
config_stage="${LOCAL_STAGE}/postgresql-config"
install -d -m 0700 "${config_stage}"
install -m 0600 \
/etc/postgresql/16/main/postgresql.conf \
/etc/postgresql/16/main/pg_hba.conf \
/etc/postgresql/16/main/pg_ident.conf \
/etc/postgresql/16/main/start.conf \
"${config_stage}/"
tar -C "${config_stage}" -czf "${LOCAL_STAGE}/${config_name}" \
postgresql.conf pg_hba.conf pg_ident.conf start.conf
tar -tzf "${LOCAL_STAGE}/${config_name}" >/dev/null
tar -C /home/umbra/src \
--exclude='UmbraTrans/.venv' \
--exclude='UmbraTrans/.git' \
--exclude='UmbraTrans/.local' \
--exclude='UmbraTrans/agent-mailbox-local' \
--exclude='UmbraTrans/.env' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='*.token' \
--exclude='*/__pycache__' \
--exclude='*.pyc' \
-czf "${LOCAL_STAGE}/${workspace_name}" \
UmbraTrans
tar -tzf "${LOCAL_STAGE}/${workspace_name}" >/dev/null
publish_file "${LOCAL_STAGE}/${db_name}" "${POSTGRES_DIR}" "${db_name}"
write_checksum_file "${LOCAL_STAGE}/${db_name}" "${db_name}"
publish_file "${LOCAL_STAGE}/${db_name}.sha256" "${POSTGRES_DIR}" "${db_name}.sha256"
publish_file "${LOCAL_STAGE}/${config_name}" "${CONFIG_DIR}" "${config_name}"
write_checksum_file "${LOCAL_STAGE}/${config_name}" "${config_name}"
publish_file "${LOCAL_STAGE}/${config_name}.sha256" "${CONFIG_DIR}" "${config_name}.sha256"
publish_file "${LOCAL_STAGE}/${workspace_name}" "${WORKSPACE_DIR}" "${workspace_name}"
write_checksum_file "${LOCAL_STAGE}/${workspace_name}" "${workspace_name}"
publish_file "${LOCAL_STAGE}/${workspace_name}.sha256" "${WORKSPACE_DIR}" "${workspace_name}.sha256"
cat > "${LOCAL_STAGE}/postgres.manifest" <<EOF
created_at=${created_at}
database=${DB_NAME}
format=pg_dump_custom
file=${db_name}
sha256_file=${db_name}.sha256
EOF
publish_file "${LOCAL_STAGE}/postgres.manifest" "${MANIFEST_DIR}" "umbracontrol-${run_id}.manifest"
cat > "${LOCAL_STAGE}/config.manifest" <<EOF
created_at=${created_at}
scope=postgresql-config-only
file=${config_name}
sha256_file=${config_name}.sha256
EOF
publish_file "${LOCAL_STAGE}/config.manifest" "${MANIFEST_DIR}" "umbracontrol-config-${run_id}.manifest"
cat > "${LOCAL_STAGE}/workspace.manifest" <<EOF
created_at=${created_at}
scope=workspace-source-config-only
file=${workspace_name}
sha256_file=${workspace_name}.sha256
EOF
publish_file "${LOCAL_STAGE}/workspace.manifest" "${MANIFEST_DIR}" "umbratrans-workspace-${run_id}.manifest"
as_user find "${POSTGRES_DIR}" -maxdepth 1 -type f \
\( -name 'umbracontrol-*.dump' -o -name 'umbracontrol-*.dump.sha256' \) -mtime +30 -delete
as_user find "${CONFIG_DIR}" -maxdepth 1 -type f \
\( -name 'umbracontrol-config-*.tar.gz' -o -name 'umbracontrol-config-*.tar.gz.sha256' \) -mtime +30 -delete
as_user find "${WORKSPACE_DIR}" -maxdepth 1 -type f \
\( -name 'umbratrans-workspace-*.tar.gz' -o -name 'umbratrans-workspace-*.tar.gz.sha256' \) -mtime +14 -delete
as_user find "${MANIFEST_DIR}" -maxdepth 1 -type f \
\( -name 'umbracontrol-*.manifest' -o -name 'umbracontrol-config-*.manifest' -o -name 'umbratrans-workspace-*.manifest' \) -mtime +30 -delete
as_user find "${BACKUP_ROOT}" -type f -name '.*.tmp' -mmin +60 -delete
log "completed ${created_at}; PostgreSQL, configuration, and workspace verified"

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Sanitized UmbraTrans logical backups to NAS2
After=network-online.target postgresql@16-main.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/umbratrans-backup
User=root
Group=root
UMask=0077
Nice=10
IOSchedulingClass=best-effort
NoNewPrivileges=yes

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Run UmbraTrans NAS2 backups daily
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=15m
AccuracySec=1m
Unit=umbratrans-backup.service
[Install]
WantedBy=timers.target