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