fix(openbao): validate the publish bind before writing any state

deploy.sh narrows the Docker publish bind to OPENBAO_ADDR when that is an
IP, but never checked that the host actually holds that address. A typo'd
IP therefore failed late, inside `docker compose up`:

  failed to bind host port 192.160.100.50:8200/tcp:
  cannot assign requested address

...by which point .env had been seeded and the TLS cert generated with the
bad address in its SAN. Neither is rewritten on a re-run (.env is never
overwritten, gen-tls.sh never regenerates over an existing pair), so
re-running with a corrected OPENBAO_ADDR silently changed nothing.

Add host_addrs() + check_bind_addr(), run before anything is written:
- lists the host's addresses from plain `ip addr show` -- no -o/scope
  filters, since busybox ip supports neither -- falling back to ifconfig,
  and skipping the check when neither exists rather than blocking;
- skips 0.0.0.0 / :: / *, and unwraps an [IPv6] publish literal;
- SKIP_BIND_CHECK=1 overrides for an address that only comes up later.

Resolve the bind compose will really interpolate, which follows compose's
own precedence -- shell environment before .env:
- exported (automations.sh passes answers via `env VAR=...`, or a
  standalone OPENBAO_BIND=... run): the environment wins, so warn when
  .env disagrees, because a later bare `docker compose up` would not;
- derived here: that assignment is not exported, so .env wins;
- in neither: compose falls back to 0.0.0.0 and publishes the API on
  every interface -- warn, since that is a silent exposure.

