Files
UmbraTrans/scripts/umbratrans_backup.sh
2026-10-18 19:01:05 -07:00

200 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
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"