#!/bin/sh set -eu # Run this on the Unraid host before Borg starts. # It refreshes the latest database dumps in a stable directory so Borg can # version the dump artifacts instead of raw live database files. DUMP_ROOT="${DUMP_ROOT:-/mnt/user/backups/borg/dumps}" LATEST_DIR="$DUMP_ROOT/latest" TMP_DIR="$DUMP_ROOT/.tmp" SHARED_PG_ADMIN_USER="${SHARED_PG_ADMIN_USER:-mailarchiver}" SHARED_PG_PASSWORD_FILE="${SHARED_PG_PASSWORD_FILE:-/mnt/user/appdata/secrets/postgres_password.txt}" log() { printf '%s %s\n' "[borg-dumps]" "$*" } warn() { printf '%s %s\n' "[borg-dumps][warn]" "$*" >&2 } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then warn "Required command missing: $1" exit 1 fi } need_container() { docker inspect "$1" >/dev/null 2>&1 } ensure_dirs() { mkdir -p "$LATEST_DIR" "$TMP_DIR" } atomic_write() { target="$1" tmp="$2" mode="${3:-644}" mkdir -p "$(dirname "$target")" # Standard 0644, damit der Nearline-Pull-Workflow (docs/H_DRIVE_NEARLINE_PULL.md) # und Restore-Test-Skripte die Dumps per SMB-Read-Share oder unprivilegiert # lesen koennen. Sensible Sonderfaelle wie unraid-flash-config rufen mit # explizitem 600 auf, damit die bewusste Beschraenkung erhalten bleibt. chmod "$mode" "$tmp" mv "$tmp" "$target" } dump_pg_db() { container="$1" password="$2" user="$3" db="$4" output="$5" tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping PostgreSQL database '$db' from $container" docker exec -e "PGPASSWORD=$password" "$container" \ pg_dump -U "$user" -d "$db" -Fc > "$tmp" atomic_write "$output" "$tmp" } dump_pg_globals() { container="$1" password="$2" user="$3" output="$4" tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping PostgreSQL globals from $container" docker exec -e "PGPASSWORD=$password" "$container" \ pg_dumpall -U "$user" --globals-only > "$tmp" atomic_write "$output" "$tmp" } dump_sqlite_file() { source="$1" output="$2" label="$3" if [ ! -f "$source" ]; then warn "Skipping missing SQLite database for $label: $source" return 0 fi tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping SQLite database '$label' from $source" rm -f "$tmp" if ! sqlite3 "$source" ".backup $tmp"; then warn "SQLite backup failed for $label" rm -f "$tmp" return 1 fi if [ "$(sqlite3 "$tmp" 'PRAGMA quick_check;')" != "ok" ]; then warn "SQLite quick_check failed for $label" rm -f "$tmp" return 1 fi atomic_write "$output" "$tmp" } dump_sqlite_container() { container="$1" db_path="$2" output="$3" host_source="${4:-}" if ! need_container "$container"; then warn "Skipping missing container: $container" return 0 fi if ! docker exec "$container" sh -lc 'command -v sqlite3 >/dev/null 2>&1'; then if [ -n "$host_source" ]; then warn "Container $container has no sqlite3; using host-side SQLite backup for $host_source" dump_sqlite_file "$host_source" "$output" "$container" return fi warn "Skipping SQLite backup for $container because sqlite3 is missing in the container and no host fallback is configured" return 1 fi container_tmp="/tmp/$(basename "$output").bak" tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping SQLite database '$db_path' from $container" rm -f "$tmp" docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true if ! docker exec "$container" sqlite3 "$db_path" ".backup $container_tmp"; then warn "SQLite backup failed for $container:$db_path" docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true rm -f "$tmp" return 1 fi docker cp "$container:$container_tmp" "$tmp" docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true if [ "$(sqlite3 "$tmp" 'PRAGMA quick_check;')" != "ok" ]; then warn "SQLite quick_check failed for $container:$db_path" rm -f "$tmp" return 1 fi atomic_write "$output" "$tmp" } dump_file_copy() { source="$1" output="$2" label="$3" if [ ! -f "$source" ]; then warn "Skipping missing file dump for $label: $source" return 0 fi tmp="$TMP_DIR/$(basename "$output").tmp" log "Copying file-backed state '$label' from $source" rm -f "$tmp" cp "$source" "$tmp" atomic_write "$output" "$tmp" } backup_unraid_flash_config() { output="$LATEST_DIR/unraid-flash-config.tar.gz" checksum="$LATEST_DIR/unraid-flash-config.tar.gz.sha256" manifest="$LATEST_DIR/unraid-flash-config.manifest.txt" tmp="$TMP_DIR/unraid-flash-config.tar.gz.tmp" tmp_checksum="$TMP_DIR/unraid-flash-config.tar.gz.sha256.tmp" tmp_manifest="$TMP_DIR/unraid-flash-config.manifest.txt.tmp" if [ ! -d /boot/config ]; then warn "Skipping Unraid flash config backup because /boot/config is missing" return 1 fi log "Backing up Unraid flash configuration from /boot/config" rm -f "$tmp" "$tmp_checksum" "$tmp_manifest" tar -C /boot \ --exclude='config/plugins/*/*.txz' \ --exclude='config/plugins/*/*.tgz' \ --exclude='config/plugins/*/*.tar' \ --exclude='config/plugins/*/*.tar.*' \ --exclude='config/plugins/*/*.zip' \ --exclude='config/plugins/*/*.md5' \ -czf "$tmp" config # Flash-Config ist sensibel (enthaelt /boot/config inkl. Plugin-/SMB-/Network-Settings); # bewusst 0600, damit der Nearline-Pull ueber SMB sie nicht versehentlich greift. atomic_write "$output" "$tmp" 600 ( cd "$LATEST_DIR" sha256sum "$(basename "$output")" ) > "$tmp_checksum" atomic_write "$checksum" "$tmp_checksum" 600 { printf 'created_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'host=%s\n' "$(hostname)" if [ -f /etc/unraid-version ]; then sed 's/^/unraid_/' /etc/unraid-version fi printf 'source=/boot/config\n' printf 'archive=%s\n' "$(basename "$output")" printf 'checksum=%s\n' "$(basename "$checksum")" printf 'note=%s\n' 'Contains Unraid configuration and must be treated as secret backup material.' printf 'excluded=%s\n' 'downloadable plugin package archives under /boot/config/plugins/*/' } > "$tmp_manifest" atomic_write "$manifest" "$tmp_manifest" 600 } dump_optional_pg_db() { container="$1" password="$2" user="$3" db="$4" output="$5" if docker exec -e "PGPASSWORD=$password" "$container" \ psql -U "$user" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'" \ | grep -q 1; then dump_pg_db "$container" "$password" "$user" "$db" "$output" else warn "Skipping missing PostgreSQL database '$db' in $container" fi } dump_mysql_container() { container="$1" output="$2" if ! need_container "$container"; then warn "Skipping missing container: $container" return 0 fi info="$(docker exec "$container" sh -lc 'printf "%s|%s|%s" "${MARIADB_DATABASE:-${MYSQL_DATABASE:-}}" "${MARIADB_USER:-${MYSQL_USER:-root}}" "${MARIADB_PASSWORD:-${MYSQL_PASSWORD:-}}"' || true)" db="$(printf '%s' "$info" | cut -d'|' -f1)" user="$(printf '%s' "$info" | cut -d'|' -f2)" password="$(printf '%s' "$info" | cut -d'|' -f3)" if [ -z "$db" ] || [ -z "$password" ]; then warn "Skipping MySQL/MariaDB dump for $container because DB credentials were not discoverable" return 0 fi tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping MariaDB/MySQL database '$db' from $container" docker exec "$container" sh -lc "mysqldump --single-transaction --quick -u\"$user\" -p\"$password\" \"$db\"" > "$tmp" atomic_write "$output" "$tmp" } dump_mongo_container() { container="$1" output="$2" if ! need_container "$container"; then warn "Skipping missing container: $container" return 0 fi if ! docker exec "$container" sh -lc 'command -v mongodump >/dev/null 2>&1'; then warn "Skipping Mongo dump for $container because mongodump is not available in the container image" return 0 fi tmp="$TMP_DIR/$(basename "$output").tmp" log "Dumping MongoDB archive from $container" docker exec "$container" sh -lc 'mongodump --archive --gzip --username "$MONGO_INITDB_ROOT_USERNAME" --password "$(cat /run/secrets/mongo_password)" --authenticationDatabase admin' > "$tmp" atomic_write "$output" "$tmp" } main() { need_cmd docker need_cmd sqlite3 need_cmd tar need_cmd sha256sum ensure_dirs # Shared PostgreSQL 18 (historischer Containername: postgresql17) if need_container "postgresql17"; then # Use the cluster admin/superuser for all shared-cluster dumps. The # application roles exist, but they can have different passwords from the # bootstrap postgres secret used by the shared container. shared_pg_password="$(cat "$SHARED_PG_PASSWORD_FILE")" dump_pg_globals "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "$LATEST_DIR/postgresql17-globals.sql" dump_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "mailarchiver" "$LATEST_DIR/postgresql17-mailarchiver.dump" dump_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "paperless" "$LATEST_DIR/postgresql17-paperless.dump" dump_optional_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "authelia" "$LATEST_DIR/postgresql17-authelia.dump" else warn "Skipping shared PostgreSQL dumps because container 'postgresql17' is missing" fi # Dedicated PostgreSQL databases if need_container "mealie-postgres"; then mealie_password="$(cat /mnt/user/appdata/secrets/mealie_postgres_password.txt)" dump_pg_db "mealie-postgres" "$mealie_password" "mealie" "mealie" "$LATEST_DIR/mealie.dump" else warn "Skipping missing container: mealie-postgres" fi if need_container "immich_postgres"; then immich_password="$(cat /mnt/user/appdata/secrets/immich_postgres_password.txt)" dump_pg_db "immich_postgres" "$immich_password" "immich" "immich" "$LATEST_DIR/immich.dump" else warn "Skipping missing container: immich_postgres" fi if need_container "nextcloud-postgres"; then nextcloud_password="$(cat /mnt/user/appdata/secrets/nextcloud_postgres_password.txt)" dump_pg_db "nextcloud-postgres" "$nextcloud_password" "nextcloud" "nextcloud" "$LATEST_DIR/nextcloud.dump" else warn "Skipping missing container: nextcloud-postgres" fi # SQLite databases dump_sqlite_container "gitea" "/data/gitea/gitea.db" "$LATEST_DIR/gitea.sqlite.dump" "/mnt/user/services/gitea/data/gitea/gitea.db" dump_sqlite_container "vaultwarden" "/data/db.sqlite3" "$LATEST_DIR/vaultwarden.sqlite.dump" "/mnt/user/appdata/vaultwarden/db.sqlite3" dump_sqlite_container "speedtest-tracker" "/config/database.sqlite" "$LATEST_DIR/speedtest-tracker.sqlite.dump" "/mnt/user/appdata/speedtest-tracker/config/database.sqlite" # Filebrowser uses file-backed app state, but this installation is not SQLite. dump_file_copy "/mnt/user/appdata/filebrowser/database/filebrowser.db" "$LATEST_DIR/filebrowser.bolt.dump" "filebrowser" # Additional host-side SQLite dumps for admin tooling with appdata files. dump_sqlite_file "/mnt/user/appdata/borg-ui/data/borg.db" "$LATEST_DIR/borg-ui.sqlite" "borg-ui" dump_sqlite_file "/mnt/user/appdata/grafana/grafana.db" "$LATEST_DIR/grafana.sqlite" "grafana" # MongoDB dump_mongo_container "komodo-mongo" "$LATEST_DIR/komodo-mongo.archive.gz" # Unraid USB flash configuration. This is generated into the existing dump # set so Borg carries it off-site together with the database artifacts. backup_unraid_flash_config log "Finished refreshing dump set in $LATEST_DIR" } main "$@"