Compare commits

..

4 Commits

Author SHA1 Message Date
471692c283 Automate demo-app AppRole SecretID rotation on a weekly timer
A SecretID cannot be renewed the way a token can -- it must be re-issued by a
privileged caller -- so renew-app-tokens.sh structurally could not cover it.
The original expired on ~2026-07-28 and broke app-get-secret.sh silently until
it was noticed on 2026-08-22. This closes the last credential in the stack
that had no automated lifecycle.

Weekly against a 30d secret_id_ttl gives 4x margin: three consecutive failed
rotations can occur before anything actually breaks, and the first failure
already mails an alert via OnFailure=openbao-alert@%n.service.

Order of operations is the safety property: mint -> verify the new SecretID
actually authenticates -> only then overwrite the file -> only then prune old
accessors. Any failure leaves the previous working credential in place and
exits non-zero. Verified by pointing BAO_ADDR at a dead port: exit 1, file
byte-identical, consumer unaffected. A SecretID that fails its verification
login is destroyed rather than installed.

Accessors are kept one cycle deep (KEEP=2: current + previous) so a consumer
that read the file just before rotation can still log in. Verified across
three consecutive runs: steady state stays at 2, oldest pruned each cycle.

The rotator skips its verification login when secret_id_num_uses would be
consumed by it, which is why the policy grants read on the role config. That
read exposes TTLs and bound policies but NOT the RoleID, which lives at the
separate .../role-id path and stays denied -- verified 403, along with 403 on
the demo secret itself and on other AppRoles.

Token at /etc/openbao-approle-rotate.token matches the /etc/openbao-*.token
glob, so the renew loop picks it up automatically (verified: renewed=10
failed=0) and it cannot lapse the way the backup tokens did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk
2026-08-22 09:33:16 +02:00
ee0b8c818f Alert by email when an OpenBAO maintenance unit fails
A failed oneshot unit is silent by default, which is why the backup failures
went unnoticed for 24 days. All three maintenance units now carry
OnFailure=openbao-alert@%n.service, which mails the failure with systemctl
status and the last 40 journal lines.

Reuses the existing ClaudeAdmin notifier pattern: curl --ssl-reqd to
securesmtp.t-online.de:587, password read from KV secret/data/smtp/healthcheck
via a new root-owned periodic token /etc/openbao-alert-smtp.token bound to
smtp-healthcheck-ro. That path matches /etc/openbao-*.token, so the
token-renew loop picks it up automatically and it cannot lapse the way the
others did (verified: renewed=9 failed=0).

Two failure modes designed around:

- Circular dependency. The SMTP password lives in OpenBAO, but the likeliest
  cause of these units failing is OpenBAO being down or sealed -- so fetching
  the password would fail exactly when the alert matters most. The alert is
  therefore always written to /var/log/openbao-alerts.log (0600) BEFORE any
  network call, and successful fetches refresh a root-only 0600 credential
  cache used as fallback. Verified with BAO_ADDR pointed at a dead port: the
  email still went out, tagged as sent via cache.
- Recursion. The alert unit has no OnFailure of its own and always exits 0;
  a send failure is logged and syslogged rather than raised.

Uses %i, not %I: unit names contain '-', which systemd unescaping would turn
into '/' (openbao-backup.service -> openbao/backup.service).

Verified end to end with a throwaway failing unit (since removed): failure ->
OnFailure -> alert unit -> email delivered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk
2026-08-22 09:22:05 +02:00
c5ff1a3fca Upgrade OpenBAO 2.5.5 -> 2.6.2
Picks up 6 security fixes across 2.6.0/2.6.2, notably GHSA-xp3c-3jw3-4vcr
(LIST bypassing an explicit deny from a wildcard grant).

Checked the 2.6.x breaking changes against this deployment before upgrading:

- Identity-template hardening (+ * / , now rejected in template values):
  no {{ }} templating in any policy, and every pki/ssh role has
  *_template = false. Unaffected.
- Container default user changed root -> openbao (uid 100): the raft volume
  at /openbao/file is already uid 100 and the config bind-mount is
  world-readable :ro, so no chown fixup was needed. Both containers now run
  as uid=100(openbao). Unaffected.
