Add verified NAS backup automation
This commit is contained in:
18
README.md
18
README.md
@@ -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
196
scripts/umbratrans_backup.sh
Executable 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"
|
||||
14
systemd/umbratrans-backup.service
Normal file
14
systemd/umbratrans-backup.service
Normal 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
|
||||
12
systemd/umbratrans-backup.timer
Normal file
12
systemd/umbratrans-backup.timer
Normal 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
|
||||
Reference in New Issue
Block a user