#!/usr/bin/env bash # OpenBao source backend for render-secrets.sh. Sourced, never run directly. # # Functions: # thermograph_openbao_source -> merged dotenv on STDOUT # thermograph_render_openbao -> source, then write # thermograph_render_openbao_centralis [out] -> quoted /etc/centralis.env # # The first two serve the app stack (/etc/thermograph.env, raw unquoted dotenv). The # third serves Centralis, whose file is SOURCED by bash and therefore single-quoted — # a different shape for a different consumer, not an inconsistency. # # render_thermograph_secrets in render-secrets.sh dispatches to the second when # TG_SECRETS_BACKEND=openbao, and is otherwise completely untouched — so the SOPS path # keeps byte-identical behaviour and all new risk is confined to this file. See the # note above thermograph_render_openbao on why the write path is duplicated here # rather than shared, and when to consolidate. # # WHY OUTPUT SHAPE IS FROZEN: /etc/thermograph.env must stay raw, UNQUOTED KEY=value. # Centralis' secrets_inventory / secrets_render compare that file textually # (deploy/secrets/README.md:206-210), compose reads it via `env_file:`, and the Swarm # stack's env-entrypoint.sh parses it line by line. Quoting it "properly" would be an # improvement in isolation and a silent break in context. # thermograph_openbao_source # # Reads the common set then the per-environment set and merges them with the # environment winning — the same last-wins semantic as the SOPS concatenation, except # performed explicitly here because OpenBao returns a map per path rather than a # stream that can simply be appended. thermograph_openbao_source() { local env_name="${1:?env name required}" local addr="${THERMOGRAPH_BAO_ADDR:-https://10.10.0.1:8200}" local mount="${THERMOGRAPH_BAO_MOUNT:-thermograph}" # Explicit override first, then the per-environment value env-topology.sh derives, # then the conventional path. Same precedence shape as render-secrets.sh:44's # THERMOGRAPH_SECRETS_BACKEND / TG_SECRETS_BACKEND pair, for the same reason: the # THERMOGRAPH_-prefixed name is the by-hand escape hatch, the TG_ one is what the # deploy path sets. Falling straight through to the bare default is what made beta # authenticate as tg-prod and get denied its own path. local approle="${THERMOGRAPH_BAO_APPROLE:-${TG_BAO_APPROLE:-/etc/thermograph/openbao-approle}}" local ca="${THERMOGRAPH_BAO_CACERT:-/etc/thermograph/openbao-ca.crt}" command -v bao >/dev/null 2>&1 || { echo "!! bao CLI not installed but this host is configured for the openbao backend" >&2 return 1 } command -v python3 >/dev/null 2>&1 || { echo "!! python3 required to render dotenv from OpenBao JSON" >&2 return 1 } # AppRole credentials: role_id and secret_id, one per line, 0400 root — the same # protection level as /etc/thermograph/age.key today. Read directly if we can, else # via sudo, mirroring how render-secrets.sh lifts the age key. local creds if [ -r "$approle" ]; then creds=$(cat "$approle") else creds=$(sudo cat "$approle" 2>/dev/null || true) fi [ -n "$creds" ] || { echo "!! cannot read OpenBao AppRole credentials at $approle (need sudo)" >&2 return 1 } local role_id secret_id role_id=$(printf '%s\n' "$creds" | sed -n '1p') secret_id=$(printf '%s\n' "$creds" | sed -n '2p') [ -n "$role_id" ] && [ -n "$secret_id" ] || { echo "!! $approle malformed: expected role_id on line 1, secret_id on line 2" >&2 return 1 } export BAO_ADDR="$addr" [ -f "$ca" ] && export BAO_CACERT="$ca" # Exchange the AppRole for a short-lived token. The token TTL is set on the role # (see bootstrap.sh) and is deliberately measured in minutes: it only has to # outlive one render. local token token=$(bao write -field=token auth/approle/login \ role_id="$role_id" secret_id="$secret_id" 2>/dev/null) || { echo "!! OpenBao AppRole login failed for env='${env_name}'" >&2 echo "!! check that OpenBao at $addr is reachable and UNSEALED" >&2 return 1 } export BAO_TOKEN="$token" # Fetch. `common` is skipped for dev by the same flag the SOPS path uses — but note # that on the OpenBao path the flag is belt, not braces: dev's AppRole is bound to # the tg-host-dev policy, which DENIES thermograph/data/common outright. If this # flag is ever forgotten at a call site, dev gets a 403 rather than a file full of # production credentials. That inversion — from "the caller must remember" to "the # store refuses" — is the main reason for this migration. local common_json="" env_json if [ "${THERMOGRAPH_SECRETS_SKIP_COMMON:-0}" != 1 ]; then common_json=$(bao kv get -format=json -mount="$mount" common 2>/dev/null) || { echo "!! cannot read ${mount}/common for env='${env_name}'" >&2 echo "!! if this is dev, THERMOGRAPH_SECRETS_SKIP_COMMON=1 was not set and the" >&2 echo "!! tg-host-dev policy correctly refused — that is the guard working." >&2 return 1 } fi env_json=$(bao kv get -format=json -mount="$mount" "env/${env_name}" 2>/dev/null) || { echo "!! cannot read ${mount}/env/${env_name}" >&2 return 1 } # Merge and emit. Python rather than jq because jq is not installed on these hosts # and python3 is (the Centralis renderer already relies on it). # # This step also enforces the two invariants the dotenv format cannot express: # * a value containing a newline is REJECTED, because it would silently become # two lines and the second would parse as a bogus KEY=value or be dropped; # * a value containing a shell metacharacter is REJECTED, because # /etc/thermograph.env is sourced by bash in six places (deploy.sh:133, # deploy-stack.sh:98, both daemon entrypoints, ops-cron.yml:85, terraform), so # `$(...)` or a backtick in a secret is a live command-execution path on the # deploy host. Refusing here turns a latent code-execution bug into a loud # render failure. # # An earlier version of this comment claimed the SOPS path had never tripped # this "only because every current value happens to be alphanumeric". That is # false: CENTRALIS_TOKENS is JSON and full of double quotes. It has never # tripped THESE paths because it is not in them — it lives at # centralis/ and renders to /etc/centralis.env, which is single-quoted # precisely because raw dotenv cannot carry it. See # thermograph_render_openbao_centralis at the end of this file. THERMOGRAPH_BAO_COMMON="$common_json" \ THERMOGRAPH_BAO_ENV="$env_json" \ python3 - <<'PY' import json, os, re, sys def load(raw): if not raw: return {} doc = json.loads(raw) return doc.get("data", {}).get("data", {}) or {} merged = {} # common first, environment second: last-wins, matching the SOPS concatenation order. for src in ("THERMOGRAPH_BAO_COMMON", "THERMOGRAPH_BAO_ENV"): merged.update(load(os.environ.get(src, ""))) KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Characters that change meaning when the file is sourced by bash. UNSAFE = set("`$\\\"'") bad = [] lines = [] # Validate EVERYTHING before emitting a single byte. Printing as we go would leave # partial output on stdout when a later key fails, and render-secrets.sh's whole # discipline is that a failed source never produces a partial env file. for key in sorted(merged): if not KEY_RE.match(key): bad.append(f"{key}: not a valid env var name") continue val = merged[key] if val is None: val = "" if not isinstance(val, str): val = json.dumps(val, separators=(",", ":")) if "\n" in val or "\r" in val: bad.append(f"{key}: value contains a newline (would corrupt the dotenv)") continue if UNSAFE & set(val): bad.append( f"{key}: value contains a shell metacharacter (one of ` $ \\ \" ') and " f"/etc/thermograph.env is sourced by bash — refusing" ) continue lines.append(f"{key}={val}") if bad: sys.stderr.write("!! refusing to render; unsafe values:\n") for line in bad: sys.stderr.write(f"!! {line}\n") sys.exit(1) if not lines: sys.stderr.write("!! refusing to render: OpenBao returned zero keys\n") sys.exit(1) sys.stdout.write("\n".join(lines) + "\n") PY } # thermograph_render_openbao # # The OpenBao equivalent of render_thermograph_secrets: source, then write. # # ON THE DUPLICATED WRITE PATH. The write logic below mirrors # render-secrets.sh:130-154 rather than being factored out and shared. That is a # deliberate, temporary choice, not an oversight: # # render-secrets.sh is on the deploy path for all three environments and there is no # test suite in front of it (infra/CLAUDE.md: "Shell here runs as root over SSH # against live hosts with no test suite in front of it"). Extracting the write path # would mean the SOPS render — which currently works — starts flowing through newly # moved code that cannot be tested end-to-end from here. An early-return branch keeps # the SOPS path byte-identical and confines all new risk to the new backend. # # CONSOLIDATE THESE after prod has been on the OpenBao backend for a full release # cycle and the SOPS path is being deleted anyway. Until then, a change to one write # path must be made in both — which is exactly the cost being accepted here. thermograph_render_openbao() { local env_name="${1:?env name required}" local out="${2:?output path required}" echo "==> Rendering $out from OpenBao (env=${env_name})" local tmp; tmp=$(mktemp) # Holds DECRYPTED secrets. Removed on every exit path. Deliberately not a # `trap ... RETURN` — see render-secrets.sh:82-91 for why that is actively wrong in # a sourced function (the trap persists into the caller and re-fires on its next # `source`, where the local is unset and `set -u` makes it fatal and silent). if ! thermograph_openbao_source "$env_name" > "$tmp"; then rm -f "$tmp" return 1 fi # An empty render must never reach $out. The source function already refuses a # zero-key result, so this is a second line of defence rather than the only one: # a truncated /etc/thermograph.env is the single worst outcome available here, # because thermograph.service uses `EnvironmentFile=-` (missing is NOT fatal) and # the app self-generates AUTH_SECRET and the VAPID pair when they are absent. The # result would be a green deploy that silently invalidated every session and # deleted every push subscription. if [ ! -s "$tmp" ]; then echo "!! OpenBao render produced an empty file; refusing to write $out" >&2 rm -f "$tmp" return 1 fi local rc=0 if [ -f "$out" ] && [ -w "$out" ]; then cat "$tmp" > "$out" || rc=1 elif install -m 0640 "$tmp" "$out" 2>/dev/null; then : elif sudo install -m 0640 -o "$(id -un)" -g "$(id -gn)" "$tmp" "$out" 2>/dev/null; then : else echo "!! cannot write $out (need file write access or passwordless sudo)" >&2 rc=1 fi if [ "$rc" = 0 ] && [ -n "${THERMOGRAPH_SECRETS_ENV_FILE_MIRROR:-}" ]; then if ! mkdir -p "$(dirname "$THERMOGRAPH_SECRETS_ENV_FILE_MIRROR")" \ || ! install -m 0600 "$tmp" "$THERMOGRAPH_SECRETS_ENV_FILE_MIRROR"; then echo "!! cannot write mirror at $THERMOGRAPH_SECRETS_ENV_FILE_MIRROR" >&2 rc=1 fi fi rm -f "$tmp" return "$rc" } # --------------------------------------------------------------------------- # CENTRALIS # --------------------------------------------------------------------------- # /etc/centralis.env is a different file, with a different consumer, and therefore a # different output shape. render-secrets.sh:206-233 carries the full argument; the # short version is that it 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' # # `set -a; . file` runs every line through bash's whole expansion pipeline — quote # removal, parameter expansion, command substitution, field splitting. Raw dotenv is # therefore not a safe representation here. CENTRALIS_TOKENS is JSON: rendered # unquoted, bash strips its quotes, and Centralis fails CLOSED on the malformed result # by serving a single `shared` identity — indistinguishable from the file never having # been written. That is the 2026-07-24 incident. So this path emits POSIX # single-quoted assignments and PROVES them by sourcing the result in a clean shell # before anything touches $dest. # # This mirrors render_centralis_secrets rather than sharing with it, for the same # reason given above thermograph_render_openbao: the SOPS path stays byte-identical # and all new risk stays in this file. Consolidate the two when the SOPS path is being # deleted anyway, not before. # _thermograph_openbao_login -> token on STDOUT # # Separate from the login inline in thermograph_openbao_source deliberately: that # function is now exercised by prod and beta parity and is left untouched. _thermograph_openbao_login() { local approle="$1" addr="$2" ca="$3" local creds role_id secret_id if [ -r "$approle" ]; then creds=$(cat "$approle") else creds=$(sudo cat "$approle" 2>/dev/null || true) fi [ -n "$creds" ] || { echo "!! cannot read OpenBao AppRole credentials at $approle (need sudo)" >&2 return 1 } role_id=$(printf '%s\n' "$creds" | sed -n '1p') secret_id=$(printf '%s\n' "$creds" | sed -n '2p') [ -n "$role_id" ] && [ -n "$secret_id" ] || { echo "!! $approle malformed: expected role_id on line 1, secret_id on line 2" >&2 return 1 } export BAO_ADDR="$addr" [ -f "$ca" ] && export BAO_CACERT="$ca" bao write -field=token auth/approle/login \ role_id="$role_id" secret_id="$secret_id" 2>/dev/null } # thermograph_render_openbao_centralis [dest] thermograph_render_openbao_centralis() { local env_name="${1:?env name required}" local dest="${2:-${CENTRALIS_RENDER_DEST:-/etc/centralis.env}}" local addr="${THERMOGRAPH_BAO_ADDR:-https://10.10.0.1:8200}" local mount="${THERMOGRAPH_BAO_MOUNT:-thermograph}" local ca="${THERMOGRAPH_BAO_CACERT:-/etc/thermograph/openbao-ca.crt}" local approle="${THERMOGRAPH_BAO_CENTRALIS_APPROLE:-/etc/thermograph/openbao-approle-centralis}" command -v bao >/dev/null 2>&1 || { echo "!! bao CLI not installed but this host is configured for the openbao backend" >&2 return 1 } command -v python3 >/dev/null 2>&1 || { echo "!! python3 not installed; needed to quote values safely for a sourced file" >&2 return 1 } echo "==> Rendering ${dest} from OpenBao (${mount}/centralis/${env_name})" local token token=$(_thermograph_openbao_login "$approle" "$addr" "$ca") || return 1 [ -n "$token" ] || { echo "!! OpenBao AppRole login failed for centralis" >&2 echo "!! check that OpenBao at $addr is reachable and UNSEALED" >&2 return 1 } export BAO_TOKEN="$token" # ONE 0700 temp directory holding plaintext, so cleanup is a single rm on every # path. Deliberately not a `trap ... RETURN`: this file is SOURCED, and such a trap # persists into the caller's shell and re-fires on its next `source`. local work rc=0 work=$(mktemp -d) || { echo "!! mktemp -d failed" >&2; return 1; } chmod 700 "$work" # Subshell so no failure path can skip the cleanup, and so umask cannot leak out. # Every step carries its own `|| exit 1`: `set -e` is DISABLED inside a compound # command that is the left operand of `||`, which this subshell is. ( umask 077 bao kv get -format=json -mount="$mount" "centralis/${env_name}" \ > "$work/kv.json" 2>/dev/null || { echo "!! cannot read ${mount}/centralis/${env_name}" >&2 exit 1 } python3 - "$work/kv.json" "$work/plain.json" <<'UNWRAP' || exit 1 import json, sys doc = json.load(open(sys.argv[1], encoding="utf-8")) data = doc.get("data", {}).get("data", {}) or {} if not data: sys.stderr.write("!! OpenBao returned zero keys for centralis\n") sys.exit(1) json.dump(data, open(sys.argv[2], "w", encoding="utf-8")) UNWRAP python3 - "$work/plain.json" "$work/out.env" "$work/want.json" <<'EMIT' || exit 1 import json, re, sys src, out_env, want_json = sys.argv[1], sys.argv[2], sys.argv[3] NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") def shell_quote(v): """POSIX single-quoting. Inside '...' every byte is literal except ' itself, which is closed, backslash-escaped and reopened. There is no escape sequence to get wrong and no character class to keep up to date: it is total, and total is the only property worth having for a file bash will expand.""" return "'" + v.replace("'", "'\\''") + "'" def scalar(k, v): if isinstance(v, str): return v if isinstance(v, bool): # before int: bool is an int in Python return "true" if v else "false" if isinstance(v, (int, float)): return json.dumps(v) if v is None: return "" sys.stderr.write( "!! %s is a %s, but an env file holds only scalars\n" % (k, type(v).__name__)) sys.exit(1) data = json.load(open(src, encoding="utf-8")) want, lines = {}, [] for k, v in data.items(): if not NAME.match(k): sys.stderr.write("!! %r is not a usable shell variable name\n" % (k,)) sys.exit(1) want[k] = scalar(k, v) lines.append("%s=%s\n" % (k, shell_quote(want[k]))) with open(out_env, "w", encoding="utf-8") as fh: fh.write("# Rendered by infra/deploy/render-secrets-openbao.sh from OpenBao.\n") fh.write("# DO NOT EDIT BY HAND: the next render overwrites this file, and a\n") fh.write("# hand-edit is how the token registry was lost on 2026-07-24.\n") fh.write("# Rotate with `bao kv put` against thermograph/centralis/ instead.\n") fh.writelines(lines) with open(want_json, "w", encoding="utf-8") as fh: json.dump(want, fh) sys.stderr.write(" %d keys quoted\n" % len(want)) EMIT # PROVE IT. Source the rendered file exactly the way the deploy does — clean # environment, `set -a`, nothing inherited that could mask a dropped key — and # read every value back. This is the check the 2026-07-24 file could not pass. # shellcheck disable=SC2016 # single quotes are the point: "$1" must be expanded # by the inner `bash -c` against the argument after `_`, not by this shell. env -i PATH="$PATH" bash -c \ 'set -a; . "$1"; set +a; exec python3 -c " import json, os, sys sys.stdout.write(json.dumps(dict(os.environ)))"' _ "$work/out.env" \ > "$work/got.json" || exit 1 python3 - "$work/want.json" "$work/got.json" <<'VERIFY' || exit 1 import json, sys want = json.load(open(sys.argv[1], encoding="utf-8")) got = json.load(open(sys.argv[2], encoding="utf-8")) bad = [] for k, v in want.items(): if k not in got: bad.append("%s: not set at all after sourcing" % k) elif got[k] != v: # NEVER print either value. The difference is the finding; the content is # not, and this runs inside a deploy log. bad.append("%s: survives sourcing as a DIFFERENT value (len %d -> %d)" % (k, len(v), len(got[k]))) if bad: sys.stderr.write("!! the rendered file does not round-trip through bash:\n") for b in bad: sys.stderr.write("!! %s\n" % b) sys.exit(1) # CENTRALIS_TOKENS is the reason for all of the above: JSON, full of double quotes, # in a file bash expands. Centralis fails CLOSED on malformed JSON — it drops to the # single `shared` identity, which looks exactly like the registry never having been # configured. Refuse to write a file that would do that. raw = got.get("CENTRALIS_TOKENS", "") if raw: try: reg = json.loads(raw) except ValueError as exc: sys.stderr.write("!! CENTRALIS_TOKENS is not valid JSON after sourcing: %s\n" % exc) sys.exit(1) if not isinstance(reg, dict) or not reg: sys.stderr.write("!! CENTRALIS_TOKENS must be a non-empty JSON object\n") sys.exit(1) if not all(isinstance(s, str) and isinstance(t, str) and t for s, t in reg.items()): sys.stderr.write("!! CENTRALIS_TOKENS must map subject -> non-empty token string\n") sys.exit(1) # Subjects are names on audit lines, not credentials. Printing them is the whole # point: it is how an operator sees at a glance that nobody was lost. sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n" % (len(reg), ", ".join(sorted(reg)))) sys.stderr.write(" %d keys survive `set -a; . ` byte-for-byte\n" % len(want)) VERIFY ) || rc=1 # Only now, with a file read back through bash and matched value for value, does # anything touch $dest. 0600: four bearer credentials for the estate's control # plane, with no group that needs them. if [ "$rc" -eq 0 ]; then if [ -f "$dest" ] && [ -w "$dest" ]; then cat "$work/out.env" > "$dest" || rc=1 elif install -m 0600 "$work/out.env" "$dest" 2>/dev/null; then : elif sudo install -m 0600 -o "$(id -un)" -g "$(id -gn)" "$work/out.env" "$dest" 2>/dev/null; then : else echo "!! cannot write ${dest} (need file write access or passwordless sudo)" >&2 rc=1 fi else echo "!! render aborted; ${dest} left untouched" >&2 fi rm -rf "$work" return "$rc" }