141 lines
6.3 KiB
Bash
Executable File
141 lines
6.3 KiB
Bash
Executable File
#!/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 自动同步到另一台服务器或对象存储。"
|