feat: harden data access and simplify user management

This commit is contained in:
xuelong committed 2026-08-25 21:02:30 -07:00
1 parent 3dd5731751
commit adb780bc82
51 files changed
+3239 -2121

No files matched your search

+102 -35
View File
@@ -3,46 +3,95 @@ set -Eeuo pipefail
umask 077
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
BACKUP_ROOT="${DATA_BACKUP_ROOT:-${PROJECT_DIR}/backups/mysql}"
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}"
if [[ -z "${PASSPHRASE_FILE}" || ! -f "${PASSPHRASE_FILE}" || ! -r "${PASSPHRASE_FILE}" ]]; then
echo "DATA_BACKUP_PASSPHRASE_FILE 必须指向可读的独立密钥文件" >&2
fail() {
echo "错误:$*" >&2
exit 2
fi
if [[ "$(wc -c < "${PASSPHRASE_FILE}" | tr -d ' ')" -lt 32 ]]; then
echo "备份加密口令文件内容不能少于 32 个字符" >&2
exit 2
fi
if [[ -z "${BACKUP_ROOT}" || "${BACKUP_ROOT}" == "/" ]]; then
echo "拒绝使用不安全的备份目录" >&2
exit 2
fi
}
for command_name in docker gzip openssl; do
command -v "${command_name}" >/dev/null 2>&1 || {
echo "缺少命令:${command_name}" >&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
mkdir -p -- "${BACKUP_ROOT}"
[[ "${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}"
temporary_path="${BACKUP_ROOT}/.${archive_name}.tmp"
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_path}"
rm -f -- "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}"
rmdir -- "${work_dir}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
cd -- "${PROJECT_DIR}"
docker compose exec -T mysql sh -eu -c '
[[ "$(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 \
@@ -52,22 +101,40 @@ docker compose exec -T mysql sh -eu -c '
--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_path}"
-pass "file:${PASSPHRASE_FILE}" -out "${temporary_archive}"
test -s "${temporary_path}"
mv -- "${temporary_path}" "${archive_path}"
[[ -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 "${archive_path}" | awk '{print $1}')"
checksum="$(sha256sum "${temporary_archive}" | awk '{print $1}')"
else
checksum="$(shasum -a 256 "${archive_path}" | awk '{print $1}')"
checksum="$(shasum -a 256 "${temporary_archive}" | awk '{print $1}')"
fi
printf '%s %s\n' "${checksum}" "${archive_name}" > "${checksum_path}"
printf '{\n "created_at": "%s",\n "file": "%s",\n "sha256": "%s",\n "encrypted": true,\n "cipher": "AES-256-CBC/PBKDF2",\n "compression": "gzip"\n}\n' \
"${timestamp}" "${archive_name}" "${checksum}" > "${manifest_path}"
chmod 600 "${archive_path}" "${checksum_path}" "${manifest_path}"
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}"
echo "加密备份已生成:${archive_path}"
echo "请将 .enc、.sha256 和 .json 同步到独立的异地存储。"
# 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 自动同步到另一台服务器或对象存储。"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# MySQL only runs docker-entrypoint-initdb.d scripts for a new data volume.
# Run this once after upgrading an existing deployment so the dedicated
# account-provisioning user receives its narrowly scoped privileges.
docker compose exec -T mysql \
sh /docker-entrypoint-initdb.d/01-data-center-grants.sh
echo "DBeaver 管理接入的独立 MySQL 签发账号已配置。"
+108
View File
@@ -0,0 +1,108 @@
#!/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:-}"
RETENTION_DAYS="${DATA_BACKUP_RETENTION_DAYS:-30}"
SERVICE_USER="${MYSQL_BACKUP_SERVICE_USER:-${SUDO_USER:-root}}"
DOCKER_SOCKET="${DOCKER_HOST:-}"
ENV_DIR="/etc/travel-kg"
ENV_FILE="${ENV_DIR}/mysql-backup.env"
SERVICE_FILE="/etc/systemd/system/nianxx-mysql-backup.service"
TIMER_FILE="/etc/systemd/system/nianxx-mysql-backup.timer"
fail() {
echo "错误:$*" >&2
exit 2
}
safe_path() {
[[ "$1" =~ ^/[A-Za-z0-9._/-]+$ ]]
}
[[ "${EUID}" -eq 0 ]] || fail "请使用 sudo -E 执行"
command -v systemctl >/dev/null 2>&1 || fail "服务器没有systemd,不能安装定时任务"
command -v runuser >/dev/null 2>&1 || fail "缺少runuser命令"
id "${SERVICE_USER}" >/dev/null 2>&1 || fail "备份服务用户不存在:${SERVICE_USER}"
safe_path "${PROJECT_DIR}" || fail "代码目录只能包含字母、数字、点、下划线、横线和斜线"
safe_path "${BACKUP_ROOT}" || fail "DATA_BACKUP_ROOT 必须是安全的绝对路径"
safe_path "${PASSPHRASE_FILE}" || fail "DATA_BACKUP_PASSPHRASE_FILE 必须是安全的绝对路径"
safe_path "${DATA_DIR}" || fail "MYSQL_DATA_DIR 必须是安全的绝对路径"
[[ "${STORAGE_ID}" =~ ^[A-Za-z0-9._-]{16,128}$ ]] || fail "MYSQL_STORAGE_ID格式错误"
[[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || fail "DATA_BACKUP_RETENTION_DAYS必须是整数"
(( RETENTION_DAYS >= 7 && RETENTION_DAYS <= 3650 )) || fail "备份保留天数必须在7–3650天之间"
[[ -x "${PROJECT_DIR}/scripts/backup_mysql_encrypted.sh" ]] || fail "备份脚本不存在或不可执行"
[[ -d "${BACKUP_ROOT}" && ! -L "${BACKUP_ROOT}" ]] || fail "备份目录不存在或是符号链接"
[[ -d "${DATA_DIR}" && ! -L "${DATA_DIR}" ]] || fail "MySQL数据目录不存在或是符号链接"
[[ -f "${PASSPHRASE_FILE}" && ! -L "${PASSPHRASE_FILE}" ]] || fail "备份口令文件不存在或是符号链接"
runuser -u "${SERVICE_USER}" -- test -w "${BACKUP_ROOT}" || fail "${SERVICE_USER}不能写入备份目录"
runuser -u "${SERVICE_USER}" -- test -r "${PASSPHRASE_FILE}" || fail "${SERVICE_USER}不能读取备份口令文件"
if [[ -n "${DOCKER_SOCKET}" && ! "${DOCKER_SOCKET}" =~ ^unix:///[A-Za-z0-9._/-]+$ ]]; then
fail "DOCKER_HOST只允许安全的本机Unix socket"
fi
SERVICE_GROUP="$(id -gn "${SERVICE_USER}")"
install -d -m 0750 -o root -g "${SERVICE_GROUP}" "${ENV_DIR}"
install -m 0640 -o root -g "${SERVICE_GROUP}" /dev/null "${ENV_FILE}"
{
printf 'DATA_BACKUP_ROOT=%s\n' "${BACKUP_ROOT}"
printf 'DATA_BACKUP_PASSPHRASE_FILE=%s\n' "${PASSPHRASE_FILE}"
printf 'DATA_BACKUP_RETENTION_DAYS=%s\n' "${RETENTION_DAYS}"
printf 'MYSQL_DATA_DIR=%s\n' "${DATA_DIR}"
printf 'MYSQL_STORAGE_ID=%s\n' "${STORAGE_ID}"
if [[ -n "${DOCKER_SOCKET}" ]]; then
printf 'DOCKER_HOST=%s\n' "${DOCKER_SOCKET}"
fi
} > "${ENV_FILE}"
install -m 0644 /dev/null "${SERVICE_FILE}"
{
printf '%s\n' '[Unit]'
printf '%s\n' 'Description=Encrypted MySQL backup for Nianxx Data Center'
printf '%s\n' 'Wants=network-online.target'
printf '%s\n' 'After=network-online.target docker.service'
printf '\n%s\n' '[Service]'
printf '%s\n' 'Type=oneshot'
printf 'User=%s\n' "${SERVICE_USER}"
printf 'Group=%s\n' "${SERVICE_GROUP}"
printf 'WorkingDirectory=%s\n' "${PROJECT_DIR}"
printf 'EnvironmentFile=%s\n' "${ENV_FILE}"
printf 'ExecStart=%s/scripts/backup_mysql_encrypted.sh\n' "${PROJECT_DIR}"
printf '%s\n' 'UMask=0077'
printf '%s\n' 'NoNewPrivileges=true'
printf '%s\n' 'PrivateTmp=true'
printf '%s\n' 'ProtectSystem=full'
printf '%s\n' 'ProtectHome=false'
printf '%s\n' 'LockPersonality=true'
} > "${SERVICE_FILE}"
install -m 0644 /dev/null "${TIMER_FILE}"
{
printf '%s\n' '[Unit]'
printf '%s\n' 'Description=Daily encrypted MySQL backup for Nianxx Data Center'
printf '\n%s\n' '[Timer]'
printf '%s\n' 'OnCalendar=*-*-* 02:17:00'
printf '%s\n' 'RandomizedDelaySec=15m'
printf '%s\n' 'Persistent=true'
printf '%s\n' 'Unit=nianxx-mysql-backup.service'
printf '\n%s\n' '[Install]'
printf '%s\n' 'WantedBy=timers.target'
} > "${TIMER_FILE}"
systemctl daemon-reload
systemctl start nianxx-mysql-backup.service
systemctl enable --now nianxx-mysql-backup.timer
echo "首次加密备份已验证,systemd每日备份已启用。"
echo "查看定时器:systemctl list-timers nianxx-mysql-backup.timer"
echo "查看日志:journalctl -u nianxx-mysql-backup.service"
echo "确认异地同步后,才可在.env中设置 DATA_BACKUP_ENABLED=true。"
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
STORAGE_MOUNT="${MYSQL_STORAGE_MOUNT:-}"
DATA_DIR="${MYSQL_DATA_DIR:-}"
BACKUP_ROOT="${DATA_BACKUP_ROOT:-}"
STORAGE_ID="${MYSQL_STORAGE_ID:-}"
MYSQL_UID="${MYSQL_CONTAINER_UID:-999}"
MYSQL_GID="${MYSQL_CONTAINER_GID:-999}"
ADOPT_EXISTING="${ADOPT_EXISTING_MYSQL_DATA:-false}"
fail() {
echo "错误:$*" >&2
exit 2
}
if [[ "${EUID}" -ne 0 ]]; then
fail "请使用 sudo -E 执行,以便创建并设置持久化目录权限"
fi
for command_name in find findmnt install mountpoint realpath; do
command -v "${command_name}" >/dev/null 2>&1 || fail "缺少命令:${command_name}"
done
[[ "${STORAGE_MOUNT}" == /* ]] || fail "MYSQL_STORAGE_MOUNT 必须是独立数据盘的绝对挂载点"
[[ "${DATA_DIR}" == /* ]] || fail "MYSQL_DATA_DIR 必须是项目外绝对路径"
[[ "${BACKUP_ROOT}" == /* ]] || fail "DATA_BACKUP_ROOT 必须是项目外绝对路径"
[[ "${STORAGE_ID}" =~ ^[A-Za-z0-9._-]{16,128}$ ]] || fail "MYSQL_STORAGE_ID 必须为16–128位安全标识"
[[ "${MYSQL_UID}" =~ ^[0-9]+$ && "${MYSQL_GID}" =~ ^[0-9]+$ ]] || fail "MySQL容器UID/GID必须是数字"
STORAGE_MOUNT="$(realpath -e -- "${STORAGE_MOUNT}")"
mountpoint -q -- "${STORAGE_MOUNT}" || fail "${STORAGE_MOUNT} 当前不是独立挂载点,拒绝写入系统盘"
filesystem_type="$(findmnt -n -o FSTYPE --target "${STORAGE_MOUNT}")"
mount_options="$(findmnt -n -o OPTIONS --target "${STORAGE_MOUNT}")"
case "${filesystem_type}" in
overlay|tmpfs|ramfs|nfs|nfs4|cifs|fuse.*)
fail "MySQL运行数据不能使用 ${filesystem_type} 文件系统;请使用本地持久化块存储"
;;
esac
case ",${mount_options}," in
*,rw,*) ;;
*) fail "数据盘 ${STORAGE_MOUNT} 不是可写挂载" ;;
esac
DATA_DIR="$(realpath -m -- "${DATA_DIR}")"
BACKUP_ROOT="$(realpath -m -- "${BACKUP_ROOT}")"
case "${DATA_DIR}/" in
"${STORAGE_MOUNT}/"*) ;;
*) fail "MYSQL_DATA_DIR 必须位于已确认的数据盘 ${STORAGE_MOUNT} 下" ;;
esac
[[ "${DATA_DIR}" != "${STORAGE_MOUNT}" ]] || fail "MySQL数据目录不能直接使用磁盘挂载根目录"
case "${DATA_DIR}/" in
"${PROJECT_DIR}/"*) fail "MySQL数据目录不能位于代码仓库内" ;;
esac
case "${BACKUP_ROOT}/" in
"${PROJECT_DIR}/"*) fail "备份目录不能位于代码仓库内" ;;
esac
if [[ "${DATA_DIR}" == "${BACKUP_ROOT}" || "${BACKUP_ROOT}/" == "${DATA_DIR}/"* || "${DATA_DIR}/" == "${BACKUP_ROOT}/"* ]]; then
fail "MySQL运行数据目录和备份目录不能相互包含"
fi
install -d -m 0750 -o "${MYSQL_UID}" -g "${MYSQL_GID}" -- "${DATA_DIR}"
backup_uid="${SUDO_UID:-0}"
backup_gid="${SUDO_GID:-0}"
install -d -m 0700 -o "${backup_uid}" -g "${backup_gid}" -- "${BACKUP_ROOT}"
marker="${STORAGE_MOUNT}/.nianxx-mysql-storage-id"
[[ ! -L "${marker}" ]] || fail "存储标识文件不能是符号链接"
if [[ -f "${marker}" ]]; then
actual_id="$(<"${marker}")"
[[ "${actual_id}" == "${STORAGE_ID}" ]] || fail "数据盘标识不一致,拒绝覆盖或切换数据库"
else
existing_entry="$(find "${DATA_DIR}" -mindepth 1 -maxdepth 1 -print -quit)"
if [[ -n "${existing_entry}" ]]; then
[[ "${ADOPT_EXISTING}" == "true" ]] || fail "目标目录已有数据但缺少标识;确认备份后设置 ADOPT_EXISTING_MYSQL_DATA=true 再执行"
[[ -f "${DATA_DIR}/auto.cnf" && -d "${DATA_DIR}/mysql" ]] || fail "现有目录不是完整的MySQL数据目录"
fi
temporary_marker="$(mktemp "${STORAGE_MOUNT}/.storage-id.XXXXXX")"
printf '%s\n' "${STORAGE_ID}" > "${temporary_marker}"
chown root:root "${temporary_marker}"
chmod 0444 "${temporary_marker}"
mv -n -- "${temporary_marker}" "${marker}"
fi
actual_id="$(<"${marker}")"
[[ "${actual_id}" == "${STORAGE_ID}" ]] || fail "数据盘标识写入后校验失败"
existing_entry="$(find "${DATA_DIR}" -mindepth 1 -maxdepth 1 -print -quit)"
if [[ -n "${existing_entry}" && ( ! -f "${DATA_DIR}/auto.cnf" || ! -d "${DATA_DIR}/mysql" ) ]]; then
fail "MySQL数据目录非空但结构不完整,拒绝启动"
fi
chown "${MYSQL_UID}:${MYSQL_GID}" "${DATA_DIR}"
chmod 0750 "${DATA_DIR}"
echo "MySQL生产存储已准备完成:"
echo " 数据盘挂载点:${STORAGE_MOUNT}"
echo " 文件系统:${filesystem_type} (${mount_options})"
echo " MySQL数据目录:${DATA_DIR}"
echo " 加密备份目录:${BACKUP_ROOT}"
echo " 存储标识:${STORAGE_ID}"
echo "请将以上三个路径/标识写入服务器 .env,然后再启动生产 Compose。"
+15 -14
View File
@@ -4,27 +4,28 @@ set -Eeuo pipefail
archive_path="${1:-}"
passphrase_file="${DATA_BACKUP_PASSPHRASE_FILE:-}"
if [[ -z "${archive_path}" || ! -f "${archive_path}" ]]; then
echo "用法:DATA_BACKUP_PASSPHRASE_FILE=/安全路径/密钥 $0 /备份/mysql-all-*.enc" >&2
fail() {
echo "错误:$*" >&2
exit 2
fi
if [[ -z "${passphrase_file}" || ! -r "${passphrase_file}" ]]; then
echo "缺少可读的 DATA_BACKUP_PASSPHRASE_FILE" >&2
exit 2
fi
}
[[ -n "${archive_path}" ]] || fail "用法:DATA_BACKUP_PASSPHRASE_FILE=/安全路径/密钥 $0 /备份/mysql-all-*.enc"
[[ "${archive_path}" == /* && ! -L "${archive_path}" && -f "${archive_path}" ]] || fail "备份必须是现有绝对路径,且不能是符号链接"
[[ "${passphrase_file}" == /* && ! -L "${passphrase_file}" && -f "${passphrase_file}" && -r "${passphrase_file}" ]] || fail "缺少安全、可读的 DATA_BACKUP_PASSPHRASE_FILE"
archive_path="$(realpath -e -- "${archive_path}")"
passphrase_file="$(realpath -e -- "${passphrase_file}")"
checksum_file="${archive_path}.sha256"
if [[ ! -f "${checksum_file}" ]]; then
echo "缺少校验文件:${checksum_file}" >&2
exit 2
fi
[[ ! -L "${checksum_file}" && -f "${checksum_file}" ]] || fail "缺少安全的校验文件:${checksum_file}"
expected="$(awk 'NR==1 {print $1}' "${checksum_file}")"
[[ "${expected}" =~ ^[0-9a-fA-F]{64}$ ]] || fail "SHA-256校验文件格式错误"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "${archive_path}" | awk '{print $1}')"
else
actual="$(shasum -a 256 "${archive_path}" | awk '{print $1}')"
fi
if [[ -z "${expected}" || "${actual}" != "${expected}" ]]; then
if [[ "${actual,,}" != "${expected,,}" ]]; then
echo "备份 SHA-256 校验失败" >&2
exit 1
fi
@@ -32,5 +33,5 @@ fi
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass "file:${passphrase_file}" -in "${archive_path}" | gzip -t
echo "备份加密、校验和与压缩结构验证通过。"
echo "生产环境仍应定期恢复到隔离 MySQL 实例进行完整恢复演练。"
echo "备份加密、SHA-256校验和与压缩结构验证通过。"
echo "生产环境仍应定期恢复到隔离MySQL实例进行完整恢复演练。"