180 lines
7.9 KiB
Bash
180 lines
7.9 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
# Bring up OpenBao on vps2 from nothing. Run ONCE, as root, ON vps2, BY THE OPERATOR.
|
||
|
|
#
|
||
|
|
# sudo bash infra/openbao/bootstrap.sh
|
||
|
|
#
|
||
|
|
# ============================================================================
|
||
|
|
# WHY THIS IS NOT AUTOMATED, AND MUST NOT BE
|
||
|
|
# ============================================================================
|
||
|
|
# `bao operator init` mints the credentials that become the new root of trust for the
|
||
|
|
# entire estate: the recovery keys and the initial root token. Those have to be
|
||
|
|
# CUSTODIED BY A HUMAN. Any place a script could put them — a file on the box, the
|
||
|
|
# repo, a CI log, an agent transcript — is worse custody than the age key this
|
||
|
|
# migration is meant to improve on.
|
||
|
|
#
|
||
|
|
# So this script does everything up to and including init, prints the recovery
|
||
|
|
# material ONCE to the operator's terminal, and then stops and tells you what to do
|
||
|
|
# with it. It deliberately does not store it, mail it, or push it anywhere.
|
||
|
|
#
|
||
|
|
# Everything AFTER custody (policies, AppRoles, seeding, parity checks) is automated,
|
||
|
|
# because none of it produces a credential a human needs to hold.
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
BAO_VERSION="${BAO_VERSION:-2.6.1}"
|
||
|
|
MESH_ADDR="${MESH_ADDR:-10.10.0.1}"
|
||
|
|
SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
|
|
||
|
|
[ "$(id -u)" = 0 ] || { echo "!! run as root (sudo bash $0)" >&2; exit 1; }
|
||
|
|
|
||
|
|
hostname_now="$(hostname)"
|
||
|
|
case "$hostname_now" in
|
||
|
|
vmi3453260) : ;; # vps2
|
||
|
|
*)
|
||
|
|
# Refuse to bootstrap the secret store on the wrong box. Putting it on vps1 would
|
||
|
|
# invert the estate's central security decision: infra/CLAUDE.md states "vps1 must
|
||
|
|
# never hold prod credentials. It's the box that runs Forgejo, its CI runner, and
|
||
|
|
# dev's unreviewed branch — the opposite of an isolation boundary." With a static
|
||
|
|
# seal, the box also holds the key that decrypts everything.
|
||
|
|
#
|
||
|
|
# This mirrors the guard infra-sync.yml already has (commit 7583258, "refuse to
|
||
|
|
# render dev's vault onto a host that is not dev").
|
||
|
|
echo "!! this host is '$hostname_now', not vps2 (vmi3453260)." >&2
|
||
|
|
echo "!! OpenBao belongs on vps2. Refusing." >&2
|
||
|
|
exit 1
|
||
|
|
;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
echo "==> 1/7 install the bao binary (v${BAO_VERSION})"
|
||
|
|
if ! command -v bao >/dev/null 2>&1; then
|
||
|
|
# NOTE: checksum verification. provision-secrets.sh installs sops and age with a bare
|
||
|
|
# `sudo curl` and no verification at all, which is a standing weakness (a compromised
|
||
|
|
# release URL yields a root binary that sees every plaintext). Do not copy that here.
|
||
|
|
# The checksums file is signed by the release; verify before installing.
|
||
|
|
tmpd="$(mktemp -d)"
|
||
|
|
base="https://github.com/openbao/openbao/releases/download/v${BAO_VERSION}"
|
||
|
|
curl -fsSL -o "$tmpd/bao.tar.gz" "${base}/bao_${BAO_VERSION}_linux_amd64.tar.gz"
|
||
|
|
curl -fsSL -o "$tmpd/checksums" "${base}/bao_${BAO_VERSION}_SHA256SUMS"
|
||
|
|
( cd "$tmpd" && grep "linux_amd64.tar.gz" checksums | sha256sum -c - ) || {
|
||
|
|
echo "!! checksum mismatch on the bao tarball; refusing to install" >&2
|
||
|
|
rm -rf "$tmpd"; exit 1
|
||
|
|
}
|
||
|
|
tar -xzf "$tmpd/bao.tar.gz" -C "$tmpd" bao
|
||
|
|
install -m 0755 -o root -g root "$tmpd/bao" /usr/local/bin/bao
|
||
|
|
rm -rf "$tmpd"
|
||
|
|
fi
|
||
|
|
bao version
|
||
|
|
|
||
|
|
echo "==> 2/7 user, directories, TLS"
|
||
|
|
id -u openbao >/dev/null 2>&1 || useradd --system --home /var/lib/openbao --shell /usr/sbin/nologin openbao
|
||
|
|
install -d -o openbao -g openbao -m 0700 /var/lib/openbao
|
||
|
|
install -d -o openbao -g openbao -m 0750 /var/log/openbao
|
||
|
|
install -d -o root -g root -m 0755 /etc/openbao
|
||
|
|
install -d -o openbao -g openbao -m 0700 /etc/openbao/tls
|
||
|
|
|
||
|
|
if [ ! -f /etc/openbao/tls/bao.crt ]; then
|
||
|
|
# Self-signed, 10 years, SAN on the mesh address. Deliberately NOT from Caddy/ACME:
|
||
|
|
# that would put DNS and the public internet in the boot chain of the secret store.
|
||
|
|
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
|
||
|
|
-keyout /etc/openbao/tls/bao.key -out /etc/openbao/tls/bao.crt \
|
||
|
|
-subj "/CN=openbao.thermograph.internal" \
|
||
|
|
-addext "subjectAltName=IP:${MESH_ADDR},IP:127.0.0.1" >/dev/null 2>&1
|
||
|
|
chown openbao:openbao /etc/openbao/tls/bao.key /etc/openbao/tls/bao.crt
|
||
|
|
chmod 0400 /etc/openbao/tls/bao.key
|
||
|
|
chmod 0444 /etc/openbao/tls/bao.crt
|
||
|
|
fi
|
||
|
|
# Consumers need the cert to trust the listener.
|
||
|
|
install -m 0444 /etc/openbao/tls/bao.crt /etc/thermograph/openbao-ca.crt
|
||
|
|
|
||
|
|
echo "==> 3/7 static seal key"
|
||
|
|
if [ ! -f /etc/openbao/unseal.key ]; then
|
||
|
|
# 32 random bytes, base64. Same protection level as /etc/thermograph/age.key.
|
||
|
|
( umask 077; openssl rand -base64 32 > /etc/openbao/unseal.key )
|
||
|
|
chown openbao:openbao /etc/openbao/unseal.key
|
||
|
|
chmod 0400 /etc/openbao/unseal.key
|
||
|
|
echo " generated /etc/openbao/unseal.key (0400 openbao)"
|
||
|
|
echo " ⚠️ COPY THIS OFF THE BOX into your password manager before proceeding."
|
||
|
|
echo " Without an off-box copy, a raft snapshot is UNRESTORABLE."
|
||
|
|
else
|
||
|
|
echo " /etc/openbao/unseal.key already present, leaving alone"
|
||
|
|
fi
|
||
|
|
|
||
|
|
echo "==> 4/7 config + systemd unit"
|
||
|
|
install -m 0640 -o root -g openbao "$SELF_DIR/config/openbao.hcl" /etc/openbao/config.hcl
|
||
|
|
install -m 0644 -o root -g root "$SELF_DIR/openbao.service" /etc/systemd/system/openbao.service
|
||
|
|
# logrotate MUST use SIGHUP: without it bao keeps writing to an unlinked inode and the
|
||
|
|
# audit log silently stops growing — and a wedged audit device makes OpenBao stop
|
||
|
|
# answering requests entirely, which would block every deploy on the estate.
|
||
|
|
cat > /etc/logrotate.d/openbao <<'ROTATE'
|
||
|
|
/var/log/openbao/audit.log {
|
||
|
|
daily
|
||
|
|
rotate 30
|
||
|
|
compress
|
||
|
|
missingok
|
||
|
|
notifempty
|
||
|
|
create 0600 openbao openbao
|
||
|
|
postrotate
|
||
|
|
systemctl kill --signal=SIGHUP openbao.service 2>/dev/null || true
|
||
|
|
endscript
|
||
|
|
}
|
||
|
|
ROTATE
|
||
|
|
systemctl daemon-reload
|
||
|
|
systemctl enable --now openbao.service
|
||
|
|
sleep 3
|
||
|
|
systemctl is-active --quiet openbao.service || {
|
||
|
|
echo "!! openbao.service failed to start; check: journalctl -u openbao -n 50" >&2
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
export BAO_ADDR="https://127.0.0.1:8200"
|
||
|
|
export BAO_CACERT=/etc/openbao/tls/bao.crt
|
||
|
|
|
||
|
|
echo "==> 5/7 initialise"
|
||
|
|
if bao status -format=json 2>/dev/null | grep -q '"initialized": *true'; then
|
||
|
|
echo " already initialised, skipping"
|
||
|
|
else
|
||
|
|
echo
|
||
|
|
echo " ############################################################"
|
||
|
|
echo " # RECOVERY MATERIAL FOLLOWS. IT IS SHOWN EXACTLY ONCE. #"
|
||
|
|
echo " # Store the 3 recovery keys in 3 DIFFERENT places. #"
|
||
|
|
echo " # 2 of 3 are needed to rekey or mint a new root token. #"
|
||
|
|
echo " ############################################################"
|
||
|
|
echo
|
||
|
|
# -recovery-shares/-threshold, not -key-shares: with an auto-unseal seal, init
|
||
|
|
# yields RECOVERY keys (which authorise rekey and generate-root) rather than unseal
|
||
|
|
# keys. 2-of-3 here is redundancy against loss, not separation of duty — there is one
|
||
|
|
# operator. Do NOT use -recovery-shares=0: that leaves no route to a new root token.
|
||
|
|
bao operator init -recovery-shares=3 -recovery-threshold=2
|
||
|
|
echo
|
||
|
|
echo " Press Enter once the recovery keys AND /etc/openbao/unseal.key are"
|
||
|
|
echo " stored off this machine."
|
||
|
|
read -r _
|
||
|
|
fi
|
||
|
|
|
||
|
|
bao status || true
|
||
|
|
|
||
|
|
cat <<EOF
|
||
|
|
|
||
|
|
==> 6/7 and 7/7 need a root token, which is NOT stored anywhere by design.
|
||
|
|
|
||
|
|
Paste the initial root token (or one minted from recovery keys) and run:
|
||
|
|
|
||
|
|
export BAO_ADDR=https://127.0.0.1:8200 BAO_CACERT=/etc/openbao/tls/bao.crt
|
||
|
|
export BAO_TOKEN=<root token>
|
||
|
|
bash ${SELF_DIR}/bootstrap-policies.sh
|
||
|
|
|
||
|
|
That script enables the KV mount, writes the policies, creates the AppRoles and
|
||
|
|
installs each host's credentials. Then, from YOUR OWN machine (it needs your age
|
||
|
|
key, and per infra/CLAUDE.md a script that reads production secrets is not for an
|
||
|
|
agent to run):
|
||
|
|
|
||
|
|
infra/openbao/seed-from-sops.sh --dry-run # check first
|
||
|
|
infra/openbao/seed-from-sops.sh --all
|
||
|
|
infra/openbao/verify-parity.sh --all # must PASS before any flip
|
||
|
|
|
||
|
|
Then revoke the root token: bao token revoke -self
|
||
|
|
|
||
|
|
NOTHING is cut over by any of the above. TG_SECRETS_BACKEND defaults to sops for
|
||
|
|
every environment in infra/deploy/env-topology.sh, so SOPS stays authoritative
|
||
|
|
until you flip an environment in a reviewed PR — dev first.
|
||
|
|
EOF
|