From b027de21828f3ca4eefc4de68e4bcf83b472b0f1 Mon Sep 17 00:00:00 2001 From: William Gill Date: Wed, 8 Jul 2026 19:33:23 -0500 Subject: [PATCH] fix(openbao): follow-ups from adversarial fix-verification An adversarial re-verification of 0812f34 found a couple of the fixes were incomplete or regressed; addressed here. - DR was an INCOMPLETE fix: `raft snapshot save`/`restore` are token-gated (sys/storage/raft/snapshot is sudo-capable) and the container carries no ambient token, so the previously-"fixed" backup returned "missing client token" and streamed a zero-byte snapshot. Both the deploy.sh runbook and the README DR flow now pass `-e BAO_TOKEN=` on save and restore. - IPv6 bind REGRESSION (introduced by the bind-narrowing): a bare IPv6 literal in the compose port map (`fd00::10:8200:8200`) is invalid and aborts at `docker compose pull`. Now IPv4 vs IPv6 are classified separately and IPv6 is bracketed (`[fd00::10]:8200:8200`). - Docker readiness race (now reachable since install_docker is actually called): openrc backgrounds dockerd and returns before the socket is up, so the next `docker compose pull` raced it under set -e. install_docker now polls `docker info` for up to 30s. - UID detection hardened: added `-T` to the one-off `docker compose run`, and an empty result is now a loud warning (with the manual-chown remedy) instead of a silent fall-through to root that would re-create the crash-loop on a non-root image. - The raft-volume chown is gated to first run (captured before any compose-run instantiates the volume), so idempotent re-deploys don't recursively re-chown a live raft dir. Co-Authored-By: Claude Opus 4.8 --- deployments/openbao/README.md | 11 ++++-- deployments/openbao/deploy.sh | 71 ++++++++++++++++++++++++----------- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/deployments/openbao/README.md b/deployments/openbao/README.md index 3c02f08..4b642b1 100644 --- a/deployments/openbao/README.md +++ b/deployments/openbao/README.md @@ -120,10 +120,15 @@ handing them the root token and unseal keys. The vault is the sole recovery path for encrypted tapes — back it up: +Snapshot save/restore are **token-gated** (`sys/storage/raft/snapshot` is +sudo-capable) — pass a token that has that path (the root token works, or mint a +dedicated snapshot-policy token). The container has no ambient token, so supply +it via `-e BAO_TOKEN`. + ```bash # Consistent raft snapshot (safe while running); write it to a scratch path, NOT # into the live raft dir: -docker compose exec openbao \ +docker compose exec -T -e BAO_TOKEN= openbao \ bao operator raft snapshot save -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap # then stream the RAW file off-box, age-encrypted. Use `exec -T ... cat`, not # `compose cp openbao:… -` (which emits a TAR wrapper that won't restore): @@ -132,12 +137,12 @@ docker compose exec -T openbao cat /tmp/openbao.snap | \ ``` Restore (into a fresh, unsealed vault): decrypt, copy the raw `.snap` in, and -apply it: +apply it (also token-gated): ```bash age -d -i ~/.age/key.txt openbao-DATE.snap.age > openbao.snap docker compose cp openbao.snap openbao:/tmp/openbao.snap -docker compose exec openbao \ +docker compose exec -T -e BAO_TOKEN= openbao \ bao operator raft snapshot restore -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap ``` diff --git a/deployments/openbao/deploy.sh b/deployments/openbao/deploy.sh index b657b88..fc46df2 100644 --- a/deployments/openbao/deploy.sh +++ b/deployments/openbao/deploy.sh @@ -80,6 +80,15 @@ install_docker() { elif command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true fi + # dockerd is often started in the background (esp. openrc) and returns before + # the socket is listening -- poll so the first `docker compose` call doesn't + # race it and abort under set -e. + local i + for i in $(seq 1 30); do + docker info >/dev/null 2>&1 && return + sleep 1 + done + warn "Docker daemon not ready after 30s; continuing (compose may fail -- check 'docker info')." } open_bao_port() { @@ -154,10 +163,11 @@ prompt OPENBAO_ADDR "LAN address the Kanrisha tape host reaches this vault at (I # Build the cert SAN list: loopback + whatever OPENBAO_ADDR is (IP vs DNS) + # any extra SANs the operator exported in OPENBAO_TLS_SANS. -ADDR_IS_IP=0 -if [[ "$OPENBAO_ADDR" =~ ^[0-9.]+$ || "$OPENBAO_ADDR" == *:* ]]; then - ADDR_SAN="IP:${OPENBAO_ADDR}" - ADDR_IS_IP=1 +ADDR_KIND=dns +if [[ "$OPENBAO_ADDR" =~ ^[0-9.]+$ ]]; then + ADDR_SAN="IP:${OPENBAO_ADDR}"; ADDR_KIND=ipv4 +elif [[ "$OPENBAO_ADDR" == *:* ]]; then + ADDR_SAN="IP:${OPENBAO_ADDR}"; ADDR_KIND=ipv6 else ADDR_SAN="DNS:${OPENBAO_ADDR}" fi @@ -166,12 +176,14 @@ SANS="DNS:localhost,IP:127.0.0.1,${ADDR_SAN}" # A Docker-published port bypasses the host INPUT firewall, so the interface bind # is the real restriction. If the operator left OPENBAO_BIND at the all-interfaces -# default and OPENBAO_ADDR is an IP, narrow the publish to just that LAN IP. -if [[ "$OPENBAO_BIND" == "0.0.0.0" && "$ADDR_IS_IP" == "1" ]]; then - OPENBAO_BIND="$OPENBAO_ADDR" - log "Binding the API to ${OPENBAO_BIND} only (set OPENBAO_BIND to override)." -elif [[ "$OPENBAO_BIND" == "0.0.0.0" ]]; then - warn "OPENBAO_BIND=0.0.0.0 and OPENBAO_ADDR is a DNS name -- API publishes on ALL interfaces. Set OPENBAO_BIND to a LAN IP to narrow it." +# default and OPENBAO_ADDR is an IP, narrow the publish to just that LAN IP. IPv6 +# literals must be bracketed in the compose port mapping ([addr]:8200:8200). +if [[ "$OPENBAO_BIND" == "0.0.0.0" ]]; then + case "$ADDR_KIND" in + ipv4) OPENBAO_BIND="$OPENBAO_ADDR"; log "Binding the API to ${OPENBAO_BIND} only (set OPENBAO_BIND to override)." ;; + ipv6) OPENBAO_BIND="[${OPENBAO_ADDR}]"; log "Binding the API to ${OPENBAO_BIND} only (set OPENBAO_BIND to override)." ;; + *) warn "OPENBAO_BIND=0.0.0.0 and OPENBAO_ADDR is a DNS name -- API publishes on ALL interfaces. Set OPENBAO_BIND to a LAN IP to narrow it." ;; + esac fi # ---------------------------------------------------------------------------- @@ -220,25 +232,40 @@ fi install_docker cd "$STACK_DIR" + +# Record whether this is the first deploy (raft volume not yet created) BEFORE any +# `docker compose run` below instantiates the volume -- the first-run data chown +# gates on this. +FIRST_RUN=0 +docker volume inspect openbao_openbao-data >/dev/null 2>&1 || FIRST_RUN=1 + log "Pulling image..." docker compose pull # The OpenBao server process runs as the image's own user -- root in some image # variants, a non-root service user in others -- and reads its config + TLS over # read-only bind mounts and writes the raft data volume. So those must be owned by -# that UID. Detect it from the pulled image and align ownership; all of this is a -# harmless no-op when the image runs as root. -BAO_UID=$(docker compose run --rm --no-deps --entrypoint id openbao -u 2>/dev/null | tr -dc '0-9' || true) -BAO_UID=${BAO_UID:-0} +# that UID. Detect it from the pulled image (-T: no TTY on the piped stdout) and +# align ownership; a no-op when the image runs as root. +BAO_UID=$(docker compose run --rm --no-deps -T --entrypoint id openbao -u 2>/dev/null | tr -dc '0-9') +if [[ -z "$BAO_UID" ]]; then + # `id -u` always prints a number if it ran, so empty means detection failed -- + # do NOT silently assume root (that would re-create the unreadable-config + # crash-loop on a non-root image). Fall back to root but say so loudly. + warn "Could not detect the OpenBao container UID; assuming root. If OpenBao crash-loops on start, chown $STACK_DIR/config.hcl + $STACK_DIR/tls (and the openbao-data volume) to the container's UID (docker compose exec openbao id -u)." + BAO_UID=0 +fi log "OpenBao container runs as UID ${BAO_UID}; aligning file/volume ownership." chown -R "${BAO_UID}:${BAO_UID}" "$STACK_DIR/config.hcl" "$STACK_DIR/tls" # tls.key stays private to that UID; config.hcl + tls.crt are non-secret. chmod 0600 "$STACK_DIR/tls/tls.key" 2>/dev/null || true -# Raft data volume is created root-owned on first mount; make it writable by the -# server UID (a one-shot root container chowns the mounted volume in-place). -docker compose run --rm --no-deps --user 0:0 --entrypoint chown openbao \ - -R "${BAO_UID}:${BAO_UID}" /openbao/data 2>/dev/null || \ - warn "Could not pre-chown the raft data volume; if OpenBao fails to write storage, chown the openbao-data volume to UID ${BAO_UID}." +# Raft data volume: created root-owned on first mount. Only the FIRST run needs +# the chown (idempotent re-runs would recursively re-chown a live raft dir). +if [[ "$FIRST_RUN" == "1" ]]; then + docker compose run --rm --no-deps --user 0:0 --entrypoint chown openbao \ + -R "${BAO_UID}:${BAO_UID}" /openbao/data 2>/dev/null || \ + warn "Could not pre-chown the raft data volume; if OpenBao fails to write storage, chown the openbao-data volume to UID ${BAO_UID}." +fi log "Starting OpenBao..." docker compose up -d --remove-orphans @@ -279,8 +306,10 @@ TLS: ${STACK_DIR}/tls/tls.crt (give this to the Kanrisha daemon as address = "https://${OPENBAO_ADDR}:8200" ca_cert = "/etc/kanrisha/openbao-ca.crt" # = tls.crt above -3. Back it up (this vault is the sole recovery path for encrypted tapes): - docker compose exec openbao \\ +3. Back it up (this vault is the sole recovery path for encrypted tapes). Snapshot + save is token-gated -- pass a token with sys/storage/raft/snapshot (the root + token works): + docker compose exec -T -e BAO_TOKEN= openbao \\ bao operator raft snapshot save -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap docker compose exec -T openbao cat /tmp/openbao.snap > ${STACK_DIR}/openbao.snap ...then age-encrypt + copy it off-box. See the README for the full DR flow.