#!/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" if [[ ! -f "$ENV_FILE" ]]; then log "Seeding $ENV_FILE..." install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" sed -i \ -e "s|^OPENBAO_ADDR=.*|OPENBAO_ADDR=${OPENBAO_ADDR}|" \ -e "s|^OPENBAO_BIND=.*|OPENBAO_BIND=${OPENBAO_BIND}|" \ -e "s|^OPENBAO_TLS_SANS=.*|OPENBAO_TLS_SANS=${SANS}|" \ -e "s|^OPENBAO_UI=.*|OPENBAO_UI=${EFFECTIVE_UI}|" \ "$ENV_FILE" 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 _t=$(mktemp) _SE_V="$EFFECTIVE_UI" awk ' BEGIN { v = ENVIRON["_SE_V"]; seen = 0 } !seen && index($0, "OPENBAO_UI=") == 1 { print "OPENBAO_UI=" v; seen = 1; next } { print } END { if (!seen) print "OPENBAO_UI=" v } ' "$ENV_FILE" > "$_t" cat "$_t" > "$ENV_FILE"; rm -f "$_t" log " OPENBAO_UI: ${_cur_ui:-} -> ${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 " --entrypoint sh openbao/openbao:\${OPENBAO_TAG:-2.5.5} -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 <> 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 # 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= 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= 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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