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