Files
homelab/k8s/talos-iam/setup_vault.sh
T
Story Crater Bot 831dd50805 k8s/iam: add cloudnativepg postgres and vault + authentik
- PostgreSQL 3-replica HA with pgvector
- Vault S3 storage backend (MinIO)
- Authentik federated OIDC provider
- Vault auto-unseal via postStart hook
2026-08-18 15:08:00 -07:00

528 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# k8s/talos-iam/setup_vault.sh
# Deploys HashiCorp Vault into the iam namespace, initialises it, and wires
# the JWT auth backend to Authentik so the talos-cli secrets subcommand works.
#
# Prerequisites:
# - Authentik already running in the iam namespace (run setup_talos_iam.sh first)
# - kubectl configured (KUBECONFIG → cluster-config/kubeconfig)
# - helm >= 3.x
# - vault CLI installed locally (https://developer.hashicorp.com/vault/downloads)
# - talos-iam/.env containing (see .env.example):
# MINIO_ROOT_USER=...
# MINIO_ROOT_PASSWORD=...
# The script seeds initial secrets and needs read access to MinIO credentials.
#
# What this script does (in order):
# 1. Create vault-minio-creds K8s Secret (MinIO creds for the S3 backend)
# 2. Helm install hashicorp/vault
# 3. Wait for vault pod to be Running
# 4. vault operator init → capture unseal keys + root token
# 5. vault operator unseal (3 of 5 key shares)
# 6. Login with root token
# 7. Enable KV v2 secret engine at secret/
# 8. Enable JWT auth backend, configure with Authentik JWKS
# 9. Write policies (shell-secrets, mcp-readonly, cluster-admin)
# 10. Create JWT roles (shell, mcp, cluster)
# 11. Seed initial secrets from .env
# 12. Register talos-cli-shell OIDC app in Authentik via API
# 13. Print next steps
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
KUBECONFIG="${KUBECONFIG:-${REPO_ROOT}/cluster-config/kubeconfig}"
export KUBECONFIG
NAMESPACE=storage
RELEASE=vault
AUTHENTIK_URL="http://authentik-server.iam.svc.cluster.local"
VAULT_ADDR_CLUSTER="http://vault.storage.svc.cluster.local:8200"
# Local access via port-forward during setup
VAULT_PORT_FWD_ADDR="http://127.0.0.1:8200"
UNSEAL_KEYS_FILE="$HOME/.vault-data/.vault-init.json" # KEEP OFFLINE after setup
# ── Failure surfacing ─────────────────────────────────────────────────────────
CURRENT_STEP="init"
on_err() {
local rc=$?
echo "[ERROR] step '${CURRENT_STEP}' failed (exit ${rc} at line ${BASH_LINENO[0]})" >&2
exit "${rc}"
}
trap on_err ERR
step() { CURRENT_STEP="$1"; echo ""; echo "==> $2"; }
# ── Load credentials ──────────────────────────────────────────────────────────
step load_env "Loading credentials from talos-iam/.env..."
if [[ -f "$HOME/.authentik/.env" ]]; then
# shellcheck source=/dev/null
source "$HOME/.authentik/.env"
fi
for var in MINIO_ROOT_USER MINIO_ROOT_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: ${var} is not set — export it or add it to talos-iam/.env" >&2
exit 1
fi
done
# ── 1. MinIO credentials secret for Vault S3 backend ─────────────────────────
step minio_secret "Creating vault-minio-creds secret..."
kubectl create secret generic vault-minio-creds \
--namespace="${NAMESPACE}" \
--from-literal=access_key="${MINIO_ROOT_USER}" \
--from-literal=secret_key="${MINIO_ROOT_PASSWORD}" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 1b. Placeholder vault-unseal-keys secret (required before Helm install) ───
# extraSecretEnvironmentVars references vault-unseal-keys at pod start time,
# but the real keys only exist after vault operator init. Create empty placeholders
# so the pod starts; step vault_unseal_secret overwrites them with real values.
step vault_unseal_placeholder "Creating placeholder vault-unseal-keys secret..."
kubectl create secret generic vault-unseal-keys \
--namespace="${NAMESPACE}" \
--from-literal=key1="" \
--from-literal=key2="" \
--from-literal=key3="" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 1c. Register talos-cli-shell OIDC app in Authentik ───────────────────────
# Must exist before Helm install so Vault can validate the JWKS URL at jwt/config time.
step authentik_app "Registering talos-cli-shell OIDC provider in Authentik..."
AUTHENTIK_API="http://127.0.0.1:7000/api/v3"
kubectl port-forward -n "${NAMESPACE}" svc/authentik-server 7000:80 &
AK_PF_PID=$!
trap 'kill ${AK_PF_PID} 2>/dev/null' EXIT
sleep 3
# Fetch required flow PKs from Authentik
_auth_flow_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/flows/instances/?slug=default-provider-authorization-implicit-consent" \
| jq -r '.results[0].pk // empty')
_inval_flow_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/flows/instances/?slug=default-provider-invalidation-flow" \
| jq -r '.results[0].pk // empty')
if [[ -z "${_auth_flow_pk}" || -z "${_inval_flow_pk}" ]]; then
echo "ERROR: required Authentik flows not found — is provision_oidc.py already run?" >&2
exit 1
fi
_signing_key_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/crypto/certificatekeypairs/?has_key=true&page_size=1" \
| jq -r '.results[0].pk // empty')
if [[ -z "${_signing_key_pk}" ]]; then
echo "ERROR: no signing key found in Authentik — create one under System → Certificates" >&2
exit 1
fi
_provider_payload=$(jq -n \
--arg auth_flow "${_auth_flow_pk}" \
--arg inval_flow "${_inval_flow_pk}" \
--arg signing_key "${_signing_key_pk}" \
'{
name: "talos-cli-shell",
client_type: "confidential",
grant_types: ["client_credentials"],
token_validity: "minutes=5",
sub_mode: "hashed_user_id",
include_claims_in_id_token: true,
audience: "vault",
authorization_flow: $auth_flow,
invalidation_flow: $inval_flow,
signing_key: $signing_key,
redirect_uris: []
}')
_existing_provider=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/providers/oauth2/?name=talos-cli-shell" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_provider}" ]]; then
echo "Provider talos-cli-shell exists (pk=${_existing_provider}) — patching config..."
PROVIDER_PK="${_existing_provider}"
curl -sf -X PATCH "${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_provider_payload}" > /dev/null
CLIENT_SECRET="(unchanged — retrieve from Authentik admin UI if needed)"
else
PROVIDER_PK=$(curl -sf -X POST "${AUTHENTIK_API}/providers/oauth2/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_provider_payload}" | jq -r '.pk')
CLIENT_SECRET=$(curl -sf -X POST \
"${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/set_secret/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' | jq -r '.client_secret')
echo "Created provider pk=${PROVIDER_PK}"
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ Authentik OIDC app registered. │"
echo "│ Client ID: talos-cli-shell │"
echo "│ Client Secret: ${CLIENT_SECRET} │"
echo "│ Store this secret — it will not be shown again. │"
echo "└─────────────────────────────────────────────────────────────────┘"
fi
# Idempotent: ensure the application exists and is bound to the provider.
# Runs on both create and patch paths so re-runs always produce a consistent state.
_existing_app=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/core/applications/?slug=talos-cli-shell" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_app}" ]]; then
echo "Application talos-cli-shell exists (pk=${_existing_app}) — ensuring provider binding..."
curl -sf -X PATCH "${AUTHENTIK_API}/core/applications/${_existing_app}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"provider\": ${PROVIDER_PK}}" > /dev/null
else
curl -sf -X POST "${AUTHENTIK_API}/core/applications/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson pk "${PROVIDER_PK}" \
'{"name":"talos-cli-shell","slug":"talos-cli-shell","provider":$pk}')" > /dev/null
echo "Application talos-cli-shell created and bound to provider pk=${PROVIDER_PK}"
fi
kill "${AK_PF_PID}" 2>/dev/null || true
unset AK_PF_PID
# ── 1d. Register vault-browser OIDC app in Authentik (browser / UI login) ────
# Separate from talos-cli-shell: this uses authorization_code grant so humans
# can log in via the Vault UI or `vault login -method=oidc`.
step authentik_vault_browser "Registering vault-browser OIDC provider in Authentik..."
AUTHENTIK_API_BROWSER="http://127.0.0.1:7000/api/v3"
kubectl port-forward -n iam svc/authentik-server 7000:80 &
AK_PF2_PID=$!
trap 'kill ${AK_PF2_PID} 2>/dev/null' EXIT
sleep 3
_auth_flow_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-authorization-implicit-consent" \
| jq -r '.results[0].pk // empty')
_inval_flow_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-invalidation-flow" \
| jq -r '.results[0].pk // empty')
_signing_key_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/crypto/certificatekeypairs/?has_key=true&page_size=1" \
| jq -r '.results[0].pk // empty')
_vault_browser_payload=$(jq -n \
--arg auth_flow "${_auth_flow_pk2}" \
--arg inval_flow "${_inval_flow_pk2}" \
--arg signing_key "${_signing_key_pk2}" \
'{
name: "vault-browser",
client_type: "confidential",
client_id: "vault-browser",
grant_types: ["authorization_code", "refresh"],
token_validity: "hours=8",
sub_mode: "hashed_user_id",
include_claims_in_id_token: true,
authorization_flow: $auth_flow,
invalidation_flow: $inval_flow,
signing_key: $signing_key,
redirect_uris: [
{"matching_mode": "strict", "url": "http://vault.riotpiao.homelab.com/ui/vault/auth/oidc/oidc/callback"},
{"matching_mode": "strict", "url": "http://localhost:8250/oidc/callback"}
]
}')
_existing_vault_browser=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/?name=vault-browser" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_vault_browser}" ]]; then
echo "Provider vault-browser exists (pk=${_existing_vault_browser}) — patching..."
VAULT_BROWSER_PK="${_existing_vault_browser}"
curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_vault_browser_payload}" > /dev/null
# Re-fetch the client secret from the detail endpoint (list responses mask it)
VAULT_BROWSER_CLIENT_SECRET=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \
| jq -r '.client_secret')
else
VAULT_BROWSER_PK=$(curl -sf -X POST "${AUTHENTIK_API_BROWSER}/providers/oauth2/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_vault_browser_payload}" | jq -r '.pk')
VAULT_BROWSER_CLIENT_SECRET=$(curl -sf -X POST \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/set_secret/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' | jq -r '.client_secret')
echo "Created vault-browser provider pk=${VAULT_BROWSER_PK}"
fi
# Bind application
_existing_vault_app=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/core/applications/?slug=vault-browser" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_vault_app}" ]]; then
curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/core/applications/${_existing_vault_app}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"provider\": ${VAULT_BROWSER_PK}}" > /dev/null
echo "Application vault-browser updated."
else
curl -sf -X POST "${AUTHENTIK_API_BROWSER}/core/applications/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson pk "${VAULT_BROWSER_PK}" \
'{"name":"vault-browser","slug":"vault-browser","provider":$pk}')" > /dev/null
echo "Application vault-browser created."
fi
kill "${AK_PF2_PID}" 2>/dev/null || true
unset AK_PF2_PID
# ── 2. Ensure vault bucket exists in MinIO ────────────────────────────────────
step minio_bucket "Ensuring 'vault' bucket exists in MinIO..."
kubectl run vault-bucket-init --rm -i --restart=Never \
--namespace=storage \
--image=minio/mc:latest \
--env="MC_HOST_local=http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@minio.storage.svc.cluster.local:9000" \
--command -- mc mb --ignore-existing local/vault
# ── 3. Helm install Vault ─────────────────────────────────────────────────────
step helm_install "Installing HashiCorp Vault via Helm..."
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update hashicorp
helm upgrade --install "${RELEASE}" hashicorp/vault \
--namespace "${NAMESPACE}" \
--values "${SCRIPT_DIR}/vault-values.yaml" \
--wait --timeout=120s
# ── 4. Wait for vault pod ─────────────────────────────────────────────────────
step wait_pod "Waiting for vault-0 pod to be Running..."
kubectl wait pod/vault-0 \
--namespace="${NAMESPACE}" \
--for=condition=Ready=false \
--timeout=60s 2>/dev/null || true # pod starts uninitialized (not Ready) — that's expected
# Give it a moment to bind the port
sleep 5
# ── 5. Port-forward for init/unseal ──────────────────────────────────────────
step port_forward "Starting port-forward to vault-0 on 127.0.0.1:8200..."
kubectl port-forward -n "${NAMESPACE}" pod/vault-0 8200:8200 &
PF_PID=$!
trap 'kill ${PF_PID} 2>/dev/null; on_err' ERR
trap 'kill ${PF_PID} 2>/dev/null' EXIT
sleep 3
export VAULT_ADDR="${VAULT_PORT_FWD_ADDR}"
# ── 6. Init ───────────────────────────────────────────────────────────────────
step vault_init "Initialising Vault (5 key shares, threshold 3)..."
if vault status 2>/dev/null | grep -q "Initialized.*true"; then
echo "Vault is already initialised — skipping init."
else
vault operator init \
-key-shares=5 \
-key-threshold=3 \
-format=json > "${UNSEAL_KEYS_FILE}"
chmod 600 "${UNSEAL_KEYS_FILE}"
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ IMPORTANT: unseal keys + root token saved to: │"
echo "│ ${UNSEAL_KEYS_FILE}"
echo "│ Move this file OFFLINE (USB / password manager) immediately. │"
echo "└─────────────────────────────────────────────────────────────────┘"
fi
# ── 6b. Store unseal keys in K8s Secret for auto-unseal on restart ───────────
step vault_unseal_secret "Storing 3 unseal keys in vault-unseal-keys Secret..."
kubectl create secret generic vault-unseal-keys \
--namespace="${NAMESPACE}" \
--from-literal=key1="$(jq -r '.unseal_keys_b64[0]' "${UNSEAL_KEYS_FILE}")" \
--from-literal=key2="$(jq -r '.unseal_keys_b64[1]' "${UNSEAL_KEYS_FILE}")" \
--from-literal=key3="$(jq -r '.unseal_keys_b64[2]' "${UNSEAL_KEYS_FILE}")" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 7. Unseal ─────────────────────────────────────────────────────────────────
step vault_unseal "Unsealing Vault (3 of 5 shares)..."
if vault status 2>/dev/null | grep -q "Sealed.*false"; then
echo "Vault is already unsealed — skipping."
else
for i in 0 1 2; do
KEY=$(jq -r ".unseal_keys_b64[${i}]" "${UNSEAL_KEYS_FILE}")
vault operator unseal "${KEY}"
done
fi
# ── 8. Login with root token ──────────────────────────────────────────────────
step vault_login "Logging in with root token..."
ROOT_TOKEN=$(jq -r ".root_token" "${UNSEAL_KEYS_FILE}")
vault login "${ROOT_TOKEN}"
# ── 9. Enable KV v2 ──────────────────────────────────────────────────────────
step kv_enable "Enabling KV v2 at secret/..."
vault secrets enable -path=secret kv-v2 2>/dev/null || echo "KV v2 already enabled."
# ── 10. Enable JWT auth backend ───────────────────────────────────────────────
step jwt_enable "Enabling JWT auth backend..."
vault auth enable jwt 2>/dev/null || echo "JWT auth already enabled."
JWKS_URL="${AUTHENTIK_URL}/application/o/talos-cli-shell/jwks/"
vault write auth/jwt/config \
jwks_url="${JWKS_URL}" \
default_role="shell"
echo "JWT auth configured with JWKS at: ${JWKS_URL}"
# ── 11. Write policies ────────────────────────────────────────────────────────
step policies "Writing Vault policies..."
vault policy write shell-secrets - <<'EOF'
# shell-secrets: read MCP keys and cloud tokens for interactive shell sessions
path "secret/data/mcp/*" {
capabilities = ["read"]
}
path "secret/data/cloud/*" {
capabilities = ["read"]
}
EOF
vault policy write mcp-readonly - <<'EOF'
# mcp-readonly: each MCP server reads only its own path
path "secret/data/mcp/{{identity.entity.aliases.*.metadata.client_id}}" {
capabilities = ["read"]
}
EOF
vault policy write cluster-admin - <<'EOF'
# cluster-admin: read cluster service credentials (MinIO, Grafana, etc.)
path "secret/data/cluster/*" {
capabilities = ["read", "update"]
}
path "secret/data/mcp/*" {
capabilities = ["read", "create", "update", "delete"]
}
path "secret/data/cloud/*" {
capabilities = ["read", "create", "update", "delete"]
}
EOF
# ── 12. Create JWT roles ──────────────────────────────────────────────────────
step jwt_roles "Creating JWT roles..."
# shell role: interactive shell sessions via talos-cli
vault write auth/jwt/role/shell \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
policies="shell-secrets" \
ttl=4h \
max_ttl=8h
# mcp role: MCP server processes (short TTL, non-renewable)
vault write auth/jwt/role/mcp \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
policies="mcp-readonly" \
ttl=1h \
max_ttl=1h
# cluster role: admin operations (bootstrap scripts, rotation jobs)
vault write auth/jwt/role/cluster - <<'EOF'
{
"role_type": "jwt",
"bound_audiences": ["vault"],
"user_claim": "sub",
"bound_claims": {"groups": ["homelab-admins"]},
"token_policies": ["cluster-admin"],
"token_ttl": "1h"
}
EOF
# ── 13. Enable OIDC auth (browser / UI login via Authentik) ──────────────────
step oidc_enable "Enabling OIDC auth method for browser login..."
vault auth enable oidc 2>/dev/null || echo "OIDC auth already enabled."
vault write auth/oidc/config \
oidc_discovery_url="http://authentik.riotpiao.homelab.com/application/o/vault-browser/" \
oidc_client_id="vault-browser" \
oidc_client_secret="${VAULT_BROWSER_CLIENT_SECRET}" \
default_role="homelab"
# homelab role: all authenticated Authentik users get shell-secrets + cluster-admin.
# Restrict further by adding bound_claims once group-based mapping is configured.
vault write auth/oidc/role/homelab \
role_type=oidc \
bound_audiences="vault-browser" \
allowed_redirect_uris="http://vault.riotpiao.homelab.com/ui/vault/auth/oidc/oidc/callback,http://localhost:8250/oidc/callback" \
user_claim="sub" \
oidc_scopes="openid,profile,email" \
token_policies="shell-secrets,cluster-admin" \
token_ttl=8h \
token_max_ttl=12h
echo "OIDC auth configured."
echo " Browser login: http://vault.riotpiao.homelab.com → sign in with Authentik"
echo " CLI login: VAULT_ADDR=http://vault.riotpiao.homelab.com vault login -method=oidc"
# ── 14. Seed initial secrets ──────────────────────────────────────────────────
step seed_secrets "Seeding initial secrets from environment (accessible after OIDC login)..."
# Cluster service credentials
vault kv put secret/cluster/minio \
user="${MINIO_ROOT_USER}" \
password="${MINIO_ROOT_PASSWORD}"
if [[ -n "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then
vault kv put secret/cluster/grafana \
password="${GRAFANA_ADMIN_PASSWORD}"
fi
if [[ -n "${DUCKDNS_TOKEN:-}" ]]; then
vault kv put secret/cloud/duckdns \
token="${DUCKDNS_TOKEN}"
fi
# MCP / AI tooling keys (optional — add when available)
if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
vault kv put secret/mcp/anthropic api_key="${ANTHROPIC_API_KEY}"
fi
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
vault kv put secret/mcp/github token="${GITHUB_TOKEN}"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "==> Vault bootstrap complete."
echo ""
echo "Next steps:"
echo " 1. Move ${UNSEAL_KEYS_FILE} OFFLINE (USB / password manager)."
echo " 2. Browser login: open http://vault.riotpiao.homelab.com → choose OIDC → sign in with Authentik"
echo " 3. CLI login: export VAULT_ADDR=http://vault.riotpiao.homelab.com"
echo " vault login -method=oidc"
echo " 4. Read MinIO credentials from Vault after login:"
echo " vault kv get secret/cluster/minio"
echo " 5. talos-cli (JWT): talos secrets login && talos secrets status"