301 行
11 KiB
Bash
可执行文件
301 行
11 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
CONFIG_FILE="${RECEIVER_CONFIG_FILE:-/etc/hyb-oa-offhost-backup.conf}"
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
# Root-owned installer output with path-only assignments.
|
|
# shellcheck disable=SC1090
|
|
source "$CONFIG_FILE"
|
|
fi
|
|
|
|
BACKUP_INBOX_DIR="${BACKUP_INBOX_DIR:-}"
|
|
BACKUP_ARCHIVE_DIR="${BACKUP_ARCHIVE_DIR:-}"
|
|
BACKUP_WEEKLY_DIR="${BACKUP_WEEKLY_DIR:-}"
|
|
RRSYNC_BIN="${RRSYNC_BIN:-/usr/bin/rrsync}"
|
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
|
DAILY_KEEP="${DAILY_KEEP:-7}"
|
|
WEEKLY_KEEP="${WEEKLY_KEEP:-4}"
|
|
|
|
fail() {
|
|
echo "[offhost-receiver][ERROR] $1" >&2
|
|
exit 1
|
|
}
|
|
|
|
validate_absolute_path() {
|
|
local name="$1"
|
|
local value="$2"
|
|
if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
|
|
fail "$name must be an explicit safe absolute path"
|
|
fi
|
|
if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \
|
|
|| "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then
|
|
fail "$name contains an unsafe path segment"
|
|
fi
|
|
}
|
|
|
|
validate_layout() {
|
|
local name value
|
|
for name in BACKUP_INBOX_DIR BACKUP_ARCHIVE_DIR BACKUP_WEEKLY_DIR; do
|
|
value="${!name:-}"
|
|
validate_absolute_path "$name" "$value"
|
|
[[ -d "$value" && ! -L "$value" ]] || fail "$name is not an existing regular directory"
|
|
done
|
|
if [[ "$BACKUP_INBOX_DIR" == "$BACKUP_ARCHIVE_DIR" \
|
|
|| "$BACKUP_INBOX_DIR" == "$BACKUP_WEEKLY_DIR" \
|
|
|| "$BACKUP_ARCHIVE_DIR" == "$BACKUP_WEEKLY_DIR" ]]; then
|
|
fail "backup receiver directories must be distinct"
|
|
fi
|
|
local first second
|
|
for first in "$BACKUP_INBOX_DIR" "$BACKUP_ARCHIVE_DIR" "$BACKUP_WEEKLY_DIR"; do
|
|
for second in "$BACKUP_INBOX_DIR" "$BACKUP_ARCHIVE_DIR" "$BACKUP_WEEKLY_DIR"; do
|
|
if [[ "$first" != "$second" && ( "$first" == "$second"/* || "$second" == "$first"/* ) ]]; then
|
|
fail "backup receiver directories must not be nested"
|
|
fi
|
|
done
|
|
done
|
|
if [[ "$(stat -c %d "$BACKUP_INBOX_DIR")" != "$(stat -c %d "$BACKUP_ARCHIVE_DIR")" \
|
|
|| "$(stat -c %d "$BACKUP_ARCHIVE_DIR")" != "$(stat -c %d "$BACKUP_WEEKLY_DIR")" ]]; then
|
|
fail "receiver directories must share a filesystem for atomic finalize and weekly hardlinks"
|
|
fi
|
|
[[ "$DAILY_KEEP" =~ ^[1-9][0-9]*$ ]] || fail "DAILY_KEEP must be positive"
|
|
[[ "$WEEKLY_KEEP" =~ ^[1-9][0-9]*$ ]] || fail "WEEKLY_KEEP must be positive"
|
|
command -v "$PYTHON_BIN" >/dev/null 2>&1 || fail "python3 is required"
|
|
command -v flock >/dev/null 2>&1 || fail "flock is required"
|
|
}
|
|
|
|
validate_run_id() {
|
|
local run_id="$1"
|
|
[[ "$run_id" =~ ^auto-[0-9]{8}T[0-9]{6}Z$ ]] \
|
|
|| fail "run id must use auto-YYYYMMDDTHHMMSSZ"
|
|
local run_day="${run_id#auto-}"
|
|
run_day="${run_day%%T*}"
|
|
[[ "$run_day" == "$(date -u +%Y%m%d)" || "$run_day" == "$(date -u -d yesterday +%Y%m%d)" ]] \
|
|
|| fail "run id UTC day is outside the accepted receiver window"
|
|
}
|
|
|
|
validate_batch() {
|
|
local batch_dir="$1"
|
|
local run_id="$2"
|
|
local expected_manifest_sha="$3"
|
|
|
|
[[ -d "$batch_dir" && ! -L "$batch_dir" ]] || fail "partial batch is missing"
|
|
[[ "$expected_manifest_sha" =~ ^[0-9a-f]{64}$ ]] || fail "manifest SHA-256 is invalid"
|
|
|
|
local entry_count regular_count
|
|
entry_count="$(find "$batch_dir" -mindepth 1 -maxdepth 1 -printf '.' | wc -c | tr -d ' ')"
|
|
regular_count="$(find "$batch_dir" -mindepth 1 -maxdepth 1 -type f -printf '.' | wc -c | tr -d ' ')"
|
|
[[ "$entry_count" == "3" && "$regular_count" == "3" ]] \
|
|
|| fail "batch must contain exactly three regular files"
|
|
for file_name in database.dump storage.tar.gz manifest.json; do
|
|
[[ -f "$batch_dir/$file_name" && ! -L "$batch_dir/$file_name" ]] \
|
|
|| fail "batch required file is missing"
|
|
done
|
|
|
|
local actual_manifest_sha
|
|
actual_manifest_sha="$(sha256sum "$batch_dir/manifest.json" | awk '{print $1}')"
|
|
[[ "$actual_manifest_sha" == "$expected_manifest_sha" ]] \
|
|
|| fail "manifest checksum mismatch"
|
|
|
|
"$PYTHON_BIN" - "$batch_dir" "$run_id" <<'PYTHON'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
batch_dir, expected_run_id = sys.argv[1:]
|
|
manifest_path = os.path.join(batch_dir, "manifest.json")
|
|
with open(manifest_path, "r", encoding="utf-8") as handle:
|
|
manifest = json.load(handle)
|
|
|
|
if manifest.get("schemaVersion") != 1:
|
|
raise SystemExit("unsupported manifest schema")
|
|
if manifest.get("status") != "complete" or manifest.get("runId") != expected_run_id:
|
|
raise SystemExit("manifest identity mismatch")
|
|
if not manifest.get("buildSha") and not manifest.get("sourceImageDigest"):
|
|
raise SystemExit("manifest source identity missing")
|
|
|
|
artifacts = manifest.get("artifacts") or {}
|
|
expected = {
|
|
"database": "database.dump",
|
|
"storage": "storage.tar.gz",
|
|
}
|
|
for key, expected_name in expected.items():
|
|
artifact = artifacts.get(key) or {}
|
|
if artifact.get("file") != expected_name:
|
|
raise SystemExit("manifest artifact name mismatch")
|
|
file_path = os.path.join(batch_dir, expected_name)
|
|
if os.path.getsize(file_path) != artifact.get("bytes"):
|
|
raise SystemExit("manifest artifact size mismatch")
|
|
digest = hashlib.sha256()
|
|
with open(file_path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
if digest.hexdigest() != artifact.get("sha256"):
|
|
raise SystemExit("manifest artifact checksum mismatch")
|
|
PYTHON
|
|
}
|
|
|
|
prune_named_directories() {
|
|
local root="$1"
|
|
local prefix="$2"
|
|
local keep="$3"
|
|
local -a entries=()
|
|
mapfile -t entries < <(find "$root" -mindepth 1 -maxdepth 1 -type d \
|
|
-name "${prefix}*" -printf '%f\n' | LC_ALL=C sort)
|
|
local remove_count=$((${#entries[@]} - keep))
|
|
if (( remove_count <= 0 )); then
|
|
return 0
|
|
fi
|
|
local index entry target
|
|
for ((index = 0; index < remove_count; index += 1)); do
|
|
entry="${entries[$index]}"
|
|
target="$root/$entry"
|
|
[[ "$entry" == "$prefix"* && -d "$target" && ! -L "$target" ]] \
|
|
|| fail "retention target failed safety validation"
|
|
rm -rf -- "$target"
|
|
done
|
|
}
|
|
|
|
publish_weekly_checkpoint() {
|
|
local archive_dir="$1"
|
|
local weekly_dir="$2"
|
|
local replace_existing="$3"
|
|
if [[ -e "$weekly_dir" && "$replace_existing" != "1" ]]; then
|
|
return 0
|
|
fi
|
|
local weekly_temp="${weekly_dir}.new.$$"
|
|
local weekly_rollback="${weekly_dir}.rollback.$$"
|
|
[[ ! -e "$weekly_temp" && ! -e "$weekly_rollback" ]] || return 1
|
|
if ! cp -al -- "$archive_dir" "$weekly_temp"; then
|
|
rm -rf -- "$weekly_temp"
|
|
return 1
|
|
fi
|
|
if [[ -e "$weekly_dir" ]]; then
|
|
[[ -d "$weekly_dir" && ! -L "$weekly_dir" ]] || return 1
|
|
mv -- "$weekly_dir" "$weekly_rollback" || return 1
|
|
fi
|
|
if ! mv -- "$weekly_temp" "$weekly_dir"; then
|
|
rm -rf -- "$weekly_temp"
|
|
if [[ -e "$weekly_rollback" ]]; then
|
|
mv -- "$weekly_rollback" "$weekly_dir"
|
|
fi
|
|
return 1
|
|
fi
|
|
if [[ -e "$weekly_rollback" ]]; then
|
|
rm -rf -- "$weekly_rollback"
|
|
fi
|
|
}
|
|
|
|
finalize_batch() {
|
|
local run_id="$1"
|
|
local expected_manifest_sha="$2"
|
|
validate_run_id "$run_id"
|
|
validate_layout
|
|
exec 9>"$BACKUP_ARCHIVE_DIR/.finalize.lock"
|
|
chmod 0600 "$BACKUP_ARCHIVE_DIR/.finalize.lock"
|
|
flock -n 9 || fail "another finalize operation is already running"
|
|
|
|
local batch_name="hyb-oa-backup-$run_id"
|
|
local partial_dir="$BACKUP_INBOX_DIR/${batch_name}.partial"
|
|
local archive_dir="$BACKUP_ARCHIVE_DIR/$batch_name"
|
|
local already_archived=0
|
|
if [[ -e "$archive_dir" ]]; then
|
|
[[ -d "$archive_dir" && ! -L "$archive_dir" ]] || fail "archive target is invalid"
|
|
validate_batch "$archive_dir" "$run_id" "$expected_manifest_sha"
|
|
already_archived=1
|
|
fi
|
|
|
|
local run_day="${run_id#auto-}"
|
|
run_day="${run_day%%T*}"
|
|
local existing_for_day="" replacement_rollback=""
|
|
local -a existing_for_day_entries=()
|
|
mapfile -t existing_for_day_entries < <(find "$BACKUP_ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d \
|
|
-name "hyb-oa-backup-auto-${run_day}T*Z" ! -name "$batch_name" -print)
|
|
[[ "${#existing_for_day_entries[@]}" -le 1 ]] || fail "daily archive invariant is already violated"
|
|
if [[ "${#existing_for_day_entries[@]}" -eq 1 ]]; then
|
|
existing_for_day="${existing_for_day_entries[0]}"
|
|
fi
|
|
|
|
if [[ "$already_archived" == "0" ]]; then
|
|
validate_batch "$partial_dir" "$run_id" "$expected_manifest_sha"
|
|
chmod 0400 "$partial_dir/database.dump" "$partial_dir/storage.tar.gz" "$partial_dir/manifest.json"
|
|
if [[ -n "$existing_for_day" ]]; then
|
|
[[ "$existing_for_day" =~ /hyb-oa-backup-auto-${run_day}T[0-9]{6}Z$ \
|
|
&& -d "$existing_for_day" && ! -L "$existing_for_day" ]] \
|
|
|| fail "existing daily archive is invalid"
|
|
replacement_rollback="$BACKUP_ARCHIVE_DIR/.daily-replacement-${run_day}.$$"
|
|
[[ ! -e "$replacement_rollback" ]] || fail "daily replacement rollback target already exists"
|
|
mv -- "$existing_for_day" "$replacement_rollback"
|
|
fi
|
|
if ! mv -- "$partial_dir" "$archive_dir"; then
|
|
if [[ -n "$replacement_rollback" && -e "$replacement_rollback" ]]; then
|
|
mv -- "$replacement_rollback" "$existing_for_day"
|
|
fi
|
|
fail "daily archive publication failed"
|
|
fi
|
|
fi
|
|
|
|
local iso_week
|
|
iso_week="$($PYTHON_BIN - "$run_day" <<'PYTHON'
|
|
from datetime import datetime
|
|
import sys
|
|
day = datetime.strptime(sys.argv[1], "%Y%m%d").date()
|
|
iso = day.isocalendar()
|
|
print(f"week-{iso.year:04d}-W{iso.week:02d}")
|
|
PYTHON
|
|
)"
|
|
local weekly_dir="$BACKUP_WEEKLY_DIR/$iso_week"
|
|
if ! publish_weekly_checkpoint "$archive_dir" "$weekly_dir" "$([[ -n "$replacement_rollback" ]] && echo 1 || echo 0)"; then
|
|
if [[ -n "$replacement_rollback" && -e "$replacement_rollback" ]]; then
|
|
rm -rf -- "$archive_dir"
|
|
mv -- "$replacement_rollback" "$existing_for_day"
|
|
fi
|
|
fail "weekly checkpoint update failed"
|
|
fi
|
|
if [[ -n "$replacement_rollback" && -e "$replacement_rollback" ]]; then
|
|
rm -rf -- "$replacement_rollback"
|
|
fi
|
|
|
|
prune_named_directories "$BACKUP_ARCHIVE_DIR" "hyb-oa-backup-auto-" "$DAILY_KEEP"
|
|
prune_named_directories "$BACKUP_WEEKLY_DIR" "week-" "$WEEKLY_KEEP"
|
|
echo "[offhost-receiver][PASS] finalized batch with verified manifest"
|
|
}
|
|
|
|
run_forced_command() {
|
|
validate_layout
|
|
local original_command="${SSH_ORIGINAL_COMMAND:-}"
|
|
if [[ "$original_command" == rsync\ --server\ * ]]; then
|
|
if [[ "$original_command" =~ (^|[[:space:]])--sender([[:space:]]|$) ]]; then
|
|
fail "read access is forbidden"
|
|
fi
|
|
if [[ "$original_command" =~ (^|[[:space:]])--(delete|delete-before|delete-after|delete-during|delete-delay|delete-excluded|remove-source-files|remove-sent-files|inplace)(=|[[:space:]]|$) ]]; then
|
|
fail "destructive rsync option is forbidden"
|
|
fi
|
|
[[ -x "$RRSYNC_BIN" ]] || fail "rrsync is required"
|
|
exec "$RRSYNC_BIN" -wo "$BACKUP_INBOX_DIR"
|
|
fi
|
|
|
|
if [[ "$original_command" =~ ^hyb-backup-finalize[[:space:]](auto-[0-9]{8}T[0-9]{6}Z)[[:space:]]([0-9a-f]{64})$ ]]; then
|
|
finalize_batch "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
|
|
return 0
|
|
fi
|
|
fail "unsupported forced command"
|
|
}
|
|
|
|
case "${1:-forced}" in
|
|
forced)
|
|
run_forced_command
|
|
;;
|
|
finalize-test)
|
|
[[ "${RECEIVER_TEST_MODE:-0}" == "1" ]] || fail "test mode is disabled"
|
|
finalize_batch "${2:-}" "${3:-}"
|
|
;;
|
|
validate-config)
|
|
validate_layout
|
|
echo "[offhost-receiver][PASS] configuration valid"
|
|
;;
|
|
*)
|
|
fail "unsupported mode"
|
|
;;
|
|
esac
|