#!/usr/bin/env bash # ============================================================================ # PostgreSQL OneKey Backup # # Version: v1.0.0 # Author: Leon (silenceace@gmail.com) # Repo: https://github.com/funnyzak/pgsql-onekey-backup # License: MIT # # Requirements: Bash 4.2+, PostgreSQL client tools, flock, sha256sum/shasum, # zip. curl is required when an HTTP notification channel is configured; # apprise plus timeout/gtimeout are required only for the Apprise CLI channel. # # Basic example: # bash pgsql_backup.sh \ # --target-dir /var/backups/postgresql \ # --passfile /etc/postgresql-backup/pgpass \ # --database app --database analytics # # Password environment-variable example (preferred over --password for Cron): # export PGSQL_BACKUP_PASSWORD='replace-with-your-password' # bash pgsql_backup.sh \ # --target-dir /var/backups/postgresql \ # --host 127.0.0.1 --port 5432 --user backup \ # --database app # # Password command-line example (may be visible in ps and shell history): # bash pgsql_backup.sh \ # --target-dir /var/backups/postgresql \ # --host 127.0.0.1 --port 5432 --user backup \ # --password 'replace-with-your-password' \ # --database app # # Check configuration without connecting, backing up, or sending notifications: # bash pgsql_backup.sh --target-dir /var/backups/postgresql --check # # Every setting supports an environment variable and a command-line option. # Command-line values override environment variables. Configure only the # notification channels you need: # export PGSQL_BACKUP_BARK_SERVER=https://api.day.app # export PGSQL_BACKUP_BARK_DEVICE_KEY=replace-with-device-key # export PGSQL_BACKUP_BARK_SOUND=alarm # optional # export PGSQL_BACKUP_BARK_GROUP=postgresql-backup # optional # export PGSQL_BACKUP_NTFY_SERVER=https://ntfy.sh # export PGSQL_BACKUP_NTFY_TOPIC=postgresql-backup # export PGSQL_BACKUP_NTFY_TOKEN=replace-with-token # optional # export PGSQL_BACKUP_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-me # export PGSQL_BACKUP_WECOM_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me' # export PGSQL_BACKUP_DINGTALK_WEBHOOK_URL='https://oapi.dingtalk.com/robot/send?access_token=replace-me' # export PGSQL_BACKUP_GOTIFY_SERVER=https://gotify.example.com # export PGSQL_BACKUP_GOTIFY_TOKEN=replace-with-app-token # export PGSQL_BACKUP_GOTIFY_PRIORITY=5 # optional # export PGSQL_BACKUP_NOTIFY_REDACT=true # optional, default # export PGSQL_BACKUP_NOTIFY_TIMEOUT=10 # optional, 1-300 # bash pgsql_backup.sh --target-dir /var/backups/postgresql --database app # # The same Bark settings can be written as command-line options: # bash pgsql_backup.sh \ # --target-dir /var/backups/postgresql --database app \ # --bark-server https://api.day.app \ # --bark-device-key replace-with-device-key \ # --bark-sound alarm --bark-group postgresql-backup --notify-timeout 10 # # Apprise API supports both stateful and stateless curl calls: # export PGSQL_BACKUP_APPRISE_API_URL=http://127.0.0.1:8000/notify/postgresql-backup # # For stateless /notify, also set PGSQL_BACKUP_APPRISE_URLS. # Apprise CLI remains available when PGSQL_BACKUP_APPRISE_CONFIG points to a # protected Apprise configuration file. PGSQL_BACKUP_APPRISE_TAGS accepts a # comma-separated tag expression. # export PGSQL_BACKUP_APPRISE_CONFIG=/etc/postgresql-backup/apprise.conf # export PGSQL_BACKUP_APPRISE_TAGS=ops,admin # optional # CLI equivalents: --apprise-config, --apprise-api-url, --apprise-urls, # --apprise-tags, --ntfy-*, --feishu-webhook-url, --wecom-webhook-url, # --dingtalk-webhook-url, and --gotify-*. # # Cron uses one physical line. Quote URLs and tokens. Escape every percent sign # as \% because Cron processes percent signs before the shell does: # 0 3 * * * /usr/bin/bash /opt/postgresql-backup/pgsql_backup.sh --target-dir /var/backups/postgresql --database app --bark-server 'https://api.day.app' --bark-device-key 'replace-me' # # Default notifications: success and failure. Add --notify-start to also send # the start event. See README.md and --help for complete usage. # ============================================================================ set +x set -Eeuo pipefail set +a umask 077 readonly SCRIPT_VERSION="v1.0.0" readonly PROGRAM_NAME="pgsql-onekey-backup" readonly MAX_DATABASE_SLUG_BYTES=160 TARGET_DIR=${PGSQL_BACKUP_TARGET_DIR:-} DB_HOST=${PGSQL_BACKUP_HOST:-127.0.0.1} DB_PORT=${PGSQL_BACKUP_PORT:-5432} DB_USER=${PGSQL_BACKUP_USER:-postgres} DB_PASSWORD=${PGSQL_BACKUP_PASSWORD:-} MAINTENANCE_DATABASE=${PGSQL_BACKUP_MAINTENANCE_DATABASE:-postgres} PASSFILE=${PGSQL_BACKUP_PASSFILE:-} SERVICE_FILE=${PGSQL_BACKUP_SERVICE_FILE:-} SERVICE=${PGSQL_BACKUP_SERVICE:-} FORMAT=${PGSQL_BACKUP_FORMAT:-custom} OUTPUT_MODE=${PGSQL_BACKUP_OUTPUT_MODE:-zip} COMPRESSION=${PGSQL_BACKUP_COMPRESSION:-6} COMPRESSION_EXPLICIT=false [[ -n ${PGSQL_BACKUP_COMPRESSION+x} ]] && COMPRESSION_EXPLICIT=true GLOBALS_MODE=${PGSQL_BACKUP_GLOBALS_MODE:-auto} INCLUDE_ROLE_PASSWORDS=${PGSQL_BACKUP_INCLUDE_ROLE_PASSWORDS:-false} EXPIRE_HOURS=${PGSQL_BACKUP_EXPIRE_HOURS:-4320} SERVER_NAME=${PGSQL_BACKUP_SERVER_NAME:-$(hostname 2>/dev/null || printf 'unknown')} NOTIFY_START=${PGSQL_BACKUP_NOTIFY_START:-false} NOTIFY_REDACT=${PGSQL_BACKUP_NOTIFY_REDACT:-true} NOTIFY_TIMEOUT=${PGSQL_BACKUP_NOTIFY_TIMEOUT:-10} BEFORE_HOOK=${PGSQL_BACKUP_BEFORE_HOOK:-} AFTER_DATABASE_HOOK=${PGSQL_BACKUP_AFTER_DATABASE_HOOK:-} AFTER_HOOK=${PGSQL_BACKUP_AFTER_HOOK:-} DATABASE_VALUES=${PGSQL_BACKUP_DATABASES:-} DUMP_OPTION_VALUES=${PGSQL_BACKUP_DUMP_OPTIONS:-} CHECK_ONLY=${PGSQL_BACKUP_CHECK:-false} DEPENDENCY_REPORT=false APPRISE_CLI_CONFIG=${PGSQL_BACKUP_APPRISE_CONFIG:-} APPRISE_API_URL=${PGSQL_BACKUP_APPRISE_API_URL:-} APPRISE_API_URLS=${PGSQL_BACKUP_APPRISE_URLS:-} APPRISE_TAGS=${PGSQL_BACKUP_APPRISE_TAGS:-} BARK_SERVER=${PGSQL_BACKUP_BARK_SERVER:-} BARK_DEVICE_KEY=${PGSQL_BACKUP_BARK_DEVICE_KEY:-} BARK_SOUND=${PGSQL_BACKUP_BARK_SOUND:-} BARK_GROUP=${PGSQL_BACKUP_BARK_GROUP:-} NTFY_SERVER=${PGSQL_BACKUP_NTFY_SERVER:-} NTFY_TOPIC=${PGSQL_BACKUP_NTFY_TOPIC:-} NTFY_TOKEN=${PGSQL_BACKUP_NTFY_TOKEN:-} FEISHU_WEBHOOK_URL=${PGSQL_BACKUP_FEISHU_WEBHOOK_URL:-} WECOM_WEBHOOK_URL=${PGSQL_BACKUP_WECOM_WEBHOOK_URL:-} DINGTALK_WEBHOOK_URL=${PGSQL_BACKUP_DINGTALK_WEBHOOK_URL:-} GOTIFY_SERVER=${PGSQL_BACKUP_GOTIFY_SERVER:-} GOTIFY_TOKEN=${PGSQL_BACKUP_GOTIFY_TOKEN:-} GOTIFY_PRIORITY=${PGSQL_BACKUP_GOTIFY_PRIORITY:-5} DB_HOST_EXPLICIT=false DB_PORT_EXPLICIT=false DB_USER_EXPLICIT=false [[ -n ${PGSQL_BACKUP_HOST+x} ]] && DB_HOST_EXPLICIT=true [[ -n ${PGSQL_BACKUP_PORT+x} ]] && DB_PORT_EXPLICIT=true [[ -n ${PGSQL_BACKUP_USER+x} ]] && DB_USER_EXPLICIT=true PASSWORD_CONFIGURED=false [[ -n $DB_PASSWORD ]] && PASSWORD_CONFIGURED=true export -n TARGET_DIR DB_HOST DB_PORT DB_USER DB_PASSWORD MAINTENANCE_DATABASE PASSFILE SERVICE_FILE SERVICE FORMAT OUTPUT_MODE COMPRESSION GLOBALS_MODE INCLUDE_ROLE_PASSWORDS EXPIRE_HOURS SERVER_NAME NOTIFY_START NOTIFY_REDACT NOTIFY_TIMEOUT BEFORE_HOOK AFTER_DATABASE_HOOK AFTER_HOOK DATABASE_VALUES DUMP_OPTION_VALUES CHECK_ONLY APPRISE_CLI_CONFIG APPRISE_API_URL APPRISE_API_URLS APPRISE_TAGS BARK_SERVER BARK_DEVICE_KEY BARK_SOUND BARK_GROUP NTFY_SERVER NTFY_TOPIC NTFY_TOKEN FEISHU_WEBHOOK_URL WECOM_WEBHOOK_URL DINGTALK_WEBHOOK_URL GOTIFY_SERVER GOTIFY_TOKEN GOTIFY_PRIORITY unset PGSQL_BACKUP_TARGET_DIR PGSQL_BACKUP_HOST PGSQL_BACKUP_PORT PGSQL_BACKUP_USER PGSQL_BACKUP_PASSWORD PGSQL_BACKUP_MAINTENANCE_DATABASE PGSQL_BACKUP_PASSFILE PGSQL_BACKUP_SERVICE_FILE PGSQL_BACKUP_SERVICE PGSQL_BACKUP_DATABASES PGSQL_BACKUP_DUMP_OPTIONS PGSQL_BACKUP_FORMAT PGSQL_BACKUP_OUTPUT_MODE PGSQL_BACKUP_COMPRESSION PGSQL_BACKUP_GLOBALS_MODE PGSQL_BACKUP_INCLUDE_ROLE_PASSWORDS PGSQL_BACKUP_EXPIRE_HOURS PGSQL_BACKUP_SERVER_NAME PGSQL_BACKUP_NOTIFY_START PGSQL_BACKUP_NOTIFY_REDACT PGSQL_BACKUP_NOTIFY_TIMEOUT PGSQL_BACKUP_BEFORE_HOOK PGSQL_BACKUP_AFTER_DATABASE_HOOK PGSQL_BACKUP_AFTER_HOOK PGSQL_BACKUP_CHECK PGSQL_BACKUP_APPRISE_CONFIG PGSQL_BACKUP_APPRISE_API_URL PGSQL_BACKUP_APPRISE_URLS PGSQL_BACKUP_APPRISE_TAGS PGSQL_BACKUP_BARK_SERVER PGSQL_BACKUP_BARK_DEVICE_KEY PGSQL_BACKUP_BARK_SOUND PGSQL_BACKUP_BARK_GROUP PGSQL_BACKUP_NTFY_SERVER PGSQL_BACKUP_NTFY_TOPIC PGSQL_BACKUP_NTFY_TOKEN PGSQL_BACKUP_FEISHU_WEBHOOK_URL PGSQL_BACKUP_WECOM_WEBHOOK_URL PGSQL_BACKUP_DINGTALK_WEBHOOK_URL PGSQL_BACKUP_GOTIFY_SERVER PGSQL_BACKUP_GOTIFY_TOKEN PGSQL_BACKUP_GOTIFY_PRIORITY declare -a DATABASES=() DUMP_OPTIONS=() DATABASE_FILES=() DATABASE_STARTS=() DATABASE_ENDS=() DATABASE_SIZES=() declare -a NOTIFICATION_NAMES=() NOTIFICATION_SENDERS=() NOTIFICATION_VALIDATORS=() MISSING_DEPENDENCIES=() MISSING_DEPENDENCY_KINDS=() DATABASE_COUNT=0 CLI_DATABASES_SET=false CLI_DUMP_OPTIONS_SET=false ALL_DATABASES_REQUESTED=false NOTIFICATION_CHANNEL_COUNT=0 TIMEOUT_COMMAND= RUN_ID= STAGE_DIR= PUBLISHED_DIR= ERROR_LOG= CURRENT_PHASE=initialization START_EPOCH=0 FINAL_MESSAGE= NOTIFY_ON_EXIT=false CLIENT_VERSION= SERVER_VERSION= ACTUAL_GLOBALS_MODE=skip CONNECTION_SOURCE=arguments CREDENTIAL_SOURCE=default usage() { cat <<'EOF' Usage: pgsql_backup.sh [options] Required: --target-dir DIR Existing private backup root directory Database: --host HOST PostgreSQL host (default: 127.0.0.1) --port PORT PostgreSQL port (default: 5432) --user USER PostgreSQL user (default: postgres) --password PASSWORD Direct password (prefer environment variable) --database NAME Database to back up; repeat as needed --all-databases Discover all connectable non-template databases --maintenance-database NAME Maintenance database (default: postgres) --passfile FILE Protected libpq password file --service-file FILE Protected libpq service file --service NAME libpq service name --dump-option OPTION Allowlisted pg_dump option; repeat as needed --default-dump-options Clear environment dump options Output: --format MODE custom or plain (default: custom) --output-mode MODE zip, files, or both (default: zip) --compression LEVEL custom compression level, 0-9 (default: 6) --globals-mode MODE auto, include, or skip (default: auto) --include-role-passwords Include role password hashes (high risk) --no-include-role-passwords Exclude role password hashes (default) --expire-hours HOURS Remove managed runs older than HOURS --before-hook FILE Executable run before database dumps --after-database-hook FILE Executable run after each database dump --after-hook FILE Executable run after publishing and pruning Notifications: --notify-start / --no-notify-start --notify-redact / --no-notify-redact --notify-timeout SECONDS --apprise-config FILE --apprise-api-url URL --apprise-urls URLS --apprise-tags TAGS --bark-server URL --bark-device-key KEY --bark-sound SOUND --bark-group GROUP --ntfy-server URL --ntfy-topic TOPIC --ntfy-token TOKEN --feishu-webhook-url URL --wecom-webhook-url URL --dingtalk-webhook-url URL --gotify-server URL --gotify-token TOKEN --gotify-priority NUMBER Other: --server-name NAME Label shown in notifications --check / --no-check Offline configuration check --dependency-report Show dependencies without database access -h, --help Show help -V, --version Show version All settings also support the PGSQL_BACKUP_* environment variables documented in README.md. List environment values contain one item per line. CLI lists replace their environment counterparts. Only "--option value" is accepted. EOF } log() { local level=$1; shift; printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*"; } die() { FINAL_MESSAGE=$*; log ERROR "$*" >&2; exit 2; } require_value() { [[ $2 -ge 2 ]] || die "$1 requires a value"; } validate_dump_option() { local option=$1 ! value_has_control_characters "$option" || die "Dump options cannot contain control characters" case "$option" in --data-only|--schema-only|--blobs|--no-blobs|--no-owner|--no-privileges|--no-comments|--no-security-labels|--no-publications|--no-subscriptions|--inserts|--column-inserts|--on-conflict-do-nothing|--verbose|--no-synchronized-snapshots) ;; --schema=?*|--exclude-schema=?*|--table=?*|--exclude-table=?*|--exclude-table-data=?*|--rows-per-insert=?*|--lock-wait-timeout=?*) ;; *) die "Unsupported --dump-option: $option" ;; esac } load_environment_lists() { local value if [[ -n $DATABASE_VALUES ]]; then while IFS= read -r value; do [[ -n $value ]] || die "Database list contains an empty value"; DATABASES+=("$value"); done <<< "$DATABASE_VALUES" fi if [[ -n $DUMP_OPTION_VALUES ]]; then while IFS= read -r value; do [[ -n $value ]] || die "Dump option list contains an empty value"; DUMP_OPTIONS+=("$value"); done <<< "$DUMP_OPTION_VALUES" fi DATABASE_COUNT=${#DATABASES[@]} [[ $DATABASE_COUNT -eq 0 ]] && ALL_DATABASES_REQUESTED=true } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --target-dir) require_value "$1" "$#"; TARGET_DIR=$2; shift 2 ;; --host) require_value "$1" "$#"; DB_HOST=$2; DB_HOST_EXPLICIT=true; shift 2 ;; --port) require_value "$1" "$#"; DB_PORT=$2; DB_PORT_EXPLICIT=true; shift 2 ;; --user) require_value "$1" "$#"; DB_USER=$2; DB_USER_EXPLICIT=true; shift 2 ;; --password) require_value "$1" "$#"; [[ -n $2 ]] || die "--password cannot be empty"; DB_PASSWORD=$2; PASSWORD_CONFIGURED=true; shift 2 ;; --database) require_value "$1" "$#"; if [[ $CLI_DATABASES_SET == false ]]; then DATABASES=(); CLI_DATABASES_SET=true; fi; ALL_DATABASES_REQUESTED=false; [[ -n $2 ]] || die "--database cannot be empty"; DATABASES+=("$2"); shift 2 ;; --all-databases) DATABASES=(); CLI_DATABASES_SET=true; ALL_DATABASES_REQUESTED=true; shift ;; --maintenance-database) require_value "$1" "$#"; MAINTENANCE_DATABASE=$2; shift 2 ;; --passfile) require_value "$1" "$#"; PASSFILE=$2; shift 2 ;; --service-file) require_value "$1" "$#"; SERVICE_FILE=$2; shift 2 ;; --service) require_value "$1" "$#"; SERVICE=$2; shift 2 ;; --dump-option) require_value "$1" "$#"; if [[ $CLI_DUMP_OPTIONS_SET == false ]]; then DUMP_OPTIONS=(); CLI_DUMP_OPTIONS_SET=true; fi; validate_dump_option "$2"; DUMP_OPTIONS+=("$2"); shift 2 ;; --default-dump-options) DUMP_OPTIONS=(); CLI_DUMP_OPTIONS_SET=true; shift ;; --format) require_value "$1" "$#"; FORMAT=$2; shift 2 ;; --output-mode) require_value "$1" "$#"; OUTPUT_MODE=$2; shift 2 ;; --compression) require_value "$1" "$#"; COMPRESSION=$2; COMPRESSION_EXPLICIT=true; shift 2 ;; --globals-mode) require_value "$1" "$#"; GLOBALS_MODE=$2; shift 2 ;; --include-role-passwords) INCLUDE_ROLE_PASSWORDS=true; shift ;; --no-include-role-passwords) INCLUDE_ROLE_PASSWORDS=false; shift ;; --expire-hours) require_value "$1" "$#"; EXPIRE_HOURS=$2; shift 2 ;; --before-hook) require_value "$1" "$#"; BEFORE_HOOK=$2; shift 2 ;; --after-database-hook) require_value "$1" "$#"; AFTER_DATABASE_HOOK=$2; shift 2 ;; --after-hook) require_value "$1" "$#"; AFTER_HOOK=$2; shift 2 ;; --notify-start) NOTIFY_START=true; shift ;; --no-notify-start) NOTIFY_START=false; shift ;; --notify-redact) NOTIFY_REDACT=true; shift ;; --no-notify-redact) NOTIFY_REDACT=false; shift ;; --notify-timeout) require_value "$1" "$#"; NOTIFY_TIMEOUT=$2; shift 2 ;; --apprise-config) require_value "$1" "$#"; APPRISE_CLI_CONFIG=$2; shift 2 ;; --apprise-api-url) require_value "$1" "$#"; APPRISE_API_URL=$2; shift 2 ;; --apprise-urls) require_value "$1" "$#"; APPRISE_API_URLS=$2; shift 2 ;; --apprise-tags) require_value "$1" "$#"; APPRISE_TAGS=$2; shift 2 ;; --bark-server) require_value "$1" "$#"; BARK_SERVER=$2; shift 2 ;; --bark-device-key) require_value "$1" "$#"; BARK_DEVICE_KEY=$2; shift 2 ;; --bark-sound) require_value "$1" "$#"; BARK_SOUND=$2; shift 2 ;; --bark-group) require_value "$1" "$#"; BARK_GROUP=$2; shift 2 ;; --ntfy-server) require_value "$1" "$#"; NTFY_SERVER=$2; shift 2 ;; --ntfy-topic) require_value "$1" "$#"; NTFY_TOPIC=$2; shift 2 ;; --ntfy-token) require_value "$1" "$#"; NTFY_TOKEN=$2; shift 2 ;; --feishu-webhook-url) require_value "$1" "$#"; FEISHU_WEBHOOK_URL=$2; shift 2 ;; --wecom-webhook-url) require_value "$1" "$#"; WECOM_WEBHOOK_URL=$2; shift 2 ;; --dingtalk-webhook-url) require_value "$1" "$#"; DINGTALK_WEBHOOK_URL=$2; shift 2 ;; --gotify-server) require_value "$1" "$#"; GOTIFY_SERVER=$2; shift 2 ;; --gotify-token) require_value "$1" "$#"; GOTIFY_TOKEN=$2; shift 2 ;; --gotify-priority) require_value "$1" "$#"; GOTIFY_PRIORITY=$2; shift 2 ;; --server-name) require_value "$1" "$#"; SERVER_NAME=$2; shift 2 ;; --check) CHECK_ONLY=true; shift ;; --no-check) CHECK_ONLY=false; shift ;; --dependency-report) DEPENDENCY_REPORT=true; shift ;; -h|--help) usage; exit 0 ;; -V|--version) printf '%s %s\n' "$PROGRAM_NAME" "$SCRIPT_VERSION"; exit 0 ;; --) shift; [[ $# -eq 0 ]] || die "Positional arguments are not supported" ;; --password=*|--apprise-api-url=*|--apprise-urls=*|--bark-device-key=*|--ntfy-token=*|--feishu-webhook-url=*|--wecom-webhook-url=*|--dingtalk-webhook-url=*|--gotify-token=*) die "Inline secret forms are not supported" ;; --*=*) die "Inline option forms are not supported: ${1%%=*}" ;; *) die "Unknown option: $1" ;; esac done DATABASE_COUNT=${#DATABASES[@]} } command_exists() { command -v "$1" >/dev/null 2>&1 } http_notifications_requested() { [[ -n $APPRISE_API_URL || -n $APPRISE_API_URLS || -n $BARK_SERVER || -n $BARK_DEVICE_KEY || -n $NTFY_SERVER || -n $NTFY_TOPIC || -n $FEISHU_WEBHOOK_URL || -n $WECOM_WEBHOOK_URL || -n $DINGTALK_WEBHOOK_URL || -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]] } dependency_command_path() { local candidate for candidate in "$@"; do if command_exists "$candidate"; then command -v "$candidate" return 0 fi done return 1 } record_missing_dependency() { MISSING_DEPENDENCIES+=("$1") MISSING_DEPENDENCY_KINDS+=("$2") } inspect_dependency() { local label=$1 local kind=$2 local needed=$3 local skip_reason=$4 local verbose=$5 local path shift 5 if [[ $needed != true ]]; then [[ $verbose == true ]] && printf '[SKIP] %s: %s\n' "$label" "$skip_reason" return 0 fi if path=$(dependency_command_path "$@"); then [[ $verbose == true ]] && printf '[OK] %s: %s\n' "$label" "$path" return 0 fi [[ $verbose == true ]] && printf '[MISSING] %s\n' "$label" record_missing_dependency "$label" "$kind" } inspect_dependencies() { local verbose=${1:-false} local zip_needed=false local curl_needed=false local apprise_needed=false MISSING_DEPENDENCIES=() MISSING_DEPENDENCY_KINDS=() [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] && zip_needed=true http_notifications_requested && curl_needed=true [[ -n $APPRISE_CLI_CONFIG ]] && apprise_needed=true inspect_dependency "pg_dump" postgres true "" "$verbose" pg_dump inspect_dependency "pg_dumpall" postgres true "" "$verbose" pg_dumpall inspect_dependency "pg_restore" postgres true "" "$verbose" pg_restore inspect_dependency "psql" postgres true "" "$verbose" psql inspect_dependency "flock" flock true "" "$verbose" flock inspect_dependency "sha256sum or shasum" checksum true "" "$verbose" sha256sum shasum inspect_dependency "zip" zip "$zip_needed" "output mode is $OUTPUT_MODE" "$verbose" zip inspect_dependency "curl" curl "$curl_needed" "no HTTP notification channel is configured" "$verbose" curl inspect_dependency "apprise" apprise "$apprise_needed" "Apprise CLI is not configured" "$verbose" apprise inspect_dependency "timeout or gtimeout" timeout "$apprise_needed" "Apprise CLI is not configured" "$verbose" timeout gtimeout } strip_os_release_quotes() { local value=$1 if [[ ${#value} -ge 2 && ( ( ${value:0:1} == '"' && ${value: -1} == '"' ) || ( ${value:0:1} == "'" && ${value: -1} == "'" ) ) ]]; then value=${value:1:${#value}-2} fi printf '%s\n' "$value" } detect_system() { local key local value local uname_value SYSTEM_ID= SYSTEM_NAME= if [[ -r /etc/os-release ]]; then while IFS='=' read -r key value; do case "$key" in ID) SYSTEM_ID=$(strip_os_release_quotes "$value") ;; PRETTY_NAME) SYSTEM_NAME=$(strip_os_release_quotes "$value") ;; esac done < /etc/os-release fi if [[ -z $SYSTEM_NAME ]] && command_exists uname; then uname_value=$(uname -s 2>/dev/null || true) case "$uname_value" in Darwin) SYSTEM_ID=macos SYSTEM_NAME=macOS ;; Linux) SYSTEM_ID=${SYSTEM_ID:-linux} SYSTEM_NAME=Linux ;; *) SYSTEM_NAME=${uname_value:-Unknown} ;; esac fi SYSTEM_ID=${SYSTEM_ID:-unknown} SYSTEM_NAME=${SYSTEM_NAME:-Unknown} } detect_package_manager() { local manager PACKAGE_MANAGER= for manager in apt-get dnf yum apk zypper pacman brew; do if command_exists "$manager"; then PACKAGE_MANAGER=$manager return 0 fi done return 1 } dependency_package_name() { local kind=$1 case "$PACKAGE_MANAGER:$kind" in apt-get:postgres) printf 'postgresql-client\n' ;; dnf:postgres|yum:postgres) printf 'postgresql\n' ;; apk:postgres) printf 'postgresql-client\n' ;; zypper:postgres|pacman:postgres) printf 'postgresql\n' ;; brew:postgres) printf 'libpq\n' ;; *:flock) printf 'util-linux\n' ;; *:checksum|*:timeout) printf 'coreutils\n' ;; *:zip) printf 'zip\n' ;; *:curl) printf 'curl\n' ;; *) return 1 ;; esac } package_is_listed() { local expected=$1 shift local package for package in "$@"; do [[ $package == "$expected" ]] && return 0 done return 1 } print_install_guidance() { local -a packages=() local index local kind local package detect_system printf '\nDetected system: %s\n' "$SYSTEM_NAME" if ! detect_package_manager; then printf 'No supported package manager was found. Install the missing commands with your system package manager.\n' return 0 fi for ((index = 0; index < ${#MISSING_DEPENDENCY_KINDS[@]}; index++)); do kind=${MISSING_DEPENDENCY_KINDS[$index]} if package=$(dependency_package_name "$kind"); then package_is_listed "$package" "${packages[@]}" || packages+=("$package") fi done printf 'Detected package manager: %s\n' "$PACKAGE_MANAGER" if [[ ${#packages[@]} -gt 0 ]]; then printf '\nSuggested install command (review before running):\n' case "$PACKAGE_MANAGER" in apt-get) printf 'sudo apt-get update\n' printf 'sudo apt-get install' ;; dnf) printf 'sudo dnf install' ;; yum) printf 'sudo yum install' ;; apk) printf 'sudo apk add' ;; zypper) printf 'sudo zypper install' ;; pacman) printf 'sudo pacman -S' ;; brew) printf 'brew install' ;; esac printf ' %s' "${packages[@]}" printf '\n' fi if package_is_listed apprise "${MISSING_DEPENDENCY_KINDS[@]}"; then printf 'Apprise CLI: follow https://github.com/caronc/apprise#installation\n' fi printf 'The script does not execute these commands automatically.\n' } show_dependency_report() { printf 'Dependency report:\n\n' inspect_dependencies true if [[ ${#MISSING_DEPENDENCIES[@]} -eq 0 ]]; then printf '\nAll required dependencies are available.\n' return 0 fi print_install_guidance return 2 } validate_dependencies() { local missing_list local IFS=', ' inspect_dependencies false [[ ${#MISSING_DEPENDENCIES[@]} -eq 0 ]] && return 0 missing_list="${MISSING_DEPENDENCIES[*]}" printf 'Missing required dependencies: %s\n' "$missing_list" >&2 print_install_guidance >&2 FINAL_MESSAGE="Missing required dependencies: $missing_list" return 1 } file_mode() { local path=$1 stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null } file_owner() { local path=$1 stat -c '%u' "$path" 2>/dev/null || stat -f '%u' "$path" 2>/dev/null } file_size() { local path=$1 stat -c '%s' "$path" 2>/dev/null || stat -f '%z' "$path" 2>/dev/null } format_bytes() { local bytes=$1 local divisor=1 local unit=B local whole local tenths if (( bytes >= 1125899906842624 )); then divisor=1125899906842624 unit=PB elif (( bytes >= 1099511627776 )); then divisor=1099511627776 unit=TB elif (( bytes >= 1073741824 )); then divisor=1073741824 unit=GB elif (( bytes >= 1048576 )); then divisor=1048576 unit=MB elif (( bytes >= 1024 )); then divisor=1024 unit=KB else printf '%s B\n' "$bytes" return 0 fi whole=$((bytes / divisor)) tenths=$((((bytes % divisor) * 10 + divisor / 2) / divisor)) if (( tenths == 10 )); then whole=$((whole + 1)) tenths=0 fi printf '%s.%s %s\n' "$whole" "$tenths" "$unit" } backup_artifacts_size() { local artifact local artifact_size local total=0 local found=false [[ -n $PUBLISHED_DIR && -d $PUBLISHED_DIR ]] || return 1 while IFS= read -r -d '' artifact; do artifact_size=$(file_size "$artifact") || return 1 [[ $artifact_size =~ ^[0-9]+$ ]] || return 1 total=$((total + artifact_size)) found=true done < <(find "$PUBLISHED_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.dump' -o -name '*.zip' \) -print0) [[ $found == true ]] || return 1 format_bytes "$total" } normalize_decimal() { local value=$1 while [[ ${#value} -gt 1 && ${value:0:1} == 0 ]]; do value=${value:1} done printf '%s\n' "$value" } normalize_signed_decimal() { local value=$1 local sign= if [[ $value == -* ]]; then sign=- value=${value#-} fi value=$(normalize_decimal "$value") if [[ $value == 0 ]]; then sign= fi printf '%s%s\n' "$sign" "$value" } validate_secret_file() { local label=$1 local variable_name=$2 local path=${!variable_name} local mode local owner local current_uid local canonical_parent local canonical_path ! value_has_control_characters "$path" || die "$label path cannot contain control characters" [[ ! -L $path ]] || die "$label must not be a symbolic link: $path" canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || die "Cannot resolve parent directory for $label: $path" canonical_path="$canonical_parent/$(basename "$path")" path=$canonical_path [[ -f $path && -r $path ]] || die "$label is not a readable file: $path" mode=$(file_mode "$path") || die "Cannot read permissions for $label: $path" [[ $mode =~ ^[0-7]+$ ]] || die "Invalid permissions for $label: $path" (( (8#$mode & 077) == 0 )) || die "$label must not be accessible by group or others: $path" owner=$(file_owner "$path") || die "Cannot read owner for $label: $path" current_uid=$(id -u) || die "Cannot determine the current user ID" [[ $owner == "$current_uid" ]] || die "$label must be owned by the current user: $path" if ! check_trusted_directory_chain "$label parent directory" "$canonical_parent" "$current_uid"; then die "$FINAL_MESSAGE" fi printf -v "$variable_name" '%s' "$canonical_path" } check_trusted_directory_chain() { local label=$1 local directory=$2 local current_uid=$3 local mode local owner while :; do mode=$(file_mode "$directory") || { FINAL_MESSAGE="Cannot read $label permissions: $directory" return 1 } if [[ ! $mode =~ ^[0-7]+$ ]]; then FINAL_MESSAGE="Invalid $label permissions: $directory" return 1 fi if (( (8#$mode & 022) != 0 )); then FINAL_MESSAGE="$label must not be writable by group or others: $directory" return 1 fi owner=$(file_owner "$directory") || { FINAL_MESSAGE="Cannot read $label owner: $directory" return 1 } if [[ $owner != "$current_uid" && $owner != 0 ]]; then FINAL_MESSAGE="$label must be owned by the current user or root: $directory" return 1 fi [[ $directory == / ]] && break directory=$(dirname "$directory") done } validate_hook() { local label=$1 local variable_name=$2 local path=${!variable_name} local mode local owner local current_uid local logical_parent local canonical_parent local canonical_path [[ -z $path ]] && return 0 ! value_has_control_characters "$path" || die "$label path cannot contain control characters" [[ ! -L $path ]] || die "$label must not be a symbolic link: $path" [[ -f $path && -x $path ]] || die "$label must be an executable file: $path" logical_parent=$(cd "$(dirname "$path")" && pwd -L) || die "Cannot resolve parent directory for $label: $path" canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || die "Cannot resolve parent directory for $label: $path" [[ $logical_parent == "$canonical_parent" ]] || die "$label parent path must not contain symbolic links: $path" canonical_path="$canonical_parent/$(basename "$path")" mode=$(file_mode "$path") || die "Cannot read permissions for $label: $path" [[ $mode =~ ^[0-7]+$ ]] || die "Invalid permissions for $label: $path" (( (8#$mode & 022) == 0 )) || die "$label must not be writable by group or others: $path" owner=$(file_owner "$path") || die "Cannot read owner for $label: $path" current_uid=$(id -u) || die "Cannot determine the current user ID" [[ $owner == "$current_uid" ]] || die "$label must be owned by the current user: $path" if ! check_trusted_directory_chain "Hook parent directory" "$canonical_parent" "$current_uid"; then die "$FINAL_MESSAGE" fi printf -v "$variable_name" '%s' "$canonical_path" } prepare_target_dir() { local mode local owner local current_uid local canonical_parent [[ -n $TARGET_DIR ]] || die "--target-dir is required" ! value_has_control_characters "$TARGET_DIR" || die "Target directory cannot contain control characters" [[ -d $TARGET_DIR && ! -L $TARGET_DIR ]] || die "Target directory must already exist and must not be a symbolic link: $TARGET_DIR" TARGET_DIR=$(cd "$TARGET_DIR" && pwd -P) || die "Cannot resolve target directory: $TARGET_DIR" [[ $TARGET_DIR != / ]] || die "The filesystem root cannot be used as --target-dir" [[ -w $TARGET_DIR ]] || die "Target directory is not writable: $TARGET_DIR" mode=$(file_mode "$TARGET_DIR") || die "Cannot read target directory permissions: $TARGET_DIR" [[ $mode =~ ^[0-7]+$ ]] || die "Invalid target directory permissions: $TARGET_DIR" (( (8#$mode & 022) == 0 )) || die "Target directory must not be writable by group or others: $TARGET_DIR" owner=$(file_owner "$TARGET_DIR") || die "Cannot read target directory owner: $TARGET_DIR" current_uid=$(id -u) || die "Cannot determine the current user ID" [[ $owner == "$current_uid" ]] || die "Target directory must be owned by the current user: $TARGET_DIR" canonical_parent=$(dirname "$TARGET_DIR") if ! check_trusted_directory_chain "Target parent directory" "$canonical_parent" "$current_uid"; then die "$FINAL_MESSAGE" fi } check_private_file_ready() { local label=$1 local variable_name=$2 local path=${!variable_name} local mode local owner local current_uid local canonical_parent local canonical_path if value_has_control_characters "$path"; then FINAL_MESSAGE="$label path cannot contain control characters" return 1 fi if [[ -L $path ]]; then FINAL_MESSAGE="$label must not be a symbolic link: $path" return 1 fi canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || { FINAL_MESSAGE="Cannot resolve parent directory for $label: $path" return 1 } canonical_path="$canonical_parent/$(basename "$path")" path=$canonical_path if [[ ! -f $path || ! -r $path ]]; then FINAL_MESSAGE="$label is not a readable file: $path" return 1 fi mode=$(file_mode "$path") || { FINAL_MESSAGE="Cannot read permissions for $label: $path" return 1 } if [[ ! $mode =~ ^[0-7]+$ ]] || (( (8#$mode & 077) != 0 )); then FINAL_MESSAGE="$label must not be accessible by group or others: $path" return 1 fi owner=$(file_owner "$path") || { FINAL_MESSAGE="Cannot read owner for $label: $path" return 1 } current_uid=$(id -u) || { FINAL_MESSAGE="Cannot determine the current user ID" return 1 } if [[ $owner != "$current_uid" ]]; then FINAL_MESSAGE="$label must be owned by the current user: $path" return 1 fi if ! check_trusted_directory_chain "$label parent directory" "$canonical_parent" "$current_uid"; then return 1 fi printf -v "$variable_name" '%s' "$canonical_path" } notification_config_problem() { local channel=$1 shift local message=$1 shift local variable_name if [[ $CHECK_ONLY == true ]]; then die "$channel notification configuration is invalid: $message" fi log WARN "$channel notification disabled: $message" >&2 for variable_name in "$@"; do printf -v "$variable_name" '%s' '' done } url_is_http() { local url=$1 local authority ! value_has_control_characters "$url" || return 1 [[ $url != *[[:space:]]* ]] || return 1 if [[ $url == https://* ]]; then authority=${url#https://} authority=${authority%%[/?#]*} [[ -n $authority ]] return fi [[ $url == http://* ]] || return 1 authority=${url#http://} authority=${authority%%[/?#]*} [[ $authority != *@* ]] || return 1 [[ $authority =~ ^(localhost|127\.0\.0\.1)(:[0-9]{1,5})?$ || $authority =~ ^\[::1\](:[0-9]{1,5})?$ ]] } value_has_control_characters() { local value=$1 local LC_ALL=C [[ $value =~ [[:cntrl:]] ]] } validate_http_channel_url() { local channel=$1 local variable_name=$2 local url=${!variable_name} [[ -z $url ]] && return 0 if ! url_is_http "$url"; then notification_config_problem "$channel" "$variable_name must use https://; http:// is allowed only for loopback addresses" "$variable_name" fi } configure_notifications() { local gotify_magnitude local http_channel_count=0 local notification_requested=false if [[ -n $APPRISE_CLI_CONFIG || -n $APPRISE_API_URL || -n $APPRISE_API_URLS || -n $APPRISE_TAGS || -n $BARK_SERVER || -n $BARK_DEVICE_KEY || -n $BARK_SOUND || -n $BARK_GROUP || -n $NTFY_SERVER || -n $NTFY_TOPIC || -n $NTFY_TOKEN || -n $FEISHU_WEBHOOK_URL || -n $WECOM_WEBHOOK_URL || -n $DINGTALK_WEBHOOK_URL || -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]]; then notification_requested=true fi [[ $notification_requested == true ]] || return 0 if [[ ! $NOTIFY_TIMEOUT =~ ^[0-9]+$ ]]; then if [[ $CHECK_ONLY == true ]]; then die "Notification timeout (--notify-timeout / PGSQL_BACKUP_NOTIFY_TIMEOUT) must be an integer between 1 and 300" fi log WARN "Invalid notification timeout; using 10 seconds" >&2 NOTIFY_TIMEOUT=10 else NOTIFY_TIMEOUT=$(normalize_decimal "$NOTIFY_TIMEOUT") if [[ ${#NOTIFY_TIMEOUT} -gt 3 ]] || (( NOTIFY_TIMEOUT < 1 || NOTIFY_TIMEOUT > 300 )); then if [[ $CHECK_ONLY == true ]]; then die "Notification timeout (--notify-timeout / PGSQL_BACKUP_NOTIFY_TIMEOUT) must be between 1 and 300" fi log WARN "Invalid notification timeout; using 10 seconds" >&2 NOTIFY_TIMEOUT=10 fi fi if [[ -n $APPRISE_CLI_CONFIG ]] && value_has_control_characters "$APPRISE_CLI_CONFIG"; then notification_config_problem "Apprise CLI" "configuration path contains control characters" APPRISE_CLI_CONFIG fi if [[ -n $APPRISE_CLI_CONFIG ]]; then if ! check_private_file_ready "Apprise CLI configuration" APPRISE_CLI_CONFIG; then notification_config_problem "Apprise CLI" "$FINAL_MESSAGE" APPRISE_CLI_CONFIG FINAL_MESSAGE= elif ! command_exists apprise; then if [[ $CHECK_ONLY == false ]]; then notification_config_problem "Apprise CLI" "apprise was not found in PATH" APPRISE_CLI_CONFIG fi elif command_exists timeout; then TIMEOUT_COMMAND=timeout elif command_exists gtimeout; then TIMEOUT_COMMAND=gtimeout elif [[ $CHECK_ONLY == false ]]; then notification_config_problem "Apprise CLI" "timeout or gtimeout was not found in PATH" APPRISE_CLI_CONFIG fi fi if [[ -n $APPRISE_API_URLS && -z $APPRISE_API_URL ]]; then notification_config_problem "Apprise API" "APPRISE_API_URLS requires APPRISE_API_URL" APPRISE_API_URLS fi if value_has_control_characters "$APPRISE_API_URLS"; then notification_config_problem "Apprise API" "service URLs contain control characters" APPRISE_API_URL APPRISE_API_URLS fi if value_has_control_characters "$APPRISE_TAGS"; then notification_config_problem "Apprise" "tags contain control characters" APPRISE_TAGS fi if [[ -n $BARK_SERVER || -n $BARK_DEVICE_KEY ]]; then if [[ -z $BARK_SERVER || -z $BARK_DEVICE_KEY ]]; then notification_config_problem "Bark" "BARK_SERVER and BARK_DEVICE_KEY are both required" BARK_SERVER BARK_DEVICE_KEY fi fi if value_has_control_characters "$BARK_DEVICE_KEY$BARK_SOUND$BARK_GROUP"; then notification_config_problem "Bark" "Bark device key, sound, or group contains control characters" BARK_SERVER BARK_DEVICE_KEY BARK_SOUND BARK_GROUP fi if [[ -n $NTFY_SERVER || -n $NTFY_TOPIC ]]; then if [[ -z $NTFY_SERVER || -z $NTFY_TOPIC ]]; then notification_config_problem "ntfy" "NTFY_SERVER and NTFY_TOPIC are both required" NTFY_SERVER NTFY_TOPIC fi fi if value_has_control_characters "$NTFY_TOPIC$NTFY_TOKEN"; then notification_config_problem "ntfy" "ntfy topic or token contains control characters" NTFY_SERVER NTFY_TOPIC fi if [[ -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]]; then if [[ -z $GOTIFY_SERVER || -z $GOTIFY_TOKEN ]]; then notification_config_problem "Gotify" "GOTIFY_SERVER and GOTIFY_TOKEN are both required" GOTIFY_SERVER GOTIFY_TOKEN elif value_has_control_characters "$GOTIFY_TOKEN"; then notification_config_problem "Gotify" "Gotify token contains control characters" GOTIFY_SERVER GOTIFY_TOKEN elif [[ ! $GOTIFY_PRIORITY =~ ^-?[0-9]+$ ]]; then notification_config_problem "Gotify" "priority must be an integer between -9999 and 9999" GOTIFY_SERVER GOTIFY_TOKEN else GOTIFY_PRIORITY=$(normalize_signed_decimal "$GOTIFY_PRIORITY") gotify_magnitude=${GOTIFY_PRIORITY#-} if [[ ${#gotify_magnitude} -gt 4 ]]; then notification_config_problem "Gotify" "priority must be between -9999 and 9999" GOTIFY_SERVER GOTIFY_TOKEN fi fi fi validate_http_channel_url "Apprise API" APPRISE_API_URL validate_http_channel_url "Bark" BARK_SERVER validate_http_channel_url "ntfy" NTFY_SERVER validate_http_channel_url "Feishu" FEISHU_WEBHOOK_URL validate_http_channel_url "WeCom" WECOM_WEBHOOK_URL validate_http_channel_url "DingTalk" DINGTALK_WEBHOOK_URL validate_http_channel_url "Gotify" GOTIFY_SERVER [[ -n $APPRISE_API_URL ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $BARK_SERVER ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $NTFY_SERVER ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $FEISHU_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $WECOM_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $DINGTALK_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1)) [[ -n $GOTIFY_SERVER ]] && http_channel_count=$((http_channel_count + 1)) if [[ $http_channel_count -gt 0 ]] && ! command_exists curl; then if [[ $CHECK_ONLY == false ]]; then notification_config_problem "HTTP" "curl was not found in PATH" APPRISE_API_URL BARK_SERVER NTFY_SERVER FEISHU_WEBHOOK_URL WECOM_WEBHOOK_URL DINGTALK_WEBHOOK_URL GOTIFY_SERVER http_channel_count=0 fi fi NOTIFICATION_NAMES=() NOTIFICATION_SENDERS=() NOTIFICATION_VALIDATORS=() [[ -n $APPRISE_CLI_CONFIG ]] && register_notification_channel "Apprise CLI" send_apprise_cli_notification notification_response_accepted [[ -n $APPRISE_API_URL ]] && register_notification_channel "Apprise API" send_apprise_api_notification validate_apprise_api_response [[ -n $BARK_SERVER ]] && register_notification_channel "Bark" send_bark_notification validate_bark_response [[ -n $NTFY_SERVER ]] && register_notification_channel "ntfy" send_ntfy_notification notification_response_accepted [[ -n $FEISHU_WEBHOOK_URL ]] && register_notification_channel "Feishu" send_feishu_notification validate_feishu_response [[ -n $WECOM_WEBHOOK_URL ]] && register_notification_channel "WeCom" send_wecom_notification validate_errcode_response [[ -n $DINGTALK_WEBHOOK_URL ]] && register_notification_channel "DingTalk" send_dingtalk_notification validate_errcode_response [[ -n $GOTIFY_SERVER ]] && register_notification_channel "Gotify" send_gotify_notification notification_response_accepted NOTIFICATION_CHANNEL_COUNT=${#NOTIFICATION_NAMES[@]} if [[ $NOTIFICATION_CHANNEL_COUNT -eq 0 ]]; then if [[ $CHECK_ONLY == true ]]; then die "Notification configuration does not enable a complete channel" fi log WARN "Notification configuration does not enable a complete channel" >&2 fi } register_notification_channel() { NOTIFICATION_NAMES+=("$1") NOTIFICATION_SENDERS+=("$2") NOTIFICATION_VALIDATORS+=("$3") } validate_config() { local database dump_option seen="\n" [[ $NOTIFY_REDACT == true || $NOTIFY_REDACT == false ]] || die "Notification redaction setting must be true or false" [[ $NOTIFY_START == true || $NOTIFY_START == false ]] || die "Notify-start setting must be true or false" [[ $CHECK_ONLY == true || $CHECK_ONLY == false ]] || die "Check setting must be true or false" [[ $INCLUDE_ROLE_PASSWORDS == true || $INCLUDE_ROLE_PASSWORDS == false ]] || die "Role-password setting must be true or false" case "$FORMAT" in custom|plain) ;; *) die "--format must be custom or plain" ;; esac case "$OUTPUT_MODE" in zip|files|both) ;; *) die "--output-mode must be zip, files, or both" ;; esac case "$GLOBALS_MODE" in auto|include|skip) ;; *) die "--globals-mode must be auto, include, or skip" ;; esac [[ $COMPRESSION =~ ^[0-9]$ ]] || die "--compression must be between 0 and 9" [[ $FORMAT == custom || $COMPRESSION_EXPLICIT == false ]] || die "--compression only applies to custom format" [[ ! ( $GLOBALS_MODE == skip && $INCLUDE_ROLE_PASSWORDS == true ) ]] || die "Role passwords cannot be included when globals are skipped" [[ -z $SERVICE_FILE || -n $SERVICE ]] || die "--service-file requires --service" [[ ! ( $PASSWORD_CONFIGURED == true && -n $PASSFILE ) ]] || die "Direct password and passfile are mutually exclusive" [[ ! ( $PASSWORD_CONFIGURED == true && -n $SERVICE ) ]] || die "Direct password cannot be combined with service; use a passfile" CURRENT_PHASE=validation configure_notifications if [[ $CHECK_ONLY == false && $NOTIFICATION_CHANNEL_COUNT -gt 0 ]]; then trap on_exit EXIT; trap 'on_signal INT' INT; trap 'on_signal TERM' TERM; NOTIFY_ON_EXIT=true fi prepare_target_dir for database in "$DB_HOST" "$DB_USER" "$MAINTENANCE_DATABASE" "$SERVICE" "$SERVER_NAME"; do ! value_has_control_characters "$database" || die "Configuration values cannot contain control characters" done [[ -n $MAINTENANCE_DATABASE ]] || die "--maintenance-database cannot be empty" [[ $DB_PORT =~ ^[0-9]+$ ]] || die "--port must be between 1 and 65535" DB_PORT=$(normalize_decimal "$DB_PORT") (( DB_PORT >= 1 && DB_PORT <= 65535 )) || die "--port must be between 1 and 65535" [[ $EXPIRE_HOURS =~ ^[0-9]+$ ]] || die "--expire-hours must be a non-negative integer" EXPIRE_HOURS=$(normalize_decimal "$EXPIRE_HOURS") (( EXPIRE_HOURS <= 8760000 )) || die "--expire-hours must not exceed 8760000" if [[ $PASSWORD_CONFIGURED == true ]]; then ! value_has_control_characters "$DB_PASSWORD" || die "Database password cannot contain control characters" fi for database in "${DATABASES[@]}"; do [[ -n $database ]] || die "Database names cannot be empty" ! value_has_control_characters "$database" || die "Database names cannot contain control characters" [[ $seen != *$'\n'"$database"$'\n'* ]] || die "Duplicate database: $database" seen+="$database"$'\n' done for dump_option in "${DUMP_OPTIONS[@]}"; do validate_dump_option "$dump_option"; done [[ -z $PASSFILE ]] || validate_secret_file "PostgreSQL passfile" PASSFILE [[ -z $SERVICE_FILE ]] || validate_secret_file "PostgreSQL service file" SERVICE_FILE validate_hook "Before hook" BEFORE_HOOK validate_hook "After database hook" AFTER_DATABASE_HOOK validate_hook "After hook" AFTER_HOOK validate_dependencies || exit 2 if [[ -n $SERVICE ]]; then CONNECTION_SOURCE=service; fi if [[ $PASSWORD_CONFIGURED == true ]]; then CREDENTIAL_SOURCE=password elif [[ -n $PASSFILE && -n $SERVICE ]]; then CREDENTIAL_SOURCE=service+passfile elif [[ -n $PASSFILE ]]; then CREDENTIAL_SOURCE=passfile elif [[ -n $SERVICE ]]; then CREDENTIAL_SOURCE=service else CREDENTIAL_SOURCE=default fi } cleanup_stage() { [[ -n $STAGE_DIR && -d $STAGE_DIR ]] || return 0 case "$STAGE_DIR" in "$TARGET_DIR"/.staging/*) rm -rf -- "$STAGE_DIR" || { log ERROR "Cannot remove staging directory: $STAGE_DIR" >&2 return 1 } ;; *) log ERROR "Refusing to remove unexpected staging path: $STAGE_DIR" >&2 return 1 ;; esac } cleanup_error_log() { [[ -n $ERROR_LOG && -e $ERROR_LOG ]] || return 0 case "$ERROR_LOG" in "$TARGET_DIR"/.staging/pgback_*.error.log) rm -f -- "$ERROR_LOG" || { log ERROR "Cannot remove runtime error log: $ERROR_LOG" >&2 return 1 } ;; *) log ERROR "Refusing to remove unexpected error log path: $ERROR_LOG" >&2 return 1 ;; esac } database_summary() { local IFS=, if [[ $NOTIFY_REDACT == true ]]; then printf '[redacted]\n'; else printf '%s\n' "${DATABASES[*]:-ALL}"; fi } connection_value() { if [[ $CONNECTION_SOURCE == service && $1 != true ]]; then printf 'profile-managed\n'; else printf '%s\n' "$2"; fi } database_connection_summary() { if [[ $NOTIFY_REDACT == true ]]; then printf 'source=%s; host=[redacted]; port=[redacted]; user=[redacted]\n' "$CONNECTION_SOURCE" else printf 'source=%s; host=%s; port=%s; user=%s\n' "$CONNECTION_SOURCE" "$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")" "$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")" "$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")" fi } json_escape() { local value=$1 value=$(LC_ALL=C printf '%s' "$value" | tr -d '\000-\010\013\014\016-\037') value=${value//\\/\\\\} value=${value//\"/\\\"} value=${value//$'\n'/\\n} value=${value//$'\r'/\\r} value=${value//$'\t'/\\t} printf '%s' "$value" } strip_trailing_slashes() { local value=$1 while [[ $value == */ ]]; do value=${value%/} done printf '%s\n' "$value" } curl_config_escape() { local value=$1 value=${value//\\/\\\\} value=${value//\"/\\\"} printf '%s' "$value" } notification_response_accepted() { return 0 } validate_apprise_api_response() { local http_code=$1 [[ $http_code == 200 ]] } validate_bark_response() { local response=$2 [[ $response =~ \"code\"[[:space:]]*:[[:space:]]*200 ]] } validate_feishu_response() { local response=$2 [[ $response =~ \"StatusCode\"[[:space:]]*:[[:space:]]*0 || $response =~ \"code\"[[:space:]]*:[[:space:]]*0 ]] } validate_errcode_response() { local response=$2 [[ $response =~ \"errcode\"[[:space:]]*:[[:space:]]*0 ]] } post_notification_json() { local channel=$1 local url=$2 local payload=$3 local secret_header=$4 local response_validator=$5 local curl_config local output local response local http_code curl_config=$(printf 'url = "%s"\n' "$(curl_config_escape "$url")") if [[ -n $secret_header ]]; then curl_config+=$'\n' curl_config+=$(printf 'header = "%s"\n' "$(curl_config_escape "$secret_header")") fi if ! output=$(curl \ --disable \ --silent \ --connect-timeout 5 \ --max-time "$NOTIFY_TIMEOUT" \ --max-filesize 65536 \ --request POST \ --header 'Content-Type: application/json' \ --config /dev/fd/3 \ --data-binary @- \ --write-out $'\n%{http_code}' \ 3<<<"$curl_config" \ <<<"$payload" \ 2>/dev/null); then log WARN "$channel notification request failed" >&2 return 1 fi http_code=${output##*$'\n'} response=${output%$'\n'*} if [[ ! $http_code =~ ^2[0-9][0-9]$ ]]; then log WARN "$channel notification returned HTTP $http_code" >&2 return 1 fi if ! "$response_validator" "$http_code" "$response"; then log WARN "$channel notification service rejected the request (HTTP $http_code)" >&2 return 1 fi return 0 } send_apprise_cli_notification() { shift local type=$1 local title=$2 local body=$3 if [[ -n $APPRISE_TAGS ]]; then "$TIMEOUT_COMMAND" --signal=TERM --kill-after=2 "$NOTIFY_TIMEOUT" apprise \ -c "$APPRISE_CLI_CONFIG" \ -n "$type" \ -t "$title" \ -g "$APPRISE_TAGS" <<<"$body" >/dev/null 2>&1 else "$TIMEOUT_COMMAND" --signal=TERM --kill-after=2 "$NOTIFY_TIMEOUT" apprise \ -c "$APPRISE_CLI_CONFIG" \ -n "$type" \ -t "$title" <<<"$body" >/dev/null 2>&1 fi } send_apprise_api_notification() { local response_validator=$1 local type=$2 local title=$3 local body=$4 local payload payload=$(printf '{"body":"%s","title":"%s","type":"%s","format":"text"' \ "$(json_escape "$body")" \ "$(json_escape "$title")" \ "$(json_escape "$type")") if [[ -n $APPRISE_API_URLS ]]; then payload+=",\"urls\":\"$(json_escape "$APPRISE_API_URLS")\"" fi if [[ -n $APPRISE_TAGS ]]; then payload+=",\"tag\":\"$(json_escape "$APPRISE_TAGS")\"" fi payload+='}' post_notification_json "Apprise API" "$APPRISE_API_URL" "$payload" "" "$response_validator" } send_bark_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local server local payload server=$(strip_trailing_slashes "$BARK_SERVER") payload=$(printf '{"title":"%s","body":"%s"' \ "$(json_escape "$title")" \ "$(json_escape "$body")") if [[ -n $BARK_SOUND ]]; then payload+=",\"sound\":\"$(json_escape "$BARK_SOUND")\"" fi if [[ -n $BARK_GROUP ]]; then payload+=",\"group\":\"$(json_escape "$BARK_GROUP")\"" fi payload+='}' post_notification_json "Bark" "$server/$BARK_DEVICE_KEY" "$payload" "" "$response_validator" } send_ntfy_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local server local payload server=$(strip_trailing_slashes "$NTFY_SERVER") payload=$(printf '{"topic":"%s","title":"%s","message":"%s"}' \ "$(json_escape "$NTFY_TOPIC")" \ "$(json_escape "$title")" \ "$(json_escape "$body")") if [[ -n $NTFY_TOKEN ]]; then post_notification_json "ntfy" "$server" "$payload" \ "Authorization: Bearer $NTFY_TOKEN" "$response_validator" else post_notification_json "ntfy" "$server" "$payload" "" "$response_validator" fi } send_feishu_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local content local payload content=$(printf '%s\n\n%s' "$title" "$body") payload=$(printf '{"msg_type":"text","content":{"text":"%s"}}' \ "$(json_escape "$content")") post_notification_json "Feishu" "$FEISHU_WEBHOOK_URL" "$payload" "" "$response_validator" } send_wecom_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local content local payload content=$(printf '%s\n\n%s' "$title" "$body") payload=$(printf '{"msgtype":"text","text":{"content":"%s"}}' \ "$(json_escape "$content")") post_notification_json "WeCom" "$WECOM_WEBHOOK_URL" "$payload" "" "$response_validator" } send_dingtalk_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local content local payload content=$(printf '%s\n\n%s' "$title" "$body") payload=$(printf '{"msgtype":"text","text":{"content":"%s"}}' \ "$(json_escape "$content")") post_notification_json "DingTalk" "$DINGTALK_WEBHOOK_URL" "$payload" "" "$response_validator" } send_gotify_notification() { local response_validator=$1 local _type=$2 local title=$3 local body=$4 local server local payload server=$(strip_trailing_slashes "$GOTIFY_SERVER") payload=$(printf '{"title":"%s","message":"%s","priority":%s}' \ "$(json_escape "$title")" \ "$(json_escape "$body")" \ "$GOTIFY_PRIORITY") post_notification_json "Gotify" "$server/message" "$payload" \ "X-Gotify-Key: $GOTIFY_TOKEN" "$response_validator" } send_notification_channel() { local channel=$1 local sender=$2 local response_validator=$3 shift 3 if "$sender" "$response_validator" "$@"; then log INFO "$channel notification sent" return 0 fi log WARN "$channel notification failed" >&2 return 1 } notify_event() { local event=$1 local message=$2 local type local title local body local backup_size local notification_message=$message local output_value=${PUBLISHED_DIR:-not-published} local elapsed=0 local success_count=0 local index local pid local -a notification_pids=() [[ $NOTIFICATION_CHANNEL_COUNT -gt 0 ]] || return 0 case "$event" in start) type=info ;; success) type=success ;; failure) type=failure ;; *) log WARN "Skipping unknown notification event: $event" return 0 ;; esac if [[ $NOTIFY_REDACT == true ]]; then [[ -z $PUBLISHED_DIR ]] || output_value='[redacted]' case "$event" in start) notification_message="Backup started" ;; success) notification_message="Backup completed" ;; failure) notification_message="Backup failed; check local logs" ;; esac fi if [[ $START_EPOCH -gt 0 ]]; then elapsed=$(($(date +%s) - START_EPOCH)) fi title="[$SERVER_NAME] PostgreSQL backup $event" body=$(printf '%s\n' \ "Result: $event" \ "Server: $SERVER_NAME" \ "Database connection: $(database_connection_summary)" \ "Databases: $(database_summary)" \ "Run ID: ${RUN_ID:-not-created}" \ "Phase: $CURRENT_PHASE" \ "Output: $output_value" \ "Elapsed: ${elapsed}s" \ "Message: $notification_message") if backup_size=$(backup_artifacts_size); then body=$(printf '%s\nBackup size: %s' "$body" "$backup_size") fi for ((index = 0; index < NOTIFICATION_CHANNEL_COUNT; index++)); do send_notification_channel \ "${NOTIFICATION_NAMES[$index]}" \ "${NOTIFICATION_SENDERS[$index]}" \ "${NOTIFICATION_VALIDATORS[$index]}" \ "$type" \ "$title" \ "$body" & notification_pids+=("$!") done for pid in "${notification_pids[@]}"; do if wait "$pid"; then success_count=$((success_count + 1)) fi done log INFO "Notifications sent: $success_count/$NOTIFICATION_CHANNEL_COUNT channel(s)" [[ $success_count -gt 0 ]] } on_exit() { local status=$? local event local message trap - EXIT INT TERM set +e exec 8<&- cleanup_stage cleanup_error_log clear_secrets exec 9<&- if [[ $NOTIFY_ON_EXIT == true ]]; then if [[ $status -eq 0 ]]; then event=success message=${FINAL_MESSAGE:-Backup completed} else event=failure message=${FINAL_MESSAGE:-Backup command failed during $CURRENT_PHASE} fi notify_event "$event" "$message" fi exit "$status" } on_signal() { local signal=$1 FINAL_MESSAGE="Backup interrupted by $signal during $CURRENT_PHASE" log ERROR "Backup interrupted by $signal during $CURRENT_PHASE" >&2 if [[ $signal == INT ]]; then exit 130 fi exit 143 } read_error_log() { [[ -s $ERROR_LOG ]] || return 0 LC_ALL=C tail -c 8192 "$ERROR_LOG" | LC_ALL=C tr -d '\000-\011\013-\037\177' | LC_ALL=C sed 's/[^[:print:]]/?/g' | tail -n 20 } fail_with_error_log() { local message=$1 local detail detail=$(read_error_log) if [[ -n $detail ]]; then die "$message: $detail" fi die "$message" } acquire_lock() { local managed_path CURRENT_PHASE=locking for managed_path in "$TARGET_DIR/.staging" "$TARGET_DIR/runs"; do [[ ! -L $managed_path ]] || die "Managed path must not be a symbolic link: $managed_path" done mkdir -p -- "$TARGET_DIR/.staging" "$TARGET_DIR/runs" || die "Cannot prepare backup directories" for managed_path in "$TARGET_DIR/.staging" "$TARGET_DIR/runs"; do validate_managed_directory "$managed_path" done exec 9<"$TARGET_DIR" || die "Cannot open target directory for locking" flock -n 9 || die "Another backup task is already running for $TARGET_DIR" } validate_managed_directory() { local path=$1 local mode local owner local current_uid [[ -d $path && ! -L $path ]] || die "Managed path must be a real directory: $path" mode=$(file_mode "$path") || die "Cannot read managed directory permissions: $path" [[ $mode =~ ^[0-7]+$ ]] || die "Invalid managed directory permissions: $path" (( (8#$mode & 022) == 0 )) || die "Managed directory must not be writable by group or others: $path" owner=$(file_owner "$path") || die "Cannot read managed directory owner: $path" current_uid=$(id -u) || die "Cannot determine the current user ID" [[ $owner == "$current_uid" ]] || die "Managed directory must be owned by the current user: $path" } create_stage() { CURRENT_PHASE=staging RUN_ID="pgback_$(date -u '+%Y%m%dT%H%M%SZ')_$$" STAGE_DIR=$(mktemp -d "$TARGET_DIR/.staging/${RUN_ID}.XXXXXX") || die "Cannot create staging directory" chmod 700 "$STAGE_DIR" || die "Cannot protect staging directory" ERROR_LOG="$TARGET_DIR/.staging/${RUN_ID}.error.log" (set -o noclobber; : > "$ERROR_LOG") 2>/dev/null || die "Cannot create runtime error log" chmod 600 "$ERROR_LOG" || die "Cannot protect runtime error log" } run_pg_command() { local database=$1 database_option=$2 client=$3 shift 3 local -a command=("$client" "$database_option" "$(database_conninfo "$database")") if [[ -z $SERVICE || $DB_HOST_EXPLICIT == true ]]; then command+=(--host "$DB_HOST"); fi if [[ -z $SERVICE || $DB_PORT_EXPLICIT == true ]]; then command+=(--port "$DB_PORT"); fi if [[ -z $SERVICE || $DB_USER_EXPLICIT == true ]]; then command+=(--username "$DB_USER"); fi command+=("$@") ( unset PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE export PGDATABASE="$database" if [[ $PASSWORD_CONFIGURED == true ]]; then export PGPASSWORD="$DB_PASSWORD"; fi if [[ -n $PASSFILE ]]; then export PGPASSFILE="$PASSFILE"; fi if [[ -n $SERVICE ]]; then export PGSERVICE="$SERVICE"; fi if [[ -n $SERVICE_FILE ]]; then export PGSERVICEFILE="$SERVICE_FILE"; fi exec "${command[@]}" ) } database_conninfo() { local value=$1 # A dbname option may otherwise reinterpret values containing '=' or a URI # prefix as a full conninfo string. Quote it as a conninfo literal instead. value=${value//\\/\\\\} value=${value//\'/\\\'} printf "dbname='%s'" "$value" } clear_secrets() { DB_PASSWORD=; unset PGPASSWORD PGPASSFILE PGSERVICEFILE 2>/dev/null || true; } run_hook() { local label=$1 hook=$2 [[ -n $hook ]] || return 0 log INFO "Running $label hook: $hook" if ! env -i PATH="$PATH" HOME="${HOME:-}" BACKUP_RUN_ID="$RUN_ID" BACKUP_TARGET_DIR="$TARGET_DIR" BACKUP_STAGE_DIR="$STAGE_DIR" BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" "$hook" 2>"$ERROR_LOG"; then fail_with_error_log "$label hook failed"; fi } verify_database_artifact() { local file=$1 path="$STAGE_DIR/$1" [[ -f $path && ! -L $path && -s $path ]] || die "Database artifact is missing, linked, or empty: $file" if [[ $FORMAT == custom ]]; then pg_restore --list "$path" >/dev/null 2>"$ERROR_LOG" || fail_with_error_log "Custom archive verification failed: $file" else LC_ALL=C head -20 "$path" | grep -q 'PostgreSQL database dump' || die "Plain SQL header verification failed: $file" fi } verify_all_database_artifacts() { local file for file in "${DATABASE_FILES[@]}"; do verify_database_artifact "$file"; done } run_after_database_hook() { local database=$1 file=$2 index=$3 [[ -n $AFTER_DATABASE_HOOK ]] || return 0 CURRENT_PHASE=after-database-hook if ! env -i PATH="$PATH" HOME="${HOME:-}" BACKUP_RUN_ID="$RUN_ID" BACKUP_TARGET_DIR="$TARGET_DIR" BACKUP_STAGE_DIR="$STAGE_DIR" BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" BACKUP_DATABASE_NAME="$database" BACKUP_DATABASE_FILE="$STAGE_DIR/$file" BACKUP_DATABASE_INDEX="$index" BACKUP_DATABASE_TOTAL="$DATABASE_COUNT" BACKUP_DUMP_FORMAT="$FORMAT" BACKUP_OUTPUT_MODE="$OUTPUT_MODE" "$AFTER_DATABASE_HOOK" 2>"$ERROR_LOG"; then fail_with_error_log "After database hook failed"; fi verify_all_database_artifacts } query_scalar() { local sql=$1 run_pg_command "$MAINTENANCE_DATABASE" --dbname psql -X -A -t -v ON_ERROR_STOP=1 --no-password --command "$sql" } check_connection_and_versions() { local server_num client_major server_major tool tool_version tool_major CURRENT_PHASE=connection-check server_num=$(query_scalar 'SHOW server_version_num' 2>"$ERROR_LOG") || fail_with_error_log "PostgreSQL connection check failed" [[ $server_num =~ ^[0-9]+$ ]] || die "Server returned an invalid version" SERVER_VERSION=$server_num CLIENT_VERSION=$(LC_ALL=C pg_dump --version 2>"$ERROR_LOG") || fail_with_error_log "Cannot read pg_dump version" client_major=$(printf '%s' "$CLIENT_VERSION" | sed -E 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/') [[ $client_major =~ ^[0-9]+$ ]] || die "Cannot parse pg_dump version" for tool in pg_dumpall pg_restore psql; do tool_version=$(LC_ALL=C "$tool" --version 2>"$ERROR_LOG") || fail_with_error_log "Cannot read $tool version" tool_major=$(printf '%s' "$tool_version" | sed -E 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/') [[ $tool_major =~ ^[0-9]+$ ]] || die "Cannot parse $tool version" [[ $tool_major == "$client_major" ]] || die "PostgreSQL client major versions do not match: pg_dump=$client_major, $tool=$tool_major" done server_major=$((server_num / 10000)) (( client_major >= server_major )) || die "pg_dump client major version $client_major is older than server major version $server_major" } discover_databases() { local database discovery_file seen=$'\n' CURRENT_PHASE="database-discovery" if [[ ${#DATABASES[@]} -eq 0 ]]; then discovery_file=$STAGE_DIR/.databases.nul if ! run_pg_command "$MAINTENANCE_DATABASE" --dbname psql -X -A -t -0 -v ON_ERROR_STOP=1 --no-password --command "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname" >"$discovery_file" 2>"$ERROR_LOG"; then rm -f -- "$discovery_file" fail_with_error_log "Database discovery failed" fi while IFS= read -r -d '' database; do DATABASES+=("$database"); done <"$discovery_file" rm -f -- "$discovery_file" [[ ${#DATABASES[@]} -gt 0 ]] || fail_with_error_log "No connectable non-template databases were discovered" fi for database in "${DATABASES[@]}"; do [[ -n $database ]] || die "Database names cannot be empty" ! value_has_control_characters "$database" || die "Database names cannot contain control characters" [[ $seen != *$'\n'"$database"$'\n'* ]] || die "Duplicate database: $database" seen+="$database"$'\n' done DATABASE_COUNT=${#DATABASES[@]} if [[ $GLOBALS_MODE == include || ( $GLOBALS_MODE == auto && $ALL_DATABASES_REQUESTED == true ) ]]; then ACTUAL_GLOBALS_MODE=include; else ACTUAL_GLOBALS_MODE=skip; fi } safe_database_slug() { local slug slug=$(LC_ALL=C printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'); [[ -n $slug ]] || slug=database printf '%s\n' "${slug:0:MAX_DATABASE_SLUG_BYTES}" } dump_databases() { local index database filename extension started ended CURRENT_PHASE=dumping-database [[ $FORMAT == custom ]] && extension=dump || extension=sql for ((index=0; index"$ERROR_LOG" || fail_with_error_log "pg_dump failed" else run_pg_command "$database" --dbname pg_dump --create --no-password --file "$STAGE_DIR/$filename" --format plain "${DUMP_OPTIONS[@]}" 2>"$ERROR_LOG" || fail_with_error_log "pg_dump failed" fi verify_database_artifact "$filename" run_after_database_hook "$database" "$filename" "$((index+1))" ended=$(date +%s); DATABASE_ENDS+=("$ended"); DATABASE_SIZES+=("$(file_size "$STAGE_DIR/$filename")") done } dump_globals() { [[ $ACTUAL_GLOBALS_MODE == include ]] || return 0 CURRENT_PHASE=dumping-globals [[ $INCLUDE_ROLE_PASSWORDS == true ]] && log WARN "Role password hashes will be included in globals.sql" if [[ $INCLUDE_ROLE_PASSWORDS == true ]]; then run_pg_command "$MAINTENANCE_DATABASE" --database pg_dumpall --globals-only --no-password --file "$STAGE_DIR/globals.sql" 2>"$ERROR_LOG" || fail_with_error_log "pg_dumpall failed" else run_pg_command "$MAINTENANCE_DATABASE" --database pg_dumpall --globals-only --no-password --file "$STAGE_DIR/globals.sql" --no-role-passwords 2>"$ERROR_LOG" || fail_with_error_log "pg_dumpall failed" fi [[ -f $STAGE_DIR/globals.sql && ! -L $STAGE_DIR/globals.sql && -s $STAGE_DIR/globals.sql ]] || die "pg_dumpall created an invalid globals.sql" } create_archive() { local -a names=("${DATABASE_FILES[@]}") if [[ $ACTUAL_GLOBALS_MODE == include ]]; then names+=(globals.sql); fi [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] || return 0 CURRENT_PHASE=compressing if [[ $FORMAT == custom ]]; then (cd "$STAGE_DIR" && zip -q -0 backup.zip.tmp "${names[@]}") || die "zip failed"; else (cd "$STAGE_DIR" && zip -q backup.zip.tmp "${names[@]}") || die "zip failed"; fi zip -T "$STAGE_DIR/backup.zip.tmp" >/dev/null || die "backup.zip failed its integrity check" mv -- "$STAGE_DIR/backup.zip.tmp" "$STAGE_DIR/backup.zip" || die "Cannot finalize backup.zip" if [[ $OUTPUT_MODE == zip ]]; then rm -- "${DATABASE_FILES[@]/#/$STAGE_DIR/}" if [[ $ACTUAL_GLOBALS_MODE == include ]]; then rm -- "$STAGE_DIR/globals.sql"; fi fi } write_manifest() { local end_epoch index artifact CURRENT_PHASE=manifest; end_epoch=$(date +%s) { printf 'format_version=1\nrun_id=%s\n' "$RUN_ID"; } > "$STAGE_DIR/.pgsql-onekey-backup-run" || die "Cannot write ownership marker" { printf 'format_version=1\nscript_version=%s\nrun_id=%s\nserver_name=%s\n' "$SCRIPT_VERSION" "$RUN_ID" "$SERVER_NAME" printf 'connection_source=%s\ncredential_source=%s\n' "$CONNECTION_SOURCE" "$CREDENTIAL_SOURCE" printf 'db_host=%s\ndb_port=%s\ndb_user=%s\nmaintenance_database=%s\n' "$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")" "$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")" "$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")" "$MAINTENANCE_DATABASE" printf 'client_version=%s\nserver_version=%s\ndump_format=%s\noutput_mode=%s\nglobals_mode=%s\nrole_passwords_included=%s\n' "$CLIENT_VERSION" "$SERVER_VERSION" "$FORMAT" "$OUTPUT_MODE" "$ACTUAL_GLOBALS_MODE" "$INCLUDE_ROLE_PASSWORDS" printf 'started_epoch=%s\nfinished_epoch=%s\nelapsed_seconds=%s\n' "$START_EPOCH" "$end_epoch" "$((end_epoch-START_EPOCH))" for ((index=0; index "$STAGE_DIR/manifest.txt" || die "Cannot write manifest" } write_and_verify_checksums() { local -a artifacts=(); local artifact CURRENT_PHASE=checksums while IFS= read -r -d '' artifact; do artifacts+=("$(basename "$artifact")"); done < <(find "$STAGE_DIR" -maxdepth 1 -type f ! -name 'SHA256SUMS' -print0) [[ ${#artifacts[@]} -gt 0 ]] || die "No backup artifacts were created" if command_exists sha256sum; then (cd "$STAGE_DIR" && sha256sum -- "${artifacts[@]}" > SHA256SUMS && sha256sum -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"; else (cd "$STAGE_DIR" && shasum -a 256 "${artifacts[@]}" > SHA256SUMS && shasum -a 256 -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"; fi } publish_run() { local target="$TARGET_DIR/runs/$RUN_ID"; CURRENT_PHASE=publishing; [[ ! -e $target ]] || die "Backup run already exists"; mv -- "$STAGE_DIR" "$target" || die "Cannot publish backup run"; PUBLISHED_DIR=$target; STAGE_DIR=; } identity_file_matches() { local path=$1 expected=$2 [[ -f $path && ! -L $path ]] || return 1 [[ $(grep -c '^format_version=1$' "$path") -eq 1 && $(grep -c "^run_id=$expected$" "$path") -eq 1 ]] } managed_run_is_valid() { local id; [[ -d $1 && ! -L $1 ]] || return 1; id=$(basename "$1"); identity_file_matches "$1/.pgsql-onekey-backup-run" "$id" && identity_file_matches "$1/manifest.txt" "$id"; } prune_expired_runs() { local minutes expired (( EXPIRE_HOURS > 0 )) || return 0; CURRENT_PHASE=retention; minutes=$((EXPIRE_HOURS*60)) while IFS= read -r -d '' expired; do case "$expired" in "$TARGET_DIR"/runs/pgback_*) if managed_run_is_valid "$expired"; then log INFO "Removing expired managed run: $expired"; rm -rf -- "$expired" || die "Cannot remove expired run"; else log WARN "Skipping unrecognized backup directory: $expired"; fi ;; *) die "Refusing unexpected retention path" ;; esac; done < <(find "$TARGET_DIR/runs" -mindepth 1 -maxdepth 1 -type d -name 'pgback_*' -mmin "+$minutes" -print0) } run_backup() { local end_epoch trap on_exit EXIT; trap 'on_signal INT' INT; trap 'on_signal TERM' TERM; NOTIFY_ON_EXIT=true START_EPOCH=$(date +%s); acquire_lock; create_stage; check_connection_and_versions; discover_databases [[ $NOTIFY_START == false ]] || notify_event start "Backup started" || true CURRENT_PHASE=before-hook; run_hook Before "$BEFORE_HOOK" dump_databases; dump_globals; verify_all_database_artifacts; create_archive; write_manifest; write_and_verify_checksums; publish_run; prune_expired_runs clear_secrets; CURRENT_PHASE=after-hook; run_hook After "$AFTER_HOOK" CURRENT_PHASE=complete; end_epoch=$(date +%s); FINAL_MESSAGE="Backup completed: $PUBLISHED_DIR ($((end_epoch-START_EPOCH))s)"; log INFO "$FINAL_MESSAGE" } main() { if (( BASH_VERSINFO[0] < 4 || ( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 ) )); then printf 'Bash 4.2 or newer is required.\n' >&2 return 2 fi load_environment_lists; parse_args "$@" if [[ $DEPENDENCY_REPORT == true ]]; then case "$OUTPUT_MODE" in zip|files|both) ;; *) die "--output-mode must be zip, files, or both" ;; esac; show_dependency_report; return $?; fi validate_config if [[ $CHECK_ONLY == true ]]; then log INFO "Configuration check passed."; return 0; fi run_backup } main "$@"