#!/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