Files
automations/deployments/openbao
57_WolveandClaude Opus 4.8 0812f345a8 fix(openbao): sanity-audit fixes — 4 blockers + hardening
A multi-agent sanity audit of the freshly-merged deployment found four
end-to-end blockers (and several smaller issues); all fixed here.

HIGH (were blocking):
- deploy.sh never called install_docker(), so `docker compose pull` hit
  command-not-found on any host without Docker. Now called before the
  compose steps.
- The container's server process runs as the image's own (often non-root)
  user but the mounted config/TLS were root-owned 0640/0600 and the raft
  volume root-owned -> vault crash-looped, never binding :8200. deploy.sh
  now detects the image UID after pull and aligns ownership of config.hcl,
  ./tls and the data volume (a no-op when the image runs as root);
  config.hcl is installed 0644 (holds no secrets).
- Docs told operators to set the daemon key `openbao_ca_cert`, but the
  Kanrisha daemon's key is `ca_cert` (config.go, mapstructure:"ca_cert").
  The wrong key is fatal on strict unmarshal / leaves TLS unverified.
  Renamed in all 5 places (config.hcl, gen-tls.sh, deploy.sh x2, README).
- DR backup used `docker compose cp openbao:… -`, which emits a TAR stream,
  so the age-encrypted snapshot was tar-wrapped and would not restore.
  Switched to `docker compose exec -T openbao cat` for the raw bytes, wrote
  the snapshot to a scratch path (not the live raft dir), and documented the
  matching restore.

MEDIUM:
- Swap detection used `swapon --show` (absent on BusyBox) and `\s` (GNU-only)
  -> silently no-op on Alpine, leaving swap on. Now uses /proc/swaps and
  [[:space:]] so mlock hardening actually holds on musl.
- A Docker-published port bypasses the host INPUT firewall, so the source
  rule was illusory. deploy.sh now narrows OPENBAO_BIND to OPENBAO_ADDR when
  it is an IP, the compose/README/.env comments state the reality, and a new
  Exposure section + an init-immediately warning were added.
- Fixed broken ../kanrisha/ and deployments/kanrisha/ links (separate repo).

LOW:
- OPENBAO_TLS_SANS is now honored (folded into the SAN list from the env).
- .gitignore excludes *.snap / *.snap.age.
- Bootstrap note clarifies bootstrap.sh needs the `bao` CLI (run it from the
  Kanrisha host/workstation, not this Docker-only vault host).
- README multi-OS count corrected (eight stacks) + automations.sh header
  lists openbao.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:11:37 -05:00
..

openbao

Hardened OpenBao — the tape-encryption key store for Kanrisha (the LTO tape-archive system; separate repo, separate host). Deliberately separate from the tape host: a compromise of the tape node must not reach the vault, and OpenBao's mlock/TLS/unseal lifecycle is cleaner on its own box.

Unlike the other stacks here there is no Caddy / Let's Encrypt — a secrets store terminates TLS itself and is reached over the LAN, not the public internet.

  • Native TLS on the listener — self-signed by default, or a CA-signed cert from your Smallstep CA over ACME.
  • Integrated raft storage — snapshot-based DR.
  • mlock on — key material never hits swap.
  • Manual unseal by default, or PKCS#11 HSM auto-unseal.

⚠️ This vault is the sole recovery path for encrypted tapes. Losing the OpenBao data and the unseal keys/root token loses every encrypted tape. Take raft snapshots and store the unseal material out of band (below).

Required .env values

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).
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.

See .env.example for the full list.

Deploy

./automations.sh        # Deploy on this host → deploy: openbao
# or, non-interactive:
OPENBAO_ADDR=10.0.0.10 SKIP_PROMPTS=1 bash deployments/openbao/deploy.sh

