Dotfiles/setup/modules/FreeipaAnsible/image/checkmk-ldap-configure.sh

278 lines
12 KiB
Bash
Executable File

#!/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 <<EOF || true
set -e
ldapadd -x -D "cn=Directory Manager" -w "\$DM_PW" -H ldap://localhost <<LDIF 2>&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 <<LDIF 2>&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 <<EOF
${GREEN}CheckMK ↔ FreeIPA LDAP configuration complete.${NC}
Connection: freeipa (389directoryserver)
Server: $IPA_SERVER
Users DN: $USERS_DN
Groups DN: $GROUPS_DN
Bind DN: $CMK_LDAP_BIND_DN
Roles: ${CMK_ADMIN_GROUP} → admin, ${CMK_USER_GROUP} → user
Grant someone access:
docker exec $IPA_CONTAINER ipa group-add-member ${CMK_ADMIN_GROUP} --users=<uid>
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