All checks were successful
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) / changes (pull_request) Successful in 7s
PR build (required check) / gate (pull_request) Successful in 1s
secrets-guard / encrypted (pull_request) Successful in 9s
shell-lint / shellcheck (pull_request) Successful in 8s
render-secrets-openbao.sh resolved the AppRole file to a single host-wide default, /etc/thermograph/openbao-approle — which is prod's. On vps2 that made beta's render authenticate as tg-prod, and tg-host-prod.hcl denies thermograph/data/env/beta, so beta could never render from OpenBao. It surfaces as `cannot read thermograph/env/beta`: the policy working correctly against the wrong identity. env-topology.sh exists precisely because vps2 runs two environments and one host-wide marker cannot answer "which environment is this". The AppRole path is the same question and had been given the same host-wide answer. It is now derived per environment alongside host, checkout, branch, ports and DB role, and the renderer resolves THERMOGRAPH_BAO_APPROLE, then TG_BAO_APPROLE, then the conventional path — the same precedence render-secrets.sh:44 already uses for the backend selector. Only beta differs. prod and dev keep the conventional path; dev is alone on vps1 so there is no collision there. Beta parity passes when the path is supplied by hand (24 keys = 8 + 16 shared); this makes it automatic. Derivation confirmed by sourcing env-topology.sh for all three environments.
245 lines
11 KiB
Bash
245 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# OpenBao source backend for render-secrets.sh. Sourced, never run directly.
|
|
#
|
|
# Two functions:
|
|
# thermograph_openbao_source <env> -> merged dotenv on STDOUT
|
|
# thermograph_render_openbao <env> <out> -> source, then write <out>
|
|
#
|
|
# 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 <env_name>
|
|
#
|
|
# 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. The SOPS path has always had this hazard and has never tripped it
|
|
# only because every current value happens to be alphanumeric. Refusing here
|
|
# turns a latent code-execution bug into a loud render failure.
|
|
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 <env_name> <out_file>
|
|
#
|
|
# 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"
|
|
}
|