#!/usr/bin/env bash # # set-r2-credentials.sh — put ONE Cloudflare R2 API token into every credential Secret that consumes # the off-cluster backup target, without plaintext ever touching the disk. # # WHY THIS EXISTS # The same S3 key is read by four different Secrets in three namespaces, in TWO different shapes # (Velero wants an AWS INI file; CNPG/barman wants discrete ACCESS_KEY_ID / ACCESS_SECRET_KEY keys). # Hand-editing four SOPS files is four chances to typo one of them — and a typo'd backup credential # does not fail at apply time, it fails at 03:00 with no backup and a green HelmRelease. # # It uses `sops set`, which edits an ALREADY-ENCRYPTED file in place: the new value is encrypted as # it is written and NO decrypted copy of the file is ever created. That is the whole point — the # `cp template plaintext.yaml && edit && sops -e -i` dance leaves a plaintext Secret sitting in the # working tree, one `git add -A` away from being committed forever. # # USAGE (inside the dev container — it has sops and mounts the age key) # ./infra/devcontainer/dev.sh run platform/backup/set-r2-credentials.sh # → prompts for the two values (never echoed, never in shell history, never in a file) # # ./infra/devcontainer/dev.sh run platform/backup/set-r2-credentials.sh --check # → touches nothing; just reports whether the four Secrets already agree with each other # # R2_ACCESS_KEY_ID=… R2_SECRET_ACCESS_KEY=… …/set-r2-credentials.sh --no-prompt # → non-interactive. Prefer the prompts: an env var is visible in /proc and in `history`. # # WHAT IT DOES NOT DO # - It does not touch clusters/base/cluster-vars.yaml. The endpoint is not a secret and belongs in # a reviewable plain manifest; the script prints the exact edit to make. # - It does not commit. Review `git diff` (you will see only ENC[...] blobs change) and commit. # - It does not delete or rewrite platform/backup/minio-credentials.sops.yaml. That is the RETAINED # pre-migration MinIO key and it is what you need to read the pre-migration backups. # # SEE infra/BACKUP-R2.md for the whole procedure, including the restore that actually proves it. set -euo pipefail # Belt and braces: never let a stray `set -x` in a parent shell echo a credential out of this script. set +x here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" root="$(git -C "${here}" rev-parse --show-toplevel 2>/dev/null || (cd "${here}/.." && pwd))" cd "${root}" MODE=set PROMPT=1 while [ $# -gt 0 ]; do case "$1" in --check) MODE=check; shift ;; --no-prompt) PROMPT=0; shift ;; -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; esac done die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf '\033[33mWARN: %s\033[0m\n' "$*" >&2; } command -v sops >/dev/null 2>&1 || die "'sops' not found — run this inside ./infra/devcontainer/dev.sh." # ---------------------------------------------------------------- the consumers # file|shape. shape=ini -> stringData.cloud, an AWS INI credentials file (velero-plugin-for-aws) # shape=discrete -> stringData.ACCESS_KEY_ID + ACCESS_SECRET_KEY (CNPG barmanObjectStore) CONSUMERS=( "platform/velero/velero-cloud.sops.yaml|ini" "platform/keycloak/keycloak-db-backup-credentials.sops.yaml|discrete" "apps/wave-0/nextcloud-db-backup-credentials.sops.yaml|discrete" "apps/wave-0/semaphore-db-backup-credentials.sops.yaml|discrete" ) for entry in "${CONSUMERS[@]}"; do f="${entry%%|*}" [ -f "${f}" ] || die "missing consumer file ${f} — has the layout changed? Fix this script, do not skip the file." grep -q '^sops:' "${f}" || die "${f} has no top-level sops: block — refusing to touch a file that is not SOPS-encrypted." done # A 32-bit fingerprint of a high-entropy secret: enough to prove two files hold the SAME value, # nowhere near enough to recover it. Values themselves are NEVER printed. fp() { local h; h="$(printf '%s' "$1" | sha256sum)"; printf '%s' "${h:0:8}"; } # Read one value out of an encrypted file without ever writing plaintext anywhere. xtract() { sops -d --extract "$2" "$1"; } # Pull the two values out of an AWS INI blob using shell only. ini_field() { # local line v="" while IFS= read -r line; do case "${line}" in "$2"=*|"$2 "*|*[![:alnum:]_]"$2"=*) v="${line#*=}"; v="${v#"${v%%[![:space:]]*}"}" ;; esac done <<<"$1" printf '%s' "${v}" } report() { # print the fingerprint table; returns non-zero when the four files disagree local entry f shape id sec akid_fp sec_fp first_id="" first_sec="" agree=0 printf ' %-58s %-10s %-10s\n' FILE ID-FP SECRET-FP for entry in "${CONSUMERS[@]}"; do f="${entry%%|*}"; shape="${entry##*|}" if [ "${shape}" = ini ]; then local blob; blob="$(xtract "${f}" '["stringData"]["cloud"]' 2>/dev/null || true)" id="$(ini_field "${blob}" aws_access_key_id)" sec="$(ini_field "${blob}" aws_secret_access_key)" blob="" else id="$(xtract "${f}" '["stringData"]["ACCESS_KEY_ID"]' 2>/dev/null || true)" sec="$(xtract "${f}" '["stringData"]["ACCESS_SECRET_KEY"]' 2>/dev/null || true)" fi [ -n "${id}" ] || { warn "${f}: could not read the access key id (age key available?)"; agree=1; } [ -n "${sec}" ] || { warn "${f}: could not read the secret access key"; agree=1; } akid_fp="$(fp "${id}")"; sec_fp="$(fp "${sec}")" printf ' %-58s %-10s %-10s\n' "${f}" "${akid_fp}" "${sec_fp}" if [ -z "${first_id}" ]; then first_id="${akid_fp}"; first_sec="${sec_fp}" elif [ "${akid_fp}" != "${first_id}" ] || [ "${sec_fp}" != "${first_sec}" ]; then agree=1; fi id=""; sec="" done return "${agree}" } if [ "${MODE}" = check ]; then say "current state of the four backup credential Secrets (values never printed)" if report; then info "ALL FOUR AGREE. (Agreeing is not the same as being right — prove it with bootstrap/standup.sh s3check.)" exit 0 fi die "the four Secrets do NOT all hold the same key. Velero and CNPG would be writing to the same target with different credentials — one of them is stale. Re-run this script without --check." fi # ---------------------------------------------------------------- the two values AKID="${R2_ACCESS_KEY_ID-}" SAK="${R2_SECRET_ACCESS_KEY-}" if [ "${PROMPT}" = 1 ]; then say "paste the R2 API token (Cloudflare dashboard -> R2 -> Manage API Tokens)" info "Object Read & Write, scoped to the velero + cnpg buckets. Neither value is echoed or stored." [ -n "${AKID}" ] || { printf ' Access Key ID: '; IFS= read -rs AKID; printf '\n'; } [ -n "${SAK}" ] || { printf ' Secret Access Key: '; IFS= read -rs SAK; printf '\n'; } fi [ -n "${AKID}" ] || die "no access key id given (prompt, or R2_ACCESS_KEY_ID with --no-prompt)." [ -n "${SAK}" ] || die "no secret access key given (prompt, or R2_SECRET_ACCESS_KEY with --no-prompt)." # FAIL CLOSED on anything that is not a plain S3 credential. These values are passed to `sops set` as # JSON, so a quote, a backslash or a newline would corrupt the Secret rather than error — and the # usual cause is a copy-paste that swallowed a newline or a trailing space, which would produce a # credential that authenticates nowhere and a backup that silently never runs. for pair in "access key id:${AKID}" "secret access key:${SAK}"; do case "${pair#*:}" in *[!A-Za-z0-9._~+/=-]*) die "the ${pair%%:*} contains a character outside [A-Za-z0-9._~+/=-]. Almost certainly a stray space, quote or newline from the paste. Nothing was changed." ;; esac done # R2 issues 32-hex ids and 64-hex secrets today. A mismatch is a warning, not a refusal: the format is # Cloudflare's to change, and refusing outright would make this script the thing that blocks a rotation. case "${AKID}" in *[!0-9a-f]*|"") warn "the access key id is not 32 lowercase hex chars — unusual for R2. Continuing." ;; *) [ "${#AKID}" = 32 ] || warn "the access key id is ${#AKID} chars, not the usual 32. Continuing." ;; esac [ "${#SAK}" -ge 40 ] || warn "the secret access key is only ${#SAK} chars — unusual for R2 (64). Continuing." # ---------------------------------------------------------------- write say "writing the credential into ${#CONSUMERS[@]} SOPS Secrets (encrypted in place; no plaintext file)" for entry in "${CONSUMERS[@]}"; do f="${entry%%|*}"; shape="${entry##*|}" case "${shape}" in ini) # velero-plugin-for-aws reads this key VERBATIM as an AWS credentials file. The [default] # profile name and the two underscore_names are the plugin's contract, not a style choice. sops set "${f}" '["stringData"]["cloud"]' \ "\"[default]\\naws_access_key_id=${AKID}\\naws_secret_access_key=${SAK}\\n\"" ;; discrete) sops set "${f}" '["stringData"]["ACCESS_KEY_ID"]' "\"${AKID}\"" sops set "${f}" '["stringData"]["ACCESS_SECRET_KEY"]' "\"${SAK}\"" ;; esac info "updated ${f}" done AKID_FP="$(fp "${AKID}")"; SAK_FP="$(fp "${SAK}")" AKID=""; SAK="" say "verifying by decrypting each file back (values never printed)" report || die "the files still disagree after writing — do NOT commit. Inspect with 'sops '." info "every file now reads id-fp=${AKID_FP} secret-fp=${SAK_FP} — matching what was pasted." say "NEXT — in this order, and do not skip the restore" cat <<'NEXT' 1. Set the endpoint (NOT a secret, so it lives in a plain reviewable manifest): clusters/base/cluster-vars.yaml S3_BACKUP_ENDPOINT: "https://.r2.cloudflarestorage.com" S3_BACKUP_REGION: "auto" # R2 accepts nothing else 2. Prove the target BEFORE merging anything (writes and deletes one throwaway object): bootstrap/standup.sh --kubeconfig --expect-server \ --s3-endpoint https://.r2.cloudflarestorage.com s3check 3. Only then commit + merge, and follow infra/BACKUP-R2.md through the RESTORE. A backup that has never been restored is not a backup, and R-STATE-03 is currently PROVEN against MinIO only. NEXT