#!/usr/bin/env bash # Second half of bootstrap: KV mount, policies, AppRoles, host credentials. # Run on vps2 with BAO_TOKEN set to a root token. Idempotent — safe to re-run. # # export BAO_ADDR=https://127.0.0.1:8200 BAO_CACERT=/etc/openbao/tls/bao.crt # export BAO_TOKEN= # sudo -E bash infra/openbao/bootstrap-policies.sh # # Split from bootstrap.sh because everything here is safely automatable: none of it # produces a credential a human has to custody. The one thing that does — the recovery # material from `bao operator init` — is in bootstrap.sh and stops for the operator. set -euo pipefail MOUNT="${THERMOGRAPH_BAO_MOUNT:-thermograph}" SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" POLICY_DIR="$SELF_DIR/policies" : "${BAO_TOKEN:?BAO_TOKEN must be set to a root token}" : "${BAO_ADDR:=https://127.0.0.1:8200}" export BAO_ADDR command -v bao >/dev/null 2>&1 || { echo "!! bao not on PATH" >&2; exit 1; } echo "==> KV v2 mount at ${MOUNT}/" if bao secrets list -format=json | grep -q "\"${MOUNT}/\""; then echo " already mounted" else bao secrets enable -path="$MOUNT" -version=2 kv fi echo "==> policies" for p in "$POLICY_DIR"/*.hcl; do name="$(basename "$p" .hcl)" bao policy write "$name" "$p" >/dev/null echo " wrote $name" done echo "==> approle auth" bao auth list -format=json | grep -q '"approle/"' || bao auth enable approle # One role per environment, CIDR-bound to the host that legitimately renders it. # # secret_id_ttl=0 (never expires) is deliberate: a secret-id that silently lapses # mid-quarter is a self-inflicted outage. The short lifetime lives on the TOKEN # instead — it only has to outlive one render. # # The CIDR binding is the part that makes a leaked secret-id close to useless: it is # only accepted from the correct WireGuard address. add_role() { local role="$1" policy="$2" cidr="$3" bao write "auth/approle/role/${role}" \ token_policies="$policy" \ secret_id_bound_cidrs="$cidr" \ token_bound_cidrs="$cidr" \ secret_id_ttl=0 secret_id_num_uses=0 \ token_ttl=5m token_max_ttl=15m token_num_uses=10 >/dev/null echo " role ${role} -> ${policy} (bound ${cidr})" } add_role tg-prod tg-host-prod 10.10.0.1/32 add_role tg-beta tg-host-beta 10.10.0.1/32 add_role tg-dev tg-host-dev 10.10.0.2/32 add_role tg-centralis tg-centralis 10.10.0.1/32 # Install the local (vps2) credentials. prod and beta both render on this box. # # Both files are owned by `agent`, because that is the single identity both # environments actually deploy as: env-topology.sh sets TG_SSH_TARGET=agent@... for # beta and for prod, deploy.yml uses one VPS2_SSH_USER for both, and vps2 has no # `deploy` user at all (only `ubuntu` and `agent`). An earlier revision chowned beta's # file to `deploy:deploy` on the theory that beta and prod deploy as different users; # they do not, and the chown simply failed. # # So be accurate about what separate credentials buy on this box: NOT deploy-user # isolation — there is one deploy user, and it can read both files. What they buy is # per-environment ATTRIBUTION in the audit log, and a policy boundary that stops a # *mistake* crossing between environments: a beta render authenticating with beta's # secret-id is refused thermograph/data/env/prod by tg-host-beta.hcl, so a wrong # THERMOGRAPH_ENV fails closed instead of quietly rendering the other environment's # database password. Real deploy-user isolation would need a `deploy` user on vps2 and # a separate SSH credential for beta in deploy.yml — a larger change than this script. install_creds() { local role="$1" dest="$2" owner="$3" local rid sid rid=$(bao read -field=role_id "auth/approle/role/${role}/role-id") sid=$(bao write -f -field=secret_id "auth/approle/role/${role}/secret-id") # umask before creation, so the file is never briefly world-readable. ( umask 077; printf '%s\n%s\n' "$rid" "$sid" > "$dest" ) # Abort the function if chown fails, and remove the half-installed file. The call # site wraps this in `|| echo`, which suppresses `set -e` for everything inside — # so without this check a failed chown falls straight through to chmod and prints a # line asserting an ownership that was never applied. A root-owned credential the # renderer cannot read, reported as installed, is worse than no credential at all: # it fails at deploy time instead of here. if ! chown "$owner" "$dest"; then rm -f "$dest" echo " !! chown $owner failed for $dest — removed, nothing installed" >&2 return 1 fi chmod 0400 "$dest" echo " installed $dest (0400 $owner)" } if [ "$(id -u)" = 0 ]; then install -d -o root -g root -m 0755 /etc/thermograph install_creds tg-prod /etc/thermograph/openbao-approle "agent:agent" install_creds tg-beta /etc/thermograph/openbao-approle-beta "agent:agent" install_creds tg-centralis /etc/thermograph/openbao-approle-centralis "root:root" else echo "!! not root; skipping credential install. Re-run with sudo -E." >&2 fi cat < done. Policies and AppRoles are in place; the vault is still EMPTY. dev's credentials must be installed on vps1, not here. From your own machine: bao read -field=role_id auth/approle/role/tg-dev/role-id bao write -f -field=secret_id auth/approle/role/tg-dev/secret-id # then, on vps1, write both lines to /etc/thermograph/openbao-approle (0400 agent) Prefer response wrapping so the secret-id never lands in shell history or a log: bao write -f -wrap-ttl=5m auth/approle/role/tg-dev/secret-id # gives a wrapping token # on vps1: bao unwrap (single-use: a failed unwrap means interception) Next: seed, then verify parity. Neither cuts anything over. infra/openbao/seed-from-sops.sh --dry-run infra/openbao/seed-from-sops.sh --all infra/openbao/verify-parity.sh --all EOF