#!/usr/bin/env bash # Seed OpenBao from the SOPS vault. One-shot, idempotent, run BY THE OPERATOR. # # infra/openbao/seed-from-sops.sh --dry-run # key names only, writes nothing # infra/openbao/seed-from-sops.sh --env dev # seed one environment # infra/openbao/seed-from-sops.sh --all # seed everything # # ============================================================================ # THIS SCRIPT READS EVERY PRODUCTION SECRET IN THE ESTATE IN PLAINTEXT. # ============================================================================ # infra/CLAUDE.md and deploy/secrets/README.md both say that # deploy/secrets/seed-from-live.sh "reads production secrets and is explicitly NOT for # an agent to run". This script is in the same category and inherits the same rule: # run it yourself, from your own terminal, with your own age key. # # It never prints a value. Every diagnostic is key NAMES and counts, so the output is # safe to paste into an issue. --dry-run makes that guarantee mechanical: it resolves # and validates everything, then reports what it WOULD write without contacting # OpenBao at all. # # Why seed from SOPS rather than re-entering values by hand: the safety property of # this entire migration is that the OpenBao render can be diffed BYTE-FOR-BYTE against # the SOPS render (see verify-parity.sh). That only works if the values are identical. # Re-typing 61 values would silently rotate whichever ones were mistyped, and the # self-generating ones (AUTH_SECRET, the VAPID pair) fail SILENTLY when wrong — the app # mints a replacement, comes up green, and invalidates every session and push # subscription. So: copy exactly now, rotate deliberately later. set -euo pipefail MOUNT="${THERMOGRAPH_BAO_MOUNT:-thermograph}" AGE_KEY="${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}" DRY_RUN=0 TARGETS=() # Resolve the repo's infra/ directory from this script's own location, so the script # works from any cwd. This is the same mistake terraform's remote-exec makes # (main.tf:256 passes app_dir where app_dir/infra is wanted), so it is worth being # explicit rather than clever. SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" INFRA_DIR="$(cd -- "$SELF_DIR/.." && pwd)" VAULT_DIR="$INFRA_DIR/deploy/secrets" usage() { cat >&2 <<'EOF' usage: seed-from-sops.sh [--dry-run] (--all | --env ...) --dry-run validate and report key names/counts; contact OpenBao not at all --all seed common, dev, beta, prod and centralis.prod --env seed one of: common | dev | beta | prod | centralis.prod Environment: SOPS_AGE_KEY_FILE age private key (default ~/.config/sops/age/keys.txt) BAO_ADDR OpenBao address (default https://10.10.0.1:8200) BAO_TOKEN an operator token with write on /data/* THERMOGRAPH_BAO_MOUNT KV v2 mount name (default thermograph) EOF exit 2 } while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --all) TARGETS=(common dev beta prod centralis.prod); shift ;; --env) [ $# -ge 2 ] || usage; TARGETS+=("$2"); shift 2 ;; -h|--help) usage ;; *) echo "!! unknown argument: $1" >&2; usage ;; esac done [ ${#TARGETS[@]} -gt 0 ] || usage # The KV v2 path for a given vault file. common.yaml and centralis.prod.yaml are # special-cased; everything else is an environment overlay. # # Keeping `common` at its own top-level path rather than under env/ is what makes the # dev policy expressible: tg-host-dev.hcl denies exactly one path, and no wildcard # over env/* can accidentally re-include it. bao_path_for() { case "$1" in common) printf 'common\n' ;; centralis.prod) printf 'centralis/prod\n' ;; dev|beta|prod) printf 'env/%s\n' "$1" ;; *) echo "!! unknown vault target: $1" >&2; return 1 ;; esac } [ -f "$AGE_KEY" ] || { echo "!! age key not found at $AGE_KEY" >&2; exit 1; } command -v sops >/dev/null 2>&1 || { echo "!! sops not on PATH" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "!! python3 not on PATH" >&2; exit 1; } if [ "$DRY_RUN" = 0 ]; then command -v bao >/dev/null 2>&1 || { echo "!! bao not on PATH" >&2; exit 1; } [ -n "${BAO_TOKEN:-}" ] || { echo "!! BAO_TOKEN is not set" >&2; exit 1; } export BAO_ADDR="${BAO_ADDR:-https://10.10.0.1:8200}" fi rc=0 for target in "${TARGETS[@]}"; do src="$VAULT_DIR/${target}.yaml" if [ ! -f "$src" ]; then echo "!! no vault file at $src" >&2 rc=1 continue fi path="$(bao_path_for "$target")" || { rc=1; continue; } # Decrypt to JSON, not dotenv. JSON is lossless: the dotenv writer flattens a value # containing a newline into a literal \n that is indistinguishable from a backslash # followed by n (render-secrets.sh:198 documents this). Seeding through dotenv would # bake that ambiguity into OpenBao permanently. plain_json="$(SOPS_AGE_KEY_FILE="$AGE_KEY" \ sops -d --input-type yaml --output-type json "$src" 2>/dev/null)" || { echo "!! failed to decrypt $src" >&2 rc=1 continue } # Validate and reshape into the flat string map KV v2 wants. Refuses the same unsafe # values render-secrets-openbao.sh refuses, so an unrenderable value is caught at # SEED time — before anything depends on it — rather than at deploy time. # Which renderer will consume this path decides which values are legal, so the # validation has to match it. The app stack's /etc/thermograph.env is raw, UNQUOTED # KEY=value (render-secrets-openbao.sh:14-18 freezes that shape), so a metacharacter # there is a live expansion hazard. /etc/centralis.env is POSIX single-quoted by # render_centralis_secrets and its OpenBao counterpart, where every byte inside # '...' is literal — so applying the dotenv rules to it rejects data that renders # perfectly well. CENTRALIS_TOKENS is JSON and can never satisfy them. quoted=0 case "$target" in centralis.*) quoted=1 ;; esac reshaped="$(THERMOGRAPH_SEED_JSON="$plain_json" THERMOGRAPH_SEED_QUOTED="$quoted" python3 - <<'PY' import json, os, re, sys doc = json.loads(os.environ["THERMOGRAPH_SEED_JSON"]) if not isinstance(doc, dict) or not doc: sys.stderr.write("!! vault did not decrypt to a non-empty object\n") sys.exit(1) # 1 => destined for a single-quoted env file; 0 => raw dotenv. QUOTED = os.environ.get("THERMOGRAPH_SEED_QUOTED") == "1" KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") UNSAFE = set("`$\\\"'") out, bad = {}, [] for key, val in doc.items(): if key == "sops": continue if not KEY_RE.match(key): bad.append(f"{key}: not a valid env var name") continue if val is None: val = "" if not isinstance(val, str): val = json.dumps(val, separators=(",", ":")) if not QUOTED: if "\n" in val or "\r" in val: bad.append(f"{key}: contains a newline") continue if UNSAFE & set(val): bad.append(f"{key}: contains a shell metacharacter (` $ \\ \" ')") continue out[key] = val if bad: sys.stderr.write("!! unsafe values, refusing:\n") for line in bad: sys.stderr.write(f"!! {line}\n") sys.exit(1) # stdout line 1: space-separated key NAMES, for the operator-visible log. # stdout line 2+: the JSON payload, fed to `bao kv put` on stdin. sys.stdout.write(" ".join(sorted(out)) + "\n") sys.stdout.write(json.dumps(out) + "\n") PY )" || { echo "!! validation failed for $target" >&2; rc=1; continue; } names="$(printf '%s\n' "$reshaped" | sed -n '1p')" payload="$(printf '%s\n' "$reshaped" | sed -n '2p')" count="$(printf '%s\n' "$names" | wc -w | tr -d ' ')" if [ "$DRY_RUN" = 1 ]; then echo "== ${target}.yaml -> ${MOUNT}/${path} (${count} keys, DRY RUN, nothing written)" printf ' %s\n' "$names" continue fi # `bao kv put -` reads a JSON object from stdin, so no value ever appears in argv # (where it would be visible in /proc and in the shell's history). if printf '%s' "$payload" | bao kv put -mount="$MOUNT" "$path" - >/dev/null; then echo "== ${target}.yaml -> ${MOUNT}/${path} (${count} keys written)" else echo "!! failed writing ${MOUNT}/${path}" >&2 rc=1 fi done if [ "$rc" = 0 ] && [ "$DRY_RUN" = 0 ]; then cat <