{ # nixosConfigurations.openldap-server example. To use this module in your own NixOS config, import it with: # { # imports = [ ogc.nixosModules.openldap ]; # openldap = { # enable = true; # organization = "myorg"; # extension = "com"; # domain = "myorg.com"; # adminPasswordFile = "/run/secrets/ldap-admin-password"; # services.gitea.passwordFile = "/run/secrets/ldap-gitea-password"; # # ... etc # }; # } description = "NixOS OpenLDAP server with declarative directory content"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; }; outputs = { self, nixpkgs, ... }: { nixosModules.openldap = { config, lib, pkgs, ... }: let cfg = config.openldap; # Build the postfix-book schema as an LDIF file suitable for OLC (cn=config) postfixBookSchemaLdif = pkgs.writeText "postfix-book.ldif" '' dn: cn=postfix-book,cn=schema,cn=config objectClass: olcSchemaConfig cn: postfix-book olcAttributeTypes: {0}( 1.3.6.1.4.1.29426.1.10.1 NAME 'mailHomeDirectory' DESC 'The absolute path to the mail user home directory' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) olcAttributeTypes: {1}( 1.3.6.1.4.1.29426.1.10.2 NAME 'mailAlias' DESC 'RFC822 Mailbox - mail alias' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) olcAttributeTypes: {2}( 1.3.6.1.4.1.29426.1.10.3 NAME 'mailUidNumber' DESC 'UID required to access the mailbox' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) olcAttributeTypes: {3}( 1.3.6.1.4.1.29426.1.10.4 NAME 'mailGidNumber' DESC 'GID required to access the mailbox' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) olcAttributeTypes: {4}( 1.3.6.1.4.1.29426.1.10.5 NAME 'mailEnabled' DESC 'TRUE to enable, FALSE to disable account' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE ) olcAttributeTypes: {5}( 1.3.6.1.4.1.29426.1.10.6 NAME 'mailGroupMember' DESC 'Name of a mail distribution list' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) olcAttributeTypes: {6}( 1.3.6.1.4.1.29426.1.10.7 NAME 'mailQuota' DESC 'Mail quota limit in kilobytes' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) olcAttributeTypes: {7}( 1.3.6.1.4.1.29426.1.10.8 NAME 'mailStorageDirectory' DESC 'The absolute path to the mail users mailbox' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) olcObjectClasses: {0}( 1.3.6.1.4.1.29426.1.2.2.1 NAME 'PostfixBookMailAccount' DESC 'Mail account used in Postfix Book' SUP top AUXILIARY MUST mail MAY ( mailHomeDirectory $ mailAlias $ mailGroupMember $ mailUidNumber $ mailGidNumber $ mailEnabled $ mailQuota $ mailStorageDirectory ) ) olcObjectClasses: {1}( 1.3.6.1.4.1.29426.1.2.2.2 NAME 'PostfixBookMailForward' DESC 'Mail forward used in Postfix Book' SUP top AUXILIARY MUST ( mail $ mailAlias ) ) ''; # Shorthands baseDn = "dc=${cfg.organization},dc=${cfg.extension}"; adminDn = "cn=admin,${baseDn}"; servicesDn = "ou=services,${baseDn}"; # Helper to build a service account LDIF entry mkServiceEntry = uid: '' dn: uid=${uid},${servicesDn} objectClass: simpleSecurityObject objectClass: account objectClass: top uid: ${uid} userPassword: {SSHA}kZBGx5nrimaj4UJr5KglO9tpOSsPWb/5 ''; in { options.openldap = { enable = lib.mkOption {type = lib.types.bool;}; organization = lib.mkOption { type = lib.types.str; example = "myorg"; description = "The LDAP organization component (first dc= part of the base DN)."; }; extension = lib.mkOption { type = lib.types.str; example = "com"; description = "The LDAP extension component (second dc= part of the base DN)."; }; domain = lib.mkOption { type = lib.types.str; example = "myorg.com"; description = "The domain used for email addresses (e.g. admin@domain)."; }; adminPasswordFile = lib.mkOption { type = lib.types.path; description = '' Path to a file containing the hashed admin password (e.g. output of slappasswd). The file must be readable by the openldap user. ''; }; services = { gitea = { uid = lib.mkOption { type = lib.types.str; default = "gitea"; description = "UID for the Gitea LDAP service account."; }; passwordFile = lib.mkOption { type = lib.types.path; description = "Path to file containing the hashed password for the Gitea service account."; }; }; hauk = { uid = lib.mkOption { type = lib.types.str; default = "hauk"; description = "UID for the Hauk LDAP service account."; }; passwordFile = lib.mkOption { type = lib.types.path; description = "Path to file containing the hashed password for the Hauk service account."; }; }; mail = { uid = lib.mkOption { type = lib.types.str; default = "mail"; description = "UID for the mail (postfix/dovecot/roundcube) LDAP service account."; }; passwordFile = lib.mkOption { type = lib.types.path; description = "Path to file containing the hashed password for the mail service account."; }; }; nextcloud = { uid = lib.mkOption { type = lib.types.str; default = "nextcloud"; description = "UID for the Nextcloud LDAP service account."; }; passwordFile = lib.mkOption { type = lib.types.path; description = "Path to file containing the hashed password for the Nextcloud service account."; }; }; }; dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/openldap/data"; description = "Directory for the MDB database files."; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = with pkgs; [ openldap ]; services.openldap = { settings = { attrs = { olcLogLevel = "conns config"; }; children = { # ── Schema includes ────────────────────────────────────────── "cn=schema".includes = [ "${pkgs.openldap}/etc/schema/core.ldif" "${pkgs.openldap}/etc/schema/cosine.ldif" "${pkgs.openldap}/etc/schema/inetorgperson.ldif" "${pkgs.openldap}/etc/schema/nis.ldif" "${postfixBookSchemaLdif}" ]; "olcDatabase={-1}frontend".attrs = { objectClass = [ "olcDatabaseConfig" "olcFrontendConfig" ]; olcDatabase = "{-1}frontend"; olcAccess = [ ''{0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break'' ''{1}to dn.exact="" by * read'' ''{2}to dn.base="cn=Subschema" by * read'' ]; olcSizeLimit = "500"; structuralObjectClass = "olcDatabaseConfig"; }; "olcDatabase={0}config".attrs = { #objectClass = [ "olcDatabaseConfig" "olcConfig" ]; objectClass = "olcDatabaseConfig"; olcDatabase = "{0}config"; olcRootDN = "cn=admin,cn=config"; olcAccess = [ ''{0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break'' ]; }; # ── MDB database ───────────────────────────────────────────── "olcDatabase={1}mdb".attrs = { objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ]; olcDatabase = "{1}mdb"; olcDbDirectory = cfg.dataDir; olcSuffix = baseDn; olcRootDN = adminDn; olcRootPW = "{SSHA}kZBGx5nrimaj4UJr5KglO9tpOSsPWb/5"; olcDbCheckpoint = "512 30"; olcDbIndex = [ "objectClass eq" "cn,uid eq" "uidNumber,gidNumber eq" "member,memberUid eq" ]; olcDbMaxSize = "1073741824"; # ── ACLs (from _acl_add_0.ldif and _acl_add_1.ldif) ──────── olcAccess = [ # ACL 0: password access (from _acl_add_0.ldif) ''{0}to dn.subtree="${baseDn}" attrs=userPassword by self write by dn.base="${adminDn}" write by dn.children="${servicesDn}" read by anonymous auth by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage by * none'' # ACL 1: general subtree access (from _acl_add_1.ldif) ''{1}to dn.subtree="${baseDn}" by self read by dn.base="${adminDn}" write by dn.children="${servicesDn}" read by * none'' # ACL 2: allow root via SASL EXTERNAL (ldapi socket peer) ''{2}to dn.subtree="${baseDn}" by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage by * none'' ]; }; }; }; # ── Declarative DIT content ────────────────────────────────────── # This populates the database on every start, ensuring the entries # from all the LDIF files in openldap-data/ are present. declarativeContents."${baseDn}" = '' dn: ${baseDn} objectClass: top objectClass: dcObject objectClass: organization o: ${cfg.organization} dc: ${cfg.organization} dn: ${adminDn} objectClass: organizationalRole objectClass: simpleSecurityObject objectClass: extensibleObject cn: admin description: LDAP administrator mail: admin@${cfg.domain} userPassword: {SSHA}kZBGx5nrimaj4UJr5KglO9tpOSsPWb/5 dn: ou=people,${baseDn} objectClass: organizationalUnit objectClass: top ou: people dn: ou=services,${baseDn} objectClass: organizationalUnit objectClass: top ou: services ${mkServiceEntry cfg.services.gitea.uid} ${mkServiceEntry cfg.services.hauk.uid} ${mkServiceEntry cfg.services.mail.uid} ${mkServiceEntry cfg.services.nextcloud.uid} ''; }; # ── Set service account passwords from files at activation time ──── # declarativeContents doesn't support reading passwords from files, # so we use a systemd service to set them after slapd starts. systemd.services.openldap-set-service-passwords = { description = "Set LDAP service account passwords from files"; after = [ "openldap.service" ]; requires = [ "openldap.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.openldap ]; script = let mkPasswordScript = name: svcCfg: '' echo "Setting password for ${name} service account (uid=${svcCfg.uid})..." PASSWORD=$(cat "${svcCfg.passwordFile}") ldapmodify -Y EXTERNAL -H ldapi:/// -w aaa <