diff --git a/nixos/doc/manual/configuration/configuration.xml b/nixos/doc/manual/configuration/configuration.xml index fb3f1498a9b..cfa5619938b 100644 --- a/nixos/doc/manual/configuration/configuration.xml +++ b/nixos/doc/manual/configuration/configuration.xml @@ -27,6 +27,7 @@ effect after you run nixos-rebuild. + diff --git a/nixos/doc/manual/default.nix b/nixos/doc/manual/default.nix index 69da1f94882..86a39322ba5 100644 --- a/nixos/doc/manual/default.nix +++ b/nixos/doc/manual/default.nix @@ -57,6 +57,7 @@ let chmod -R u+w . cp ${../../modules/services/databases/postgresql.xml} configuration/postgresql.xml cp ${../../modules/services/misc/gitlab.xml} configuration/gitlab.xml + cp ${../../modules/services/misc/taskserver/doc.xml} configuration/taskserver.xml cp ${../../modules/security/acme.xml} configuration/acme.xml cp ${../../modules/i18n/input-method/default.xml} configuration/input-methods.xml ln -s ${optionsDocBook} options-db.xml diff --git a/nixos/modules/misc/ids.nix b/nixos/modules/misc/ids.nix index c3bade2ee6b..86332d71949 100644 --- a/nixos/modules/misc/ids.nix +++ b/nixos/modules/misc/ids.nix @@ -261,6 +261,7 @@ syncthing = 237; mfi = 238; caddy = 239; + taskd = 240; # When adding a uid, make sure it doesn't match an existing gid. And don't use uids above 399! @@ -493,6 +494,7 @@ syncthing = 237; #mfi = 238; # unused caddy = 239; + taskd = 240; # When adding a gid, make sure it doesn't match an existing # uid. Users and groups with the same name should have equal diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 5b3d19e0bba..6384f8a3d9a 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -250,6 +250,7 @@ ./services/misc/sundtek.nix ./services/misc/svnserve.nix ./services/misc/synergy.nix + ./services/misc/taskserver ./services/misc/uhub.nix ./services/misc/zookeeper.nix ./services/monitoring/apcupsd.nix diff --git a/nixos/modules/services/misc/taskserver/default.nix b/nixos/modules/services/misc/taskserver/default.nix new file mode 100644 index 00000000000..8459aafeee7 --- /dev/null +++ b/nixos/modules/services/misc/taskserver/default.nix @@ -0,0 +1,541 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.taskserver; + + taskd = "${pkgs.taskserver}/bin/taskd"; + + mkVal = val: + if val == true then "true" + else if val == false then "false" + else if isList val then concatStringsSep ", " val + else toString val; + + mkConfLine = key: val: let + result = "${key} = ${mkVal val}"; + in optionalString (val != null && val != []) result; + + mkManualPkiOption = desc: mkOption { + type = types.nullOr types.path; + default = null; + description = desc + '' + + Setting this option will prevent automatic CA creation and handling. + + ''; + }; + + manualPkiOptions = { + ca.cert = mkManualPkiOption '' + Fully qualified path to the CA certificate. + ''; + + server.cert = mkManualPkiOption '' + Fully qualified path to the server certificate. + ''; + + server.crl = mkManualPkiOption '' + Fully qualified path to the server certificate revocation list. + ''; + + server.key = mkManualPkiOption '' + Fully qualified path to the server key. + ''; + }; + + mkAutoDesc = preamble: '' + ${preamble} + + + This option is for the automatically handled CA and will be ignored if any + of the options are set. + + ''; + + mkExpireOption = desc: mkOption { + type = types.nullOr types.int; + default = null; + example = 365; + apply = val: if isNull val then -1 else val; + description = mkAutoDesc '' + The expiration time of ${desc} in days or null for no + expiration time. + ''; + }; + + autoPkiOptions = { + bits = mkOption { + type = types.int; + default = 4096; + example = 2048; + description = mkAutoDesc "The bit size for generated keys."; + }; + + expiration = { + ca = mkExpireOption "the CA certificate"; + server = mkExpireOption "the server certificate"; + client = mkExpireOption "client certificates"; + crl = mkExpireOption "the certificate revocation list (CRL)"; + }; + }; + + needToCreateCA = let + notFound = path: let + dotted = concatStringsSep "." path; + in throw "Can't find option definitions for path `${dotted}'."; + findPkiDefinitions = path: attrs: let + mkSublist = key: val: let + newPath = path ++ singleton key; + in if isOption val + then attrByPath newPath (notFound newPath) cfg.pki.manual + else findPkiDefinitions newPath val; + in flatten (mapAttrsToList mkSublist attrs); + in all isNull (findPkiDefinitions [] manualPkiOptions); + + configFile = pkgs.writeText "taskdrc" ('' + # systemd related + daemon = false + log = - + + # logging + ${mkConfLine "debug" cfg.debug} + ${mkConfLine "ip.log" cfg.ipLog} + + # general + ${mkConfLine "ciphers" cfg.ciphers} + ${mkConfLine "confirmation" cfg.confirmation} + ${mkConfLine "extensions" cfg.extensions} + ${mkConfLine "queue.size" cfg.queueSize} + ${mkConfLine "request.limit" cfg.requestLimit} + + # client + ${mkConfLine "client.allow" cfg.allowedClientIDs} + ${mkConfLine "client.deny" cfg.disallowedClientIDs} + + # server + server = ${cfg.listenHost}:${toString cfg.listenPort} + ${mkConfLine "trust" cfg.trust} + + # PKI options + ${if needToCreateCA then '' + ca.cert = ${cfg.dataDir}/keys/ca.cert + server.cert = ${cfg.dataDir}/keys/server.cert + server.key = ${cfg.dataDir}/keys/server.key + server.crl = ${cfg.dataDir}/keys/server.crl + '' else '' + ca.cert = ${cfg.pki.ca.cert} + server.cert = ${cfg.pki.server.cert} + server.key = ${cfg.pki.server.key} + server.crl = ${cfg.pki.server.crl} + ''} + '' + cfg.extraConfig); + + orgOptions = { name, ... }: { + options.users = mkOption { + type = types.uniq (types.listOf types.str); + default = []; + example = [ "alice" "bob" ]; + description = '' + A list of user names that belong to the organization. + ''; + }; + + options.groups = mkOption { + type = types.listOf types.str; + default = []; + example = [ "workers" "slackers" ]; + description = '' + A list of group names that belong to the organization. + ''; + }; + }; + + mkShellStr = val: "'${replaceStrings ["'"] ["'\\''"] val}'"; + + certtool = "${pkgs.gnutls.bin}/bin/certtool"; + + nixos-taskserver = pkgs.buildPythonPackage { + name = "nixos-taskserver"; + namePrefix = ""; + + src = pkgs.runCommand "nixos-taskserver-src" {} '' + mkdir -p "$out" + cat "${pkgs.substituteAll { + src = ./helper-tool.py; + inherit taskd certtool; + inherit (cfg) dataDir user group fqdn; + certBits = cfg.pki.auto.bits; + clientExpiration = cfg.pki.auto.expiration.client; + crlExpiration = cfg.pki.auto.expiration.crl; + }}" > "$out/main.py" + cat > "$out/setup.py" <. + ''; + }; + + user = mkOption { + type = types.str; + default = "taskd"; + description = "User for Taskserver."; + }; + + group = mkOption { + type = types.str; + default = "taskd"; + description = "Group for Taskserver."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/taskserver"; + description = "Data directory for Taskserver."; + }; + + ciphers = mkOption { + type = types.nullOr (types.separatedString ":"); + default = null; + example = "NORMAL:-VERS-SSL3.0"; + description = let + url = "https://gnutls.org/manual/html_node/Priority-Strings.html"; + in '' + List of GnuTLS ciphers to use. See the GnuTLS documentation about + priority strings at for full details. + ''; + }; + + organisations = mkOption { + type = types.attrsOf (types.submodule orgOptions); + default = {}; + example.myShinyOrganisation.users = [ "alice" "bob" ]; + example.myShinyOrganisation.groups = [ "staff" "outsiders" ]; + example.yetAnotherOrganisation.users = [ "foo" "bar" ]; + description = '' + An attribute set where the keys name the organisation and the values + are a set of lists of and + . + ''; + }; + + confirmation = mkOption { + type = types.bool; + default = true; + description = '' + Determines whether certain commands are confirmed. + ''; + }; + + debug = mkOption { + type = types.bool; + default = false; + description = '' + Logs debugging information. + ''; + }; + + extensions = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Fully qualified path of the Taskserver extension scripts. + Currently there are none. + ''; + }; + + ipLog = mkOption { + type = types.bool; + default = false; + description = '' + Logs the IP addresses of incoming requests. + ''; + }; + + queueSize = mkOption { + type = types.int; + default = 10; + description = '' + Size of the connection backlog, see + listen + 2 + . + ''; + }; + + requestLimit = mkOption { + type = types.int; + default = 1048576; + description = '' + Size limit of incoming requests, in bytes. + ''; + }; + + allowedClientIDs = mkOption { + type = with types; loeOf (either (enum ["all" "none"]) str); + default = []; + example = [ "[Tt]ask [2-9]+" ]; + description = '' + A list of regular expressions that are matched against the reported + client id (such as task 2.3.0). + + The values all or none have + special meaning. Overidden by any entry in the option + . + ''; + }; + + disallowedClientIDs = mkOption { + type = with types; loeOf (either (enum ["all" "none"]) str); + default = []; + example = [ "[Tt]ask [2-9]+" ]; + description = '' + A list of regular expressions that are matched against the reported + client id (such as task 2.3.0). + + The values all or none have + special meaning. Any entry here overrides those in + . + ''; + }; + + listenHost = mkOption { + type = types.str; + default = "localhost"; + example = "::"; + description = '' + The address (IPv4, IPv6 or DNS) to listen on. + + If the value is something else than localhost the + port defined by is automatically added to + . + ''; + }; + + listenPort = mkOption { + type = types.int; + default = 53589; + description = '' + Port number of the Taskserver. + ''; + }; + + fqdn = mkOption { + type = types.str; + default = "localhost"; + description = '' + The fully qualified domain name of this server, which is also used + as the common name in the certificates. + ''; + }; + + trust = mkOption { + type = types.enum [ "allow all" "strict" ]; + default = "strict"; + description = '' + Determines how client certificates are validated. + + The value allow all performs no client + certificate validation. This is not recommended. The value + strict causes the client certificate to be + validated against a CA. + ''; + }; + + pki.manual = manualPkiOptions; + pki.auto = autoPkiOptions; + + extraConfig = mkOption { + type = types.lines; + default = ""; + example = "client.cert = /tmp/debugging.cert"; + description = '' + Extra lines to append to the taskdrc configuration file. + ''; + }; + }; + }; + + config = mkMerge [ + (mkIf cfg.enable { + environment.systemPackages = [ pkgs.taskserver nixos-taskserver ]; + + users.users = optional (cfg.user == "taskd") { + name = "taskd"; + uid = config.ids.uids.taskd; + description = "Taskserver user"; + group = cfg.group; + }; + + users.groups = optional (cfg.group == "taskd") { + name = "taskd"; + gid = config.ids.gids.taskd; + }; + + systemd.services.taskserver-init = { + wantedBy = [ "taskserver.service" ]; + before = [ "taskserver.service" ]; + description = "Initialize Taskserver Data Directory"; + + preStart = '' + mkdir -m 0770 -p "${cfg.dataDir}" + chown "${cfg.user}:${cfg.group}" "${cfg.dataDir}" + ''; + + script = '' + ${taskd} init + echo "include ${configFile}" > "${cfg.dataDir}/config" + touch "${cfg.dataDir}/.is_initialized" + ''; + + environment.TASKDDATA = cfg.dataDir; + + unitConfig.ConditionPathExists = "!${cfg.dataDir}/.is_initialized"; + + serviceConfig.Type = "oneshot"; + serviceConfig.User = cfg.user; + serviceConfig.Group = cfg.group; + serviceConfig.PermissionsStartOnly = true; + serviceConfig.PrivateNetwork = true; + serviceConfig.PrivateDevices = true; + serviceConfig.PrivateTmp = true; + }; + + systemd.services.taskserver = { + description = "Taskwarrior Server"; + + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment.TASKDDATA = cfg.dataDir; + + preStart = let + jsonOrgs = builtins.toJSON cfg.organisations; + jsonFile = pkgs.writeText "orgs.json" jsonOrgs; + helperTool = "${nixos-taskserver}/bin/nixos-taskserver"; + in "${helperTool} process-json '${jsonFile}'"; + + serviceConfig = { + ExecStart = "@${taskd} taskd server"; + ExecReload = "${pkgs.coreutils}/bin/kill -USR1 $MAINPID"; + Restart = "on-failure"; + PermissionsStartOnly = true; + PrivateTmp = true; + PrivateDevices = true; + User = cfg.user; + Group = cfg.group; + }; + }; + }) + (mkIf needToCreateCA { + systemd.services.taskserver-ca = { + wantedBy = [ "taskserver.service" ]; + after = [ "taskserver-init.service" ]; + before = [ "taskserver.service" ]; + description = "Initialize CA for TaskServer"; + serviceConfig.Type = "oneshot"; + serviceConfig.UMask = "0077"; + serviceConfig.PrivateNetwork = true; + serviceConfig.PrivateTmp = true; + + script = '' + silent_certtool() { + if ! output="$("${certtool}" "$@" 2>&1)"; then + echo "GNUTLS certtool invocation failed with output:" >&2 + echo "$output" >&2 + fi + } + + mkdir -m 0700 -p "${cfg.dataDir}/keys" + chown root:root "${cfg.dataDir}/keys" + + if [ ! -e "${cfg.dataDir}/keys/ca.key" ]; then + silent_certtool -p \ + --bits ${toString cfg.pki.auto.bits} \ + --outfile "${cfg.dataDir}/keys/ca.key" + silent_certtool -s \ + --template "${pkgs.writeText "taskserver-ca.template" '' + cn = ${cfg.fqdn} + expiration_days = ${toString cfg.pki.auto.expiration.ca} + cert_signing_key + ca + ''}" \ + --load-privkey "${cfg.dataDir}/keys/ca.key" \ + --outfile "${cfg.dataDir}/keys/ca.cert" + + chgrp "${cfg.group}" "${cfg.dataDir}/keys/ca.cert" + chmod g+r "${cfg.dataDir}/keys/ca.cert" + fi + + if [ ! -e "${cfg.dataDir}/keys/server.key" ]; then + silent_certtool -p \ + --bits ${toString cfg.pki.auto.bits} \ + --outfile "${cfg.dataDir}/keys/server.key" + + silent_certtool -c \ + --template "${pkgs.writeText "taskserver-cert.template" '' + cn = ${cfg.fqdn} + expiration_days = ${toString cfg.pki.auto.expiration.server} + tls_www_server + encryption_key + signing_key + ''}" \ + --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \ + --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \ + --load-privkey "${cfg.dataDir}/keys/server.key" \ + --outfile "${cfg.dataDir}/keys/server.cert" + + chgrp "${cfg.group}" \ + "${cfg.dataDir}/keys/server.key" \ + "${cfg.dataDir}/keys/server.cert" + + chmod g+r \ + "${cfg.dataDir}/keys/server.key" \ + "${cfg.dataDir}/keys/server.cert" + fi + + if [ ! -e "${cfg.dataDir}/keys/server.crl" ]; then + silent_certtool --generate-crl \ + --template "${pkgs.writeText "taskserver-crl.template" '' + expiration_days = ${toString cfg.pki.auto.expiration.crl} + ''}" \ + --load-ca-privkey "${cfg.dataDir}/keys/ca.key" \ + --load-ca-certificate "${cfg.dataDir}/keys/ca.cert" \ + --outfile "${cfg.dataDir}/keys/server.crl" + + chgrp "${cfg.group}" "${cfg.dataDir}/keys/server.crl" + chmod g+r "${cfg.dataDir}/keys/server.crl" + fi + + chmod go+x "${cfg.dataDir}/keys" + ''; + }; + }) + (mkIf (cfg.listenHost != "localhost") { + networking.firewall.allowedTCPPorts = [ cfg.listenPort ]; + }) + { meta.doc = ./taskserver.xml; } + ]; +} diff --git a/nixos/modules/services/misc/taskserver/doc.xml b/nixos/modules/services/misc/taskserver/doc.xml new file mode 100644 index 00000000000..48591129264 --- /dev/null +++ b/nixos/modules/services/misc/taskserver/doc.xml @@ -0,0 +1,144 @@ + + + Taskserver + + + Taskserver is the server component of + Taskwarrior, a free and + open source todo list application. + + + + Upstream documentation: + + + +
+ Configuration + + + Taskserver does all of its authentication via TLS using client + certificates, so you either need to roll your own CA or purchase a + certificate from a known CA, which allows creation of client + certificates. + + These certificates are usually advertised as + server certificates. + + + + So in order to make it easier to handle your own CA, there is a helper + tool called nixos-taskserver which manages the custom + CA along with Taskserver organisations, users and groups. + + + + While the client certificates in Taskserver only authenticate whether a + user is allowed to connect, every user has its own UUID which identifies + it as an entity. + + + + With nixos-taskserver the client certificate is created + along with the UUID of the user, so it handles all of the credentials + needed in order to setup the Taskwarrior client to work with a Taskserver. + +
+ +
+ The nixos-taskserver tool + + + Because Taskserver by default only provides scripts to setup users + imperatively, the nixos-taskserver tool is used for + addition and deletion of organisations along with users and groups defined + by and as well for + imperative set up. + + + + The tool is designed to not interfere if the command is used to manually + set up some organisations, users or groups. + + + + For example if you add a new organisation using + nixos-taskserver org add foo, the organisation is not + modified and deleted no matter what you define in + , even if you're adding + the same organisation in that option. + + + + The tool is modelled to imitate the official taskd + command, documentation for each subcommand can be shown by using the + switch. + +
+
+ Declarative/automatic CA management + + + Everything is done according to what you specify in the module options, + however in order to set up a Taskwarrior client for synchronisation with a + Taskserver instance, you have to transfer the keys and certificates to the + client machine. + + + + This is done using + nixos-taskserver user export $orgname $username which + is printing a shell script fragment to stdout which can either be used + verbatim or adjusted to import the user on the client machine. + + + + For example, let's say you have the following configuration: + +{ + services.taskserver.enable = true; + services.taskserver.fqdn = "server"; + services.taskserver.listenHost = "::"; + services.taskserver.organisations.my-company.users = [ "alice" ]; +} + + This creates an organisation called my-company with the + user alice. + + + + Now in order to import the alice user to another + machine alicebox, all we need to do is something like + this: + +$ ssh server nixos-taskserver user export my-company alice | sh + + Of course, if no SSH daemon is available on the server you can also copy + & paste it directly into a shell. + + + + After this step the user should be set up and you can start synchronising + your tasks for the first time with task sync init on + alicebox. + + + + Subsequent synchronisation requests merely require the command + task sync after that stage. + +
+
+ Manual CA management + + + If you set any options within + , the automatic user and + CA management by the nixos-taskserver is disabled and + you need to create certificates and keys by yourself. + +
+
diff --git a/nixos/modules/services/misc/taskserver/helper-tool.py b/nixos/modules/services/misc/taskserver/helper-tool.py new file mode 100644 index 00000000000..03e7cdf8987 --- /dev/null +++ b/nixos/modules/services/misc/taskserver/helper-tool.py @@ -0,0 +1,673 @@ +import grp +import json +import pwd +import os +import re +import string +import subprocess +import sys + +from contextlib import contextmanager +from shutil import rmtree +from tempfile import NamedTemporaryFile + +import click + +CERTTOOL_COMMAND = "@certtool@" +CERT_BITS = "@certBits@" +CLIENT_EXPIRATION = "@clientExpiration@" +CRL_EXPIRATION = "@crlExpiration@" + +TASKD_COMMAND = "@taskd@" +TASKD_DATA_DIR = "@dataDir@" +TASKD_USER = "@user@" +TASKD_GROUP = "@group@" +FQDN = "@fqdn@" + +CA_KEY = os.path.join(TASKD_DATA_DIR, "keys", "ca.key") +CA_CERT = os.path.join(TASKD_DATA_DIR, "keys", "ca.cert") +CRL_FILE = os.path.join(TASKD_DATA_DIR, "keys", "server.crl") + +RE_CONFIGUSER = re.compile(r'^\s*user\s*=(.*)$') +RE_USERKEY = re.compile(r'New user key: (.+)$', re.MULTILINE) + + +def lazyprop(fun): + """ + Decorator which only evaluates the specified function when accessed. + """ + name = '_lazy_' + fun.__name__ + + @property + def _lazy(self): + val = getattr(self, name, None) + if val is None: + val = fun(self) + setattr(self, name, val) + return val + + return _lazy + + +class TaskdError(OSError): + pass + + +def run_as_taskd_user(): + uid = pwd.getpwnam(TASKD_USER).pw_uid + gid = grp.getgrnam(TASKD_GROUP).gr_gid + os.setgid(gid) + os.setuid(uid) + + +def taskd_cmd(cmd, *args, **kwargs): + """ + Invoke taskd with the specified command with the privileges of the 'taskd' + user and 'taskd' group. + + If 'capture_stdout' is passed as a keyword argument with the value True, + the return value are the contents the command printed to stdout. + """ + capture_stdout = kwargs.pop("capture_stdout", False) + fun = subprocess.check_output if capture_stdout else subprocess.check_call + return fun( + [TASKD_COMMAND, cmd, "--data", TASKD_DATA_DIR] + list(args), + preexec_fn=run_as_taskd_user, + **kwargs + ) + + +def certtool_cmd(*args, **kwargs): + """ + Invoke certtool from GNUTLS and return the output of the command. + + The provided arguments are added to the certtool command and keyword + arguments are added to subprocess.check_output(). + + Note that this will suppress all output of certtool and it will only be + printed whenever there is an unsuccessful return code. + """ + return subprocess.check_output( + [CERTTOOL_COMMAND] + list(args), + preexec_fn=lambda: os.umask(0077), + stderr=subprocess.STDOUT, + **kwargs + ) + + +def label(msg): + if sys.stdout.isatty() or sys.stderr.isatty(): + sys.stderr.write(msg + "\n") + + +def mkpath(*args): + return os.path.join(TASKD_DATA_DIR, "orgs", *args) + + +def mark_imperative(*path): + """ + Mark the specified path as being imperatively managed by creating an empty + file called ".imperative", so that it doesn't interfere with the + declarative configuration. + """ + open(os.path.join(mkpath(*path), ".imperative"), 'a').close() + + +def is_imperative(*path): + """ + Check whether the given path is marked as imperative, see mark_imperative() + for more information. + """ + full_path = [] + for component in path: + full_path.append(component) + if os.path.exists(os.path.join(mkpath(*full_path), ".imperative")): + return True + return False + + +def fetch_username(org, key): + for line in open(mkpath(org, "users", key, "config"), "r"): + match = RE_CONFIGUSER.match(line) + if match is None: + continue + return match.group(1).strip() + return None + + +@contextmanager +def create_template(contents): + """ + Generate a temporary file with the specified contents as a list of strings + and yield its path as the context. + """ + template = NamedTemporaryFile(mode="w", prefix="certtool-template") + template.writelines(map(lambda l: l + "\n", contents)) + template.flush() + yield template.name + template.close() + + +def generate_key(org, user): + basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user) + if os.path.exists(basedir): + raise OSError("Keyfile directory for {} already exists.".format(user)) + + privkey = os.path.join(basedir, "private.key") + pubcert = os.path.join(basedir, "public.cert") + + try: + os.makedirs(basedir, mode=0700) + + certtool_cmd("-p", "--bits", CERT_BITS, "--outfile", privkey) + + template_data = [ + "organization = {0}".format(org), + "cn = {}".format(FQDN), + "expiration_days = {}".format(CLIENT_EXPIRATION), + "tls_www_client", + "encryption_key", + "signing_key" + ] + + with create_template(template_data) as template: + certtool_cmd( + "-c", + "--load-privkey", privkey, + "--load-ca-privkey", CA_KEY, + "--load-ca-certificate", CA_CERT, + "--template", template, + "--outfile", pubcert + ) + except: + rmtree(basedir) + raise + + +def revoke_key(org, user): + basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user) + if not os.path.exists(basedir): + raise OSError("Keyfile directory for {} doesn't exist.".format(user)) + + pubcert = os.path.join(basedir, "public.cert") + + expiration = "expiration_days = {}".format(CRL_EXPIRATION) + + with create_template([expiration]) as template: + oldcrl = NamedTemporaryFile(mode="wb", prefix="old-crl") + oldcrl.write(open(CRL_FILE, "rb").read()) + oldcrl.flush() + certtool_cmd( + "--generate-crl", + "--load-crl", oldcrl.name, + "--load-ca-privkey", CA_KEY, + "--load-ca-certificate", CA_CERT, + "--load-certificate", pubcert, + "--template", template, + "--outfile", CRL_FILE + ) + oldcrl.close() + rmtree(basedir) + + +def is_key_line(line, match): + return line.startswith("---") and line.lstrip("- ").startswith(match) + + +def getkey(*args): + path = os.path.join(TASKD_DATA_DIR, "keys", *args) + buf = [] + for line in open(path, "r"): + if len(buf) == 0: + if is_key_line(line, "BEGIN"): + buf.append(line) + continue + + buf.append(line) + + if is_key_line(line, "END"): + return ''.join(buf) + raise IOError("Unable to get key from {}.".format(path)) + + +def mktaskkey(cfg, path, keydata): + heredoc = 'cat > "{}" <nest("initialize client for user $user", sub { + $client->succeed( + (su $user, "rm -rf /home/$user/.task"), + (su $user, "task rc.confirmation=no config confirmation no") + ); + + my $exportinfo = $server->succeed( + "nixos-taskserver user export $org $user" + ); + + $exportinfo =~ s/'/'\\'''/g; + + $client->nest("importing taskwarrior configuration", sub { + my $cmd = su $user, "eval '$exportinfo' >&2"; + my ($status, $out) = $client->execute_($cmd); + if ($status != 0) { + $client->log("output: $out"); + die "command `$cmd' did not succeed (exit code $status)\n"; + } + }); + + $client->succeed(su $user, + "task config taskd.server server:${portStr} >&2" + ); + + $client->succeed(su $user, "task sync init >&2"); + }); + } + } + + sub restartServer { + $server->succeed("systemctl restart taskserver.service"); + $server->waitForOpenPort(${portStr}); + } + + sub readdImperativeUser { + $server->nest("(re-)add imperative user bar", sub { + $server->execute("nixos-taskserver org remove imperativeOrg"); + $server->succeed( + "nixos-taskserver org add imperativeOrg", + "nixos-taskserver user add imperativeOrg bar" + ); + setupClientsFor "imperativeOrg", "bar"; + }); + } + + sub testSync ($) { + my $user = $_[0]; + subtest "sync for user $user", sub { + $client1->succeed(su $user, "task add foo >&2"); + $client1->succeed(su $user, "task sync >&2"); + $client2->fail(su $user, "task list >&2"); + $client2->succeed(su $user, "task sync >&2"); + $client2->succeed(su $user, "task list >&2"); + }; + } + + sub checkClientCert ($) { + my $user = $_[0]; + my $cmd = "gnutls-cli". + " --x509cafile=/home/$user/.task/keys/ca.cert". + " --x509keyfile=/home/$user/.task/keys/private.key". + " --x509certfile=/home/$user/.task/keys/public.cert". + " --port=${portStr} server < /dev/null"; + return su $user, $cmd; + } + + startAll; + + $server->waitForUnit("taskserver.service"); + + $server->succeed( + "nixos-taskserver user list testOrganisation | grep -qxF alice", + "nixos-taskserver user list testOrganisation | grep -qxF foo", + "nixos-taskserver user list anotherOrganisation | grep -qxF bob" + ); + + $server->waitForOpenPort(${portStr}); + + $client1->waitForUnit("multi-user.target"); + $client2->waitForUnit("multi-user.target"); + + setupClientsFor "testOrganisation", "alice"; + setupClientsFor "testOrganisation", "foo"; + setupClientsFor "anotherOrganisation", "bob"; + + testSync $_ for ("alice", "bob", "foo"); + + $server->fail("nixos-taskserver user add imperativeOrg bar"); + readdImperativeUser; + + testSync "bar"; + + subtest "checking certificate revocation of user bar", sub { + $client1->succeed(checkClientCert "bar"); + + $server->succeed("nixos-taskserver user remove imperativeOrg bar"); + restartServer; + + $client1->fail(checkClientCert "bar"); + + $client1->succeed(su "bar", "task add destroy everything >&2"); + $client1->fail(su "bar", "task sync >&2"); + }; + + readdImperativeUser; + + subtest "checking certificate revocation of org imperativeOrg", sub { + $client1->succeed(checkClientCert "bar"); + + $server->succeed("nixos-taskserver org remove imperativeOrg"); + restartServer; + + $client1->fail(checkClientCert "bar"); + + $client1->succeed(su "bar", "task add destroy even more >&2"); + $client1->fail(su "bar", "task sync >&2"); + }; + + readdImperativeUser; + + subtest "check whether declarative config overrides user bar", sub { + restartServer; + testSync "bar"; + }; + ''; +}