#!/usr/bin/env bash # lib/railiance-backup-common.sh — age encrypt + Nextcloud upload helpers (Option A) set -euo pipefail : "${RAILIANCE_BACKUP_AGE_PUBLIC_KEY:=age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz}" : "${RAILIANCE_BACKUP_BAO_PATH:=platform/workloads/railiance/backup/offsite-lane}" : "${RAILIANCE_BACKUP_NC_PREFIX:=forgejo}" railiance_backup_load_openbao_lane() { if [[ -n "${RAILIANCE_BACKUP_NC_TOKEN:-}" && -n "${RAILIANCE_BACKUP_NC_WEBDAV_URL:-}" ]]; then return 0 fi command -v bao >/dev/null 2>&1 || return 0 bao kv metadata get "${RAILIANCE_BACKUP_BAO_PATH}" >/dev/null 2>&1 || return 0 : "${RAILIANCE_BACKUP_NC_TOKEN:=$(bao kv get -field=NC_WEBDAV_TOKEN "${RAILIANCE_BACKUP_BAO_PATH}" 2>/dev/null)}" : "${RAILIANCE_BACKUP_NC_WEBDAV_URL:=$(bao kv get -field=NC_WEBDAV_URL "${RAILIANCE_BACKUP_BAO_PATH}" 2>/dev/null)}" } railiance_backup_require_openbao_lane() { railiance_backup_load_openbao_lane if [[ -z "${RAILIANCE_BACKUP_NC_TOKEN:-}" ]]; then echo "ERROR: governed backup credential unavailable (CCR-2026-0004)" >&2 return 1 fi # Build a file-drop URL only after the required token is present. : "${RAILIANCE_BACKUP_NC_WEBDAV_URL:=https://nx4069.your-storageshare.de/public.php/dav/filesdrop/${RAILIANCE_BACKUP_NC_TOKEN}}" } railiance_backup_require_tools() { # Prefer vendored static binaries (activity-core hostPath / offline runners). local root vendor root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" vendor="${root}/tools/vendor/bin" if [[ -d "$vendor" ]]; then export PATH="${vendor}:${PATH}" fi if [[ -n "${RAILIANCE_BACKUP_AGE_BIN:-}" && -x "${RAILIANCE_BACKUP_AGE_BIN}" ]]; then export PATH="$(dirname "${RAILIANCE_BACKUP_AGE_BIN}"):${PATH}" fi local t for t in age kubectl; do command -v "$t" >/dev/null 2>&1 || { echo "ERROR: missing required tool: $t (run tools/cmd/install-cnpg-backup-vendor-tools)" >&2; exit 1; } done # curl optional — upload falls back to python3 urllib (activity-core worker has no curl) if ! command -v curl >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then echo "ERROR: need curl or python3 for Nextcloud upload" >&2 exit 1 fi } railiance_backup_nc_upload() { local file="$1" remote_name="$2" local dest="${RAILIANCE_BACKUP_NC_WEBDAV_URL%/}/${RAILIANCE_BACKUP_NC_PREFIX}/${remote_name}" if [[ "$dest" != https://* || "$dest" == *$'\n'* || "$dest" == *$'\r'* || "${RAILIANCE_BACKUP_NC_TOKEN}" == *$'\n'* || "${RAILIANCE_BACKUP_NC_TOKEN}" == *$'\r'* || "$file" == *$'\n'* || "$file" == *$'\r'* ]]; then echo "ERROR: invalid backup upload input" >&2 return 1 fi if command -v curl >/dev/null 2>&1; then # Credentials and credential-bearing URLs travel over stdin, never argv. local config_url config_token config_file status config_url="${dest//\\/\\\\}"; config_url="${config_url//\"/\\\"}" config_token="${RAILIANCE_BACKUP_NC_TOKEN//\\/\\\\}"; config_token="${config_token//\"/\\\"}" config_file="${file//\\/\\\\}"; config_file="${config_file//\"/\\\"}" status="$(printf 'url = "%s"\nuser = "%s:"\nupload-file = "%s"\n' \ "$config_url" "$config_token" "$config_file" | \ curl --disable --silent --fail --proto '=https' --max-time 600 \ --output /dev/null --write-out '%{http_code}' --config - 2>/dev/null)" \ || { echo "ERROR: Nextcloud upload failed" >&2; return 1; } case "$status" in 200|201|204) ;; *) echo "ERROR: Nextcloud upload failed" >&2; return 1 ;; esac return 0 fi # Python fallback (worker image) RAILIANCE_BACKUP_NC_TOKEN="${RAILIANCE_BACKUP_NC_TOKEN}" \ RAILIANCE_BACKUP_UPLOAD_URL="${dest}" \ RAILIANCE_BACKUP_UPLOAD_FILE="${file}" \ python3 - <<'PY' || { echo "ERROR: Nextcloud upload failed" >&2; return 1; } import os, urllib.request, urllib.error, base64, sys url = os.environ["RAILIANCE_BACKUP_UPLOAD_URL"] path = os.environ["RAILIANCE_BACKUP_UPLOAD_FILE"] token = os.environ["RAILIANCE_BACKUP_NC_TOKEN"] class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise urllib.error.HTTPError(req.full_url, code, 'redirect refused', headers, fp) try: with open(path, 'rb') as source: chunks = iter(lambda: source.read(1024 * 1024), b'') req = urllib.request.Request(url, data=chunks, method="PUT") req.add_header("Authorization", "Basic " + base64.b64encode(f"{token}:".encode()).decode()) req.add_header("Content-Type", "application/octet-stream") req.add_header("Content-Length", str(os.fstat(source.fileno()).st_size)) with urllib.request.build_opener(NoRedirect()).open(req, timeout=600) as resp: if resp.status not in (200, 201, 204): sys.exit(1) except Exception: # URLs may themselves contain credentials: never emit exception text. sys.exit(1) PY } railiance_backup_encrypt() { local src="$1" dest="$2" age -r "${RAILIANCE_BACKUP_AGE_PUBLIC_KEY}" -o "$dest" "$src" } railiance_backup_prune_local() { local dir="$1" pattern="$2" keep="${3:-7}" find "$dir" -maxdepth 1 -name "$pattern" -type f 2>/dev/null \ | sort -r | tail -n +"$((keep + 1))" | xargs -r rm -f } railiance_backup_record_success() { local stamp_dir="$1" ts="$2" mkdir -p "$stamp_dir" echo "$ts" > "${stamp_dir}/.last-success" echo "$ts" >> "${stamp_dir}/success-log" }