1 { config, lib, pkgs, ... }:
3 cfg = config.services.portunus;
7 options.services.portunus = {
8 enable = lib.mkEnableOption "Portunus, a self-contained user/group management and authentication service for LDAP";
10 domain = lib.mkOption {
12 example = "sso.example.com";
13 description = "Subdomain which gets reverse proxied to Portunus webserver.";
17 type = lib.types.port;
20 Port where the Portunus webserver should listen on.
22 This must be put behind a TLS-capable reverse proxy because Portunus only listens on localhost.
26 package = lib.mkPackageOption pkgs "portunus" { };
28 seedPath = lib.mkOption {
29 type = lib.types.nullOr lib.types.path;
32 Path to a portunus seed file in json format.
33 See <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration> for available options.
37 seedSettings = lib.mkOption {
38 type = with lib.types; nullOr (attrsOf (listOf (attrsOf anything)));
41 Seed settings for users and groups.
42 See upstream for format <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration>
46 stateDir = lib.mkOption {
47 type = lib.types.path;
48 default = "/var/lib/portunus";
49 description = "Path where Portunus stores its state.";
55 description = "User account under which Portunus runs its webserver.";
58 group = lib.mkOption {
61 description = "Group account under which Portunus runs its webserver.";
65 enable = lib.mkEnableOption ''
68 To activate dex, first a search user must be created in the Portunus web ui
69 and then the password must to be set as the `DEX_SEARCH_USER_PASSWORD` environment variable
70 in the [](#opt-services.dex.environmentFile) setting
73 oidcClients = lib.mkOption {
74 type = lib.types.listOf (lib.types.submodule {
76 callbackURL = lib.mkOption {
78 description = "URL where the OIDC client should redirect";
82 description = "ID of the OIDC client";
89 callbackURL = "https://example.com/client/oidc/callback";
96 The OIDC secret must be set as the `DEX_CLIENT_''${id}` environment variable
97 in the [](#opt-services.dex.environmentFile) setting.
100 Make sure the id only contains characters that are allowed in an environment variable name, e.g. no -.
105 port = lib.mkOption {
106 type = lib.types.port;
108 description = "Port where dex should listen on.";
113 package = lib.mkOption {
114 type = lib.types.package;
115 default = pkgs.openldap;
116 defaultText = lib.literalExpression "pkgs.openldap.override { libxcrypt = pkgs.libxcrypt-legacy; }";
117 description = "The OpenLDAP package to use.";
120 searchUserName = lib.mkOption {
121 type = lib.types.str;
125 The login name of the search user.
126 This user account must be configured in Portunus either manually or via seeding.
130 suffix = lib.mkOption {
131 type = lib.types.str;
132 example = "dc=example,dc=org";
134 The DN of the topmost entry in your LDAP directory.
135 Please refer to the Portunus documentation for more information on how this impacts the structure of the LDAP directory.
140 type = lib.types.bool;
143 Whether to enable LDAPS protocol.
144 This also adds two entries to the `/etc/hosts` file to point [](#opt-services.portunus.domain) to localhost,
145 so that CLIs and programs can use ldaps protocol and verify the certificate without opening the firewall port for the protocol.
147 This requires a TLS certificate for [](#opt-services.portunus.domain) to be configured via [](#opt-security.acme.certs).
151 user = lib.mkOption {
152 type = lib.types.str;
153 default = "openldap";
154 description = "User account under which Portunus runs its LDAP server.";
157 group = lib.mkOption {
158 type = lib.types.str;
159 default = "openldap";
160 description = "Group account under which Portunus runs its LDAP server.";
165 config = lib.mkIf cfg.enable {
168 assertion = cfg.dex.enable -> cfg.ldap.searchUserName != "";
169 message = "services.portunus.dex.enable requires services.portunus.ldap.searchUserName to be set.";
173 # add ldapsearch(1) etc. to interactive shells
174 environment.systemPackages = [ cfg.ldap.package ];
176 # allow connecting via ldaps /w certificate without opening ports
177 networking.hosts = lib.mkIf cfg.ldap.tls {
178 "::1" = [ cfg.domain ];
179 "127.0.0.1" = [ cfg.domain ];
183 dex = lib.mkIf cfg.dex.enable {
186 issuer = "https://${cfg.domain}/dex";
187 web.http = "127.0.0.1:${toString cfg.dex.port}";
190 config.file = "/var/lib/dex/dex.db";
192 enablePasswordDB = false;
198 host = "${cfg.domain}:636";
199 bindDN = "uid=${cfg.ldap.searchUserName},ou=users,${cfg.ldap.suffix}";
200 bindPW = "$DEX_SEARCH_USER_PASSWORD";
202 baseDN = "ou=users,${cfg.ldap.suffix}";
203 filter = "(objectclass=person)";
208 preferredUsernameAttr = "uid";
211 baseDN = "ou=groups,${cfg.ldap.suffix}";
212 filter = "(objectclass=groupOfNames)";
214 userMatchers = [{ userAttr = "DN"; groupAttr = "member"; }];
219 staticClients = lib.forEach cfg.dex.oidcClients (client: {
221 redirectURIs = [ client.callbackURL ];
222 name = "OIDC for ${client.id}";
223 secretEnv = "DEX_CLIENT_${client.id}";
228 portunus.seedPath = lib.mkIf (cfg.seedSettings != null) (pkgs.writeText "seed.json" (builtins.toJSON cfg.seedSettings));
232 dex = lib.mkIf cfg.dex.enable {
234 # `dex.service` is super locked down out of the box, but we need some
235 # place to write the SQLite database. This creates $STATE_DIRECTORY below
236 # /var/lib/private because DynamicUser=true, but it gets symlinked into
237 # /var/lib/dex inside the unit
238 StateDirectory = "dex";
243 description = "Self-contained authentication service";
244 wantedBy = [ "multi-user.target" ];
245 after = [ "network.target" ];
247 ExecStart = "${cfg.package}/bin/portunus-orchestrator";
248 Restart = "on-failure";
251 PORTUNUS_LDAP_SUFFIX = cfg.ldap.suffix;
252 PORTUNUS_SERVER_BINARY = "${cfg.package}/bin/portunus-server";
253 PORTUNUS_SERVER_GROUP = cfg.group;
254 PORTUNUS_SERVER_USER = cfg.user;
255 PORTUNUS_SERVER_HTTP_LISTEN = "127.0.0.1:${toString cfg.port}";
256 PORTUNUS_SERVER_STATE_DIR = cfg.stateDir;
257 PORTUNUS_SLAPD_BINARY = "${cfg.ldap.package}/libexec/slapd";
258 PORTUNUS_SLAPD_GROUP = cfg.ldap.group;
259 PORTUNUS_SLAPD_USER = cfg.ldap.user;
260 PORTUNUS_SLAPD_SCHEMA_DIR = "${cfg.ldap.package}/etc/schema";
261 } // (lib.optionalAttrs (cfg.seedPath != null) ({
262 PORTUNUS_SEED_PATH = cfg.seedPath;
263 })) // (lib.optionalAttrs cfg.ldap.tls (
265 acmeDirectory = config.security.acme.certs."${cfg.domain}".directory;
268 PORTUNUS_SERVER_HTTP_SECURE = "true";
269 PORTUNUS_SLAPD_TLS_CA_CERTIFICATE = "/etc/ssl/certs/ca-certificates.crt";
270 PORTUNUS_SLAPD_TLS_CERTIFICATE = "${acmeDirectory}/cert.pem";
271 PORTUNUS_SLAPD_TLS_DOMAIN_NAME = cfg.domain;
272 PORTUNUS_SLAPD_TLS_PRIVATE_KEY = "${acmeDirectory}/key.pem";
277 users.users = lib.mkMerge [
278 (lib.mkIf (cfg.ldap.user == "openldap") {
280 group = cfg.ldap.group;
284 (lib.mkIf (cfg.user == "portunus") {
292 users.groups = lib.mkMerge [
293 (lib.mkIf (cfg.ldap.user == "openldap") {
296 (lib.mkIf (cfg.user == "portunus") {
302 meta.maintainers = [ lib.maintainers.majewsky ] ++ lib.teams.c3d2.members;