Files
automations/deployments/openbao/deploy.sh
T
57_WolveandClaude Opus 5 f8c839a163 feat(openbao): pin 2.6.2, and correct the mlock and recovery-path claims
2.5.5 was two releases behind. Verified against the openbao/openbao source at
tag v2.6.2 that the bump is safe for an existing vault: raft's on-disk format
is unchanged (identical bbolt / hashicorp-raft / raft-boltdb pins,
byte-identical physical/raft/types.proto), snapshots restore in both
directions, and every stanza this config.hcl uses still parses -- the config
parser only moved import paths and the listener diff is additive.

Three corrections that came out of that check.

deploy.sh's printed crash-loop recovery command was already broken by 2.6.0.
That release adds `USER openbao` to the alpine image, so the container no
longer starts as root; the `docker run ... --entrypoint sh ... chown` it tells
the operator to run now executes as uid 100 and fails. Added `--user 0:0`, and
dropped the stale 2.5.5 literal from the same string. The detection logic
itself is unaffected -- `id -u openbao` still reads /etc/passwd and still
returns 100, verified by extracting the passwd layer from both published
images -- so a02524a holds and the volume does not need re-chowning.

The mlock story was wrong, and was already wrong on 2.5.5. OpenBao removed
mlock support: its own config parser carries "OpenBao has dropped support for
mlock. Please remove the line disable_mlock = false from your config and
disable or encrypt swap instead." So `cap_add: [IPC_LOCK]` and
`ulimits: memlock: -1` are inert, and config.hcl's "mlock keeps key material
off swap -- REQUIRED" was describing something that does not happen. The
BEHAVIOUR was right all along, because deploy.sh disables swap, which is the
actual mitigation; only the explanation was wrong. Corrected in config.hcl,
docker-compose.yml and the README. The two compose settings stay: they are
harmless, and removing them would recreate every deployed container for no
gain.

The built-in `seal "pkcs11"` stanza is deprecated in 2.6.0 for removal in
v2.7.0, and the HSM distribution is discontinued by then; PKCS#11 auto-unseal
continues only via the external `plugin "kms" "pkcs11" {}`. Noted where the
commented-out stanza lives, since a stack relying on it has to move before
taking 2.7.x.

Also worth recording and NOT acting on: do not switch to the openbao-distroless
image variant. It ships no shell, which breaks every `docker compose run
--entrypoint sh` probe deploy.sh uses.

Upgrading a LIVE vault is not automatic -- snapshot first, and the restart
comes back sealed. That is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:10:20 -05:00

607 lines
32 KiB
Bash

