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