- Cloud KMS + pkcs11 seals become external plugins in 2.7.0: this uses the
  transit seal, which stays built in. Unaffected.
- physical/file deprecated in 2.7.0: storage is raft. Unaffected.
- Pre-v2.5.0 corrupt identity groups purged on unseal: cluster was created
  on 2.5.5, and the `family` group survived. Unaffected.
- Arch-specific tags (2.6.x-arm64) no longer published: compose uses the
  plain multi-arch tag. Unaffected.

Upgrade order was unsealer first (recreate, manually unseal 1-of-1 shamir,
confirm the autounseal transit key is readable), then main, which
auto-unsealed via transit. Cluster IDs preserved on both.

Verified after: all 13 mounts, 4 auth methods and 14 policies intact; KV
read, PKI issue, SSH CA sign and transit encrypt all work through their
scoped tokens; backup token still 403s outside its scope; raft snapshots
succeed on 2.6.2; HTTPS via Traefik serves 2.6.2 with a trusted chain.

Note: sys/seal-status now reports `commit_date` instead of `build_date`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk
2026-08-22 09:12:14 +02:00
123e65ee46 Renew root-owned /etc infra tokens too (backups were dead 24 days)
renew-app-tokens.sh only walked ~/.config/openbao/*.token, so the three
root-owned tokens were never renewed and lapsed on 2026-07-29:

  /etc/openbao-backup.token           -> nightly raft snapshots failed (403)
  /etc/openbao-unsealer-backup.token  -> same, unsealer instance
  /etc/openbao-cert-renew.token       -> would have failed silently at <21d

Nightly backups had been failing for 24 days before this was noticed; the
last good snapshot was 2026-07-28. All three tokens have been re-issued as
periodic (30d) and the script now covers both sets.

The unsealer's token belongs to a separate instance with no published port,
so it renews via `docker compose exec` rather than curl -- renewing it
against main returns 403. Note `bao token renew` takes no -self flag; the
bare form is the renew-self call.

Service now runs as root to read /etc, and chowns the log back to lutz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk
2026-08-22 09:12:14 +02:00
11 changed files with 360 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
services:
openbao:
image: openbao/openbao:2.5.5
image: openbao/openbao:2.6.2
container_name: openbao
restart: unless-stopped
# The image entrypoint already runs `bao server -config=/openbao/config`
@@ -32,7 +32,7 @@ services:
# Minimal sidecar instance that provides transit auto-unseal for the main
# node above. Shamir-sealed itself; only reachable on the internal network.
openbao-unsealer:
image: openbao/openbao:2.5.5
image: openbao/openbao:2.6.2
container_name: openbao-unsealer
restart: unless-stopped
command: server

View File

@@ -0,0 +1,31 @@
# approle-rotate — lets the rotation timer mint and prune SecretIDs for the
# demo-app AppRole, and nothing else. Consumed via a periodic token at
# /etc/openbao-approle-rotate.token by scripts/rotate-approle-secret-id.sh.
#
# Deliberately scoped to the single role: no access to the RoleID, no ability
# to read any secret, and no reach into other AppRoles. A leak of this token
# lets an attacker mint credentials for demo-app only -- which is why the role
# itself stays limited to secret/demo-app/*.
# Read the role config -- only for secret_id_num_uses, so the rotator knows
# whether a verification login would burn a limited-use SecretID. This exposes
# role settings (TTLs, bound policies) but NOT the RoleID, which lives at the
# separate .../role-id path and stays denied.
path "auth/approle/role/demo-app" {
capabilities = ["read"]
}
# Generate a new SecretID, and list existing accessors for pruning.
path "auth/approle/role/demo-app/secret-id" {
capabilities = ["update", "list"]
}
# Inspect an accessor (creation_time drives which ones are safe to prune).
path "auth/approle/role/demo-app/secret-id-accessor/lookup" {
capabilities = ["update"]
}
# Destroy a superseded SecretID by accessor.
path "auth/approle/role/demo-app/secret-id-accessor/destroy" {
capabilities = ["update"]
}

View File

@@ -3,27 +3,70 @@
# tokens live only as long as they're renewed within their period; nothing was
# renewing these, so they expired (2026-08-01). Runs daily via a systemd timer.
#
# Best-effort: reads each ~/.config/openbao/*.token and calls renew-self. A dead
# token logs a failure but never aborts the rest. No secrets are printed.
# Covers TWO sets of tokens:
# 1. $USER_DIR/*.token — user-owned app tokens, renewed against main.
# 2. /etc/openbao-*.token — root-owned infra tokens (backup, cert-renew).
# The /etc set lapsed unnoticed for 24 days (2026-07-29..08-22) because this
# script only walked the user dir, which broke nightly backups and would have
# broken cert renewal — hence it now runs as root to read both.
#
# The unsealer's backup token belongs to the SEPARATE openbao-unsealer instance,
# which publishes no host port, so it is renewed via `docker compose exec`
# rather than curl. Renewing it against main would 403.
#
# Best-effort: a dead token logs a failure but never aborts the rest. No secrets
# are printed.
set -uo pipefail
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
DIR="$HOME/.config/openbao"
LOG="$DIR/token-renew.log"
PROJECT_DIR="/home/lutz/Projects/OpenBAO"
USER_DIR="${TOKEN_DIR:-/home/lutz/.config/openbao}" # explicit: $HOME is /root under the timer
UNSEALER_TOKEN="/etc/openbao-unsealer-backup.token"
LOG="$USER_DIR/token-renew.log"
ts="$(date '+%F %T %Z')"
shopt -s nullglob
renewed=0; failed=0
for tf in "$DIR"/*.token; do # *.token only — backup files (*.token.bak.*) don't match
tok="$(cat "$tf" 2>/dev/null)"
ok() { echo "$ts $1 renewed ttl=${2}s" >> "$LOG"; renewed=$((renewed+1)); }
bad() { echo "$ts $1 RENEW FAILED: $2" >> "$LOG"; failed=$((failed+1)); }
# renew_via_api <token-file>
renew_via_api() {
local tf="$1" name tok resp ttl
name="$(basename "$tf")"
[ -n "$tok" ] || { echo "$ts $name EMPTY" >> "$LOG"; failed=$((failed+1)); continue; }
tok="$(cat "$tf" 2>/dev/null)"
[ -n "$tok" ] || { bad "$name" "EMPTY"; return; }
resp="$(curl -sS --max-time 10 -H "X-Vault-Token: $tok" -X POST "$ADDR/v1/auth/token/renew-self" 2>/dev/null)"
ttl="$(printf '%s' "$resp" | jq -r '.auth.lease_duration // empty' 2>/dev/null)"
if [ -n "$ttl" ]; then
echo "$ts $name renewed ttl=${ttl}s" >> "$LOG"; renewed=$((renewed+1))
else
echo "$ts $name RENEW FAILED: $(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null)" >> "$LOG"; failed=$((failed+1))
fi
if [ -n "$ttl" ]; then ok "$name" "$ttl"
else bad "$name" "$(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null)"; fi
}
# renew_via_exec <token-file> — for the unsealer instance (no published port)
renew_via_exec() {
local tf="$1" name tok resp ttl
name="$(basename "$tf")"
tok="$(cat "$tf" 2>/dev/null)"
[ -n "$tok" ] || { bad "$name" "EMPTY"; return; }
# Bare `bao token renew` (no TOKEN arg) is the renew-self form; there is no
# -self flag in OpenBao's CLI.
resp="$(cd "$PROJECT_DIR" && docker compose exec -T -e BAO_TOKEN="$tok" openbao-unsealer \
bao token renew -format=json 2>&1)"
ttl="$(printf '%s' "$resp" | jq -r '.auth.lease_duration // empty' 2>/dev/null)"
if [ -n "$ttl" ]; then ok "$name" "$ttl"
else bad "$name" "$(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null || printf '%s' "$resp" | tr '\n' ' ')"; fi
}
# 1. user-owned app tokens (*.token only — backups like *.token.bak.* don't match)
for tf in "$USER_DIR"/*.token; do renew_via_api "$tf"; done
# 2. root-owned infra tokens; the unsealer one needs the exec path
for tf in /etc/openbao-*.token; do
[ -r "$tf" ] || { bad "$(basename "$tf")" "not readable (run as root)"; continue; }
if [ "$tf" = "$UNSEALER_TOKEN" ]; then renew_via_exec "$tf"; else renew_via_api "$tf"; fi
done
echo "$ts summary: renewed=$renewed failed=$failed" >> "$LOG"
# Running as root must not leave the log root-owned for the next user-context read.
chown lutz:lutz "$LOG" 2>/dev/null || true
[ "$failed" -eq 0 ]

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Rotate the demo-app AppRole SecretID before its 30d TTL expires.
#
# WHY THIS EXISTS: unlike tokens, a SecretID cannot be renewed -- it must be
# re-issued by a privileged caller. So renew-app-tokens.sh structurally cannot
# cover it, and the original SecretID silently expired on ~2026-07-28, leaving
# scripts/app-get-secret.sh broken until it was noticed on 2026-08-22.
#
# ORDER OF OPERATIONS IS THE SAFETY PROPERTY. We mint, then PROVE the new
# SecretID actually authenticates, and only then overwrite the file and prune
# old accessors. A failure at any step leaves the previous working credential
# untouched and exits non-zero, so OnFailure= mails the alert.
#
# Old accessors are kept one cycle deep (KEEP=2: current + previous) so a
# consumer that read the file microseconds before rotation can still log in.
#
# Installed as openbao-approle-rotate.timer (weekly -- 4x margin on a 30d TTL,
# so three consecutive failures can occur before anything actually breaks).
set -uo pipefail
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
ROLE="${ROLE:-demo-app}"
TOKEN_FILE="/etc/openbao-approle-rotate.token"
OUT="${OUT:-/home/lutz/.config/openbao/approle/secret_id}"
OWNER="lutz:lutz"
KEEP="${KEEP:-2}" # accessors to retain: current + previous
log() { printf '%s [approle-rotate] %s\n' "$(date '+%F %T')" "$*"; }
die() { log "ERROR: $*"; exit 1; }
[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)"
TOK="$(cat "$TOKEN_FILE")"
[ -n "$TOK" ] || die "token file $TOKEN_FILE is empty"
command -v jq >/dev/null || die "jq not found"
api() { # <method> <path> [data]
local m="$1" p="$2" d="${3:-}"
if [ -n "$d" ]; then
curl -sS --max-time 15 -H "X-Vault-Token: $TOK" -X "$m" -d "$d" "$ADDR/v1/$p"
else
curl -sS --max-time 15 -H "X-Vault-Token: $TOK" -X "$m" "$ADDR/v1/$p"
fi
}
# --- 0. sanity: a test login must not burn a limited-use SecretID -----------
NUM_USES="$(api GET "auth/approle/role/$ROLE" | jq -r '.data.secret_id_num_uses // empty')"
[ -n "$NUM_USES" ] || die "cannot read role $ROLE (token scoped wrong, or role gone)"
VERIFY=1
if [ "$NUM_USES" != "0" ] && [ "$NUM_USES" -le 1 ] 2>/dev/null; then
VERIFY=0
log "NOTE secret_id_num_uses=$NUM_USES -- skipping verification login so it is not consumed"
fi
# --- 1. mint ---------------------------------------------------------------
RESP="$(api POST "auth/approle/role/$ROLE/secret-id" \
"{\"metadata\":\"{\\\"rotated_at\\\":\\\"$(date -u '+%FT%TZ')\\\",\\\"by\\\":\\\"rotate-approle-secret-id.sh\\\"}\"}")"
NEW_SID="$(printf '%s' "$RESP" | jq -r '.data.secret_id // empty')"
NEW_ACC="$(printf '%s' "$RESP" | jq -r '.data.secret_id_accessor // empty')"
[ -n "$NEW_SID" ] && [ -n "$NEW_ACC" ] \
|| die "mint failed: $(printf '%s' "$RESP" | jq -c '.errors // .')"
log "minted new SecretID (accessor ${NEW_ACC:0:8}...)"
# --- 2. PROVE it works before touching anything ----------------------------
if [ "$VERIFY" = "1" ]; then
RID="$(cat "$(dirname "$OUT")/role_id" 2>/dev/null)"
[ -n "$RID" ] || die "role_id file missing next to $OUT; refusing to rotate blind"
LOGIN="$(curl -sS --max-time 15 --data "$(RID="$RID" SID="$NEW_SID" python3 -c \
'import json,os;print(json.dumps({"role_id":os.environ["RID"],"secret_id":os.environ["SID"]}))')" \
"$ADDR/v1/auth/approle/login" 2>/dev/null)"
TESTTOK="$(printf '%s' "$LOGIN" | jq -r '.auth.client_token // empty')"
if [ -z "$TESTTOK" ]; then
api POST "auth/approle/role/$ROLE/secret-id-accessor/destroy" \
"{\"secret_id_accessor\":\"$NEW_ACC\"}" >/dev/null 2>&1
die "new SecretID failed verification login; destroyed it, left existing credential in place: $(printf '%s' "$LOGIN" | jq -c '.errors // .')"
fi
# don't leave a live token lying around just because we tested
curl -sS --max-time 10 -H "X-Vault-Token: $TESTTOK" -X POST \
"$ADDR/v1/auth/token/revoke-self" >/dev/null 2>&1 || true
log "verification login OK (test token revoked)"
fi
# --- 3. install atomically -------------------------------------------------
TMP="${OUT}.tmp.$$"
( umask 077; printf '%s' "$NEW_SID" > "$TMP" ) || die "cannot write $TMP"
chmod 0600 "$TMP" || die "chmod failed on $TMP"
chown "$OWNER" "$TMP" 2>/dev/null || log "WARN could not chown $TMP to $OWNER"
mv -f "$TMP" "$OUT" || die "atomic replace of $OUT failed"
log "installed new SecretID at $OUT"
# --- 4. prune superseded accessors (keep current + previous) ---------------
mapfile -t ACCS < <(api LIST "auth/approle/role/$ROLE/secret-id" | jq -r '.data.keys[]? // empty')
if [ "${#ACCS[@]}" -gt "$KEEP" ]; then
# order by creation_time, newest first
ORDERED="$(for a in "${ACCS[@]}"; do
ct="$(api POST "auth/approle/role/$ROLE/secret-id-accessor/lookup" \
"{\"secret_id_accessor\":\"$a\"}" | jq -r '.data.creation_time // empty')"
[ -n "$ct" ] && printf '%s\t%s\n' "$ct" "$a"
done | sort -r)"
n=0
while IFS=$'\t' read -r ct a; do
[ -n "$a" ] || continue
n=$((n+1))
[ "$n" -le "$KEEP" ] && continue
if api POST "auth/approle/role/$ROLE/secret-id-accessor/destroy" \
"{\"secret_id_accessor\":\"$a\"}" >/dev/null 2>&1; then
log "pruned superseded accessor ${a:0:8}... (created $ct)"
else
log "WARN could not prune accessor ${a:0:8}..."
fi
done <<< "$ORDERED"
fi
REMAIN="$(api LIST "auth/approle/role/$ROLE/secret-id" | jq -r '.data.keys | length // 0')"
log "done; $REMAIN SecretID accessor(s) live for role $ROLE"

114
scripts/send-failure-alert.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# OnFailure handler for the OpenBAO systemd timers. Invoked as:
# send-failure-alert.sh <failed-unit-name>
# by openbao-alert@.service, which the timer units reference via OnFailure=.
#
# Backups died silently for 24 days (2026-07-29..08-22) because a failed
# oneshot unit makes no noise. This makes it make noise.
#
# CIRCULAR-DEPENDENCY NOTE: the SMTP password lives in OpenBAO, but the most
# likely reason one of these units failed is that OpenBAO itself is down or
# sealed -- in which case fetching the password would fail too, and the alert
# would be lost exactly when it matters most. So:
# 1. every alert is ALWAYS appended to $ALERT_LOG first, before any network
# call, so a durable record exists even with no OpenBAO and no internet;
# 2. creds are fetched from OpenBAO when it is reachable, and cached to a
# root-only 0600 file that is used as the fallback when it is not.
# The cache is a deliberate trade-off, consistent with the house rule that
# secrets live in root-owned 0600 files or come from the store at runtime.
set -uo pipefail
UNIT="${1:-unknown.unit}"
HOST="$(hostname -s 2>/dev/null || echo pi)"
TS="$(date '+%F %T %Z')"
BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
TOKEN_FILE="/etc/openbao-alert-smtp.token"
CACHE_FILE="/etc/openbao-smtp-cache"
ALERT_LOG="/var/log/openbao-alerts.log"
SMTP_HOST="securesmtp.t-online.de"; SMTP_PORT="587"
MAIL_FROM="lutz.finsterle@t-online.de"; MAIL_TO="lutz.finsterle@t-online.de"
# --- 1. gather context ------------------------------------------------------
STATUS="$(systemctl status --no-pager --full "$UNIT" 2>&1 | head -40)"
JOURNAL="$(journalctl -u "$UNIT" -n 40 --no-pager 2>&1 | tail -40)"
RESULT="$(systemctl show -p Result --value "$UNIT" 2>/dev/null)"
EXECMAIN="$(systemctl show -p ExecMainStatus --value "$UNIT" 2>/dev/null)"
BODY="OpenBAO maintenance unit FAILED on ${HOST}.
Unit: ${UNIT}
When: ${TS}
Result: ${RESULT:-unknown} (exit status ${EXECMAIN:-?})
This unit is part of the OpenBAO safety net (raft snapshots, TLS cert renewal,
scoped-token renewal). A failure here is silent by default -- if you are reading
this, the alerting is doing its job. Investigate promptly: a lapsed token or a
missed snapshot degrades quietly and is easy to miss for weeks.
--- systemctl status ---
${STATUS}
--- last 40 journal lines ---
${JOURNAL}
"
# --- 2. durable local record FIRST (never depends on OpenBAO or the network) --
{
echo "===== ${TS} ${UNIT} ====="
printf '%s\n\n' "$BODY"
} >> "$ALERT_LOG" 2>/dev/null
chmod 0600 "$ALERT_LOG" 2>/dev/null || true
# --- 3. resolve SMTP creds: OpenBAO first, cached copy as fallback -----------
user=""; pass=""; cred_src=""
if [ -r "$TOKEN_FILE" ]; then
tok="$(cat "$TOKEN_FILE" 2>/dev/null)"
if [ -n "$tok" ]; then
resp="$(curl -sS --max-time 10 -H "X-Vault-Token: $tok" \
"$BAO_ADDR/v1/secret/data/smtp/healthcheck" 2>/dev/null)"
user="$(printf '%s' "$resp" | jq -r '.data.data.username // empty' 2>/dev/null)"
pass="$(printf '%s' "$resp" | jq -r '.data.data.password // empty' 2>/dev/null)"
if [ -n "$user" ] && [ -n "$pass" ]; then
cred_src="openbao"
# refresh the offline fallback copy
umask 077
printf '%s\n%s\n' "$user" "$pass" > "${CACHE_FILE}.tmp" 2>/dev/null \
&& chmod 0600 "${CACHE_FILE}.tmp" 2>/dev/null \
&& mv -f "${CACHE_FILE}.tmp" "$CACHE_FILE" 2>/dev/null
fi
fi
fi
if { [ -z "$user" ] || [ -z "$pass" ]; } && [ -r "$CACHE_FILE" ]; then
user="$(sed -n 1p "$CACHE_FILE" 2>/dev/null)"
pass="$(sed -n 2p "$CACHE_FILE" 2>/dev/null)"
cred_src="cache (OpenBAO unreachable -- it may itself be the problem)"
fi
if [ -z "$user" ] || [ -z "$pass" ]; then
echo "${TS} ${UNIT}: ALERT EMAIL NOT SENT -- no SMTP creds from OpenBAO or cache" >> "$ALERT_LOG"
logger -t openbao-alert "FAILED unit ${UNIT}; could not send email (no SMTP creds)"
exit 0 # never fail the handler: that would just add noise, not signal
fi
# --- 4. send ----------------------------------------------------------------
msg="$(printf 'From: %s\r\nTo: %s\r\nSubject: [ALERT] %s: %s failed\r\nDate: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n' \
"$MAIL_FROM" "$MAIL_TO" "$HOST" "$UNIT" "$(date -R)" "$BODY (creds via ${cred_src})")"
if printf '%s' "$msg" | curl -sS --max-time 30 --ssl-reqd \
--url "smtp://$SMTP_HOST:$SMTP_PORT" --user "$user:$pass" \
--mail-from "$MAIL_FROM" --mail-rcpt "$MAIL_TO" --upload-file - 2>>"$ALERT_LOG"; then
echo "${TS} ${UNIT}: alert email sent (creds via ${cred_src})" >> "$ALERT_LOG"
logger -t openbao-alert "FAILED unit ${UNIT}; alert email sent"
else
echo "${TS} ${UNIT}: ALERT EMAIL FAILED TO SEND (creds via ${cred_src})" >> "$ALERT_LOG"
logger -t openbao-alert "FAILED unit ${UNIT}; alert email could NOT be sent"
fi
# Keep the log bounded.
tail -n 2000 "$ALERT_LOG" > "${ALERT_LOG}.tmp" 2>/dev/null && mv -f "${ALERT_LOG}.tmp" "$ALERT_LOG" 2>/dev/null
chmod 0600 "$ALERT_LOG" 2>/dev/null || true
exit 0

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Email alert for failed OpenBAO maintenance unit %i
# Deliberately no OnFailure= here: if the alerter itself fails it must not
# recurse. It exits 0 on send failure and logs to /var/log/openbao-alerts.log.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# %i (escaped instance), NOT %I: unit names contain '-', which systemd's
# unescaping would turn into '/' (openbao-backup.service -> openbao/backup).
ExecStart=/home/lutz/Projects/OpenBAO/scripts/send-failure-alert.sh %i
# Runs as root: reads /etc/openbao-alert-smtp.token and journalctl -u.

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Rotate the demo-app AppRole SecretID before its 30d TTL expires
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
OnFailure=openbao-alert@%n.service
[Service]
Type=oneshot
# Runs as root: reads /etc/openbao-approle-rotate.token and writes the
# SecretID back as lutz:lutz 0600.
ExecStart=/home/lutz/Projects/OpenBAO/scripts/rotate-approle-secret-id.sh

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Weekly rotation of the demo-app AppRole SecretID
[Timer]
# Weekly against a 30d secret_id_ttl: 4x margin, so three consecutive failed
# rotations can happen before the credential actually expires -- and the first
# failure already mails an alert via OnFailure=.
OnCalendar=Sun *-*-* 03:10:00
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target

View File

@@ -3,6 +3,7 @@ Description=Raft snapshot backup of both OpenBAO instances (main + unsealer)
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
OnFailure=openbao-alert@%n.service
[Service]
Type=oneshot

View File

@@ -3,6 +3,7 @@ Description=Renew openbao.famfi.home cert from OpenBAO PKI and reload Traefik
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
OnFailure=openbao-alert@%n.service
[Service]
Type=oneshot

View File

@@ -3,10 +3,11 @@ Description=Renew scoped OpenBAO app tokens so periodic tokens never lapse
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
OnFailure=openbao-alert@%n.service
[Service]
Type=oneshot
User=lutz
Group=lutz
# Runs as root: must read the root-owned /etc/openbao-*.token infra tokens
# (backup x2, cert-renew) alongside the user-owned ~lutz/.config/openbao ones.
ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-app-tokens.sh
# Renews ~/.config/openbao/*.token via auth/token/renew-self. Best-effort.
# Renews both sets via auth/token/renew-self. Best-effort.