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 format = "setuptools";
141 name = "nixos-taskserver";
142
143 src = pkgs.runCommand "nixos-taskserver-src" { preferLocalBuild = true; } ''
144 mkdir -p "$out"
145 cat "${
146 pkgs.replaceVars ./helper-tool.py {
147 inherit taskd certtool;
148 inherit (cfg)
149 dataDir
150 user
151 group
152 fqdn
153 ;
154 certBits = cfg.pki.auto.bits;
155 clientExpiration = cfg.pki.auto.expiration.client;
156 crlExpiration = cfg.pki.auto.expiration.crl;
157 isAutoConfig = if needToCreateCA then "True" else "False";
158 }
159 }" > "$out/main.py"
160 cat > "$out/setup.py" <<EOF
161 from setuptools import setup
162 setup(name="nixos-taskserver",
163 py_modules=["main"],
164 install_requires=["Click"],
165 entry_points="[console_scripts]\\nnixos-taskserver=main:cli")
166 EOF
167 '';
168
169 propagatedBuildInputs = [ click ];
170 };
171
172in
173{
174 options = {
175 services.taskserver = {
176 enable = lib.mkOption {
177 type = lib.types.bool;
178 default = false;
179 description =
180 let
181 url = "https://nixos.org/manual/nixos/stable/index.html#module-services-taskserver";
182 in
183 ''
184 Whether to enable the Taskwarrior 2 server.
185
186 More instructions about NixOS in conjunction with Taskserver can be
187 found [in the NixOS manual](${url}).
188 '';
189 };
190
191 user = lib.mkOption {
192 type = lib.types.str;
193 default = "taskd";
194 description = "User for Taskserver.";
195 };
196
197 group = lib.mkOption {
198 type = lib.types.str;
199 default = "taskd";
200 description = "Group for Taskserver.";
201 };
202
203 dataDir = lib.mkOption {
204 type = lib.types.path;
205 default = "/var/lib/taskserver";
206 description = "Data directory for Taskserver.";
207 };
208
209 ciphers = lib.mkOption {
210 type = lib.types.nullOr (lib.types.separatedString ":");
211 default = null;
212 example = "NORMAL:-VERS-SSL3.0";
213 description =
214 let
215 url = "https://gnutls.org/manual/html_node/Priority-Strings.html";
216 in
217 ''
218 List of GnuTLS ciphers to use. See the GnuTLS documentation about
219 priority strings at <${url}> for full details.
220 '';
221 };
222
223 organisations = lib.mkOption {
224 type = lib.types.attrsOf (lib.types.submodule orgOptions);
225 default = { };
226 example.myShinyOrganisation.users = [
227 "alice"
228 "bob"
229 ];
230 example.myShinyOrganisation.groups = [
231 "staff"
232 "outsiders"
233 ];
234 example.yetAnotherOrganisation.users = [
235 "foo"
236 "bar"
237 ];
238 description = ''
239 An attribute set where the keys name the organisation and the values
240 are a set of lists of {option}`users` and
241 {option}`groups`.
242 '';
243 };
244
245 confirmation = lib.mkOption {
246 type = lib.types.bool;
247 default = true;
248 description = ''
249 Determines whether certain commands are confirmed.
250 '';
251 };
252
253 debug = lib.mkOption {
254 type = lib.types.bool;
255 default = false;
256 description = ''
257 Logs debugging information.
258 '';
259 };
260
261 extensions = lib.mkOption {
262 type = lib.types.nullOr lib.types.path;
263 default = null;
264 description = ''
265 Fully qualified path of the Taskserver extension scripts.
266 Currently there are none.
267 '';
268 };
269
270 ipLog = lib.mkOption {
271 type = lib.types.bool;
272 default = false;
273 description = ''
274 Logs the IP addresses of incoming requests.
275 '';
276 };
277
278 queueSize = lib.mkOption {
279 type = lib.types.int;
280 default = 10;
281 description = ''
282 Size of the connection backlog, see {manpage}`listen(2)`.
283 '';
284 };
285
286 requestLimit = lib.mkOption {
287 type = lib.types.int;
288 default = 1048576;
289 description = ''
290 Size limit of incoming requests, in bytes.
291 '';
292 };
293
294 allowedClientIDs = lib.mkOption {
295 type = with lib.types; either str (listOf str);
296 default = [ ];
297 example = [ "[Tt]ask [2-9]+" ];
298 description = ''
299 A list of regular expressions that are matched against the reported
300 client id (such as `task 2.3.0`).
301
302 The values `all` or `none` have
303 special meaning. Overridden by any entry in the option
304 {option}`services.taskserver.disallowedClientIDs`.
305 '';
306 };
307
308 disallowedClientIDs = lib.mkOption {
309 type = with lib.types; either str (listOf str);
310 default = [ ];
311 example = [ "[Tt]ask [2-9]+" ];
312 description = ''
313 A list of regular expressions that are matched against the reported
314 client id (such as `task 2.3.0`).
315
316 The values `all` or `none` have
317 special meaning. Any entry here overrides those in
318 {option}`services.taskserver.allowedClientIDs`.
319 '';
320 };
321
322 listenHost = lib.mkOption {
323 type = lib.types.str;
324 default = "localhost";
325 example = "::";
326 description = ''
327 The address (IPv4, IPv6 or DNS) to listen on.
328 '';
329 };
330
331 listenPort = lib.mkOption {
332 type = lib.types.port;
333 default = 53589;
334 description = ''
335 Port number of the Taskserver.
336 '';
337 };
338
339 openFirewall = lib.mkOption {
340 type = lib.types.bool;
341 default = false;
342 description = ''
343 Whether to open the firewall for the specified Taskserver port.
344 '';
345 };
346
347 fqdn = lib.mkOption {
348 type = lib.types.str;
349 default = "localhost";
350 description = ''
351 The fully qualified domain name of this server, which is also used
352 as the common name in the certificates.
353 '';
354 };
355
356 trust = lib.mkOption {
357 type = lib.types.enum [
358 "allow all"
359 "strict"
360 ];
361 default = "strict";
362 description = ''
363 Determines how client certificates are validated.
364
365 The value `allow all` performs no client
366 certificate validation. This is not recommended. The value
367 `strict` causes the client certificate to be
368 validated against a CA.
369 '';
370 };
371
372 pki.manual = manualPkiOptions;
373 pki.auto = autoPkiOptions;
374
375 config = lib.mkOption {
376 type = lib.types.attrs;
377 example.client.cert = "/tmp/debugging.cert";
378 description = ''
379 Configuration options to pass to Taskserver.
380
381 The options here are the same as described in
382 {manpage}`taskdrc(5)` from the `taskwarrior2` package, but with one difference:
383
384 The `server` option is
385 `server.listen` here, because the
386 `server` option would collide with other options
387 like `server.cert` and we would run in a type error
388 (attribute set versus string).
389
390 Nix types like integers or booleans are automatically converted to
391 the right values Taskserver would expect.
392 '';
393 apply =
394 let
395 mkKey =
396 path:
397 if
398 path == [
399 "server"
400 "listen"
401 ]
402 then
403 "server"
404 else
405 lib.concatStringsSep "." path;
406 recurse =
407 path: attrs:
408 let
409 mapper =
410 name: val:
411 let
412 newPath = path ++ [ name ];
413 scalar =
414 if val == true then
415 "true"
416 else if val == false then
417 "false"
418 else
419 toString val;
420 in
421 if lib.isAttrs val then recurse newPath val else [ "${mkKey newPath}=${scalar}" ];
422 in
423 lib.concatLists (lib.mapAttrsToList mapper attrs);
424 in
425 recurse [ ];
426 };
427 };
428 };
429
430 imports = [
431 (lib.mkRemovedOptionModule [ "services" "taskserver" "extraConfig" ] ''
432 This option was removed in favor of `services.taskserver.config` with
433 different semantics (it's now a list of attributes instead of lines).
434
435 Please look up the documentation of `services.taskserver.config' to get
436 more information about the new way to pass additional configuration
437 options.
438 '')
439 ];
440
441 config = lib.mkMerge [
442 (lib.mkIf cfg.enable {
443 environment.systemPackages = [ nixos-taskserver ];
444
445 users.users = lib.optionalAttrs (cfg.user == "taskd") {
446 taskd = {
447 uid = config.ids.uids.taskd;
448 description = "Taskserver user";
449 group = cfg.group;
450 };
451 };
452
453 users.groups = lib.optionalAttrs (cfg.group == "taskd") {
454 taskd.gid = config.ids.gids.taskd;
455 };
456
457 services.taskserver.config = {
458 # systemd related
459 daemon = false;
460 log = "-";
461
462 # logging
463 debug = cfg.debug;
464 ip.log = cfg.ipLog;
465
466 # general
467 ciphers = cfg.ciphers;
468 confirmation = cfg.confirmation;
469 extensions = cfg.extensions;
470 queue.size = cfg.queueSize;
471 request.limit = cfg.requestLimit;
472
473 # client
474 client.allow = cfg.allowedClientIDs;
475 client.deny = cfg.disallowedClientIDs;
476
477 # server
478 trust = cfg.trust;
479 server = {
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}