diff --git a/docs/md/ansipa-setup.md b/docs/md/ansipa-setup.md index d4ab2a0..b7b1d84 100644 --- a/docs/md/ansipa-setup.md +++ b/docs/md/ansipa-setup.md @@ -3,13 +3,16 @@ **ansipa** = **Ans**ible + Free**IPA**: a GPO-like management plane for Linux fleets. FreeIPA provides identity, Kerberos, sudo/HBAC and host/user groups; a lightweight client-side enforcer turns group membership into enforced policy; -CheckMK monitors the fleet; Keycloak adds SSO; and an nginx gateway fronts all +CheckMK monitors the fleet; Keycloak adds SSO; Nextcloud provides files, +calendar and contacts on the same identities; and an nginx gateway fronts all the web UIs behind one hostname. This guide covers a full deployment: architecture, every port, the reverse-proxy gateway, the exact snippet to expose it behind your own reverse proxy, the NAT / behind-firewall behaviour, and known distro caveats. For the policy -catalogue and playbook reference see [FreeIPA & Ansible](freeipa-ansible.md). +catalogue and playbook reference see [FreeIPA & Ansible](freeipa-ansible.md); +for how logins flow from FreeIPA through Keycloak into each service — and why +CheckMK is the one exception — see [ansipa Single Sign-On](ansipa-sso.md). --- @@ -24,8 +27,10 @@ catalogue and playbook reference see [FreeIPA & Ansible](freeipa-ansible.md). https://ansipa ──┼──►│ /ipa/ ─► freeipa :443 ───┼──► 172.30.0.10 FreeIPA │ .example.com │ │ /cmk/ ─► checkmk :5000 ──┼──► 172.30.0.12 CheckMK │ (:443) │ │ /auth/ ─► keycloak :8080 ──┼──► keycloak Keycloak │ - │ └────────────────────────────┘ │ │ - │ :8088 (http) └─ postgres (KC backend) │ + │ │ /nextcloud/ ─► nextcloud ──┼──► nextcloud Nextcloud │ + │ └────────────────────────────┘ │ │ │ + │ :8088 (http) │ └─ redis │ + │ └─ postgres (KC + NC) │ └────────────────────────────────────────────────────────────────────┘ ▲ ▲ client-initiated only │ (enroll, kinit, policy pull, │ @@ -61,7 +66,9 @@ cp .env.example .env && $EDITOR .env # set domain, realm, passwords, ports docker compose up -d docker compose logs -f freeipa # watch first-boot (~10 min) -./keycloak-configure.sh # wire Keycloak → FreeIPA LDAP (once healthy) +./keycloak-configure.sh # Keycloak → FreeIPA LDAP + OIDC clients +./nextcloud-configure.sh # Nextcloud → IPA LDAP + Keycloak OIDC +./checkmk-ldap-configure.sh # CheckMK → FreeIPA LDAP (no OIDC in Raw) ``` --- @@ -168,16 +175,17 @@ udp dport { 88, 464, 137, 138 } accept ## The nginx gateway (portal + reverse proxy) -The `ansipa-nginx` service fronts all three web UIs on **one hostname/port** and +The `ansipa-nginx` service fronts every web UI on **one hostname/port** and serves a portal landing page. Each backend is proxied at the path it already uses natively, so there is no path rewriting to fight: | Path | Backend | Notes | |------|---------|-------| -| `/` | portal start page | static, links to the three UIs | +| `/` | portal start page | static, links to each UI | | `/ipa/` | FreeIPA `:443` | Host + Referer pinned to the IPA hostname (IPA anti-CSRF) | | `/cmk/` | CheckMK `:5000` | CheckMK already serves under `/cmk` | | `/auth/` | Keycloak `:8080` | Keycloak runs with `KC_HTTP_RELATIVE_PATH=/auth` | +| `/nextcloud/` | Nextcloud `:80` | matches `OVERWRITEWEBROOT`; `/.well-known/*` DAV redirects sit at the root | The gateway speaks plain HTTP on `ANSIPA_HTTP_PORT` (default **8088**) and trusts `X-Forwarded-*`, so it is designed to sit **behind your own TLS-terminating diff --git a/docs/md/ansipa-sso.md b/docs/md/ansipa-sso.md new file mode 100644 index 0000000..1c91c89 --- /dev/null +++ b/docs/md/ansipa-sso.md @@ -0,0 +1,281 @@ +# ansipa Single Sign-On + +How identity flows through the ansipa stack, which services can actually use +it, and how to wire the infrastructure appliances (Proxmox VE/BS, OPNsense) +into it. + +The rule for this stack: **no service keeps its own account list.** Every +account lives in FreeIPA, and every login is decided either by Keycloak or by +FreeIPA itself. The only exceptions are two deliberate break-glass local +admins, documented at the bottom. + +--- + +## The identity chain + +``` +FreeIPA ── accounts, groups, sudo/HBAC rules. The source of truth. + │ + ├─ LDAP (READ_ONLY federation) ──▶ Keycloak ── issues OIDC/SAML + │ │ + │ ├─▶ Nextcloud (user_oidc) + │ ├─▶ Proxmox VE (OpenID realm) + │ ├─▶ Proxmox Backup (OpenID realm) + │ └─▶ OPNsense (see caveat) + │ + └─ LDAP (direct bind) ───────────▶ CheckMK + └ no SAML/OIDC in the Community + edition, so it binds IPA itself +``` + +Keycloak mirrors FreeIPA read-only. Creating a user in Keycloak's own database +is always a mistake here — it produces an account IPA does not know about, and +it will not survive a re-sync. + +--- + +## Support matrix + +| Service | Method | Edition/version caveat | +|---|---|---| +| Nextcloud | OIDC via Keycloak, accounts from IPA LDAP | `user_oidc` app must be installed | +| Proxmox VE | OpenID Connect realm | PVE 7.0+ | +| Proxmox Backup Server | OpenID Connect realm | PBS 2.2+ | +| CheckMK | **Direct LDAP to FreeIPA** | SAML is commercial-only; OIDC unsupported in every edition | +| OPNsense | LDAP to FreeIPA (recommended) | No built-in OIDC relying-party support | + +### The two that cannot use Keycloak + +**CheckMK.** The Community (Raw) edition has no SAML — the docs state plainly +that "SAML is supported in the commercial editions" — and no edition of CheckMK +supports OIDC at all. The old `mod_auth_mellon` workaround was dropped in 2.3. +So CheckMK binds FreeIPA over LDAP directly. Identity still comes from one +place, it just takes a shorter path. Run `./checkmk-ldap-configure.sh`. + +**OPNsense.** It can consume LDAP and RADIUS, but it is not an OIDC relying +party, so Keycloak cannot front the web UI. Point it at FreeIPA's LDAP +directly (System → Access → Servers), which keeps accounts centralised even +though the login is not federated. The `opnsense` Keycloak client that +`keycloak-configure.sh` can provision is only useful if you put an +OIDC-aware proxy in front of the UI, or for OPNsense plugins that speak OIDC. + +--- + +## Setup order + +Everything below assumes the stack is up (`./run.sh all`). + +```bash +cd setup/modules/FreeipaAnsible/image + +./keycloak-configure.sh # IPA LDAP federation + OIDC clients + groups claim +./nextcloud-configure.sh # Nextcloud → IPA LDAP + Keycloak OIDC +./checkmk-ldap-configure.sh # CheckMK → IPA LDAP directly +``` + +`keycloak-configure.sh` writes each client's secret to `image/.oidc-secrets` +(mode 0600, gitignored). Clients are only created for relying parties whose +base URL is set in `.env` — `NC_PUBLIC_URL`, `PVE_URL`, `PBS_URL`, +`OPNSENSE_URL`. Set one later and re-run; existing clients keep their secrets. + +The discovery URL every relying party needs is: + +``` +https:///auth/realms//.well-known/openid-configuration +``` + +--- + +## Nextcloud + +Two backends do two different jobs, and both are required: + +- **`user_ldap`** binds FreeIPA and owns the account list. Users, display + names, mail and group membership come from IPA and are read-only. +- **`user_oidc`** performs the login against Keycloak, so password policy, MFA + and session control stay in one place. + +The join between them is the username, and it is the part that breaks if you +change it carelessly. Nextcloud's internal username is pinned to the IPA `uid` +(`ldapExpertUsernameAttr`), and the OIDC provider maps `preferred_username` +onto it with **unique-uid disabled**. Leave unique-uid on and `user_oidc` +hashes the claim into an ID of its own, so every SSO login creates a second, +empty account beside the LDAP one. + +Verify after setup: + +```bash +docker compose exec -u www-data nextcloud php occ user:list +``` + +That should list your FreeIPA users. Log in once through the Keycloak button, +then run it again — if a second account appeared for the same person, the uid +mapping is wrong. + +Restrict who gets an account with `NC_LDAP_GROUP=usr_nextcloud` in `.env`; the +LDAP filter also excludes IPA-disabled users (`nsAccountLock=TRUE`), so +disabling someone in IPA locks them out of Nextcloud too. + +--- + +## Proxmox VE + +Set `PVE_URL=https://pve.example.com:8006` in `.env`, re-run +`./keycloak-configure.sh`, then on the PVE node: + +```bash +pveum realm add keycloak \ + --type openid \ + --issuer-url https:///auth/realms/ \ + --client-id proxmox-ve \ + --client-key \ + --username-claim username \ + --autocreate 1 +``` + +`--username-claim` accepts only `subject`, `username` or `email` — not +`preferred_username`, despite that being the underlying claim name. `username` +is the one you want; it is what makes the PVE user match the IPA uid. + +`--autocreate 1` creates the PVE user entry on first login. It does **not** +grant permissions: assign those to groups under Datacenter → Permissions. + +Two things that reliably break this: + +- Keycloak must be reachable from the PVE node over **HTTPS with a + certificate the node trusts**. A self-signed gateway cert produces an + opaque HTTP 500 at login; install the CA on the node. +- Proxmox expects RS256-signed tokens, which is Keycloak's default. Do not + switch the realm or client to ES256/HS256. + +## Proxmox Backup Server + +Same model, `PBS_URL` and the `proxmox-bs` client: + +```bash +proxmox-backup-manager openid create keycloak \ + --issuer-url https:///auth/realms/ \ + --client-id proxmox-bs \ + --client-key \ + --username-claim username \ + --autocreate true +``` + +PBS needs its own client — reusing the PVE one fails on the redirect URI. + +--- + +## Monitoring the same appliances + +Separate from SSO, and worth not conflating: these appliances are also +monitored, and monitoring uses CheckMK's automation credentials, never a user +identity. + +### Proxmox VE / PBS — already wired + +`ansipa-infra-checkmk-install.sh` installs the agent, drops the +`ansipa_pve` / `ansipa_pbs` local check and registers the host under +`/ansipa/infra`. Run it on each node: + +```bash +./ansipa-infra-checkmk-install.sh https://mon.example.com:8090 cmk +``` + +That covers cluster quorum, storage utilisation, guest counts and backup +freshness (PVE); datastore usage, last-backup outcome and task failures (PBS). + +**Also worth enabling:** CheckMK ships a built-in *Proxmox VE special agent* +(Setup → Agents → VM, cloud, container → Proxmox VE) that talks to the PVE API +on port 8006 and generates piggyback data for the guests. It complements the +local checks rather than replacing them — the special agent sees the cluster +from outside and gives you per-VM data, the local check sees the node from +inside. Create a dedicated `checkmk@pve` user in the *Proxmox VE +authentication server* realm for it, not an OIDC user: the special agent needs +a password it can present non-interactively. + +### OPNsense — new + +```bash +scp ansipa-opnsense-*.sh root@fw:/root/ +ssh root@fw /root/ansipa-opnsense-checkmk-install.sh \ + https://mon.example.com:8090 cmk +``` + +Adds `Ansipa_OPN_Gateways`, `Ansipa_OPN_States` (pf state table against its +hard limit — the check that actually catches a firewall falling over), +`Ansipa_OPN_Firmware`, and `Ansipa_OPN_CARP` on HA pairs. + +The transport is deliberately **agent-over-SSH**, not the usual TCP 6556 via +inetd. OPNsense generates `/etc/inetd.conf`, `/etc/services` and +`/etc/hosts.allow` from `config.xml`, so it reverts those edits on the next +reboot or config apply and monitoring dies quietly weeks later. With the SSH +datasource nothing listens on the firewall and nothing gets overwritten. The +installer prints the exact key-restriction and CheckMK rule to add. + +If you would rather not give CheckMK SSH access at all, enable SNMP on the +firewall and set the host to SNMP in CheckMK — you get interfaces, CPU, memory +and uptime, but none of the `Ansipa_OPN_*` checks, which need local execution. + +### Keycloak itself + +Keycloak exposes Prometheus-format metrics on the management port 9000 +(`/metrics`, with `KC_HEALTH_ENABLED=true` already set in the compose file). +CheckMK Raw can scrape those, and health endpoints `/health/ready` and +`/health/live` are the cheap liveness signal. Note these live on port **9000** +and are *not* under `KC_HTTP_RELATIVE_PATH` — probing +`https://gateway/auth/health/ready` returns 404 and will look like an outage +that is not there. + +--- + +## Packet captures to the SmartestHome LLM + +Short answer: **not through CheckMK.** Passing a CheckMK API key to the LLM is +a good idea, but it buys you monitoring state, not packet data. + +**Why not.** CheckMK is a state-and-metrics system. Its REST API exposes hosts, +services, service states, metrics, events, downtimes and comments — there is no +packet-capture object and no blob transport. You could technically smuggle +base64 pcap through a custom agent section, but the agent payload is re-parsed +in full on every check cycle (60s by default): captures are megabytes per +second, agent output is expected to be kilobytes, and you would blow up the +monitoring core to build a bad file transfer. + +**What does work, with the same "one API key" ergonomics:** + +1. **Give the LLM the CheckMK REST API for state.** One automation secret gets + it every host, every service, current and historical metrics, and the event + console. That answers "what is broken, since when, and what changed" — + which is most of what you actually want an LLM triaging. + +2. **Put the packet analysis where the packets are.** Run Suricata or Zeek on + the OPNsense box (both are OPNsense plugins). They turn traffic into + structured events — `eve.json`, Zeek logs — which is far better LLM input + than raw pcap anyway, and orders of magnitude smaller. + +3. **Bridge the two.** A local check on the firewall summarises IDS alert + counts/severities into CheckMK, so alerts show up beside everything else and + the LLM can see them through the same API key. When it needs detail, it + fetches the full event from the Suricata/Zeek store — a second, narrow + credential — using the timestamp and flow ID CheckMK gave it. + +That keeps the "one key for situational awareness" property you wanted, without +making the monitoring system carry traffic it was never designed to move. If +you do want genuine on-demand pcap, the honest design is a small capture +service on the firewall exposing an authenticated endpoint, referenced *from* a +CheckMK service — not tunnelled through it. + +--- + +## Break-glass accounts + +Two local accounts deliberately do not live in FreeIPA, because they are how +you get in when the directory or Keycloak is the thing that is broken: + +| Account | Where | Reach it at | +|---|---|---| +| `cmkadmin` | CheckMK | `/cmk/` normal login form | +| `ncadmin` (`NC_ADMIN_USER`) | Nextcloud | `/nextcloud/login?direct=1` | + +Keep both in a password manager. Everything else — including your own +day-to-day admin account — should be an IPA identity. diff --git a/docs/md/index.md b/docs/md/index.md index 589910d..082b74e 100644 --- a/docs/md/index.md +++ b/docs/md/index.md @@ -18,6 +18,7 @@ A production-grade Arch Linux configuration for network administration, developm | [Archiso](archiso.md) | Building the custom live installer ISO | | [ansipa Setup Guide](ansipa-setup.md) | Full deployment: ports, nginx portal + reverse proxy, NAT, enrollment | | [ansipa Administration](ansipa-administration.md) | Day-to-day operation: the full policy catalogue + recipes | +| [ansipa Single Sign-On](ansipa-sso.md) | Identity chain: FreeIPA → Keycloak → Nextcloud/Proxmox/OPNsense; CheckMK's LDAP exception | | [FreeIPA & Ansible](freeipa-ansible.md) | Identity management and automated config deployment | | [Editors](editors.md) | Neovim, Micro, Yazi | | [Utilities](utilities.md) | Encryption helpers, ClamAV, credentials, update scripts | diff --git a/readme.md b/readme.md index a75913c..8f7e322 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ To add modules to an existing system: `bash ~/Dotfiles/setup/install-modules.sh` - **Hostname uniqueness** — the MAC address of the primary NIC is appended automatically when an answerfile hostname is set (`myhost` → `myhost-aabbccddee11`). - **LUKS encryption** — backup key is auto-generated from `/dev/urandom`, enrolled in a second LUKS slot, written to `/_LUKS_BACKUP_KEY` (root-only, inside the encrypted container). Collected by Ansible and stored on the SMB `ansipa-luks-keys` share (KeyAdmin-only read access). - **Custom ISO** — `setup/archiso/` builds a live USB that can embed a pre-baked answerfile for zero-touch deployment. The live environment also includes a **System Reset** mode that reinstalls the root subvolume while preserving home data and FIDO2 auth keys. -- **FreeIPA + Keycloak + Samba container** — `setup/modules/FreeipaAnsible/image/` ships a single `docker compose up` stack: FreeIPA for identity, Keycloak for OIDC, and Samba for scan-result and LUKS-key SMB shares. Host-group-driven policies (binary blocking, daemon enable/disable, daily scans, alert delivery) are enforced on enrolled clients every 30 minutes via Ansible-deployed timers. +- **FreeIPA + Keycloak + CheckMK + Nextcloud container** — `setup/modules/FreeipaAnsible/image/` ships a single `docker compose up` stack: FreeIPA for identity, Keycloak for OIDC, CheckMK for monitoring, Nextcloud for files/calendar/contacts, and Samba for LUKS-key SMB shares. FreeIPA is the single source of truth for accounts; see [ansipa-sso.md](docs/md/ansipa-sso.md). Host-group-driven policies (binary blocking, daemon enable/disable, daily scans, alert delivery) are enforced on enrolled clients every 30 minutes via Ansible-deployed timers. - **Modular** — core, shell, services, and desktop are independent components; pick only what you need. --- @@ -43,6 +43,7 @@ Full docs live in [`docs/md/`](docs/md/) (Markdown) and [`docs/html/`](docs/html | Optional modules & app catalogue | [modules.md](docs/md/modules.md) | [modules.html](docs/html/modules.html) | | Custom Archiso builder | [archiso.md](docs/md/archiso.md) | [archiso.html](docs/html/archiso.html) | | FreeIPA, Ansible, Keycloak & SMB | [freeipa-ansible.md](docs/md/freeipa-ansible.md) | [freeipa-ansible.html](docs/html/freeipa-ansible.html) | +| ansipa Single Sign-On | [ansipa-sso.md](docs/md/ansipa-sso.md) | [ansipa-sso.html](docs/html/ansipa-sso.html) | | Editors (Neovim, Micro, Yazi) | [editors.md](docs/md/editors.md) | [editors.html](docs/html/editors.html) | | Utilities (encrypt, ClamAV, updates) | [utilities.md](docs/md/utilities.md) | [utilities.html](docs/html/utilities.html) | diff --git a/setup/modules/FreeipaAnsible/image/.env.example b/setup/modules/FreeipaAnsible/image/.env.example index 3cc7f6a..194268a 100644 --- a/setup/modules/FreeipaAnsible/image/.env.example +++ b/setup/modules/FreeipaAnsible/image/.env.example @@ -73,6 +73,66 @@ KC_DB_PASSWORD=ChangeMe000! # ── Keycloak → FreeIPA LDAP federation ─────────────────────────────────────── # Leave IPA_BIND_PASSWORD blank to reuse IPA_DM_PASSWORD. # In production, create a dedicated read-only service account in FreeIPA. -IPA_BIND_DN=cn=Directory Manager +# +# QUOTE any value containing spaces. docker compose parses this file itself and +# does not need the quotes, but the *-configure.sh scripts `source` it as shell, +# where an unquoted `cn=Directory Manager` is read as the assignment +# `IPA_BIND_DN=cn=Directory` followed by a command named `Manager`. +IPA_BIND_DN="cn=Directory Manager" IPA_BIND_PASSWORD= IPA_USE_LDAPS=false + +# ── Nextcloud ───────────────────────────────────────────────────────────────── +# Served at ${NC_PUBLIC_URL}, i.e. the /nextcloud subpath of the ansipa gateway. +# +# Accounts come from FreeIPA (LDAP backend) and logins go through Keycloak +# (user_oidc) — run ./nextcloud-configure.sh after the stack is up to wire both. +# NC_ADMIN_USER/NC_ADMIN_PASSWORD are a BREAK-GLASS LOCAL account: Nextcloud +# cannot complete its installer without one. It is not an IPA identity; keep the +# password in your password manager and use it only if SSO itself is broken. +# +# NC_PUBLIC_URL — the externally reachable Nextcloud base URL. This is what +# goes in the Keycloak redirect URI and what sync clients +# are handed, so it must be the address USERS type (your +# TLS proxy's hostname), not the container address. +# NC_TRUSTED_DOMAINS — space-separated hostnames Nextcloud will answer for. +# A host that is not listed here gets a hard "untrusted +# domain" error page instead of a login form. +NC_DB_PASSWORD=ChangeMe_NcDb! +NC_ADMIN_USER=ncadmin +NC_ADMIN_PASSWORD=ChangeMe_NcAdmin! +NC_PUBLIC_URL=http://localhost:8088/nextcloud +NC_TRUSTED_DOMAINS=localhost + +# ── Nextcloud → FreeIPA LDAP (account source of truth) ─────────────────────── +# Dedicated read-only bind account is strongly recommended over Directory +# Manager. Leave blank to fall back to IPA_BIND_DN / IPA_BIND_PASSWORD. +# NC_LDAP_GROUP — if set, ONLY members of this IPA group get a Nextcloud +# account (e.g. usr_nextcloud). Blank means every IPA user. +# Quote any DN containing spaces (see the note under IPA_BIND_DN below). +NC_LDAP_BIND_DN= +NC_LDAP_BIND_PASSWORD= +NC_LDAP_GROUP= + +# ── CheckMK → FreeIPA LDAP ──────────────────────────────────────────────────── +# CheckMK Community/Raw edition has NO SAML and NO OIDC (both are commercial- +# edition features), so it cannot sit behind Keycloak. It binds FreeIPA over +# LDAP directly instead — still one account store, one less hop. +# Run ./checkmk-ldap-configure.sh to apply. +# CMK_LDAP_ADMIN_GROUP — IPA group whose members get the CheckMK "admin" role. +# CMK_LDAP_USER_GROUP — IPA group whose members get the read-only "user" role. +CMK_LDAP_ADMIN_GROUP=usr_cmk_admins +CMK_LDAP_USER_GROUP=usr_cmk_users + +# ── External SSO relying parties (Proxmox VE / PBS / OPNsense) ──────────────── +# Base URLs of infrastructure appliances that should log in through Keycloak. +# Set a URL here and keycloak-configure.sh provisions a matching OIDC client, +# writing its secret to .oidc-secrets; leave one blank to skip that client. +# Use the URL an ADMIN types in the browser, including the port. +# PVE_URL e.g. https://pve.corp.example.com:8006 +# PBS_URL e.g. https://pbs.corp.example.com:8007 +# OPNSENSE_URL e.g. https://fw.corp.example.com +# See docs/md/ansipa-sso.md for the matching config on each appliance. +PVE_URL= +PBS_URL= +OPNSENSE_URL= diff --git a/setup/modules/FreeipaAnsible/image/.gitignore b/setup/modules/FreeipaAnsible/image/.gitignore index 65f6a9c..99818db 100644 --- a/setup/modules/FreeipaAnsible/image/.gitignore +++ b/setup/modules/FreeipaAnsible/image/.gitignore @@ -1,2 +1,12 @@ # Test credentials — never commit .env + +# Keycloak OIDC client secrets, written by keycloak-configure.sh +.oidc-secrets +.oidc-secrets.tmp + +# Which Nextcloud LDAP config nextcloud-configure.sh owns (deployment state) +.nextcloud-ldap-config + +# CheckMK's FreeIPA sysaccount bind credentials, written by checkmk-ldap-configure.sh +.cmk-ldap-bind diff --git a/setup/modules/FreeipaAnsible/image/ansipa-opnsense-checkmk-install.sh b/setup/modules/FreeipaAnsible/image/ansipa-opnsense-checkmk-install.sh new file mode 100755 index 0000000..fc5de59 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-opnsense-checkmk-install.sh @@ -0,0 +1,165 @@ +#!/bin/sh +# ansipa-opnsense-checkmk-install.sh — wire an OPNsense firewall into CheckMK. +# +# Run as root ON THE FIREWALL. POSIX sh only: OPNsense is FreeBSD and has no +# bash in the base system. +# +# Usage: +# ansipa-opnsense-checkmk-install.sh [HOST_FQDN] [CMK_USER] +# +# Copy this script plus ansipa-opnsense-monitor.sh to the firewall first, e.g. +# scp ansipa-opnsense-*.sh root@fw:/root/ +# +# ── Transport: why SSH and not the usual agent port ────────────────────────── +# The normal CheckMK agent listens on TCP 6556 via inetd. On OPNsense that is a +# poor fit: /etc/inetd.conf, /etc/services and /etc/hosts.allow are GENERATED +# from config.xml, so the firewall silently reverts those edits on the next +# reboot or config apply, and monitoring dies quietly weeks later. +# +# So this installs the agent as a plain script and leaves the transport to +# CheckMK's "individual program call" datasource — CheckMK SSHes in and runs +# the agent on demand. Nothing listens, nothing to punch through the firewall +# rules, and nothing for OPNsense to overwrite. The script prints the exact +# rule to create at the end. +# +# It also registers the host in CheckMK via the REST API, in folder +# /ansipa/infra alongside the PVE/PBS hosts. + +set -eu + +LOG_TAG="ansipa-opnsense-checkmk" +log() { echo "[$LOG_TAG] $*"; } +warn() { echo "[$LOG_TAG][WARN] $*" >&2; } +die() { echo "[$LOG_TAG][ERROR] $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "Run as root." +[ $# -ge 3 ] || die "Usage: $0 [HOST_FQDN] [CMK_USER]" + +CMK_URL=$(echo "$1" | sed 's|/$||') +CMK_SITE="$2" +CMK_SECRET="$3" +HOST_FQDN="${4:-$(hostname)}" +CMK_USER="${5:-automation}" +CMK_API="${CMK_URL}/${CMK_SITE}/check_mk/api/1.0" + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +AGENT_BIN=/usr/local/bin/check_mk_agent +LOCAL_DIR=/usr/local/lib/check_mk_agent/local + +command -v opnsense-version >/dev/null 2>&1 \ + || die "opnsense-version not found — this does not look like an OPNsense host." +log "OPNsense detected: $(opnsense-version 2>/dev/null | head -1)" + +# ── Install the FreeBSD agent ──────────────────────────────────────────────── +# Pulled from the monitoring server so agent and server versions always match. +if [ ! -x "$AGENT_BIN" ]; then + log "Downloading the FreeBSD agent from ${CMK_URL}..." + fetch -q -o "$AGENT_BIN" \ + --user-agent="ansipa" \ + "${CMK_URL}/${CMK_SITE}/check_mk/agents/check_mk_agent.freebsd" 2>/dev/null \ + || curl -sf -u "${CMK_USER}:${CMK_SECRET}" \ + "${CMK_URL}/${CMK_SITE}/check_mk/agents/check_mk_agent.freebsd" \ + -o "$AGENT_BIN" 2>/dev/null \ + || die "Could not download check_mk_agent.freebsd — check CMK_URL/site/secret." + chmod 755 "$AGENT_BIN" + log "Agent installed at $AGENT_BIN" +else + log "Agent already present at $AGENT_BIN" +fi + +# ── Install the ansipa local check ─────────────────────────────────────────── +mkdir -p "$LOCAL_DIR" +if [ -f "$SCRIPT_DIR/ansipa-opnsense-monitor.sh" ]; then + install -m 755 "$SCRIPT_DIR/ansipa-opnsense-monitor.sh" "$LOCAL_DIR/ansipa_opnsense" + log "Installed local check: $LOCAL_DIR/ansipa_opnsense" +else + warn "ansipa-opnsense-monitor.sh not found next to this script — local check NOT installed." +fi + +# ── Keep the firmware status cache warm ────────────────────────────────────── +# The local check reads a cached update count rather than hitting the mirrors on +# every poll. /usr/local/etc/rc.syshook.d survives reboots and firmware updates, +# unlike /etc/crontab which OPNsense regenerates. +SYSHOOK_DIR=/usr/local/etc/rc.syshook.d/start +mkdir -p "$SYSHOOK_DIR" +cat > "$SYSHOOK_DIR/99-ansipa-cmk" <<'HOOK' +#!/bin/sh +# ansipa: refresh the firmware status cache once a day so the CheckMK local +# check can report pending updates without going to the network itself. +( + while true; do + /usr/local/sbin/configctl firmware check >/dev/null 2>&1 || true + sleep 86400 + done +) & +HOOK +chmod 755 "$SYSHOOK_DIR/99-ansipa-cmk" +log "Installed firmware-cache refresher: $SYSHOOK_DIR/99-ansipa-cmk" +"$SYSHOOK_DIR/99-ansipa-cmk" 2>/dev/null || true + +# ── Register the host in CheckMK ───────────────────────────────────────────── +# Same folder the PVE/PBS appliances land in. 422 means it already exists. +_folder_http=$(curl -sf -o /dev/null -w '%{http_code}' \ + -X POST "${CMK_API}/domain-types/folder_config/collections/all" \ + -H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \ + -H "Content-Type: application/json" -H "Accept: application/json" \ + -d '{"name":"infra","title":"Ansipa Infra (PVE/PBS)","parent":"/ansipa"}' \ + 2>/dev/null || echo "000") +case "$_folder_http" in + 2*|422) ;; + *) warn "Unexpected response creating /ansipa/infra: HTTP $_folder_http" ;; +esac + +_HOST_IP=$(ifconfig 2>/dev/null | awk '/inet /{if($2!="127.0.0.1"){print $2; exit}}') + +if curl -sf -o /dev/null \ + -H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \ + "${CMK_API}/objects/host_config/${HOST_FQDN}" 2>/dev/null; then + log "${HOST_FQDN} already registered in CheckMK" +else + _body="{\"host_name\":\"${HOST_FQDN}\",\"folder\":\"/ansipa/infra\"" + [ -n "$_HOST_IP" ] && _body="${_body},\"attributes\":{\"ipaddress\":\"${_HOST_IP}\",\"tag_role\":\"opnsense\"}" + _body="${_body}}" + _http=$(curl -sf -o /dev/null -w '%{http_code}' \ + -X POST "${CMK_API}/domain-types/host_config/collections/all" \ + -H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \ + -H "Content-Type: application/json" -H "Accept: application/json" \ + -d "$_body" 2>/dev/null || echo "000") + case "$_http" in + 2*) log "Registered ${HOST_FQDN} in CheckMK (/ansipa/infra)" ;; + *) warn "Host registration failed (HTTP ${_http}) — add it in the UI." ;; + esac +fi + +cat < "): +# Ansipa_OPN_Gateways per-gateway up/down from the gateway status API +# Ansipa_OPN_States pf state-table usage against the configured hard limit +# Ansipa_OPN_Firmware pending firmware/package updates +# Ansipa_OPN_CARP CARP interface health (skipped cleanly if unused) +# +# CPU / RAM / disk / interface counters are already covered by the stock +# FreeBSD agent checks — deliberately not duplicated here. + +PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin +export PATH + +# ── Gateways ──────────────────────────────────────────────────────────────── +# `configctl interface gateways status` returns a JSON blob. There is no jq in +# the OPNsense base system, so the objects are split onto separate lines and +# read with sed. We count anything not explicitly Online/none as degraded. +if command -v configctl >/dev/null 2>&1; then + _gw=$(configctl interface gateways status 2>/dev/null) + if [ -z "$_gw" ]; then + echo "3 Ansipa_OPN_Gateways - could not query gateway status" + else + # One JSON object per line, then pull name + status_translated per line. + _parsed=$(printf '%s' "$_gw" | sed 's/},[[:space:]]*{/}\ +{/g') + _total=$(echo "$_parsed" | grep -c '"name"' 2>/dev/null || echo 0) + _downlist=$(echo "$_parsed" \ + | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*"status_translated"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1=\2/p' \ + | grep -v '=Online$' | tr '\n' ' ') + _down=$(printf '%s' "$_downlist" | wc -w | tr -d ' ') + + if [ "${_total:-0}" -eq 0 ]; then + echo "3 Ansipa_OPN_Gateways - no gateways found in status output" + elif [ "${_down:-0}" -gt 0 ]; then + echo "2 Ansipa_OPN_Gateways gateways_total=${_total};; gateways_down=${_down};1;1 ${_down}/${_total} gateway(s) not online: ${_downlist}" + else + echo "0 Ansipa_OPN_Gateways gateways_total=${_total};; gateways_down=0;1;1 all ${_total} gateway(s) online" + fi + fi +else + echo "3 Ansipa_OPN_Gateways - configctl not found (not an OPNsense host?)" +fi + +# ── pf state table ────────────────────────────────────────────────────────── +# A firewall that hits its state limit drops new connections while looking +# perfectly healthy on CPU/RAM, so this is the check that actually catches it. +if command -v pfctl >/dev/null 2>&1; then + _cur=$(pfctl -si 2>/dev/null | awk '/current entries/ {print $3; exit}') + _lim=$(pfctl -sm 2>/dev/null | awk '/^states/ {print $4; exit}') + if [ -z "$_cur" ] || [ -z "$_lim" ] || [ "${_lim:-0}" -eq 0 ] 2>/dev/null; then + echo "3 Ansipa_OPN_States - could not read pf state table counters" + else + _pct=$(( _cur * 100 / _lim )) + _perf="states=${_cur};$(( _lim * 80 / 100 ));$(( _lim * 90 / 100 ));0;${_lim} states_pct=${_pct}%;80;90" + if [ "$_pct" -ge 90 ]; then + echo "2 Ansipa_OPN_States ${_perf} pf states ${_cur}/${_lim} (${_pct}%) — near the hard limit" + elif [ "$_pct" -ge 80 ]; then + echo "1 Ansipa_OPN_States ${_perf} pf states ${_cur}/${_lim} (${_pct}%)" + else + echo "0 Ansipa_OPN_States ${_perf} pf states ${_cur}/${_lim} (${_pct}%)" + fi + fi +else + echo "3 Ansipa_OPN_States - pfctl not found" +fi + +# ── Firmware / package updates ────────────────────────────────────────────── +# Read the cached firmware status rather than triggering a check: this runs +# every agent poll, and hitting the mirrors that often is antisocial and slow. +# `configctl firmware check` refreshes the cache; a cron job on the firewall +# should do that once a day (the installer sets one up). +_fw_status=/tmp/pkg_upgrade.progress +if command -v opnsense-version >/dev/null 2>&1; then + _pending="" + if [ -r /var/cache/opnsense-update/changelog/firmware.json ]; then + _pending=$(sed -n 's/.*"new_packages_count"[[:space:]]*:[[:space:]]*"\{0,1\}\([0-9]*\).*/\1/p' \ + /var/cache/opnsense-update/changelog/firmware.json 2>/dev/null | head -1) + fi + if [ -z "$_pending" ] && command -v pkg >/dev/null 2>&1; then + # Offline count from the local package DB — no network access. + _pending=$(pkg version -vIL= 2>/dev/null | wc -l | tr -d ' ') + fi + _ver=$(opnsense-version 2>/dev/null | head -1) + if [ -z "$_pending" ]; then + echo "3 Ansipa_OPN_Firmware - could not determine pending update count (${_ver})" + elif [ "$_pending" -gt 0 ] 2>/dev/null; then + echo "1 Ansipa_OPN_Firmware pending_updates=${_pending};1;;0; ${_pending} update(s) pending — ${_ver}" + else + echo "0 Ansipa_OPN_Firmware pending_updates=0;1;;0; up to date — ${_ver}" + fi +else + echo "3 Ansipa_OPN_Firmware - opnsense-version not found" +fi +unset _fw_status + +# ── CARP (high availability) ──────────────────────────────────────────────── +# Only meaningful on an HA pair. On a standalone firewall there are no carp +# interfaces and we stay silent rather than emitting a permanent UNKNOWN. +if command -v ifconfig >/dev/null 2>&1; then + _carp=$(ifconfig 2>/dev/null | grep -c ' carp:' 2>/dev/null || echo 0) + if [ "${_carp:-0}" -gt 0 ]; then + _demote=$(sysctl -n net.inet.carp.demotion 2>/dev/null || echo 0) + _init=$(ifconfig 2>/dev/null | grep ' carp:' | grep -c 'INIT' || echo 0) + if [ "${_init:-0}" -gt 0 ]; then + echo "2 Ansipa_OPN_CARP carp_ifaces=${_carp};; carp_demotion=${_demote};; ${_init} CARP interface(s) in INIT state" + elif [ "${_demote:-0}" -ne 0 ]; then + echo "1 Ansipa_OPN_CARP carp_ifaces=${_carp};; carp_demotion=${_demote};; CARP demotion factor is ${_demote} (not 0)" + else + echo "0 Ansipa_OPN_CARP carp_ifaces=${_carp};; carp_demotion=0;; ${_carp} CARP interface(s) healthy" + fi + fi +fi diff --git a/setup/modules/FreeipaAnsible/image/checkmk-ldap-configure.sh b/setup/modules/FreeipaAnsible/image/checkmk-ldap-configure.sh new file mode 100755 index 0000000..1a71800 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/checkmk-ldap-configure.sh @@ -0,0 +1,277 @@ +#!/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 diff --git a/setup/modules/FreeipaAnsible/image/docker-compose.yml b/setup/modules/FreeipaAnsible/image/docker-compose.yml index 5bb32b8..58a4737 100644 --- a/setup/modules/FreeipaAnsible/image/docker-compose.yml +++ b/setup/modules/FreeipaAnsible/image/docker-compose.yml @@ -1,11 +1,23 @@ -# FreeIPA + Keycloak + PostgreSQL constellation +# FreeIPA + Keycloak + CheckMK + Nextcloud + PostgreSQL constellation +# +# Identity flows one way, and only one way: +# FreeIPA (accounts, groups, sudo/HBAC — the source of truth) +# └→ Keycloak federates IPA over LDAP, issues OIDC/SAML +# ├→ Nextcloud (user_oidc, with the IPA LDAP backend owning accounts) +# ├→ Proxmox VE / PBS (OpenID Connect realm) +# └→ OPNsense (OIDC where supported, otherwise IPA LDAP directly) +# └→ CheckMK binds IPA over LDAP DIRECTLY — the Community/Raw edition +# has no SAML or OIDC, so Keycloak cannot front it. +# No service keeps its own account list; see docs/md/ansipa-sso.md. # # Setup: # cp .env.example .env && $EDITOR .env # docker compose up -d # docker compose logs -f freeipa # watch first-boot install (~10 min) # # Once freeipa is healthy: -# ./keycloak-configure.sh # wire Keycloak → FreeIPA LDAP +# ./keycloak-configure.sh # wire Keycloak → FreeIPA LDAP + OIDC clients +# ./nextcloud-configure.sh # wire Nextcloud → IPA LDAP + Keycloak OIDC +# ./checkmk-ldap-configure.sh # wire CheckMK → FreeIPA LDAP # # To run without Keycloak: # docker compose up -d freeipa @@ -40,6 +52,10 @@ volumes: name: cmk-data cmk-creds: # shared: CheckMK writes automation.secret here; FreeIPA reads it name: cmk-creds + nextcloud-data: # Nextcloud /var/www/html (config, apps, and user data) + name: nextcloud-data + nextcloud-redis: # Redis persistence for Nextcloud file locking / cache + name: nextcloud-redis networks: ipa-net: @@ -109,7 +125,12 @@ services: retries: 20 start_period: 600s - # ── PostgreSQL (Keycloak backend) ──────────────────────────────────────────── + # ── PostgreSQL (Keycloak + Nextcloud backend) ──────────────────────────────── + # One server, two databases. The nextcloud role/DB is created by the initdb + # hook below — but initdb hooks run ONLY when the data volume is empty, so on + # a stack that already has a keycloak-db volume the hook never fires. run.sh + # therefore also creates the role/DB idempotently before starting Nextcloud; + # keep both paths in sync if you change the names here. postgres: image: postgres:16-alpine container_name: keycloak-db @@ -118,8 +139,10 @@ services: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${KC_DB_PASSWORD:?set KC_DB_PASSWORD in .env} + NC_DB_PASSWORD: ${NC_DB_PASSWORD:?set NC_DB_PASSWORD in .env} volumes: - keycloak-db:/var/lib/postgresql/data + - ./postgres/initdb:/docker-entrypoint-initdb.d:ro networks: ipa-net: healthcheck: @@ -247,12 +270,83 @@ services: retries: 20 start_period: 90s + # ── Redis (Nextcloud file locking + cache) ─────────────────────────────────── + # Nextcloud's transactional file locking needs a real lock backend; without it + # concurrent clients corrupt each other's uploads. Not exposed to the host. + nextcloud-redis: + image: redis:7-alpine + container_name: nextcloud-redis + restart: unless-stopped + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - nextcloud-redis:/data + networks: + ipa-net: + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 20s + timeout: 5s + retries: 5 + + # ── Nextcloud ──────────────────────────────────────────────────────────────── + # Identity comes from FreeIPA, never from Nextcloud's own user table: + # • the LDAP backend binds to FreeIPA and owns the account list, and + # • user_oidc logs people in through Keycloak (which itself federates IPA). + # ./nextcloud-configure.sh wires both up after first start. The admin account + # created here (NC_ADMIN_USER) is a break-glass local account only — it exists + # because Nextcloud refuses to install without one. + # + # Served at the /nextcloud subpath of the ansipa gateway. OVERWRITEWEBROOT + # makes Nextcloud generate URLs under that prefix; OVERWRITECLIURL does the + # same for cron/CLI-generated links. Protocol is deliberately NOT pinned — + # with TRUSTED_PROXIES set, Nextcloud honours X-Forwarded-Proto, so the same + # container works behind a TLS proxy and over plain HTTP on ANSIPA_HTTP_PORT. + nextcloud: + image: nextcloud:31-apache + container_name: nextcloud + restart: unless-stopped + environment: + POSTGRES_HOST: postgres + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: ${NC_DB_PASSWORD:?set NC_DB_PASSWORD in .env} + REDIS_HOST: nextcloud-redis + NEXTCLOUD_ADMIN_USER: ${NC_ADMIN_USER:-ncadmin} + NEXTCLOUD_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD:?set NC_ADMIN_PASSWORD in .env} + # Every hostname the gateway may be reached under, space-separated. + NEXTCLOUD_TRUSTED_DOMAINS: ${NC_TRUSTED_DOMAINS:-localhost} + TRUSTED_PROXIES: 172.30.0.0/24 + OVERWRITEWEBROOT: /nextcloud + OVERWRITECLIURL: ${NC_PUBLIC_URL:-http://localhost:8088/nextcloud} + # Nextcloud's own cron + background jobs. + NEXTCLOUD_UPDATE: "1" + extra_hosts: + - "${IPA_HOSTNAME:-ipa.example.com}:172.30.0.10" + volumes: + - nextcloud-data:/var/www/html + depends_on: + postgres: + condition: service_healthy + nextcloud-redis: + condition: service_healthy + networks: + ipa-net: + healthcheck: + # status.php answers before the installer finishes, which is what we want: + # nextcloud-configure.sh waits on this and then drives occ. + test: ["CMD-SHELL", "curl -sf http://localhost/status.php -o /dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 20 + start_period: 120s + # ── nginx gateway: reverse proxy + portal start-page ───────────────────────── # Single HTTP entry point that fronts all three web UIs on one hostname: # / → portal start page (links to each service) # /ipa/ → FreeIPA web UI (proxied to the freeipa container, 443) # /cmk/ → CheckMK (proxied to the checkmk container, 5000) # /auth/ → Keycloak (KC served under /auth relative path) + # /nextcloud/ → Nextcloud (OVERWRITEWEBROOT matches this prefix) # # It speaks plain HTTP and trusts X-Forwarded-* so it is meant to sit BEHIND # the operator's own TLS-terminating reverse proxy (see .env ANSIPA_* vars and @@ -267,6 +361,7 @@ services: ANSIPA_IPA_UPSTREAM: 172.30.0.10 ANSIPA_CMK_UPSTREAM: 172.30.0.12:5000 ANSIPA_KC_UPSTREAM: keycloak:8080 + ANSIPA_NC_UPSTREAM: nextcloud:80 IPA_HOSTNAME: ${IPA_HOSTNAME:-ipa.example.com} volumes: - ./nginx/templates:/etc/nginx/templates:ro # *.template → envsubst → conf.d @@ -277,6 +372,7 @@ services: - freeipa - checkmk - keycloak + - nextcloud networks: ipa-net: healthcheck: diff --git a/setup/modules/FreeipaAnsible/image/keycloak-configure.sh b/setup/modules/FreeipaAnsible/image/keycloak-configure.sh index f771a09..1db0e6e 100755 --- a/setup/modules/FreeipaAnsible/image/keycloak-configure.sh +++ b/setup/modules/FreeipaAnsible/image/keycloak-configure.sh @@ -1,7 +1,19 @@ #!/bin/bash -# keycloak-configure.sh — wire Keycloak to FreeIPA via LDAP user federation +# keycloak-configure.sh — wire Keycloak to FreeIPA, and provision OIDC clients # -# Run this AFTER both FreeIPA and Keycloak are fully up. +# Two jobs: +# 1. LDAP user federation, so FreeIPA stays the single source of truth for +# accounts and groups and Keycloak only ever mirrors it (READ_ONLY). +# 2. A confidential OIDC client per relying party (Nextcloud, Proxmox VE/PBS, +# OPNsense), with a "groups" claim so those apps can authorize on IPA +# group membership. Secrets land in ./.oidc-secrets (0600, gitignored). +# +# CheckMK is deliberately NOT here: the Community/Raw edition supports neither +# SAML nor OIDC, so it binds FreeIPA over LDAP directly — see +# ./checkmk-ldap-configure.sh. +# +# Run this AFTER both FreeIPA and Keycloak are fully up. Safe to re-run: every +# object is created-or-updated, and existing clients keep their secrets. # Reads settings from environment variables or a .env file in the same directory. # # Required env vars: @@ -29,7 +41,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -f "$SCRIPT_DIR/.env" ]] && set -a && source "$SCRIPT_DIR/.env" && set +a -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +# 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; } @@ -40,7 +56,11 @@ info() { echo -e "${CYAN}[i]${NC} $*"; } : "${IPA_DM_PASSWORD:?IPA_DM_PASSWORD is required}" : "${KC_ADMIN_PASSWORD:?KC_ADMIN_PASSWORD is required}" -KC_URL="${KC_URL:-http://localhost:8080}" +# Must include the relative path Keycloak is served under: docker-compose.yml +# sets KC_HTTP_RELATIVE_PATH=/auth so the gateway can proxy it at a native +# subpath, which moves EVERY endpoint (admin API, realms, token) under /auth. +KC_URL="${KC_URL:-http://localhost:8080/auth}" +KC_URL="${KC_URL%/}" KC_ADMIN="${KC_ADMIN:-admin}" KC_REALM="${KC_REALM:-freeipa}" KC_REALM_DISPLAY="${KC_REALM_DISPLAY:-$IPA_DOMAIN}" @@ -77,9 +97,13 @@ kc_status() { curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $TOKEN" "$KC_URL$1"; } # ─── Wait for Keycloak ──────────────────────────────────────────────────────── +# Probe the master realm, not /health/ready: the health endpoints live on the +# separate management port 9000 and are NOT under KC_HTTP_RELATIVE_PATH, so +# "$KC_URL/health/ready" 404s forever on this deployment. /realms/master +# answers 200 on the serving port as soon as Keycloak is actually usable. info "Waiting for Keycloak at $KC_URL..." for i in $(seq 1 60); do - curl -sf "$KC_URL/health/ready" &>/dev/null && break + curl -sf "$KC_URL/realms/master" &>/dev/null && break [[ $i -eq 60 ]] && { error "Keycloak not ready after 120s."; exit 1; } sleep 2 done @@ -247,6 +271,139 @@ kc_put "/admin/realms/$KC_REALM" \ '{"loginWithEmailAllowed":true,"duplicateEmailsAllowed":false}' >/dev/null log "Email login enabled on realm '$KC_REALM'." +TOKEN=$(kc_token) + +# ─── Group membership in tokens ─────────────────────────────────────────────── +# Relying parties authorize on FreeIPA group membership (Nextcloud quota groups, +# Proxmox permissions, ...), so every token needs a "groups" claim. The LDAP +# group mapper above only imports groups INTO Keycloak — a protocol mapper is +# what actually puts them in the ID token / userinfo response. +# +# full.path=false emits bare names ("usr_nextcloud") rather than "/usr_nextcloud", +# because that is what the consumers below match against. +log "Adding realm-wide 'groups' claim mapper..." +GROUPS_SCOPE_ID=$(kc_get "/admin/realms/$KC_REALM/client-scopes" \ + | jq -r '.[] | select(.name=="groups") | .id // empty' | head -1) + +if [[ -z "$GROUPS_SCOPE_ID" ]]; then + kc_post "/admin/realms/$KC_REALM/client-scopes" '{ + "name": "groups", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope":"true","display.on.consent.screen":"false"} + }' >/dev/null 2>&1 || true + GROUPS_SCOPE_ID=$(kc_get "/admin/realms/$KC_REALM/client-scopes" \ + | jq -r '.[] | select(.name=="groups") | .id // empty' | head -1) +fi + +if [[ -n "$GROUPS_SCOPE_ID" ]]; then + HAS_GM=$(kc_get "/admin/realms/$KC_REALM/client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models" \ + | jq -r '.[] | select(.name=="groups") | .id // empty' | head -1) + if [[ -z "$HAS_GM" ]]; then + kc_post "/admin/realms/$KC_REALM/client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models" '{ + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "groups" + } + }' >/dev/null 2>&1 || true + fi + # Hand the scope to every new client automatically. + kc_put "/admin/realms/$KC_REALM/default-default-client-scopes/$GROUPS_SCOPE_ID" "" >/dev/null 2>&1 || true + log " 'groups' client scope ready." +else + warn " could not create the 'groups' client scope — add it by hand if apps need group claims." +fi + +# ─── OIDC clients ───────────────────────────────────────────────────────────── +# One confidential client per relying party. Secrets are written to +# .oidc-secrets (mode 0600, gitignored) so nextcloud-configure.sh and the +# Proxmox/OPNsense setup steps can pick them up without a trip through the UI. +# +# Clients whose base URL is not configured in .env are skipped — set PVE_URL, +# PBS_URL or OPNSENSE_URL and re-run to add them later. Re-running is safe: an +# existing client is updated in place and keeps its secret. +SECRETS_FILE="$SCRIPT_DIR/.oidc-secrets" + +kc_client() { + local client_id="$1" name="$2" root_url="$3" redirect="$4" + local payload existing cid secret + + payload=$(jq -n \ + --arg id "$client_id" --arg name "$name" \ + --arg root "$root_url" --arg redir "$redirect" \ + '{ + clientId: $id, name: $name, enabled: true, protocol: "openid-connect", + publicClient: false, bearerOnly: false, + standardFlowEnabled: true, directAccessGrantsEnabled: false, + serviceAccountsEnabled: false, implicitFlowEnabled: false, + rootUrl: $root, baseUrl: $root, + redirectUris: [$redir], webOrigins: ["+"], + attributes: {"post.logout.redirect.uris": $root + "/*"} + }') + + existing=$(kc_get "/admin/realms/$KC_REALM/clients?clientId=$client_id" \ + | jq -r '.[0].id // empty') + + if [[ -n "$existing" ]]; then + kc_put "/admin/realms/$KC_REALM/clients/$existing" "$payload" >/dev/null + cid="$existing" + warn " client '$client_id' already existed — updated (secret unchanged)." + else + kc_post "/admin/realms/$KC_REALM/clients" "$payload" >/dev/null + cid=$(kc_get "/admin/realms/$KC_REALM/clients?clientId=$client_id" \ + | jq -r '.[0].id // empty') + log " client '$client_id' created." + fi + + [[ -z "$cid" ]] && { error " could not resolve client '$client_id'."; return 1; } + + secret=$(kc_get "/admin/realms/$KC_REALM/clients/$cid/client-secret" \ + | jq -r '.value // empty') + [[ -z "$secret" ]] && { error " could not read secret for '$client_id'."; return 1; } + + # Rewrite this client's line in place, keeping the others. + touch "$SECRETS_FILE"; chmod 600 "$SECRETS_FILE" + grep -v "^${client_id}=" "$SECRETS_FILE" > "$SECRETS_FILE.tmp" 2>/dev/null || true + mv "$SECRETS_FILE.tmp" "$SECRETS_FILE" + echo "${client_id}=${secret}" >> "$SECRETS_FILE" + chmod 600 "$SECRETS_FILE" +} + +log "Provisioning OIDC clients..." + +# Nextcloud — user_oidc's callback is always /apps/user_oidc/code +NC_PUBLIC_URL="${NC_PUBLIC_URL:-}" +if [[ -n "$NC_PUBLIC_URL" ]]; then + NC_PUBLIC_URL="${NC_PUBLIC_URL%/}" + kc_client "nextcloud" "Nextcloud" "$NC_PUBLIC_URL" "$NC_PUBLIC_URL/apps/user_oidc/code" || true +else + warn " NC_PUBLIC_URL unset — skipping the Nextcloud client." +fi + +# Proxmox VE / PBS — the OpenID realm posts back to the web UI root. +if [[ -n "${PVE_URL:-}" ]]; then + kc_client "proxmox-ve" "Proxmox VE" "${PVE_URL%/}" "${PVE_URL%/}/*" || true +else + warn " PVE_URL unset — skipping the Proxmox VE client." +fi +if [[ -n "${PBS_URL:-}" ]]; then + kc_client "proxmox-bs" "Proxmox Backup Server" "${PBS_URL%/}" "${PBS_URL%/}/*" || true +else + warn " PBS_URL unset — skipping the Proxmox Backup Server client." +fi +if [[ -n "${OPNSENSE_URL:-}" ]]; then + kc_client "opnsense" "OPNsense" "${OPNSENSE_URL%/}" "${OPNSENSE_URL%/}/*" || true +else + warn " OPNSENSE_URL unset — skipping the OPNsense client." +fi + +[[ -f "$SECRETS_FILE" ]] && log "Client secrets written to $SECRETS_FILE (mode 0600)." + # ─── Summary ───────────────────────────────────────────────────────────────── cat </dev/null && cut -d= -f1 "$SECRETS_FILE" | tr '\n' ' ' || echo "(none — set NC_PUBLIC_URL / PVE_URL / PBS_URL / OPNSENSE_URL)") + Client secrets: $SECRETS_FILE + + Discovery URL (give this to relying parties): + $KC_URL/realms/$KC_REALM/.well-known/openid-configuration + Next steps: • Verify users are visible: Admin console → Users - • Set up client applications (OIDC/SAML) in this realm + • Run ./nextcloud-configure.sh to point Nextcloud at IPA LDAP + this realm + • CheckMK Community edition has no OIDC/SAML — run ./checkmk-ldap-configure.sh + to bind it straight to FreeIPA instead + • Proxmox VE / PBS: add an OpenID Connect realm with the discovery URL above + and the proxmox-ve / proxmox-bs client secret • For production: switch Keycloak to 'start' mode with a TLS cert • For Kerberos/SPNEGO: supply an HTTP service keytab and set allowKerberosAuthentication=true in the LDAP provider config diff --git a/setup/modules/FreeipaAnsible/image/nextcloud-configure.sh b/setup/modules/FreeipaAnsible/image/nextcloud-configure.sh new file mode 100755 index 0000000..489e781 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/nextcloud-configure.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# nextcloud-configure.sh — point Nextcloud at FreeIPA for accounts and at +# Keycloak for logins. +# +# Run AFTER the stack is up and ./keycloak-configure.sh has provisioned the +# "nextcloud" OIDC client. Safe to re-run. +# +# The two backends do different jobs, and both are needed: +# +# user_ldap binds FreeIPA and OWNS the account list. Users, display names, +# mail and group membership come from IPA and are read-only here, +# which is what makes IPA the source of truth rather than a thing +# Nextcloud happens to sync from once. +# user_oidc performs the actual login against Keycloak, so password policy, +# MFA and session control live in one place for every app. +# +# The join between them is the username. Nextcloud's internal username is +# pinned to the IPA `uid` (ldapExpertUsernameAttr), and the OIDC provider maps +# `preferred_username` onto it with unique-uid disabled — otherwise user_oidc +# hashes the claim into an ID of its own and every SSO login creates a SECOND, +# empty account beside the LDAP one. With them aligned, user_oidc's +# soft_auto_provision updates the existing LDAP user instead. +# +# Required env (from .env): +# IPA_SERVER / IPA_HOSTNAME, IPA_DOMAIN, NC_PUBLIC_URL, KC_REALM +# NC_LDAP_BIND_DN + NC_LDAP_BIND_PASSWORD (falls back to IPA_BIND_* / DM) +# Optional: +# NC_LDAP_GROUP restrict Nextcloud accounts to members of this IPA group +# KC_PUBLIC_URL externally reachable Keycloak base URL (default: derived) + +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}}" +NC_PUBLIC_URL="${NC_PUBLIC_URL:?NC_PUBLIC_URL is required (see .env.example)}" +NC_PUBLIC_URL="${NC_PUBLIC_URL%/}" +KC_REALM="${KC_REALM:-freeipa}" + +IPA_BASEDN="dc=${IPA_DOMAIN/./,dc=}" +LDAP_BIND_DN="${NC_LDAP_BIND_DN:-${IPA_BIND_DN:-cn=Directory Manager}}" +LDAP_BIND_PW="${NC_LDAP_BIND_PASSWORD:-${IPA_BIND_PASSWORD:-${IPA_DM_PASSWORD:-}}}" +[[ -z "$LDAP_BIND_PW" ]] && { error "No LDAP bind password (set NC_LDAP_BIND_PASSWORD)."; exit 1; } +if [[ "$LDAP_BIND_DN" == "cn=Directory Manager" ]]; then + warn "Binding LDAP as Directory Manager. For production create a read-only" + warn " sysaccount in FreeIPA and set NC_LDAP_BIND_DN / NC_LDAP_BIND_PASSWORD." +fi + +LDAP_SCHEME="ldap"; LDAP_PORT=389 +[[ "${IPA_USE_LDAPS:-false}" == "true" ]] && LDAP_SCHEME="ldaps" && LDAP_PORT=636 + +# Keycloak's externally reachable base. Nextcloud redirects the BROWSER here, so +# it must be an address the user's machine can resolve — not keycloak:8080. +if [[ -z "${KC_PUBLIC_URL:-}" ]]; then + # Default: same origin as Nextcloud, since both sit behind the ansipa gateway. + KC_PUBLIC_URL="${NC_PUBLIC_URL%/nextcloud}/auth" +fi +KC_PUBLIC_URL="${KC_PUBLIC_URL%/}" +DISCOVERY_URI="${KC_PUBLIC_URL}/realms/${KC_REALM}/.well-known/openid-configuration" + +occ() { docker compose exec -T -u www-data nextcloud php occ "$@"; } + +# ─── Wait for the installer to finish ──────────────────────────────────────── +info "Waiting for Nextcloud to finish installing..." +for i in $(seq 1 60); do + if occ status 2>/dev/null | grep -q 'installed: true'; then break; fi + [[ $i -eq 60 ]] && { error "Nextcloud not installed after 5 minutes."; exit 1; } + sleep 5 +done +log "Nextcloud is installed." + +# ─── Apps ──────────────────────────────────────────────────────────────────── +log "Enabling user_ldap and user_oidc..." +occ app:enable user_ldap >/dev/null 2>&1 || warn " could not enable user_ldap" +# user_oidc is not bundled; install pulls it from the app store on first run. +if ! occ app:list 2>/dev/null | grep -q 'user_oidc'; then + occ app:install user_oidc >/dev/null 2>&1 \ + || { error " could not install user_oidc (no app store access?)." + error " Install it from the Apps page, then re-run this script."; exit 1; } +fi +occ app:enable user_oidc >/dev/null 2>&1 || warn " could not enable user_oidc" +log " apps ready." + +# ─── LDAP backend (FreeIPA owns the accounts) ──────────────────────────────── +log "Configuring the FreeIPA LDAP backend..." + +# Which config ID we own is recorded in a state file rather than recovered by +# parsing `ldap:show-config`, whose table output is not a stable interface. If +# the recorded ID has since been deleted in the UI, fall through and make a new +# one — re-running must never silently configure a config that no longer exists. +LDAP_STATE="$SCRIPT_DIR/.nextcloud-ldap-config" +LDAP_CFG="" +if [[ -f "$LDAP_STATE" ]]; then + _saved=$(tr -d '[:space:]' < "$LDAP_STATE") + if [[ -n "$_saved" ]] && occ ldap:show-config "$_saved" >/dev/null 2>&1; then + LDAP_CFG="$_saved" + log " reusing LDAP config $LDAP_CFG" + else + warn " recorded LDAP config '${_saved}' is gone — creating a fresh one." + fi +fi + +if [[ -z "$LDAP_CFG" ]]; then + LDAP_CFG=$(occ ldap:create-empty-config 2>/dev/null | grep -oE 's[0-9]+' | head -1 || true) + [[ -z "$LDAP_CFG" ]] && { error "Could not create an LDAP configuration."; exit 1; } + echo "$LDAP_CFG" > "$LDAP_STATE" + log " created LDAP config $LDAP_CFG" +fi + +set_ldap() { occ ldap:set-config "$LDAP_CFG" "$1" "$2" >/dev/null 2>&1 \ + || warn " ldap:set-config $1 failed"; } + +set_ldap ldapHost "${LDAP_SCHEME}://${IPA_SERVER}" +set_ldap ldapPort "$LDAP_PORT" +set_ldap ldapAgentName "$LDAP_BIND_DN" +set_ldap ldapAgentPassword "$LDAP_BIND_PW" +set_ldap ldapBase "$IPA_BASEDN" +set_ldap ldapBaseUsers "cn=users,cn=accounts,$IPA_BASEDN" +set_ldap ldapBaseGroups "cn=groups,cn=accounts,$IPA_BASEDN" + +# Only enabled, non-expired posix accounts. IPA marks disabled users with +# nsAccountLock=TRUE, so exclude them — otherwise a user disabled in IPA keeps +# a working Nextcloud account, which defeats the whole point of central identity. +_user_filter="(&(objectClass=posixAccount)(!(nsAccountLock=TRUE)))" +if [[ -n "${NC_LDAP_GROUP:-}" ]]; then + _user_filter="(&(objectClass=posixAccount)(!(nsAccountLock=TRUE))(memberOf=cn=${NC_LDAP_GROUP},cn=groups,cn=accounts,${IPA_BASEDN}))" + log " restricting accounts to IPA group '${NC_LDAP_GROUP}'" +fi +set_ldap ldapUserFilter "$_user_filter" +set_ldap ldapUserFilterMode "1" # 1 = raw filter, don't let the UI rewrite it +set_ldap ldapLoginFilter "(&${_user_filter}(uid=%uid))" +set_ldap ldapLoginFilterMode "1" +set_ldap ldapUserDisplayName "displayName" +set_ldap ldapEmailAttribute "mail" +set_ldap ldapQuotaAttribute "" +set_ldap ldapUserFilterObjectclass "posixAccount" + +# THE critical setting: make Nextcloud's internal username the IPA uid. +# Left at its default, Nextcloud invents a UUID-based internal name and the +# OIDC preferred_username will never line up with it. +set_ldap ldapExpertUsernameAttr "uid" + +set_ldap ldapGroupFilter "(objectClass=groupOfNames)" +set_ldap ldapGroupFilterMode "1" +set_ldap ldapGroupDisplayName "cn" +set_ldap ldapGroupMemberAssocAttr "member" +set_ldap ldapNestedGroups "0" +set_ldap turnOnPasswordChange "0" # passwords change in IPA, not here +set_ldap hasMemberOfFilterSupport "1" +set_ldap useMemberOfToDetectMembership "1" +set_ldap ldapConfigurationActive "1" + +if occ ldap:test-config "$LDAP_CFG" 2>&1 | grep -qi 'valid\|successful'; then + log " LDAP bind OK." +else + warn " LDAP test-config did not report success — check the bind DN/password." + warn " (run: docker compose exec -u www-data nextcloud php occ ldap:test-config $LDAP_CFG)" +fi + +# ─── OIDC login via Keycloak ───────────────────────────────────────────────── +log "Configuring the Keycloak OIDC provider..." + +CLIENT_SECRET="" +if [[ -f .oidc-secrets ]]; then + CLIENT_SECRET=$(grep '^nextcloud=' .oidc-secrets | cut -d= -f2- || true) +fi +if [[ -z "$CLIENT_SECRET" ]]; then + error "No 'nextcloud' client secret in .oidc-secrets." + error "Run ./keycloak-configure.sh first (with NC_PUBLIC_URL set in .env)." + exit 1 +fi + +# soft_auto_provision (default on, set explicitly here so it survives someone +# turning auto_provision off later): when the OIDC subject already exists in +# another backend — our LDAP one — update that account rather than making a +# duplicate in the user_oidc backend. +occ config:system:set user_oidc auto_provision --value=true --type=boolean >/dev/null +occ config:system:set user_oidc soft_auto_provision --value=true --type=boolean >/dev/null + +# --unique-uid=0 keeps the user ID as the raw claim instead of a hash of +# (claim + provider name); with the hash on, SSO logins never match LDAP users. +occ user_oidc:provider keycloak \ + --clientid="nextcloud" \ + --clientsecret="$CLIENT_SECRET" \ + --discoveryuri="$DISCOVERY_URI" \ + --scope="openid email profile groups" \ + --mapping-uid="preferred_username" \ + --mapping-display-name="name" \ + --mapping-email="email" \ + --unique-uid=0 \ + --group-provisioning=1 \ + >/dev/null 2>&1 \ + && log " provider 'keycloak' registered." \ + || warn " user_oidc:provider failed — check 'occ user_oidc:provider --help' for this app version." + +# ─── Instance settings ─────────────────────────────────────────────────────── +occ config:system:set overwrite.cli.url --value="$NC_PUBLIC_URL" >/dev/null +occ config:system:set default_phone_region --value="${NC_PHONE_REGION:-DE}" >/dev/null + +# Nudge people to the SSO button but keep the password form reachable at +# ?direct=1 — the break-glass local admin still needs a way in if Keycloak is +# down, which is exactly when you cannot fix it through Keycloak. +occ config:system:set hide_login_form --value=false --type=boolean >/dev/null + +# Best-effort: this command only exists on newer Nextcloud releases, and the +# LDAP backend populates itself on demand anyway, so a miss here is harmless. +occ user:sync-account-data >/dev/null 2>&1 || true + +cat <

ansipa

-

FreeIPA identity · Ansible policy · CheckMK monitoring · Keycloak SSO

+

FreeIPA identity · Ansible policy · CheckMK monitoring · Keycloak SSO · Nextcloud

@@ -29,6 +29,11 @@

Single sign-on and identity federation against FreeIPA LDAP.

/auth/ + +

Nextcloud

+

Files, calendar & contacts — accounts from FreeIPA, login via Keycloak.

+ /nextcloud/ +