1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.taskserver;
9
10 taskd = "${pkgs.taskserver}/bin/taskd";
11
12 mkManualPkiOption =
13 desc:
14 lib.mkOption {
15 type = lib.types.nullOr lib.types.path;
16 default = null;
17 description = ''
18 ${desc}
19
20 ::: {.note}
21 Setting this option will prevent automatic CA creation and handling.
22 :::
23 '';
24 };
25
26 manualPkiOptions = {
27 ca.cert = mkManualPkiOption ''
28 Fully qualified path to the CA certificate.
29 '';
30
31 server.cert = mkManualPkiOption ''
32 Fully qualified path to the server certificate.
33 '';
34
35 server.crl = mkManualPkiOption ''
36 Fully qualified path to the server certificate revocation list.
37 '';
38
39 server.key = mkManualPkiOption ''
40 Fully qualified path to the server key.
41 '';
42 };
43
44 mkAutoDesc = preamble: ''
45 ${preamble}
46
47 ::: {.note}
48 This option is for the automatically handled CA and will be ignored if any
49 of the {option}`services.taskserver.pki.manual.*` options are set.
50 :::
51 '';
52
53 mkExpireOption =
54 desc:
55 lib.mkOption {
56 type = lib.types.nullOr lib.types.int;
57 default = null;
58 example = 365;
59 apply = val: if val == null then -1 else val;
60 description = mkAutoDesc ''
61 The expiration time of ${desc} in days or `null` for no
62 expiration time.
63 '';
64 };
65
66 autoPkiOptions = {
67 bits = lib.mkOption {
68 type = lib.types.int;
69 default = 4096;
70 example = 2048;
71 description = mkAutoDesc "The bit size for generated keys.";
72 };
73
74 expiration = {
75 ca = mkExpireOption "the CA certificate";
76 server = mkExpireOption "the server certificate";
77 client = mkExpireOption "client certificates";
78 crl = mkExpireOption "the certificate revocation list (CRL)";
79 };
80 };
81
82 needToCreateCA =
83 let
84 notFound =
85 path:
86 let
87 dotted = lib.concatStringsSep "." path;
88 in
89 throw "Can't find option definitions for path `${dotted}'.";
90 findPkiDefinitions =
91 path: attrs:
92 let
93 mkSublist =
94 key: val:
95 let
96 newPath = path ++ lib.singleton key;
97 in
98 if lib.isOption val then
99 lib.attrByPath newPath (notFound newPath) cfg.pki.manual
100 else
101 findPkiDefinitions newPath val;
102 in
103 lib.flatten (lib.mapAttrsToList mkSublist attrs);
104 in
105 lib.all (x: x == null) (findPkiDefinitions [ ] manualPkiOptions);
106
107 orgOptions =
108 { ... }:
109 {
110 options.users = lib.mkOption {
111 type = lib.types.uniq (lib.types.listOf lib.types.str);
112 default = [ ];
113 example = [
114 "alice"
115 "bob"
116 ];
117 description = ''
118 A list of user names that belong to the organization.
119 '';
120 };
121
122 options.groups = lib.mkOption {
123 type = lib.types.listOf lib.types.str;
124 default = [ ];
125 example = [
126 "workers"
127 "slackers"
128 ];
129 description = ''
130 A list of group names that belong to the organization.
131 '';
132 };
133 };
134
135 certtool = "${pkgs.gnutls.bin}/bin/certtool";
136
137 nixos-taskserver =
138 with pkgs.python3.pkgs;
139 buildPythonApplication {
140 name = "nixos-taskserver";
141
142 src = pkgs.runCommand "nixos-taskserver-src" { preferLocalBuild = true; } ''
143 mkdir -p "$out"
144 cat "${
145 pkgs.replaceVars ./helper-tool.py {
146 inherit taskd certtool;
147 inherit (cfg)
148 dataDir
149 user
150 group
151 fqdn
152 ;
153 certBits = cfg.pki.auto.bits;
154 clientExpiration = cfg.pki.auto.expiration.client;
155 crlExpiration = cfg.pki.auto.expiration.crl;
156 isAutoConfig = if needToCreateCA then "True" else "False";
157 }
158 }" > "$out/main.py"
159 cat > "$out/setup.py" <<EOF
160 from setuptools import setup
161 setup(name="nixos-taskserver",
162 py_modules=["main"],
163 install_requires=["Click"],
164 entry_points="[console_scripts]\\nnixos-taskserver=main:cli")
165 EOF
166 '';
167
168 propagatedBuildInputs = [ click ];
169 };
170
171in
172{
173 options = {
174 services.taskserver = {
175 enable = lib.mkOption {
176 type = lib.types.bool;
177 default = false;
178 description =
179 let
180 url = "https://nixos.org/manual/nixos/stable/index.html#module-services-taskserver";
181 in
182 ''
183 Whether to enable the Taskwarrior 2 server.
184
185 More instructions about NixOS in conjunction with Taskserver can be
186 found [in the NixOS manual](${url}).
187 '';
188 };
189
190 user = lib.mkOption {
191 type = lib.types.str;
192 default = "taskd";
193 description = "User for Taskserver.";
194 };
195
196 group = lib.mkOption {
197 type = lib.types.str;
198 default = "taskd";
199 description = "Group for Taskserver.";
200 };
201
202 dataDir = lib.mkOption {
203 type = lib.types.path;
204 default = "/var/lib/taskserver";
205 description = "Data directory for Taskserver.";
206 };
207
208 ciphers = lib.mkOption {
209 type = lib.types.nullOr (lib.types.separatedString ":");
210 default = null;
211 example = "NORMAL:-VERS-SSL3.0";
212 description =
213 let
214 url = "https://gnutls.org/manual/html_node/Priority-Strings.html";
215 in
216 ''
217 List of GnuTLS ciphers to use. See the GnuTLS documentation about
218 priority strings at <${url}> for full details.
219 '';
220 };
221
222 organisations = lib.mkOption {
223 type = lib.types.attrsOf (lib.types.submodule orgOptions);
224 default = { };
225 example.myShinyOrganisation.users = [
226 "alice"
227 "bob"
228 ];
229 example.myShinyOrganisation.groups = [
230 "staff"
231 "outsiders"
232 ];
233 example.yetAnotherOrganisation.users = [
234 "foo"
235 "bar"
236 ];
237 description = ''
238 An attribute set where the keys name the organisation and the values
239 are a set of lists of {option}`users` and
240 {option}`groups`.
241 '';
242 };
243
244 confirmation = lib.mkOption {
245 type = lib.types.bool;
246 default = true;
247 description = ''
248 Determines whether certain commands are confirmed.
249 '';
250 };
251
252 debug = lib.mkOption {
253 type = lib.types.bool;
254 default = false;
255 description = ''
256 Logs debugging information.
257 '';
258 };
259
260 extensions = lib.mkOption {
261 type = lib.types.nullOr lib.types.path;
262 default = null;
263 description = ''
264 Fully qualified path of the Taskserver extension scripts.
265 Currently there are none.
266 '';
267 };
268
269 ipLog = lib.mkOption {
270 type = lib.types.bool;
271 default = false;
272 description = ''
273 Logs the IP addresses of incoming requests.
274 '';
275 };
276
277 queueSize = lib.mkOption {
278 type = lib.types.int;
279 default = 10;
280 description = ''
281 Size of the connection backlog, see {manpage}`listen(2)`.
282 '';
283 };
284
285 requestLimit = lib.mkOption {
286 type = lib.types.int;
287 default = 1048576;
288 description = ''
289 Size limit of incoming requests, in bytes.
290 '';
291 };
292
293 allowedClientIDs = lib.mkOption {
294 type = with lib.types; either str (listOf str);
295 default = [ ];
296 example = [ "[Tt]ask [2-9]+" ];
297 description = ''
298 A list of regular expressions that are matched against the reported
299 client id (such as `task 2.3.0`).
300
301 The values `all` or `none` have
302 special meaning. Overridden by any entry in the option
303 {option}`services.taskserver.disallowedClientIDs`.
304 '';
305 };
306
307 disallowedClientIDs = lib.mkOption {
308 type = with lib.types; either str (listOf str);
309 default = [ ];
310 example = [ "[Tt]ask [2-9]+" ];
311 description = ''
312 A list of regular expressions that are matched against the reported
313 client id (such as `task 2.3.0`).
314
315 The values `all` or `none` have
316 special meaning. Any entry here overrides those in
317 {option}`services.taskserver.allowedClientIDs`.
318 '';
319 };
320
321 listenHost = lib.mkOption {
322 type = lib.types.str;
323 default = "localhost";
324 example = "::";
325 description = ''
326 The address (IPv4, IPv6 or DNS) to listen on.
327 '';
328 };
329
330 listenPort = lib.mkOption {
331 type = lib.types.int;
332 default = 53589;
333 description = ''
334 Port number of the Taskserver.
335 '';
336 };
337
338 openFirewall = lib.mkOption {
339 type = lib.types.bool;
340 default = false;
341 description = ''
342 Whether to open the firewall for the specified Taskserver port.
343 '';
344 };
345
346 fqdn = lib.mkOption {
347 type = lib.types.str;
348 default = "localhost";
349 description = ''
350 The fully qualified domain name of this server, which is also used
351 as the common name in the certificates.
352 '';
353 };
354
355 trust = lib.mkOption {
356 type = lib.types.enum [
357 "allow all"
358 "strict"
359 ];
360 default = "strict";
361 description = ''
362 Determines how client certificates are validated.
363
364 The value `allow all` performs no client
365 certificate validation. This is not recommended. The value
366 `strict` causes the client certificate to be
367 validated against a CA.
368 '';
369 };
370
371 pki.manual = manualPkiOptions;
372 pki.auto = autoPkiOptions;
373
374 config = lib.mkOption {
375 type = lib.types.attrs;
376 example.client.cert = "/tmp/debugging.cert";
377 description = ''
378 Configuration options to pass to Taskserver.
379
380 The options here are the same as described in
381 {manpage}`taskdrc(5)` from the `taskwarrior2` package, but with one difference:
382
383 The `server` option is
384 `server.listen` here, because the
385 `server` option would collide with other options
386 like `server.cert` and we would run in a type error
387 (attribute set versus string).
388
389 Nix types like integers or booleans are automatically converted to
390 the right values Taskserver would expect.
391 '';
392 apply =
393 let
394 mkKey =
395 path:
396 if
397 path == [
398 "server"
399 "listen"
400 ]
401 then
402 "server"
403 else
404 lib.concatStringsSep "." path;
405 recurse =
406 path: attrs:
407 let
408 mapper =
409 name: val:
410 let
411 newPath = path ++ [ name ];
412 scalar =
413 if val == true then
414 "true"
415 else if val == false then
416 "false"
417 else
418 toString val;
419 in
420 if lib.isAttrs val then recurse newPath val else [ "${mkKey newPath}=${scalar}" ];
421 in
422 lib.concatLists (lib.mapAttrsToList mapper attrs);
423 in
424 recurse [ ];
425 };
426 };
427 };
428
429 imports = [
430 (lib.mkRemovedOptionModule [ "services" "taskserver" "extraConfig" ] ''
431 This option was removed in favor of `services.taskserver.config` with
432 different semantics (it's now a list of attributes instead of lines).
433
434 Please look up the documentation of `services.taskserver.config' to get
435 more information about the new way to pass additional configuration
436 options.
437 '')
438 ];
439
440 config = lib.mkMerge [
441 (lib.mkIf cfg.enable {
442 environment.systemPackages = [ nixos-taskserver ];
443
444 users.users = lib.optionalAttrs (cfg.user == "taskd") {
445 taskd = {
446 uid = config.ids.uids.taskd;
447 description = "Taskserver user";
448 group = cfg.group;
449 };
450 };
451
452 users.groups = lib.optionalAttrs (cfg.group == "taskd") {
453 taskd.gid = config.ids.gids.taskd;
454 };
455
456 services.taskserver.config = {
457 # systemd related
458 daemon = false;
459 log = "-";
460
461 # logging
462 debug = cfg.debug;
463 ip.log = cfg.ipLog;
464
465 # general
466 ciphers = cfg.ciphers;
467 confirmation = cfg.confirmation;
468 extensions = cfg.extensions;
469 queue.size = cfg.queueSize;
470 request.limit = cfg.requestLimit;
471
472 # client
473 client.allow = cfg.allowedClientIDs;
474 client.deny = cfg.disallowedClientIDs;
475
476 # server
477 trust = cfg.trust;
478 server =
479 {
480 listen = "${cfg.listenHost}:${toString cfg.listenPort}";
481 }
482 // (
483 if needToCreateCA then
484 {
485 cert = "${cfg.dataDir}/keys/server.cert";
486 key = "${cfg.dataDir}/keys/server.key";
487 crl = "${cfg.dataDir}/keys/server.crl";
488 }
489 else
490 {
491 cert = "${cfg.pki.manual.server.cert}";
492 key = "${cfg.pki.manual.server.key}";
493 ${lib.mapNullable (_: "crl") cfg.pki.manual.server.crl} = "${cfg.pki.manual.server.crl}";
494 }
495 );
496
497 ca.cert = if needToCreateCA then "${cfg.dataDir}/keys/ca.cert" else "${cfg.pki.manual.ca.cert}";
498 };
499
500 systemd.tmpfiles.rules = [
501 "d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group}"
502 "z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group}"
503 ];
504
505 systemd.services.taskserver-init = {
506 wantedBy = [ "taskserver.service" ];
507 before = [ "taskserver.service" ];
508 description = "Initialize Taskserver Data Directory";
509
510 script = ''
511 ${taskd} init
512 touch "${cfg.dataDir}/.is_initialized"
513 '';
514
515 environment.TASKDDATA = cfg.dataDir;
516
517 unitConfig.ConditionPathExists = "!${cfg.dataDir}/.is_initialized";
518
519 serviceConfig.Type = "oneshot";
520 serviceConfig.User = cfg.user;
521 serviceConfig.Group = cfg.group;
522 serviceConfig.PermissionsStartOnly = true;
523 serviceConfig.PrivateNetwork = true;
524 serviceConfig.PrivateDevices = true;
525 serviceConfig.PrivateTmp = true;
526 };
527
528 systemd.services.taskserver = {
529 description = "Taskwarrior 2 Server";
530
531 wantedBy = [ "multi-user.target" ];
532 after = [ "network.target" ];
533
534 environment.TASKDDATA = cfg.dataDir;
535
536 preStart =
537 let
538 jsonOrgs = builtins.toJSON cfg.organisations;
539 jsonFile = pkgs.writeText "orgs.json" jsonOrgs;
540 helperTool = "${nixos-taskserver}/bin/nixos-taskserver";
541 in
542 "${helperTool} process-json '${jsonFile}'";
543
544 serviceConfig = {
545 ExecStart =
546 let
547 mkCfgFlag = flag: lib.escapeShellArg "--${flag}";
548 cfgFlags = lib.concatMapStringsSep " " mkCfgFlag cfg.config;
549 in
550 "@${taskd} taskd server ${cfgFlags}";
551 ExecReload = "${pkgs.coreutils}/bin/kill -USR1 $MAINPID";
552 Restart = "on-failure";
553 PermissionsStartOnly = true;
554 PrivateTmp = true;
555 PrivateDevices = true;
556 User = cfg.user;
557 Group = cfg.group;
558 };
559 };
560 })
561 (lib.mkIf (cfg.enable && needToCreateCA) {
562 systemd.services.taskserver-ca = {
563 wantedBy = [ "taskserver.service" ];
564 after = [ "taskserver-init.service" ];
565 before = [ "taskserver.service" ];
566 description = "Initialize CA for TaskServer";
567 serviceConfig.Type = "oneshot";
568 serviceConfig.UMask = "0077";
569 serviceConfig.PrivateNetwork = true;
570 serviceConfig.PrivateTmp = true;
571
572 script = ''
573 silent_certtool() {
574 if ! output="$("${certtool}" "$@" 2>&1)"; then
575 echo "GNUTLS certtool invocation failed with output:" >&2
576 echo "$output" >&2
577 fi
578 }
579
580 mkdir -m 0700 -p "${cfg.dataDir}/keys"
581 chown root:root "${cfg.dataDir}/keys"
582
583 if [ ! -e "${cfg.dataDir}/keys/ca.key" ]; then
584 silent_certtool -p \
585 --bits ${toString cfg.pki.auto.bits} \
586 --outfile "${cfg.dataDir}/keys/ca.key"
587 silent_certtool -s \
588 --template "${pkgs.writeText "taskserver-ca.template" ''
589 cn = ${cfg.fqdn}
590 expiration_days = ${toString cfg.pki.auto.expiration.ca}
591 cert_signing_key
592 ca
593 ''}" \
594 --load-privkey "${cfg.dataDir}/keys/ca.key" \
595 --outfile "${cfg.dataDir}/keys/ca.cert"
596
597 chgrp "${cfg.group}" "${cfg.dataDir}/keys/ca.cert"
598 chmod g+r "${cfg.dataDir}/keys/ca.cert"
599 fi
600
601 if [ ! -e "${cfg.dataDir}/keys/server.key" ]; then
602 silent_certtool -p \
603 --bits ${toString cfg.pki.auto.bits} \
604 --outfile "${cfg.dataDir}/keys/server.key"
605
606 silent_certtool -c \
607 --template "${pkgs.writeText "taskserver-cert.template" ''
608 cn = ${cfg.fqdn}
609 expiration_days = ${toString cfg.pki.auto.expiration.server}
610 tls_www_server
611 encryption_key
612 signing_key
613 ''}" \
614 --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \
615 --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \
616 --load-privkey "${cfg.dataDir}/keys/server.key" \
617 --outfile "${cfg.dataDir}/keys/server.cert"
618
619 chgrp "${cfg.group}" \
620 "${cfg.dataDir}/keys/server.key" \
621 "${cfg.dataDir}/keys/server.cert"
622
623 chmod g+r \
624 "${cfg.dataDir}/keys/server.key" \
625 "${cfg.dataDir}/keys/server.cert"
626 fi
627
628 if [ ! -e "${cfg.dataDir}/keys/server.crl" ]; then
629 silent_certtool --generate-crl \
630 --template "${pkgs.writeText "taskserver-crl.template" ''
631 expiration_days = ${toString cfg.pki.auto.expiration.crl}
632 ''}" \
633 --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \
634 --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \
635 --outfile "${cfg.dataDir}/keys/server.crl"
636
637 chgrp "${cfg.group}" "${cfg.dataDir}/keys/server.crl"
638 chmod g+r "${cfg.dataDir}/keys/server.crl"
639 fi
640
641 chmod go+x "${cfg.dataDir}/keys"
642 '';
643 };
644 })
645 (lib.mkIf (cfg.enable && cfg.openFirewall) {
646 networking.firewall.allowedTCPPorts = [ cfg.listenPort ];
647 })
648 ];
649
650 meta.doc = ./default.md;
651}