Also warn when .env's OPENBAO_ADDR differs from this run's, naming the
cert that has to be deleted for the SAN to be regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:09:23 -05:00
co-authored by Claude Opus 5
parent a3843d3d85
commit 920edc50b3
2 changed files with 112 additions and 1 deletions
+29 -1
View File
@@ -25,7 +25,7 @@ internet.
| Variable | Notes |
|----------|-------|
| `OPENBAO_ADDR` | IP/DNS the Kanrisha tape host uses to reach this vault (goes in the cert SAN; you point the daemon at `https://$OPENBAO_ADDR:8200`). |
| `OPENBAO_BIND` | Host interface the API publishes on. Left at the default, deploy.sh narrows it to `OPENBAO_ADDR` when that is an IP (a published port bypasses the INPUT firewall, so this bind is the real restriction — see [Exposure](#exposure)). |
| `OPENBAO_BIND` | Host interface the API publishes on. Left at the default, deploy.sh narrows it to `OPENBAO_ADDR` when that is an IP (a published port bypasses the INPUT firewall, so this bind is the real restriction — see [Exposure](#exposure)). It must be an address this host actually holds — deploy.sh checks that before writing anything and refuses otherwise (`SKIP_BIND_CHECK=1` to override). |
| `OPENBAO_TLS_SANS` | **Extra** SANs beyond `OPENBAO_ADDR` + loopback (which deploy.sh always adds). Read from the environment at deploy time — `export` it before running deploy.sh. |
| `OPENBAO_TAG` | OpenBao image tag (pin it). |
| `OPENBAO_HSM_PIN` | Only for PKCS#11 auto-unseal. Leave blank for manual unseal. |
@@ -57,6 +57,34 @@ docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
**Store the unseal keys + root token out of band** — ideally age-encrypted with
your backup recipient (`globals/age-pubkey.txt`), never on this host.
### Re-running after a wrong address
`deploy.sh` is idempotent, but two things it writes are deliberately **sticky**:
`.env` (never overwritten) and `tls/tls.{crt,key}` (never regenerated over an
existing pair). So passing a corrected `OPENBAO_ADDR` to a re-run does *not* move
the vault — the cert keeps the old SAN, and the old `.env` usually still decides
the bind. Which `OPENBAO_BIND` wins follows Compose's own precedence:
| How `OPENBAO_BIND` is set | What Compose uses |
|---------------------------|-------------------|
| Exported into deploy.sh's environment (`OPENBAO_BIND=… bash deploy.sh`, or answering the bind prompt — `automations.sh` passes answers via `env VAR=…`) | the **environment** value; `.env` is ignored for this run, so a later hand-run `docker compose up -d` can bind somewhere else |
| Derived by deploy.sh (bind prompt left blank → narrowed to `OPENBAO_ADDR`) | the **`.env`** value, since that assignment is never exported |
| Absent from both | `0.0.0.0`**all interfaces** |
deploy.sh warns on each of those mismatches. To actually change the address:
```bash
cd /srv/openbao
docker compose down # keeps the raft volume
sed -i 's/OLD_IP/NEW_IP/g' .env # OPENBAO_ADDR + OPENBAO_BIND
rm -f tls/tls.crt tls/tls.key # force a new SAN
# then re-run deploy.sh with the corrected OPENBAO_ADDR
```
A bind address the host does not hold is caught up front; without that check
Docker fails the `up` with `cannot assign requested address` only *after* the
bad value is already in `.env` and the cert.
## TLS
- **Self-signed (default):** `deploy.sh` runs [`gen-tls.sh`](gen-tls.sh) to
+83
View File
@@ -28,10 +28,19 @@ set -euo pipefail
: "${STACK_DIR:=/srv/openbao}"
: "${SKIP_DOCKER_INSTALL:=0}"
: "${SKIP_BIND_CHECK:=0}" # 1 = publish on an address this host does not (yet) have
: "${FORCE:=0}"
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1
: "${OPENBAO_ADDR:=}"
# Whether OPENBAO_BIND arrived in this script's ENVIRONMENT (automations.sh
# passes answers via `env VAR=... bash deploy.sh`, and a standalone run may
# export it too). If it did it is still exported when we reach compose, which
# prefers the shell environment over $STACK_DIR/.env; if this script derives it
# below instead, the assignment is NOT exported and the .env wins. Must be read
# before the := default, which would make an unset var look set.
BIND_FROM_ENV=0
[[ -n "${OPENBAO_BIND+x}" ]] && BIND_FROM_ENV=1
: "${OPENBAO_BIND:=0.0.0.0}"
: "${DISABLE_SWAP:=1}" # set 0 to skip swapoff (mlock then only best-effort)
@@ -109,6 +118,43 @@ open_bao_port() {
fi
}
# Addresses currently assigned to this host, one per line. Parses plain
# `ip addr show` output -- no -o/scope filters, since busybox ip (what Alpine
# ships by default) supports neither -- with an ifconfig fallback.
host_addrs() {
if command -v ip >/dev/null 2>&1; then
ip addr show 2>/dev/null | awk '$1=="inet"||$1=="inet6"{split($2,a,"/"); print a[1]}'
elif command -v ifconfig >/dev/null 2>&1; then
ifconfig 2>/dev/null | awk '$1=="inet"||$1=="inet6"{v=$2; if(v=="addr:") v=$3; sub(/^addr:/,"",v); split(v,a,"/"); if(a[1]!="") print a[1]}'
fi
}
# A published port can only bind an address this host actually owns. Docker does
# not find that out until `up`, where it fails with a bare "cannot assign
# requested address" -- by which point this script has seeded .env and burned the
# address into the cert SAN, neither of which a re-run rewrites. So check first.
check_bind_addr() {
local bind="$1" bare addrs
bare="${bind#[}"; bare="${bare%]}" # unwrap an [IPv6] publish literal
case "$bare" in ''|0.0.0.0|'::'|'*') return 0 ;; esac
if [[ "$SKIP_BIND_CHECK" == "1" ]]; then
warn "SKIP_BIND_CHECK=1 -- not checking whether ${bare} is local."
return 0
fi
addrs="$(host_addrs)"
# Empty means the probe found no tool to ask, not that the address is absent
# -- do not block a deploy on that.
if [[ -z "$addrs" ]]; then
warn "No ip/ifconfig to list this host's addresses; skipping the bind check."
return 0
fi
if printf '%s\n' "$addrs" | grep -qxF "$bare"; then
return 0
fi
warn "Addresses on this host: $(printf '%s\n' "$addrs" | tr '\n' ' ')"
die "Nothing here is assigned ${bare}, so Docker cannot publish 8200 on it. Fix OPENBAO_ADDR / OPENBAO_BIND (compose reads an exported OPENBAO_BIND first, then ${STACK_DIR}/.env), or set SKIP_BIND_CHECK=1 if the address only comes up later."
}
disable_swap() {
[[ "$DISABLE_SWAP" == "1" ]] || { warn "DISABLE_SWAP=0 -- mlock will be best-effort."; return; }
# Detect active swap via /proc/swaps (a header line + one line per device) so
@@ -186,6 +232,43 @@ if [[ "$OPENBAO_BIND" == "0.0.0.0" ]]; then
esac
fi
# Which OPENBAO_BIND `docker compose` interpolates decides where the port lands,
# and compose reads the shell environment BEFORE $STACK_DIR/.env. So an exported
# value wins; one derived above does not, and the .env -- which deploy.sh never
# rewrites -- wins instead. Resolve which, say so when the two disagree, and
# confirm the address is really on this box before anything is written to disk.
EFFECTIVE_BIND="$OPENBAO_BIND"
if [[ -f "$STACK_DIR/.env" ]]; then
ENV_BIND=$(sed -n 's/^OPENBAO_BIND=//p' "$STACK_DIR/.env" | tail -n1)
ENV_ADDR=$(sed -n 's/^OPENBAO_ADDR=//p' "$STACK_DIR/.env" | tail -n1)
if [[ "$BIND_FROM_ENV" == "1" ]]; then
# Exported, so compose prefers it -- but a stale .env line still bites a
# later hand-run `docker compose up` that has no such environment.
if [[ -n "$ENV_BIND" && "$ENV_BIND" != "$OPENBAO_BIND" ]]; then
warn "OPENBAO_BIND=${OPENBAO_BIND} came from the environment, so compose prefers it: THIS run binds ${OPENBAO_BIND}."
warn "But ${STACK_DIR}/.env still says ${ENV_BIND} -- update that line, or a later plain 'docker compose up -d' will bind ${ENV_BIND}."
fi
elif [[ -n "$ENV_BIND" ]]; then
EFFECTIVE_BIND="$ENV_BIND"
if [[ "$ENV_BIND" != "$OPENBAO_BIND" ]]; then
warn "${STACK_DIR}/.env pins OPENBAO_BIND=${ENV_BIND}. Nothing was exported this run, so compose uses that, not the ${OPENBAO_BIND} derived here -- edit the .env to change the bind."
fi
else
# .env exists but has no OPENBAO_BIND line (hand-edited?): compose falls
# back to the compose-file default, which publishes on everything.
EFFECTIVE_BIND=0.0.0.0
warn "${STACK_DIR}/.env has no OPENBAO_BIND line and none was exported -- compose falls back to 0.0.0.0, publishing the API on ALL interfaces."
warn "Add 'OPENBAO_BIND=${OPENBAO_BIND}' to ${STACK_DIR}/.env to narrow it."
fi
if [[ -n "$ENV_ADDR" && "$ENV_ADDR" != "$OPENBAO_ADDR" ]]; then
warn "${STACK_DIR}/.env still says OPENBAO_ADDR=${ENV_ADDR}, and an existing cert in ${STACK_DIR}/tls is never regenerated over."
warn "To actually move the vault to ${OPENBAO_ADDR}: edit that .env, then 'rm -f ${STACK_DIR}/tls/tls.crt ${STACK_DIR}/tls/tls.key', then re-run."
fi
fi
check_bind_addr "$EFFECTIVE_BIND"
# ----------------------------------------------------------------------------
# Lay down the stack
# ----------------------------------------------------------------------------