#!/usr/bin/env bash
#
# deploy.sh -- deploy the hardened OpenBao tape-encryption key store on a
# same-LAN host (Alpine / Debian / Alma). Single-node, runs as root.
#
# What this does:
# 1. Installs docker + compose if missing.
# 2. Lays down docker-compose.yml, config.hcl, gen-tls.sh in $STACK_DIR.
# 3. Seeds .env on first run (OPENBAO_ADDR into the cert SAN); never
# overwrites an existing .env.
# 4. Generates a self-signed TLS cert (if ./tls is empty) -- drop a CA-signed
# pair there instead to use your Smallstep CA.
# 5. Disables swap (so mlock is meaningful) and opens 8200/tcp.
# 6. Pulls images and brings the stack up. OpenBao starts SEALED -- initialise
# + unseal once afterwards (printed at the end).
#
# Idempotent: re-run to apply config changes / pull new images.
#
# Self-contained: docker-compose.yml, config.hcl, gen-tls.sh, .env.example are
# embedded as a base64 tar.gz at the bottom. Rebuild with build.sh after edits.
#
# Usage:
# OPENBAO_ADDR=10.0.0.10 bash deploy.sh # interactive prompt for the rest
# OPENBAO_ADDR=vault.lan SKIP_PROMPTS=1 bash deploy.sh
# STACK_DIR=/opt/openbao bash deploy.sh
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}"
# Same question for the UI switch: an explicit OPENBAO_UI=0 has to be told
# apart from "not mentioned", or a re-run would silently re-enable the UI.
UI_FROM_ENV=0
[[ -n "${OPENBAO_UI+x}" ]] && UI_FROM_ENV=1
: "${OPENBAO_UI:=}"
: "${DISABLE_SWAP:=1}" # set 0 to skip swapoff (mlock then only best-effort)
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Run as root."
# ---------------------------------------------------------------------------
# OS detection + Docker install (Alpine / Debian / Alma). Inlined so this
# deploy.sh stays self-contained when scp'd standalone.
# ---------------------------------------------------------------------------
osfam() {
local id="" like=""
if [[ -r /etc/os-release ]]; then
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
fi
case " $id $like " in
*" alpine "*) echo alpine ;;
*" debian "*|*" ubuntu "*) echo debian ;;
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
*) echo "${id:-unknown}" ;;
esac
}
install_docker() {
[[ "$SKIP_DOCKER_INSTALL" == "1" ]] && { log "Skipping Docker install."; return; }
if command -v docker >/dev/null 2>&1; then
log "Docker already installed: $(docker --version)"
else
log "Installing Docker (OS: $(osfam))..."
case "$(osfam)" in
alpine) apk add -q docker docker-cli-compose openrc ;;
debian|rhel) command -v curl >/dev/null 2>&1 || \
{ command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; }
curl -fsSL https://get.docker.com | sh ;;
*) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;;
esac
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add docker default >/dev/null 2>&1 || true
rc-service docker status >/dev/null 2>&1 || rc-service docker start
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() {
# Register 8200/tcp (the vault API). Prefer the host firewall when present;
# else ufw/firewalld if active. Restrict the source to the tape host where
# you can -- this is a secrets store, not a public service.
if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then
log "Registering 8200/tcp with host firewall..."
printf '8200/tcp\n' > /etc/firewall/ports.d/openbao.rule
/usr/local/sbin/firewall-apply
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
log "ufw active -- allowing 8200/tcp..."
ufw allow 8200/tcp >/dev/null
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
log "firewalld active -- allowing 8200/tcp..."
firewall-cmd -q --add-port=8200/tcp --permanent
firewall-cmd -q --reload
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
# `|| true` is load-bearing: host_addrs ends in a pipeline, and under
# `set -o pipefail` a probe that fails AFTER printing usable addresses (or an
# absent awk) would make this plain assignment non-zero and kill the whole
# deploy at this line, silently -- before the fail-open below is ever reached.
addrs="$(host_addrs || true)"
# 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 "Could not list this host's addresses (no ip/ifconfig, or it failed); 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
# this works on musl/BusyBox too, where `swapon --show` does not exist.
if [[ -r /proc/swaps ]] && [[ "$(wc -l < /proc/swaps)" -gt 1 ]]; then
log "Disabling swap (mlock keeps key material off disk)..."
swapoff -a || warn "swapoff failed -- disable swap manually."
else
log "No active swap."
fi
# Persist: comment any swap lines in fstab so it stays off across reboots.
# [[:space:]] (not \s) so the match works under musl/BusyBox grep/sed.
if [[ -f /etc/fstab ]] && grep -qE '^[^#].*[[:space:]]swap[[:space:]]' /etc/fstab; then
sed -i.bak -E 's|^([^#].*[[:space:]]swap[[:space:]].*)$|# \1 # disabled for OpenBao mlock|' /etc/fstab
log "Commented swap entries in /etc/fstab (backup: /etc/fstab.bak)."
fi
}
# ----------------------------------------------------------------------------
# Extract embedded archive (docker-compose.yml, config.hcl, gen-tls.sh, .env.example)
# ----------------------------------------------------------------------------
SCRIPT_DIR=$(mktemp -d -t openbao-deploy.XXXXXX)
trap 'rm -rf "$SCRIPT_DIR"' EXIT
extract_archive() {
grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \
| tail -n +2 \
| base64 -d \
| tar -xz -C "$SCRIPT_DIR" 2>/dev/null || true
}
extract_archive
# Fallback: run straight from the source dir (before build.sh embeds a payload).
if [[ ! -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
SRC=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
for f in docker-compose.yml config.hcl gen-tls.sh .env.example; do
[[ -f "$SRC/$f" ]] || die "Missing $f (no embedded payload and not in $SRC -- run build.sh)."
cp "$SRC/$f" "$SCRIPT_DIR/$f"
done
fi
# ----------------------------------------------------------------------------
# Prompt for required values
# ----------------------------------------------------------------------------
prompt() {
local var="$1" msg="$2" cur="${!1}"
[[ -n "$cur" ]] && return
[[ "$SKIP_PROMPTS" == "1" ]] && die "$var required (set it in the environment; running with SKIP_PROMPTS=1)."
read -r -p "$msg: " "$var"
}
prompt OPENBAO_ADDR "LAN address the Kanrisha tape host reaches this vault at (IP or DNS)"
[[ -n "$OPENBAO_ADDR" ]] || die "OPENBAO_ADDR is required."
# Build the cert SAN list: loopback + whatever OPENBAO_ADDR is (IP vs DNS) +
# any extra SANs the operator exported in OPENBAO_TLS_SANS.
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
SANS="DNS:localhost,IP:127.0.0.1,${ADDR_SAN}"
[[ -n "${OPENBAO_TLS_SANS:-}" ]] && SANS="${SANS},${OPENBAO_TLS_SANS}"
# 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. 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
# 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"
# config.hcl is rendered from OPENBAO_UI, so resolve which value actually
# applies: one passed to this run wins, else whatever .env already deploys,
# else the default. Without this, `OPENBAO_UI=0 bash deploy.sh` against an
# existing node would look like it worked and change nothing.
EFFECTIVE_UI="$OPENBAO_UI"
if [[ "$UI_FROM_ENV" != "1" && -f "$STACK_DIR/.env" ]]; then
_env_ui=$(sed -n 's/^OPENBAO_UI=//p' "$STACK_DIR/.env" | tail -n1)
[[ -n "$_env_ui" ]] && EFFECTIVE_UI="$_env_ui"
fi
[[ -n "$EFFECTIVE_UI" ]] || EFFECTIVE_UI=1
case "$EFFECTIVE_UI" in
1|true|yes|on) UI_HCL=true ;;
0|false|no|off) UI_HCL=false ;;
*) die "OPENBAO_UI must be 1 or 0 (got '${EFFECTIVE_UI}')." ;;
esac
# ----------------------------------------------------------------------------
# Lay down the stack
# ----------------------------------------------------------------------------
log "Setting up $STACK_DIR..."
install -d -m 0750 "$STACK_DIR"
install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
# config.hcl holds no secrets and is read by the in-container server process
# (which may be a non-root user) over a read-only mount -- keep it world-readable.
# Rendered rather than copied, so @UI@ reflects OPENBAO_UI. A bind-mounted
# file's CONTENTS are not part of the compose config hash, so `up -d` alone
# would leave a changed config.hcl unloaded -- track whether it actually
# changed and restart below only then, because restarting re-SEALS the vault.
CONFIG_CHANGED=0
_UI_HCL="$UI_HCL" awk '{ gsub(/@UI@/, ENVIRON["_UI_HCL"]); print }' \
"$SCRIPT_DIR/config.hcl" > "$SCRIPT_DIR/config.hcl.rendered"
# `cmp -s` exits 1 when they differ, so it stays inside an `if` condition:
# `cmp -s A B && CONFIG_CHANGED=1` would trip set -e whenever they matched.
if [[ -f "$STACK_DIR/config.hcl" ]] && ! cmp -s "$STACK_DIR/config.hcl" "$SCRIPT_DIR/config.hcl.rendered"; then
CONFIG_CHANGED=1
fi
install -m 0644 "$SCRIPT_DIR/config.hcl.rendered" "$STACK_DIR/config.hcl"
log "Web UI: ${UI_HCL} (OPENBAO_UI=${EFFECTIVE_UI})"
install -m 0750 "$SCRIPT_DIR/gen-tls.sh" "$STACK_DIR/gen-tls.sh"
ENV_FILE="$STACK_DIR/.env"
set_env() { # <KEY> <value>: update KEY in .env, or append if absent
# The value goes through the ENVIRONMENT, never interpolated into a sed
# script. Interpolating it corrupts any value containing & (sed expands it
# to the whole match) and aborts the run on one containing the s|||
# delimiter -- both reachable for an OIDC client secret or an issuer URL
# with a query string. Same helper as 947c899 gave the other stacks.
local key="$1" val="$2" tmp
if [[ ! -f "$ENV_FILE" ]]; then
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
return 0
fi
tmp="$(mktemp)"
_SE_KEY="$key" _SE_VAL="$val" awk '
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
{ print }
END { if (!seen) print k "=" v }
' "$ENV_FILE" > "$tmp"
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the 0600 mode/owner
rm -f "$tmp"
}
if [[ ! -f "$ENV_FILE" ]]; then
log "Seeding $ENV_FILE..."
install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE"
set_env OPENBAO_ADDR "$OPENBAO_ADDR"
set_env OPENBAO_BIND "$OPENBAO_BIND"
set_env OPENBAO_TLS_SANS "$SANS"
set_env OPENBAO_UI "$EFFECTIVE_UI"
else
log ".env exists; leaving it alone."
# ...except a UI switch passed to THIS run: config.hcl is rendered from
# it above, so letting .env keep the old value means the next run
# silently reverts the UI. Only touch the key when it was actually passed.
if [[ "$UI_FROM_ENV" == "1" ]]; then
_cur_ui=$(sed -n 's/^OPENBAO_UI=//p' "$ENV_FILE" | tail -n1)
if [[ "$_cur_ui" != "$EFFECTIVE_UI" ]]; then
set_env OPENBAO_UI "$EFFECTIVE_UI"
log " OPENBAO_UI: ${_cur_ui:-<unset>} -> ${EFFECTIVE_UI}"
fi
fi
fi
# ----------------------------------------------------------------------------
# TLS: self-signed unless a cert is already present (drop in a CA-signed pair
# to use your Smallstep CA -- see the README).
# ----------------------------------------------------------------------------
install -d -m 0750 "$STACK_DIR/tls"
OPENBAO_TLS_SANS="$SANS" bash "$STACK_DIR/gen-tls.sh" "$STACK_DIR/tls"
disable_swap
open_bao_port
# ----------------------------------------------------------------------------
# Bring up the stack
# ----------------------------------------------------------------------------
if [[ "$FORCE" != "1" ]]; then
printf '\nAbout to start OpenBao from %s (TLS on https://%s:8200). Continue? [y/N] ' "$STACK_DIR" "$OPENBAO_ADDR"
read -r ans
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; }
fi
install_docker
cd "$STACK_DIR"
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 (-T: no TTY on the piped stdout) and
# align ownership; a no-op when the image runs as root.
# Ask for the account the ENTRYPOINT switches to, not the one a probe starts as.
# The image's entrypoint runs as root and then does `su-exec openbao "$@"` before
# exec'ing the server, so `--entrypoint id -u` bypasses that drop and reports 0.
# Chowning to 0 on the strength of that leaves the server running as the
# unprivileged account with a root-owned raft volume, and it crash-loops on
# "failed to open bolt file: /openbao/data/vault.db: permission denied".
BAO_UID=$(docker compose run --rm --no-deps -T --entrypoint sh openbao \
-c 'id -u openbao 2>/dev/null' 2>/dev/null | tr -dc '0-9')
if [[ -z "$BAO_UID" ]]; then
# No such account: this image runs the server as whatever the entrypoint
# started as, so the older probe is the right answer here.
BAO_UID=$(docker compose run --rm --no-deps -T --entrypoint id openbao -u 2>/dev/null | tr -dc '0-9')
fi
if [[ -z "$BAO_UID" ]]; then
# Both probes failed. Falling back to root is the DANGEROUS direction -- it is
# exactly what produced the crash loop -- so say how to recover.
warn "Could not detect the OpenBao service account; assuming root."
warn "If OpenBao crash-loops with a permission error on /openbao/data, run:"
warn " cd ${STACK_DIR} && docker compose down"
warn " docker run --rm -v openbao_openbao-data:/data -v ${STACK_DIR}/tls:/tls \\"
warn " --user 0:0 --entrypoint sh openbao/openbao:\${OPENBAO_TAG:-2.6.2} -c 'chown -R openbao:openbao /data /tls'"
BAO_UID=0
fi
log "OpenBao server 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. Gate on what the volume ACTUALLY is, not on whether this is
# the first run: a volume left root-owned by an earlier deploy (or by a botched
# UID detection) would otherwise never be repaired, since the first-run flag is
# false forever after. Reading the current owner costs one container start and
# still keeps the recursive chown off a healthy live raft dir.
_data_uid=$(docker compose run --rm --no-deps -T --user 0:0 --entrypoint stat openbao \
-c '%u' /openbao/data 2>/dev/null | tr -dc '0-9')
if [[ -z "$_data_uid" ]]; then
warn "Could not read the raft volume's ownership; skipping the data chown."
elif [[ "$_data_uid" != "$BAO_UID" ]]; then
log "Raft volume is owned by UID ${_data_uid}; chowning to ${BAO_UID}..."
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 chown the raft data volume; OpenBao will fail to write storage. chown the openbao-data volume to UID ${BAO_UID}."
fi
log "Starting OpenBao..."
_bao_before="$(docker compose ps -q openbao 2>/dev/null || true)"
docker compose up -d --remove-orphans
_bao_after="$(docker compose ps -q openbao 2>/dev/null || true)"
if (( CONFIG_CHANGED )) && [[ -n "$_bao_before" && "$_bao_before" == "$_bao_after" ]]; then
warn "config.hcl changed and compose did not recreate the container; restarting to load it."
warn "NOTE: a restart re-SEALS the vault -- you will have to unseal again."
docker compose restart openbao || warn "Restart failed; run: cd ${STACK_DIR} && docker compose restart openbao"
fi
# OpenBao starts SEALED (and, first time, uninitialised), so it reports unhealthy
# until you init + unseal -- that is expected. Give it a moment to bind.
sleep 5
docker compose ps
# OpenBao starts SEALED and therefore reports UNHEALTHY until it is initialised
# and unsealed -- that is expected and the summary below explains it. A
# RESTARTING container is a different thing entirely: it crashed, compose is
# looping it, and every command in that summary will fail against it. Say so
# plainly rather than printing an unqualified DEPLOYED.
BAO_STATE="$(docker inspect -f '{{.State.Status}}' openbao 2>/dev/null || echo unknown)"
if [[ "$BAO_STATE" != "running" ]]; then
warn "Container state is '${BAO_STATE}', not 'running' -- OpenBao is crash-looping, not merely sealed."
warn "Nothing below will work until that is fixed. Start with:"
warn " cd ${STACK_DIR} && docker compose logs --tail=60 openbao"
fi
cat <<EOF
================================================================
DEPLOYED (OpenBao starts SEALED -- finish setup below)
Address: https://${OPENBAO_ADDR}:8200
Stack dir: ${STACK_DIR}
^ run every 'docker compose' command below from there:
cd ${STACK_DIR}
From anywhere else compose reports "no configuration file
provided: not found".
TLS: ${STACK_DIR}/tls/tls.crt (give this to the Kanrisha daemon as
[encryption.openbao].ca_cert)
>> Initialise NOW: an uninitialised vault reachable on the LAN can be init'd by
anyone who connects, capturing the root token + unseal keys. Do step 1 before
walking away.
1. Initialise + unseal (ONCE). Store the unseal keys + root token OUT OF BAND:
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \\
bao operator init -tls-skip-verify
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \\
bao operator unseal -tls-skip-verify <key> # x3, three different keys
2. Bootstrap for Kanrisha. Run the Kanrisha repo's deploy/openbao/bootstrap.sh
from a host that HAS the 'bao' CLI (the Kanrisha host or your workstation --
this vault host only ships Docker), pointed at this vault. Copy tls.crt there
first as the BAO_CACERT:
BAO_ADDR=https://${OPENBAO_ADDR}:8200 BAO_CACERT=/path/to/openbao-ca.crt \\
BAO_TOKEN=<root> bash bootstrap.sh
Then point the Kanrisha daemon at:
[encryption.openbao]
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). 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.
Manage:
docker compose logs -f
docker compose pull && docker compose up -d # update
docker compose down # stop, keep the vault data
docker compose down -v # stop, WIPE the vault (DESTROYS keys)
Re-running this script is idempotent (it won't re-init or touch .env / tls).
================================================================
EOF
if [[ "$BAO_STATE" != "running" ]]; then
warn "Reminder: the container is '${BAO_STATE}'. Fix that before step 1 above."
fi
# IMPORTANT: nothing executable below this line. Everything after
# __ARCHIVE_BELOW__ is the embedded tar.gz payload (base64), added by build.sh.
exit 0
__ARCHIVE_BELOW__
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
uKW327It27It27It27It27It27It27It27It27It27It27It27It2/JfWP4ByV0gzABQAAA=