#!/usr/bin/env bash # Create or reset the Keycloak master-realm "admin" user directly in PostgreSQL (Keycloak 24 Quarkus # has no bootstrap-admin CLI). Use when user_entity is empty or you must rotate the admin password. # # Requirements: SSH to Proxmox, pct to PostgreSQL CT (default 7803), sudo postgres psql on DB "keycloak". # Does not print the password to stdout; writes it to a file you pass, or merges into repo .env. # # Usage: # KEYCLOAK_ADMIN_PASSWORD='your-secure-value' ./scripts/deployment/keycloak-bootstrap-or-reset-master-admin-db.sh # ./scripts/deployment/keycloak-bootstrap-or-reset-master-admin-db.sh # generates password → .env # # Env: # PROXMOX_HOST (default 192.168.11.11), POSTGRES_CT_VMID (7803), KEYCLOAK_CT_VMID (7802) # KEYCLOAK_ADMIN_USERNAME (default admin), KEYCLOAK_DB_NAME (keycloak) # KEYCLOAK_ADMIN_PASSWORD — if unset, a random alphanumeric password is generated # WRITE_ENV_FILE — path to .env to upsert KEYCLOAK_ADMIN + KEYCLOAK_ADMIN_PASSWORD (default: repo .env) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # shellcheck source=/dev/null source "${PROJECT_ROOT}/config/ip-addresses.conf" 2>/dev/null || true PROXMOX_HOST="${PROXMOX_HOST:-${PROXMOX_HOST_R630_01:-192.168.11.11}}" POSTGRES_CT_VMID="${POSTGRES_CT_VMID:-7803}" KEYCLOAK_CT_VMID="${KEYCLOAK_CT_VMID:-${SANKOFA_KEYCLOAK_VMID:-7802}}" ADMIN_USER="${KEYCLOAK_ADMIN:-admin}" DB_NAME="${KEYCLOAK_DB_NAME:-keycloak}" WRITE_ENV_FILE="${WRITE_ENV_FILE:-${PROJECT_ROOT}/.env}" SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15) gen_pass() { openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32 } NEW_PASS="${KEYCLOAK_ADMIN_PASSWORD:-}" if [[ -z "$NEW_PASS" ]]; then NEW_PASS="$(gen_pass)" fi SQL_GEN="$(mktemp)" trap 'rm -f "$SQL_GEN"' EXIT python3 - "$NEW_PASS" "$ADMIN_USER" >"$SQL_GEN" <<'PY' import json, base64, hashlib, os, sys, time, uuid password, admin_user = sys.argv[1], sys.argv[2] salt = os.urandom(16) iters = 27500 dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iters) secret_data = json.dumps( { "value": base64.b64encode(dk).decode(), "salt": base64.b64encode(salt).decode(), "additionalParameters": {}, }, separators=(",", ":"), ) credential_data = json.dumps( {"hashIterations": iters, "algorithm": "pbkdf2-sha256", "additionalParameters": {}}, separators=(",", ":"), ) ts = int(time.time() * 1000) user_id = str(uuid.uuid4()) cred_id = str(uuid.uuid4()) def q(s: str) -> str: return s.replace("'", "''") sd, cd = q(secret_data), q(credential_data) user_esc = q(admin_user) print("BEGIN;") print( f""" DO $do$ DECLARE rid TEXT; r_admin TEXT; r_default TEXT; uid TEXT; n INT; v_secret TEXT := '{sd}'; v_cred TEXT := '{cd}'; BEGIN SELECT id INTO rid FROM realm WHERE name = 'master' LIMIT 1; IF rid IS NULL THEN RAISE EXCEPTION 'realm master not found'; END IF; SELECT id INTO r_admin FROM keycloak_role WHERE realm_id = rid AND name = 'admin' AND client IS NULL LIMIT 1; SELECT id INTO r_default FROM keycloak_role WHERE realm_id = rid AND name = 'default-roles-master' AND client IS NULL LIMIT 1; IF r_admin IS NULL OR r_default IS NULL THEN RAISE EXCEPTION 'missing admin or default-roles-master role'; END IF; SELECT COUNT(*) INTO n FROM user_entity WHERE realm_id = rid AND username = '{user_esc}'; IF n = 0 THEN INSERT INTO user_entity ( id, email, email_constraint, email_verified, enabled, realm_id, username, created_timestamp, not_before ) VALUES ( '{user_id}', '{user_esc}@sankofa.nexus', '{user_esc}@sankofa.nexus', true, true, rid, '{user_esc}', {ts}, 0 ); uid := '{user_id}'; INSERT INTO user_role_mapping (role_id, user_id) VALUES (r_admin, uid); INSERT INTO user_role_mapping (role_id, user_id) VALUES (r_default, uid); ELSE SELECT id INTO uid FROM user_entity WHERE realm_id = rid AND username = '{user_esc}' LIMIT 1; END IF; DELETE FROM credential WHERE user_id = uid AND type = 'password'; INSERT INTO credential (id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) VALUES ( '{cred_id}', NULL, 'password', uid, {ts}, NULL, v_secret, v_cred, 10 ); END $do$; """ ) print("COMMIT;") PY ssh "${SSH_OPTS[@]}" "root@${PROXMOX_HOST}" \ "pct exec ${POSTGRES_CT_VMID} -- sudo -u postgres psql -d ${DB_NAME} -v ON_ERROR_STOP=1 -f -" <"$SQL_GEN" ssh "${SSH_OPTS[@]}" "root@${PROXMOX_HOST}" \ "pct exec ${KEYCLOAK_CT_VMID} -- systemctl restart keycloak" echo "[ok] Keycloak master admin user '${ADMIN_USER}' password set in DB; Keycloak restarted on CT ${KEYCLOAK_CT_VMID}." if [[ -n "${WRITE_ENV_FILE}" ]]; then python3 - "${WRITE_ENV_FILE}" "${NEW_PASS}" "${ADMIN_USER}" <<'PY' import re import sys from pathlib import Path path, password, admin_user = Path(sys.argv[1]), sys.argv[2], sys.argv[3] text = path.read_text() if path.exists() else "" def upsert_line(body: str, key: str, value: str) -> str: line = f"{key}={value}" if re.search(rf"^{re.escape(key)}=", body, flags=re.M): return re.sub(rf"^{re.escape(key)}=.*$", line, body, flags=re.M, count=1) if body and not body.endswith("\n"): body += "\n" return body + line + "\n" text = upsert_line(text, "KEYCLOAK_ADMIN", admin_user) text = upsert_line(text, "KEYCLOAK_ADMIN_PASSWORD", password) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) PY echo "[ok] Updated ${WRITE_ENV_FILE} (KEYCLOAK_ADMIN, KEYCLOAK_ADMIN_PASSWORD)." fi