1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.portunus;
9
10in
11{
12 options.services.portunus = {
13 enable = lib.mkEnableOption "Portunus, a self-contained user/group management and authentication service for LDAP";
14
15 domain = lib.mkOption {
16 type = lib.types.str;
17 example = "sso.example.com";
18 description = "Subdomain which gets reverse proxied to Portunus webserver.";
19 };
20
21 port = lib.mkOption {
22 type = lib.types.port;
23 default = 8080;
24 description = ''
25 Port where the Portunus webserver should listen on.
26
27 This must be put behind a TLS-capable reverse proxy because Portunus only listens on localhost.
28 '';
29 };
30
31 package = lib.mkPackageOption pkgs "portunus" { };
32
33 seedPath = lib.mkOption {
34 type = lib.types.nullOr lib.types.path;
35 default = null;
36 description = ''
37 Path to a portunus seed file in json format.
38 See <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration> for available options.
39 '';
40 };
41
42 seedSettings = lib.mkOption {
43 type = with lib.types; nullOr (attrsOf (listOf (attrsOf anything)));
44 default = null;
45 description = ''
46 Seed settings for users and groups.
47 See upstream for format <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration>
48 '';
49 };
50
51 stateDir = lib.mkOption {
52 type = lib.types.path;
53 default = "/var/lib/portunus";
54 description = "Path where Portunus stores its state.";
55 };
56
57 user = lib.mkOption {
58 type = lib.types.str;
59 default = "portunus";
60 description = "User account under which Portunus runs its webserver.";
61 };
62
63 group = lib.mkOption {
64 type = lib.types.str;
65 default = "portunus";
66 description = "Group account under which Portunus runs its webserver.";
67 };
68
69 dex = {
70 enable = lib.mkEnableOption ''
71 Dex ldap connector.
72
73 To activate dex, first a search user must be created in the Portunus web ui
74 and then the password must to be set as the `DEX_SEARCH_USER_PASSWORD` environment variable
75 in the [](#opt-services.dex.environmentFile) setting
76 '';
77
78 oidcClients = lib.mkOption {
79 type = lib.types.listOf (
80 lib.types.submodule {
81 options = {
82 callbackURL = lib.mkOption {
83 type = lib.types.str;
84 description = "URL where the OIDC client should redirect";
85 };
86 id = lib.mkOption {
87 type = lib.types.str;
88 description = "ID of the OIDC client";
89 };
90 };
91 }
92 );
93 default = [ ];
94 example = [
95 {
96 callbackURL = "https://example.com/client/oidc/callback";
97 id = "service";
98 }
99 ];
100 description = ''
101 List of OIDC clients.
102
103 The OIDC secret must be set as the `DEX_CLIENT_''${id}` environment variable
104 in the [](#opt-services.dex.environmentFile) setting.
105
106 ::: {.note}
107 Make sure the id only contains characters that are allowed in an environment variable name, e.g. no -.
108 :::
109 '';
110 };
111
112 port = lib.mkOption {
113 type = lib.types.port;
114 default = 5556;
115 description = "Port where dex should listen on.";
116 };
117 };
118
119 ldap = {
120 package = lib.mkOption {
121 type = lib.types.package;
122 default = pkgs.openldap;
123 defaultText = lib.literalExpression "pkgs.openldap";
124 description = "The OpenLDAP package to use.";
125 };
126
127 searchUserName = lib.mkOption {
128 type = lib.types.str;
129 default = "";
130 example = "admin";
131 description = ''
132 The login name of the search user.
133 This user account must be configured in Portunus either manually or via seeding.
134 '';
135 };
136
137 suffix = lib.mkOption {
138 type = lib.types.str;
139 example = "dc=example,dc=org";
140 description = ''
141 The DN of the topmost entry in your LDAP directory.
142 Please refer to the Portunus documentation for more information on how this impacts the structure of the LDAP directory.
143 '';
144 };
145
146 tls = lib.mkOption {
147 type = lib.types.bool;
148 default = false;
149 description = ''
150 Whether to enable LDAPS protocol.
151 This also adds two entries to the `/etc/hosts` file to point [](#opt-services.portunus.domain) to localhost,
152 so that CLIs and programs can use ldaps protocol and verify the certificate without opening the firewall port for the protocol.
153
154 This requires a TLS certificate for [](#opt-services.portunus.domain) to be configured via [](#opt-security.acme.certs).
155 '';
156 };
157
158 user = lib.mkOption {
159 type = lib.types.str;
160 default = "openldap";
161 description = "User account under which Portunus runs its LDAP server.";
162 };
163
164 group = lib.mkOption {
165 type = lib.types.str;
166 default = "openldap";
167 description = "Group account under which Portunus runs its LDAP server.";
168 };
169 };
170 };
171
172 config = lib.mkIf cfg.enable {
173 assertions = [
174 {
175 assertion = cfg.dex.enable -> cfg.ldap.searchUserName != "";
176 message = "services.portunus.dex.enable requires services.portunus.ldap.searchUserName to be set.";
177 }
178 ];
179
180 # add ldapsearch(1) etc. to interactive shells
181 environment.systemPackages = [ cfg.ldap.package ];
182
183 # allow connecting via ldaps /w certificate without opening ports
184 networking.hosts = lib.mkIf cfg.ldap.tls {
185 "::1" = [ cfg.domain ];
186 "127.0.0.1" = [ cfg.domain ];
187 };
188
189 services = {
190 dex = lib.mkIf cfg.dex.enable {
191 enable = true;
192 settings = {
193 issuer = "https://${cfg.domain}/dex";
194 web.http = "127.0.0.1:${toString cfg.dex.port}";
195 storage = {
196 type = "sqlite3";
197 config.file = "/var/lib/dex/dex.db";
198 };
199 enablePasswordDB = false;
200 connectors = [
201 {
202 type = "ldap";
203 id = "ldap";
204 name = "LDAP";
205 config = {
206 host = "${cfg.domain}:636";
207 bindDN = "uid=${cfg.ldap.searchUserName},ou=users,${cfg.ldap.suffix}";
208 bindPW = "$DEX_SEARCH_USER_PASSWORD";
209 userSearch = {
210 baseDN = "ou=users,${cfg.ldap.suffix}";
211 filter = "(objectclass=person)";
212 username = "uid";
213 idAttr = "uid";
214 emailAttr = "mail";
215 nameAttr = "cn";
216 preferredUsernameAttr = "uid";
217 };
218 groupSearch = {
219 baseDN = "ou=groups,${cfg.ldap.suffix}";
220 filter = "(objectclass=groupOfNames)";
221 nameAttr = "cn";
222 userMatchers = [
223 {
224 userAttr = "DN";
225 groupAttr = "member";
226 }
227 ];
228 };
229 };
230 }
231 ];
232
233 staticClients = lib.forEach cfg.dex.oidcClients (client: {
234 inherit (client) id;
235 redirectURIs = [ client.callbackURL ];
236 name = "OIDC for ${client.id}";
237 secretEnv = "DEX_CLIENT_${client.id}";
238 });
239 };
240 };
241
242 portunus.seedPath = lib.mkIf (cfg.seedSettings != null) (
243 pkgs.writeText "seed.json" (builtins.toJSON cfg.seedSettings)
244 );
245 };
246
247 systemd.services = {
248 dex = lib.mkIf cfg.dex.enable {
249 serviceConfig = {
250 # `dex.service` is super locked down out of the box, but we need some
251 # place to write the SQLite database. This creates $STATE_DIRECTORY below
252 # /var/lib/private because DynamicUser=true, but it gets symlinked into
253 # /var/lib/dex inside the unit
254 StateDirectory = "dex";
255 };
256 };
257
258 portunus = {
259 description = "Self-contained authentication service";
260 wantedBy = [ "multi-user.target" ];
261 after = [ "network.target" ];
262 serviceConfig = {
263 ExecStart = "${cfg.package}/bin/portunus-orchestrator";
264 Restart = "on-failure";
265 };
266 environment =
267 {
268 PORTUNUS_LDAP_SUFFIX = cfg.ldap.suffix;
269 PORTUNUS_SERVER_BINARY = "${cfg.package}/bin/portunus-server";
270 PORTUNUS_SERVER_GROUP = cfg.group;
271 PORTUNUS_SERVER_USER = cfg.user;
272 PORTUNUS_SERVER_HTTP_LISTEN = "127.0.0.1:${toString cfg.port}";
273 PORTUNUS_SERVER_STATE_DIR = cfg.stateDir;
274 PORTUNUS_SLAPD_BINARY = "${cfg.ldap.package}/libexec/slapd";
275 PORTUNUS_SLAPD_GROUP = cfg.ldap.group;
276 PORTUNUS_SLAPD_USER = cfg.ldap.user;
277 PORTUNUS_SLAPD_SCHEMA_DIR = "${cfg.ldap.package}/etc/schema";
278 }
279 // (lib.optionalAttrs (cfg.seedPath != null) ({
280 PORTUNUS_SEED_PATH = cfg.seedPath;
281 }))
282 // (lib.optionalAttrs cfg.ldap.tls (
283 let
284 acmeDirectory = config.security.acme.certs."${cfg.domain}".directory;
285 in
286 {
287 PORTUNUS_SERVER_HTTP_SECURE = "true";
288 PORTUNUS_SLAPD_TLS_CA_CERTIFICATE = config.security.pki.caBundle;
289 PORTUNUS_SLAPD_TLS_CERTIFICATE = "${acmeDirectory}/cert.pem";
290 PORTUNUS_SLAPD_TLS_DOMAIN_NAME = cfg.domain;
291 PORTUNUS_SLAPD_TLS_PRIVATE_KEY = "${acmeDirectory}/key.pem";
292 }
293 ));
294 };
295 };
296
297 users.users = lib.mkMerge [
298 (lib.mkIf (cfg.ldap.user == "openldap") {
299 openldap = {
300 group = cfg.ldap.group;
301 isSystemUser = true;
302 };
303 })
304 (lib.mkIf (cfg.user == "portunus") {
305 portunus = {
306 group = cfg.group;
307 isSystemUser = true;
308 };
309 })
310 ];
311
312 users.groups = lib.mkMerge [
313 (lib.mkIf (cfg.ldap.user == "openldap") {
314 openldap = { };
315 })
316 (lib.mkIf (cfg.user == "portunus") {
317 portunus = { };
318 })
319 ];
320 };
321
322 meta.maintainers = [ lib.maintainers.majewsky ] ++ lib.teams.c3d2.members;
323}