thermograph/infra/openbao/seed-from-sops.sh
Emi Griffith 657e27ba9f
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 5s
secrets-guard / encrypted (push) Successful in 4s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 1s
Sync infra to hosts / sync-prod (push) Has been skipped
shell-lint / shellcheck (push) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / changes (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 7s
openbao: render Centralis from the vault
Centralis could not migrate at all. `seed-from-sops.sh --all` refused
centralis.prod with `CENTRALIS_TOKENS: contains a shell metacharacter`, and
render-secrets-openbao.sh had no Centralis path whatsoever — so the seeded data
would have had nothing to read it.

Both halves come from applying the app stack's rules to a file that does not share
its shape. /etc/thermograph.env is raw unquoted KEY=value. /etc/centralis.env is
consumed by exactly one thing and that thing is a shell:

    sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up'

CENTRALIS_TOKENS is JSON, so it can never satisfy a no-metacharacters rule, and
rendering it unquoted is precisely the 2026-07-24 failure: bash strips the quotes,
Centralis fails closed on the malformed registry and serves a single `shared`
identity, indistinguishable from the file never being written.

So:

- seed-from-sops.sh validates against the renderer that will consume the path. The
  dotenv rules still apply to common and env/*; centralis/* is exempt because it is
  single-quoted downstream.
- render-secrets-openbao.sh gains thermograph_render_openbao_centralis, mirroring
  render_centralis_secrets: fetch as JSON, emit POSIX single-quoted assignments,
  then PROVE the file by sourcing it under `env -i` with `set -a` and comparing
  every value back before anything touches the destination. Mirrored rather than
  shared, per the existing note above thermograph_render_openbao — consolidate when
  the SOPS path is deleted.
- render_centralis_secrets gains the same early-return backend dispatch the app
  path already uses, so the new function is reachable. Without it the SOPS path was
  the only path regardless of TG_SECRETS_BACKEND.

Verified against a synthetic registry containing a JSON token map, an apostrophe,
and `$(...)`/backtick/backslash/double-quote payloads: 5 keys round-trip
byte-for-byte through `set -a; . file`, CENTRALIS_TOKENS parses to the right
subjects, and the command substitution does not execute. The negative case is
covered too — fed the unquoted 2026-07-24 shape, the round-trip check reports the
value as changed (by length, never content) and refuses to write.

Also corrects the claim in render-secrets-openbao.sh that the SOPS path never
tripped the metacharacter hazard "because every current value happens to be
alphanumeric". CENTRALIS_TOKENS is the counter-example; it never tripped those
paths because it was never in them.
2026-07-30 19:55:17 -07:00

210 lines
8.3 KiB
Bash
Executable file

#!/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 <name> ...)
--dry-run validate and report key names/counts; contact OpenBao not at all
--all seed common, dev, beta, prod and centralis.prod
--env <name> 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 <mount>/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 <<EOF
Seeded. NOTHING is cut over yet — SOPS is still authoritative for every
environment, because TG_SECRETS_BACKEND defaults to sops in env-topology.sh.
Next: prove equivalence before flipping anything.
infra/openbao/verify-parity.sh --env dev
EOF
fi
exit "$rc"