#!/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=` puts it in the docker process's argv, and # /proc//cmdline is world-readable -- every user on the box can read it. # * `BAO_TOKEN= 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 # 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 # 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") " 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