Merge pull request #14476 (taskserver)

This adds a Taskserver module along with documentation and a small
helper tool which eases managing a custom CA along with Taskserver
organisations, users and groups.

Taskserver is the server component of Taskwarrior, a TODO list
application for the command line.

The work has been started by @matthiasbeyer back in mid 2015 and I have
continued to work on it recently, so this merge contains commits from
both of us.

Thanks particularly to @nbp and @matthiasbeyer for reviewing and
suggesting improvements.

I've tested this with the new test (nixos/tests/taskserver.nix) this
branch adds and it fails because of the changes introduced by the
closure-size branch, so we need to do additional work on base of this.

aszlig 9ed9e268 0876c2f4

+1530
+1
nixos/doc/manual/configuration/configuration.xml
··· 27 27 <!-- FIXME: auto-include NixOS module docs --> 28 28 <xi:include href="postgresql.xml" /> 29 29 <xi:include href="gitlab.xml" /> 30 + <xi:include href="taskserver.xml" /> 30 31 <xi:include href="acme.xml" /> 31 32 <xi:include href="input-methods.xml" /> 32 33
+1
nixos/doc/manual/default.nix
··· 57 57 chmod -R u+w . 58 58 cp ${../../modules/services/databases/postgresql.xml} configuration/postgresql.xml 59 59 cp ${../../modules/services/misc/gitlab.xml} configuration/gitlab.xml 60 + cp ${../../modules/services/misc/taskserver/doc.xml} configuration/taskserver.xml 60 61 cp ${../../modules/security/acme.xml} configuration/acme.xml 61 62 cp ${../../modules/i18n/input-method/default.xml} configuration/input-methods.xml 62 63 ln -s ${optionsDocBook} options-db.xml
+2
nixos/modules/misc/ids.nix
··· 261 261 syncthing = 237; 262 262 mfi = 238; 263 263 caddy = 239; 264 + taskd = 240; 264 265 265 266 # When adding a uid, make sure it doesn't match an existing gid. And don't use uids above 399! 266 267 ··· 493 494 syncthing = 237; 494 495 #mfi = 238; # unused 495 496 caddy = 239; 497 + taskd = 240; 496 498 497 499 # When adding a gid, make sure it doesn't match an existing 498 500 # uid. Users and groups with the same name should have equal
+1
nixos/modules/module-list.nix
··· 250 250 ./services/misc/sundtek.nix 251 251 ./services/misc/svnserve.nix 252 252 ./services/misc/synergy.nix 253 + ./services/misc/taskserver 253 254 ./services/misc/uhub.nix 254 255 ./services/misc/zookeeper.nix 255 256 ./services/monitoring/apcupsd.nix
+541
nixos/modules/services/misc/taskserver/default.nix
··· 1 + { config, lib, pkgs, ... }: 2 + 3 + with lib; 4 + 5 + let 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 + configFile = pkgs.writeText "taskdrc" ('' 98 + # systemd related 99 + daemon = false 100 + log = - 101 + 102 + # logging 103 + ${mkConfLine "debug" cfg.debug} 104 + ${mkConfLine "ip.log" cfg.ipLog} 105 + 106 + # general 107 + ${mkConfLine "ciphers" cfg.ciphers} 108 + ${mkConfLine "confirmation" cfg.confirmation} 109 + ${mkConfLine "extensions" cfg.extensions} 110 + ${mkConfLine "queue.size" cfg.queueSize} 111 + ${mkConfLine "request.limit" cfg.requestLimit} 112 + 113 + # client 114 + ${mkConfLine "client.allow" cfg.allowedClientIDs} 115 + ${mkConfLine "client.deny" cfg.disallowedClientIDs} 116 + 117 + # server 118 + server = ${cfg.listenHost}:${toString cfg.listenPort} 119 + ${mkConfLine "trust" cfg.trust} 120 + 121 + # PKI options 122 + ${if needToCreateCA then '' 123 + ca.cert = ${cfg.dataDir}/keys/ca.cert 124 + server.cert = ${cfg.dataDir}/keys/server.cert 125 + server.key = ${cfg.dataDir}/keys/server.key 126 + server.crl = ${cfg.dataDir}/keys/server.crl 127 + '' else '' 128 + ca.cert = ${cfg.pki.ca.cert} 129 + server.cert = ${cfg.pki.server.cert} 130 + server.key = ${cfg.pki.server.key} 131 + server.crl = ${cfg.pki.server.crl} 132 + ''} 133 + '' + cfg.extraConfig); 134 + 135 + orgOptions = { name, ... }: { 136 + options.users = mkOption { 137 + type = types.uniq (types.listOf types.str); 138 + default = []; 139 + example = [ "alice" "bob" ]; 140 + description = '' 141 + A list of user names that belong to the organization. 142 + ''; 143 + }; 144 + 145 + options.groups = mkOption { 146 + type = types.listOf types.str; 147 + default = []; 148 + example = [ "workers" "slackers" ]; 149 + description = '' 150 + A list of group names that belong to the organization. 151 + ''; 152 + }; 153 + }; 154 + 155 + mkShellStr = val: "'${replaceStrings ["'"] ["'\\''"] val}'"; 156 + 157 + certtool = "${pkgs.gnutls}/bin/certtool"; 158 + 159 + nixos-taskserver = pkgs.buildPythonPackage { 160 + name = "nixos-taskserver"; 161 + namePrefix = ""; 162 + 163 + src = pkgs.runCommand "nixos-taskserver-src" {} '' 164 + mkdir -p "$out" 165 + cat "${pkgs.substituteAll { 166 + src = ./helper-tool.py; 167 + inherit taskd certtool; 168 + inherit (cfg) dataDir user group fqdn; 169 + certBits = cfg.pki.auto.bits; 170 + clientExpiration = cfg.pki.auto.expiration.client; 171 + crlExpiration = cfg.pki.auto.expiration.crl; 172 + }}" > "$out/main.py" 173 + cat > "$out/setup.py" <<EOF 174 + from setuptools import setup 175 + setup(name="nixos-taskserver", 176 + py_modules=["main"], 177 + install_requires=["Click"], 178 + entry_points="[console_scripts]\\nnixos-taskserver=main:cli") 179 + EOF 180 + ''; 181 + 182 + propagatedBuildInputs = [ pkgs.pythonPackages.click ]; 183 + }; 184 + 185 + in { 186 + options = { 187 + services.taskserver = { 188 + enable = mkOption { 189 + type = types.bool; 190 + default = false; 191 + example = true; 192 + description = '' 193 + Whether to enable the Taskwarrior server. 194 + 195 + More instructions about NixOS in conjuction with Taskserver can be 196 + found in the NixOS manual at 197 + <olink targetdoc="manual" targetptr="module-taskserver"/>. 198 + ''; 199 + }; 200 + 201 + user = mkOption { 202 + type = types.str; 203 + default = "taskd"; 204 + description = "User for Taskserver."; 205 + }; 206 + 207 + group = mkOption { 208 + type = types.str; 209 + default = "taskd"; 210 + description = "Group for Taskserver."; 211 + }; 212 + 213 + dataDir = mkOption { 214 + type = types.path; 215 + default = "/var/lib/taskserver"; 216 + description = "Data directory for Taskserver."; 217 + }; 218 + 219 + ciphers = mkOption { 220 + type = types.nullOr (types.separatedString ":"); 221 + default = null; 222 + example = "NORMAL:-VERS-SSL3.0"; 223 + description = let 224 + url = "https://gnutls.org/manual/html_node/Priority-Strings.html"; 225 + in '' 226 + List of GnuTLS ciphers to use. See the GnuTLS documentation about 227 + priority strings at <link xlink:href="${url}"/> for full details. 228 + ''; 229 + }; 230 + 231 + organisations = mkOption { 232 + type = types.attrsOf (types.submodule orgOptions); 233 + default = {}; 234 + example.myShinyOrganisation.users = [ "alice" "bob" ]; 235 + example.myShinyOrganisation.groups = [ "staff" "outsiders" ]; 236 + example.yetAnotherOrganisation.users = [ "foo" "bar" ]; 237 + description = '' 238 + An attribute set where the keys name the organisation and the values 239 + are a set of lists of <option>users</option> and 240 + <option>groups</option>. 241 + ''; 242 + }; 243 + 244 + confirmation = mkOption { 245 + type = types.bool; 246 + default = true; 247 + description = '' 248 + Determines whether certain commands are confirmed. 249 + ''; 250 + }; 251 + 252 + debug = mkOption { 253 + type = types.bool; 254 + default = false; 255 + description = '' 256 + Logs debugging information. 257 + ''; 258 + }; 259 + 260 + extensions = mkOption { 261 + type = types.nullOr 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 = mkOption { 270 + type = types.bool; 271 + default = false; 272 + description = '' 273 + Logs the IP addresses of incoming requests. 274 + ''; 275 + }; 276 + 277 + queueSize = mkOption { 278 + type = types.int; 279 + default = 10; 280 + description = '' 281 + Size of the connection backlog, see <citerefentry> 282 + <refentrytitle>listen</refentrytitle> 283 + <manvolnum>2</manvolnum> 284 + </citerefentry>. 285 + ''; 286 + }; 287 + 288 + requestLimit = mkOption { 289 + type = types.int; 290 + default = 1048576; 291 + description = '' 292 + Size limit of incoming requests, in bytes. 293 + ''; 294 + }; 295 + 296 + allowedClientIDs = mkOption { 297 + type = with types; loeOf (either (enum ["all" "none"]) str); 298 + default = []; 299 + example = [ "[Tt]ask [2-9]+" ]; 300 + description = '' 301 + A list of regular expressions that are matched against the reported 302 + client id (such as <literal>task 2.3.0</literal>). 303 + 304 + The values <literal>all</literal> or <literal>none</literal> have 305 + special meaning. Overidden by any entry in the option 306 + <option>services.taskserver.disallowedClientIDs</option>. 307 + ''; 308 + }; 309 + 310 + disallowedClientIDs = mkOption { 311 + type = with types; loeOf (either (enum ["all" "none"]) str); 312 + default = []; 313 + example = [ "[Tt]ask [2-9]+" ]; 314 + description = '' 315 + A list of regular expressions that are matched against the reported 316 + client id (such as <literal>task 2.3.0</literal>). 317 + 318 + The values <literal>all</literal> or <literal>none</literal> have 319 + special meaning. Any entry here overrides those in 320 + <option>services.taskserver.allowedClientIDs</option>. 321 + ''; 322 + }; 323 + 324 + listenHost = mkOption { 325 + type = types.str; 326 + default = "localhost"; 327 + example = "::"; 328 + description = '' 329 + The address (IPv4, IPv6 or DNS) to listen on. 330 + 331 + If the value is something else than <literal>localhost</literal> the 332 + port defined by <option>listenPort</option> is automatically added to 333 + <option>networking.firewall.allowedTCPPorts</option>. 334 + ''; 335 + }; 336 + 337 + listenPort = mkOption { 338 + type = types.int; 339 + default = 53589; 340 + description = '' 341 + Port number of the Taskserver. 342 + ''; 343 + }; 344 + 345 + fqdn = mkOption { 346 + type = types.str; 347 + default = "localhost"; 348 + description = '' 349 + The fully qualified domain name of this server, which is also used 350 + as the common name in the certificates. 351 + ''; 352 + }; 353 + 354 + trust = mkOption { 355 + type = types.enum [ "allow all" "strict" ]; 356 + default = "strict"; 357 + description = '' 358 + Determines how client certificates are validated. 359 + 360 + The value <literal>allow all</literal> performs no client 361 + certificate validation. This is not recommended. The value 362 + <literal>strict</literal> causes the client certificate to be 363 + validated against a CA. 364 + ''; 365 + }; 366 + 367 + pki.manual = manualPkiOptions; 368 + pki.auto = autoPkiOptions; 369 + 370 + extraConfig = mkOption { 371 + type = types.lines; 372 + default = ""; 373 + example = "client.cert = /tmp/debugging.cert"; 374 + description = '' 375 + Extra lines to append to the taskdrc configuration file. 376 + ''; 377 + }; 378 + }; 379 + }; 380 + 381 + config = mkMerge [ 382 + (mkIf cfg.enable { 383 + environment.systemPackages = [ pkgs.taskserver nixos-taskserver ]; 384 + 385 + users.users = optional (cfg.user == "taskd") { 386 + name = "taskd"; 387 + uid = config.ids.uids.taskd; 388 + description = "Taskserver user"; 389 + group = cfg.group; 390 + }; 391 + 392 + users.groups = optional (cfg.group == "taskd") { 393 + name = "taskd"; 394 + gid = config.ids.gids.taskd; 395 + }; 396 + 397 + systemd.services.taskserver-init = { 398 + wantedBy = [ "taskserver.service" ]; 399 + before = [ "taskserver.service" ]; 400 + description = "Initialize Taskserver Data Directory"; 401 + 402 + preStart = '' 403 + mkdir -m 0770 -p "${cfg.dataDir}" 404 + chown "${cfg.user}:${cfg.group}" "${cfg.dataDir}" 405 + ''; 406 + 407 + script = '' 408 + ${taskd} init 409 + echo "include ${configFile}" > "${cfg.dataDir}/config" 410 + touch "${cfg.dataDir}/.is_initialized" 411 + ''; 412 + 413 + environment.TASKDDATA = cfg.dataDir; 414 + 415 + unitConfig.ConditionPathExists = "!${cfg.dataDir}/.is_initialized"; 416 + 417 + serviceConfig.Type = "oneshot"; 418 + serviceConfig.User = cfg.user; 419 + serviceConfig.Group = cfg.group; 420 + serviceConfig.PermissionsStartOnly = true; 421 + serviceConfig.PrivateNetwork = true; 422 + serviceConfig.PrivateDevices = true; 423 + serviceConfig.PrivateTmp = true; 424 + }; 425 + 426 + systemd.services.taskserver = { 427 + description = "Taskwarrior Server"; 428 + 429 + wantedBy = [ "multi-user.target" ]; 430 + after = [ "network.target" ]; 431 + 432 + environment.TASKDDATA = cfg.dataDir; 433 + 434 + preStart = let 435 + jsonOrgs = builtins.toJSON cfg.organisations; 436 + jsonFile = pkgs.writeText "orgs.json" jsonOrgs; 437 + helperTool = "${nixos-taskserver}/bin/nixos-taskserver"; 438 + in "${helperTool} process-json '${jsonFile}'"; 439 + 440 + serviceConfig = { 441 + ExecStart = "@${taskd} taskd server"; 442 + ExecReload = "${pkgs.coreutils}/bin/kill -USR1 $MAINPID"; 443 + Restart = "on-failure"; 444 + PermissionsStartOnly = true; 445 + PrivateTmp = true; 446 + PrivateDevices = true; 447 + User = cfg.user; 448 + Group = cfg.group; 449 + }; 450 + }; 451 + }) 452 + (mkIf needToCreateCA { 453 + systemd.services.taskserver-ca = { 454 + wantedBy = [ "taskserver.service" ]; 455 + after = [ "taskserver-init.service" ]; 456 + before = [ "taskserver.service" ]; 457 + description = "Initialize CA for TaskServer"; 458 + serviceConfig.Type = "oneshot"; 459 + serviceConfig.UMask = "0077"; 460 + serviceConfig.PrivateNetwork = true; 461 + serviceConfig.PrivateTmp = true; 462 + 463 + script = '' 464 + silent_certtool() { 465 + if ! output="$("${certtool}" "$@" 2>&1)"; then 466 + echo "GNUTLS certtool invocation failed with output:" >&2 467 + echo "$output" >&2 468 + fi 469 + } 470 + 471 + mkdir -m 0700 -p "${cfg.dataDir}/keys" 472 + chown root:root "${cfg.dataDir}/keys" 473 + 474 + if [ ! -e "${cfg.dataDir}/keys/ca.key" ]; then 475 + silent_certtool -p \ 476 + --bits ${toString cfg.pki.auto.bits} \ 477 + --outfile "${cfg.dataDir}/keys/ca.key" 478 + silent_certtool -s \ 479 + --template "${pkgs.writeText "taskserver-ca.template" '' 480 + cn = ${cfg.fqdn} 481 + expiration_days = ${toString cfg.pki.auto.expiration.ca} 482 + cert_signing_key 483 + ca 484 + ''}" \ 485 + --load-privkey "${cfg.dataDir}/keys/ca.key" \ 486 + --outfile "${cfg.dataDir}/keys/ca.cert" 487 + 488 + chgrp "${cfg.group}" "${cfg.dataDir}/keys/ca.cert" 489 + chmod g+r "${cfg.dataDir}/keys/ca.cert" 490 + fi 491 + 492 + if [ ! -e "${cfg.dataDir}/keys/server.key" ]; then 493 + silent_certtool -p \ 494 + --bits ${toString cfg.pki.auto.bits} \ 495 + --outfile "${cfg.dataDir}/keys/server.key" 496 + 497 + silent_certtool -c \ 498 + --template "${pkgs.writeText "taskserver-cert.template" '' 499 + cn = ${cfg.fqdn} 500 + expiration_days = ${toString cfg.pki.auto.expiration.server} 501 + tls_www_server 502 + encryption_key 503 + signing_key 504 + ''}" \ 505 + --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \ 506 + --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \ 507 + --load-privkey "${cfg.dataDir}/keys/server.key" \ 508 + --outfile "${cfg.dataDir}/keys/server.cert" 509 + 510 + chgrp "${cfg.group}" \ 511 + "${cfg.dataDir}/keys/server.key" \ 512 + "${cfg.dataDir}/keys/server.cert" 513 + 514 + chmod g+r \ 515 + "${cfg.dataDir}/keys/server.key" \ 516 + "${cfg.dataDir}/keys/server.cert" 517 + fi 518 + 519 + if [ ! -e "${cfg.dataDir}/keys/server.crl" ]; then 520 + silent_certtool --generate-crl \ 521 + --template "${pkgs.writeText "taskserver-crl.template" '' 522 + expiration_days = ${toString cfg.pki.auto.expiration.crl} 523 + ''}" \ 524 + --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \ 525 + --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \ 526 + --outfile "${cfg.dataDir}/keys/server.crl" 527 + 528 + chgrp "${cfg.group}" "${cfg.dataDir}/keys/server.crl" 529 + chmod g+r "${cfg.dataDir}/keys/server.crl" 530 + fi 531 + 532 + chmod go+x "${cfg.dataDir}/keys" 533 + ''; 534 + }; 535 + }) 536 + (mkIf (cfg.listenHost != "localhost") { 537 + networking.firewall.allowedTCPPorts = [ cfg.listenPort ]; 538 + }) 539 + { meta.doc = ./taskserver.xml; } 540 + ]; 541 + }
+144
nixos/modules/services/misc/taskserver/doc.xml
··· 1 + <chapter xmlns="http://docbook.org/ns/docbook" 2 + xmlns:xlink="http://www.w3.org/1999/xlink" 3 + version="5.0" 4 + xml:id="module-taskserver"> 5 + 6 + <title>Taskserver</title> 7 + 8 + <para> 9 + Taskserver is the server component of 10 + <link xlink:href="https://taskwarrior.org/">Taskwarrior</link>, a free and 11 + open source todo list application. 12 + </para> 13 + 14 + <para> 15 + <emphasis>Upstream documentation:</emphasis> 16 + <link xlink:href="https://taskwarrior.org/docs/#taskd"/> 17 + </para> 18 + 19 + <section> 20 + <title>Configuration</title> 21 + 22 + <para> 23 + Taskserver does all of its authentication via TLS using client 24 + certificates, so you either need to roll your own CA or purchase a 25 + certificate from a known CA, which allows creation of client 26 + certificates. 27 + 28 + These certificates are usually advertised as 29 + <quote>server certificates</quote>. 30 + </para> 31 + 32 + <para> 33 + So in order to make it easier to handle your own CA, there is a helper 34 + tool called <command>nixos-taskserver</command> which manages the custom 35 + CA along with Taskserver organisations, users and groups. 36 + </para> 37 + 38 + <para> 39 + While the client certificates in Taskserver only authenticate whether a 40 + user is allowed to connect, every user has its own UUID which identifies 41 + it as an entity. 42 + </para> 43 + 44 + <para> 45 + With <command>nixos-taskserver</command> the client certificate is created 46 + along with the UUID of the user, so it handles all of the credentials 47 + needed in order to setup the Taskwarrior client to work with a Taskserver. 48 + </para> 49 + </section> 50 + 51 + <section> 52 + <title>The nixos-taskserver tool</title> 53 + 54 + <para> 55 + Because Taskserver by default only provides scripts to setup users 56 + imperatively, the <command>nixos-taskserver</command> tool is used for 57 + addition and deletion of organisations along with users and groups defined 58 + by <option>services.taskserver.organisations</option> and as well for 59 + imperative set up. 60 + </para> 61 + 62 + <para> 63 + The tool is designed to not interfere if the command is used to manually 64 + set up some organisations, users or groups. 65 + </para> 66 + 67 + <para> 68 + For example if you add a new organisation using 69 + <command>nixos-taskserver org add foo</command>, the organisation is not 70 + modified and deleted no matter what you define in 71 + <option>services.taskserver.organisations</option>, even if you're adding 72 + the same organisation in that option. 73 + </para> 74 + 75 + <para> 76 + The tool is modelled to imitate the official <command>taskd</command> 77 + command, documentation for each subcommand can be shown by using the 78 + <option>--help</option> switch. 79 + </para> 80 + </section> 81 + <section> 82 + <title>Declarative/automatic CA management</title> 83 + 84 + <para> 85 + Everything is done according to what you specify in the module options, 86 + however in order to set up a Taskwarrior client for synchronisation with a 87 + Taskserver instance, you have to transfer the keys and certificates to the 88 + client machine. 89 + </para> 90 + 91 + <para> 92 + This is done using 93 + <command>nixos-taskserver user export $orgname $username</command> which 94 + is printing a shell script fragment to stdout which can either be used 95 + verbatim or adjusted to import the user on the client machine. 96 + </para> 97 + 98 + <para> 99 + For example, let's say you have the following configuration: 100 + <screen> 101 + { 102 + services.taskserver.enable = true; 103 + services.taskserver.fqdn = "server"; 104 + services.taskserver.listenHost = "::"; 105 + services.taskserver.organisations.my-company.users = [ "alice" ]; 106 + } 107 + </screen> 108 + This creates an organisation called <literal>my-company</literal> with the 109 + user <literal>alice</literal>. 110 + </para> 111 + 112 + <para> 113 + Now in order to import the <literal>alice</literal> user to another 114 + machine <literal>alicebox</literal>, all we need to do is something like 115 + this: 116 + <screen> 117 + $ ssh server nixos-taskserver user export my-company alice | sh 118 + </screen> 119 + Of course, if no SSH daemon is available on the server you can also copy 120 + &amp; paste it directly into a shell. 121 + </para> 122 + 123 + <para> 124 + After this step the user should be set up and you can start synchronising 125 + your tasks for the first time with <command>task sync init</command> on 126 + <literal>alicebox</literal>. 127 + </para> 128 + 129 + <para> 130 + Subsequent synchronisation requests merely require the command 131 + <command>task sync</command> after that stage. 132 + </para> 133 + </section> 134 + <section> 135 + <title>Manual CA management</title> 136 + 137 + <para> 138 + If you set any options within 139 + <option>service.taskserver.pki.manual.*</option>, the automatic user and 140 + CA management by the <command>nixos-taskserver</command> is disabled and 141 + you need to create certificates and keys by yourself. 142 + </para> 143 + </section> 144 + </chapter>
+673
nixos/modules/services/misc/taskserver/helper-tool.py
··· 1 + import grp 2 + import json 3 + import pwd 4 + import os 5 + import re 6 + import string 7 + import subprocess 8 + import sys 9 + 10 + from contextlib import contextmanager 11 + from shutil import rmtree 12 + from tempfile import NamedTemporaryFile 13 + 14 + import click 15 + 16 + CERTTOOL_COMMAND = "@certtool@" 17 + CERT_BITS = "@certBits@" 18 + CLIENT_EXPIRATION = "@clientExpiration@" 19 + CRL_EXPIRATION = "@crlExpiration@" 20 + 21 + TASKD_COMMAND = "@taskd@" 22 + TASKD_DATA_DIR = "@dataDir@" 23 + TASKD_USER = "@user@" 24 + TASKD_GROUP = "@group@" 25 + FQDN = "@fqdn@" 26 + 27 + CA_KEY = os.path.join(TASKD_DATA_DIR, "keys", "ca.key") 28 + CA_CERT = os.path.join(TASKD_DATA_DIR, "keys", "ca.cert") 29 + CRL_FILE = os.path.join(TASKD_DATA_DIR, "keys", "server.crl") 30 + 31 + RE_CONFIGUSER = re.compile(r'^\s*user\s*=(.*)$') 32 + RE_USERKEY = re.compile(r'New user key: (.+)$', re.MULTILINE) 33 + 34 + 35 + def lazyprop(fun): 36 + """ 37 + Decorator which only evaluates the specified function when accessed. 38 + """ 39 + name = '_lazy_' + fun.__name__ 40 + 41 + @property 42 + def _lazy(self): 43 + val = getattr(self, name, None) 44 + if val is None: 45 + val = fun(self) 46 + setattr(self, name, val) 47 + return val 48 + 49 + return _lazy 50 + 51 + 52 + class TaskdError(OSError): 53 + pass 54 + 55 + 56 + def run_as_taskd_user(): 57 + uid = pwd.getpwnam(TASKD_USER).pw_uid 58 + gid = grp.getgrnam(TASKD_GROUP).gr_gid 59 + os.setgid(gid) 60 + os.setuid(uid) 61 + 62 + 63 + def taskd_cmd(cmd, *args, **kwargs): 64 + """ 65 + Invoke taskd with the specified command with the privileges of the 'taskd' 66 + user and 'taskd' group. 67 + 68 + If 'capture_stdout' is passed as a keyword argument with the value True, 69 + the return value are the contents the command printed to stdout. 70 + """ 71 + capture_stdout = kwargs.pop("capture_stdout", False) 72 + fun = subprocess.check_output if capture_stdout else subprocess.check_call 73 + return fun( 74 + [TASKD_COMMAND, cmd, "--data", TASKD_DATA_DIR] + list(args), 75 + preexec_fn=run_as_taskd_user, 76 + **kwargs 77 + ) 78 + 79 + 80 + def certtool_cmd(*args, **kwargs): 81 + """ 82 + Invoke certtool from GNUTLS and return the output of the command. 83 + 84 + The provided arguments are added to the certtool command and keyword 85 + arguments are added to subprocess.check_output(). 86 + 87 + Note that this will suppress all output of certtool and it will only be 88 + printed whenever there is an unsuccessful return code. 89 + """ 90 + return subprocess.check_output( 91 + [CERTTOOL_COMMAND] + list(args), 92 + preexec_fn=lambda: os.umask(0077), 93 + stderr=subprocess.STDOUT, 94 + **kwargs 95 + ) 96 + 97 + 98 + def label(msg): 99 + if sys.stdout.isatty() or sys.stderr.isatty(): 100 + sys.stderr.write(msg + "\n") 101 + 102 + 103 + def mkpath(*args): 104 + return os.path.join(TASKD_DATA_DIR, "orgs", *args) 105 + 106 + 107 + def mark_imperative(*path): 108 + """ 109 + Mark the specified path as being imperatively managed by creating an empty 110 + file called ".imperative", so that it doesn't interfere with the 111 + declarative configuration. 112 + """ 113 + open(os.path.join(mkpath(*path), ".imperative"), 'a').close() 114 + 115 + 116 + def is_imperative(*path): 117 + """ 118 + Check whether the given path is marked as imperative, see mark_imperative() 119 + for more information. 120 + """ 121 + full_path = [] 122 + for component in path: 123 + full_path.append(component) 124 + if os.path.exists(os.path.join(mkpath(*full_path), ".imperative")): 125 + return True 126 + return False 127 + 128 + 129 + def fetch_username(org, key): 130 + for line in open(mkpath(org, "users", key, "config"), "r"): 131 + match = RE_CONFIGUSER.match(line) 132 + if match is None: 133 + continue 134 + return match.group(1).strip() 135 + return None 136 + 137 + 138 + @contextmanager 139 + def create_template(contents): 140 + """ 141 + Generate a temporary file with the specified contents as a list of strings 142 + and yield its path as the context. 143 + """ 144 + template = NamedTemporaryFile(mode="w", prefix="certtool-template") 145 + template.writelines(map(lambda l: l + "\n", contents)) 146 + template.flush() 147 + yield template.name 148 + template.close() 149 + 150 + 151 + def generate_key(org, user): 152 + basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user) 153 + if os.path.exists(basedir): 154 + raise OSError("Keyfile directory for {} already exists.".format(user)) 155 + 156 + privkey = os.path.join(basedir, "private.key") 157 + pubcert = os.path.join(basedir, "public.cert") 158 + 159 + try: 160 + os.makedirs(basedir, mode=0700) 161 + 162 + certtool_cmd("-p", "--bits", CERT_BITS, "--outfile", privkey) 163 + 164 + template_data = [ 165 + "organization = {0}".format(org), 166 + "cn = {}".format(FQDN), 167 + "expiration_days = {}".format(CLIENT_EXPIRATION), 168 + "tls_www_client", 169 + "encryption_key", 170 + "signing_key" 171 + ] 172 + 173 + with create_template(template_data) as template: 174 + certtool_cmd( 175 + "-c", 176 + "--load-privkey", privkey, 177 + "--load-ca-privkey", CA_KEY, 178 + "--load-ca-certificate", CA_CERT, 179 + "--template", template, 180 + "--outfile", pubcert 181 + ) 182 + except: 183 + rmtree(basedir) 184 + raise 185 + 186 + 187 + def revoke_key(org, user): 188 + basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user) 189 + if not os.path.exists(basedir): 190 + raise OSError("Keyfile directory for {} doesn't exist.".format(user)) 191 + 192 + pubcert = os.path.join(basedir, "public.cert") 193 + 194 + expiration = "expiration_days = {}".format(CRL_EXPIRATION) 195 + 196 + with create_template([expiration]) as template: 197 + oldcrl = NamedTemporaryFile(mode="wb", prefix="old-crl") 198 + oldcrl.write(open(CRL_FILE, "rb").read()) 199 + oldcrl.flush() 200 + certtool_cmd( 201 + "--generate-crl", 202 + "--load-crl", oldcrl.name, 203 + "--load-ca-privkey", CA_KEY, 204 + "--load-ca-certificate", CA_CERT, 205 + "--load-certificate", pubcert, 206 + "--template", template, 207 + "--outfile", CRL_FILE 208 + ) 209 + oldcrl.close() 210 + rmtree(basedir) 211 + 212 + 213 + def is_key_line(line, match): 214 + return line.startswith("---") and line.lstrip("- ").startswith(match) 215 + 216 + 217 + def getkey(*args): 218 + path = os.path.join(TASKD_DATA_DIR, "keys", *args) 219 + buf = [] 220 + for line in open(path, "r"): 221 + if len(buf) == 0: 222 + if is_key_line(line, "BEGIN"): 223 + buf.append(line) 224 + continue 225 + 226 + buf.append(line) 227 + 228 + if is_key_line(line, "END"): 229 + return ''.join(buf) 230 + raise IOError("Unable to get key from {}.".format(path)) 231 + 232 + 233 + def mktaskkey(cfg, path, keydata): 234 + heredoc = 'cat > "{}" <<EOF\n{}EOF'.format(path, keydata) 235 + cmd = 'task config taskd.{} -- "{}"'.format(cfg, path) 236 + return heredoc + "\n" + cmd 237 + 238 + 239 + class User(object): 240 + def __init__(self, org, name, key): 241 + self.__org = org 242 + self.name = name 243 + self.key = key 244 + 245 + def export(self): 246 + pubcert = getkey(self.__org, self.name, "public.cert") 247 + privkey = getkey(self.__org, self.name, "private.key") 248 + cacert = getkey("ca.cert") 249 + 250 + keydir = "${TASKDATA:-$HOME/.task}/keys" 251 + 252 + credentials = '/'.join([self.__org, self.name, self.key]) 253 + allow_unquoted = string.ascii_letters + string.digits + "/-_." 254 + if not all((c in allow_unquoted) for c in credentials): 255 + credentials = "'" + credentials.replace("'", r"'\''") + "'" 256 + 257 + script = [ 258 + "umask 0077", 259 + 'mkdir -p "{}"'.format(keydir), 260 + mktaskkey("certificate", os.path.join(keydir, "public.cert"), 261 + pubcert), 262 + mktaskkey("key", os.path.join(keydir, "private.key"), privkey), 263 + mktaskkey("ca", os.path.join(keydir, "ca.cert"), cacert), 264 + "task config taskd.credentials -- {}".format(credentials) 265 + ] 266 + 267 + return "\n".join(script) + "\n" 268 + 269 + 270 + class Group(object): 271 + def __init__(self, org, name): 272 + self.__org = org 273 + self.name = name 274 + 275 + 276 + class Organisation(object): 277 + def __init__(self, name, ignore_imperative): 278 + self.name = name 279 + self.ignore_imperative = ignore_imperative 280 + 281 + def add_user(self, name): 282 + """ 283 + Create a new user along with a certificate and key. 284 + 285 + Returns a 'User' object or None if the user already exists. 286 + """ 287 + if self.ignore_imperative and is_imperative(self.name): 288 + return None 289 + if name not in self.users.keys(): 290 + output = taskd_cmd("add", "user", self.name, name, 291 + capture_stdout=True) 292 + key = RE_USERKEY.search(output) 293 + if key is None: 294 + msg = "Unable to find key while creating user {}." 295 + raise TaskdError(msg.format(name)) 296 + 297 + generate_key(self.name, name) 298 + newuser = User(self.name, name, key.group(1)) 299 + self._lazy_users[name] = newuser 300 + return newuser 301 + return None 302 + 303 + def del_user(self, name): 304 + """ 305 + Delete a user and revoke its keys. 306 + """ 307 + if name in self.users.keys(): 308 + user = self.get_user(name) 309 + if self.ignore_imperative and \ 310 + is_imperative(self.name, "users", user.key): 311 + return 312 + 313 + # Work around https://bug.tasktools.org/browse/TD-40: 314 + rmtree(mkpath(self.name, "users", user.key)) 315 + 316 + revoke_key(self.name, name) 317 + del self._lazy_users[name] 318 + 319 + def add_group(self, name): 320 + """ 321 + Create a new group. 322 + 323 + Returns a 'Group' object or None if the group already exists. 324 + """ 325 + if self.ignore_imperative and is_imperative(self.name): 326 + return None 327 + if name not in self.groups.keys(): 328 + taskd_cmd("add", "group", self.name, name) 329 + newgroup = Group(self.name, name) 330 + self._lazy_groups[name] = newgroup 331 + return newgroup 332 + return None 333 + 334 + def del_group(self, name): 335 + """ 336 + Delete a group. 337 + """ 338 + if name in self.users.keys(): 339 + if self.ignore_imperative and \ 340 + is_imperative(self.name, "groups", name): 341 + return 342 + taskd_cmd("remove", "group", self.name, name) 343 + del self._lazy_groups[name] 344 + 345 + def get_user(self, name): 346 + return self.users.get(name) 347 + 348 + @lazyprop 349 + def users(self): 350 + result = {} 351 + for key in os.listdir(mkpath(self.name, "users")): 352 + user = fetch_username(self.name, key) 353 + if user is not None: 354 + result[user] = User(self.name, user, key) 355 + return result 356 + 357 + def get_group(self, name): 358 + return self.groups.get(name) 359 + 360 + @lazyprop 361 + def groups(self): 362 + result = {} 363 + for group in os.listdir(mkpath(self.name, "groups")): 364 + result[group] = Group(self.name, group) 365 + return result 366 + 367 + 368 + class Manager(object): 369 + def __init__(self, ignore_imperative=False): 370 + """ 371 + Instantiates an organisations manager. 372 + 373 + If ignore_imperative is True, all actions that modify data are checked 374 + whether they're created imperatively and if so, they will result in no 375 + operation. 376 + """ 377 + self.ignore_imperative = ignore_imperative 378 + 379 + def add_org(self, name): 380 + """ 381 + Create a new organisation. 382 + 383 + Returns an 'Organisation' object or None if the organisation already 384 + exists. 385 + """ 386 + if name not in self.orgs.keys(): 387 + taskd_cmd("add", "org", name) 388 + neworg = Organisation(name, self.ignore_imperative) 389 + self._lazy_orgs[name] = neworg 390 + return neworg 391 + return None 392 + 393 + def del_org(self, name): 394 + """ 395 + Delete and revoke keys of an organisation with all its users and 396 + groups. 397 + """ 398 + org = self.get_org(name) 399 + if org is not None: 400 + if self.ignore_imperative and is_imperative(name): 401 + return 402 + for user in org.users.keys(): 403 + org.del_user(user) 404 + for group in org.groups.keys(): 405 + org.del_group(group) 406 + taskd_cmd("remove", "org", name) 407 + del self._lazy_orgs[name] 408 + 409 + def get_org(self, name): 410 + return self.orgs.get(name) 411 + 412 + @lazyprop 413 + def orgs(self): 414 + result = {} 415 + for org in os.listdir(mkpath()): 416 + result[org] = Organisation(org, self.ignore_imperative) 417 + return result 418 + 419 + 420 + class OrganisationType(click.ParamType): 421 + name = 'organisation' 422 + 423 + def convert(self, value, param, ctx): 424 + org = Manager().get_org(value) 425 + if org is None: 426 + self.fail("Organisation {} does not exist.".format(value)) 427 + return org 428 + 429 + ORGANISATION = OrganisationType() 430 + 431 + 432 + @click.group() 433 + @click.pass_context 434 + def cli(ctx): 435 + """ 436 + Manage Taskserver users and certificates 437 + """ 438 + for path in (CA_KEY, CA_CERT, CRL_FILE): 439 + if not os.path.exists(path): 440 + msg = "CA setup not done or incomplete, missing file {}." 441 + ctx.fail(msg.format(path)) 442 + 443 + 444 + @cli.group("org") 445 + def org_cli(): 446 + """ 447 + Manage organisations 448 + """ 449 + pass 450 + 451 + 452 + @cli.group("user") 453 + def user_cli(): 454 + """ 455 + Manage users 456 + """ 457 + pass 458 + 459 + 460 + @cli.group("group") 461 + def group_cli(): 462 + """ 463 + Manage groups 464 + """ 465 + pass 466 + 467 + 468 + @user_cli.command("list") 469 + @click.argument("organisation", type=ORGANISATION) 470 + def list_users(organisation): 471 + """ 472 + List all users belonging to the specified organisation. 473 + """ 474 + label("The following users exists for {}:".format(organisation.name)) 475 + for user in organisation.users.values(): 476 + sys.stdout.write(user.name + "\n") 477 + 478 + 479 + @group_cli.command("list") 480 + @click.argument("organisation", type=ORGANISATION) 481 + def list_groups(organisation): 482 + """ 483 + List all users belonging to the specified organisation. 484 + """ 485 + label("The following users exists for {}:".format(organisation.name)) 486 + for group in organisation.groups.values(): 487 + sys.stdout.write(group.name + "\n") 488 + 489 + 490 + @org_cli.command("list") 491 + def list_orgs(): 492 + """ 493 + List available organisations 494 + """ 495 + label("The following organisations exist:") 496 + for org in Manager().orgs: 497 + sys.stdout.write(org.name + "\n") 498 + 499 + 500 + @user_cli.command("getkey") 501 + @click.argument("organisation", type=ORGANISATION) 502 + @click.argument("user") 503 + def get_uuid(organisation, user): 504 + """ 505 + Get the UUID of the specified user belonging to the specified organisation. 506 + """ 507 + userobj = organisation.get_user(user) 508 + if userobj is None: 509 + msg = "User {} doesn't exist in organisation {}." 510 + sys.exit(msg.format(userobj.name, organisation.name)) 511 + 512 + label("User {} has the following UUID:".format(userobj.name)) 513 + sys.stdout.write(user.key + "\n") 514 + 515 + 516 + @user_cli.command("export") 517 + @click.argument("organisation", type=ORGANISATION) 518 + @click.argument("user") 519 + def export_user(organisation, user): 520 + """ 521 + Export user of the specified organisation as a series of shell commands 522 + that can be used on the client side to easily import the certificates. 523 + 524 + Note that the private key will be exported as well, so use this with care! 525 + """ 526 + userobj = organisation.get_user(user) 527 + if userobj is None: 528 + msg = "User {} doesn't exist in organisation {}." 529 + sys.exit(msg.format(userobj.name, organisation.name)) 530 + 531 + sys.stdout.write(userobj.export()) 532 + 533 + 534 + @org_cli.command("add") 535 + @click.argument("name") 536 + def add_org(name): 537 + """ 538 + Create an organisation with the specified name. 539 + """ 540 + if os.path.exists(mkpath(name)): 541 + msg = "Organisation with name {} already exists." 542 + sys.exit(msg.format(name)) 543 + 544 + taskd_cmd("add", "org", name) 545 + mark_imperative(name) 546 + 547 + 548 + @org_cli.command("remove") 549 + @click.argument("name") 550 + def del_org(name): 551 + """ 552 + Delete the organisation with the specified name. 553 + 554 + All of the users and groups will be deleted as well and client certificates 555 + will be revoked. 556 + """ 557 + Manager().del_org(name) 558 + msg = ("Organisation {} deleted. Be sure to restart the Taskserver" 559 + " using 'systemctl restart taskserver.service' in order for" 560 + " the certificate revocation to apply.") 561 + click.echo(msg.format(name), err=True) 562 + 563 + 564 + @user_cli.command("add") 565 + @click.argument("organisation", type=ORGANISATION) 566 + @click.argument("user") 567 + def add_user(organisation, user): 568 + """ 569 + Create a user for the given organisation along with a client certificate 570 + and print the key of the new user. 571 + 572 + The client certificate along with it's public key can be shown via the 573 + 'user export' subcommand. 574 + """ 575 + userobj = organisation.add_user(user) 576 + if userobj is None: 577 + msg = "User {} already exists in organisation {}." 578 + sys.exit(msg.format(user, organisation)) 579 + else: 580 + mark_imperative(organisation.name, "users", userobj.key) 581 + 582 + 583 + @user_cli.command("remove") 584 + @click.argument("organisation", type=ORGANISATION) 585 + @click.argument("user") 586 + def del_user(organisation, user): 587 + """ 588 + Delete a user from the given organisation. 589 + 590 + This will also revoke the client certificate of the given user. 591 + """ 592 + organisation.del_user(user) 593 + msg = ("User {} deleted. Be sure to restart the Taskserver using" 594 + " 'systemctl restart taskserver.service' in order for the" 595 + " certificate revocation to apply.") 596 + click.echo(msg.format(user), err=True) 597 + 598 + 599 + @group_cli.command("add") 600 + @click.argument("organisation", type=ORGANISATION) 601 + @click.argument("group") 602 + def add_group(organisation, group): 603 + """ 604 + Create a group for the given organisation. 605 + """ 606 + groupobj = organisation.add_group(group) 607 + if groupobj is None: 608 + msg = "Group {} already exists in organisation {}." 609 + sys.exit(msg.format(group, organisation)) 610 + else: 611 + mark_imperative(organisation.name, "groups", groupobj.name) 612 + 613 + 614 + @group_cli.command("remove") 615 + @click.argument("organisation", type=ORGANISATION) 616 + @click.argument("group") 617 + def del_group(organisation, group): 618 + """ 619 + Delete a group from the given organisation. 620 + """ 621 + organisation.del_group(group) 622 + click("Group {} deleted.".format(group), err=True) 623 + 624 + 625 + def add_or_delete(old, new, add_fun, del_fun): 626 + """ 627 + Given an 'old' and 'new' list, figure out the intersections and invoke 628 + 'add_fun' against every element that is not in the 'old' list and 'del_fun' 629 + against every element that is not in the 'new' list. 630 + 631 + Returns a tuple where the first element is the list of elements that were 632 + added and the second element consisting of elements that were deleted. 633 + """ 634 + old_set = set(old) 635 + new_set = set(new) 636 + to_delete = old_set - new_set 637 + to_add = new_set - old_set 638 + for elem in to_delete: 639 + del_fun(elem) 640 + for elem in to_add: 641 + add_fun(elem) 642 + return to_add, to_delete 643 + 644 + 645 + @cli.command("process-json") 646 + @click.argument('json-file', type=click.File('rb')) 647 + def process_json(json_file): 648 + """ 649 + Create and delete users, groups and organisations based on a JSON file. 650 + 651 + The structure of this file is exactly the same as the 652 + 'services.taskserver.organisations' option of the NixOS module and is used 653 + for declaratively adding and deleting users. 654 + 655 + Hence this subcommand is not recommended outside of the scope of the NixOS 656 + module. 657 + """ 658 + data = json.load(json_file) 659 + 660 + mgr = Manager(ignore_imperative=True) 661 + add_or_delete(mgr.orgs.keys(), data.keys(), mgr.add_org, mgr.del_org) 662 + 663 + for org in mgr.orgs.values(): 664 + if is_imperative(org.name): 665 + continue 666 + add_or_delete(org.users.keys(), data[org.name]['users'], 667 + org.add_user, org.del_user) 668 + add_or_delete(org.groups.keys(), data[org.name]['groups'], 669 + org.add_group, org.del_group) 670 + 671 + 672 + if __name__ == '__main__': 673 + cli()
+1
nixos/release.nix
··· 253 253 tests.sddm = callTest tests/sddm.nix {}; 254 254 tests.sddm-kde5 = callTest tests/sddm-kde5.nix {}; 255 255 tests.simple = callTest tests/simple.nix {}; 256 + tests.taskserver = callTest tests/taskserver.nix {}; 256 257 tests.tomcat = callTest tests/tomcat.nix {}; 257 258 tests.udisks2 = callTest tests/udisks2.nix {}; 258 259 tests.virtualbox = callSubTests tests/virtualbox.nix { system = "x86_64-linux"; };
+166
nixos/tests/taskserver.nix
··· 1 + import ./make-test.nix { 2 + name = "taskserver"; 3 + 4 + nodes = rec { 5 + server = { 6 + services.taskserver.enable = true; 7 + services.taskserver.listenHost = "::"; 8 + services.taskserver.fqdn = "server"; 9 + services.taskserver.organisations = { 10 + testOrganisation.users = [ "alice" "foo" ]; 11 + anotherOrganisation.users = [ "bob" ]; 12 + }; 13 + }; 14 + 15 + client1 = { pkgs, ... }: { 16 + environment.systemPackages = [ pkgs.taskwarrior pkgs.gnutls ]; 17 + users.users.alice.isNormalUser = true; 18 + users.users.bob.isNormalUser = true; 19 + users.users.foo.isNormalUser = true; 20 + users.users.bar.isNormalUser = true; 21 + }; 22 + 23 + client2 = client1; 24 + }; 25 + 26 + testScript = { nodes, ... }: let 27 + cfg = nodes.server.config.services.taskserver; 28 + portStr = toString cfg.listenPort; 29 + in '' 30 + sub su ($$) { 31 + my ($user, $cmd) = @_; 32 + my $esc = $cmd =~ s/'/'\\${"'"}'/gr; 33 + return "su - $user -c '$esc'"; 34 + } 35 + 36 + sub setupClientsFor ($$) { 37 + my ($org, $user) = @_; 38 + 39 + for my $client ($client1, $client2) { 40 + $client->nest("initialize client for user $user", sub { 41 + $client->succeed( 42 + (su $user, "rm -rf /home/$user/.task"), 43 + (su $user, "task rc.confirmation=no config confirmation no") 44 + ); 45 + 46 + my $exportinfo = $server->succeed( 47 + "nixos-taskserver user export $org $user" 48 + ); 49 + 50 + $exportinfo =~ s/'/'\\'''/g; 51 + 52 + $client->nest("importing taskwarrior configuration", sub { 53 + my $cmd = su $user, "eval '$exportinfo' >&2"; 54 + my ($status, $out) = $client->execute_($cmd); 55 + if ($status != 0) { 56 + $client->log("output: $out"); 57 + die "command `$cmd' did not succeed (exit code $status)\n"; 58 + } 59 + }); 60 + 61 + $client->succeed(su $user, 62 + "task config taskd.server server:${portStr} >&2" 63 + ); 64 + 65 + $client->succeed(su $user, "task sync init >&2"); 66 + }); 67 + } 68 + } 69 + 70 + sub restartServer { 71 + $server->succeed("systemctl restart taskserver.service"); 72 + $server->waitForOpenPort(${portStr}); 73 + } 74 + 75 + sub readdImperativeUser { 76 + $server->nest("(re-)add imperative user bar", sub { 77 + $server->execute("nixos-taskserver org remove imperativeOrg"); 78 + $server->succeed( 79 + "nixos-taskserver org add imperativeOrg", 80 + "nixos-taskserver user add imperativeOrg bar" 81 + ); 82 + setupClientsFor "imperativeOrg", "bar"; 83 + }); 84 + } 85 + 86 + sub testSync ($) { 87 + my $user = $_[0]; 88 + subtest "sync for user $user", sub { 89 + $client1->succeed(su $user, "task add foo >&2"); 90 + $client1->succeed(su $user, "task sync >&2"); 91 + $client2->fail(su $user, "task list >&2"); 92 + $client2->succeed(su $user, "task sync >&2"); 93 + $client2->succeed(su $user, "task list >&2"); 94 + }; 95 + } 96 + 97 + sub checkClientCert ($) { 98 + my $user = $_[0]; 99 + my $cmd = "gnutls-cli". 100 + " --x509cafile=/home/$user/.task/keys/ca.cert". 101 + " --x509keyfile=/home/$user/.task/keys/private.key". 102 + " --x509certfile=/home/$user/.task/keys/public.cert". 103 + " --port=${portStr} server < /dev/null"; 104 + return su $user, $cmd; 105 + } 106 + 107 + startAll; 108 + 109 + $server->waitForUnit("taskserver.service"); 110 + 111 + $server->succeed( 112 + "nixos-taskserver user list testOrganisation | grep -qxF alice", 113 + "nixos-taskserver user list testOrganisation | grep -qxF foo", 114 + "nixos-taskserver user list anotherOrganisation | grep -qxF bob" 115 + ); 116 + 117 + $server->waitForOpenPort(${portStr}); 118 + 119 + $client1->waitForUnit("multi-user.target"); 120 + $client2->waitForUnit("multi-user.target"); 121 + 122 + setupClientsFor "testOrganisation", "alice"; 123 + setupClientsFor "testOrganisation", "foo"; 124 + setupClientsFor "anotherOrganisation", "bob"; 125 + 126 + testSync $_ for ("alice", "bob", "foo"); 127 + 128 + $server->fail("nixos-taskserver user add imperativeOrg bar"); 129 + readdImperativeUser; 130 + 131 + testSync "bar"; 132 + 133 + subtest "checking certificate revocation of user bar", sub { 134 + $client1->succeed(checkClientCert "bar"); 135 + 136 + $server->succeed("nixos-taskserver user remove imperativeOrg bar"); 137 + restartServer; 138 + 139 + $client1->fail(checkClientCert "bar"); 140 + 141 + $client1->succeed(su "bar", "task add destroy everything >&2"); 142 + $client1->fail(su "bar", "task sync >&2"); 143 + }; 144 + 145 + readdImperativeUser; 146 + 147 + subtest "checking certificate revocation of org imperativeOrg", sub { 148 + $client1->succeed(checkClientCert "bar"); 149 + 150 + $server->succeed("nixos-taskserver org remove imperativeOrg"); 151 + restartServer; 152 + 153 + $client1->fail(checkClientCert "bar"); 154 + 155 + $client1->succeed(su "bar", "task add destroy even more >&2"); 156 + $client1->fail(su "bar", "task sync >&2"); 157 + }; 158 + 159 + readdImperativeUser; 160 + 161 + subtest "check whether declarative config overrides user bar", sub { 162 + restartServer; 163 + testSync "bar"; 164 + }; 165 + ''; 166 + }