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 自动同步到另一台服务器或对象存储。"
|
||||
Reference in new issue
Block a user