openbao/init.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/sh
# Initialises OpenBao for AlexClaw, and makes its TLS certificates. Runs as the
# one-shot `openbao-init` service (`openbao-test-init` in the test stack) on
# every start of the stack; see docs/architecture/openbao.md.
#
# 1. Certificates. If OpenBao is not running yet, a new CA and server
# certificate are made into OpenBao's TLS volume, and the CA certificate
# into AlexClaw's bootstrap mount. The CA key exists only in this
# container's /tmp and is gone when it exits. A running OpenBao keeps the
# certificate it was started with.
# 2. Ready. /tmp/ready tells compose OpenBao may start (it waits for this
# service to be healthy).
# 3. Initialisation, once. An initialised OpenBao is left as it is. Otherwise:
# initialise (static seal: recovery keys, no unseal keys), enable kv-v2 at
# secret/ (one version per secret) and transit with key "alexclaw", write
# the policy, create the
# AppRole bound to AlexClaw's address, write role_id and secret_id to the
# bootstrap mount; write the snapshot-only `backup` policy and AppRole,
# bound to the backup service's address, with its credentials on the
# backup mount (never AlexClaw's); print the recovery key once, and revoke
# the root token.
# Outside the test stack this needs an operator at a terminal, who confirms
# the keys are saved offline.
set -eu
: "${OPENBAO_ADDR:?OPENBAO_ADDR is not set}"
: "${OPENBAO_HOSTNAME:?OPENBAO_HOSTNAME is not set}"
: "${ALEXCLAW_ADDRESS:?ALEXCLAW_ADDRESS is not set}"
: "${BACKUP_ADDRESS:?BACKUP_ADDRESS is not set}"
TLS=/openbao/tls
BOOTSTRAP=/bootstrap
# The backup's own credentials: mounted by this service and the backup service
# only, never by AlexClaw.
BACKUP_CREDENTIALS=/backup-credentials
# OpenBao's user, and the group it shares with AlexClaw's user.
BAO_UID=100
SHARED_GID=1000
export BAO_ADDR="$OPENBAO_ADDR"
export BAO_CACERT="$TLS/ca.pem"
say() { printf 'openbao-init: %s\n' "$*"; }
fail() { printf 'openbao-init: %s\n' "$*" >&2; exit 1; }
running() { nc -z -w 2 "$OPENBAO_HOSTNAME" 8200 2>/dev/null; }
make_certificates() {
work=$(mktemp -d)
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \
-subj "/CN=AlexClaw OpenBao CA" -keyout "$work/ca.key" -out "$work/ca.pem" 2>/dev/null
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-subj "/CN=$OPENBAO_HOSTNAME" -keyout "$work/server.key" -out "$work/server.csr" 2>/dev/null
printf 'subjectAltName=DNS:%s,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n' "$OPENBAO_HOSTNAME" \
> "$work/ext"
openssl x509 -req -in "$work/server.csr" -CA "$work/ca.pem" -CAkey "$work/ca.key" \
-CAcreateserial -days 825 -extfile "$work/ext" -out "$work/server.pem" 2>/dev/null
install -o "$BAO_UID" -g "$SHARED_GID" -m 0400 "$work/server.key" "$TLS/server.key"
install -o root -g "$SHARED_GID" -m 0444 "$work/server.pem" "$TLS/server.pem"
install -o root -g "$SHARED_GID" -m 0444 "$work/ca.pem" "$TLS/ca.pem"
install -o root -g "$SHARED_GID" -m 0444 "$work/ca.pem" "$BOOTSTRAP/ca.pem"
install -o root -g root -m 0444 "$work/ca.pem" "$BACKUP_CREDENTIALS/ca.pem"
# /tmp is this container's tmpfs: the CA key goes with it.
rm -rf "$work"
say "new TLS certificate for $OPENBAO_HOSTNAME; the CA key is discarded"
}
# bao status: 0 unsealed, 2 sealed, 1 error (not answering).
wait_for_openbao() {
tries=0
until bao status >/dev/null 2>&1 || [ $? -eq 2 ]; do
tries=$((tries + 1))
[ "$tries" -le 120 ] || fail "OpenBao did not answer at $OPENBAO_ADDR within 60 seconds"
sleep 0.5
done
}
wait_until_unsealed() {
tries=0
until bao status >/dev/null 2>&1; do
tries=$((tries + 1))
[ "$tries" -le 60 ] || fail "OpenBao did not unseal within 30 seconds: is the unseal key mounted?"
sleep 0.5
done
}
initialised() { bao status -format=json 2>/dev/null | grep -q '"initialized": true'; }
confirm_saved() {
while :; do
printf 'Saved the unseal key file AND the recovery key above offline? Type SAVED: '
read -r answer || fail "no confirmation: the root token is revoked; store the unseal key file and the recovery key before closing this terminal"
[ "$answer" = "SAVED" ] && return 0
done
}
configure() {
# The file audit device is declared in config.hcl; OpenBao must actually
# have it on before AlexClaw is given any access (S8).
bao audit list -format=json 2>/dev/null | grep -q '"alexclaw/"' ||
fail "the file audit device is not enabled: AlexClaw is not given access"
bao secrets enable -path=secret kv-v2 >/dev/null
# One version per secret: a rotation leaves no readable old value. The mount
# holds AlexClaw's secrets and nothing else, so the setting is the mount's.
bao write secret/config max_versions=1 >/dev/null
bao secrets enable transit >/dev/null
bao write -f transit/keys/alexclaw >/dev/null
# The admin's second factor: OpenBao holds the key and checks the codes.
bao secrets enable totp >/dev/null
bao policy write alexclaw - >/dev/null <<'POLICY'
path "secret/data/alexclaw/*" {
capabilities = ["create", "read", "update"]
}
# The token renews itself before it expires; with no default policy on it,
# this is the one self-service path it needs.
path "auth/token/renew-self" {
capabilities = ["update"]
}
# Deleting a secret destroys every version and its metadata.
path "secret/metadata/alexclaw/*" {
capabilities = ["delete"]
}
# The MCP key is kept as an HMAC under this key: recognised, never stored.
path "transit/hmac/alexclaw" {
capabilities = ["update"]
}
# Recovery codes are checked against their HMACs here, in constant time.
path "transit/verify/alexclaw" {
capabilities = ["update"]
}
# The admin's TOTP key: enrolled (update writes it), described (read gives
# metadata, never the secret) and deleted when 2FA is turned off.
path "totp/keys/admin" {
capabilities = ["read", "update", "delete"]
}
# Codes are checked here. A read of this path would generate a code, so it
# is not granted.
path "totp/code/admin" {
capabilities = ["update"]
}
POLICY
bao auth enable approle >/dev/null
# Only what the alexclaw policy grants: no default policy on its tokens.
bao write auth/approle/role/alexclaw \
token_policies=alexclaw \
token_no_default_policy=true \
secret_id_bound_cidrs="$ALEXCLAW_ADDRESS/32" \
token_bound_cidrs="$ALEXCLAW_ADDRESS/32" \
token_ttl=1h token_max_ttl=24h \
secret_id_ttl=0 secret_id_num_uses=0 >/dev/null
role_id=$(bao read -field=role_id auth/approle/role/alexclaw/role-id)
secret_id=$(bao write -f -field=secret_id auth/approle/role/alexclaw/secret-id)
# Written in /tmp, then installed: a file left read-only by an earlier run is
# replaced, not written into.
work=$(mktemp -d)
printf '%s' "$role_id" > "$work/role_id"
printf '%s' "$secret_id" > "$work/secret_id"
install -o root -g "$SHARED_GID" -m 0440 "$work/role_id" "$BOOTSTRAP/role_id"
install -o root -g "$SHARED_GID" -m 0440 "$work/secret_id" "$BOOTSTRAP/secret_id"
rm -rf "$work"
unset role_id secret_id
say "AppRole bound to $ALEXCLAW_ADDRESS; role_id and secret_id written to the bootstrap mount"
configure_backup
}
# The backup: a raft snapshot, taken by a credential that can do nothing else
# and that AlexClaw never holds — AlexClaw must not be able to copy its own
# vault. Used only by the on-demand `openbao-backup` service.
configure_backup() {
bao policy write backup - >/dev/null <<'POLICY'
path "sys/storage/raft/snapshot" {
capabilities = ["read"]
}
POLICY
bao write auth/approle/role/backup \
token_policies=backup \
token_no_default_policy=true \
secret_id_bound_cidrs="$BACKUP_ADDRESS/32" \
token_bound_cidrs="$BACKUP_ADDRESS/32" \
token_ttl=5m token_max_ttl=15m token_num_uses=1 \
secret_id_ttl=0 secret_id_num_uses=0 >/dev/null
role_id=$(bao read -field=role_id auth/approle/role/backup/role-id)
secret_id=$(bao write -f -field=secret_id auth/approle/role/backup/secret-id)
work=$(mktemp -d)
printf '%s' "$role_id" > "$work/role_id"
printf '%s' "$secret_id" > "$work/secret_id"
install -o root -g root -m 0400 "$work/role_id" "$BACKUP_CREDENTIALS/role_id"
install -o root -g root -m 0400 "$work/secret_id" "$BACKUP_CREDENTIALS/secret_id"
rm -rf "$work"
unset role_id secret_id
say "backup AppRole bound to $BACKUP_ADDRESS; its credentials written to the backup mount"
}
revoke_root() {
if [ -n "${BAO_TOKEN:-}" ]; then
bao token revoke -self >/dev/null 2>&1 && say "root token revoked" ||
say "COULD NOT REVOKE THE ROOT TOKEN: revoke it by hand"
unset BAO_TOKEN
fi
}
initialise() {
if [ "${OPENBAO_INIT_UNATTENDED:-}" != "1" ] && [ ! -t 0 ]; then
fail "OpenBao is not initialised. Initialise it once, at a terminal: docker compose run --rm openbao-init"
fi
out=$(bao operator init -recovery-shares=1 -recovery-threshold=1)
BAO_TOKEN=$(printf '%s\n' "$out" | sed -n 's/^Initial Root Token: //p')
recovery=$(printf '%s\n' "$out" | sed -n 's/^Recovery Key 1: //p')
unset out
[ -n "$BAO_TOKEN" ] || fail "operator init returned no root token"
export BAO_TOKEN
trap revoke_root EXIT
printf '\n Recovery key (shown once, never again): %s\n' "$recovery"
printf ' Unseal key: the file "key" in the host directory OPENBAO_UNSEAL_DIR\n'
printf ' (default ./openbao/unseal). Losing it loses every secret in OpenBao.\n\n'
unset recovery
if [ "${OPENBAO_INIT_UNATTENDED:-}" != "1" ]; then
confirm_saved
fi
wait_until_unsealed
configure
revoke_root
trap - EXIT
say "OpenBao initialised"
}
if running; then
say "OpenBao is running: its certificate is kept"
else
make_certificates
fi
touch /tmp/ready
wait_for_openbao
if initialised; then
say "OpenBao is already initialised: nothing to do"
exit 0
fi
initialise