#!/usr/bin/env bash set -Eeuo pipefail umask 077 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" BACKUP_ROOT="${DATA_BACKUP_ROOT:-}" PASSPHRASE_FILE="${DATA_BACKUP_PASSPHRASE_FILE:-}" DATA_DIR="${MYSQL_DATA_DIR:-}" STORAGE_ID="${MYSQL_STORAGE_ID:-unknown}" RETENTION_DAYS="${DATA_BACKUP_RETENTION_DAYS:-30}" MYSQL_CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-travel-kg-mysql}" fail() { echo "错误:$*" >&2 exit 2 } path_is_within() { local candidate="$1" local parent="$2" [[ "${candidate}" == "${parent}" || "${candidate}/" == "${parent}/"* ]] } secure_secret_file() { local secret_file="$1" local permissions permission_digits permission_value [[ "${secret_file}" == /* ]] || fail "DATA_BACKUP_PASSPHRASE_FILE 必须是绝对路径" [[ ! -L "${secret_file}" ]] || fail "备份加密口令文件不能是符号链接" [[ -f "${secret_file}" && -r "${secret_file}" ]] || fail "备份加密口令文件不存在或不可读" secret_file="$(realpath -e -- "${secret_file}")" path_is_within "${secret_file}" "${PROJECT_DIR}" && fail "备份加密口令不能存放在代码仓库内" path_is_within "${secret_file}" "${BACKUP_ROOT}" && fail "加密口令不能与备份文件存放在同一目录" if [[ -n "${DATA_DIR}" ]] && path_is_within "${secret_file}" "${DATA_DIR}"; then fail "加密口令不能存放在MySQL运行数据目录内" fi [[ "$(wc -c < "${secret_file}" | tr -d '[:space:]')" -ge 32 ]] || fail "备份加密口令不能少于32个字符" if permissions="$(stat -c '%a' -- "${secret_file}" 2>/dev/null)"; then : else permissions="$(stat -f '%Lp' -- "${secret_file}")" fi permission_digits="${permissions: -3}" [[ "${permission_digits}" =~ ^[0-7]{3}$ ]] || fail "无法识别备份口令文件权限" permission_value=$((8#${permission_digits})) (( (permission_value & 8#077) == 0 )) || fail "备份口令文件不能允许组用户或其他用户访问,请执行 chmod 600" PASSPHRASE_FILE="${secret_file}" } for command_name in docker find gzip openssl realpath stat; do command -v "${command_name}" >/dev/null 2>&1 || fail "缺少命令:${command_name}" done [[ "${BACKUP_ROOT}" == /* ]] || fail "DATA_BACKUP_ROOT 必须指向代码仓库外的绝对目录" [[ "${BACKUP_ROOT}" != "/" && ! -L "${BACKUP_ROOT}" && -d "${BACKUP_ROOT}" ]] || fail "备份目录必须预先创建,且不能是根目录或符号链接" BACKUP_ROOT="$(realpath -e -- "${BACKUP_ROOT}")" path_is_within "${BACKUP_ROOT}" "${PROJECT_DIR}" && fail "拒绝将生产备份写入代码仓库" [[ -w "${BACKUP_ROOT}" ]] || fail "DATA_BACKUP_ROOT 当前不可写" if [[ -n "${DATA_DIR}" ]]; then [[ "${DATA_DIR}" == /* && ! -L "${DATA_DIR}" && -d "${DATA_DIR}" ]] || fail "MYSQL_DATA_DIR 必须是现有绝对目录" DATA_DIR="$(realpath -e -- "${DATA_DIR}")" if path_is_within "${BACKUP_ROOT}" "${DATA_DIR}" || path_is_within "${DATA_DIR}" "${BACKUP_ROOT}"; then fail "MySQL运行数据目录和备份目录不能相互包含" fi fi [[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || fail "DATA_BACKUP_RETENTION_DAYS 必须是整数" (( RETENTION_DAYS >= 7 && RETENTION_DAYS <= 3650 )) || fail "备份保留天数必须在7–3650天之间" [[ "${MYSQL_CONTAINER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] || fail "MYSQL_CONTAINER_NAME格式错误" secure_secret_file "${PASSPHRASE_FILE}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" archive_name="mysql-all-${timestamp}.sql.gz.enc" archive_path="${BACKUP_ROOT}/${archive_name}" checksum_path="${archive_path}.sha256" manifest_path="${archive_path}.json" work_dir="$(mktemp -d "${BACKUP_ROOT}/.mysql-backup.${timestamp}.XXXXXX")" temporary_archive="${work_dir}/${archive_name}" temporary_checksum="${work_dir}/${archive_name}.sha256" temporary_manifest="${work_dir}/${archive_name}.json" cleanup() { rm -f -- "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}" rmdir -- "${work_dir}" 2>/dev/null || true } trap cleanup EXIT INT TERM [[ "$(docker inspect --format '{{.State.Running}}' "${MYSQL_CONTAINER_NAME}" 2>/dev/null)" == "true" ]] || fail "MySQL容器未运行:${MYSQL_CONTAINER_NAME}" docker exec -i "${MYSQL_CONTAINER_NAME}" sh -eu -c ' MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump \ --user=root \ --all-databases \ --single-transaction \ --quick \ --routines \ --events \ --triggers \ --hex-blob \ --source-data=2 \ --set-gtid-purged=OFF ' | gzip -9 | openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ -pass "file:${PASSPHRASE_FILE}" -out "${temporary_archive}" [[ -s "${temporary_archive}" ]] || fail "备份文件为空" openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ -pass "file:${PASSPHRASE_FILE}" -in "${temporary_archive}" | gzip -t if command -v sha256sum >/dev/null 2>&1; then checksum="$(sha256sum "${temporary_archive}" | awk '{print $1}')" else checksum="$(shasum -a 256 "${temporary_archive}" | awk '{print $1}')" fi printf '%s %s\n' "${checksum}" "${archive_name}" > "${temporary_checksum}" printf '{\n "created_at": "%s",\n "file": "%s",\n "sha256": "%s",\n "encrypted": true,\n "cipher": "AES-256-CBC/PBKDF2",\n "compression": "gzip",\n "scope": "all-databases",\n "storage_id": "%s",\n "retention_days": %s\n}\n' \ "${timestamp}" "${archive_name}" "${checksum}" "${STORAGE_ID}" "${RETENTION_DAYS}" > "${temporary_manifest}" chmod 600 "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}" # Sidecars are published first and the archive last, so backup scanners never # observe a final .enc file without its checksum and manifest. mv -- "${temporary_checksum}" "${checksum_path}" mv -- "${temporary_manifest}" "${manifest_path}" mv -- "${temporary_archive}" "${archive_path}" DATA_BACKUP_PASSPHRASE_FILE="${PASSPHRASE_FILE}" \ "${SCRIPT_DIR}/verify_mysql_backup.sh" "${archive_path}" deleted_count=0 while IFS= read -r -d '' expired_archive; do rm -f -- "${expired_archive}" "${expired_archive}.sha256" "${expired_archive}.json" deleted_count=$((deleted_count + 1)) done < <(find "${BACKUP_ROOT}" -maxdepth 1 -type f -name 'mysql-all-*.sql.gz.enc' -mtime "+${RETENTION_DAYS}" -print0) echo "加密备份已生成并验证:${archive_path}" echo "已清理 ${deleted_count} 组超过 ${RETENTION_DAYS} 天的历史备份。" echo "还必须将 .enc、.sha256 和 .json 自动同步到另一台服务器或对象存储。"