Files
automations/deployments/openbao/baoctl
T
57_WolveandClaude Opus 5 393223dead feat(openbao): add baoctl, a session wrapper for running bao commands
This host has no `bao` CLI, only Docker, so every authenticated command is a
`docker compose exec` -- and every obvious way to get a token in there leaks it.
`-e BAO_TOKEN=<value>` puts it in the docker process's argv, which
/proc/<pid>/cmdline exposes to every user on the box. An inline
`BAO_TOKEN=<value> cmd` adds shell history on top.

The stdin trick 0eb7f26 uses cannot be the general answer, and the reason is
capability rather than ergonomics: bao's kvbuilder consumes stdin exactly once,
and the operator needs it for `policy write NAME -`, `write PATH -` (a JSON
body) and `key=-` (a single secret value). Spend stdin on the token and an OIDC
client secret has nowhere left to go but argv, reintroducing the leak that was
just closed. update.sh keeps piping because it runs one unattended command that
needs no stdin of its own; interactive work needs something else.

So baoctl is a SESSION wrapper. `baoctl login` prompts once with echo off --
verified against v2.6.2 that bao reads it through termios and requires a TTY --
and afterwards commands are typed verbatim with stdin free.

Three details that are load-bearing, all source-verified at v2.6.2:

- `bao login` prints the token in its success table. Without -no-print the
  interactive path dumps the root token into the exec session's scrollback,
  which is worse than what it replaces.
- The token you type is not what the session keeps. baoctl immediately mints a
  short-lived child and swaps it in via `bao token create -field=token |
  bao login -no-print -`, so the value never reaches an argv or a stdout, the
  session expires on its own, and logout can revoke it without killing the root
  token. A failed mint discards the login rather than leaving the typed token
  sitting in the session.
- logout both revokes AND removes the file. `bao token revoke -self` does not
  delete it and there is no `bao logout` in 2.6.x, so revoking alone leaves a
  stale file that fails with permission errors instead of "not logged in".

The session lives at /dev/shm/.bao-session in the container, pointed at by
BAO_TOKEN_PATH (new in 2.6.0). /dev/shm is already a per-container tmpfs, so the
token never touches disk and dies with the container -- and arranging that
needed no compose change, which matters because recreating this container means
a seal cycle and three unseal keys typed by a human.

It verifies TLS instead of reaching for -tls-skip-verify: ./tls is already
mounted read-only into the container and the generated cert carries
IP:127.0.0.1 in its SANs, so BAO_CACERT validates against the real listener.
-tls-skip-verify would have been the lazy default and is strictly worse.

Also warns when BAO_TOKEN is set in the caller's shell: baoctl never forwards
it, but an operator who set one will assume it is in play and debug the wrong
credential.

Caught while testing: `--ttl` with no value exited SILENTLY, because `shift 2`
with one argument left fails and set -e takes the script down before the
validation ran. Now the argument count is checked first -- another member of
this repo's set -e trap family.

Verified without a live host: help works with no stack present and touches no
docker; the missing-stack path errors cleanly; all four option-validation paths
report rather than exiting silently; the payload carries baoctl.
Not verified: login, the mint-and-swap and logout against a running vault.

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

225 lines
9.6 KiB
Bash