deploy.sh installs Docker, generates a self-signed TLS cert (if none present), seeds .env, disables swap (for mlock), narrows the API bind to the LAN IP, opens 8200/tcp, aligns file/volume ownership to the container's UID, and brings the stack up. OpenBao starts sealed — initialise + unseal once (do this immediately; an uninitialised vault reachable on the LAN can be init'd by anyone who connects):

docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
  bao operator init -tls-skip-verify           # prints 5 unseal keys + root token
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
  bao operator unseal -tls-skip-verify <key>   # x3, three different keys

Store the unseal keys + root token out of band — ideally age-encrypted with your backup recipient (globals/age-pubkey.txt), never on this host.

TLS

  • Self-signed (default): deploy.sh runs gen-tls.sh to create ./tls/tls.{crt,key} with OPENBAO_ADDR in the SAN. Hand tls.crt to the Kanrisha daemon as [encryption.openbao].ca_cert.

  • Smallstep CA over ACME (option): issue a cert from your step-ca and drop it in ./tls instead — gen-tls.sh then no-ops. e.g. with the step client:

    step ca certificate "$OPENBAO_ADDR" ./tls/tls.crt ./tls/tls.key \
      --provisioner acme --acme https://ca.lan/acme/acme/directory
    # renew on a timer:  step ca renew --daemon ./tls/tls.crt ./tls/tls.key
    

    Give the Kanrisha daemon your Smallstep root as ca_cert (then it trusts the vault without -tls-skip-verify).

Auto-unseal (optional)

Default is manual unseal after each restart. For hands-off restarts, enable the seal "pkcs11" stanza in config.hcl, mount the PKCS#11 module + device into the openbao service, and set OPENBAO_HSM_PIN in .env.

Bootstrap for Kanrisha

Once unsealed, enable KV v2 + AppRole and seed the domain key with the Kanrisha bootstrap script (from the Kanrisha repo, deploy/openbao/bootstrap.sh). It calls the bao CLI directly, so run it from a host that has bao (the Kanrisha host or your workstation) pointed at this vault — this vault host only ships Docker. Copy tls.crt to that host first and pass it as BAO_CACERT:

BAO_ADDR=https://$OPENBAO_ADDR:8200 BAO_CACERT=/path/to/openbao-ca.crt \
  BAO_TOKEN=<root> bash bootstrap.sh

It enables the kanrisha-tape KV-v2 mount (with effectively-unlimited max_versions so a key rotation never orphans old tapes), creates the AppRole + policy, seeds the domain key, and prints the [encryption.openbao] block for the Kanrisha config. Point the daemon at address = "https://$OPENBAO_ADDR:8200".

Exposure

This is a secrets store on the LAN, not a public service. Two things to know:

  • A Docker-published port is DNAT'd and bypasses the host INPUT firewall. A source-restricting rule on INPUT does not gate :8200. The real interface restriction is the publish bind: deploy.sh defaults OPENBAO_BIND to OPENBAO_ADDR when that is an IP, so the API listens only on that LAN IP. To restrict by source host, use a FORWARD/DOCKER-USER rule or network segmentation, not INPUT.
  • mTLS (optional): to require the tape host to present a client cert, enable the tls_require_and_verify_client_cert stanza in config.hcl and issue the tape host a client cert from the same CA.

And initialise the vault immediately after deploy.sh — an uninitialised vault reachable on the LAN can be bao operator init'd by anyone who connects, handing them the root token and unseal keys.

Backup / DR

The vault is the sole recovery path for encrypted tapes — back it up:

# Consistent raft snapshot (safe while running); write it to a scratch path, NOT
# into the live raft dir:
docker compose exec 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):
docker compose exec -T openbao cat /tmp/openbao.snap | \
  age -r "$(cat /path/to/globals/age-pubkey.txt)" > "openbao-$(date +%F).snap.age"

Restore (into a fresh, unsealed vault): decrypt, copy the raw .snap in, and apply it:

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 \
  bao operator raft snapshot restore -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap

Snapshots do not contain the unseal keys or root token — you still need those to unseal a restored vault, which is why they are stored separately.