commit c5566640564a96b031313037fa9219d69d6f2f0e Author: Patrick de Ruiter Date: Thu Dec 25 12:36:39 2025 +0100 Initial Commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2553f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Required +LDAP_DOMAIN=example.com +LDAP_ORGANISATION=Example ORG +LDAP_ADMIN_PASSWORD=Yourpasswordhere + +# Optional - TLS +LDAP_TLS_ENABLED=true +LDAP_TLS_CERT_FILE=/certs/ldap.crt +LDAP_TLS_KEY_FILE=/certs/ldap.key +LDAP_TLS_CA_FILE=/certs/ca.crt + +# Optional - Service accounts +LDAP_CREATE_SERVICE_ACCOUNTS=true +# If not set, random passwords will be generated and saved to /var/lib/openldap/service-passwords.txt +# LDAP_SERVICE_KEYCLOAK_PASSWORD= +# LDAP_SERVICE_NEXTCLOUD_PASSWORD= +# LDAP_SERVICE_GITEA_PASSWORD= +# LDAP_SERVICE_POSTFIX_PASSWORD= +# LDAP_SERVICE_DOVECOT_PASSWORD= +# LDAP_SERVICE_SSSD_PASSWORD= + +# Optional - Logging +LDAP_LOG_LEVEL=256 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d449876 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Env files usualy contain credentials, we do not want to store those in git +.env + +# No Claude code stuff in here either +.claude + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73c7255 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM alpine:3.23 + +LABEL maintainer="WeBuildYourCloud" +LABEL description="Enterprise OpenLDAP container with rfc2307bis, memberOf, and custom schemas" + +# Install OpenLDAP and required packages +RUN apk add --no-cache \ + openldap \ + openldap-clients \ + openldap-back-mdb \ + openldap-overlay-memberof \ + openldap-overlay-refint \ + openldap-overlay-unique \ + openldap-overlay-ppolicy \ + openssl \ + argon2 \ + && mkdir -p /var/lib/openldap/openldap-data \ + && mkdir -p /etc/openldap/slapd.d \ + && mkdir -p /run/openldap \ + && mkdir -p /certs \ + && chown -R ldap:ldap /var/lib/openldap \ + && chown -R ldap:ldap /etc/openldap/slapd.d \ + && chown -R ldap:ldap /run/openldap + +# Copy custom schemas +COPY schema/*.schema /etc/openldap/schema/ + +# Copy initialization scripts +COPY scripts/ /scripts/ +RUN chmod +x /scripts/*.sh + +# Copy LDIF templates +COPY ldif/ /ldif/ + +# Copy entrypoint +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose ports +EXPOSE 389 636 + +# Volumes for persistence +VOLUME ["/var/lib/openldap/openldap-data", "/etc/openldap/slapd.d", "/certs"] + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ldapsearch -x -H ldap://localhost -b "" -s base "objectClass=*" || exit 1 + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bbd647 --- /dev/null +++ b/README.md @@ -0,0 +1,669 @@ +# OpenLDAP Container Operations Guide + +## Introduction + +This document provides operational guidance for deploying and managing the Enterprise OpenLDAP container. It explains not just the how, but also the what and why behind each configuration decision. + +### What This Container Provides + +This is a purpose-built OpenLDAP container designed to serve as a central identity provider for an enterprise environment. It provides: + +- **Centralized user authentication** for all services (Keycloak, Nextcloud, Gitea, mail, etc.) +- **POSIX account support** for Linux systems via SSSD integration +- **Group-based access control** with automatic membership tracking +- **Service account isolation** so each application has its own credentials with limited permissions + +### Why OpenLDAP? + +LDAP (Lightweight Directory Access Protocol) remains the standard for centralized identity management because: + +1. **Universal support** - Nearly every enterprise application supports LDAP authentication +2. **Hierarchical organization** - Natural fit for organizational structures (users, groups, departments) +3. **Fine-grained access control** - Different applications can have different levels of access +4. **Proven reliability** - Decades of production use in demanding environments + +### Key Design Decisions + +**RFC2307bis instead of NIS schema**: Traditional LDAP setups use the NIS schema where `posixGroup` is a STRUCTURAL class. This prevents combining POSIX groups with other group types. We use RFC2307bis where `posixGroup` is AUXILIARY, allowing groups to have both POSIX attributes (gidNumber) and member tracking (member attribute). This is essential for the memberOf overlay to work correctly. + +**Pre-configured overlays**: The container includes four overlays that are critical for enterprise use: +- **memberOf** - Automatically maintains reverse group membership on user objects +- **refint** - Maintains referential integrity when users are deleted +- **unique** - Prevents duplicate UIDs, email addresses, and ID numbers +- **ppolicy** - Enforces password complexity and expiration policies + +**Service accounts**: Instead of sharing the admin password with applications, each service gets its own account with read-only access to the directory. This follows the principle of least privilege and provides audit trails. + +--- + +## Architecture Overview + +### Directory Structure + +The container creates a directory tree optimized for enterprise use: + +``` +dc=example,dc=com ← Your organization's base +│ +├── ou=People ← All user accounts live here +│ └── uid=jdoe ← Individual user +│ +├── ou=Groups ← Authorization and access groups +│ ├── cn=admins ← LDAP administrators +│ └── cn=developers ← Example application group +│ +├── ou=Services ← Application service accounts +│ ├── cn=keycloak ← For Keycloak LDAP federation +│ ├── cn=nextcloud ← For Nextcloud user backend +│ ├── cn=gitea ← For Gitea authentication +│ ├── cn=postfix ← For mail routing lookups +│ ├── cn=dovecot ← For mail authentication +│ └── cn=sssd ← For Linux PAM/NSS +│ +├── ou=Domains ← Virtual mail domains (for mail server) +│ +├── ou=Policies ← Password and security policies +│ └── cn=default ← Default password policy +│ +└── ou=Kerberos ← Reserved for Kerberos integration +``` + +**Why this structure?** + +- **Separate OUs for each purpose** makes ACLs (access control lists) simpler and more secure +- **Service accounts in their own OU** allows restricting what parts of the tree they can access +- **Policies OU** keeps password policies separate from user data +- **Domains OU** is specifically for mail server virtual domain configuration + +### How Authentication Works + +When an application authenticates a user: + +1. Application connects to LDAP using its service account (e.g., `cn=keycloak,ou=Services,...`) +2. Application searches for the user in `ou=People` by uid or email +3. Application attempts to bind (authenticate) as that user with their password +4. LDAP verifies the password and returns success/failure +5. Application can then query the user's groups via the `memberOf` attribute + +The service account never sees user passwords - it only searches for users. The actual password verification happens when the user's DN is used for a bind operation. + +--- + +## Getting Started + +### Prerequisites + +Before deploying, ensure you have: + +- Docker installed and running +- Decided on your domain name (this determines your base DN) +- Generated a strong admin password +- (Optional) TLS certificates if you need encrypted connections + +### Step 1: Basic Deployment + +The simplest deployment requires just three pieces of information: + +```bash +docker run -d --name openldap \ + -e LDAP_DOMAIN=example.com \ + -e LDAP_ORGANISATION="Example Corporation" \ + -e LDAP_ADMIN_PASSWORD=your-secure-password \ + -p 389:389 \ + enterprise-openldap:latest +``` + +**What happens on first run:** + +1. The container detects this is a fresh start (no existing database) +2. It generates the base DN from your domain (`example.com` → `dc=example,dc=com`) +3. Creates the cn=config database with your settings +4. Loads all required schemas in the correct order +5. Configures the overlays (memberOf, refint, unique, ppolicy) +6. Creates the directory structure (OUs) +7. Sets up ACLs to protect sensitive data +8. Optionally creates service accounts +9. Starts the LDAP server + +On subsequent restarts, steps 2-8 are skipped - the existing database is used. + +### Step 2: Verify the Deployment + +Test that the server is responding: + +```bash +# Anonymous query to verify the server is up +ldapsearch -x -H ldap://localhost:389 -b "" -s base "(objectClass=*)" +``` + +Test admin authentication: + +```bash +ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=example,dc=com" \ + -w "your-secure-password" \ + -b "dc=example,dc=com" "(objectClass=organizationalUnit)" +``` + +You should see the OUs (People, Groups, Services, etc.) listed. + +### Step 3: Enable Service Accounts (Recommended) + +Service accounts allow applications to connect with limited, auditable credentials: + +```bash +docker run -d --name openldap \ + -e LDAP_DOMAIN=example.com \ + -e LDAP_ORGANISATION="Example Corporation" \ + -e LDAP_ADMIN_PASSWORD=your-secure-password \ + -e LDAP_CREATE_SERVICE_ACCOUNTS=true \ + -p 389:389 \ + enterprise-openldap:latest +``` + +The generated passwords are saved to `/var/lib/openldap/service-passwords.txt` inside the container. Retrieve them: + +```bash +docker exec openldap cat /var/lib/openldap/service-passwords.txt +``` + +**Important**: Save these passwords securely, then delete the file: + +```bash +docker exec openldap rm /var/lib/openldap/service-passwords.txt +``` + +### Step 4: Add Persistence + +Without volumes, your data is lost when the container is removed. For production, always use volumes: + +```bash +docker run -d --name openldap \ + -v openldap-data:/var/lib/openldap/openldap-data \ + -v openldap-config:/etc/openldap/slapd.d \ + -e LDAP_DOMAIN=example.com \ + -e LDAP_ORGANISATION="Example Corporation" \ + -e LDAP_ADMIN_PASSWORD=your-secure-password \ + -e LDAP_CREATE_SERVICE_ACCOUNTS=true \ + -p 389:389 \ + enterprise-openldap:latest +``` + +**Volume purposes:** + +| Volume | Contains | Why it matters | +|--------|----------|----------------| +| `/var/lib/openldap/openldap-data` | The actual LDAP database (MDB format) | All your users, groups, and data | +| `/etc/openldap/slapd.d` | The cn=config configuration | Schema, overlays, ACLs, indexes | + +### Step 5: Enable TLS (Production) + +For production environments, you should enable TLS to encrypt connections: + +```bash +docker run -d --name openldap \ + -v openldap-data:/var/lib/openldap/openldap-data \ + -v openldap-config:/etc/openldap/slapd.d \ + -v /path/to/certs:/certs:ro \ + -e LDAP_DOMAIN=example.com \ + -e LDAP_ORGANISATION="Example Corporation" \ + -e LDAP_ADMIN_PASSWORD=your-secure-password \ + -e LDAP_TLS_ENABLED=true \ + -p 389:389 -p 636:636 \ + enterprise-openldap:latest +``` + +The `/certs` directory must contain: +- `ldap.crt` - Server certificate +- `ldap.key` - Private key (readable only by root/ldap user) +- `ca.crt` - CA certificate (for client verification) + +**Port 389 vs 636:** +- Port 389 (LDAP) - Unencrypted, but supports STARTTLS upgrade +- Port 636 (LDAPS) - TLS from the start, like HTTPS + +Most modern applications support STARTTLS on port 389, which is generally preferred. + +--- + +## Configuration Reference + +### Required Environment Variables + +These must be set for the container to start: + +| Variable | Purpose | Example | +|----------|---------|---------| +| `LDAP_DOMAIN` | Your organization's domain. Converted to base DN automatically. | `example.com` → `dc=example,dc=com` | +| `LDAP_ORGANISATION` | Human-readable organization name, stored in the base entry. | `Example Corporation` | +| `LDAP_ADMIN_PASSWORD` | Password for the directory administrator account. **Use cleartext** - the container hashes it automatically with `slappasswd`. | `Str0ng!Passw0rd` | + +### Optional Environment Variables + +#### General Settings + +| Variable | Default | Purpose | +|----------|---------|---------| +| `LDAP_BASE_DN` | Generated from domain | Override if you need a non-standard base DN | +| `LDAP_CONFIG_PASSWORD` | Random | Password for cn=config administrative access. Only needed for advanced configuration changes. | +| `LDAP_LOG_LEVEL` | `256` | Controls logging verbosity. `256` logs connections and operations. See [Log Levels](#understanding-log-levels). | + +#### TLS Settings + +| Variable | Default | Purpose | +|----------|---------|---------| +| `LDAP_TLS_ENABLED` | `true` | Master switch for TLS. Set `false` to disable completely. | +| `LDAP_TLS_CERT_FILE` | `/certs/ldap.crt` | Path to server certificate inside container | +| `LDAP_TLS_KEY_FILE` | `/certs/ldap.key` | Path to private key inside container | +| `LDAP_TLS_CA_FILE` | `/certs/ca.crt` | Path to CA certificate for client verification | +| `LDAP_TLS_VERIFY_CLIENT` | `try` | Client cert policy: `never` (don't ask), `try` (optional), `demand` (required) | + +#### Service Account Settings + +| Variable | Default | Purpose | +|----------|---------|---------| +| `LDAP_CREATE_SERVICE_ACCOUNTS` | `false` | Set `true` to create pre-configured service accounts | +| `LDAP_SERVICE_KEYCLOAK_PASSWORD` | Random | Explicit password for Keycloak account | +| `LDAP_SERVICE_NEXTCLOUD_PASSWORD` | Random | Explicit password for Nextcloud account | +| `LDAP_SERVICE_GITEA_PASSWORD` | Random | Explicit password for Gitea account | +| `LDAP_SERVICE_POSTFIX_PASSWORD` | Random | Explicit password for Postfix account | +| `LDAP_SERVICE_DOVECOT_PASSWORD` | Random | Explicit password for Dovecot account | +| `LDAP_SERVICE_SSSD_PASSWORD` | Random | Explicit password for SSSD account | + +**When to set explicit passwords:** If you're deploying with configuration management (Ansible, Terraform) and need predictable credentials, set them explicitly. For manual deployments, random passwords are more secure. + +--- + +## Day-to-Day Operations + +### Adding Users + +Users belong in `ou=People`. Here's a complete user entry: + +```ldif +dn: uid=jdoe,ou=People,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +uid: jdoe +cn: John Doe +sn: Doe +givenName: John +mail: john.doe@example.com +uidNumber: 10001 +gidNumber: 10000 +homeDirectory: /home/jdoe +loginShell: /bin/bash +userPassword: {SSHA}hashed-password +``` + +**Object classes explained:** +- `inetOrgPerson` - Standard LDAP person with email, phone, etc. +- `posixAccount` - UNIX account attributes (uid/gid numbers, home directory, shell) +- `shadowAccount` - Password aging and expiration + +Add the user: + +```bash +ldapadd -x -H ldap://localhost:389 \ + -D "cn=admin,dc=example,dc=com" \ + -w "admin-password" \ + -f user.ldif +``` + +### Adding Groups + +Groups use `groupOfMembers` (for member tracking) combined with `posixGroup` (for UNIX gidNumber): + +```ldif +dn: cn=developers,ou=Groups,dc=example,dc=com +objectClass: groupOfMembers +objectClass: posixGroup +cn: developers +gidNumber: 10001 +description: Development team +member: uid=jdoe,ou=People,dc=example,dc=com +``` + +**Why groupOfMembers instead of groupOfNames?** + +`groupOfNames` requires at least one member - you can't have an empty group. `groupOfMembers` allows empty groups, which is often needed when setting up access control before adding members. + +### Checking Group Membership + +Thanks to the memberOf overlay, you can see a user's groups directly: + +```bash +ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=example,dc=com" \ + -w "admin-password" \ + -b "uid=jdoe,ou=People,dc=example,dc=com" \ + memberOf +``` + +This returns all groups the user belongs to, without having to search each group. + +### Modifying Entries + +To change an attribute: + +```ldif +dn: uid=jdoe,ou=People,dc=example,dc=com +changetype: modify +replace: mail +mail: j.doe@example.com +``` + +```bash +ldapmodify -x -H ldap://localhost:389 \ + -D "cn=admin,dc=example,dc=com" \ + -w "admin-password" \ + -f modify.ldif +``` + +### Deleting Entries + +```bash +ldapdelete -x -H ldap://localhost:389 \ + -D "cn=admin,dc=example,dc=com" \ + -w "admin-password" \ + "uid=jdoe,ou=People,dc=example,dc=com" +``` + +The refint overlay automatically removes the user from any groups they belonged to. + +--- + +## Connecting Applications + +### Keycloak + +Keycloak uses LDAP for user federation. Configure it with: + +| Setting | Value | +|---------|-------| +| Connection URL | `ldap://openldap:389` | +| Bind DN | `cn=keycloak,ou=Services,dc=example,dc=com` | +| Bind Credential | (from service-passwords.txt) | +| Users DN | `ou=People,dc=example,dc=com` | +| Username Attribute | `uid` | +| RDN Attribute | `uid` | +| UUID Attribute | `entryUUID` | + +### Nextcloud + +In Nextcloud's LDAP settings: + +| Setting | Value | +|---------|-------| +| Host | `openldap` | +| Port | `389` | +| User DN | `cn=nextcloud,ou=Services,dc=example,dc=com` | +| Password | (from service-passwords.txt) | +| Base DN | `dc=example,dc=com` | +| User Filter | `(&(objectClass=inetOrgPerson)(uid=%uid))` | +| Group Filter | `(objectClass=groupOfMembers)` | + +### SSSD (Linux PAM/NSS) + +For Linux systems to authenticate against LDAP: + +```ini +# /etc/sssd/sssd.conf +[sssd] +services = nss, pam +domains = example.com + +[domain/example.com] +id_provider = ldap +auth_provider = ldap +ldap_uri = ldap://openldap.example.com +ldap_search_base = dc=example,dc=com +ldap_default_bind_dn = cn=sssd,ou=Services,dc=example,dc=com +ldap_default_authtok = sssd-password-here +ldap_user_search_base = ou=People,dc=example,dc=com +ldap_group_search_base = ou=Groups,dc=example,dc=com +``` + +--- + +## Backup and Recovery + +### Why Backups Matter + +The LDAP directory contains: +- All user accounts and credentials +- Group memberships and access control +- Service account configurations +- Password policies + +Losing this data means losing access to all integrated systems. + +### Creating Backups + +Export the directory to LDIF format: + +```bash +# Backup user data (database 1) +docker exec openldap slapcat -n 1 > ldap-data-$(date +%Y%m%d).ldif + +# Backup configuration (database 0) - optional but recommended +docker exec openldap slapcat -n 0 > ldap-config-$(date +%Y%m%d).ldif +``` + +**Backup frequency recommendation:** +- Daily for the data backup +- After any configuration changes for the config backup + +### Restoring from Backup + +**Warning**: This destroys all existing data! + +```bash +# Stop the container +docker stop openldap +docker rm openldap + +# Remove existing volumes +docker volume rm openldap-data openldap-config + +# Create fresh container with backup +docker run --rm \ + -v openldap-data:/var/lib/openldap/openldap-data \ + -v $(pwd)/ldap-data-20240101.ldif:/backup.ldif:ro \ + enterprise-openldap:latest \ + sh -c "slapadd -n 1 -l /backup.ldif && chown -R ldap:ldap /var/lib/openldap" + +# Start normally +docker run -d --name openldap \ + -v openldap-data:/var/lib/openldap/openldap-data \ + -v openldap-config:/etc/openldap/slapd.d \ + ... # your normal settings +``` + +--- + +## Troubleshooting + +### Understanding Log Levels + +The `LDAP_LOG_LEVEL` controls what gets logged. Common values: + +| Level | What it shows | When to use | +|-------|---------------|-------------| +| `0` | Nothing | Never in production | +| `256` | Connections, operations, results | Normal operation | +| `384` | Above + ACL decisions | Debugging access issues | +| `512` | Stats with entry counts | Performance analysis | +| `-1` | Everything | Last resort debugging | + +Increase temporarily for debugging: + +```bash +docker run -e LDAP_LOG_LEVEL=384 ... +``` + +### Common Issues + +#### Container exits immediately + +**Symptom**: Container starts and stops within seconds. + +**Cause**: Missing required environment variables. + +**Solution**: Check that `LDAP_DOMAIN`, `LDAP_ORGANISATION`, and `LDAP_ADMIN_PASSWORD` are all set. + +```bash +docker logs openldap # Will show which variable is missing +``` + +#### Cannot authenticate as admin + +**Symptom**: `ldap_bind: Invalid credentials (49)` + +**Causes**: +1. Wrong password +2. Wrong bind DN (check the domain components match your LDAP_DOMAIN) +3. Typing `dc=example.com` instead of `dc=example,dc=com` + +**Solution**: Verify the exact bind DN: + +```bash +# Your bind DN is always: cn=admin, +# If LDAP_DOMAIN=example.com, then it's: cn=admin,dc=example,dc=com +``` + +#### TLS connection refused + +**Symptom**: Cannot connect to port 636. + +**Cause**: Certificates not found or not readable. + +**Solution**: Check the logs for TLS errors: + +```bash +docker logs openldap | grep -i tls +``` + +Verify certificates are mounted correctly: + +```bash +docker exec openldap ls -la /certs/ +``` + +#### Service account cannot search + +**Symptom**: Service account binds successfully but searches return empty. + +**Cause**: ACLs restrict what the service account can see. + +**Solution**: Service accounts can only read specific OUs. Verify you're searching the correct base: + +```bash +# Correct - searching People OU +ldapsearch -D "cn=keycloak,ou=Services,..." -b "ou=People,dc=example,dc=com" ... + +# Wrong - searching the entire tree +ldapsearch -D "cn=keycloak,ou=Services,..." -b "dc=example,dc=com" ... +``` + +#### Users don't have memberOf attribute + +**Symptom**: User exists in group but `memberOf` attribute is empty. + +**Cause**: User was added before the memberOf overlay, or group uses wrong attribute. + +**Solution**: The memberOf overlay only tracks `member` attribute on `groupOfMembers` objects. Verify: + +1. Group has `objectClass: groupOfMembers` +2. Group uses `member` attribute (not `memberUid`) + +To fix existing users, remove and re-add them to groups. + +--- + +## Security Recommendations + +### Password Security + +1. **Use strong admin password** - At least 16 characters with mixed case, numbers, symbols +2. **Rotate service account passwords** periodically +3. **Delete service-passwords.txt** after retrieving the passwords +4. **Never commit passwords** to version control + +### Network Security + +1. **Use TLS** for all production deployments +2. **Restrict port access** - Only allow LDAP ports from trusted networks +3. **Use internal Docker networks** when possible: + +```bash +docker network create ldap-net +docker run --network ldap-net --name openldap ... +# Other containers on ldap-net can access via hostname "openldap" +``` + +### Access Control + +1. **Use service accounts** instead of sharing admin credentials +2. **Principle of least privilege** - Service accounts only get read access +3. **Audit regularly** - Check who has access to what + +--- + +## Docker Compose Reference + +Complete production-ready example: + +```yaml +version: '3.8' + +services: + openldap: + image: enterprise-openldap:latest + container_name: openldap + hostname: ldap.example.com + environment: + LDAP_DOMAIN: example.com + LDAP_ORGANISATION: "Example Corporation" + LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:?Set LDAP_ADMIN_PASSWORD} + LDAP_TLS_ENABLED: "true" + LDAP_CREATE_SERVICE_ACCOUNTS: "true" + LDAP_LOG_LEVEL: "256" + volumes: + - openldap-data:/var/lib/openldap/openldap-data + - openldap-config:/etc/openldap/slapd.d + - ./certs:/certs:ro + ports: + - "389:389" + - "636:636" + networks: + - backend + restart: unless-stopped + healthcheck: + test: ["CMD", "ldapsearch", "-x", "-H", "ldap://localhost", "-b", "", "-s", "base"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + backend: + driver: bridge + +volumes: + openldap-data: + openldap-config: +``` + +Create a `.env` file: + +```bash +LDAP_ADMIN_PASSWORD=your-secure-password-here +``` + +Start: + +```bash +docker compose up -d +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8e45df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + openldap: + build: . + image: enterprise-openldap:latest + container_name: openldap + hostname: ldap.example.com + environment: + LDAP_DOMAIN: example.com + LDAP_ORGANISATION: "Example Corporation" + LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:-changeme} + LDAP_TLS_ENABLED: "true" + LDAP_CREATE_SERVICE_ACCOUNTS: "true" + LDAP_LOG_LEVEL: "256" + volumes: + - ldap_data:/var/lib/openldap/openldap-data + - ldap_config:/etc/openldap/slapd.d + - ./certs:/certs:ro + - ./ldif:/ldif/custom:ro + ports: + - "389:389" + - "636:636" + networks: + - ldap_net + restart: unless-stopped + +volumes: + ldap_data: + ldap_config: + +networks: + ldap_net: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..3b5b9e9 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,115 @@ +#!/bin/sh +set -e + +# Source utility functions +. /scripts/utils.sh + +# LDAPI socket URL - must use URL-encoded path for Alpine +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" +export LDAPI_SOCKET + +# Validate required environment variables +if [ -z "$LDAP_DOMAIN" ]; then + log_error "LDAP_DOMAIN is required" + exit 1 +fi + +if [ -z "$LDAP_ORGANISATION" ]; then + log_error "LDAP_ORGANISATION is required" + exit 1 +fi + +if [ -z "$LDAP_ADMIN_PASSWORD" ]; then + log_error "LDAP_ADMIN_PASSWORD is required" + exit 1 +fi + +# Generate base DN from domain if not provided +if [ -z "$LDAP_BASE_DN" ]; then + LDAP_BASE_DN=$(echo "$LDAP_DOMAIN" | sed 's/^/dc=/; s/\./,dc=/g') +fi +export LDAP_BASE_DN + +# Extract DC component for base entry +LDAP_DC=$(echo "$LDAP_DOMAIN" | cut -d'.' -f1) +export LDAP_DC + +# Set defaults for optional variables +export LDAP_CONFIG_PASSWORD="${LDAP_CONFIG_PASSWORD:-$(generate_password)}" +export LDAP_TLS_ENABLED="${LDAP_TLS_ENABLED:-true}" +export LDAP_TLS_CERT_FILE="${LDAP_TLS_CERT_FILE:-/certs/ldap.crt}" +export LDAP_TLS_KEY_FILE="${LDAP_TLS_KEY_FILE:-/certs/ldap.key}" +export LDAP_TLS_CA_FILE="${LDAP_TLS_CA_FILE:-/certs/ca.crt}" +export LDAP_TLS_VERIFY_CLIENT="${LDAP_TLS_VERIFY_CLIENT:-try}" +export LDAP_LOG_LEVEL="${LDAP_LOG_LEVEL:-256}" +export LDAP_READONLY="${LDAP_READONLY:-false}" + +log_info "OpenLDAP Container Starting" +log_info "Domain: $LDAP_DOMAIN" +log_info "Base DN: $LDAP_BASE_DN" +log_info "Organisation: $LDAP_ORGANISATION" + +# Check if already initialized +if [ ! -f /var/lib/openldap/openldap-data/data.mdb ]; then + log_info "First run - initializing OpenLDAP..." + + # Initialize cn=config + /scripts/init-config.sh + + # Load schemas in order + /scripts/init-schemas.sh + + # Configure overlays + /scripts/init-overlays.sh + + # Create base DIT + /scripts/init-dit.sh + + # Configure ACLs + /scripts/init-acls.sh + + # Create service accounts if requested + if [ "$LDAP_CREATE_SERVICE_ACCOUNTS" = "true" ]; then + /scripts/init-services.sh + fi + + # Process custom LDIF files if present + if [ -d /ldif/custom ] && [ "$(ls -A /ldif/custom 2>/dev/null)" ]; then + log_info "Processing custom LDIF files..." + for ldif in /ldif/custom/*.ldif; do + if [ -f "$ldif" ]; then + log_info "Loading: $ldif" + ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" -f "$ldif" || \ + log_warn "Failed to load $ldif (may already exist)" + fi + done + fi + + log_info "Initialization complete." +else + log_info "Database exists - starting normally." +fi + +# Ensure proper ownership +chown -R ldap:ldap /var/lib/openldap +chown -R ldap:ldap /etc/openldap/slapd.d +chown -R ldap:ldap /run/openldap + +# Build slapd arguments +SLAPD_URLS="ldap:/// $LDAPI_SOCKET" +if [ "$LDAP_TLS_ENABLED" = "true" ]; then + if [ -f "$LDAP_TLS_CERT_FILE" ] && [ -f "$LDAP_TLS_KEY_FILE" ]; then + SLAPD_URLS="ldap:/// ldaps:/// $LDAPI_SOCKET" + log_info "TLS enabled - listening on ldaps://" + else + log_warn "TLS enabled but certificates not found - skipping ldaps://" + fi +fi + +log_info "Starting slapd with URLs: $SLAPD_URLS" + +# Start slapd +exec /usr/sbin/slapd -h "$SLAPD_URLS" \ + -F /etc/openldap/slapd.d \ + -u ldap -g ldap \ + -d "${LDAP_LOG_LEVEL}" diff --git a/ldif/.gitkeep b/ldif/.gitkeep new file mode 100644 index 0000000..92d7ef0 --- /dev/null +++ b/ldif/.gitkeep @@ -0,0 +1,2 @@ +# Place custom LDIF files here for automatic loading during initialization +# Files will be loaded in alphabetical order after base DIT creation diff --git a/schema/enterprise.schema b/schema/enterprise.schema new file mode 100644 index 0000000..f714644 --- /dev/null +++ b/schema/enterprise.schema @@ -0,0 +1,206 @@ +# =========================================================================== +# Enterprise Platform LDAP Schema +# Version: 1.0 +# +# OID Base: 1.3.6.1.4.1.99999 (DEVELOPMENT - Apply for your own from IANA) +# +# Structure: +# 1.3.6.1.4.1.99999.1 - Enterprise Platform +# 1.3.6.1.4.1.99999.1.1 - Attribute Types +# 1.3.6.1.4.1.99999.1.2 - Object Classes +# +# Includes: +# - Virtual Mail (Postfix/Dovecot) +# - Nextcloud integration +# - Service access control +# +# Dependencies (must be loaded first): +# - core.schema +# - cosine.schema +# - inetorgperson.schema +# - rfc2307bis.schema +# =========================================================================== + + +# =========================================================================== +# SECTION 1: VIRTUAL MAIL ATTRIBUTES AND OBJECTS +# =========================================================================== + +# --------------------------------------------------------------------------- +# Mail Attribute Types +# --------------------------------------------------------------------------- + +attributetype ( 1.3.6.1.4.1.99999.1.1.1 + NAME 'mailDomain' + DESC 'Virtual mail domain name' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.2 + NAME 'mailTransport' + DESC 'Postfix transport (e.g., lmtp:unix:private/dovecot-lmtp)' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.3 + NAME 'mailbox' + DESC 'Relative mailbox path (e.g., domain.com/user/)' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.4 + NAME 'mailQuota' + DESC 'Mailbox quota in bytes' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.5 + NAME 'mailEnabled' + DESC 'Mail account or domain enabled (TRUE/FALSE)' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.6 + NAME 'maildrop' + DESC 'Final delivery address or forward destination' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.7 + NAME 'mailAlias' + DESC 'Additional email addresses for this account' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.8 + NAME 'mailHomeDirectory' + DESC 'Base path for mail storage' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.9 + NAME 'domainQuota' + DESC 'Total quota for all accounts in domain (bytes)' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.10 + NAME 'domainMaxAccounts' + DESC 'Maximum number of accounts in domain' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.99999.1.1.11 + NAME 'domainMaxAliases' + DESC 'Maximum number of aliases in domain' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +# --------------------------------------------------------------------------- +# Mail Object Classes +# --------------------------------------------------------------------------- + +objectclass ( 1.3.6.1.4.1.99999.1.2.1 + NAME 'mailDomainObject' + DESC 'Virtual mail domain' + SUP top STRUCTURAL + MUST ( mailDomain $ mailEnabled ) + MAY ( mailTransport $ mailHomeDirectory $ domainQuota $ + domainMaxAccounts $ domainMaxAliases $ description ) ) + +objectclass ( 1.3.6.1.4.1.99999.1.2.2 + NAME 'mailAccountObject' + DESC 'Virtual mail account - extends inetOrgPerson' + SUP inetOrgPerson STRUCTURAL + MUST ( mail $ mailEnabled ) + MAY ( mailbox $ mailQuota $ maildrop $ mailAlias $ + mailHomeDirectory $ description ) ) + +objectclass ( 1.3.6.1.4.1.99999.1.2.3 + NAME 'mailAliasObject' + DESC 'Mail alias or distribution list' + SUP top STRUCTURAL + MUST ( mail $ maildrop $ mailEnabled ) + MAY ( cn $ description ) ) + + +# =========================================================================== +# SECTION 2: NEXTCLOUD ATTRIBUTES AND OBJECTS +# Using official Nextcloud OIDs (1.3.6.1.4.1.49213.1) for compatibility +# =========================================================================== + +attributetype ( 1.3.6.1.4.1.49213.1.1.1 + NAME 'nextcloudEnabled' + DESC 'Whether user or group should be available in Nextcloud' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.49213.1.1.2 + NAME 'nextcloudQuota' + DESC 'Nextcloud disk quota (e.g., 15 GB)' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +objectclass ( 1.3.6.1.4.1.49213.1.2.1 + NAME 'nextcloudUser' + DESC 'Nextcloud user account' + SUP top AUXILIARY + MAY ( nextcloudEnabled $ nextcloudQuota ) ) + +objectclass ( 1.3.6.1.4.1.49213.1.2.2 + NAME 'nextcloudGroup' + DESC 'Nextcloud group' + SUP top AUXILIARY + MAY ( nextcloudEnabled ) ) + + +# =========================================================================== +# SECTION 3: SERVICE ACCESS CONTROL +# Based on PADL ldapns schema (OID 1.3.6.1.4.1.5765) +# =========================================================================== + +attributetype ( 1.3.6.1.4.1.5765.100.1 + NAME 'authorizedService' + DESC 'Service authorized for this account' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) + +objectclass ( 1.3.6.1.4.1.5765.100.2 + NAME 'authorizedServiceObject' + DESC 'Service authorization object' + SUP top AUXILIARY + MAY ( authorizedService ) ) + + +# =========================================================================== +# SECTION 4: ADDITIONAL UTILITY OBJECTS +# =========================================================================== + +# Service account object for bind DNs +objectclass ( 1.3.6.1.4.1.99999.1.2.10 + NAME 'serviceAccount' + DESC 'Service account for application binding' + SUP top STRUCTURAL + MUST ( cn ) + MAY ( description $ userPassword ) ) diff --git a/schema/kerberos.schema b/schema/kerberos.schema new file mode 100644 index 0000000..e7cf620 --- /dev/null +++ b/schema/kerberos.schema @@ -0,0 +1,291 @@ +# =========================================================================== +# MIT Kerberos LDAP Schema +# +# This schema enables storing Kerberos principals in OpenLDAP. +# It is loaded by default but remains DORMANT until Kerberos is enabled. +# +# OID Base: 2.16.840.1.113719.1.301 +# +# When Kerberos is enabled: +# 1. krbPrincipalAux objectClass is added to user entries +# 2. MIT KDC is deployed with LDAP backend +# 3. Principals are created via kadmin +# +# Source: MIT Kerberos source code (src/plugins/kdb/ldap/libkdb_ldap/kerberos.schema) +# Reference: https://web.mit.edu/kerberos/krb5-latest/doc/admin/conf_ldap.html +# +# Dependencies: +# - core.schema +# =========================================================================== + +# --------------------------------------------------------------------------- +# Attribute Types +# --------------------------------------------------------------------------- + +attributetype ( 2.16.840.1.113719.1.301.4.1.1 + NAME 'krbPrincipalName' + DESC 'Kerberos principal name (e.g., user@REALM)' + EQUALITY caseExactIA5Match + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.2.1 + NAME 'krbPrincipalKey' + DESC 'Kerberos principal key data (managed by KDC)' + EQUALITY octetStringMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) + +attributetype ( 2.16.840.1.113719.1.301.4.3.1 + NAME 'krbTicketPolicyReference' + DESC 'DN of ticket policy' + EQUALITY distinguishedNameMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.4.1 + NAME 'krbPrincipalExpiration' + DESC 'Principal expiration time' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.5.1 + NAME 'krbPasswordExpiration' + DESC 'Password expiration time' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.6.1 + NAME 'krbMaxTicketLife' + DESC 'Maximum ticket lifetime in seconds' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.7.1 + NAME 'krbMaxRenewableLife' + DESC 'Maximum renewable ticket lifetime in seconds' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.8.1 + NAME 'krbTicketFlags' + DESC 'Kerberos ticket flags' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.9.1 + NAME 'krbPrincipalType' + DESC 'Kerberos principal type' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.10.1 + NAME 'krbPwdPolicyReference' + DESC 'DN of password policy' + EQUALITY distinguishedNameMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.11.1 + NAME 'krbPrincipalReferences' + DESC 'DN of associated principal entries' + EQUALITY distinguishedNameMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) + +attributetype ( 2.16.840.1.113719.1.301.4.12.1 + NAME 'krbLastPwdChange' + DESC 'Time of last password change' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.13.1 + NAME 'krbLastSuccessfulAuth' + DESC 'Time of last successful authentication' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.14.1 + NAME 'krbLastFailedAuth' + DESC 'Time of last failed authentication' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.15.1 + NAME 'krbLoginFailedCount' + DESC 'Number of consecutive failed login attempts' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.16.1 + NAME 'krbExtraData' + DESC 'Extra data for Kerberos' + EQUALITY octetStringMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) + +attributetype ( 2.16.840.1.113719.1.301.4.17.1 + NAME 'krbAllowedToDelegateTo' + DESC 'Services this principal can delegate to (S4U2Proxy)' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +# --------------------------------------------------------------------------- +# Realm Container Attributes +# --------------------------------------------------------------------------- + +attributetype ( 2.16.840.1.113719.1.301.4.20.1 + NAME 'krbSubTrees' + DESC 'DNs of subtrees containing principals' + EQUALITY distinguishedNameMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) + +attributetype ( 2.16.840.1.113719.1.301.4.21.1 + NAME 'krbSearchScope' + DESC 'Search scope for principals (0=base, 1=one, 2=sub)' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.22.1 + NAME 'krbPrincContainerRef' + DESC 'DN of principal container' + EQUALITY distinguishedNameMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 ) + +attributetype ( 2.16.840.1.113719.1.301.4.23.1 + NAME 'krbMaxPwdLife' + DESC 'Maximum password lifetime in realm' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.24.1 + NAME 'krbMinPwdLife' + DESC 'Minimum password lifetime in realm' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.25.1 + NAME 'krbPwdMinDiffChars' + DESC 'Minimum number of character classes in password' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.26.1 + NAME 'krbPwdMinLength' + DESC 'Minimum password length' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.27.1 + NAME 'krbPwdHistoryLength' + DESC 'Number of passwords to keep in history' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.28.1 + NAME 'krbPwdMaxFailure' + DESC 'Maximum password failures before lockout' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.29.1 + NAME 'krbPwdFailureCountInterval' + DESC 'Failure count reset interval in seconds' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 2.16.840.1.113719.1.301.4.30.1 + NAME 'krbPwdLockoutDuration' + DESC 'Lockout duration in seconds' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +# --------------------------------------------------------------------------- +# Object Classes +# --------------------------------------------------------------------------- + +# Auxiliary class for adding Kerberos attributes to user entries +# This is what gets added to users when Kerberos premium feature is enabled +objectclass ( 2.16.840.1.113719.1.301.6.8.1 + NAME 'krbPrincipalAux' + DESC 'Auxiliary class for Kerberos principal attributes' + SUP top AUXILIARY + MAY ( krbPrincipalName $ krbPrincipalKey $ krbTicketPolicyReference $ + krbPrincipalExpiration $ krbPasswordExpiration $ + krbMaxTicketLife $ krbMaxRenewableLife $ krbTicketFlags $ + krbPrincipalType $ krbPwdPolicyReference $ krbPrincipalReferences $ + krbLastPwdChange $ krbLastSuccessfulAuth $ krbLastFailedAuth $ + krbLoginFailedCount $ krbExtraData $ krbAllowedToDelegateTo ) ) + +# Structural class for standalone principal entries (less common) +objectclass ( 2.16.840.1.113719.1.301.6.9.1 + NAME 'krbPrincipal' + DESC 'Structural class for Kerberos principals' + SUP top STRUCTURAL + MUST krbPrincipalName + MAY ( krbPrincipalKey $ krbTicketPolicyReference $ + krbPrincipalExpiration $ krbPasswordExpiration $ + krbMaxTicketLife $ krbMaxRenewableLife $ krbTicketFlags $ + krbPrincipalType $ krbPwdPolicyReference $ krbPrincipalReferences $ + krbLastPwdChange $ krbLastSuccessfulAuth $ krbLastFailedAuth $ + krbLoginFailedCount $ krbExtraData $ krbAllowedToDelegateTo ) ) + +# Container for Kerberos realm +objectclass ( 2.16.840.1.113719.1.301.6.1.1 + NAME 'krbRealmContainer' + DESC 'Container for Kerberos realm' + SUP top STRUCTURAL + MUST cn + MAY ( krbSubTrees $ krbSearchScope $ krbPrincContainerRef $ + krbMaxTicketLife $ krbMaxRenewableLife $ krbTicketFlags ) ) + +# Ticket policy object +objectclass ( 2.16.840.1.113719.1.301.6.2.1 + NAME 'krbTicketPolicy' + DESC 'Kerberos ticket policy' + SUP top STRUCTURAL + MUST cn + MAY ( krbMaxTicketLife $ krbMaxRenewableLife $ krbTicketFlags ) ) + +# Password policy object for Kerberos +objectclass ( 2.16.840.1.113719.1.301.6.3.1 + NAME 'krbPwdPolicy' + DESC 'Kerberos password policy' + SUP top STRUCTURAL + MUST cn + MAY ( krbMaxPwdLife $ krbMinPwdLife $ krbPwdMinDiffChars $ + krbPwdMinLength $ krbPwdHistoryLength $ krbPwdMaxFailure $ + krbPwdFailureCountInterval $ krbPwdLockoutDuration ) ) + +# Service principal container +objectclass ( 2.16.840.1.113719.1.301.6.4.1 + NAME 'krbService' + DESC 'Kerberos service' + SUP krbPrincipal STRUCTURAL ) diff --git a/schema/openssh-lpk.schema b/schema/openssh-lpk.schema new file mode 100644 index 0000000..fc6522a --- /dev/null +++ b/schema/openssh-lpk.schema @@ -0,0 +1,29 @@ +# +# OpenSSH LDAP Public Key Schema +# Used by: SSH servers, Gitea, GitLab CE (with LDAP sync) +# +# OID: 1.3.6.1.4.1.24552.500.1.1 (OpenSSH project registered OID) +# +# Installation: +# Include in slapd.conf or convert to LDIF for cn=config +# +# Usage: +# Add 'ldapPublicKey' objectClass to user entries +# Add 'sshPublicKey' attribute with public key(s) +# +# SSH Server Config (/etc/ssh/sshd_config): +# AuthorizedKeysCommand /usr/local/bin/ldap-ssh-keys.sh +# AuthorizedKeysCommandUser nobody +# + +attributetype ( 1.3.6.1.4.1.24552.500.1.1.1.13 + NAME 'sshPublicKey' + DESC 'OpenSSH Public Key' + EQUALITY octetStringMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) + +objectclass ( 1.3.6.1.4.1.24552.500.1.1.2.0 + NAME 'ldapPublicKey' + DESC 'OpenSSH LDAP Public Key Objectclass' + SUP top AUXILIARY + MAY ( sshPublicKey $ uid ) ) diff --git a/schema/rfc2307bis.schema b/schema/rfc2307bis.schema new file mode 100644 index 0000000..c46abb5 --- /dev/null +++ b/schema/rfc2307bis.schema @@ -0,0 +1,340 @@ +# =========================================================================== +# RFC2307bis Schema +# +# This schema provides POSIX account and group support with the critical +# difference from RFC2307 (nis.schema) that posixGroup is AUXILIARY, +# allowing it to be combined with groupOfNames/groupOfMembers for +# proper memberOf overlay support. +# +# Source: https://github.com/jtyr/rfc2307bis +# See also: https://tools.ietf.org/html/draft-howard-rfc2307bis-02 +# +# Key differences from nis.schema (RFC2307): +# - posixGroup is AUXILIARY (not STRUCTURAL) +# - Allows combining with groupOfNames/groupOfMembers +# - memberOf overlay works correctly +# - Empty groups are allowed +# +# Dependencies: +# - core.schema +# - cosine.schema +# +# DO NOT load nis.schema alongside this schema - they conflict! +# +# NOTE: On Alpine Linux, uidNumber and gidNumber are built-in to OpenLDAP +# and must NOT be redefined. They are commented out below. +# =========================================================================== + +# Attribute types from RFC 2307 + +# uidNumber is built-in on Alpine Linux OpenLDAP +#attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber' +# DESC 'An integer uniquely identifying a user in an administrative domain' +# EQUALITY integerMatch +# ORDERING integerOrderingMatch +# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 +# SINGLE-VALUE ) + +# gidNumber is built-in on Alpine Linux OpenLDAP +#attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber' +# DESC 'An integer uniquely identifying a group in an administrative domain' +# EQUALITY integerMatch +# ORDERING integerOrderingMatch +# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 +# SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.2 NAME 'gecos' + DESC 'The GECOS field; the common name' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.3 NAME 'homeDirectory' + DESC 'The absolute path to the home directory' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.4 NAME 'loginShell' + DESC 'The path to the login shell' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.6 NAME 'shadowMin' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.7 NAME 'shadowMax' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.8 NAME 'shadowWarning' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.9 NAME 'shadowInactive' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.10 NAME 'shadowExpire' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.11 NAME 'shadowFlag' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid' + DESC 'Member UID - for backwards compatibility with RFC2307' + EQUALITY caseExactMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) + +attributetype ( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) + +attributetype ( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple' + DESC 'Netgroup triple' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) + +attributetype ( 1.3.6.1.1.1.1.15 NAME 'ipServicePort' + DESC 'Service port number' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol' + DESC 'Service protocol name' + SUP name ) + +attributetype ( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber' + DESC 'IP protocol number' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber' + DESC 'ONC RPC number' + EQUALITY integerMatch + ORDERING integerOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber' + DESC 'IPv4 addresses as a dotted decimal omitting leading zeros or IPv6 addresses as defined in RFC 4291' + SUP name ) + +attributetype ( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber' + DESC 'IP network omitting leading zeros, eg. 192.168' + SUP name + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber' + DESC 'IP netmask omitting leading zeros, eg. 255.255.255.0' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.22 NAME 'macAddress' + DESC 'MAC address in maximal, colon separated hex notation, eg. 00:00:92:90:ee:e2' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.1.1.1.23 NAME 'bootParameter' + DESC 'rpc.bootparamd parameter' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.1.1.1.24 NAME 'bootFile' + DESC 'Boot image name' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.1.1.1.26 NAME 'nisMapName' + DESC 'Name of a generic NIS map' + SUP name ) + +attributetype ( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry' + DESC 'A generic NIS entry' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.28 NAME 'nisPublicKey' + DESC 'NIS public key' + EQUALITY octetStringMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.29 NAME 'nisSecretKey' + DESC 'NIS secret key' + EQUALITY octetStringMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.30 NAME 'nisDomain' + DESC 'NIS domain' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) + +attributetype ( 1.3.6.1.1.1.1.31 NAME 'automountMapName' + DESC 'automount Map Name' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.32 NAME 'automountKey' + DESC 'Automount Key value' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.1.1.1.33 NAME 'automountInformation' + DESC 'Automount information' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) + +# Object classes from RFC 2307bis + +objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount' + DESC 'Abstraction of an account with POSIX attributes' + SUP top AUXILIARY + MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory ) + MAY ( userPassword $ loginShell $ gecos $ description ) ) + +# THIS IS THE KEY DIFFERENCE FROM RFC2307: +# posixGroup is AUXILIARY, not STRUCTURAL +# This allows combining with groupOfNames/groupOfMembers +objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' + DESC 'Abstraction of a group of accounts' + SUP top AUXILIARY + MUST ( cn $ gidNumber ) + MAY ( userPassword $ memberUid $ description ) ) + +objectclass ( 1.3.6.1.1.1.2.1 NAME 'shadowAccount' + DESC 'Additional attributes for shadow passwords' + SUP top AUXILIARY + MUST uid + MAY ( userPassword $ description $ + shadowLastChange $ shadowMin $ shadowMax $ + shadowWarning $ shadowInactive $ + shadowExpire $ shadowFlag ) ) + +objectclass ( 1.3.6.1.1.1.2.3 NAME 'ipService' + DESC 'Abstraction an Internet Protocol service. Maps an IP port and protocol (such as tcp or udp) to one or more names' + SUP top STRUCTURAL + MUST ( cn $ ipServicePort $ ipServiceProtocol ) + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.4 NAME 'ipProtocol' + DESC 'Abstraction of an IP protocol. Maps a protocol number to one or more names' + SUP top STRUCTURAL + MUST ( cn $ ipProtocolNumber ) + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.5 NAME 'oncRpc' + DESC 'Abstraction of an Open Network Computing (ONC) Remote Procedure Call (RPC) binding' + SUP top STRUCTURAL + MUST ( cn $ oncRpcNumber ) + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.6 NAME 'ipHost' + DESC 'Abstraction of a host, an IP device. The distinguished value of the cn attribute denotes the hosts canonical name' + SUP top AUXILIARY + MUST ( cn $ ipHostNumber ) + MAY ( userPassword $ l $ description $ manager ) ) + +objectclass ( 1.3.6.1.1.1.2.7 NAME 'ipNetwork' + DESC 'Abstraction of a network. The distinguished value of the cn attribute denotes the networks canonical name' + SUP top STRUCTURAL + MUST ipNetworkNumber + MAY ( cn $ ipNetmaskNumber $ l $ description $ manager ) ) + +objectclass ( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup' + DESC 'Abstraction of a netgroup. May refer to other netgroups' + SUP top STRUCTURAL + MUST cn + MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) ) + +objectclass ( 1.3.6.1.1.1.2.9 NAME 'nisMap' + DESC 'A generic abstraction of a NIS map' + SUP top STRUCTURAL + MUST nisMapName + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.10 NAME 'nisObject' + DESC 'An entry in a NIS map' + SUP top STRUCTURAL + MUST ( cn $ nisMapEntry $ nisMapName ) + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.11 NAME 'ieee802Device' + DESC 'A device with a MAC address' + SUP top AUXILIARY + MAY macAddress ) + +objectclass ( 1.3.6.1.1.1.2.12 NAME 'bootableDevice' + DESC 'A device with boot parameters' + SUP top AUXILIARY + MAY ( bootFile $ bootParameter ) ) + +objectclass ( 1.3.6.1.1.1.2.14 NAME 'nisKeyObject' + DESC 'An object with a public and secret key' + SUP top AUXILIARY + MUST ( cn $ nisPublicKey $ nisSecretKey ) + MAY ( uidNumber $ description ) ) + +objectclass ( 1.3.6.1.1.1.2.15 NAME 'nisDomainObject' + DESC 'Associates a NIS domain with a naming context' + SUP top AUXILIARY + MUST nisDomain ) + +objectclass ( 1.3.6.1.1.1.2.16 NAME 'automountMap' + DESC 'An group of related automount objects' + SUP top STRUCTURAL + MUST ( automountMapName ) + MAY description ) + +objectclass ( 1.3.6.1.1.1.2.17 NAME 'automount' + DESC 'An automount entry' + SUP top STRUCTURAL + MUST ( automountKey $ automountInformation ) + MAY description ) + +# groupOfMembers - like groupOfNames but allows empty groups +# This is essential for memberOf overlay support +objectclass ( 2.16.840.1.113730.3.2.33 NAME 'groupOfMembers' + DESC 'A group with members (like groupOfNames but member is optional)' + SUP top STRUCTURAL + MUST cn + MAY ( member $ businessCategory $ description $ o $ ou $ owner $ seeAlso ) ) diff --git a/scripts/init-acls.sh b/scripts/init-acls.sh new file mode 100644 index 0000000..5b2910f --- /dev/null +++ b/scripts/init-acls.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Configuring ACLs..." + +# Socket URL for ldapi - must use URL-encoded path +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" + +# Start slapd temporarily +log_info "Starting slapd temporarily for ACL configuration..." +/usr/sbin/slapd -h "$LDAPI_SOCKET" -F /etc/openldap/slapd.d -u ldap -g ldap +sleep 2 + +# Wait for slapd +wait_for_slapd 30 "$LDAPI_SOCKET" + +# Configure ACLs +log_info "Applying ACL rules..." +cat > /tmp/acls.ldif << EOF +dn: olcDatabase={1}mdb,cn=config +changetype: modify +replace: olcAccess +olcAccess: {0}to * by dn.exact="cn=admin,${LDAP_BASE_DN}" manage by * break +olcAccess: {1}to attrs=userPassword by self write by anonymous auth by * none +olcAccess: {2}to dn.children="ou=People,${LDAP_BASE_DN}" by self read by * break +olcAccess: {3}to dn.subtree="ou=People,${LDAP_BASE_DN}" by dn.exact="cn=keycloak,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=nextcloud,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=gitea,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=sssd,ou=Services,${LDAP_BASE_DN}" read by * break +olcAccess: {4}to dn.subtree="ou=Groups,${LDAP_BASE_DN}" by dn.exact="cn=keycloak,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=nextcloud,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=gitea,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=sssd,ou=Services,${LDAP_BASE_DN}" read by * break +olcAccess: {5}to dn.subtree="ou=Domains,${LDAP_BASE_DN}" by dn.exact="cn=postfix,ou=Services,${LDAP_BASE_DN}" read by dn.exact="cn=dovecot,ou=Services,${LDAP_BASE_DN}" read by * break +olcAccess: {6}to * by users read by * none +EOF + +ldapmodify -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/acls.ldif + +# Stop temporary slapd +log_info "Stopping temporary slapd..." +pkill slapd || true +sleep 2 + +# Cleanup +rm -f /tmp/acls.ldif + +log_info "ACL configuration complete" diff --git a/scripts/init-config.sh b/scripts/init-config.sh new file mode 100644 index 0000000..c6469fc --- /dev/null +++ b/scripts/init-config.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Initializing cn=config..." + +# Generate password hashes +LDAP_ADMIN_PASSWORD_HASH=$(hash_password "$LDAP_ADMIN_PASSWORD") +LDAP_CONFIG_PASSWORD_HASH=$(hash_password "$LDAP_CONFIG_PASSWORD") +export LDAP_ADMIN_PASSWORD_HASH LDAP_CONFIG_PASSWORD_HASH + +# Create initial slapd.d configuration +rm -rf /etc/openldap/slapd.d/* + +# Create base cn=config LDIF +cat > /tmp/init-config.ldif << EOF +dn: cn=config +objectClass: olcGlobal +cn: config +olcArgsFile: /run/openldap/slapd.args +olcPidFile: /run/openldap/slapd.pid +olcLogLevel: ${LDAP_LOG_LEVEL} + +dn: cn=module{0},cn=config +objectClass: olcModuleList +cn: module{0} +olcModulePath: /usr/lib/openldap +olcModuleLoad: back_mdb.so +olcModuleLoad: memberof.so +olcModuleLoad: refint.so +olcModuleLoad: unique.so +olcModuleLoad: ppolicy.so + +dn: cn=schema,cn=config +objectClass: olcSchemaConfig +cn: schema + +dn: olcDatabase={-1}frontend,cn=config +objectClass: olcDatabaseConfig +objectClass: olcFrontendConfig +olcDatabase: {-1}frontend +olcSizeLimit: 500 +olcAccess: {0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break +olcAccess: {1}to dn.base="" by * read +olcAccess: {2}to dn.base="cn=subschema" by * read + +dn: olcDatabase={0}config,cn=config +objectClass: olcDatabaseConfig +olcDatabase: {0}config +olcRootDN: cn=admin,cn=config +olcRootPW: ${LDAP_CONFIG_PASSWORD_HASH} +olcAccess: {0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break + +dn: olcDatabase={1}mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: {1}mdb +olcSuffix: ${LDAP_BASE_DN} +olcRootDN: cn=admin,${LDAP_BASE_DN} +olcRootPW: ${LDAP_ADMIN_PASSWORD_HASH} +olcDbDirectory: /var/lib/openldap/openldap-data +olcDbIndex: objectClass eq +olcDbIndex: cn eq,pres,sub +olcDbIndex: entryCSN eq +olcDbIndex: entryUUID eq +olcDbMaxSize: 1073741824 +EOF + +# Add TLS configuration if enabled and certs exist +if [ "$LDAP_TLS_ENABLED" = "true" ] && [ -f "$LDAP_TLS_CERT_FILE" ] && [ -f "$LDAP_TLS_KEY_FILE" ]; then + log_info "Adding TLS configuration..." + cat >> /tmp/init-config.ldif << EOF + +dn: cn=config +changetype: modify +add: olcTLSCertificateFile +olcTLSCertificateFile: ${LDAP_TLS_CERT_FILE} +- +add: olcTLSCertificateKeyFile +olcTLSCertificateKeyFile: ${LDAP_TLS_KEY_FILE} +EOF + + if [ -f "$LDAP_TLS_CA_FILE" ]; then + cat >> /tmp/init-config.ldif << EOF +- +add: olcTLSCACertificateFile +olcTLSCACertificateFile: ${LDAP_TLS_CA_FILE} +EOF + fi + + cat >> /tmp/init-config.ldif << EOF +- +add: olcTLSVerifyClient +olcTLSVerifyClient: ${LDAP_TLS_VERIFY_CLIENT} +EOF +fi + +# Import the configuration +log_info "Importing cn=config with slapadd..." +/usr/sbin/slapadd -n 0 -F /etc/openldap/slapd.d -l /tmp/init-config.ldif + +# Set proper ownership +chown -R ldap:ldap /etc/openldap/slapd.d +chown -R ldap:ldap /var/lib/openldap + +log_info "cn=config initialization complete" diff --git a/scripts/init-dit.sh b/scripts/init-dit.sh new file mode 100644 index 0000000..cdc9c94 --- /dev/null +++ b/scripts/init-dit.sh @@ -0,0 +1,118 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Creating base DIT structure..." + +# Socket URL for ldapi - must use URL-encoded path +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" + +# Start slapd temporarily +log_info "Starting slapd temporarily for DIT creation..." +/usr/sbin/slapd -h "$LDAPI_SOCKET" -F /etc/openldap/slapd.d -u ldap -g ldap +sleep 2 + +# Wait for slapd +wait_for_slapd 30 "$LDAPI_SOCKET" + +# Create base DIT LDIF +cat > /tmp/base-dit.ldif << EOF +# Base entry +dn: ${LDAP_BASE_DN} +objectClass: top +objectClass: dcObject +objectClass: organization +dc: ${LDAP_DC} +o: ${LDAP_ORGANISATION} + +# People OU +dn: ou=People,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: People +description: User accounts + +# Groups OU +dn: ou=Groups,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: Groups +description: Authorization groups + +# Services OU +dn: ou=Services,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: Services +description: Service accounts for application binding + +# Domains OU (for virtual mail domains) +dn: ou=Domains,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: Domains +description: Virtual mail domains + +# Policies OU +dn: ou=Policies,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: Policies +description: Password and access policies + +# Kerberos OU (for future use) +dn: ou=Kerberos,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: Kerberos +description: Kerberos realm container (premium feature) +EOF + +# Add base DIT +log_info "Adding base organizational units..." +ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,${LDAP_BASE_DN}" -w "${LDAP_ADMIN_PASSWORD}" -f /tmp/base-dit.ldif + +# Create default password policy +log_info "Creating default password policy..." +cat > /tmp/default-policy.ldif << EOF +dn: cn=default,ou=Policies,${LDAP_BASE_DN} +objectClass: pwdPolicy +objectClass: device +cn: default +pwdAttribute: userPassword +pwdMaxAge: 7776000 +pwdExpireWarning: 1209600 +pwdInHistory: 5 +pwdCheckQuality: 2 +pwdMinLength: 12 +pwdMaxFailure: 5 +pwdLockout: TRUE +pwdLockoutDuration: 900 +pwdGraceAuthNLimit: 3 +pwdFailureCountInterval: 900 +pwdMustChange: FALSE +pwdAllowUserChange: TRUE +pwdSafeModify: FALSE +EOF + +ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,${LDAP_BASE_DN}" -w "${LDAP_ADMIN_PASSWORD}" -f /tmp/default-policy.ldif || \ + log_warn "Password policy may already exist" + +# Create default admin group +log_info "Creating default admin group..." +cat > /tmp/admin-group.ldif << EOF +dn: cn=admins,ou=Groups,${LDAP_BASE_DN} +objectClass: groupOfMembers +objectClass: posixGroup +cn: admins +gidNumber: 10000 +description: LDAP Administrators +EOF + +ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,${LDAP_BASE_DN}" -w "${LDAP_ADMIN_PASSWORD}" -f /tmp/admin-group.ldif || \ + log_warn "Admin group may already exist" + +# Stop temporary slapd +log_info "Stopping temporary slapd..." +pkill slapd || true +sleep 2 + +# Cleanup +rm -f /tmp/base-dit.ldif /tmp/default-policy.ldif /tmp/admin-group.ldif + +log_info "Base DIT creation complete" diff --git a/scripts/init-overlays.sh b/scripts/init-overlays.sh new file mode 100644 index 0000000..ca23dfa --- /dev/null +++ b/scripts/init-overlays.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Configuring overlays..." + +# Socket URL for ldapi - must use URL-encoded path +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" + +# Start slapd temporarily to add overlays via LDAP +log_info "Starting slapd temporarily for overlay configuration..." +/usr/sbin/slapd -h "$LDAPI_SOCKET" -F /etc/openldap/slapd.d -u ldap -g ldap +sleep 2 + +# Wait for slapd +wait_for_slapd 30 "$LDAPI_SOCKET" + +# 1. memberof overlay +log_info "Configuring memberof overlay..." +cat > /tmp/overlay-memberof.ldif << EOF +dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcMemberOf +olcOverlay: memberof +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: groupOfMembers +olcMemberOfMemberAD: member +olcMemberOfMemberOfAD: memberOf +EOF + +ldapadd -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/overlay-memberof.ldif 2>/dev/null || \ + log_warn "memberof overlay may already exist" + +# 2. refint (Referential Integrity) overlay +log_info "Configuring refint overlay..." +cat > /tmp/overlay-refint.ldif << EOF +dn: olcOverlay=refint,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcRefintConfig +olcOverlay: refint +olcRefintAttribute: member +olcRefintAttribute: memberOf +EOF + +ldapadd -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/overlay-refint.ldif 2>/dev/null || \ + log_warn "refint overlay may already exist" + +# 3. unique (Attribute Uniqueness) overlay +log_info "Configuring unique overlay..." +cat > /tmp/overlay-unique.ldif << EOF +dn: olcOverlay=unique,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcUniqueConfig +olcOverlay: unique +olcUniqueURI: ldap:///ou=People,${LDAP_BASE_DN}?uid?sub +olcUniqueURI: ldap:///ou=People,${LDAP_BASE_DN}?mail?sub +olcUniqueURI: ldap:///ou=People,${LDAP_BASE_DN}?uidNumber?sub +olcUniqueURI: ldap:///ou=Groups,${LDAP_BASE_DN}?gidNumber?sub +EOF + +ldapadd -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/overlay-unique.ldif 2>/dev/null || \ + log_warn "unique overlay may already exist" + +# 4. ppolicy (Password Policy) overlay +log_info "Configuring ppolicy overlay..." +cat > /tmp/overlay-ppolicy.ldif << EOF +dn: olcOverlay=ppolicy,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcPPolicyConfig +olcOverlay: ppolicy +olcPPolicyDefault: cn=default,ou=Policies,${LDAP_BASE_DN} +olcPPolicyHashCleartext: TRUE +olcPPolicyUseLockout: TRUE +EOF + +ldapadd -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/overlay-ppolicy.ldif 2>/dev/null || \ + log_warn "ppolicy overlay may already exist" + +# Stop the temporary slapd +log_info "Stopping temporary slapd..." +pkill slapd || true +sleep 2 + +# Cleanup +rm -f /tmp/overlay-*.ldif + +log_info "Overlay configuration complete" diff --git a/scripts/init-schemas.sh b/scripts/init-schemas.sh new file mode 100644 index 0000000..2bf904f --- /dev/null +++ b/scripts/init-schemas.sh @@ -0,0 +1,116 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Loading schemas..." + +SCHEMA_DIR="/etc/openldap/schema" + +# We use slaptest to convert all schemas at once into the cn=config format +# This is more reliable than trying to load individual schemas + +log_info "Converting schemas using slaptest..." + +# Create a temporary slapd.conf with all schemas +TEMP_DIR="/tmp/schema-convert-$$" +mkdir -p "$TEMP_DIR/slapd.d" + +cat > "$TEMP_DIR/slapd.conf" << EOF +# Core schemas (built-in) +include ${SCHEMA_DIR}/core.schema +include ${SCHEMA_DIR}/cosine.schema +include ${SCHEMA_DIR}/inetorgperson.schema + +# Custom schemas - rfc2307bis replaces nis.schema +include ${SCHEMA_DIR}/rfc2307bis.schema +EOF + +# Add openssh-lpk if it exists +if [ -f "${SCHEMA_DIR}/openssh-lpk.schema" ]; then + echo "include ${SCHEMA_DIR}/openssh-lpk.schema" >> "$TEMP_DIR/slapd.conf" +fi + +# Add kerberos if it exists +if [ -f "${SCHEMA_DIR}/kerberos.schema" ]; then + echo "include ${SCHEMA_DIR}/kerberos.schema" >> "$TEMP_DIR/slapd.conf" +fi + +# Add enterprise if it exists +if [ -f "${SCHEMA_DIR}/enterprise.schema" ]; then + echo "include ${SCHEMA_DIR}/enterprise.schema" >> "$TEMP_DIR/slapd.conf" +fi + +log_info "Schema config file:" +cat "$TEMP_DIR/slapd.conf" + +# Convert schemas to cn=config format using slaptest +log_info "Running slaptest to convert schemas..." +if /usr/sbin/slaptest -f "$TEMP_DIR/slapd.conf" -F "$TEMP_DIR/slapd.d" 2>&1; then + log_info "Schema conversion successful" +else + log_error "Schema conversion failed" + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Copy the converted schema files to our slapd.d +log_info "Installing converted schemas..." +if [ -d "$TEMP_DIR/slapd.d/cn=config/cn=schema" ]; then + mkdir -p /etc/openldap/slapd.d/cn=config/cn=schema + cp -a "$TEMP_DIR/slapd.d/cn=config/cn=schema/"* /etc/openldap/slapd.d/cn=config/cn=schema/ + log_info "Schemas installed:" + ls -la /etc/openldap/slapd.d/cn=config/cn=schema/ +else + log_error "No schema directory found after conversion" + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Cleanup +rm -rf "$TEMP_DIR" + +# Fix ownership +chown -R ldap:ldap /etc/openldap/slapd.d + +# Now add indexes for schema-defined attributes +log_info "Adding database indexes..." + +# Socket URL for ldapi - must use URL-encoded path +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" + +# Start slapd temporarily to add indexes +/usr/sbin/slapd -h "$LDAPI_SOCKET" -F /etc/openldap/slapd.d -u ldap -g ldap +sleep 2 +wait_for_slapd 30 "$LDAPI_SOCKET" + +cat > /tmp/add-indexes.ldif << EOF +dn: olcDatabase={1}mdb,cn=config +changetype: modify +add: olcDbIndex +olcDbIndex: uid eq,pres,sub +- +add: olcDbIndex +olcDbIndex: uidNumber eq +- +add: olcDbIndex +olcDbIndex: gidNumber eq +- +add: olcDbIndex +olcDbIndex: mail eq,pres,sub +- +add: olcDbIndex +olcDbIndex: memberOf eq +- +add: olcDbIndex +olcDbIndex: member eq +EOF + +ldapmodify -Y EXTERNAL -H "$LDAPI_SOCKET" -f /tmp/add-indexes.ldif || log_warn "Some indexes may already exist" + +# Stop temporary slapd +pkill slapd || true +sleep 2 +rm -f /tmp/add-indexes.ldif + +log_info "Schema loading complete" diff --git a/scripts/init-services.sh b/scripts/init-services.sh new file mode 100644 index 0000000..b112310 --- /dev/null +++ b/scripts/init-services.sh @@ -0,0 +1,108 @@ +#!/bin/sh +set -e + +. /scripts/utils.sh + +log_info "Creating service accounts..." + +# Socket URL for ldapi - must use URL-encoded path +LDAPI_SOCKET="ldapi://%2Frun%2Fopenldap%2Fldapi" + +# Start slapd temporarily +log_info "Starting slapd temporarily for service account creation..." +/usr/sbin/slapd -h "$LDAPI_SOCKET" -F /etc/openldap/slapd.d -u ldap -g ldap +sleep 2 + +# Wait for slapd +wait_for_slapd 30 "$LDAPI_SOCKET" + +# Generate passwords for each service if not provided +LDAP_SERVICE_KEYCLOAK_PASSWORD="${LDAP_SERVICE_KEYCLOAK_PASSWORD:-$(generate_password)}" +LDAP_SERVICE_NEXTCLOUD_PASSWORD="${LDAP_SERVICE_NEXTCLOUD_PASSWORD:-$(generate_password)}" +LDAP_SERVICE_GITEA_PASSWORD="${LDAP_SERVICE_GITEA_PASSWORD:-$(generate_password)}" +LDAP_SERVICE_POSTFIX_PASSWORD="${LDAP_SERVICE_POSTFIX_PASSWORD:-$(generate_password)}" +LDAP_SERVICE_DOVECOT_PASSWORD="${LDAP_SERVICE_DOVECOT_PASSWORD:-$(generate_password)}" +LDAP_SERVICE_SSSD_PASSWORD="${LDAP_SERVICE_SSSD_PASSWORD:-$(generate_password)}" + +# Create service accounts LDIF +cat > /tmp/service-accounts.ldif << EOF +# Keycloak service account +dn: cn=keycloak,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: keycloak +description: Keycloak LDAP federation service account +userPassword: ${LDAP_SERVICE_KEYCLOAK_PASSWORD} + +# Nextcloud service account +dn: cn=nextcloud,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: nextcloud +description: Nextcloud LDAP backend service account +userPassword: ${LDAP_SERVICE_NEXTCLOUD_PASSWORD} + +# Gitea service account +dn: cn=gitea,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: gitea +description: Gitea LDAP authentication service account +userPassword: ${LDAP_SERVICE_GITEA_PASSWORD} + +# Postfix service account +dn: cn=postfix,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: postfix +description: Postfix virtual mailbox lookup service account +userPassword: ${LDAP_SERVICE_POSTFIX_PASSWORD} + +# Dovecot service account +dn: cn=dovecot,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: dovecot +description: Dovecot authentication service account +userPassword: ${LDAP_SERVICE_DOVECOT_PASSWORD} + +# SSSD service account +dn: cn=sssd,ou=Services,${LDAP_BASE_DN} +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: sssd +description: SSSD NSS/PAM service account +userPassword: ${LDAP_SERVICE_SSSD_PASSWORD} +EOF + +# Add service accounts +ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,${LDAP_BASE_DN}" -w "${LDAP_ADMIN_PASSWORD}" -f /tmp/service-accounts.ldif || \ + log_warn "Some service accounts may already exist" + +# Output generated passwords to a file for reference +cat > /var/lib/openldap/service-passwords.txt << EOF +# Service Account Passwords (generated on first run) +# IMPORTANT: Store these securely and delete this file after noting passwords + +LDAP_SERVICE_KEYCLOAK_PASSWORD=${LDAP_SERVICE_KEYCLOAK_PASSWORD} +LDAP_SERVICE_NEXTCLOUD_PASSWORD=${LDAP_SERVICE_NEXTCLOUD_PASSWORD} +LDAP_SERVICE_GITEA_PASSWORD=${LDAP_SERVICE_GITEA_PASSWORD} +LDAP_SERVICE_POSTFIX_PASSWORD=${LDAP_SERVICE_POSTFIX_PASSWORD} +LDAP_SERVICE_DOVECOT_PASSWORD=${LDAP_SERVICE_DOVECOT_PASSWORD} +LDAP_SERVICE_SSSD_PASSWORD=${LDAP_SERVICE_SSSD_PASSWORD} +EOF +chmod 600 /var/lib/openldap/service-passwords.txt +chown ldap:ldap /var/lib/openldap/service-passwords.txt + +log_info "Service account passwords saved to /var/lib/openldap/service-passwords.txt" +log_warn "IMPORTANT: Retrieve these passwords and delete the file for security" + +# Stop temporary slapd +log_info "Stopping temporary slapd..." +pkill slapd || true +sleep 2 + +# Cleanup +rm -f /tmp/service-accounts.ldif + +log_info "Service account creation complete" diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 0000000..442f0c5 --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Utility functions for OpenLDAP initialization + +# Logging functions +log_info() { + echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_warn() { + echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') - $1" >&2 +} + +log_error() { + echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $1" >&2 +} + +# Generate a random password +generate_password() { + head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24 +} + +# Hash password using SSHA +hash_password() { + local password="$1" + /usr/sbin/slappasswd -s "$password" +} + +# Wait for slapd to be ready +# Args: max_attempts [socket_url] +wait_for_slapd() { + local max_attempts="${1:-30}" + local socket_url="${2:-ldapi://%2Frun%2Fopenldap%2Fldapi}" + local attempt=0 + + while [ $attempt -lt $max_attempts ]; do + if ldapsearch -x -H "$socket_url" -b "" -s base "objectClass=*" >/dev/null 2>&1; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + + log_error "slapd did not become ready in time" + return 1 +} + +# Template substitution - replaces ${VAR} with environment variable values +process_template() { + local template="$1" + local output="$2" + + # Use envsubst-like behavior with sed + cp "$template" "$output" + + # Replace known variables + sed -i "s|\${LDAP_BASE_DN}|${LDAP_BASE_DN}|g" "$output" + sed -i "s|\${LDAP_DC}|${LDAP_DC}|g" "$output" + sed -i "s|\${LDAP_DOMAIN}|${LDAP_DOMAIN}|g" "$output" + sed -i "s|\${LDAP_ORGANISATION}|${LDAP_ORGANISATION}|g" "$output" + sed -i "s|\${LDAP_ADMIN_PASSWORD_HASH}|${LDAP_ADMIN_PASSWORD_HASH}|g" "$output" + sed -i "s|\${LDAP_CONFIG_PASSWORD_HASH}|${LDAP_CONFIG_PASSWORD_HASH}|g" "$output" + sed -i "s|\${LDAP_TLS_CERT_FILE}|${LDAP_TLS_CERT_FILE}|g" "$output" + sed -i "s|\${LDAP_TLS_KEY_FILE}|${LDAP_TLS_KEY_FILE}|g" "$output" + sed -i "s|\${LDAP_TLS_CA_FILE}|${LDAP_TLS_CA_FILE}|g" "$output" + sed -i "s|\${LDAP_TLS_VERIFY_CLIENT}|${LDAP_TLS_VERIFY_CLIENT}|g" "$output" +} + +# Default LDAPI socket URL +LDAPI_SOCKET="${LDAPI_SOCKET:-ldapi://%2Frun%2Fopenldap%2Fldapi}" + +# Check if a DN exists +dn_exists() { + local dn="$1" + ldapsearch -x -H "$LDAPI_SOCKET" -b "$dn" -s base "objectClass=*" >/dev/null 2>&1 +} + +# Add LDIF if it doesn't cause errors (ignore "already exists" errors) +ldif_add_safe() { + local ldif_file="$1" + local result + + result=$(ldapadd -x -H "$LDAPI_SOCKET" -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" -f "$ldif_file" 2>&1) || { + if echo "$result" | grep -q "Already exists"; then + log_warn "Entry already exists, skipping" + return 0 + else + log_error "Failed to add LDIF: $result" + return 1 + fi + } +}