#!/usr/bin/env bash
#
# baoctl -- run `bao` commands against the containerised vault without leaking
# credentials. Companion to deploy.sh, installed alongside the stack.
#
# The problem it solves: this host has no `bao` CLI, only Docker, so every
# authenticated command is a `docker compose exec`. The obvious ways to get a
# token in there all leak it:
# * `-e BAO_TOKEN=<value>` puts it in the docker process's argv, and
# /proc/<pid>/cmdline is world-readable -- every user on the box can read it.
# * `BAO_TOKEN=<value> some-command` additionally puts it in shell history.
# * piping the token to the container spends STDIN, and bao needs stdin for
# `policy write NAME -`, `write PATH -` (a JSON body) and `key=-` (a single
# secret value). Spend it on the token and an OIDC client secret has nowhere
# left to go but argv -- reintroducing the first leak.
#
# So baoctl is a SESSION wrapper, not a per-command token pump. `baoctl login`
# prompts once with echo off, stores a token inside the container, and every
# later command is typed verbatim with stdin free.
#
# The session token is NOT the one you type. After login, baoctl mints a
# short-lived child token and swaps it in, so what sits in the session expires
# on its own and `logout` can revoke it without touching your root token.
#
# The session lives at /dev/shm/.bao-session inside the container (BAO_TOKEN_PATH
# points bao's own token helper there). /dev/shm is already a per-container
# tmpfs, so the token never touches disk and dies with the container -- and no
# compose change was needed to arrange that, which matters because recreating
# this container means a seal cycle and three unseal keys typed by a human.
#
# Subcommands:
# login [--ttl 8h] [--policy NAME] prompt (hidden), then mint + store a child
# logout [--keep-token] revoke the session token AND remove it
# status seal state, and what the session can do
# <anything else> passed straight to `bao` in the container
#
# Usage:
# bash baoctl login
# bash baoctl write pki/root/generate/internal common_name="Example Root CA" ...
# bash baoctl policy write my-policy - < my-policy.hcl
# bash baoctl status
# bash baoctl logout
#
# Env:
# STACK_DIR=/srv/openbao SESSION_TTL=8h SESSION_POLICY=
set -euo pipefail
: "${STACK_DIR:=/srv/openbao}"
: "${SESSION_TTL:=8h}"
: "${SESSION_POLICY:=}"
# Inside the container. /dev/shm is tmpfs, so the session never hits disk.
TOKEN_PATH="/dev/shm/.bao-session"
ADDR_IN="https://127.0.0.1:8200"
CACERT_IN="/openbao/tls/tls.crt"
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; }
# Before the stack check, so --help works anywhere and touches no docker.
case "${1:-}" in
-h|--help|help)
awk '/^set -euo/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$0"
exit 0 ;;
esac
[[ -d "$STACK_DIR" ]] || die "No stack at $STACK_DIR (set STACK_DIR)."
dc() { ( cd "$STACK_DIR" && docker compose "$@" ); }
# BAO_TOKEN in the environment beats the token file, always. baoctl never passes
# it through -- but if the operator has one set they will assume it is in play,
# and be debugging the wrong credential.
if [[ -n "${BAO_TOKEN:-}" ]]; then
warn "BAO_TOKEN is set in your shell. baoctl ignores it and uses its own session;"
warn "unset it to avoid confusion about which credential is being used."
fi
# Verify TLS properly rather than reaching for -tls-skip-verify: deploy.sh mounts
# ./tls read-only into the container and puts IP:127.0.0.1 in the cert's SANs, so
# pointing BAO_CACERT at it actually validates. Only fall back to skipping when
# the cert genuinely is not there.
bao_env() {
printf '%s\n' "-e" "BAO_ADDR=${ADDR_IN}" "-e" "BAO_TOKEN_PATH=${TOKEN_PATH}"
if [[ -f "$STACK_DIR/tls/tls.crt" ]]; then
printf '%s\n' "-e" "BAO_CACERT=${CACERT_IN}"
else
printf '%s\n' "-e" "BAO_SKIP_VERIFY=true"
fi
}
bao_run() { # bao_run <bao args...>
# Allocate a TTY only when our own stdin is one. An interactive command wants
# one; `baoctl write path - < body.json` must NOT have one, or stdin is not
# forwarded and the payload never arrives.
local -a envs=() tf=()
while IFS= read -r line; do envs+=("$line"); done < <(bao_env)
[[ -t 0 ]] || tf=(-T)
dc exec "${tf[@]}" "${envs[@]}" openbao bao "$@"
}
session_exists() {
local -a envs=()
while IFS= read -r line; do envs+=("$line"); done < <(bao_env)
dc exec -T "${envs[@]}" openbao sh -c '[ -s "$BAO_TOKEN_PATH" ]' >/dev/null 2>&1
}
do_login() {
local ttl="$SESSION_TTL" policy="$SESSION_POLICY"
while [[ $# -gt 0 ]]; do
case "$1" in
# `shift 2` with only one argument left FAILS, and under set -e that
# exits the script silently -- before any validation below runs.
# So check the count first rather than relying on ${2:-}.
--ttl) [[ $# -ge 2 ]] || die "login: --ttl needs a value (e.g. 8h)."
ttl="$2"; shift 2 ;;
--policy) [[ $# -ge 2 ]] || die "login: --policy needs a policy name."
policy="$2"; shift 2 ;;
*) die "login: unknown option '$1' (expected --ttl or --policy)." ;;
esac
done
[[ -n "$ttl" ]] || die "login: --ttl needs a value (e.g. 8h)."
# Checked after option parsing, so a typo'd flag is reported either way.
[[ -t 0 ]] || die "login needs a terminal: bao prompts for the token with echo off, which requires a TTY. Run it from an interactive shell."
local -a envs=()
while IFS= read -r line; do envs+=("$line"); done < <(bao_env)
# Step 1: the interactive login. -no-print is not optional -- without it bao
# prints the token in its success table, dumping whatever you just typed into
# this terminal's scrollback, which is worse than what we are replacing.
log "Logging in (the prompt is hidden; nothing is echoed or stored on the host)..."
dc exec "${envs[@]}" openbao bao login -no-print \
|| die "Login failed. The token was rejected, or the vault is sealed."
# Step 2: swap the token you typed for a short-lived child, so the session
# expires by itself and `logout` can revoke it without killing your root
# token. The child never reaches an argv or a stdout: it goes straight down a
# pipe into `bao login -`, which reads the token from stdin.
log "Minting a ${ttl} session token${policy:+ with policy '${policy}'}..."
if ! dc exec -T "${envs[@]}" openbao sh -c '
ttl="$1"; pol="$2"
if [ -n "$pol" ]; then
bao token create -ttl="$ttl" -policy="$pol" -field=token
else
bao token create -ttl="$ttl" -field=token
fi | bao login -no-print -
' sh "$ttl" "$policy"; then
# Do not leave the typed token sitting in the session: the whole point of
# the swap is that a session is disposable. Clear it and fail.
dc exec -T "${envs[@]}" openbao sh -c 'rm -f "$BAO_TOKEN_PATH"' >/dev/null 2>&1 || true
die "Could not mint a session token; the login has been discarded. If the token you used cannot create child tokens, pass --policy with one it can grant."
fi
log "Session ready. Run commands with: bash $(basename "$0") <bao args...>"
do_status || true
}
do_logout() {
local keep=0
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-token) keep=1; shift ;;
*) die "logout: unknown option '$1'." ;;
esac
done
local -a envs=()
while IFS= read -r line; do envs+=("$line"); done < <(bao_env)
if ! session_exists; then
log "No session to end."
return 0
fi
# Both steps, deliberately. `bao token revoke -self` does not remove the token
# file and there is no `bao logout` in 2.6.x, so revoking alone leaves a stale
# file behind that fails with permission errors instead of "not logged in".
if [[ "$keep" == 0 ]]; then
dc exec -T "${envs[@]}" openbao bao token revoke -self >/dev/null 2>&1 \
|| warn "Could not revoke the session token (already expired?); removing it anyway."
fi
dc exec -T "${envs[@]}" openbao sh -c 'rm -f "$BAO_TOKEN_PATH"' >/dev/null 2>&1 || true
log "Session ended."
}
do_status() {
local -a envs=()
while IFS= read -r line; do envs+=("$line"); done < <(bao_env)
local rc=0
dc exec -T "${envs[@]}" openbao bao status >/dev/null 2>&1 || rc=$?
case "$rc" in
0) printf ' vault: unsealed\n' ;;
2) printf ' vault: SEALED (unseal before anything else works)\n' ;;
*) printf ' vault: unreachable (bao status rc=%s)\n' "$rc" ;;
esac
if ! session_exists; then
printf ' session: none -- run `%s login`\n' "$(basename "$0")"
return 0
fi
printf ' session: present at %s (in-container tmpfs)\n' "$TOKEN_PATH"
dc exec -T "${envs[@]}" openbao bao token lookup -format=json 2>/dev/null \
| awk -F'"' '
/"display_name"/ { printf " name: %s\n", $4 }
/"ttl"/ { gsub(/[^0-9]/, "", $0); if ($0 != "") printf " ttl: %ss\n", $0 }
' || warn "Session token present but lookup failed; it may have expired. Run logout, then login."
}
case "${1:-status}" in
login) shift; do_login "$@" ;;
logout) shift; do_logout "$@" ;;
status) shift; do_status "$@" ;;
*)
# Everything else is a bao command. Requiring a session first gives a
# clear error instead of bao's "missing client token".
session_exists || die "No session. Run: bash $(basename "$0") login"
bao_run "$@"
;;
esac