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=<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 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:33:23 -05:00
parent 0812f345a8
commit b027de2182
2 changed files with 58 additions and 24 deletions
+8 -3
View File
@@ -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=<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=<token> openbao \
bao operator raft snapshot restore -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap
```
+50 -21
View File
@@ -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=<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.