Phase 1 of moving the secret store to OpenBao. Nothing is cut over: TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS remains authoritative until an environment is flipped in a reviewed PR. The reason for the migration is one specific failure class. infra/CLAUDE.md requires that vps1 never hold prod credentials, but that rule is currently enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at three call sites. A fourth call site, or one by-hand render, writes both S3 keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl makes that a 403 instead: dev's identity cannot read the common path at all. Shape: - vps2 only, native systemd (not a container: a Swarm service is circular, and a plain docker run is one `prune -a` from gone), raft storage (2.6.0 deprecated the file backend), static auto-unseal so a reboot cannot block deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the boot chain of the secret store). - AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's secret-id is owned by its own deploy user, which is a boundary that does not exist today since prod and beta both reach the same root-owned age key. - Render still produces the same raw unquoted dotenv: Centralis compares /etc/thermograph.env textually and the Swarm entrypoint parses it by line. The backend dispatch in render-secrets.sh returns early rather than refactoring the shared write path, so the SOPS path stays byte-identical. Verified by rendering dev (12 keys) and prod (32 keys) through it, matching the live hosts. The duplicated write path is noted for consolidation once prod has been on OpenBao for a release cycle. The OpenBao renderer rejects values containing a newline or a shell metacharacter. /etc/thermograph.env is sourced by bash in six places, so such a value is a command-execution path on the deploy host; the SOPS path has always had this hazard and avoids it only because every current value happens to be alphanumeric. Also records that the age keypair is NOT retired by this migration: it is the backup encryption key for every off-box dump (ops-cron.yml:142,197, 30-day retention), so deleting it with the vault files would silently destroy a month of database recoverability. verify-parity.sh is the cutover gate — it renders both backends and diffs them, reporting key names and counts only, never values.
239 lines
10 KiB
Bash
239 lines
10 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}"
|
|
local approle="${THERMOGRAPH_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"
|
|
}
|