#!/usr/bin/env bash # ============================================================================ # MySQL OneKey Backup # # Version: v1.2.0 # Author: Leon (silenceace@gmail.com) # Repo: https://github.com/funnyzak/mysql-onekey-backup # License: MIT # # Requirements: Bash 4.2+, mysqldump, flock, sha256sum/shasum, and optionally # mysql and zip. mysql is required for separate all-database dumps; 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 mysql_backup.sh \ # --target-dir /var/backups/mysql \ # --defaults-extra-file /etc/mysql-backup/client.cnf \ # --database app --database analytics # # Password environment-variable example (preferred over --password for Cron): # export MYSQL_BACKUP_PASSWORD='replace-with-your-password' # bash mysql_backup.sh \ # --target-dir /var/backups/mysql \ # --host 127.0.0.1 --port 3306 --user backup \ # --database app # # Password command-line example (may be visible in ps and shell history): # bash mysql_backup.sh \ # --target-dir /var/backups/mysql \ # --host 127.0.0.1 --port 3306 --user backup \ # --password 'replace-with-your-password' \ # --database app # # Check configuration without connecting, backing up, or sending notifications: # bash mysql_backup.sh --target-dir /var/backups/mysql --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 MYSQL_BACKUP_BARK_SERVER=https://api.day.app # export MYSQL_BACKUP_BARK_DEVICE_KEY=replace-with-device-key # export MYSQL_BACKUP_BARK_SOUND=alarm # optional # export MYSQL_BACKUP_BARK_GROUP=mysql-backup # optional # export MYSQL_BACKUP_NTFY_SERVER=https://ntfy.sh # export MYSQL_BACKUP_NTFY_TOPIC=mysql-backup # export MYSQL_BACKUP_NTFY_TOKEN=replace-with-token # optional # export MYSQL_BACKUP_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-me # export MYSQL_BACKUP_WECOM_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me' # export MYSQL_BACKUP_DINGTALK_WEBHOOK_URL='https://oapi.dingtalk.com/robot/send?access_token=replace-me' # export MYSQL_BACKUP_GOTIFY_SERVER=https://gotify.example.com # export MYSQL_BACKUP_GOTIFY_TOKEN=replace-with-app-token # export MYSQL_BACKUP_GOTIFY_PRIORITY=5 # optional # export MYSQL_BACKUP_NOTIFY_REDACT=true # optional, default # export MYSQL_BACKUP_NOTIFY_TIMEOUT=10 # optional, 1-300 # bash mysql_backup.sh --target-dir /var/backups/mysql --database app # # The same Bark settings can be written as command-line options: # bash mysql_backup.sh \ # --target-dir /var/backups/mysql --database app \ # --bark-server https://api.day.app \ # --bark-device-key replace-with-device-key \ # --bark-sound alarm --bark-group mysql-backup --notify-timeout 10 # # Apprise API supports both stateful and stateless curl calls: # export MYSQL_BACKUP_APPRISE_API_URL=http://127.0.0.1:8000/notify/mysql-backup # # For stateless /notify, also set MYSQL_BACKUP_APPRISE_URLS. # Apprise CLI remains available when MYSQL_BACKUP_APPRISE_CONFIG points to a # protected Apprise configuration file. MYSQL_BACKUP_APPRISE_TAGS accepts a # comma-separated tag expression. # export MYSQL_BACKUP_APPRISE_CONFIG=/etc/mysql-backup/apprise.conf # export MYSQL_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/mysql-backup/mysql_backup.sh --target-dir /var/backups/mysql --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.2.0" readonly PROGRAM_NAME="mysql-onekey-backup" readonly MAX_DATABASE_SLUG_BYTES=160 TARGET_DIR=${MYSQL_BACKUP_TARGET_DIR:-} DB_HOST=${MYSQL_BACKUP_HOST:-127.0.0.1} DB_PORT=${MYSQL_BACKUP_PORT:-3306} DB_USER=${MYSQL_BACKUP_USER:-root} DB_PASSWORD=${MYSQL_BACKUP_PASSWORD:-} DEFAULTS_EXTRA_FILE=${MYSQL_BACKUP_DEFAULTS_FILE:-} LOGIN_PATH=${MYSQL_BACKUP_LOGIN_PATH:-} OUTPUT_MODE=${MYSQL_BACKUP_OUTPUT_MODE:-zip} EXPIRE_HOURS=${MYSQL_BACKUP_EXPIRE_HOURS:-4320} SERVER_NAME=${MYSQL_BACKUP_SERVER_NAME:-$(hostname 2>/dev/null || printf 'unknown')} NOTIFY_START=${MYSQL_BACKUP_NOTIFY_START:-false} NOTIFY_REDACT=${MYSQL_BACKUP_NOTIFY_REDACT:-true} NOTIFY_TIMEOUT=${MYSQL_BACKUP_NOTIFY_TIMEOUT:-10} BEFORE_HOOK=${MYSQL_BACKUP_BEFORE_HOOK:-} AFTER_DATABASE_HOOK=${MYSQL_BACKUP_AFTER_DATABASE_HOOK:-} AFTER_HOOK=${MYSQL_BACKUP_AFTER_HOOK:-} BEFORE_HOOK_COMMAND=${MYSQL_BACKUP_BEFORE_HOOK_COMMAND:-} AFTER_DATABASE_HOOK_COMMAND=${MYSQL_BACKUP_AFTER_DATABASE_HOOK_COMMAND:-} AFTER_HOOK_COMMAND=${MYSQL_BACKUP_AFTER_HOOK_COMMAND:-} DATABASE_VALUES=${MYSQL_BACKUP_DATABASES:-} DUMP_OPTION_VALUES=${MYSQL_BACKUP_DUMP_OPTIONS:-} SEPARATE_FILES=${MYSQL_BACKUP_SEPARATE_FILES:-true} CHECK_ONLY=${MYSQL_BACKUP_CHECK:-false} DEPENDENCY_REPORT=false APPRISE_CLI_CONFIG=${MYSQL_BACKUP_APPRISE_CONFIG:-} APPRISE_API_URL=${MYSQL_BACKUP_APPRISE_API_URL:-} APPRISE_API_URLS=${MYSQL_BACKUP_APPRISE_URLS:-} APPRISE_TAGS=${MYSQL_BACKUP_APPRISE_TAGS:-} BARK_SERVER=${MYSQL_BACKUP_BARK_SERVER:-} BARK_DEVICE_KEY=${MYSQL_BACKUP_BARK_DEVICE_KEY:-} BARK_SOUND=${MYSQL_BACKUP_BARK_SOUND:-} BARK_GROUP=${MYSQL_BACKUP_BARK_GROUP:-} NTFY_SERVER=${MYSQL_BACKUP_NTFY_SERVER:-} NTFY_TOPIC=${MYSQL_BACKUP_NTFY_TOPIC:-} NTFY_TOKEN=${MYSQL_BACKUP_NTFY_TOKEN:-} FEISHU_WEBHOOK_URL=${MYSQL_BACKUP_FEISHU_WEBHOOK_URL:-} WECOM_WEBHOOK_URL=${MYSQL_BACKUP_WECOM_WEBHOOK_URL:-} DINGTALK_WEBHOOK_URL=${MYSQL_BACKUP_DINGTALK_WEBHOOK_URL:-} GOTIFY_SERVER=${MYSQL_BACKUP_GOTIFY_SERVER:-} GOTIFY_TOKEN=${MYSQL_BACKUP_GOTIFY_TOKEN:-} GOTIFY_PRIORITY=${MYSQL_BACKUP_GOTIFY_PRIORITY:-5} DB_HOST_EXPLICIT=false DB_PORT_EXPLICIT=false DB_USER_EXPLICIT=false PASSWORD_CONFIGURED=false if [[ -n ${MYSQL_BACKUP_HOST+x} ]]; then DB_HOST_EXPLICIT=true fi if [[ -n ${MYSQL_BACKUP_PORT+x} ]]; then DB_PORT_EXPLICIT=true fi if [[ -n ${MYSQL_BACKUP_USER+x} ]]; then DB_USER_EXPLICIT=true fi if [[ -n $DB_PASSWORD ]]; then PASSWORD_CONFIGURED=true fi export -n \ TARGET_DIR \ DB_HOST \ DB_PORT \ DB_USER \ DB_PASSWORD \ DB_HOST_EXPLICIT \ DB_PORT_EXPLICIT \ DB_USER_EXPLICIT \ PASSWORD_CONFIGURED \ DEFAULTS_EXTRA_FILE \ LOGIN_PATH \ OUTPUT_MODE \ EXPIRE_HOURS \ SERVER_NAME \ NOTIFY_START \ NOTIFY_REDACT \ NOTIFY_TIMEOUT \ BEFORE_HOOK \ AFTER_DATABASE_HOOK \ AFTER_HOOK \ BEFORE_HOOK_COMMAND \ AFTER_DATABASE_HOOK_COMMAND \ AFTER_HOOK_COMMAND \ DATABASE_VALUES \ DUMP_OPTION_VALUES \ SEPARATE_FILES \ 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 # Keep script configuration in this shell only. Child processes receive only # the explicit BACKUP_* hook variables and notification request values. unset \ MYSQL_BACKUP_TARGET_DIR \ MYSQL_BACKUP_HOST \ MYSQL_BACKUP_PORT \ MYSQL_BACKUP_USER \ MYSQL_BACKUP_PASSWORD \ MYSQL_BACKUP_DATABASES \ MYSQL_BACKUP_DEFAULTS_FILE \ MYSQL_BACKUP_LOGIN_PATH \ MYSQL_BACKUP_DUMP_OPTIONS \ MYSQL_BACKUP_SEPARATE_FILES \ MYSQL_BACKUP_OUTPUT_MODE \ MYSQL_BACKUP_EXPIRE_HOURS \ MYSQL_BACKUP_SERVER_NAME \ MYSQL_BACKUP_NOTIFY_START \ MYSQL_BACKUP_NOTIFY_REDACT \ MYSQL_BACKUP_NOTIFY_TIMEOUT \ MYSQL_BACKUP_BEFORE_HOOK \ MYSQL_BACKUP_AFTER_DATABASE_HOOK \ MYSQL_BACKUP_AFTER_HOOK \ MYSQL_BACKUP_BEFORE_HOOK_COMMAND \ MYSQL_BACKUP_AFTER_DATABASE_HOOK_COMMAND \ MYSQL_BACKUP_AFTER_HOOK_COMMAND \ MYSQL_BACKUP_CHECK \ MYSQL_BACKUP_APPRISE_CONFIG \ MYSQL_BACKUP_APPRISE_API_URL \ MYSQL_BACKUP_APPRISE_URLS \ MYSQL_BACKUP_APPRISE_TAGS \ MYSQL_BACKUP_BARK_SERVER \ MYSQL_BACKUP_BARK_DEVICE_KEY \ MYSQL_BACKUP_BARK_SOUND \ MYSQL_BACKUP_BARK_GROUP \ MYSQL_BACKUP_NTFY_SERVER \ MYSQL_BACKUP_NTFY_TOPIC \ MYSQL_BACKUP_NTFY_TOKEN \ MYSQL_BACKUP_FEISHU_WEBHOOK_URL \ MYSQL_BACKUP_WECOM_WEBHOOK_URL \ MYSQL_BACKUP_DINGTALK_WEBHOOK_URL \ MYSQL_BACKUP_GOTIFY_SERVER \ MYSQL_BACKUP_GOTIFY_TOKEN \ MYSQL_BACKUP_GOTIFY_PRIORITY declare -a DATABASES=() declare -a DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables) declare -a MYSQLDUMP_COMMAND=() declare -a MYSQL_COMMAND=() declare -a SQL_FILES=() declare -a NOTIFICATION_NAMES=() declare -a NOTIFICATION_SENDERS=() declare -a NOTIFICATION_VALIDATORS=() declare -a MISSING_DEPENDENCIES=() declare -a MISSING_DEPENDENCY_KINDS=() DATABASE_COUNT=0 CLI_DATABASES_SET=false CLI_DUMP_OPTIONS_SET=false CLI_DEFAULTS_FILE_SET=false CLI_LOGIN_PATH_SET=false CLI_PASSWORD_SET=false NOTIFICATION_CHANNEL_COUNT=0 TIMEOUT_COMMAND= MYSQLDUMP_DISABLE_LOGIN_PATHS=false ALL_DATABASES_REQUESTED=false RUN_ID= STAGE_DIR= PUBLISHED_DIR= ERROR_LOG= CURRENT_PHASE=initialization START_EPOCH=0 FINAL_MESSAGE= NOTIFY_ON_EXIT=false usage() { cat <<'EOF' Usage: mysql_backup.sh [options] Required: --target-dir DIR Existing private backup root directory Database: --host HOST Database host (default: 127.0.0.1) --port PORT Database port (default: 3306) --user USER Database user (default: root) --password PASSWORD Database password; see security warning below --database NAME Database to back up; repeat as needed --all-databases Override MYSQL_BACKUP_DATABASES and back up all --defaults-extra-file FILE MySQL client option file --login-path NAME MySQL login path --dump-option OPTION Allowlisted mysqldump option; repeat as needed --default-dump-options Ignore MYSQL_BACKUP_DUMP_OPTIONS --single-file Put databases in one SQL file --separate-files Put each database in its own SQL file (default) Output: --output-mode MODE zip, sql, or both (default: zip) --expire-hours HOURS Delete managed runs older than HOURS; 0 disables --before-hook FILE Executable run before mysqldump --before-hook-command CMD Shell command run before mysqldump --after-database-hook FILE Executable run after each separate database dump --after-database-hook-command CMD Shell command run after each separate database dump --after-hook FILE Executable run after publishing and pruning --after-hook-command CMD Shell command run after publishing and pruning Notifications: --notify-start Also send a start notification --no-notify-start Do not send a start notification --notify-redact Hide sensitive notification details (default) --no-notify-redact Include connection, database, path, errors --notify-timeout SECONDS Notification timeout, 1-300 (default: 10) --apprise-config FILE Protected Apprise CLI configuration file --apprise-api-url URL Apprise API /notify or /notify/{key} URL --apprise-urls URLS Stateless Apprise service URLs --apprise-tags TAGS Apprise tag expression --bark-server URL Bark server URL --bark-device-key KEY Bark device key --bark-sound SOUND Bark sound name --bark-group GROUP Bark notification group --ntfy-server URL ntfy server URL --ntfy-topic TOPIC ntfy topic --ntfy-token TOKEN ntfy access token --feishu-webhook-url URL Feishu robot webhook --wecom-webhook-url URL WeCom robot webhook --dingtalk-webhook-url URL DingTalk robot webhook --gotify-server URL Gotify server URL --gotify-token TOKEN Gotify application token --gotify-priority NUMBER Gotify priority, -9999 to 9999 (default: 5) Other: --server-name NAME Name shown in notifications --check Validate configuration without backing up --no-check Run the backup when MYSQL_BACKUP_CHECK=true --dependency-report List required tools and installation guidance -h, --help Show this help -V, --version Show version Every configurable setting has an environment-variable and command-line form. Command-line values override environment variables. Secret command-line values may be visible to local process-list viewers or saved in shell history; see README.md before using Cron. MYSQL_BACKUP_PASSWORD and --password are supported. The script sends the password to MySQL client commands through an anonymous file descriptor; it is not written to disk. A protected option file or login path is safer for production. Newline-separated environment values map to repeated --database and --dump-option arguments. See README.md for the complete mapping. 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() { local option=$1 local count=$2 [[ $count -ge 2 ]] || die "$option requires a value" } validate_dump_option() { local option=$1 ! value_has_control_characters "$option" || die "Dump options cannot contain control characters" case "$option" in --password|--password=*|-p|-p?*|--defaults-extra-file|--defaults-extra-file=*|--login-path|--login-path=*|--no-defaults) die "Credential options are not allowed through --dump-option" ;; --add-drop-database|--add-drop-table|--add-locks|--allow-keywords|--comments|--compact|--complete-insert|--create-options|--disable-keys|--dump-date|--events|--extended-insert|--force|--hex-blob|--insert-ignore|--lock-tables|--no-autocommit|--no-create-db|--no-create-info|--no-data|--no-set-names|--no-tablespaces|--opt|--order-by-primary|--quick|--quote-names|--replace|--routines|--single-transaction|--skip-add-drop-table|--skip-add-locks|--skip-comments|--skip-disable-keys|--skip-dump-date|--skip-extended-insert|--skip-lock-tables|--skip-opt|--skip-quick|--skip-quote-names|--skip-triggers|--skip-tz-utc|--triggers|--tz-utc) return 0 ;; --column-statistics=0|--column-statistics=1|--set-gtid-purged=OFF|--set-gtid-purged=ON|--set-gtid-purged=AUTO|--source-data=1|--source-data=2|--master-data=1|--master-data=2) return 0 ;; --compatible=*|--default-character-set=*|--ignore-table=*|--ignore-table-data=*|--max-allowed-packet=*|--net-buffer-length=*|--where=*) return 0 ;; *) die "Unsupported --dump-option: $option" ;; esac } load_environment_lists() { local value if [[ -n $DATABASE_VALUES ]]; then while IFS= read -r value; do [[ -n $value ]] || continue DATABASES+=("$value") done <<< "$DATABASE_VALUES" DATABASE_COUNT=${#DATABASES[@]} fi if [[ -n $DUMP_OPTION_VALUES ]]; then while IFS= read -r value; do [[ -n $value ]] || continue DUMP_OPTIONS+=("$value") done <<< "$DUMP_OPTION_VALUES" fi } 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" "$#" [[ $CLI_DEFAULTS_FILE_SET == false && $CLI_LOGIN_PATH_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path" [[ -n $2 ]] || die "--password cannot be empty" DB_PASSWORD=$2 PASSWORD_CONFIGURED=true DEFAULTS_EXTRA_FILE= LOGIN_PATH= CLI_PASSWORD_SET=true shift 2 ;; --database) require_value "$1" "$#" if [[ $CLI_DATABASES_SET == false ]]; then DATABASES=() DATABASE_COUNT=0 CLI_DATABASES_SET=true fi [[ -n $2 ]] || die "--database cannot be empty" [[ $2 != -* ]] || die "Database names cannot start with '-'" DATABASES+=("$2") DATABASE_COUNT=$((DATABASE_COUNT + 1)) shift 2 ;; --all-databases) DATABASES=() DATABASE_COUNT=0 CLI_DATABASES_SET=true shift ;; --defaults-extra-file) require_value "$1" "$#" [[ $CLI_LOGIN_PATH_SET == false && $CLI_PASSWORD_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path" [[ -n $2 ]] || die "--defaults-extra-file cannot be empty" DEFAULTS_EXTRA_FILE=$2 LOGIN_PATH= DB_PASSWORD= PASSWORD_CONFIGURED=false CLI_DEFAULTS_FILE_SET=true shift 2 ;; --login-path) require_value "$1" "$#" [[ $CLI_DEFAULTS_FILE_SET == false && $CLI_PASSWORD_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path" [[ -n $2 ]] || die "--login-path cannot be empty" LOGIN_PATH=$2 DEFAULTS_EXTRA_FILE= DB_PASSWORD= PASSWORD_CONFIGURED=false CLI_LOGIN_PATH_SET=true shift 2 ;; --dump-option) require_value "$1" "$#" if [[ $CLI_DUMP_OPTIONS_SET == false ]]; then DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables) CLI_DUMP_OPTIONS_SET=true fi validate_dump_option "$2" DUMP_OPTIONS+=("$2") shift 2 ;; --default-dump-options) DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables) CLI_DUMP_OPTIONS_SET=true shift ;; --single-file) SEPARATE_FILES=false shift ;; --separate-files) SEPARATE_FILES=true shift ;; --output-mode) require_value "$1" "$#" OUTPUT_MODE=$2 shift 2 ;; --expire-hours) require_value "$1" "$#" EXPIRE_HOURS=$2 shift 2 ;; --before-hook) require_value "$1" "$#" BEFORE_HOOK=$2 BEFORE_HOOK_COMMAND= shift 2 ;; --before-hook-command) require_value "$1" "$#" BEFORE_HOOK_COMMAND=$2 BEFORE_HOOK= shift 2 ;; --after-database-hook) require_value "$1" "$#" AFTER_DATABASE_HOOK=$2 AFTER_DATABASE_HOOK_COMMAND= shift 2 ;; --after-database-hook-command) require_value "$1" "$#" AFTER_DATABASE_HOOK_COMMAND=$2 AFTER_DATABASE_HOOK= shift 2 ;; --after-hook) require_value "$1" "$#" AFTER_HOOK=$2 AFTER_HOOK_COMMAND= shift 2 ;; --after-hook-command) require_value "$1" "$#" AFTER_HOOK_COMMAND=$2 AFTER_HOOK= 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=*|-p|-p?*) die "Inline password forms are not supported; use --password PASSWORD" ;; --apprise-api-url=*|--apprise-urls=*|--bark-server=*|--bark-device-key=*|--ntfy-server=*|--ntfy-token=*|--feishu-webhook-url=*|--wecom-webhook-url=*|--dingtalk-webhook-url=*|--gotify-server=*|--gotify-token=*) die "Inline secret forms are not supported; pass the option and value as separate arguments" ;; *) die "Unknown option: $1" ;; esac done } 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 local mysql_needed=false MISSING_DEPENDENCIES=() MISSING_DEPENDENCY_KINDS=() [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] && zip_needed=true [[ $DATABASE_COUNT -eq 0 && $SEPARATE_FILES == true ]] && mysql_needed=true http_notifications_requested && curl_needed=true [[ -n $APPRISE_CLI_CONFIG ]] && apprise_needed=true inspect_dependency "mysqldump" mysql true "" "$verbose" mysqldump inspect_dependency "mysql" mysql "$mysql_needed" "single-file or explicit database mode" "$verbose" mysql 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:mysql) printf 'default-mysql-client\n' ;; dnf:mysql|yum:mysql) if [[ $SYSTEM_ID == fedora ]]; then printf 'community-mysql\n'; else printf 'mysql\n'; fi ;; apk:mysql) printf 'mariadb-client\n' ;; zypper:mysql) printf 'mariadb-client\n' ;; pacman:mysql) printf 'mariadb-clients\n' ;; brew:mysql) printf 'mysql-client\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 '*.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" } validate_hook_command() { local label=$1 local variable_name=$2 local command=${!variable_name} [[ -z $command ]] && return 0 ! value_has_control_characters "$command" || die "$label command cannot contain control characters" } validate_hook_configuration() { local label=$1 local hook_variable=$2 local command_variable=$3 local hook=${!hook_variable} local command=${!command_variable} [[ -n $hook && -n $command ]] && die "$label must configure either a script or a command, not both" validate_hook "$label" "$hook_variable" validate_hook_command "$label" "$command_variable" } 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 / MYSQL_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 / MYSQL_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() { [[ $NOTIFY_REDACT == true || $NOTIFY_REDACT == false ]] || die "Notification redaction setting must be true or false" ! value_has_control_characters "$SERVER_NAME" || die "Server name cannot contain control characters" [[ $CHECK_ONLY == true || $CHECK_ONLY == false ]] || die "Check setting must be true or false" case "$OUTPUT_MODE" in zip|sql|both) ;; *) die "--output-mode must be zip, sql, or both" ;; esac configure_notifications CURRENT_PHASE=validation 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 [[ -n $DB_HOST ]] || die "--host cannot be empty" [[ -n $DB_USER ]] || die "--user cannot be empty" ! value_has_control_characters "$DB_HOST" || die "Database host cannot contain control characters" ! value_has_control_characters "$DB_USER" || die "Database user cannot contain control characters" [[ $DB_PORT =~ ^[0-9]+$ ]] || die "--port must be between 1 and 65535" DB_PORT=$(normalize_decimal "$DB_PORT") if [[ ${#DB_PORT} -gt 5 ]] || (( DB_PORT < 1 || DB_PORT > 65535 )); then die "--port must be between 1 and 65535" fi [[ $EXPIRE_HOURS =~ ^[0-9]+$ ]] || die "--expire-hours must be a non-negative integer" EXPIRE_HOURS=$(normalize_decimal "$EXPIRE_HOURS") if [[ ${#EXPIRE_HOURS} -gt 7 ]] || (( EXPIRE_HOURS > 8760000 )); then die "--expire-hours must not exceed 8760000" fi [[ $NOTIFY_START == true || $NOTIFY_START == false ]] || die "Notify-start setting must be true or false" [[ $SEPARATE_FILES == true || $SEPARATE_FILES == false ]] || die "Separate-files setting must be true or false" local database local dump_option if [[ $DATABASE_COUNT -gt 0 ]]; then for database in "${DATABASES[@]}"; do [[ -n $database ]] || die "Database names cannot be empty" [[ $database != -* ]] || die "Database names cannot start with '-'" ! value_has_control_characters "$database" || die "Database names cannot contain control characters" done fi for dump_option in "${DUMP_OPTIONS[@]}"; do validate_dump_option "$dump_option" done if [[ $PASSWORD_CONFIGURED == true && ( -n $DEFAULTS_EXTRA_FILE || -n $LOGIN_PATH ) ]] || [[ -n $DEFAULTS_EXTRA_FILE && -n $LOGIN_PATH ]]; then die "Use only one of --password / MYSQL_BACKUP_PASSWORD, --defaults-extra-file, or --login-path" fi if [[ $PASSWORD_CONFIGURED == true ]] && value_has_control_characters "$DB_PASSWORD"; then die "Database password cannot contain control characters" fi if [[ -n $LOGIN_PATH ]] && value_has_control_characters "$LOGIN_PATH"; then die "Login Path cannot contain control characters" fi if [[ -n $DEFAULTS_EXTRA_FILE ]]; then validate_secret_file "MySQL option file" DEFAULTS_EXTRA_FILE fi validate_hook_configuration "Before hook" BEFORE_HOOK BEFORE_HOOK_COMMAND if [[ -n $AFTER_DATABASE_HOOK || -n $AFTER_DATABASE_HOOK_COMMAND ]]; then [[ $SEPARATE_FILES == true ]] || die "--after-database-hook requires --separate-files" fi validate_hook_configuration "After database hook" AFTER_DATABASE_HOOK AFTER_DATABASE_HOOK_COMMAND validate_hook_configuration "After hook" AFTER_HOOK AFTER_HOOK_COMMAND if ! validate_dependencies; then exit 2 fi if [[ $PASSWORD_CONFIGURED == true ]]; then configure_password_authentication fi } configure_password_authentication() { local help_output help_output=$(LC_ALL=C mysqldump --no-defaults --verbose --help 2>/dev/null) || die "Cannot inspect mysqldump password-option support" [[ $help_output == *--defaults-file* ]] || die "mysqldump does not support the isolated --defaults-file password mode" if [[ $help_output == *--login-path* ]]; then [[ $help_output == *--no-login-paths* ]] || die "mysqldump can read Login Path but cannot disable it; direct password mode is unsafe" MYSQLDUMP_DISABLE_LOGIN_PATHS=true 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/dbback_*.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' elif [[ $ALL_DATABASES_REQUESTED == true || $DATABASE_COUNT -eq 0 ]]; then printf 'ALL\n' else printf '%s\n' "${DATABASES[*]}" fi } connection_value() { local explicit=$1 local value=$2 if [[ -n $LOGIN_PATH && $explicit != true ]]; then printf 'profile-managed\n' else printf '%s\n' "$value" fi } database_connection_summary() { local source=arguments if [[ -n $LOGIN_PATH ]]; then source=login-path elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then source=defaults-extra-file elif [[ $PASSWORD_CONFIGURED == true ]]; then source=password fi if [[ $NOTIFY_REDACT == true ]]; then printf 'source=%s; host=[redacted]; port=[redacted]; user=[redacted]' "$source" else printf 'source=%s; host=%s; port=%s; user=%s' \ "$source" \ "$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")" \ "$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")" \ "$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")" fi if [[ -n $LOGIN_PATH ]]; then if [[ $NOTIFY_REDACT == true ]]; then printf '; login-path=[redacted]' else printf '; login-path=%s' "$LOGIN_PATH" fi fi printf '\n' } 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] MySQL 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 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="dbback_$(date -u '+%Y%m%dT%H%M%SZ')_$$" STAGE_DIR=$(mktemp -d "$TARGET_DIR/.staging/${RUN_ID}.XXXXXX") || die "Cannot create staging directory" ERROR_LOG="$TARGET_DIR/.staging/${RUN_ID}.error.log" if ! (set -o noclobber; : > "$ERROR_LOG") 2>/dev/null; then die "Cannot create runtime error log without overwriting an existing path" fi } mysql_option_escape() { local value=$1 value=${value//\\/\\\\} value=${value//\"/\\\"} printf '%s' "$value" } prepare_password_option_fd() { local escaped_password [[ $PASSWORD_CONFIGURED == true ]] || return 0 escaped_password=$(mysql_option_escape "$DB_PASSWORD") exec 8< <(printf '[client]\npassword="%s"\n' "$escaped_password") || die "Cannot create anonymous MySQL credential stream" } close_password_option_fd() { [[ $PASSWORD_CONFIGURED == true ]] || return 0 exec 8<&- } execute_hook_target() { local hook=$1 local command=$2 if [[ -n $hook ]]; then "$hook" else bash -c "$command" fi } run_hook() { local label=$1 local hook=$2 local command=$3 [[ -n $hook || -n $command ]] || return 0 if [[ -n $hook ]]; then log INFO "Running $label hook: $hook" else log INFO "Running $label hook command" fi if ! BACKUP_RUN_ID="$RUN_ID" \ BACKUP_TARGET_DIR="$TARGET_DIR" \ BACKUP_STAGE_DIR="$STAGE_DIR" \ BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" \ execute_hook_target "$hook" "$command" 2>"$ERROR_LOG" then fail_with_error_log "$label hook failed" fi } run_after_database_hook() { local database=$1 local output_file=$2 local index=$3 local staged_file [[ -n $AFTER_DATABASE_HOOK || -n $AFTER_DATABASE_HOOK_COMMAND ]] || return 0 CURRENT_PHASE=after-database-hook log INFO "Running after database hook for: $database" if ! 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="$output_file" \ BACKUP_DATABASE_INDEX="$index" \ BACKUP_DATABASE_TOTAL="$DATABASE_COUNT" \ BACKUP_OUTPUT_MODE="$OUTPUT_MODE" \ execute_hook_target "$AFTER_DATABASE_HOOK" "$AFTER_DATABASE_HOOK_COMMAND" 2>"$ERROR_LOG" then fail_with_error_log "After database hook failed for database: $database" fi for staged_file in "${SQL_FILES[@]}"; do if [[ ! -f $staged_file || -L $staged_file || ! -s $staged_file ]]; then die "After database hook removed or emptied the database dump: $(basename "$staged_file")" fi done } build_mysql_commands() { MYSQLDUMP_COMMAND=(mysqldump) MYSQL_COMMAND=(mysql) if [[ $PASSWORD_CONFIGURED == true ]]; then MYSQLDUMP_COMMAND+=("--defaults-file=/dev/fd/8") MYSQL_COMMAND+=("--defaults-file=/dev/fd/8") if [[ $MYSQLDUMP_DISABLE_LOGIN_PATHS == true ]]; then MYSQLDUMP_COMMAND+=(--no-login-paths) MYSQL_COMMAND+=(--no-login-paths) fi elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then MYSQLDUMP_COMMAND+=("--defaults-extra-file=$DEFAULTS_EXTRA_FILE") MYSQL_COMMAND+=("--defaults-extra-file=$DEFAULTS_EXTRA_FILE") elif [[ -n $LOGIN_PATH ]]; then MYSQLDUMP_COMMAND+=("--login-path=$LOGIN_PATH") MYSQL_COMMAND+=("--login-path=$LOGIN_PATH") fi if [[ -z $LOGIN_PATH || $DB_HOST_EXPLICIT == true ]]; then MYSQLDUMP_COMMAND+=("--host=$DB_HOST") MYSQL_COMMAND+=("--host=$DB_HOST") fi if [[ -z $LOGIN_PATH || $DB_PORT_EXPLICIT == true ]]; then MYSQLDUMP_COMMAND+=("--port=$DB_PORT") MYSQL_COMMAND+=("--port=$DB_PORT") fi if [[ -z $LOGIN_PATH || $DB_USER_EXPLICIT == true ]]; then MYSQLDUMP_COMMAND+=("--user=$DB_USER") MYSQL_COMMAND+=("--user=$DB_USER") fi MYSQLDUMP_COMMAND+=("${DUMP_OPTIONS[@]}") } discover_databases() { local database local database_hex local database_list="$STAGE_DIR/.databases.list" local escaped_database local index local byte local query="SELECT HEX(SCHEMA_NAME) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema','performance_schema','ndbinfo') ORDER BY SCHEMA_NAME" prepare_password_option_fd build_mysql_commands if ! "${MYSQL_COMMAND[@]}" --batch --skip-column-names --execute="$query" >"$database_list" 2>"$ERROR_LOG"; then close_password_option_fd fail_with_error_log "mysql database discovery failed" fi close_password_option_fd DATABASES=() DATABASE_COUNT=0 while IFS= read -r database_hex || [[ -n $database_hex ]]; do [[ $database_hex =~ ^([[:xdigit:]]{2})+$ ]] || die "mysql returned an invalid database name" escaped_database= for ((index = 0; index < ${#database_hex}; index += 2)); do byte=${database_hex:index:2} [[ $byte != 00 ]] || die "Discovered database names cannot contain control characters" escaped_database+="\\x$byte" done printf -v database '%b' "$escaped_database" case "$database" in information_schema|performance_schema|ndbinfo) continue ;; esac [[ $database != -* ]] || die "Discovered database names cannot start with '-'" ! value_has_control_characters "$database" || die "Discovered database names cannot contain control characters" DATABASES+=("$database") DATABASE_COUNT=$((DATABASE_COUNT + 1)) done <"$database_list" rm -- "$database_list" || die "Cannot remove temporary database list" [[ $DATABASE_COUNT -gt 0 ]] || die "No databases are available to back up" } safe_database_slug() { local database=$1 local slug slug=$(LC_ALL=C printf '%s' "$database" | sed 's/[^A-Za-z0-9_.-]/_/g') [[ -n $slug ]] || slug=database slug=${slug:0:MAX_DATABASE_SLUG_BYTES} printf '%s\n' "$slug" } dump_to_file() { local output_file=$1 shift prepare_password_option_fd build_mysql_commands if ! "${MYSQLDUMP_COMMAND[@]}" "$@" >"$output_file" 2>"$ERROR_LOG"; then close_password_option_fd fail_with_error_log "mysqldump failed" fi close_password_option_fd [[ -s $output_file ]] || die "mysqldump created an empty file: $(basename "$output_file")" SQL_FILES+=("$output_file") } dump_databases() { local index local database local slug local filename CURRENT_PHASE=dumping if [[ $DATABASE_COUNT -eq 0 && $SEPARATE_FILES == false ]]; then ALL_DATABASES_REQUESTED=true log INFO "Dumping all databases" dump_to_file "$STAGE_DIR/all_databases.sql" --all-databases return 0 fi if [[ $DATABASE_COUNT -eq 0 ]]; then ALL_DATABASES_REQUESTED=true log INFO "Discovering databases for separate dumps" discover_databases fi if [[ $SEPARATE_FILES == true ]]; then index=0 for database in "${DATABASES[@]}"; do index=$((index + 1)) slug=$(safe_database_slug "$database") printf -v filename '%03d_%s.sql' "$index" "$slug" CURRENT_PHASE=dumping log INFO "Dumping database: $database" dump_to_file "$STAGE_DIR/$filename" --databases "$database" run_after_database_hook "$database" "$STAGE_DIR/$filename" "$index" done else log INFO "Dumping $DATABASE_COUNT selected database(s)" dump_to_file "$STAGE_DIR/selected_databases.sql" --databases "${DATABASES[@]}" fi } create_archive() { local -a sql_names=() local sql_file [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] || return 0 CURRENT_PHASE=compressing for sql_file in "${SQL_FILES[@]}"; do sql_names+=("$(basename "$sql_file")") done if ! (cd "$STAGE_DIR" && zip -q backup.zip.tmp "${sql_names[@]}"); then die "zip failed" fi mv -- "$STAGE_DIR/backup.zip.tmp" "$STAGE_DIR/backup.zip" || die "Cannot finalize backup.zip" zip -T "$STAGE_DIR/backup.zip" >/dev/null || die "backup.zip failed its integrity check" if [[ $OUTPUT_MODE == zip ]]; then rm -- "${SQL_FILES[@]}" || die "Cannot remove staged SQL files after compression" fi } write_manifest() { local end_epoch local database local artifact local connection_source=arguments local manifest_db_host local manifest_db_port local manifest_db_user CURRENT_PHASE=manifest end_epoch=$(date +%s) manifest_db_host=$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST") manifest_db_port=$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT") manifest_db_user=$(connection_value "$DB_USER_EXPLICIT" "$DB_USER") if [[ -n $LOGIN_PATH ]]; then connection_source=login-path elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then connection_source=defaults-extra-file elif [[ $PASSWORD_CONFIGURED == true ]]; then connection_source=password fi { printf 'format_version=1\n' printf 'run_id=%s\n' "$RUN_ID" } > "$STAGE_DIR/.mysql-onekey-backup-run" || die "Cannot write ownership marker" { printf 'format_version=1\n' printf 'script_version=%s\n' "$SCRIPT_VERSION" printf 'run_id=%s\n' "$RUN_ID" printf 'server_name=%s\n' "$SERVER_NAME" printf 'connection_source=%s\n' "$connection_source" if [[ -n $LOGIN_PATH ]]; then printf 'login_path=%s\n' "$LOGIN_PATH" fi printf 'db_host=%s\n' "$manifest_db_host" printf 'db_port=%s\n' "$manifest_db_port" printf 'db_user=%s\n' "$manifest_db_user" printf 'output_mode=%s\n' "$OUTPUT_MODE" printf 'started_epoch=%s\n' "$START_EPOCH" printf 'finished_epoch=%s\n' "$end_epoch" printf 'elapsed_seconds=%s\n' "$((end_epoch - START_EPOCH))" if [[ $ALL_DATABASES_REQUESTED == true || $DATABASE_COUNT -eq 0 ]]; then printf 'database=ALL\n' else for database in "${DATABASES[@]}"; do printf 'database=%s\n' "$database" done fi while IFS= read -r -d '' artifact; do printf 'artifact=%s\n' "$(basename "$artifact")" done < <(find "$STAGE_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.zip' \) -print0) } > "$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 '*.sql' -o -name '*.zip' -o -name 'manifest.txt' -o -name '.mysql-onekey-backup-run' \) -print0) [[ ${#artifacts[@]} -gt 0 ]] || die "No backup artifacts were created" if command_exists sha256sum; then (cd "$STAGE_DIR" && sha256sum -- "${artifacts[@]}" > SHA256SUMS) || die "Cannot write SHA256SUMS" (cd "$STAGE_DIR" && sha256sum -c SHA256SUMS >/dev/null) || die "SHA256 verification failed" else (cd "$STAGE_DIR" && shasum -a 256 "${artifacts[@]}" > SHA256SUMS) || die "Cannot write SHA256SUMS" (cd "$STAGE_DIR" && shasum -a 256 -c SHA256SUMS >/dev/null) || die "SHA256 verification failed" fi } publish_run() { local candidate_dir CURRENT_PHASE=publishing candidate_dir="$TARGET_DIR/runs/$RUN_ID" [[ ! -e $candidate_dir ]] || die "Backup run already exists: $candidate_dir" mv -- "$STAGE_DIR" "$candidate_dir" || die "Cannot publish backup run" PUBLISHED_DIR=$candidate_dir STAGE_DIR= } identity_file_matches() { local path=$1 local expected_run_id=$2 local line local has_format=false local has_run_id=false [[ -f $path && ! -L $path ]] || return 1 while IFS= read -r line || [[ -n $line ]]; do case "$line" in format_version=1) [[ $has_format == false ]] || return 1 has_format=true ;; format_version=*) return 1 ;; "run_id=$expected_run_id") [[ $has_run_id == false ]] || return 1 has_run_id=true ;; run_id=*) return 1 ;; esac done < "$path" [[ $has_format == true && $has_run_id == true ]] } managed_run_is_valid() { local run_dir=$1 local run_id [[ -d $run_dir && ! -L $run_dir ]] || return 1 run_id=$(basename "$run_dir") identity_file_matches "$run_dir/.mysql-onekey-backup-run" "$run_id" || return 1 identity_file_matches "$run_dir/manifest.txt" "$run_id" } prune_expired_runs() { local expire_minutes local expired_run [[ $EXPIRE_HOURS -gt 0 ]] || return 0 CURRENT_PHASE=retention expire_minutes=$((EXPIRE_HOURS * 60)) while IFS= read -r -d '' expired_run; do case "$expired_run" in "$TARGET_DIR"/runs/dbback_*) if managed_run_is_valid "$expired_run"; then log INFO "Removing expired backup run: $expired_run" rm -rf -- "$expired_run" || die "Cannot remove expired backup run: $expired_run" else log WARN "Skipping unrecognized backup directory: $expired_run" fi ;; *) die "Refusing to remove unexpected retention path: $expired_run" ;; esac done < <(find "$TARGET_DIR/runs" -mindepth 1 -maxdepth 1 -type d -name 'dbback_*' -mmin "+$expire_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 if [[ $NOTIFY_START == true ]]; then notify_event start "Backup started" || true fi CURRENT_PHASE=before-hook run_hook "Before" "$BEFORE_HOOK" "$BEFORE_HOOK_COMMAND" dump_databases create_archive write_manifest write_and_verify_checksums publish_run prune_expired_runs CURRENT_PHASE=after-hook run_hook "After" "$AFTER_HOOK" "$AFTER_HOOK_COMMAND" CURRENT_PHASE=complete end_epoch=$(date +%s) FINAL_MESSAGE="Backup completed: $PUBLISHED_DIR ($((end_epoch - START_EPOCH))s)" log INFO "$FINAL_MESSAGE" } main() { load_environment_lists parse_args "$@" if [[ $DEPENDENCY_REPORT == true ]]; then case "$OUTPUT_MODE" in zip|sql|both) ;; *) die "--output-mode must be zip, sql, 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 "$@"