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";
+ };
+ '';
+}