#!/usr/bin/env bash # break-glass.sh — create the break-glass bypass account in LLDAP # # The break-glass account is a last-resort local user for when the SSO stack # itself is broken (Authelia down, KeyCape misconfigured, etc.). It is: # - Created in LLDAP with BREAKGLASS_PASSWORD from gen-secrets.sh # - Assigned to net-kingdom-admins # - NOT enrolled in privacyIDEA MFA (so it can log in even if privacyIDEA is down) # - Its password is stored ONLY in KeePassXC (never in the cluster) # # IMPORTANT: After creating this account, immediately store the password in # KeePassXC → net-kingdom/Break-glass/break-glass. Then test it by logging # in to the LLDAP WebUI directly with this account. # # Usage: # ./break-glass.sh [lldap-url] [secrets-dir] # # default: https://lldap.coulomb.social # default: ../../bootstrap/secrets set -euo pipefail LLDAP_URL="${1:-https://lldap.coulomb.social}" SECRETS_DIR="${2:-../../bootstrap/secrets}" LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" BG_ENV="$SECRETS_DIR/breakglass/secrets.env" BG_USERNAME="break-glass" BG_EMAIL="break-glass@netkingdom.local" BG_DISPLAY="Break-glass Account" PASS_COUNT=0 FAIL_COUNT=0 ok() { echo " [OK] $1"; ((PASS_COUNT++)); } fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); } info() { echo " [INFO] $1"; } for f in "$LLDAP_ENV" "$BG_ENV"; do if [[ ! -f "$f" ]]; then echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2 exit 1 fi done read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } LLDAP_ADMIN_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS) BG_PASSWORD=$(read_env "$BG_ENV" BREAKGLASS_PASSWORD) if [[ -z "$LLDAP_ADMIN_PASS" || -z "$BG_PASSWORD" ]]; then echo "ERROR: could not read LLDAP_LDAP_USER_PASS or BREAKGLASS_PASSWORD" >&2 exit 1 fi # ── Authenticate ────────────────────────────────────────────────────────────── echo "" echo "Authenticating to LLDAP at $LLDAP_URL ..." AUTH_RESP=$(curl -sf -X POST "$LLDAP_URL/auth/simple/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}" \ 2>/dev/null || echo "CURL_FAILED") if [[ "$AUTH_RESP" == "CURL_FAILED" ]]; then echo "ERROR: Could not reach $LLDAP_URL" >&2 exit 1 fi LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "") if [[ -z "$LLDAP_TOKEN" ]]; then echo "ERROR: LLDAP authentication failed" >&2 exit 1 fi info "Authenticated as admin" gql() { local query="$1"; local vars="${2:-{}}" local body body=$(python3 -c " import json, sys print(json.dumps({'query': sys.argv[1], 'variables': json.loads(sys.argv[2])})) " "$query" "$vars") curl -sf -X POST "$LLDAP_URL/api/graphql" \ -H "Authorization: Bearer $LLDAP_TOKEN" \ -H "Content-Type: application/json" \ -d "$body" 2>/dev/null || echo "CURL_FAILED" } # ── Check if user already exists ────────────────────────────────────────────── echo "" echo "Checking if break-glass user exists ..." LIST_RESP=$(gql 'query { users { id displayName email } }') if [[ "$LIST_RESP" != "CURL_FAILED" ]]; then EXISTS=$(echo "$LIST_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print('yes' if any(u['id']=='$BG_USERNAME' for u in d.get('data',{}).get('users',[])) else 'no')" \ 2>/dev/null || echo "no") if [[ "$EXISTS" == "yes" ]]; then info "User '$BG_USERNAME' already exists — skipping creation" ok "User '$BG_USERNAME' exists" else # ── Create user ─────────────────────────────────────────────────────── echo "Creating user '$BG_USERNAME' ..." CREATE_VARS=$(python3 -c " import json, sys v = { 'user': { 'id': '$BG_USERNAME', 'email': '$BG_EMAIL', 'displayName': '$BG_DISPLAY', 'firstName': 'Break', 'lastName': 'Glass' } } print(json.dumps(v)) ") RESP=$(gql 'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id creationDate } }' \ "$CREATE_VARS") ERR=$(echo "$RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "") if [[ -n "$ERR" ]]; then fail "Create user '$BG_USERNAME' — $ERR" else ok "User '$BG_USERNAME' created" fi fi else fail "Could not query user list from LLDAP" fi # ── Set password ────────────────────────────────────────────────────────────── # LLDAP requires a separate API call to set the password after user creation. echo "" echo "Setting password for '$BG_USERNAME' ..." PW_VARS=$(python3 -c " import json, sys print(json.dumps({'userId': '$BG_USERNAME', 'password': sys.argv[1]})) " "$BG_PASSWORD") PW_RESP=$(gql 'mutation SetPassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }' \ "$PW_VARS") PW_ERR=$(echo "$PW_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "") if [[ -n "$PW_ERR" ]]; then fail "Set password — $PW_ERR" else ok "Password set for '$BG_USERNAME'" fi # ── Add to net-kingdom-admins ───────────────────────────────────────────────── echo "" echo "Adding '$BG_USERNAME' to net-kingdom-admins group ..." # Find the net-kingdom-admins group ID GROUPS_RESP=$(gql 'query { groups { id displayName } }') ADMIN_GID=$(echo "$GROUPS_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); gs=[g for g in d.get('data',{}).get('groups',[]) if g['displayName']=='net-kingdom-admins']; print(gs[0]['id'] if gs else '')" \ 2>/dev/null || echo "") if [[ -z "$ADMIN_GID" ]]; then fail "Group 'net-kingdom-admins' not found — run bootstrap-users.sh first" else ADD_VARS="{\"userId\":\"$BG_USERNAME\",\"groupId\":$ADMIN_GID}" ADD_RESP=$(gql 'mutation AddToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) }' \ "$ADD_VARS") ADD_ERR=$(echo "$ADD_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "") if [[ -n "$ADD_ERR" && "$ADD_ERR" != *"already"* ]]; then fail "Add to group — $ADD_ERR" else ok "'$BG_USERNAME' is in net-kingdom-admins" fi fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo "════════════════════════════════════════════════════════════" echo " Break-glass bootstrap: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" echo "════════════════════════════════════════════════════════════" echo "" echo "CRITICAL — do these steps NOW:" echo "" echo " 1. Store the break-glass password in KeePassXC:" echo " Group: net-kingdom/Break-glass" echo " Entry: break-glass → username='$BG_USERNAME' password=" echo "" echo " 2. Test the account (LLDAP WebUI login):" echo " $LLDAP_URL" echo " Login as '$BG_USERNAME' with BREAKGLASS_PASSWORD" echo " Confirm you can see the admin panel." echo "" echo " 3. Do NOT enroll MFA for '$BG_USERNAME' in privacyIDEA." echo " This account must remain usable when privacyIDEA is unavailable." echo " Its sole authentication factor is the password stored in KeePassXC." echo "" echo " 4. Document the DR restore sequence:" echo " See sso-mfa/k8s/backup/DR-RUNBOOK.md" echo "" if [[ "$FAIL_COUNT" -gt 0 ]]; then exit 1 fi exit 0