#!/usr/bin/env bash # update-lldap-resolver-live.sh — attended, resolver-only privacyIDEA update. # # This does not repair realms or policies. It updates only the persisted # lldap-coulomb resolver after the LLDAP bind credential has changed. # # Usage: # ./update-lldap-resolver-live.sh --apply # # The operator supplies both passwords interactively. Values are kept in a # mode-0700 temporary directory and mode-0600 files, passed to a child process # by pathname, and never printed or placed in command arguments. set -euo pipefail if [[ "${1:-}" != "--apply" || "${2:-}" != "" ]]; then echo "Usage: $0 --apply" >&2 exit 2 fi if [[ ! -t 0 ]]; then echo "ERROR: --apply requires an interactive terminal." >&2 exit 2 fi PI_URL="${PI_URL:-https://pink.coulomb.social}" RESOLVER_NAME="${RESOLVER_NAME:-lldap-coulomb}" LLDAP_URL="${LLDAP_URL:-ldap://lldap.sso.svc.cluster.local:3890}" LLDAP_BASE_DN="${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" LLDAP_BIND_DN="${LLDAP_BIND_DN:-uid=admin,ou=people,dc=netkingdom,dc=local}" tmp="$(mktemp -d)" chmod 700 "$tmp" cleanup() { if [[ -f "$tmp/pi-admin" ]]; then shred -u "$tmp/pi-admin" 2>/dev/null || rm -f "$tmp/pi-admin"; fi if [[ -f "$tmp/lldap-bind" ]]; then shred -u "$tmp/lldap-bind" 2>/dev/null || rm -f "$tmp/lldap-bind"; fi rmdir "$tmp" 2>/dev/null || true } trap cleanup EXIT INT TERM printf 'privacyIDEA pi-admin password: ' >&2 IFS= read -r -s pi_admin_password printf '\n' >&2 printf 'LLDAP bind/admin password: ' >&2 IFS= read -r -s lldap_bind_password printf '\n' >&2 if [[ -z "$pi_admin_password" || -z "$lldap_bind_password" ]]; then echo "ERROR: passwords must not be empty." >&2 exit 2 fi printf '%s' "$pi_admin_password" > "$tmp/pi-admin" printf '%s' "$lldap_bind_password" > "$tmp/lldap-bind" unset pi_admin_password lldap_bind_password chmod 600 "$tmp/pi-admin" "$tmp/lldap-bind" python3 - "$tmp/pi-admin" "$tmp/lldap-bind" "$PI_URL" "$RESOLVER_NAME" \ "$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" <<'PY' import json import sys import urllib.error import urllib.request from pathlib import Path pi_path, ldap_path, base_url, resolver, ldap_url, base_dn, bind_dn = sys.argv[1:] def read_secret(path: str) -> str: value = Path(path).read_text(encoding="utf-8") if not value: raise RuntimeError("empty protected input") return value def post(path: str, payload: dict, token: str | None = None) -> dict: headers = {"Content-Type": "application/json"} if token is not None: headers["Authorization"] = token request = urllib.request.Request( base_url.rstrip("/") + path, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=20) as response: return json.load(response) except urllib.error.HTTPError as exc: raise RuntimeError(f"HTTP status {exc.code}") from None except (urllib.error.URLError, TimeoutError): raise RuntimeError("request failed") from None try: auth = post("/auth", {"username": "pi-admin", "password": read_secret(pi_path)}) token = str(auth.get("result", {}).get("value", {}).get("token", "")) if not token: raise RuntimeError("privacyIDEA authentication failed") resolver_body = { "type": "ldapresolver", "LDAPURI": ldap_url, "BINDDN": bind_dn, "BINDPW": read_secret(ldap_path), "LDAPBASE": base_dn, "LOGINNAMEATTRIBUTE": "uid", "LDAPSEARCHFILTER": "(objectClass=inetOrgPerson)", "LDAPFILTER": "(&(objectClass=inetOrgPerson)(uid=%s))", "USERINFO": json.dumps({ "username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName", }), "UIDTYPE": "uid", "NOREFERRALS": True, "NOSCHEMAS": True, } result = post(f"/resolver/{resolver}", resolver_body, token) status = result.get("result", {}).get("status") if status not in (True, "true", "True"): raise RuntimeError("privacyIDEA resolver update was rejected") except RuntimeError as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) print("privacyIDEA resolver update: PASS") PY