278 lines
12 KiB
Bash
Executable File
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
|