All checks were successful
secrets-guard / encrypted (pull_request) Successful in 4s
PR build (required check) / changes (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 6s
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) / gate (pull_request) Successful in 1s
bootstrap.sh could not run to completion on OpenBao 2.6.1: - Release asset names were wrong. `bao_<ver>_linux_amd64.tar.gz` and `bao_<ver>_SHA256SUMS` both 404; the assets are `openbao_<ver>_...` and `checksums.txt`. The tarball is now saved under its real name and the checksum grep anchored to end-of-line, because checksums.txt also lists a .sbom.json and `sha256sum -c` resolves each line by the filename inside it. - `openssl rand -base64 32` appends a newline and the static seal reads the key file raw, so the service failed with `Error configuring seal "static": unknown encoding for AES-256 key`. - The audit stanza relied on the block label being the device type. 2.6.1 requires explicit `type` and `path` with device settings under `options`. Worth noting the intermediate state: with type and path but a bare `file_path`, the server starts and silently ignores the log location, which is the worse failure given a wedged audit device stops OpenBao answering at all. - `disable_mlock` is unsupported in 2.6.1 and warned on every start. - Nothing opened port 8200 and ufw defaults to deny(incoming), so vps1 could not reach the vault. dev renders on vps1, so this would have surfaced as a timeout during cutover rather than here. bootstrap-policies.sh installed beta's credentials as `deploy:deploy`, but vps2 has no `deploy` user and both environments deploy as `agent` (env-topology.sh sets TG_SSH_TARGET=agent@ for both; deploy.yml uses one VPS2_SSH_USER). install_creds also printed a success line after a failed chown: the call site wrapped it in `|| echo`, which suppresses `set -e` for everything inside the function, so it fell through to chmod and reported an ownership it never applied. It now removes the half-written file and returns non-zero. Corrects the isolation rationale accordingly — separate credentials buy audit attribution and a policy boundary against mistakes, not deploy-user isolation. Documents the 2.6.0 root-token change (HCSEC-2026-08): `generate-root` now targets an authenticated endpoint, so recovery keys cannot mint a replacement token and revoking the last root token before a second admin identity exists is a one-way door. Also corrects the runbook's `verify-parity.sh --all`, which cannot work from a single host given the AppRole CIDR bindings.
130 lines
5.8 KiB
Bash
Executable file
130 lines
5.8 KiB
Bash
Executable file
#!/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=<root 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 <<EOF
|
|
|
|
==> 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 <token> (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
|