#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) AUTHENTIK_COMPOSE="$SCRIPT_DIR/authentik/compose.yml" VAULT_COMPOSE="$SCRIPT_DIR/ai-gateway/compose.yml" usage() { cat <<'EOF' Usage: ./homelab-emergency [arguments] Bootstrap and configuration: init-secrets identity-bootstrap Recovery: status identity-recovery [username] default: liooil identity-set-password [username] default: liooil identity-reset-2fa [username] delete all TOTP devices; default: liooil identity-reset-passkeys [username] delete all Passkeys; default: liooil vault-list [owner-sub] vault-audit [limit] vault-verify vault-delete vault-reassign vault-backup vault-rotate-key The script never prints stored provider credentials. EOF } require_command() { command -v "$1" >/dev/null 2>&1 || { echo "Required command not found: $1" >&2 exit 1 } } ensure_parent() { mkdir -p -- "$(dirname -- "$1")" } generate_base64_key() { local destination=$1 if [[ -e "$destination" ]]; then echo "Keeping existing secret: $destination" return fi ensure_parent "$destination" chmod 700 -- "$(dirname -- "$destination")" openssl rand 32 | base64 -w 0 >"$destination" chmod 600 -- "$destination" echo "Created: $destination" } vault_exec() { if docker ps --format '{{.Names}}' | grep -Fxq ai-gateway; then docker compose -f "$VAULT_COMPOSE" exec -T ai-gateway node /app/cli.mjs "$@" else docker compose -f "$VAULT_COMPOSE" run --rm --no-deps ai-gateway node /app/cli.mjs "$@" fi } identity_reset_authenticators() { local username=$1 local model_module=$2 local model_name=$3 docker compose -f "$AUTHENTIK_COMPOSE" exec -T -e TARGET_USERNAME="$username" authentik \ ak shell -c "import os; from authentik.core.models import User; from $model_module import $model_name; user=User.objects.get(username=os.environ['TARGET_USERNAME']); print($model_name.objects.filter(user=user).delete()[0])" } identity_audit() { local action=$1 local username=$2 local encoded encoded=$(printf '%s' "$username" | base64 -w 0) mkdir -p -- "$SCRIPT_DIR/site/data" printf '{"createdAt":"%s","actor":"homelab-emergency","action":"%s","target":"base64:%s","detail":"{}"}\n' \ "$(date --iso-8601=seconds)" "$action" "$encoded" >>"$SCRIPT_DIR/site/data/identity-audit.jsonl" chmod 600 -- "$SCRIPT_DIR/site/data/identity-audit.jsonl" } command_name=${1:-} shift || true case "$command_name" in init-secrets) require_command openssl require_command base64 umask 077 generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/portal_gateway_hmac" ;; identity-bootstrap) docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik \ ak shell -c "exec(open('/bootstrap/portal_identity.py').read())" docker compose -f "$SCRIPT_DIR/site/compose.yml" up -d --force-recreate xiteng-site ;; identity-recovery) username=${1:-liooil} docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik ak create_recovery_key 60 "$username" -v 0 ;; identity-set-password) username=${1:-liooil} docker compose -f "$AUTHENTIK_COMPOSE" exec authentik ak changepassword "$username" ;; identity-reset-2fa) username=${1:-liooil} read -r -p "Delete every TOTP device for $username? [y/N] " confirm [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 identity_reset_authenticators "$username" "authentik.stages.authenticator_totp.models" "TOTPDevice" identity_audit "emergency.totp.reset" "$username" ;; identity-reset-passkeys) username=${1:-liooil} read -r -p "Delete every Passkey for $username? [y/N] " confirm [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 identity_reset_authenticators "$username" "authentik.stages.authenticator_webauthn.models" "WebAuthnDevice" identity_audit "emergency.passkeys.reset" "$username" ;; status) docker compose -f "$AUTHENTIK_COMPOSE" ps authentik worker postgres redis docker compose -f "$VAULT_COMPOSE" ps ;; vault-list) vault_exec list "$@" ;; vault-audit) vault_exec audit "$@" ;; vault-verify) vault_exec verify "$@" ;; vault-delete) credential_id=${1:-} if [[ -z "$credential_id" ]]; then echo "Credential ID is required" >&2 exit 1 fi read -r -p "Permanently delete credential $credential_id? [y/N] " confirm [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 vault_exec delete "$credential_id" ;; vault-reassign) if [[ $# -lt 4 ]]; then echo "credential-id, issuer, sub and username are required" >&2 exit 1 fi vault_exec reassign "$@" ;; vault-backup) destination=${1:-} if [[ -z "$destination" ]]; then echo "Destination DB path is required" >&2 exit 1 fi absolute_destination=$(realpath -m -- "$destination") case "$absolute_destination" in "$SCRIPT_DIR/ai-gateway/data/"*) ;; *) echo "Vault CLI backup target must be under ai-gateway/data so the container can write it." >&2 echo "Suggested: ai-gateway/data/backups/vault-$(date +%Y%m%d-%H%M%S).db" >&2 exit 1 ;; esac relative_destination=/data/${absolute_destination#"$SCRIPT_DIR/ai-gateway/data/"} ensure_parent "$absolute_destination" vault_exec backup "$relative_destination" chmod 600 -- "$absolute_destination" echo "Vault backup written to $absolute_destination" ;; vault-rotate-key) new_key_file=${1:-} if [[ -z "$new_key_file" || ! -f "$new_key_file" ]]; then echo "A readable new key file is required" >&2 exit 1 fi read -r -p "Rewrap every Vault data key and then replace vault_master_key? [y/N] " confirm [[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 temporary_key="$SCRIPT_DIR/ai-gateway/secrets/vault_master_key.next" cp -- "$new_key_file" "$temporary_key" chmod 600 -- "$temporary_key" vault_exec rotate-master "/run/secrets/vault_master_key.next" mv -- "$temporary_key" "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" chmod 600 -- "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key" docker compose -f "$VAULT_COMPOSE" restart ai-gateway echo "Vault master key rotated and service restarted. Back up the new key now." ;; ""|-h|--help|help) usage ;; *) echo "Unknown command: $command_name" >&2 usage >&2 exit 1 ;; esac