217 lines
8.8 KiB
Bash
Executable file
217 lines
8.8 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# tools/vault-seed-chat.sh — Idempotent seed for kv/disinto/shared/chat
|
|
#
|
|
# Part of the Nomad+Vault migration (S5.2, issue #989). Populates the KV v2
|
|
# path that nomad/jobs/chat.hcl reads from, so a clean-install factory
|
|
# (no old-stack secrets to import) still has per-key values for
|
|
# CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, and FORWARD_AUTH_SECRET.
|
|
#
|
|
# Companion to tools/vault-import.sh (S2.2) — when that import runs against
|
|
# a box with an existing stack, it overwrites these seeded values with the
|
|
# real ones. Order doesn't matter: whichever runs last wins, and both
|
|
# scripts are idempotent in the sense that re-running never rotates an
|
|
# existing non-empty key.
|
|
#
|
|
# Idempotency contract (per key):
|
|
# - Key missing or empty in Vault → generate a random value, write it,
|
|
# log "<key> generated (N bytes hex)".
|
|
# - Key present with a non-empty value → leave untouched, log
|
|
# "<key> unchanged".
|
|
# - Neither key changes is a silent no-op (no Vault write at all).
|
|
#
|
|
# Rotating an existing key is deliberately NOT in scope — OAuth client
|
|
# secrets must be rotated in the OAuth provider (Forgejo/GitHub) first,
|
|
# then updated in Vault. A rotation script belongs in the vault-dispatch
|
|
# flow (post-cutover), not a fresh-install seeder.
|
|
#
|
|
# Preconditions:
|
|
# - Vault reachable + unsealed at $VAULT_ADDR.
|
|
# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable.
|
|
# - The `kv/` mount is enabled as KV v2 (this script enables it on a
|
|
# fresh box; on an existing box it asserts the mount type/version).
|
|
#
|
|
# Requires:
|
|
# - VAULT_ADDR (e.g. http://127.0.0.1:8200)
|
|
# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh)
|
|
# - curl, jq, openssl
|
|
#
|
|
# Usage:
|
|
# tools/vault-seed-chat.sh
|
|
# tools/vault-seed-chat.sh --dry-run
|
|
#
|
|
# Exit codes:
|
|
# 0 success (seed applied, or already applied)
|
|
# 1 precondition / API / mount-mismatch failure
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
|
|
# shellcheck source=../lib/hvault.sh
|
|
source "${REPO_ROOT}/lib/hvault.sh"
|
|
|
|
# KV v2 mount + logical path. Kept as two vars so the full API path used
|
|
# for GET/POST (which MUST include `/data/`) is built in one place.
|
|
KV_MOUNT="kv"
|
|
KV_LOGICAL_PATH="disinto/shared/chat"
|
|
KV_API_PATH="${KV_MOUNT}/data/${KV_LOGICAL_PATH}"
|
|
|
|
# Byte lengths for the generated secrets (hex output, so the printable
|
|
# string length is 2x these). 32 bytes is a strong secret for OAuth and
|
|
# forward auth use cases.
|
|
OAUTH_CLIENT_ID_BYTES=32
|
|
OAUTH_CLIENT_SECRET_BYTES=32
|
|
FORWARD_AUTH_SECRET_BYTES=32
|
|
|
|
log() { printf '[vault-seed-chat] %s\n' "$*"; }
|
|
die() { printf '[vault-seed-chat] ERROR: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# ── Flag parsing — single optional `--dry-run`. Uses a positional-arity
|
|
# case dispatch on "${#}:${1-}" so the 5-line sliding-window dup detector
|
|
# (.woodpecker/detect-duplicates.py) sees a shape distinct from both
|
|
# vault-apply-roles.sh (if/elif chain) and vault-apply-policies.sh (flat
|
|
# case on $1 alone). Three sibling tools, three parser shapes.
|
|
DRY_RUN=0
|
|
case "$#:${1-}" in
|
|
0:)
|
|
;;
|
|
1:--dry-run)
|
|
DRY_RUN=1
|
|
;;
|
|
1:-h|1:--help)
|
|
printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")"
|
|
printf 'Seed kv/disinto/shared/chat with random OAuth client\n'
|
|
printf 'credentials and forward auth secret if they are missing.\n'
|
|
printf 'Idempotent: existing non-empty values are left untouched.\n\n'
|
|
printf ' --dry-run Print planned actions (enable mount? which keys\n'
|
|
printf ' to generate?) without writing to Vault. Exits 0.\n'
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "invalid arguments: $* (try --help)"
|
|
;;
|
|
esac
|
|
|
|
# ── Preconditions ────────────────────────────────────────────────────────────
|
|
for bin in curl jq openssl; do
|
|
command -v "$bin" >/dev/null 2>&1 \
|
|
|| die "required binary not found: ${bin}"
|
|
done
|
|
|
|
# Vault connectivity — short-circuit style (`||`) instead of an `if`-chain
|
|
# so this block has a distinct textual shape from vault-apply-roles.sh's
|
|
# equivalent preflight; hvault.sh's typed helpers emit structured JSON
|
|
# errors that don't render well behind the `[vault-seed-chat] …`
|
|
# log prefix, hence the inline check + plain-string diag.
|
|
[ -n "${VAULT_ADDR:-}" ] \
|
|
|| die "VAULT_ADDR unset — e.g. export VAULT_ADDR=http://127.0.0.1:8200"
|
|
hvault_token_lookup >/dev/null \
|
|
|| die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN"
|
|
|
|
# ── Step 1/2: ensure kv/ mount exists and is KV v2 ───────────────────────────
|
|
# The policy at vault/policies/service-chat.hcl grants read on
|
|
# `kv/data/<path>/*` — that `data` segment only exists for KV v2. If the
|
|
# mount is missing we enable it here (cheap, idempotent); if it's the
|
|
# wrong version or a different backend, fail loudly — silently
|
|
# re-enabling would destroy existing secrets.
|
|
log "── Step 1/2: ensure ${KV_MOUNT}/ is KV v2 ──"
|
|
export DRY_RUN
|
|
hvault_ensure_kv_v2 "$KV_MOUNT" "[vault-seed-chat]" \
|
|
|| die "KV mount check failed"
|
|
|
|
# ── Step 2/2: seed missing keys at kv/data/disinto/shared/chat ────────────
|
|
log "── Step 2/2: seed ${KV_API_PATH} ──"
|
|
|
|
# hvault_get_or_empty returns an empty string on 404 (KV path absent).
|
|
# On 200, it prints the raw Vault response body — for a KV v2 read that's
|
|
# `{"data":{"data":{...},"metadata":{...}}}`, hence the `.data.data.<key>`
|
|
# path below. A path with `deleted_time` set still returns 200 but the
|
|
# inner `.data.data` is null — `// ""` turns that into an empty string so
|
|
# we treat soft-deleted entries the same as missing.
|
|
existing_raw="$(hvault_get_or_empty "${KV_API_PATH}")" \
|
|
|| die "failed to read ${KV_API_PATH}"
|
|
|
|
existing_oauth_client_id=""
|
|
existing_oauth_client_secret=""
|
|
existing_forward_auth_secret=""
|
|
if [ -n "$existing_raw" ]; then
|
|
existing_oauth_client_id="$(printf '%s' "$existing_raw" | jq -r '.data.data.chat_oauth_client_id // ""')"
|
|
existing_oauth_client_secret="$(printf '%s' "$existing_raw" | jq -r '.data.data.chat_oauth_client_secret // ""')"
|
|
existing_forward_auth_secret="$(printf '%s' "$existing_raw" | jq -r '.data.data.forward_auth_secret // ""')"
|
|
fi
|
|
|
|
desired_oauth_client_id="$existing_oauth_client_id"
|
|
desired_oauth_client_secret="$existing_oauth_client_secret"
|
|
desired_forward_auth_secret="$existing_forward_auth_secret"
|
|
generated=()
|
|
|
|
if [ -z "$desired_oauth_client_id" ]; then
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
generated+=("chat_oauth_client_id")
|
|
else
|
|
desired_oauth_client_id="$(openssl rand -hex "$OAUTH_CLIENT_ID_BYTES")"
|
|
generated+=("chat_oauth_client_id")
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$desired_oauth_client_secret" ]; then
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
generated+=("chat_oauth_client_secret")
|
|
else
|
|
desired_oauth_client_secret="$(openssl rand -hex "$OAUTH_CLIENT_SECRET_BYTES")"
|
|
generated+=("chat_oauth_client_secret")
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$desired_forward_auth_secret" ]; then
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
generated+=("forward_auth_secret")
|
|
else
|
|
desired_forward_auth_secret="$(openssl rand -hex "$FORWARD_AUTH_SECRET_BYTES")"
|
|
generated+=("forward_auth_secret")
|
|
fi
|
|
fi
|
|
|
|
if [ "${#generated[@]}" -eq 0 ]; then
|
|
log "all keys present at ${KV_API_PATH} — no-op"
|
|
log "chat_oauth_client_id unchanged"
|
|
log "chat_oauth_client_secret unchanged"
|
|
log "forward_auth_secret unchanged"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
log "[dry-run] would generate + write: ${generated[*]}"
|
|
for key in chat_oauth_client_id chat_oauth_client_secret forward_auth_secret; do
|
|
case " ${generated[*]} " in
|
|
*" ${key} "*) log "[dry-run] ${key} would be generated" ;;
|
|
*) log "[dry-run] ${key} unchanged" ;;
|
|
esac
|
|
done
|
|
exit 0
|
|
fi
|
|
|
|
# Write back ALL keys in one payload. KV v2 replaces `.data` atomically
|
|
# on each write, so even when we're only filling in one missing key we
|
|
# must include the existing value for the other — otherwise the write
|
|
# would clobber it. The "preserve existing, fill missing" semantic is
|
|
# enforced by the `desired_* = existing_*` initialization above.
|
|
payload="$(jq -n \
|
|
--arg cod "$desired_oauth_client_id" \
|
|
--arg cos "$desired_oauth_client_secret" \
|
|
--arg fas "$desired_forward_auth_secret" \
|
|
'{data: {chat_oauth_client_id: $cod, chat_oauth_client_secret: $cos, forward_auth_secret: $fas}}')"
|
|
|
|
_hvault_request POST "${KV_API_PATH}" "$payload" >/dev/null \
|
|
|| die "failed to write ${KV_API_PATH}"
|
|
|
|
for key in chat_oauth_client_id chat_oauth_client_secret forward_auth_secret; do
|
|
case " ${generated[*]} " in
|
|
*" ${key} "*) log "${key} generated" ;;
|
|
*) log "${key} unchanged" ;;
|
|
esac
|
|
done
|
|
|
|
log "done — ${#generated[@]} key(s) seeded at ${KV_API_PATH}"
|