From 920edc50b37543e9203be4dc2a7520fdbd4ce7c6 Mon Sep 17 00:00:00 2001 From: William Gill Date: Wed, 16 Sep 2026 13:09:23 -0500 Subject: [PATCH] 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 --- deployments/openbao/README.md | 30 ++++++++++++- deployments/openbao/deploy.sh | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/deployments/openbao/README.md b/deployments/openbao/README.md index 4b642b1..538a4c2 100644 --- a/deployments/openbao/README.md +++ b/deployments/openbao/README.md @@ -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 diff --git a/deployments/openbao/deploy.sh b/deployments/openbao/deploy.sh index fc46df2..ae55f71 100644 --- a/deployments/openbao/deploy.sh +++ b/deployments/openbao/deploy.sh @@ -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 # ----------------------------------------------------------------------------