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 自动同步到另一台服务器或对象存储。"