#!/bin/bash # checkmk-ldap-configure.sh — bind CheckMK's user management to FreeIPA. # # ── Why LDAP and not Keycloak ──────────────────────────────────────────────── # Every other app in this stack logs in through Keycloak. CheckMK cannot: # SAML is a commercial-edition feature (docs.checkmk.com/latest/en/saml.html # — "SAML is supported in the commercial editions") and there is no OIDC # support in any edition. The old mod_auth_mellon workaround was dropped in # 2.3. So CheckMK binds FreeIPA over LDAP directly. Identity still comes from # exactly one place; it just takes a shorter path. # # ── Why this writes a config file instead of calling the REST API ──────────── # REST endpoints for LDAP connections exist (werk #16527) but only from # CheckMK 2.4.0b1. docker-compose.yml pins check-mk-raw:2.3.0-latest, so the # supported route here is the config file the GUI itself writes, # ~/etc/check_mk/multisite.d/wato/user_connections.mk. # # The structure written below was verified against the 2.3 sources: directory # type "389directoryserver" is the correct one for FreeIPA (FreeIPA IS a 389DS # derivative) and gives the right defaults — user_id "uid" and member attribute # "member". Picking "openldap" instead would default the member attribute to # "uniquemember", which IPA does not use, and group-to-role mapping would # silently never match. # # ON 2.4+: prefer the REST API. Keep this script for 2.3 or delete it once the # stack moves, but do not run both — they write the same file. # # Usage: ./checkmk-ldap-configure.sh # Env overrides: CMK_CONTAINER (default "checkmk"), IPA_CONTAINER ("freeipa") set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" [[ -f .env ]] && set -a && source .env && set +a # ANSI-C quoting ($'...') so these hold real escape characters. With plain # single quotes they are the literal text \033[0;32m, which `echo -e` renders # but the summary heredocs below (plain `cat`) would print raw. RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' CYAN=$'\033[0;36m'; NC=$'\033[0m' log() { echo -e "${GREEN}[+]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } error() { echo -e "${RED}[✗]${NC} $*" >&2; } info() { echo -e "${CYAN}[i]${NC} $*"; } : "${IPA_DOMAIN:?IPA_DOMAIN is required}" IPA_SERVER="${IPA_SERVER:-${IPA_HOSTNAME:?set IPA_HOSTNAME or IPA_SERVER}}" CMK_CONTAINER="${CMK_CONTAINER:-checkmk}" IPA_CONTAINER="${IPA_CONTAINER:-freeipa}" CMK_SITE="${CMK_SITE_ID:-cmk}" CMK_ADMIN_GROUP="${CMK_LDAP_ADMIN_GROUP:-usr_cmk_admins}" CMK_USER_GROUP="${CMK_LDAP_USER_GROUP:-usr_cmk_users}" IPA_BASEDN="dc=${IPA_DOMAIN/./,dc=}" USERS_DN="cn=users,cn=accounts,$IPA_BASEDN" GROUPS_DN="cn=groups,cn=accounts,$IPA_BASEDN" SYSACCT_DN="uid=checkmk,cn=sysaccounts,cn=etc,$IPA_BASEDN" docker inspect "$CMK_CONTAINER" &>/dev/null \ || { error "Container '$CMK_CONTAINER' not found. Start the stack first."; exit 1; } # ─── Bind account ──────────────────────────────────────────────────────────── # Prefer a dedicated read-only sysaccount over Directory Manager: this password # sits in a config file inside the CheckMK container, so it should be able to # read the directory and nothing else. CMK_LDAP_BIND_DN="${CMK_LDAP_BIND_DN:-$SYSACCT_DN}" CMK_LDAP_BIND_PASSWORD="${CMK_LDAP_BIND_PASSWORD:-}" if [[ -z "$CMK_LDAP_BIND_PASSWORD" && "$CMK_LDAP_BIND_DN" == "$SYSACCT_DN" ]]; then if ! docker inspect "$IPA_CONTAINER" &>/dev/null; then error "No CMK_LDAP_BIND_PASSWORD set and the '$IPA_CONTAINER' container" error "is not running, so the sysaccount cannot be created automatically." error "Either start FreeIPA, or set CMK_LDAP_BIND_DN/CMK_LDAP_BIND_PASSWORD." exit 1 fi CMK_LDAP_BIND_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-24) log "Creating the read-only sysaccount ${SYSACCT_DN} in FreeIPA..." # sysaccounts live outside cn=accounts and are created with ldapadd, not the # ipa CLI (which only manages real user entries). Idempotent: an existing # entry returns 68 (Already exists), which we treat as success and then # reset the password so this script stays re-runnable. docker exec -i "$IPA_CONTAINER" env DM_PW="${IPA_DM_PASSWORD:?}" bash -s <&1 | grep -v "^\$" || true dn: $SYSACCT_DN changetype: add objectclass: account objectclass: simplesecurityobject uid: checkmk userPassword: $CMK_LDAP_BIND_PASSWORD passwordExpirationTime: 20380119031407Z nsIdleTimeout: 0 LDIF ldapmodify -x -D "cn=Directory Manager" -w "\$DM_PW" -H ldap://localhost <&1 | grep -v "^\$" || true dn: $SYSACCT_DN changetype: modify replace: userPassword userPassword: $CMK_LDAP_BIND_PASSWORD LDIF EOF log " sysaccount ready." # Persist it so re-runs don't rotate the password behind CheckMK's back. touch .cmk-ldap-bind; chmod 600 .cmk-ldap-bind printf 'CMK_LDAP_BIND_DN=%s\nCMK_LDAP_BIND_PASSWORD=%s\n' \ "$SYSACCT_DN" "$CMK_LDAP_BIND_PASSWORD" > .cmk-ldap-bind info " credentials saved to .cmk-ldap-bind (0600, gitignored)" elif [[ -z "$CMK_LDAP_BIND_PASSWORD" && -f .cmk-ldap-bind ]]; then # shellcheck disable=SC1091 source .cmk-ldap-bind fi [[ -z "$CMK_LDAP_BIND_PASSWORD" ]] && { error "No LDAP bind password available."; exit 1; } # ─── IPA groups that drive CheckMK roles ───────────────────────────────────── if docker inspect "$IPA_CONTAINER" &>/dev/null; then log "Ensuring IPA groups ${CMK_ADMIN_GROUP} / ${CMK_USER_GROUP} exist..." docker exec -i "$IPA_CONTAINER" env \ ADMIN_PW="${IPA_ADMIN_PASSWORD:?}" REALM="${IPA_REALM:-${IPA_DOMAIN^^}}" \ AG="$CMK_ADMIN_GROUP" UG="$CMK_USER_GROUP" bash -s <<'EOF' || warn " group creation failed (create them by hand)" set -e echo "$ADMIN_PW" | kinit "admin@${REALM}" >/dev/null 2>&1 ipa group-show "$AG" >/dev/null 2>&1 || \ ipa group-add "$AG" --desc="Members: CheckMK administrators" >/dev/null ipa group-show "$UG" >/dev/null 2>&1 || \ ipa group-add "$UG" --desc="Members: CheckMK read-only users" >/dev/null kdestroy >/dev/null 2>&1 || true EOF log " groups ready." else warn "FreeIPA container not running — create ${CMK_ADMIN_GROUP} / ${CMK_USER_GROUP} yourself." fi # ─── Write the connection config ───────────────────────────────────────────── MK_PATH="/omd/sites/${CMK_SITE}/etc/check_mk/multisite.d/wato/user_connections.mk" log "Writing $MK_PATH in container '$CMK_CONTAINER'..." # Back up first: a malformed .mk in multisite.d breaks the whole GUI, so we must # be able to put back exactly what was there. docker exec "$CMK_CONTAINER" sh -c \ "[ -f '$MK_PATH' ] && cp -a '$MK_PATH' '${MK_PATH}.ansipa-bak' || true" # The file is Python source that CheckMK exec()s, so every value must be a # valid Python literal. Do NOT build it by interpolating shell variables into # quoted strings: a password or DN containing a quote or backslash then # produces a SyntaxError, which takes the entire GUI down until the file is # removed. Instead the values are passed as environment variables and the # literal is emitted by Python's own repr() (via pprint), which escapes them # correctly by construction. docker exec -i \ -e A_SERVER="$IPA_SERVER" \ -e A_DOMAIN="$IPA_DOMAIN" \ -e A_BIND_DN="$CMK_LDAP_BIND_DN" \ -e A_BIND_PW="$CMK_LDAP_BIND_PASSWORD" \ -e A_USERS_DN="$USERS_DN" \ -e A_GROUPS_DN="$GROUPS_DN" \ -e A_ADMIN_GROUP="$CMK_ADMIN_GROUP" \ -e A_USER_GROUP="$CMK_USER_GROUP" \ -e A_MK_PATH="$MK_PATH" \ "$CMK_CONTAINER" "/omd/sites/${CMK_SITE}/bin/python3" - <<'PYEOF' import os, pprint env = os.environ groups_dn = env["A_GROUPS_DN"] connections = [ { "id": "freeipa", "type": "ldap", "description": "FreeIPA (%s)" % env["A_DOMAIN"], "comment": "Managed by ansipa. Accounts and roles come from FreeIPA groups.", "docu_url": "", "disabled": False, # 389directoryserver, not openldap: FreeIPA is a 389DS derivative, and # this choice is what makes the member attribute default to "member" # (openldap would default it to "uniquemember", which IPA never sets, # so group-to-role mapping would silently match nothing). "directory_type": ( "389directoryserver", {"connect_to": ("fixed_list", {"server": env["A_SERVER"]})}, ), "bind": (env["A_BIND_DN"], ("password", env["A_BIND_PW"])), "user_dn": env["A_USERS_DN"], "user_scope": "sub", "user_id_umlauts": "keep", "group_dn": groups_dn, "group_scope": "sub", # Roles follow IPA group membership. A user in neither group falls back # to the default user profile, i.e. gets no CheckMK access of substance. "active_plugins": { "email": {}, "alias": {}, "auth_expire": {}, "groups_to_roles": { "admin": [("cn=%s,%s" % (env["A_ADMIN_GROUP"], groups_dn), None)], "user": [("cn=%s,%s" % (env["A_USER_GROUP"], groups_dn), None)], }, }, "cache_livetime": 300, }, ] header = ( "# Managed by ansipa checkmk-ldap-configure.sh — regenerate, do not hand-edit.\n" "#\n" "# FreeIPA is the source of truth for accounts. CheckMK's Community edition\n" "# has no SAML/OIDC, so it binds the directory directly instead of going\n" "# through Keycloak like the other services in this stack do.\n" ) with open(env["A_MK_PATH"], "w") as fh: fh.write(header) fh.write("user_connections = " + pprint.pformat(connections, width=88) + "\n") PYEOF # The site user must own it — it was written by root via docker exec. docker exec "$CMK_CONTAINER" chown "${CMK_SITE}:${CMK_SITE}" "$MK_PATH" docker exec "$CMK_CONTAINER" chmod 660 "$MK_PATH" # ─── Validate, and roll back if the site cannot load it ────────────────────── log "Validating the config with CheckMK's own loader..." if docker exec "$CMK_CONTAINER" su - "$CMK_SITE" -c 'python3 -c " from cmk.gui import main_modules main_modules.load_plugins() from cmk.gui.utils.script_helpers import gui_context from cmk.gui.userdb import _connections as c from cmk.gui.userdb.ldap_connector import LDAPUserConnector with gui_context(): conns = [x for x in c.load_connection_config() if x[\"id\"] == \"freeipa\"] assert conns, \"freeipa connection not loaded\" conn = LDAPUserConnector(conns[0]) assert conn._user_id_attr() == \"uid\", conn._user_id_attr() assert conn._member_attr() == \"member\", conn._member_attr() print(\"servers:\", conn.servers()) print(\"user dn:\", conn._get_user_dn()) "' 2>&1 | sed 's/^/ /'; then log "Config validated." else error "CheckMK could not load the config — rolling back." docker exec "$CMK_CONTAINER" sh -c \ "[ -f '${MK_PATH}.ansipa-bak' ] && mv '${MK_PATH}.ansipa-bak' '$MK_PATH' || rm -f '$MK_PATH'" exit 1 fi # The GUI re-reads multisite.d per request, so no restart is needed; reload # apache anyway to drop any cached config in long-lived worker processes. docker exec "$CMK_CONTAINER" omd reload "$CMK_SITE" apache >/dev/null 2>&1 || true # Confirm the UI still serves after the change. if docker exec "$CMK_CONTAINER" curl -sf -o /dev/null \ "http://localhost:5000/${CMK_SITE}/check_mk/login.py"; then log "CheckMK GUI still responding." else warn "CheckMK login page did not respond — check 'docker logs $CMK_CONTAINER'." fi cat < Then in CheckMK: Setup → Users → "Synchronize users" (or wait for the 300s cache to lapse) and log in with the IPA username and password. cmkadmin remains a local break-glass account — it does not live in IPA, and that is deliberate: it is how you get in when the directory is unreachable. EOF