feat(openbao): hardened same-LAN tape-encryption key store for Kanrisha

A dedicated OpenBao deployment, kept OFF the Kanrisha tape host so a compromise
of the tape node can't reach the vault.

- Native TLS on the listener (self-signed by default via gen-tls.sh, or a
  CA-signed cert from a Smallstep CA over ACME) — no Caddy/Let's Encrypt;
  reached over the LAN, not the public internet.
- Integrated raft storage (clean snapshot-based DR).
- mlock on (cap_add IPC_LOCK + memlock unlimited + host swapoff in deploy.sh).
- Manual unseal by default; optional PKCS#11 HSM auto-unseal.
- deploy.sh: Docker install (Alpine/Debian/Alma), self-signed cert, .env seed,
  swapoff, firewall 8200/tcp, compose up; then prints init/unseal + the KV-v2 +
  AppRole bootstrap for Kanrisha + the raft-snapshot DR flow. Self-contained
  (config payload embedded by build.sh).
- Registered in automations.sh + the README deployment table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 14:10:22 -05:00
parent c6b0f7d418
commit 2efc9dbffb
11 changed files with 677 additions and 3 deletions
+4 -2
View File
@@ -110,11 +110,13 @@ deployments/<name>/ # one folder per stack
| [`squid`](deployments/squid/) | SSL-bump caching forward proxy — static-content cache + TLS interception via a local CA. **The exception: a forward proxy, not a Caddy/LE site.** | — |
| [`copyparty`](deployments/copyparty/) | Portable file server — web UI/WebDAV behind Caddy, plus direct **SFTP** + **FTPS**. Ships a security-notices-aware updater. | — |
| [`simplex`](deployments/simplex/) | SimpleX SMP + XFTP relay with Tor hidden services + encrypted backups. | globals/age-pubkey.txt |
| [`openbao`](deployments/openbao/) | Hardened tape-encryption key store for [Kanrisha](deployments/kanrisha/). **Exception: native TLS on the LAN, no Caddy/LE.** raft storage, mlock, self-signed or Smallstep-ACME cert, manual/HSM unseal. | globals/age-pubkey.txt (backups) |
## Conventions
- **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack (except
`squid`, which is a forward proxy with a local TLS-interception CA — no Caddy/LE).
- **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack, with two
exceptions: `squid` (a forward proxy with a local TLS-interception CA) and
`openbao` (a same-LAN secrets store with native TLS) — neither uses Caddy/LE.
- **`build.sh``deploy.sh`**: each stack's `build.sh` embeds its
`docker-compose.yml` / `Caddyfile` / `.env.example` into a single
self-contained `deploy.sh` (base64 tar.gz). That one file can be `scp`'d to a
+4 -1
View File
@@ -63,7 +63,7 @@ fi
. "$ROOT/scripts/lib.sh"
load_globals
DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex)
DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao)
SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update)
# ----------------------------------------------------------------------------
@@ -126,6 +126,9 @@ ask_deployment_vars() {
ask XFTP_QUOTA "XFTP disk quota" optional
ask SSH_PORT "SSH port" optional
ask ALLOWED_IP "Your IP to whitelist in sshguard" optional ;;
openbao)
ask OPENBAO_ADDR "LAN address the Kanrisha tape host reaches the vault at (IP or DNS)"
ask OPENBAO_BIND "Host IP to bind the API on (blank = 0.0.0.0)" optional ;;
esac
}
+31
View File
@@ -0,0 +1,31 @@
# Copy to .env and fill in. docker compose picks .env up automatically.
# deploy.sh seeds .env from this on first run; an existing .env is never
# overwritten. Never commit the populated .env.
# ─── Reachability ───────────────────────────────────────────────────────────
# Address (IP or DNS name) the KANRISHA tape host uses to reach this vault on
# the LAN. Seeds the self-signed cert SAN below and is what you point
# [encryption.openbao].address at (https://${OPENBAO_ADDR}:8200).
OPENBAO_ADDR=10.0.0.10
# Interface the published API port binds to on the host. Default is all
# interfaces; set the LAN IP to avoid listening anywhere else.
OPENBAO_BIND=0.0.0.0
# ─── TLS ────────────────────────────────────────────────────────────────────
# Comma-separated SANs for the self-signed cert deploy.sh generates. Include
# EVERY name/IP the tape host might use (deploy.sh appends OPENBAO_ADDR + the
# loopback for you). To use a CA-signed cert instead (e.g. Smallstep over ACME),
# drop tls.crt + tls.key into ./tls and these are ignored — see the README.
OPENBAO_TLS_SANS=DNS:localhost,IP:127.0.0.1
OPENBAO_TLS_DAYS=825
# ─── Image tag ──────────────────────────────────────────────────────────────
# Pin for reproducible deploys.
OPENBAO_TAG=2.5.5
# ─── Auto-unseal (optional; default is MANUAL unseal) ───────────────────────
# Only used when the seal "pkcs11" stanza is enabled in config.hcl (and the HSM
# module/device is mounted into the container). Otherwise leave blank and unseal
# manually after each restart.
OPENBAO_HSM_PIN=
+3
View File
@@ -0,0 +1,3 @@
# Never commit generated TLS material (the listener key) or a populated .env.
/tls/
.env
+109
View File
@@ -0,0 +1,109 @@
# openbao
Hardened [OpenBao](https://openbao.org) — the tape-encryption key store for
[Kanrisha](../kanrisha/). 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 binds to. Default all; set the LAN IP to narrow it. |
| `OPENBAO_TLS_SANS` | Extra SANs for the self-signed cert (deploy.sh adds `OPENBAO_ADDR` + loopback). |
| `OPENBAO_TAG` | OpenBao image tag (pin it). |
| `OPENBAO_HSM_PIN` | Only for PKCS#11 auto-unseal. Leave blank for manual unseal. |
See [`.env.example`](.env.example) for the full list.
## Deploy
```bash
./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), firewalls `8200/tcp`, and brings the
stack up. OpenBao starts **sealed** — initialise + unseal once:
```bash
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`](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].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:
```bash
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 `openbao_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`](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`):
```bash
BAO_ADDR=https://$OPENBAO_ADDR:8200 BAO_CACERT=./tls/tls.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"`.
## Backup / DR
The vault is the sole recovery path for encrypted tapes — back it up:
```bash
# Consistent raft snapshot (safe while running):
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
bao operator raft snapshot save -tls-skip-verify /openbao/data/snapshot.snap
# then copy it off-box, age-encrypted:
docker compose cp openbao:/openbao/data/snapshot.snap - | \
age -r "$(cat ../../globals/age-pubkey.txt)" > openbao-$(date +%F).snap.age
```
Restore: `bao operator raft snapshot restore`. Snapshots do **not** contain the
unseal keys — you still need those (and the root token) to unseal a restored
vault, which is why they are stored separately.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# build.sh -- (re)embed docker-compose.yml, config.hcl, gen-tls.sh and
# .env.example into deploy.sh as a base64-encoded tar.gz payload after
# __ARCHIVE_BELOW__. Idempotent: strips any existing payload first.
set -euo pipefail
DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SCRIPT="$DIR/deploy.sh"
MARKER="__ARCHIVE_BELOW__"
[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; }
for f in docker-compose.yml config.hcl gen-tls.sh .env.example; do
[[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; }
done
PAYLOAD=$(tar -czf - -C "$DIR" docker-compose.yml config.hcl gen-tls.sh .env.example | base64)
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP"
{
echo "$MARKER"
echo "$PAYLOAD"
} >> "$TMP"
mv "$TMP" "$SCRIPT"
chmod +x "$SCRIPT"
trap - EXIT
size=$(wc -c < "$SCRIPT")
echo "Built $SCRIPT (${size} bytes)"
+37
View File
@@ -0,0 +1,37 @@
#cloud-config
#
# OpenBao (Kanrisha tape-encryption key store) — harden SSH, then deploy, on a
# fresh Alpine/Debian/Alma host on the SAME LAN as the Kanrisha tape host.
#
# Fill in REPO_URL and the values in the runcmd block, then paste this as the
# instance user-data. No public DNS / 80 / 443 needed — this is a LAN vault with
# native TLS on 8200. After boot, initialise + unseal once (see the README).
packages:
- git
runcmd:
- hostnamectl set-hostname openbao || true
- |
set -e
REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git
REPO_BRANCH=main
HARDEN_SSH=1 # harden SSH on this fresh VM (set 0 to skip)
SSH_PORT=22
ALLOWED_IP= # optional: whitelist your client IP in sshguard
git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations
cd /opt/automations
# Harden SSH: PQ KEX, key-only auth, sshguard. Seeds root from
# globals/authorized_keys (or SSH_KEYS_URL).
if [ "$HARDEN_SSH" = 1 ]; then
SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" SKIP_PROMPTS=1 FORCE=1 \
bash scripts/harden-ssh.sh
fi
# Deploy OpenBao. OPENBAO_ADDR is the LAN address the Kanrisha tape host
# reaches this vault at — it goes in the TLS cert SAN. deploy.sh disables
# swap (for mlock) and firewalls 8200/tcp.
OPENBAO_ADDR=10.0.0.10 \
SKIP_PROMPTS=1 \
bash deployments/openbao/deploy.sh
+51
View File
@@ -0,0 +1,51 @@
# OpenBao — hardened, same-LAN tape-encryption key store for Kanrisha.
#
# Native TLS on the listener (no reverse proxy in front), integrated raft
# storage (clean snapshot-based DR), and mlock enabled. This vault is the SOLE
# recovery path for encrypted tapes — losing the OpenBao data AND the unseal
# material loses every encrypted tape. Back it up (raft snapshots) and store the
# unseal keys/root token out of band (see the README + DR runbook).
storage "raft" {
path = "/openbao/data"
node_id = "openbao-1"
}
# TLS-terminating listener. tls.crt/tls.key are self-signed by deploy.sh on first
# run, or a CA-signed pair you drop in ./tls (e.g. issued by your Smallstep CA
# over ACME — see the README). The Kanrisha daemon trusts this via
# [encryption.openbao].openbao_ca_cert.
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/openbao/tls/tls.crt"
tls_key_file = "/openbao/tls/tls.key"
# Optional mTLS — require + verify a client cert from the tape host:
# tls_require_and_verify_client_cert = true
# tls_client_ca_file = "/openbao/tls/client-ca.crt"
}
# mlock keeps key material off swap — REQUIRED. Needs cap_add: [IPC_LOCK] +
# ulimits memlock unlimited (docker-compose.yml) and swap disabled on the host.
# Do NOT add `disable_mlock = true`.
# Single-node raft: advertising 127.0.0.1 is fine because a lone node is always
# ACTIVE and never redirects clients (they connect straight to the LAN address).
# For HA / multiple nodes, set BAO_API_ADDR + BAO_CLUSTER_ADDR to each node's
# reachable LAN address in the compose env instead.
api_addr = "https://127.0.0.1:8200"
cluster_addr = "https://127.0.0.1:8201"
ui = false
# ── Auto-unseal (optional) — DEFAULT IS MANUAL UNSEAL ────────────────────────
# With no seal stanza OpenBao starts SEALED and needs `bao operator unseal` (x3)
# after every restart. To auto-unseal against a PKCS#11 HSM/token, uncomment and
# fill this in, mount the PKCS#11 module + device into the container, and set
# OPENBAO_HSM_PIN in .env:
#
# seal "pkcs11" {
# lib = "/usr/lib/softhsm/libsofthsm2.so"
# slot = "0"
# pin = "env://BAO_HSM_PIN"
# key_label = "kanrisha-unseal"
# mechanism = "0x1087" # CKM_AES_GCM
# }
+315
View File
@@ -0,0 +1,315 @@
#!/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}"
: "${FORCE:=0}"
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1
: "${OPENBAO_ADDR:=}"
: "${OPENBAO_BIND:=0.0.0.0}"
: "${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
}
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
}
disable_swap() {
[[ "$DISABLE_SWAP" == "1" ]] || { warn "DISABLE_SWAP=0 -- mlock will be best-effort."; return; }
if swapon --show 2>/dev/null | grep -q .; then
log "Disabling swap (mlock keeps key material off disk)..."
swapoff -a || warn "swapoff failed -- disable swap manually."
fi
# Persist: comment any swap lines in fstab so it stays off across reboots.
if [[ -f /etc/fstab ]] && grep -qE '^[^#].*\sswap\s' /etc/fstab; then
sed -i.bak -E 's|^([^#].*\sswap\s.*)$|# \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).
if [[ "$OPENBAO_ADDR" =~ ^[0-9.]+$ || "$OPENBAO_ADDR" == *:* ]]; then
ADDR_SAN="IP:${OPENBAO_ADDR}"
else
ADDR_SAN="DNS:${OPENBAO_ADDR}"
fi
SANS="DNS:localhost,IP:127.0.0.1,${ADDR_SAN}"
# ----------------------------------------------------------------------------
# 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"
install -m 0640 "$SCRIPT_DIR/config.hcl" "$STACK_DIR/config.hcl"
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}|" \
"$ENV_FILE"
else
log ".env exists; leaving it alone."
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
cd "$STACK_DIR"
log "Pulling image..."
docker compose pull
log "Starting OpenBao..."
docker compose up -d --remove-orphans
# 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
cat <<EOF
================================================================
DEPLOYED (OpenBao starts SEALED -- finish setup below)
Address: https://${OPENBAO_ADDR}:8200
Stack dir: ${STACK_DIR}
TLS: ${STACK_DIR}/tls/tls.crt (give this to the Kanrisha daemon as
[encryption.openbao].openbao_ca_cert)
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 (from the Kanrisha repo's deploy/openbao/bootstrap.sh):
BAO_ADDR=https://${OPENBAO_ADDR}:8200 BAO_CACERT=${STACK_DIR}/tls/tls.crt \\
BAO_TOKEN=<root> bash bootstrap.sh
Then point the Kanrisha daemon at:
[encryption.openbao]
address = "https://${OPENBAO_ADDR}:8200"
openbao_ca_cert = "/etc/kanrisha/openbao-ca.crt" # = tls.crt above
3. Back it up (this vault is the sole recovery path for encrypted tapes):
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \\
bao operator raft snapshot save -tls-skip-verify /openbao/data/snap.snap
...copy it off-box, age-encrypted. 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
# 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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
pZZaaqmlllpqqaWWWmr5/5N/AAPPvDEAUAAA
+50
View File
@@ -0,0 +1,50 @@
# OpenBao stack — hardened tape-encryption key store for Kanrisha.
#
# Topology (same-LAN, NO reverse proxy):
# Kanrisha tape host --https--> openbao:8200 (native TLS on the vault itself)
#
# Unlike the other stacks in this repo there is no Caddy / Let's Encrypt: a
# secrets store terminates TLS itself and is reached over the LAN, not the
# public internet. TLS is a self-signed cert (deploy.sh generates it) or a
# CA-signed pair you drop in ./tls (e.g. via your Smallstep CA over ACME).
name: openbao
volumes:
openbao-data: # raft integrated storage — the vault's persistent state
services:
openbao:
image: openbao/openbao:${OPENBAO_TAG:-2.5.5}
container_name: openbao
command: server -config=/openbao/config/config.hcl
restart: unless-stopped
# mlock: keep key material off swap. IPC_LOCK + unlimited memlock let the
# process lock its memory; the HOST must also have swap disabled.
cap_add:
- IPC_LOCK
ulimits:
memlock: -1
ports:
# Native-TLS API. Bind to the LAN IP (OPENBAO_BIND) to avoid exposing it on
# every interface; firewall it to the tape host (deploy.sh does this).
- "${OPENBAO_BIND:-0.0.0.0}:8200:8200"
volumes:
- ./config.hcl:/openbao/config/config.hcl:ro
- ./tls:/openbao/tls:ro
- openbao-data:/openbao/data
environment:
# PKCS#11 auto-unseal PIN — only read when the seal "pkcs11" stanza is
# enabled in config.hcl. Harmless (unused) otherwise.
BAO_HSM_PIN: "${OPENBAO_HSM_PIN:-}"
# HA / multi-node only: advertise this node's reachable LAN address.
# BAO_API_ADDR: "https://${OPENBAO_ADDR}:8200"
# BAO_CLUSTER_ADDR: "https://${OPENBAO_ADDR}:8201"
# 0 = unsealed (healthy); 2 = sealed -> unhealthy so a node needing an unseal
# is visible. -tls-skip-verify because the listener may use a self-signed cert.
healthcheck:
test: ["CMD", "bao", "status", "-address=https://127.0.0.1:8200", "-tls-skip-verify"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# gen-tls.sh — generate a self-signed TLS cert + key for the OpenBao listener,
# if one is not already present. Never overwrites existing files, so to use a
# CA-signed cert (e.g. issued by your Smallstep CA over ACME) just drop tls.crt +
# tls.key into the target dir instead and this becomes a no-op.
#
# Usage: bash gen-tls.sh [TLS_DIR] (default ./tls)
# Env: OPENBAO_TLS_SANS comma SAN list, e.g. "DNS:vault.lan,IP:10.0.0.10,IP:127.0.0.1"
# OPENBAO_TLS_DAYS validity in days (default 825 — the max browsers accept)
set -euo pipefail
DIR="${1:-./tls}"
SANS="${OPENBAO_TLS_SANS:-DNS:localhost,IP:127.0.0.1}"
DAYS="${OPENBAO_TLS_DAYS:-825}"
mkdir -p "$DIR"
if [[ -f "$DIR/tls.crt" && -f "$DIR/tls.key" ]]; then
echo "[gen-tls] TLS material already present in $DIR — leaving it alone."
exit 0
fi
command -v openssl >/dev/null 2>&1 || { echo "[gen-tls] openssl not found" >&2; exit 1; }
# ECDSA P-256 self-signed cert. The SAN must include every address the tape host
# (and CLI) use to reach the vault, or their TLS verification fails.
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout "$DIR/tls.key" -out "$DIR/tls.crt" -days "$DAYS" \
-subj "/CN=openbao" -addext "subjectAltName=${SANS}"
chmod 600 "$DIR/tls.key"
chmod 644 "$DIR/tls.crt"
echo "[gen-tls] Generated self-signed OpenBao cert in $DIR"
echo "[gen-tls] SANs: ${SANS} validity: ${DAYS}d"
echo "[gen-tls] Give $DIR/tls.crt to the Kanrisha daemon as"
echo "[gen-tls] [encryption.openbao].openbao_ca_cert (so it trusts this listener)."