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.mkPackageOption pkgs "openldap" { };
121
122 searchUserName = lib.mkOption {
123 type = lib.types.str;
124 default = "";
125 example = "admin";
126 description = ''
127 The login name of the search user.
128 This user account must be configured in Portunus either manually or via seeding.
129 '';
130 };
131
132 suffix = lib.mkOption {
133 type = lib.types.str;
134 example = "dc=example,dc=org";
135 description = ''
136 The DN of the topmost entry in your LDAP directory.
137 Please refer to the Portunus documentation for more information on how this impacts the structure of the LDAP directory.
138 '';
139 };
140
141 tls = lib.mkOption {
142 type = lib.types.bool;
143 default = false;
144 description = ''
145 Whether to enable LDAPS protocol.
146 This also adds two entries to the `/etc/hosts` file to point [](#opt-services.portunus.domain) to localhost,
147 so that CLIs and programs can use ldaps protocol and verify the certificate without opening the firewall port for the protocol.
148
149 This requires a TLS certificate for [](#opt-services.portunus.domain) to be configured via [](#opt-security.acme.certs).
150 '';
151 };
152
153 user = lib.mkOption {
154 type = lib.types.str;
155 default = "openldap";
156 description = "User account under which Portunus runs its LDAP server.";
157 };
158
159 group = lib.mkOption {
160 type = lib.types.str;
161 default = "openldap";
162 description = "Group account under which Portunus runs its LDAP server.";
163 };
164 };
165 };
166
167 config = lib.mkIf cfg.enable {
168 assertions = [
169 {
170 assertion = cfg.dex.enable -> cfg.ldap.searchUserName != "";
171 message = "services.portunus.dex.enable requires services.portunus.ldap.searchUserName to be set.";
172 }
173 ];
174
175 # add ldapsearch(1) etc. to interactive shells
176 environment.systemPackages = [ cfg.ldap.package ];
177
178 # allow connecting via ldaps /w certificate without opening ports
179 networking.hosts = lib.mkIf cfg.ldap.tls {
180 "::1" = [ cfg.domain ];
181 "127.0.0.1" = [ cfg.domain ];
182 };
183
184 services = {
185 dex = lib.mkIf cfg.dex.enable {
186 enable = true;
187 settings = {
188 issuer = "https://${cfg.domain}/dex";
189 web.http = "127.0.0.1:${toString cfg.dex.port}";
190 storage = {
191 type = "sqlite3";
192 config.file = "/var/lib/dex/dex.db";
193 };
194 enablePasswordDB = false;
195 connectors = [
196 {
197 type = "ldap";
198 id = "ldap";
199 name = "LDAP";
200 config = {
201 host = "${cfg.domain}:636";
202 bindDN = "uid=${cfg.ldap.searchUserName},ou=users,${cfg.ldap.suffix}";
203 bindPW = "$DEX_SEARCH_USER_PASSWORD";
204 userSearch = {
205 baseDN = "ou=users,${cfg.ldap.suffix}";
206 filter = "(objectclass=person)";
207 username = "uid";
208 idAttr = "uid";
209 emailAttr = "mail";
210 nameAttr = "cn";
211 preferredUsernameAttr = "uid";
212 };
213 groupSearch = {
214 baseDN = "ou=groups,${cfg.ldap.suffix}";
215 filter = "(objectclass=groupOfNames)";
216 nameAttr = "cn";
217 userMatchers = [
218 {
219 userAttr = "DN";
220 groupAttr = "member";
221 }
222 ];
223 };
224 };
225 }
226 ];
227
228 staticClients = lib.forEach cfg.dex.oidcClients (client: {
229 inherit (client) id;
230 redirectURIs = [ client.callbackURL ];
231 name = "OIDC for ${client.id}";
232 secretEnv = "DEX_CLIENT_${client.id}";
233 });
234 };
235 };
236
237 portunus.seedPath = lib.mkIf (cfg.seedSettings != null) (
238 pkgs.writeText "seed.json" (builtins.toJSON cfg.seedSettings)
239 );
240 };
241
242 systemd.services = {
243 dex = lib.mkIf cfg.dex.enable {
244 serviceConfig = {
245 # `dex.service` is super locked down out of the box, but we need some
246 # place to write the SQLite database. This creates $STATE_DIRECTORY below
247 # /var/lib/private because DynamicUser=true, but it gets symlinked into
248 # /var/lib/dex inside the unit
249 StateDirectory = "dex";
250 };
251 };
252
253 portunus = {
254 description = "Self-contained authentication service";
255 wantedBy = [ "multi-user.target" ];
256 after = [ "network.target" ];
257 serviceConfig = {
258 ExecStart = "${cfg.package}/bin/portunus-orchestrator";
259 Restart = "on-failure";
260 };
261 environment = {
262 PORTUNUS_LDAP_SUFFIX = cfg.ldap.suffix;
263 PORTUNUS_SERVER_BINARY = "${cfg.package}/bin/portunus-server";
264 PORTUNUS_SERVER_GROUP = cfg.group;
265 PORTUNUS_SERVER_USER = cfg.user;
266 PORTUNUS_SERVER_HTTP_LISTEN = "127.0.0.1:${toString cfg.port}";
267 PORTUNUS_SERVER_STATE_DIR = cfg.stateDir;
268 PORTUNUS_SLAPD_BINARY = "${cfg.ldap.package}/libexec/slapd";
269 PORTUNUS_SLAPD_GROUP = cfg.ldap.group;
270 PORTUNUS_SLAPD_USER = cfg.ldap.user;
271 PORTUNUS_SLAPD_SCHEMA_DIR = "${cfg.ldap.package}/etc/schema";
272 }
273 // (lib.optionalAttrs (cfg.seedPath != null) ({
274 PORTUNUS_SEED_PATH = cfg.seedPath;
275 }))
276 // (lib.optionalAttrs cfg.ldap.tls (
277 let
278 acmeDirectory = config.security.acme.certs."${cfg.domain}".directory;
279 in
280 {
281 PORTUNUS_SERVER_HTTP_SECURE = "true";
282 PORTUNUS_SLAPD_TLS_CA_CERTIFICATE = config.security.pki.caBundle;
283 PORTUNUS_SLAPD_TLS_CERTIFICATE = "${acmeDirectory}/cert.pem";
284 PORTUNUS_SLAPD_TLS_DOMAIN_NAME = cfg.domain;
285 PORTUNUS_SLAPD_TLS_PRIVATE_KEY = "${acmeDirectory}/key.pem";
286 }
287 ));
288 };
289 };
290
291 users.users = lib.mkMerge [
292 (lib.mkIf (cfg.ldap.user == "openldap") {
293 openldap = {
294 group = cfg.ldap.group;
295 isSystemUser = true;
296 };
297 })
298 (lib.mkIf (cfg.user == "portunus") {
299 portunus = {
300 group = cfg.group;
301 isSystemUser = true;
302 };
303 })
304 ];
305
306 users.groups = lib.mkMerge [
307 (lib.mkIf (cfg.ldap.user == "openldap") {
308 openldap = { };
309 })
310 (lib.mkIf (cfg.user == "portunus") {
311 portunus = { };
312 })
313 ];
314 };
315
316 meta.maintainers = [ lib.maintainers.majewsky ] ++ lib.teams.c3d2.members;
317}