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.
196 lines
7.5 KiB
Bash
Executable file
196 lines
7.5 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.
|
|
reshaped="$(THERMOGRAPH_SEED_JSON="$plain_json" 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)
|
|
|
|
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 "\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"
|