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:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user