feat: harden data access and simplify user management
This commit is contained in:
1 parent
3dd5731751
commit
adb780bc82
51 files changed
+3239
-2121
No files matched your search
@@ -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 自动同步到另一台服务器或对象存储。"
|
||||
Executable
+10
@@ -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 签发账号已配置。"
|
||||
Executable
+108
@@ -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。"
|
||||
Executable
+109
@@ -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。"
|
||||
@@ -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实例进行完整恢复演练。"
|
||||
Reference in new issue
Block a user