1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 cfg = config.services.dependency-track;
9
10 settingsFormat = pkgs.formats.javaProperties { };
11
12 frontendConfigFormat = pkgs.formats.json { };
13 frontendConfigFile = frontendConfigFormat.generate "config.json" {
14 API_BASE_URL = cfg.frontend.baseUrl;
15 OIDC_ISSUER = cfg.oidc.issuer;
16 OIDC_CLIENT_ID = cfg.oidc.clientId;
17 OIDC_SCOPE = cfg.oidc.scope;
18 OIDC_FLOW = cfg.oidc.flow;
19 OIDC_LOGIN_BUTTON_TEXT = cfg.oidc.loginButtonText;
20 };
21
22 sslEnabled =
23 config.services.nginx.virtualHosts.${cfg.nginx.domain}.addSSL
24 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.forceSSL
25 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.onlySSL
26 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.enableACME;
27
28 assertStringPath =
29 optionName: value:
30 if lib.isPath value then
31 throw ''
32 services.dependency-track.${optionName}:
33 ${toString value}
34 is a Nix path, but should be a string, since Nix
35 paths are copied into the world-readable Nix store.
36 ''
37 else
38 value;
39
40 filterNull = lib.filterAttrs (_: v: v != null);
41
42 renderSettings =
43 settings:
44 lib.mapAttrs' (
45 n: v:
46 lib.nameValuePair (lib.toUpper (lib.replaceStrings [ "." ] [ "_" ] n)) (
47 if lib.isBool v then lib.boolToString v else v
48 )
49 ) (filterNull settings);
50in
51{
52 options.services.dependency-track = {
53 enable = lib.mkEnableOption "dependency-track";
54
55 package = lib.mkPackageOption pkgs "dependency-track" { };
56
57 logLevel = lib.mkOption {
58 type = lib.types.enum [
59 "INFO"
60 "WARN"
61 "ERROR"
62 "DEBUG"
63 "TRACE"
64 ];
65 default = "INFO";
66 description = "Log level for dependency-track";
67 };
68
69 port = lib.mkOption {
70 type = lib.types.port;
71 default = 8080;
72 description = ''
73 On which port dependency-track should listen for new HTTP connections.
74 '';
75 };
76
77 javaArgs = lib.mkOption {
78 type = lib.types.listOf lib.types.str;
79 default = [ ];
80 example = lib.literalExpression ''[ "-Xmx16G" ] '';
81 description = ''
82 Java options passed to JVM. Configuring this is usually not necessary, but for small systems
83 it can be useful to tweak the JVM heap size.
84 '';
85 };
86
87 database = {
88 type = lib.mkOption {
89 type = lib.types.enum [
90 "h2"
91 "postgresql"
92 "manual"
93 ];
94 default = "postgresql";
95 description = ''
96 `h2` database is not recommended for a production setup.
97 `postgresql` this settings it recommended for production setups.
98 `manual` the module doesn't handle database settings.
99 '';
100 };
101
102 createLocally = lib.mkOption {
103 type = lib.types.bool;
104 default = true;
105 description = ''
106 Whether a database should be automatically created on the
107 local host. Set this to false if you plan on provisioning a
108 local database yourself.
109 '';
110 };
111
112 databaseName = lib.mkOption {
113 type = lib.types.str;
114 default = "dependency-track";
115 description = ''
116 Database name to use when connecting to an external or
117 manually provisioned database; has no effect when a local
118 database is automatically provisioned.
119
120 To use this with a local database, set {option}`services.dependency-track.database.createLocally`
121 to `false` and create the database and user.
122 '';
123 };
124
125 username = lib.mkOption {
126 type = lib.types.str;
127 default = "dependency-track";
128 description = ''
129 Username to use when connecting to an external or manually
130 provisioned database; has no effect when a local database is
131 automatically provisioned.
132
133 To use this with a local database, set {option}`services.dependency-track.database.createLocally`
134 to `false` and create the database and user.
135 '';
136 };
137
138 passwordFile = lib.mkOption {
139 type = lib.types.path;
140 example = "/run/keys/db_password";
141 apply = assertStringPath "passwordFile";
142 description = ''
143 The path to a file containing the database password.
144 '';
145 };
146 };
147
148 ldap.bindPasswordFile = lib.mkOption {
149 type = lib.types.path;
150 example = "/run/keys/ldap_bind_password";
151 apply = assertStringPath "bindPasswordFile";
152 description = ''
153 The path to a file containing the LDAP bind password.
154 '';
155 };
156
157 frontend = {
158 baseUrl = lib.mkOption {
159 type = lib.types.str;
160 default = lib.optionalString cfg.nginx.enable "${
161 if sslEnabled then "https" else "http"
162 }://${cfg.nginx.domain}";
163 defaultText = lib.literalExpression ''
164 lib.optionalString config.services.dependency-track.nginx.enable "''${
165 if sslEnabled then "https" else "http"
166 }://''${config.services.dependency-track.nginx.domain}";
167 '';
168 description = ''
169 The base URL of the API server.
170
171 NOTE:
172 * This URL must be reachable by the browsers of your users.
173 * The frontend container itself does NOT communicate with the API server directly, it just serves static files.
174 * When deploying to dedicated servers, please use the external IP or domain of the API server.
175 '';
176 };
177 };
178
179 oidc = {
180 enable = lib.mkEnableOption "oidc support";
181 issuer = lib.mkOption {
182 type = lib.types.str;
183 default = "";
184 description = ''
185 Defines the issuer URL to be used for OpenID Connect.
186 See alpine.oidc.issuer property of the API server.
187 '';
188 };
189 clientId = lib.mkOption {
190 type = lib.types.str;
191 default = "";
192 description = ''
193 Defines the client ID for OpenID Connect.
194 '';
195 };
196 scope = lib.mkOption {
197 type = lib.types.str;
198 default = "openid profile email";
199 description = ''
200 Defines the scopes to request for OpenID Connect.
201 See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
202 '';
203 };
204 flow = lib.mkOption {
205 type = lib.types.enum [
206 "code"
207 "implicit"
208 ];
209 default = "code";
210 description = ''
211 Specifies the OpenID Connect flow to use.
212 Values other than "implicit" will result in the Code+PKCE flow to be used.
213 Usage of the implicit flow is strongly discouraged, but may be necessary when
214 the IdP of choice does not support the Code+PKCE flow.
215 See also:
216 - <https://oauth.net/2/grant-types/implicit/>
217 - <https://oauth.net/2/pkce/>
218 '';
219 };
220 loginButtonText = lib.mkOption {
221 type = lib.types.str;
222 default = "Login with OpenID Connect";
223 description = ''
224 Defines the scopes to request for OpenID Connect.
225 See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
226 '';
227 };
228 usernameClaim = lib.mkOption {
229 type = lib.types.str;
230 default = "name";
231 example = "preferred_username";
232 description = ''
233 Defines the name of the claim that contains the username in the provider's userinfo endpoint.
234 Common claims are "name", "username", "preferred_username" or "nickname".
235 See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
236 '';
237 };
238 userProvisioning = lib.mkOption {
239 type = lib.types.bool;
240 default = false;
241 example = true;
242 description = ''
243 Specifies if mapped OpenID Connect accounts are automatically created upon successful
244 authentication. When a user logs in with a valid access token but an account has
245 not been previously provisioned, an authentication failure will be returned.
246 This allows admins to control specifically which OpenID Connect users can access the
247 system and which users cannot. When this value is set to true, a local OpenID Connect
248 user will be created and mapped to the OpenID Connect account automatically. This
249 automatic provisioning only affects authentication, not authorization.
250 '';
251 };
252 teamSynchronization = lib.mkOption {
253 type = lib.types.bool;
254 default = false;
255 example = true;
256 description = ''
257 This option will ensure that team memberships for OpenID Connect users are dynamic and
258 synchronized with membership of OpenID Connect groups or assigned roles. When a team is
259 mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
260 assigned to the team if they are a member of the group the team is mapped to. If the user
261 is later removed from the OpenID Connect group, they will also be removed from the team. This
262 option provides the ability to dynamically control user permissions via the identity provider.
263 Note that team synchronization is only performed during user provisioning and after successful
264 authentication.
265 '';
266 };
267 teams = {
268 claim = lib.mkOption {
269 type = lib.types.str;
270 default = "groups";
271 description = ''
272 Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
273 The claim must be an array of strings. Most public identity providers do not support group or role management.
274 When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
275 will most likely need to be configured.
276 '';
277 };
278 default = lib.mkOption {
279 type = lib.types.nullOr lib.types.commas;
280 default = null;
281 description = ''
282 Defines one or more team names that auto-provisioned OIDC users shall be added to.
283 Multiple team names may be provided as comma-separated list.
284
285 Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
286 or {option}`services.dependency-track.oidc.teamSynchronization`=true.
287 '';
288 };
289 };
290 };
291
292 nginx = {
293 enable = lib.mkOption {
294 type = lib.types.bool;
295 default = true;
296 example = false;
297 description = ''
298 Whether to set up an nginx virtual host.
299 '';
300 };
301
302 domain = lib.mkOption {
303 type = lib.types.str;
304 example = "dtrack.example.com";
305 description = ''
306 The domain name under which to set up the virtual host.
307 '';
308 };
309 };
310
311 settings = lib.mkOption {
312 type = lib.types.submodule {
313 freeformType = settingsFormat.type;
314 options = {
315 "alpine.data.directory" = lib.mkOption {
316 type = lib.types.path;
317 default = "/var/lib/dependency-track";
318 description = ''
319 Defines the path to the data directory. This directory will hold logs, keys,
320 and any database or index files along with application-specific files or
321 directories.
322 '';
323 };
324 "alpine.database.mode" = lib.mkOption {
325 type = lib.types.enum [
326 "server"
327 "embedded"
328 "external"
329 ];
330 default =
331 if cfg.database.type == "h2" then
332 "embedded"
333 else if cfg.database.type == "postgresql" then
334 "external"
335 else
336 null;
337 defaultText = lib.literalExpression ''
338 if config.services.dependency-track.database.type == "h2" then "embedded"
339 else if config.services.dependency-track.database.type == "postgresql" then "external"
340 else null
341 '';
342 description = ''
343 Defines the database mode of operation. Valid choices are:
344 'server', 'embedded', and 'external'.
345 In server mode, the database will listen for connections from remote hosts.
346 In embedded mode, the system will be more secure and slightly faster.
347 External mode should be used when utilizing an external database server
348 (i.e. mysql, postgresql, etc).
349 '';
350 };
351 "alpine.database.url" = lib.mkOption {
352 type = lib.types.str;
353 default =
354 if cfg.database.type == "h2" then
355 "jdbc:h2:/var/lib/dependency-track/db"
356 else if cfg.database.type == "postgresql" then
357 "jdbc:postgresql:${cfg.database.databaseName}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
358 else
359 null;
360
361 defaultText = lib.literalExpression ''
362 if config.services.dependency-track.database.type == "h2" then "jdbc:h2:/var/lib/dependency-track/db"
363 else if config.services.dependency-track.database.type == "postgresql" then "jdbc:postgresql:''${config.services.dependency-track.database.name}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
364 else null
365 '';
366 description = "Specifies the JDBC URL to use when connecting to the database.";
367 };
368 "alpine.database.driver" = lib.mkOption {
369 type = lib.types.enum [
370 "org.h2.Driver"
371 "org.postgresql.Driver"
372 "com.microsoft.sqlserver.jdbc.SQLServerDriver"
373 "com.mysql.cj.jdbc.Driver"
374 ];
375 default =
376 if cfg.database.type == "h2" then
377 "org.h2.Driver"
378 else if cfg.database.type == "postgresql" then
379 "org.postgresql.Driver"
380 else
381 null;
382 defaultText = lib.literalExpression ''
383 if config.services.dependency-track.database.type == "h2" then "org.h2.Driver"
384 else if config.services.dependency-track.database.type == "postgresql" then "org.postgresql.Driver"
385 else null;
386 '';
387 description = "Specifies the JDBC driver class to use.";
388 };
389 "alpine.database.username" = lib.mkOption {
390 type = lib.types.str;
391 default = if cfg.database.createLocally then "dependency-track" else cfg.database.username;
392 defaultText = lib.literalExpression ''
393 if config.services.dependency-track.database.createLocally then "dependency-track"
394 else config.services.dependency-track.database.username
395 '';
396 description = "Specifies the username to use when authenticating to the database.";
397 };
398 "alpine.ldap.enabled" = lib.mkOption {
399 type = lib.types.bool;
400 default = false;
401 description = ''
402 Defines if LDAP will be used for user authentication. If enabled,
403 alpine.ldap.* properties should be set accordingly.
404 '';
405 };
406 "alpine.oidc.enabled" = lib.mkOption {
407 type = lib.types.bool;
408 default = cfg.oidc.enable;
409 defaultText = lib.literalExpression "config.services.dependency-track.oidc.enable";
410 description = ''
411 Defines if OpenID Connect will be used for user authentication.
412 If enabled, alpine.oidc.* properties should be set accordingly.
413 '';
414 };
415 "alpine.oidc.client.id" = lib.mkOption {
416 type = lib.types.str;
417 default = cfg.oidc.clientId;
418 defaultText = lib.literalExpression "config.services.dependency-track.oidc.clientId";
419 description = ''
420 Defines the client ID to be used for OpenID Connect.
421 The client ID should be the same as the one configured for the frontend,
422 and will only be used to validate ID tokens.
423 '';
424 };
425 "alpine.oidc.issuer" = lib.mkOption {
426 type = lib.types.str;
427 default = cfg.oidc.issuer;
428 defaultText = lib.literalExpression "config.services.dependency-track.oidc.issuer";
429 description = ''
430 Defines the issuer URL to be used for OpenID Connect.
431 This issuer MUST support provider configuration via the /.well-known/openid-configuration endpoint.
432 See also:
433 - <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
434 - <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig>
435 '';
436 };
437 "alpine.oidc.username.claim" = lib.mkOption {
438 type = lib.types.str;
439 default = cfg.oidc.usernameClaim;
440 defaultText = lib.literalExpression "config.services.dependency-track.oidc.usernameClaim";
441 description = ''
442 Defines the name of the claim that contains the username in the provider's userinfo endpoint.
443 Common claims are "name", "username", "preferred_username" or "nickname".
444 See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
445 '';
446 };
447 "alpine.oidc.user.provisioning" = lib.mkOption {
448 type = lib.types.bool;
449 default = cfg.oidc.userProvisioning;
450 defaultText = lib.literalExpression "config.services.dependency-track.oidc.userProvisioning";
451 description = ''
452 Specifies if mapped OpenID Connect accounts are automatically created upon successful
453 authentication. When a user logs in with a valid access token but an account has
454 not been previously provisioned, an authentication failure will be returned.
455 This allows admins to control specifically which OpenID Connect users can access the
456 system and which users cannot. When this value is set to true, a local OpenID Connect
457 user will be created and mapped to the OpenID Connect account automatically. This
458 automatic provisioning only affects authentication, not authorization.
459 '';
460 };
461 "alpine.oidc.team.synchronization" = lib.mkOption {
462 type = lib.types.bool;
463 default = cfg.oidc.teamSynchronization;
464 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teamSynchronization";
465 description = ''
466 This option will ensure that team memberships for OpenID Connect users are dynamic and
467 synchronized with membership of OpenID Connect groups or assigned roles. When a team is
468 mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
469 assigned to the team if they are a member of the group the team is mapped to. If the user
470 is later removed from the OpenID Connect group, they will also be removed from the team. This
471 option provides the ability to dynamically control user permissions via the identity provider.
472 Note that team synchronization is only performed during user provisioning and after successful
473 authentication.
474 '';
475 };
476 "alpine.oidc.teams.claim" = lib.mkOption {
477 type = lib.types.str;
478 default = cfg.oidc.teams.claim;
479 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.claim";
480 description = ''
481 Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
482 The claim must be an array of strings. Most public identity providers do not support group or role management.
483 When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
484 will most likely need to be configured.
485 '';
486 };
487 "alpine.oidc.teams.default" = lib.mkOption {
488 type = lib.types.nullOr lib.types.commas;
489 default = cfg.oidc.teams.default;
490 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.default";
491 description = ''
492 Defines one or more team names that auto-provisioned OIDC users shall be added to.
493 Multiple team names may be provided as comma-separated list.
494
495 Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
496 or {option}`services.dependency-track.oidc.teamSynchronization`=true.
497 '';
498 };
499 };
500 };
501 default = { };
502 description = "See <https://docs.dependencytrack.org/getting-started/configuration/#default-configuration> for possible options";
503 };
504 };
505
506 config = lib.mkIf cfg.enable {
507 services.nginx = lib.mkIf cfg.nginx.enable {
508 enable = true;
509 recommendedGzipSettings = lib.mkDefault true;
510 recommendedOptimisation = lib.mkDefault true;
511 recommendedProxySettings = lib.mkDefault true;
512 recommendedTlsSettings = lib.mkDefault true;
513 upstreams.dependency-track.servers."localhost:${toString cfg.port}" = { };
514 virtualHosts.${cfg.nginx.domain} = {
515 locations = {
516 "/" = {
517 alias = "${cfg.package.frontend}/dist/";
518 index = "index.html";
519 tryFiles = "$uri $uri/ /index.html";
520 extraConfig = ''
521 location ~ (index\.html)$ {
522 add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
523 add_header Pragma "no-cache";
524 add_header Expires 0;
525 }
526 '';
527 };
528 "/api".proxyPass = "http://dependency-track";
529 "= /static/config.json" = {
530 alias = frontendConfigFile;
531 extraConfig = ''
532 add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
533 add_header Pragma "no-cache";
534 add_header Expires 0;
535 '';
536 };
537 };
538 };
539 };
540
541 systemd.services.dependency-track-postgresql-init = lib.mkIf cfg.database.createLocally {
542 after = [ "postgresql.target" ];
543 before = [ "dependency-track.service" ];
544 bindsTo = [ "postgresql.target" ];
545 path = [ config.services.postgresql.package ];
546 serviceConfig = {
547 Type = "oneshot";
548 RemainAfterExit = true;
549 User = "postgres";
550 Group = "postgres";
551 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
552 PrivateTmp = true;
553 };
554 script = ''
555 set -eou pipefail
556 shopt -s inherit_errexit
557
558 # Read the password from the credentials directory and
559 # escape any single quotes by adding additional single
560 # quotes after them, following the rules laid out here:
561 # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
562 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
563 db_password="''${db_password//\'/\'\'}"
564
565 echo "CREATE ROLE \"dependency-track\" WITH LOGIN PASSWORD '$db_password' CREATEDB" > /tmp/create_role.sql
566 psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='dependency-track'" | grep -q 1 || psql -tA --file="/tmp/create_role.sql"
567 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'dependency-track'" | grep -q 1 || psql -tAc 'CREATE DATABASE "dependency-track" OWNER "dependency-track"'
568 '';
569 };
570
571 services.postgresql.enable = lib.mkIf cfg.database.createLocally (lib.mkDefault true);
572
573 systemd.services."dependency-track" =
574 let
575 databaseServices =
576 if cfg.database.createLocally then
577 [
578 "dependency-track-postgresql-init.service"
579 "postgresql.target"
580 ]
581 else
582 [ ];
583 in
584 {
585 description = "Dependency Track";
586 wantedBy = [ "multi-user.target" ];
587 requires = databaseServices;
588 after = databaseServices;
589 # provide settings via env vars to allow overriding default settings.
590 environment = {
591 HOME = "%S/dependency-track";
592 }
593 // renderSettings cfg.settings;
594 serviceConfig = {
595 User = "dependency-track";
596 Group = "dependency-track";
597 DynamicUser = true;
598 StateDirectory = "dependency-track";
599 LoadCredential = [
600 "db_password:${cfg.database.passwordFile}"
601 ]
602 ++
603 lib.optional cfg.settings."alpine.ldap.enabled"
604 "ldap_bind_password:${cfg.ldap.bindPasswordFile}";
605 };
606 script = ''
607 set -eou pipefail
608 shopt -s inherit_errexit
609
610 export ALPINE_DATABASE_PASSWORD_FILE="$CREDENTIALS_DIRECTORY/db_password"
611 ${lib.optionalString cfg.settings."alpine.ldap.enabled" ''
612 export ALPINE_LDAP_BIND_PASSWORD="$(<"$CREDENTIALS_DIRECTORY/ldap_bind_password")"
613 ''}
614
615 exec ${lib.getExe pkgs.jre_headless} ${
616 lib.escapeShellArgs (
617 cfg.javaArgs
618 ++ [
619 "-DdependencyTrack.logging.level=${cfg.logLevel}"
620 "-jar"
621 "${cfg.package}/share/dependency-track/dependency-track.jar"
622 "-port"
623 "${toString cfg.port}"
624 ]
625 )
626 }
627 '';
628 };
629 };
630
631 meta = {
632 maintainers = lib.teams.cyberus.members;
633 };
634}