From f66775ce54f51abe1b80f80e64e2ad4d4b647d7f Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 18 May 2026 11:22:48 +0200 Subject: [PATCH] setup: add FreeIPA image builder and Keycloak integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freeipa-image-builder.sh: TUI chooser that builds a FreeIPA server image and exports it to four target formats: docker — builds via podman/docker, optional registry push lxc — exports container rootfs as .tar.zst Proxmox CT template, generates pct import instructions proxmox-vm — downloads Rocky/Fedora cloud image, customizes with virt-customize, outputs QCOW2 + cloud-init user-data.yml oci-archive — skopeo OCI tarball for air-gapped import Keycloak TUI option generates the full constellation: docker-compose.yml FreeIPA + Keycloak + PostgreSQL stack .env pre-filled env template (passwords placeholder) keycloak-configure.sh post-start Keycloak REST API config script image/Dockerfile: Fedora 41 + freeipa-server-dns + ansible-core, systemd-enabled container (CMD /sbin/init). image/ipa-first-boot.{sh,service}: systemd oneshot that runs ipa-server-install on first container/VM boot from env vars (IPA_DOMAIN, IPA_ADMIN_PASSWORD, IPA_DM_PASSWORD, and optionals). ConditionPathExists=!/etc/ipa/default.conf makes it idempotent. image/keycloak-configure.sh: Keycloak REST API automation that: - waits for Keycloak readiness - creates a realm - wires FreeIPA LDAP user federation (READ_ONLY, vendor=rhds) - adds attribute mappers: email, firstName, lastName, uidNumber - adds group mapper (IPA groups → Keycloak groups, cn=groups,cn=accounts) - triggers an initial full user sync image/docker-compose.yml: freeipa + postgres + keycloak services on a private 172.30.0.0/24 bridge; FreeIPA has a fixed IP so Keycloak can resolve it via extra_hosts. Co-Authored-By: Claude Sonnet 4.6 --- setup/install-modules.sh | 4 + .../modules/FreeipaAnsible/image/.env.example | 23 ++ setup/modules/FreeipaAnsible/image/Dockerfile | 68 ++++ .../FreeipaAnsible/image/docker-compose.yml | 122 ++++++ .../image/ipa-first-boot.service | 16 + .../FreeipaAnsible/image/ipa-first-boot.sh | 78 ++++ .../image/keycloak-configure.sh | 272 +++++++++++++ .../apps/freeipa-image-builder.sh | 381 ++++++++++++++++++ setup/tui-install.sh | 6 +- 9 files changed, 969 insertions(+), 1 deletion(-) create mode 100644 setup/modules/FreeipaAnsible/image/.env.example create mode 100644 setup/modules/FreeipaAnsible/image/Dockerfile create mode 100644 setup/modules/FreeipaAnsible/image/docker-compose.yml create mode 100644 setup/modules/FreeipaAnsible/image/ipa-first-boot.service create mode 100755 setup/modules/FreeipaAnsible/image/ipa-first-boot.sh create mode 100755 setup/modules/FreeipaAnsible/image/keycloak-configure.sh create mode 100755 setup/modules/optional-Modules/apps/freeipa-image-builder.sh diff --git a/setup/install-modules.sh b/setup/install-modules.sh index 67b1cf2..fc17053 100755 --- a/setup/install-modules.sh +++ b/setup/install-modules.sh @@ -120,6 +120,7 @@ count_steps() { [[ "$sel" == *"cockpit"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$sel" == *"ssh-server"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$sel" == *"freeipa-server"* ]] && TOTAL=$(( TOTAL + 1 )) + [[ "$sel" == *"freeipa-image"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$sel" == *"python"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$sel" == *"zfs"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$sel" == *"wprs"* ]] && TOTAL=$(( TOTAL + 1 )) @@ -185,6 +186,7 @@ SELECTED=$(dialog --backtitle "$BACKTITLE" \ "cockpit" "Cockpit web UI · machines · podman" off \ "ssh-server" "SSH server openssh · key-auth · enabled" off \ "freeipa-server" "FreeIPA Server interactive server setup + client gen" off \ + "freeipa-image" "FreeIPA Image OCI/LXC/Proxmox/VM builder + Keycloak" off \ "python" "Python tools pyright · pipx · pynvim" off \ "zfs" "ZFS zfs-dkms kernel module" off \ "wprs" "WPRS wprs-git (AUR)" off \ @@ -229,6 +231,7 @@ SUMMARY="" [[ "$SELECTED" == *"cockpit"* ]] && SUMMARY+=" ✦ Cockpit\n" [[ "$SELECTED" == *"ssh-server"* ]] && SUMMARY+=" ✦ SSH server\n" [[ "$SELECTED" == *"freeipa-server"* ]] && SUMMARY+=" ✦ FreeIPA Server\n" +[[ "$SELECTED" == *"freeipa-image"* ]] && SUMMARY+=" ✦ FreeIPA Image Builder\n" [[ "$SELECTED" == *"python"* ]] && SUMMARY+=" ✦ Python tools\n" [[ "$SELECTED" == *"zfs"* ]] && SUMMARY+=" ✦ ZFS\n" [[ "$SELECTED" == *"wprs"* ]] && SUMMARY+=" ✦ WPRS\n" @@ -276,6 +279,7 @@ count_steps "$SELECTED" [[ "$SELECTED" == *"cockpit"* ]] && run_module "Cockpit" "$APPS/cockpit.sh" [[ "$SELECTED" == *"ssh-server"* ]] && run_module "SSH Server" "$APPS/ssh-server.sh" [[ "$SELECTED" == *"freeipa-server"* ]] && run_module "FreeIPA Server" "$APPS/freeipa-server.sh" +[[ "$SELECTED" == *"freeipa-image"* ]] && run_module "FreeIPA Image" "$APPS/freeipa-image-builder.sh" [[ "$SELECTED" == *"python"* ]] && run_module "Python Tools" "$MODULES/optional-Modules/python.sh" [[ "$SELECTED" == *"zfs"* ]] && run_module "ZFS" "$MODULES/optional-Modules/zfs.sh" [[ "$SELECTED" == *"wprs"* ]] && run_module "WPRS" "$MODULES/optional-Modules/wprs.sh" diff --git a/setup/modules/FreeipaAnsible/image/.env.example b/setup/modules/FreeipaAnsible/image/.env.example new file mode 100644 index 0000000..65d4e23 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/.env.example @@ -0,0 +1,23 @@ +# ── FreeIPA ─────────────────────────────────────────────────────────────────── +IPA_HOSTNAME=ipa.corp.example.com +IPA_DOMAIN=corp.example.com +IPA_REALM=CORP.EXAMPLE.COM +IPA_ADMIN_PASSWORD=ChangeMe123! +IPA_DM_PASSWORD=ChangeMe456! +IPA_SETUP_DNS=false +IPA_DNS_FORWARDER= +IPA_SETUP_KRA=false + +# ── Keycloak ────────────────────────────────────────────────────────────────── +KC_HOSTNAME=keycloak.corp.example.com +KC_REALM=corp +KC_ADMIN=admin +KC_ADMIN_PASSWORD=ChangeMe789! +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 +IPA_BIND_PASSWORD= +IPA_USE_LDAPS=false diff --git a/setup/modules/FreeipaAnsible/image/Dockerfile b/setup/modules/FreeipaAnsible/image/Dockerfile new file mode 100644 index 0000000..c0f63d9 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/Dockerfile @@ -0,0 +1,68 @@ +# FreeIPA server container image (Fedora / systemd-based) +# +# Build: +# docker build -t freeipa-server . +# +# Run (quick test): +# docker run --privileged --name freeipa \ +# --tmpfs /run --tmpfs /tmp \ +# -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ +# -v freeipa-data:/data \ +# -h ipa.example.com \ +# -e IPA_DOMAIN=example.com \ +# -e IPA_ADMIN_PASSWORD=Secret123 \ +# -e IPA_DM_PASSWORD=Secret456 \ +# -p 443:443 -p 389:389 -p 636:636 -p 88:88 \ +# freeipa-server +# +# For production use docker-compose.yml instead. + +FROM fedora:41 + +ENV container=docker \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dnf install -y --setopt=install_weak_deps=False \ + freeipa-server \ + freeipa-server-dns \ + freeipa-server-trust-ad \ + freeipa-admintools \ + ansible-core \ + python3-netaddr \ + openldap-clients \ + krb5-workstation \ + bind-utils \ + procps-ng \ + net-tools \ + rsync \ + hostname \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# Mask units that either require host-level access or are irrelevant in containers +RUN systemctl mask \ + systemd-remount-fs.service \ + dev-hugepages.mount \ + sys-fs-fuse-connections.mount \ + systemd-logind.service \ + getty.target \ + console-getty.service \ + dnf-makecache.timer \ + plymouth-quit-wait.service \ + plymouth-start.service \ + network.service \ + NetworkManager.service + +COPY ipa-first-boot.sh /usr/local/sbin/ipa-first-boot.sh +COPY ipa-first-boot.service /etc/systemd/system/ipa-first-boot.service +RUN chmod +x /usr/local/sbin/ipa-first-boot.sh \ + && systemctl enable ipa-first-boot.service + +VOLUME ["/data"] + +# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP +EXPOSE 389 636 88/tcp 88/udp 464/tcp 464/udp 443 80 53/tcp 53/udp 123/udp + +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/setup/modules/FreeipaAnsible/image/docker-compose.yml b/setup/modules/FreeipaAnsible/image/docker-compose.yml new file mode 100644 index 0000000..59ac8d8 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/docker-compose.yml @@ -0,0 +1,122 @@ +# FreeIPA + Keycloak + PostgreSQL constellation +# +# 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 +# +# To run without Keycloak: +# docker compose up -d freeipa +# +# To scale out (clients enroll against the same IPA server): +# docker compose --profile clients up + +volumes: + freeipa-data: + keycloak-db: + +networks: + ipa-net: + ipam: + config: + - subnet: 172.30.0.0/24 + +services: + + # ── FreeIPA ───────────────────────────────────────────────────────────────── + freeipa: + build: + context: . + dockerfile: Dockerfile + image: freeipa-server:local + container_name: freeipa + hostname: ${IPA_HOSTNAME:-ipa.example.com} + privileged: true + tmpfs: + - /run + - /tmp + volumes: + - freeipa-data:/data + - /sys/fs/cgroup:/sys/fs/cgroup:rw + environment: + IPA_DOMAIN: ${IPA_DOMAIN:?set IPA_DOMAIN in .env} + IPA_REALM: ${IPA_REALM:-} + IPA_ADMIN_PASSWORD: ${IPA_ADMIN_PASSWORD:?set IPA_ADMIN_PASSWORD in .env} + IPA_DM_PASSWORD: ${IPA_DM_PASSWORD:?set IPA_DM_PASSWORD in .env} + IPA_SETUP_DNS: ${IPA_SETUP_DNS:-false} + IPA_DNS_FORWARDER: ${IPA_DNS_FORWARDER:-} + IPA_SETUP_KRA: ${IPA_SETUP_KRA:-false} + ports: + - "389:389" + - "636:636" + - "88:88" + - "88:88/udp" + - "464:464" + - "464:464/udp" + - "443:443" + networks: + ipa-net: + ipv4_address: 172.30.0.10 + healthcheck: + test: ["CMD-SHELL", "ipactl status 2>/dev/null | grep -q 'running'"] + interval: 30s + timeout: 15s + retries: 20 + start_period: 600s + + # ── PostgreSQL (Keycloak backend) ──────────────────────────────────────────── + postgres: + image: postgres:16-alpine + container_name: keycloak-db + restart: unless-stopped + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: ${KC_DB_PASSWORD:?set KC_DB_PASSWORD in .env} + volumes: + - keycloak-db:/var/lib/postgresql/data + networks: + ipa-net: + healthcheck: + test: ["CMD", "pg_isready", "-U", "keycloak"] + interval: 10s + retries: 5 + + # ── Keycloak ───────────────────────────────────────────────────────────────── + # After first start, run ./keycloak-configure.sh to wire FreeIPA LDAP federation. + keycloak: + image: quay.io/keycloak/keycloak:latest + container_name: keycloak + restart: unless-stopped + # Use 'start' (not start-dev) for production; requires a TLS certificate. + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KC_DB_PASSWORD:?set KC_DB_PASSWORD in .env} + KC_HOSTNAME: ${KC_HOSTNAME:-localhost} + KC_HTTP_PORT: 8080 + KC_HTTPS_PORT: 8443 + KC_HTTP_ENABLED: "true" + KC_FEATURES: preview + KEYCLOAK_ADMIN: ${KC_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD in .env} + extra_hosts: + - "${IPA_HOSTNAME:-ipa.example.com}:172.30.0.10" + ports: + - "8080:8080" + - "8443:8443" + depends_on: + postgres: + condition: service_healthy + networks: + ipa-net: + healthcheck: + test: ["CMD-SHELL", "curl -fs http://localhost:8080/health/ready || exit 1"] + interval: 20s + timeout: 10s + retries: 20 + start_period: 90s diff --git a/setup/modules/FreeipaAnsible/image/ipa-first-boot.service b/setup/modules/FreeipaAnsible/image/ipa-first-boot.service new file mode 100644 index 0000000..eb11d49 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ipa-first-boot.service @@ -0,0 +1,16 @@ +[Unit] +Description=FreeIPA Server First-Boot Configuration +After=network-online.target dirsrv.target +Wants=network-online.target +ConditionPathExists=!/etc/ipa/default.conf + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/ipa-first-boot.sh +RemainAfterExit=yes +StandardOutput=journal+console +StandardError=journal+console +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/setup/modules/FreeipaAnsible/image/ipa-first-boot.sh b/setup/modules/FreeipaAnsible/image/ipa-first-boot.sh new file mode 100755 index 0000000..53e7739 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ipa-first-boot.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# ipa-first-boot.sh — runs once on first container start via ipa-first-boot.service +# +# Required environment variables: +# IPA_DOMAIN IPA domain (e.g. corp.example.com) +# IPA_ADMIN_PASSWORD Admin UI / API password +# IPA_DM_PASSWORD Directory Manager (LDAP root) password +# +# Optional environment variables: +# IPA_REALM Kerberos realm (default: DOMAIN uppercased) +# IPA_HOSTNAME Server FQDN (default: container hostname) +# IPA_SETUP_DNS Enable integrated DNS (default: false) +# IPA_DNS_FORWARDER DNS forwarder IP +# IPA_AUTO_REVERSE Auto reverse DNS zone (default: false) +# IPA_SETUP_KRA Install KRA (default: false) +# IPA_NO_NTP Disable NTP setup (default: true) +# IPA_INSTALL_OPTS Extra verbatim flags for ipa-server-install + +set -euo pipefail + +LOG=/var/log/ipa-first-boot.log +exec > >(tee -a "$LOG") 2>&1 +echo "=== ipa-first-boot: $(date) ===" + +if [[ -f /etc/ipa/default.conf ]]; then + echo "FreeIPA already configured — skipping." + exit 0 +fi + +: "${IPA_DOMAIN:?IPA_DOMAIN is required}" +: "${IPA_ADMIN_PASSWORD:?IPA_ADMIN_PASSWORD is required}" +: "${IPA_DM_PASSWORD:?IPA_DM_PASSWORD is required}" + +IPA_REALM="${IPA_REALM:-${IPA_DOMAIN^^}}" +IPA_HOSTNAME="${IPA_HOSTNAME:-$(hostname -f)}" +IPA_SETUP_DNS="${IPA_SETUP_DNS:-false}" +IPA_AUTO_REVERSE="${IPA_AUTO_REVERSE:-false}" +IPA_SETUP_KRA="${IPA_SETUP_KRA:-false}" +IPA_NO_NTP="${IPA_NO_NTP:-true}" + +ARGS=( + --realm="$IPA_REALM" + --domain="$IPA_DOMAIN" + --admin-password="$IPA_ADMIN_PASSWORD" + --ds-password="$IPA_DM_PASSWORD" + --hostname="$IPA_HOSTNAME" + --ip-address="$(hostname -I | awk '{print $1}')" + --mkhomedir + --unattended +) + +if [[ "$IPA_SETUP_DNS" == "true" ]]; then + ARGS+=(--setup-dns) + [[ -n "${IPA_DNS_FORWARDER:-}" ]] \ + && ARGS+=(--forwarder="$IPA_DNS_FORWARDER") \ + || ARGS+=(--no-forwarders) + [[ "$IPA_AUTO_REVERSE" == "true" ]] && ARGS+=(--auto-reverse) || ARGS+=(--no-reverse) +else + ARGS+=(--no-reverse) +fi + +[[ "$IPA_NO_NTP" == "true" ]] && ARGS+=(--no-ntp) +[[ "$IPA_SETUP_KRA" == "true" ]] && ARGS+=(--setup-kra) +[[ -n "${IPA_INSTALL_OPTS:-}" ]] && read -ra EXTRA <<< "$IPA_INSTALL_OPTS" && ARGS+=("${EXTRA[@]}") + +echo "Running ipa-server-install..." +ipa-server-install "${ARGS[@]}" + +# Persist key directories to /data volume so they survive container restarts +if mountpoint -q /data 2>/dev/null; then + echo "Persisting data to /data..." + for d in /var/lib/dirsrv /var/lib/ipa /etc/ipa /etc/dirsrv \ + /etc/named.conf /var/lib/named /var/lib/krb5kdc; do + [[ -e "$d" ]] && rsync -a --relative "$d" /data/ 2>/dev/null || true + done +fi + +echo "=== ipa-first-boot complete: $(date) ===" diff --git a/setup/modules/FreeipaAnsible/image/keycloak-configure.sh b/setup/modules/FreeipaAnsible/image/keycloak-configure.sh new file mode 100755 index 0000000..f771a09 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/keycloak-configure.sh @@ -0,0 +1,272 @@ +#!/bin/bash +# keycloak-configure.sh — wire Keycloak to FreeIPA via LDAP user federation +# +# Run this AFTER both FreeIPA and Keycloak are fully up. +# Reads settings from environment variables or a .env file in the same directory. +# +# Required env vars: +# IPA_SERVER FreeIPA server FQDN +# IPA_DOMAIN FreeIPA domain +# IPA_DM_PASSWORD Directory Manager password (used as LDAP bind credential +# unless IPA_BIND_DN / IPA_BIND_PASSWORD override it) +# KC_ADMIN_PASSWORD Keycloak admin password +# +# Optional env vars (defaults shown): +# KC_URL http://localhost:8080 +# KC_ADMIN admin +# KC_REALM freeipa (realm to create) +# KC_REALM_DISPLAY +# IPA_REALM +# IPA_BIND_DN cn=Directory Manager +# IPA_BIND_PASSWORD +# IPA_USE_LDAPS false +# IPA_LDAP_PORT 389 (or 636 if LDAPS) +# SYNC_FULL_PERIOD 604800 (1 week) +# SYNC_CHANGED_PERIOD 86400 (1 day) + +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' +log() { echo -e "${GREEN}[+]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +error() { echo -e "${RED}[✗]${NC} $*" >&2; } +info() { echo -e "${CYAN}[i]${NC} $*"; } + +: "${IPA_SERVER:?IPA_SERVER is required}" +: "${IPA_DOMAIN:?IPA_DOMAIN is required}" +: "${IPA_DM_PASSWORD:?IPA_DM_PASSWORD is required}" +: "${KC_ADMIN_PASSWORD:?KC_ADMIN_PASSWORD is required}" + +KC_URL="${KC_URL:-http://localhost:8080}" +KC_ADMIN="${KC_ADMIN:-admin}" +KC_REALM="${KC_REALM:-freeipa}" +KC_REALM_DISPLAY="${KC_REALM_DISPLAY:-$IPA_DOMAIN}" +IPA_REALM="${IPA_REALM:-${IPA_DOMAIN^^}}" +IPA_BIND_DN="${IPA_BIND_DN:-cn=Directory Manager}" +IPA_BIND_PASSWORD="${IPA_BIND_PASSWORD:-$IPA_DM_PASSWORD}" +IPA_USE_LDAPS="${IPA_USE_LDAPS:-false}" +IPA_LDAP_SCHEME="ldap" +IPA_LDAP_PORT=389 +[[ "$IPA_USE_LDAPS" == "true" ]] && IPA_LDAP_SCHEME="ldaps" && IPA_LDAP_PORT=636 +IPA_LDAP_URL="${IPA_LDAP_URL:-${IPA_LDAP_SCHEME}://${IPA_SERVER}:${IPA_LDAP_PORT}}" +IPA_BASEDN="dc=${IPA_DOMAIN/./,dc=}" +SYNC_FULL_PERIOD="${SYNC_FULL_PERIOD:-604800}" +SYNC_CHANGED_PERIOD="${SYNC_CHANGED_PERIOD:-86400}" + +# ─── Helpers ────────────────────────────────────────────────────────────────── +kc_token() { + curl -sf -X POST \ + "$KC_URL/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=admin-cli&grant_type=password" \ + -d "username=$KC_ADMIN" \ + --data-urlencode "password=$KC_ADMIN_PASSWORD" \ + | jq -r '.access_token' +} + +kc_get() { curl -sf -H "Authorization: Bearer $TOKEN" "$KC_URL$1"; } +kc_post() { curl -sf -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" -d "$2" "$KC_URL$1"; } +kc_put() { curl -sf -X PUT -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" -d "$2" "$KC_URL$1"; } + +kc_status() { curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" "$KC_URL$1"; } + +# ─── Wait for Keycloak ──────────────────────────────────────────────────────── +info "Waiting for Keycloak at $KC_URL..." +for i in $(seq 1 60); do + curl -sf "$KC_URL/health/ready" &>/dev/null && break + [[ $i -eq 60 ]] && { error "Keycloak not ready after 120s."; exit 1; } + sleep 2 +done +log "Keycloak is ready." + +# ─── Authenticate ───────────────────────────────────────────────────────────── +TOKEN=$(kc_token) +[[ -z "$TOKEN" || "$TOKEN" == "null" ]] && { error "Failed to obtain Keycloak token."; exit 1; } +log "Admin token obtained." + +# ─── Create realm ───────────────────────────────────────────────────────────── +REALM_STATUS=$(kc_status "/admin/realms/$KC_REALM") +if [[ "$REALM_STATUS" == "200" ]]; then + warn "Realm '$KC_REALM' already exists — updating." + kc_put "/admin/realms/$KC_REALM" \ + "{\"realm\":\"$KC_REALM\",\"displayName\":\"$KC_REALM_DISPLAY\",\"enabled\":true, + \"ssoSessionMaxLifespan\":36000,\"accessTokenLifespan\":300}" >/dev/null +else + kc_post "/admin/realms" \ + "{\"realm\":\"$KC_REALM\",\"displayName\":\"$KC_REALM_DISPLAY\",\"enabled\":true, + \"ssoSessionMaxLifespan\":36000,\"accessTokenLifespan\":300}" >/dev/null + log "Realm '$KC_REALM' created." +fi + +TOKEN=$(kc_token) + +# ─── LDAP user federation ───────────────────────────────────────────────────── +log "Configuring FreeIPA LDAP user federation..." + +LDAP_COMPONENT=$(cat </dev/null + LDAP_ID="$EXISTING_ID" +else + LDAP_ID=$(kc_post "/admin/realms/$KC_REALM/components" "$LDAP_COMPONENT" \ + | jq -r '.id // empty') + # Keycloak returns 201 with Location header, not a body with id — extract from header or re-query + if [[ -z "$LDAP_ID" ]]; then + LDAP_ID=$(kc_get "/admin/realms/$KC_REALM/components?type=org.keycloak.storage.UserStorageProvider&name=freeipa-ldap" \ + | jq -r '.[0].id') + fi + log "LDAP provider created (id=$LDAP_ID)." +fi + +# ─── Attribute mappers ──────────────────────────────────────────────────────── +log "Adding LDAP attribute mappers..." + +add_mapper() { + local name="$1" type="$2" ldap_attr="$3" user_attr="$4" + local payload + payload=$(cat </dev/null + log " mapper: $name" + else + warn " mapper '$name' already exists — skipping." + fi +} + +add_mapper "email" "user-attribute-ldap-mapper" "mail" "email" +add_mapper "first-name" "user-attribute-ldap-mapper" "givenName" "firstName" +add_mapper "last-name" "user-attribute-ldap-mapper" "sn" "lastName" +add_mapper "uid-number" "user-attribute-ldap-mapper" "uidNumber" "uidNumber" + +# Group mapper (maps IPA groups to Keycloak groups) +GROUP_MAPPER=$(cat </dev/null + log " mapper: freeipa-groups" +fi + +# ─── Trigger initial sync ────────────────────────────────────────────────────── +log "Triggering initial user sync..." +SYNC_RESULT=$(kc_post "/admin/realms/$KC_REALM/user-storage/$LDAP_ID/sync?action=triggerFullSync" "" 2>/dev/null || echo "{}") +ADDED=$(echo "$SYNC_RESULT" | jq -r '.added // 0') +UPDATED=$(echo "$SYNC_RESULT" | jq -r '.updated // 0') +log "Sync complete: $ADDED added, $UPDATED updated." + +# ─── Enable email login ──────────────────────────────────────────────────────── +kc_put "/admin/realms/$KC_REALM" \ + '{"loginWithEmailAllowed":true,"duplicateEmailsAllowed":false}' >/dev/null +log "Email login enabled on realm '$KC_REALM'." + +# ─── Summary ───────────────────────────────────────────────────────────────── +cat <&2; } +info() { echo -e "${CYAN}[i]${NC} $*"; } +section() { echo -e "\n${BLUE}━━━ $* ━━━${NC}"; } +ask() { printf "${MAGENTA}[?]${NC} %s " "$*"; } + +[[ $EUID -eq 0 ]] && { error "Run as your normal user (not root)."; exit 1; } + +# ─── Detect container engine ────────────────────────────────────────────────── +if command -v podman &>/dev/null; then + ENGINE="podman" +elif command -v docker &>/dev/null; then + ENGINE="docker" +else + error "Neither podman nor docker found. Install one first." + exit 1 +fi +info "Container engine: $ENGINE" + +# ─── dialog theme ───────────────────────────────────────────────────────────── +TMP_D="$(mktemp -d)"; trap 'rm -rf "$TMP_D"' EXIT +BACKTITLE="FreeIPA Image Builder" +export DIALOGRC="$TMP_D/dialogrc" +cat > "$DIALOGRC" <<'EOF' +use_shadow = ON +use_colors = ON +screen_color = (BLACK,BLACK,ON) +title_color = (MAGENTA,BLACK,ON) +border_color = (MAGENTA,BLACK,ON) +button_active_color = (BLACK,MAGENTA,ON) +button_inactive_color = (WHITE,BLACK,OFF) +menubox_color = (WHITE,BLACK,OFF) +menubox_border_color = (MAGENTA,BLACK,ON) +item_color = (WHITE,BLACK,OFF) +item_selected_color = (BLACK,MAGENTA,ON) +tag_color = (CYAN,BLACK,ON) +tag_selected_color = (BLACK,CYAN,ON) +check_color = (WHITE,BLACK,OFF) +check_selected_color = (BLACK,MAGENTA,ON) +uarrow_color = (MAGENTA,BLACK,ON) +darrow_color = (MAGENTA,BLACK,ON) +EOF + +command -v dialog &>/dev/null || { sudo pacman -S --noconfirm dialog; } + +# ─── Target chooser ─────────────────────────────────────────────────────────── +TARGET=$(dialog --backtitle "$BACKTITLE" \ + --title " Select Target Format " \ + --menu "Choose the output format:" 16 70 4 \ + "docker" "Docker / Podman image (local + optional registry push)" \ + "lxc" "LXC / Proxmox CT template (.tar.zst rootfs archive)" \ + "proxmox-vm" "Proxmox VM (cloud-init QCOW2, requires virt-customize)" \ + "oci-archive" "OCI archive (skopeo tarball for air-gapped import)" \ + 3>&1 1>&2 2>&3) || { clear; echo "Aborted."; exit 0; } + +# ─── Keycloak? ─────────────────────────────────────────────────────────────── +WITH_KEYCLOAK=false +dialog --backtitle "$BACKTITLE" \ + --title " Keycloak Integration " \ + --yesno "\nInclude Keycloak in the output?\n\n\ +Adds PostgreSQL + Keycloak to the compose stack and generates\n\ +keycloak-configure.sh for LDAP federation post-start setup." 11 64 \ + && WITH_KEYCLOAK=true || true + +# ─── FreeIPA config ─────────────────────────────────────────────────────────── +section "FreeIPA configuration" +info "These values are embedded in the .env template and compose file." +info "Override them at runtime via environment variables." +echo + +ask "IPA Domain (e.g. corp.example.com):"; read -r IPA_DOMAIN +[[ -z "$IPA_DOMAIN" ]] && { error "IPA_DOMAIN is required."; exit 1; } + +GUESSED_HOSTNAME="ipa.$IPA_DOMAIN" +ask "Server FQDN [$GUESSED_HOSTNAME]:"; read -r I; IPA_HOSTNAME="${I:-$GUESSED_HOSTNAME}" + +GUESSED_REALM="${IPA_DOMAIN^^}" +ask "Kerberos Realm [$GUESSED_REALM]:"; read -r I; IPA_REALM="${I:-$GUESSED_REALM}" + +ask "Setup integrated DNS? [y/N]:"; read -r I +IPA_SETUP_DNS=false; [[ "${I,,}" == "y"* ]] && IPA_SETUP_DNS=true + +IMAGE_TAG="freeipa-server:${IPA_DOMAIN//./-}" +ask "Image tag [$IMAGE_TAG]:"; read -r I; IMAGE_TAG="${I:-$IMAGE_TAG}" + +# Output directory +DEFAULT_OUT="$HOME/freeipa-image-output" +ask "Output directory [$DEFAULT_OUT]:"; read -r I; OUTPUT_DIR="${I:-$DEFAULT_OUT}" + +# Keycloak config (if selected) +KC_HOSTNAME="keycloak.$IPA_DOMAIN" +KC_REALM="${IPA_DOMAIN%%.*}" +if [[ "$WITH_KEYCLOAK" == true ]]; then + echo + ask "Keycloak hostname [$KC_HOSTNAME]:"; read -r I; KC_HOSTNAME="${I:-$KC_HOSTNAME}" + ask "Keycloak realm name [$KC_REALM]:"; read -r I; KC_REALM="${I:-$KC_REALM}" +fi + +# Proxmox VM cloud image (only if proxmox-vm selected) +CLOUD_IMAGE_URL="" +if [[ "$TARGET" == "proxmox-vm" ]]; then + echo + ROCKY_URL="https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2" + ask "Cloud image URL [$ROCKY_URL]:"; read -r I; CLOUD_IMAGE_URL="${I:-$ROCKY_URL}" +fi + +# ─── Confirm ────────────────────────────────────────────────────────────────── +echo +info "──────────────────────────────────────" +printf " Target: %s\n" "$TARGET" +printf " Domain: %s\n" "$IPA_DOMAIN" +printf " FQDN: %s\n" "$IPA_HOSTNAME" +printf " Realm: %s\n" "$IPA_REALM" +printf " Image tag: %s\n" "$IMAGE_TAG" +printf " Keycloak: %s\n" "$WITH_KEYCLOAK" +printf " Output: %s\n" "$OUTPUT_DIR" +info "──────────────────────────────────────" +echo +ask "Proceed? [y/N]:"; read -r CONFIRM +[[ "${CONFIRM,,}" != "y"* ]] && { echo "Aborted."; exit 0; } + +mkdir -p "$OUTPUT_DIR" + +# ─── Step 1: Always build the container image ───────────────────────────────── +section "Building container image ($ENGINE)" +$ENGINE build \ + --tag "$IMAGE_TAG" \ + --label "ipa.domain=$IPA_DOMAIN" \ + --label "ipa.realm=$IPA_REALM" \ + "$IMAGE_SRC" +log "Image built: $IMAGE_TAG" + +# ─── Step 2: Target-specific export ────────────────────────────────────────── + +case "$TARGET" in + + docker) + section "Docker/Podman — local image ready" + log "Image '$IMAGE_TAG' is in the local $ENGINE store." + + ask "Push to a registry? [y/N]:"; read -r I + if [[ "${I,,}" == "y"* ]]; then + ask "Registry image name (e.g. registry.example.com/freeipa-server:latest):"; read -r REG_TAG + if [[ -n "$REG_TAG" ]]; then + $ENGINE tag "$IMAGE_TAG" "$REG_TAG" + $ENGINE push "$REG_TAG" + log "Pushed: $REG_TAG" + fi + fi + ;; + + lxc) + section "LXC / Proxmox CT template" + LXC_ARCHIVE="$OUTPUT_DIR/freeipa-server-lxc.tar.zst" + + info "Creating temporary container to export rootfs..." + TMP_CTR="freeipa-lxc-export-$$" + $ENGINE create --name "$TMP_CTR" "$IMAGE_TAG" /bin/true + + info "Exporting rootfs (this may take a minute)..." + if command -v zstd &>/dev/null; then + $ENGINE export "$TMP_CTR" | zstd -T0 -o "$LXC_ARCHIVE" + else + warn "zstd not found — falling back to gzip (.tar.gz)" + LXC_ARCHIVE="${LXC_ARCHIVE%.tar.zst}.tar.gz" + $ENGINE export "$TMP_CTR" | gzip -9 > "$LXC_ARCHIVE" + fi + $ENGINE rm "$TMP_CTR" &>/dev/null + log "LXC template: $LXC_ARCHIVE" + + # Proxmox import instructions + cat > "$OUTPUT_DIR/lxc-import-instructions.txt" < local:vztmpl/$(basename "$LXC_ARCHIVE") \\ + --hostname $IPA_HOSTNAME \\ + --memory 2048 --cores 2 \\ + --net0 name=eth0,bridge=vmbr0,ip=dhcp \\ + --ostype fedora --unprivileged 0 + + NOTE: FreeIPA requires a privileged LXC container (unprivileged=0). + Add to /etc/pve/lxc/.conf: + lxc.apparmor.profile: unconfined + lxc.cap.drop: + +Set FreeIPA env vars before first start: + pct exec -- bash -c 'cat >> /etc/environment < + pct exec -- journalctl -f -u ipa-first-boot +LXCTXT + log "Instructions: $OUTPUT_DIR/lxc-import-instructions.txt" + ;; + + proxmox-vm) + section "Proxmox VM — cloud-init QCOW2" + + if ! command -v virt-customize &>/dev/null; then + error "virt-customize not found. Install libguestfs-tools:" + error " sudo pacman -S libguestfs # Arch" + error " sudo dnf install libguestfs-tools # Fedora/RHEL" + exit 1 + fi + if ! command -v qemu-img &>/dev/null; then + error "qemu-img not found. Install qemu-img / qemu-tools." + exit 1 + fi + + CLOUD_IMG="$TMP_D/cloud-base.qcow2" + QCOW2_OUT="$OUTPUT_DIR/freeipa-server.qcow2" + + info "Downloading cloud image..." + curl -L --progress-bar -o "$CLOUD_IMG" "$CLOUD_IMAGE_URL" + + info "Customizing image (installing FreeIPA packages)..." + virt-customize -a "$CLOUD_IMG" \ + --install freeipa-server,freeipa-server-dns,ansible-core,python3-netaddr \ + --copy-in "$IMAGE_SRC/ipa-first-boot.sh":/usr/local/sbin/ \ + --copy-in "$IMAGE_SRC/ipa-first-boot.service":/etc/systemd/system/ \ + --run-command "chmod +x /usr/local/sbin/ipa-first-boot.sh" \ + --run-command "systemctl enable ipa-first-boot.service" \ + --selinux-relabel + + qemu-img convert -O qcow2 "$CLOUD_IMG" "$QCOW2_OUT" + log "QCOW2: $QCOW2_OUT" + + # Cloud-init snippet + cat > "$OUTPUT_DIR/cloud-init-user-data.yml" < --name freeipa --memory 4096 --cores 4 --net0 virtio,bridge=vmbr0" + info " qm importdisk $QCOW2_OUT local-lvm" + info " qm set --scsihw virtio-scsi-pci --scsi0 local-lvm:vm--disk-0" + info " qm set --ide2 local-lvm:cloudinit --boot c --bootdisk scsi0" + ;; + + oci-archive) + section "OCI archive (skopeo)" + + if ! command -v skopeo &>/dev/null; then + error "skopeo not found. Install: sudo pacman -S skopeo" + exit 1 + fi + + OCI_ARCHIVE="$OUTPUT_DIR/freeipa-server-oci.tar" + info "Exporting OCI archive..." + skopeo copy \ + "${ENGINE}-daemon:${IMAGE_TAG}" \ + "oci-archive:${OCI_ARCHIVE}:latest" + log "OCI archive: $OCI_ARCHIVE" + + info "Import on air-gapped host:" + info " skopeo copy oci-archive:freeipa-server-oci.tar docker-daemon:freeipa-server:latest" + info " # or for podman:" + info " skopeo copy oci-archive:freeipa-server-oci.tar containers-storage:freeipa-server:latest" + ;; + +esac + +# ─── Generate compose + .env for all targets ───────────────────────────────── +section "Generating deployment files → $OUTPUT_DIR" + +cp "$IMAGE_SRC/docker-compose.yml" "$OUTPUT_DIR/docker-compose.yml" +cp "$IMAGE_SRC/keycloak-configure.sh" "$OUTPUT_DIR/keycloak-configure.sh" +chmod +x "$OUTPUT_DIR/keycloak-configure.sh" + +# Patch compose to reference the built image tag instead of building locally +sed -i "s|image: freeipa-server:local|image: $IMAGE_TAG|" "$OUTPUT_DIR/docker-compose.yml" + +if [[ "$WITH_KEYCLOAK" == false ]]; then + # Comment out Keycloak and postgres services + python3 - < "$OUTPUT_DIR/.env" < IPA_SERVER=$IPA_HOSTNAME \\ + IPA_DOMAIN=$IPA_DOMAIN IPA_DM_PASSWORD= \\ + ./keycloak-configure.sh +KC +) +EOF diff --git a/setup/tui-install.sh b/setup/tui-install.sh index d627e3a..f3b8b99 100755 --- a/setup/tui-install.sh +++ b/setup/tui-install.sh @@ -129,7 +129,8 @@ count_steps() { [[ "$a" == *"podman"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$a" == *"cockpit"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$a" == *"ssh-server"* ]] && TOTAL=$(( TOTAL + 1 )) - [[ "$a" == *"freeipa-server"* ]] && TOTAL=$(( TOTAL + 1 )) + [[ "$a" == *"freeipa-server"* ]] && TOTAL=$(( TOTAL + 1 )) + [[ "$a" == *"freeipa-image"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$a" == *"python"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$a" == *"zfs"* ]] && TOTAL=$(( TOTAL + 1 )) [[ "$a" == *"wprs"* ]] && TOTAL=$(( TOTAL + 1 )) @@ -220,6 +221,7 @@ SELECTED_APPS=$(dialog --backtitle "$BACKTITLE" \ "cockpit" "Cockpit web UI · machines · podman" off \ "ssh-server" "SSH server openssh · key-auth · enabled" off \ "freeipa-server" "FreeIPA Server interactive server setup + client gen" off \ + "freeipa-image" "FreeIPA Image OCI/LXC/Proxmox/VM builder + Keycloak" off \ "python" "Python tools pyright · pipx · pynvim" off \ "zfs" "ZFS zfs-dkms kernel module" off \ "wprs" "WPRS wprs-git (AUR)" off \ @@ -271,6 +273,7 @@ if [[ -n "$SELECTED_APPS" ]]; then [[ "$SELECTED_APPS" == *"cockpit"* ]] && SUMMARY+=" ✦ Cockpit web UI\n" [[ "$SELECTED_APPS" == *"ssh-server"* ]] && SUMMARY+=" ✦ SSH server (openssh, key auth)\n" [[ "$SELECTED_APPS" == *"freeipa-server"* ]] && SUMMARY+=" ✦ FreeIPA Server\n" + [[ "$SELECTED_APPS" == *"freeipa-image"* ]] && SUMMARY+=" ✦ FreeIPA Image Builder\n" [[ "$SELECTED_APPS" == *"python"* ]] && SUMMARY+=" ✦ Python tools\n" [[ "$SELECTED_APPS" == *"zfs"* ]] && SUMMARY+=" ✦ ZFS\n" [[ "$SELECTED_APPS" == *"wprs"* ]] && SUMMARY+=" ✦ WPRS\n" @@ -333,6 +336,7 @@ fi [[ "$SELECTED_APPS" == *"cockpit"* ]] && run_module "Cockpit" "$APPS/cockpit.sh" [[ "$SELECTED_APPS" == *"ssh-server"* ]] && run_module "SSH Server" "$APPS/ssh-server.sh" [[ "$SELECTED_APPS" == *"freeipa-server"* ]] && run_module "FreeIPA Server" "$APPS/freeipa-server.sh" +[[ "$SELECTED_APPS" == *"freeipa-image"* ]] && run_module "FreeIPA Image" "$APPS/freeipa-image-builder.sh" [[ "$SELECTED_APPS" == *"python"* ]] && run_module "Python Tools" "$MODULES/optional-Modules/python.sh" [[ "$SELECTED_APPS" == *"zfs"* ]] && run_module "ZFS" "$MODULES/optional-Modules/zfs.sh" [[ "$SELECTED_APPS" == *"wprs"* ]] && run_module "WPRS" "$MODULES/optional-Modules/wprs.sh"