Compare commits
4 Commits
f2919cf50a
...
471692c283
| Author | SHA1 | Date | |
|---|---|---|---|
| 471692c283 | |||
| ee0b8c818f | |||
| c5ff1a3fca | |||
| 123e65ee46 |
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
openbao:
|
openbao:
|
||||||
image: openbao/openbao:2.5.5
|
image: openbao/openbao:2.6.2
|
||||||
container_name: openbao
|
container_name: openbao
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The image entrypoint already runs `bao server -config=/openbao/config`
|
# 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
|
# Minimal sidecar instance that provides transit auto-unseal for the main
|
||||||
# node above. Shamir-sealed itself; only reachable on the internal network.
|
# node above. Shamir-sealed itself; only reachable on the internal network.
|
||||||
openbao-unsealer:
|
openbao-unsealer:
|
||||||
image: openbao/openbao:2.5.5
|
image: openbao/openbao:2.6.2
|
||||||
container_name: openbao-unsealer
|
container_name: openbao-unsealer
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server
|
command: server
|
||||||
|
|||||||
31
policies/approle-rotate.hcl
Normal file
31
policies/approle-rotate.hcl
Normal 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"]
|
||||||
|
}
|
||||||
@@ -3,27 +3,70 @@
|
|||||||
# tokens live only as long as they're renewed within their period; nothing was
|
# 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.
|
# 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
|
# Covers TWO sets of tokens:
|
||||||
# token logs a failure but never aborts the rest. No secrets are printed.
|
# 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
|
set -uo pipefail
|
||||||
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||||
DIR="$HOME/.config/openbao"
|
PROJECT_DIR="/home/lutz/Projects/OpenBAO"
|
||||||
LOG="$DIR/token-renew.log"
|
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')"
|
ts="$(date '+%F %T %Z')"
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
|
|
||||||
renewed=0; failed=0
|
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")"
|
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)"
|
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)"
|
ttl="$(printf '%s' "$resp" | jq -r '.auth.lease_duration // empty' 2>/dev/null)"
|
||||||
if [ -n "$ttl" ]; then
|
if [ -n "$ttl" ]; then ok "$name" "$ttl"
|
||||||
echo "$ts $name renewed ttl=${ttl}s" >> "$LOG"; renewed=$((renewed+1))
|
else bad "$name" "$(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null)"; fi
|
||||||
else
|
}
|
||||||
echo "$ts $name RENEW FAILED: $(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null)" >> "$LOG"; failed=$((failed+1))
|
|
||||||
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
|
done
|
||||||
|
|
||||||
echo "$ts summary: renewed=$renewed failed=$failed" >> "$LOG"
|
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 ]
|
[ "$failed" -eq 0 ]
|
||||||
|
|||||||
114
scripts/rotate-approle-secret-id.sh
Executable file
114
scripts/rotate-approle-secret-id.sh
Executable 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
114
scripts/send-failure-alert.sh
Executable 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
|
||||||
13
systemd/openbao-alert@.service
Normal file
13
systemd/openbao-alert@.service
Normal 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.
|
||||||
12
systemd/openbao-approle-rotate.service
Normal file
12
systemd/openbao-approle-rotate.service
Normal 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
|
||||||
13
systemd/openbao-approle-rotate.timer
Normal file
13
systemd/openbao-approle-rotate.timer
Normal 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
|
||||||
@@ -3,6 +3,7 @@ Description=Raft snapshot backup of both OpenBAO instances (main + unsealer)
|
|||||||
After=network-online.target docker.service
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
Requires=docker.service
|
Requires=docker.service
|
||||||
|
OnFailure=openbao-alert@%n.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Description=Renew openbao.famfi.home cert from OpenBAO PKI and reload Traefik
|
|||||||
After=network-online.target docker.service
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
Requires=docker.service
|
Requires=docker.service
|
||||||
|
OnFailure=openbao-alert@%n.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ Description=Renew scoped OpenBAO app tokens so periodic tokens never lapse
|
|||||||
After=network-online.target docker.service
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
Requires=docker.service
|
Requires=docker.service
|
||||||
|
OnFailure=openbao-alert@%n.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=lutz
|
# Runs as root: must read the root-owned /etc/openbao-*.token infra tokens
|
||||||
Group=lutz
|
# (backup x2, cert-renew) alongside the user-owned ~lutz/.config/openbao ones.
|
||||||
ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-app-tokens.sh
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user