#!/usr/bin/env bash # # update.sh -- update the OpenBao container. Companion to deploy.sh, installed # alongside the stack. # # THIS UPDATER IS DELIBERATELY UNLIKE THE OTHERS IN THIS REPO. copyparty and # ergo come back by themselves after a recreate. OpenBao comes back SEALED: with # the default Shamir seal, a restart needs three unseal keys typed in by a human # before the vault serves anything again. A scheduled update at 03:00 would # therefore take the vault offline until someone turns up with the keys. # # So the scheduled path NEVER changes the running version by default. It reports. # UPDATE_POLICY=auto opts in, and even then it refuses unless auto-unseal is # configured -- because only then does the vault come back on its own. # # It also takes a raft snapshot before touching anything, which neither of the # other updaters needs to do. OpenBao's upgrade guide is explicit that reverting # the image alone does NOT roll back the data store, so that snapshot is the # rollback plan, not a formality. The snapshot is token-gated and cannot be taken # from a sealed vault, both of which this script checks up front rather than # failing halfway through. # # Subcommands: # check (default) declared vs running vs latest, seal state, and whether # an unattended update would be safe here. Changes nothing. # snapshot take a raft snapshot and leave it in SNAPSHOT_DIR. Nothing else. # update do it now: preflight -> snapshot -> down -> pin -> pull -> up # run what the schedule invokes; obeys UPDATE_POLICY # install schedule the daily `run` # uninstall remove the schedule # # Policy (UPDATE_POLICY): # notify (default) never change the running version; report only # auto update when a newer release exists -- but ONLY if auto-unseal is # configured. Without it, `run` reports and does nothing. # # Env (also read from /etc/openbao-update.conf; environment wins): # STACK_DIR=/srv/openbao UPDATE_POLICY=notify # BAO_TOKEN= required by `update`/`snapshot`. Prefer NOT to set # this inline -- see "Supplying the token" below. # BAO_TOKEN_FILE= read the token from a file (0600) instead # SNAPSHOT_DIR=/var/backups/openbao # TARGET_VERSION= pin a specific version instead of latest # DRY_RUN=0 print what would happen, change nothing # SKIP_SNAPSHOT=0 DANGEROUS: upgrade with no rollback plan # GH_REPO=openbao/openbao # # Supplying the token: # `BAO_TOKEN= bash update.sh update` works, but DO NOT do it: the value # lands in your shell history, and anything on the argv of a child process is # readable by every user on the box via /proc//cmdline. This script never # puts the token on any command line -- it hands it to the container over # stdin -- and it will prompt for it with echo off if you do not supply one. # For unattended use, point BAO_TOKEN_FILE at a 0600 file. # # Usage: # bash update.sh check # no token needed # bash update.sh snapshot # prompts for the token # bash update.sh update # prompts for the token # BAO_TOKEN_FILE=/root/.bao-root bash update.sh update # TARGET_VERSION=2.6.2 bash update.sh update set -euo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" : "${OPENBAO_UPDATE_CONF:=/etc/openbao-update.conf}" if [[ -r "$OPENBAO_UPDATE_CONF" ]]; then # Environment wins over the conf: only set what is not already set. while IFS= read -r _line || [[ -n "$_line" ]]; do [[ "$_line" =~ ^[[:space:]]*# || -z "${_line//[[:space:]]/}" ]] && continue _k="${_line%%=*}"; _v="${_line#*=}"; _k="${_k//[[:space:]]/}" [[ -n "$_k" ]] || continue [[ -n "${!_k+x}" ]] || printf -v "$_k" '%s' "$_v" done < "$OPENBAO_UPDATE_CONF" fi : "${STACK_DIR:=/srv/openbao}" : "${UPDATE_POLICY:=notify}" : "${BAO_TOKEN:=}" : "${BAO_TOKEN_FILE:=}" : "${SNAPSHOT_DIR:=/var/backups/openbao}" : "${TARGET_VERSION:=}" : "${DRY_RUN:=0}" : "${SKIP_SNAPSHOT:=0}" : "${GH_REPO:=openbao/openbao}" ENV_FILE="$STACK_DIR/.env" CONFIG_HCL="$STACK_DIR/config.hcl" 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; } # docker compose scoped to the stack dir, so ./config.hcl, ./tls and .env resolve. dc() { ( cd "$STACK_DIR" && docker compose "$@" ); } set_env() { # : update KEY in .env, or append if absent # Value goes through the ENVIRONMENT, never interpolated into a sed script -- # see 947c899 / 185f404 for why. 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" rm -f "$tmp" } env_get() { # -> value from .env ('' if absent) [[ -f "$ENV_FILE" ]] || { printf ''; return 0; } sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 } # --------------------------------------------------------------------------- # Token handling # # The token never appears on a command line. `docker exec -e BAO_TOKEN=` # would put it in the docker process's argv, and /proc//cmdline is # world-readable -- so it would leak to every user on the host, not just to the # shell history. Instead the value is written to the container's stdin and the # container's own shell reads it into the environment it then execs `bao` with. # --------------------------------------------------------------------------- acquire_token() { [[ -n "$BAO_TOKEN" ]] && return 0 if [[ -n "$BAO_TOKEN_FILE" ]]; then [[ -r "$BAO_TOKEN_FILE" ]] || die "BAO_TOKEN_FILE=${BAO_TOKEN_FILE} is not readable." BAO_TOKEN="$(head -n1 "$BAO_TOKEN_FILE")" [[ -n "$BAO_TOKEN" ]] || die "BAO_TOKEN_FILE=${BAO_TOKEN_FILE} is empty." return 0 fi if [[ -t 0 ]]; then read -rs -p "OpenBao token (input hidden): " BAO_TOKEN printf '\n' >&2 [[ -n "$BAO_TOKEN" ]] || die "No token entered." return 0 fi die "No token available and stdin is not a terminal. Set BAO_TOKEN_FILE=/path/to/a/0600/file, or run interactively to be prompted. Avoid BAO_TOKEN=... on the command line: it goes into your shell history and into /proc//cmdline." } # Run `bao ` inside the container with BAO_TOKEN set, passing the token # over stdin so it never reaches an argv anywhere on the host. bao_with_token() { printf '%s\n' "$BAO_TOKEN" | dc exec -T openbao sh -c ' read -r _t BAO_TOKEN="$_t" export BAO_TOKEN exec "$@" ' sh bao "$@" } # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- declared_version() { env_get OPENBAO_TAG; } running_version() { # `bao status` prints "Version 2.6.2". Works sealed or unsealed. # `|| true` inside the substitution: status exits 2 when sealed, and this is # a bare assignment at every call site. dc exec -T openbao bao status -address=https://127.0.0.1:8200 -tls-skip-verify 2>/dev/null \ | awk '/^Version/ { print $2; exit }' || true } # 0 = unsealed, 2 = sealed, anything else = could not tell. seal_state() { local rc=0 dc exec -T openbao bao status -address=https://127.0.0.1:8200 -tls-skip-verify \ >/dev/null 2>&1 || rc=$? printf '%s' "$rc" } container_running() { local id id="$(dc ps -q openbao 2>/dev/null || true)" [[ -n "$id" ]] || return 1 [[ "$(docker inspect -f '{{.State.Status}}' "$id" 2>/dev/null || echo unknown)" == running ]] } # An uncommented `seal "..." {` stanza means the vault unseals itself, which is # the only condition under which an unattended update is defensible. auto_unseal_configured() { [[ -f "$CONFIG_HCL" ]] || return 1 grep -qE '^[[:space:]]*seal[[:space:]]+"' "$CONFIG_HCL" } latest_version() { # No jq on a stock Alpine host; parse the tag out with sed. curl -fsSL --max-time 20 "https://api.github.com/repos/${GH_REPO}/releases/latest" 2>/dev/null \ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' \ | head -n1 || true } # --------------------------------------------------------------------------- # Preflight + snapshot # --------------------------------------------------------------------------- # The built-in pkcs11 seal is REMOVED in 2.7.0 (not merely deprecated), and the # HSM distribution is discontinued. Crossing that line with the stanza active # gives a vault that cannot unseal itself and cannot be unsealed by hand either. check_pkcs11_cliff() { # local target="$1" major_minor major_minor="${target%.*}" case "$major_minor" in 2.7|2.8|2.9|3.*) ;; *) return 0 ;; esac if [[ -f "$CONFIG_HCL" ]] && grep -qE '^[[:space:]]*seal[[:space:]]+"pkcs11"' "$CONFIG_HCL"; then die "config.hcl has an active built-in seal \"pkcs11\" stanza and the target is ${target}. That stanza is REMOVED in 2.7.0 -- the vault would start with no way to unseal. Migrate to the external 'plugin \"kms\" \"pkcs11\"' first." fi } take_snapshot() { # -> echoes the snapshot path local target="$1" stamp dest stamp="$(date -u +%Y%m%dT%H%M%SZ)" dest="${SNAPSHOT_DIR}/openbao-pre-${target}-${stamp}.snap" if [[ "$SKIP_SNAPSHOT" == "1" ]]; then warn "SKIP_SNAPSHOT=1 -- upgrading with NO rollback plan. Reverting the image alone does not roll back the data store." printf '' return 0 fi acquire_token if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: snapshot -> ${dest}" >&2 printf '%s' "$dest" return 0 fi install -d -m 0700 "$SNAPSHOT_DIR" log "Taking a raft snapshot to ${dest}..." >&2 # Write inside the container, then stream the RAW file out. `compose cp` is # NOT usable here: it emits a TAR wrapper that will not restore. bao_with_token operator raft snapshot save \ -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/pre-upgrade.snap \ >/dev/null || die "Snapshot failed -- not upgrading. Check the token has sys/storage/raft/snapshot." dc exec -T openbao cat /tmp/pre-upgrade.snap > "$dest" || die "Could not stream the snapshot out -- not upgrading." dc exec -T openbao rm -f /tmp/pre-upgrade.snap >/dev/null 2>&1 || true chmod 0600 "$dest" # A snapshot is a gzip-wrapped tar. An empty or truncated file here means the # rollback plan does not exist, so treat it as fatal rather than cosmetic. [[ -s "$dest" ]] || die "Snapshot at ${dest} is empty -- not upgrading." if command -v gzip >/dev/null 2>&1; then gzip -t "$dest" 2>/dev/null || die "Snapshot at ${dest} is not a valid gzip archive -- not upgrading." fi log "Snapshot OK ($(wc -c < "$dest") bytes). Copy it OFF this host." >&2 printf '%s' "$dest" } # --------------------------------------------------------------------------- # Subcommands # --------------------------------------------------------------------------- do_check() { [[ -d "$STACK_DIR" ]] || die "No stack at $STACK_DIR (set STACK_DIR)." local declared running latest state auto declared="$(declared_version)" latest="$(latest_version)" if container_running; then running="$(running_version)" state="$(seal_state)" else running="" state="down" fi auto_unseal_configured && auto=yes || auto=no printf ' declared (.env OPENBAO_TAG): %s\n' "${declared:-}" printf ' running (bao status): %s\n' "${running:-}" printf ' latest (%s): %s\n' "$GH_REPO" "${latest:-}" case "$state" in 0) printf ' seal state: UNSEALED\n' ;; 2) printf ' seal state: SEALED (needs 3 keys)\n' ;; down) printf ' seal state: container not running\n' ;; *) printf ' seal state: unknown (bao status rc=%s)\n' "$state" ;; esac printf ' auto-unseal configured: %s\n' "$auto" printf ' policy: %s\n' "$UPDATE_POLICY" if [[ -z "$latest" ]]; then warn "Could not reach the GitHub releases API; cannot say whether an update exists." return 0 fi if [[ "$declared" == "$latest" ]]; then log "Up to date." return 0 fi log "Update available: ${declared:-?} -> ${latest}" if [[ "$auto" == no ]]; then warn "A restart will leave the vault SEALED until someone enters three unseal keys." warn "Run it when you can do that: bash ${SELF} update (it will prompt for a token)" fi } do_update() { [[ $EUID -eq 0 ]] || die "Run as root." [[ -d "$STACK_DIR" ]] || die "No stack at $STACK_DIR (set STACK_DIR)." local from to snap from="$(declared_version)" to="${TARGET_VERSION:-$(latest_version)}" [[ -n "$to" ]] || die "Could not determine a target version (GitHub lookup failed). Set TARGET_VERSION=x.y.z." if [[ "$from" == "$to" ]]; then log "Already pinned to ${to}; nothing to do." return 0 fi check_pkcs11_cliff "$to" container_running || die "The openbao container is not running. Start it first: cd ${STACK_DIR} && docker compose up -d" # A sealed vault cannot produce a snapshot, so there would be no rollback # plan. Catch it here rather than after the container is already down. local state; state="$(seal_state)" case "$state" in 0) ;; 2) die "The vault is SEALED. A snapshot cannot be taken from a sealed vault, so there would be no rollback plan. Unseal first, then re-run." ;; *) die "Could not read the seal state (bao status rc=${state}). Refusing to upgrade blind." ;; esac log "Updating OpenBao: ${from:-?} -> ${to}" snap="$(take_snapshot "$to")" if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: docker compose down; set OPENBAO_TAG=${to}; docker compose pull; docker compose up -d" return 0 fi # Clean shutdown before swapping the image, as the upgrade guide asks. log "Stopping the stack (the vault will be sealed when it returns)..." dc down set_env OPENBAO_TAG "$to" if ! dc pull; then warn "Pull of ${to} failed; rolling the pin back to ${from:-}." [[ -n "$from" ]] && set_env OPENBAO_TAG "$from" dc up -d || true die "Update aborted. The previous version is starting again; unseal it." fi if ! dc up -d --remove-orphans; then warn "Starting ${to} failed; rolling the pin back to ${from:-}." [[ -n "$from" ]] && set_env OPENBAO_TAG "$from" dc up -d || true die "Update aborted. If the data store is at fault, restore ${snap:-the snapshot} per the README's DR runbook." fi log "Started ${to}." cat < ${to} The vault is SEALED. Nothing works until you unseal it: cd ${STACK_DIR} docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \\ bao operator unseal -tls-skip-verify # x3, three different keys Then verify: docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \\ bao status -tls-skip-verify # Version ${to}, Sealed false Rollback, if ${to} misbehaves: reverting the image alone does NOT roll back the data store. Restore the pre-upgrade snapshot -- see the README's DR runbook. snapshot: ${snap:-} ================================================================ EOF } do_snapshot() { [[ -d "$STACK_DIR" ]] || die "No stack at $STACK_DIR (set STACK_DIR)." container_running || die "The openbao container is not running." local state; state="$(seal_state)" [[ "$state" == 0 ]] || die "The vault is SEALED (or unreadable): a snapshot can only be taken from an unsealed vault." local dest; dest="$(take_snapshot manual)" [[ -n "$dest" ]] || die "No snapshot was taken." log "Snapshot written to ${dest} -- copy it OFF this host." } do_run() { # What the schedule invokes. The whole point of this branch is that it is # conservative: an unattended update of a manually-unsealed vault takes it # offline until a human arrives, so that is never the default. case "$UPDATE_POLICY" in notify) do_check return 0 ;; auto) if ! auto_unseal_configured; then do_check warn "UPDATE_POLICY=auto but no seal stanza is configured, so the vault would stay SEALED after a restart with nobody present. Not updating." return 0 fi local from to from="$(declared_version)" to="${TARGET_VERSION:-$(latest_version)}" if [[ -z "$to" || "$from" == "$to" ]]; then do_check return 0 fi log "UPDATE_POLICY=auto and auto-unseal is configured; updating ${from:-?} -> ${to}." do_update return 0 ;; *) die "UPDATE_POLICY must be 'notify' or 'auto' (got '${UPDATE_POLICY}')." ;; esac } write_conf() { install -d -m 0755 "$(dirname "$OPENBAO_UPDATE_CONF")" # BAO_TOKEN is deliberately NOT written here: a long-lived root token sitting # in a conf file next to the vault it opens defeats the point of the vault. cat > "$OPENBAO_UPDATE_CONF" < /etc/periodic/daily/openbao-update </dev/null 2>&1; then rc-update add crond default >/dev/null 2>&1 || true rc-service crond start >/dev/null 2>&1 || true fi log "Installed /etc/periodic/daily/openbao-update." else cat > /etc/systemd/system/openbao-update.service < /etc/systemd/system/openbao-update.timer </dev/null 2>&1 || true log "Installed the openbao-update.timer systemd timer." fi } do_uninstall() { [[ $EUID -eq 0 ]] || die "Run as root." rm -f /etc/periodic/daily/openbao-update if command -v systemctl >/dev/null 2>&1; then systemctl disable --now openbao-update.timer >/dev/null 2>&1 || true rm -f /etc/systemd/system/openbao-update.timer /etc/systemd/system/openbao-update.service systemctl daemon-reload >/dev/null 2>&1 || true fi log "Schedule removed. ${OPENBAO_UPDATE_CONF} left in place." } case "${1:-check}" in check) do_check ;; snapshot) do_snapshot ;; update) do_update ;; run) do_run ;; install) do_install ;; uninstall) do_uninstall ;; *) die "Usage: $(basename "$0") {check|snapshot|update|run|install|uninstall}" ;; esac