Dotfiles/setup/modules/FreeipaAnsible/image/nextcloud-configure.sh

245 lines
12 KiB
Bash
Executable File

#!/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 <<EOF
${GREEN}Nextcloud identity configuration complete.${NC}
Nextcloud: $NC_PUBLIC_URL
Accounts from: ${LDAP_SCHEME}://${IPA_SERVER} (config $LDAP_CFG)
users: cn=users,cn=accounts,$IPA_BASEDN
groups: cn=groups,cn=accounts,$IPA_BASEDN
Login via: $DISCOVERY_URI
Break-glass: ${NC_ADMIN_USER:-ncadmin} at $NC_PUBLIC_URL/login?direct=1
Verify:
• docker compose exec -u www-data nextcloud php occ user:list
→ should list your FreeIPA users
• Log in through the "Keycloak" button and confirm with occ user:list that
NO second account appeared for the same person. If one did, the uid
mapping is off — check ldapExpertUsernameAttr and --unique-uid.
Note: background jobs default to AJAX. For a real deployment switch to cron
(add a nextcloud-cron container running /cron.php, then
occ background:cron).
EOF