diff options
-rw-r--r-- | nixos/doc/manual/configuration/configuration.xml | 1 | ||||
-rw-r--r-- | nixos/doc/manual/default.nix | 1 | ||||
-rw-r--r-- | nixos/modules/misc/ids.nix | 2 | ||||
-rw-r--r-- | nixos/modules/module-list.nix | 1 | ||||
-rw-r--r-- | nixos/modules/services/misc/taskserver/default.nix | 541 | ||||
-rw-r--r-- | nixos/modules/services/misc/taskserver/doc.xml | 144 | ||||
-rw-r--r-- | nixos/modules/services/misc/taskserver/helper-tool.py | 673 | ||||
-rw-r--r-- | nixos/release.nix | 1 | ||||
-rw-r--r-- | nixos/tests/taskserver.nix | 166 |
9 files changed, 1530 insertions, 0 deletions
diff --git a/nixos/doc/manual/configuration/configuration.xml b/nixos/doc/manual/configuration/configuration.xml index fb3f1498a9b7..cfa5619938bb 100644 --- a/nixos/doc/manual/configuration/configuration.xml +++ b/nixos/doc/manual/configuration/configuration.xml @@ -27,6 +27,7 @@ effect after you run <command>nixos-rebuild</command>.</para> <!-- FIXME: auto-include NixOS module docs --> <xi:include href="postgresql.xml" /> <xi:include href="gitlab.xml" /> +<xi:include href="taskserver.xml" /> <xi:include href="acme.xml" /> <xi:include href="input-methods.xml" /> diff --git a/nixos/doc/manual/default.nix b/nixos/doc/manual/default.nix index 69da1f948829..86a39322ba51 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 c3bade2ee6b9..86332d719495 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 5b3d19e0bbaf..6384f8a3d9aa 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 000000000000..0b86b42f2cc7 --- /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 + '' + <note><para> + Setting this option will prevent automatic CA creation and handling. + </para></note> + ''; + }; + + 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} + + <note><para> + This option is for the automatically handled CA and will be ignored if any + of the <option>services.taskserver.pki.manual.*</option> options are set. + </para></note> + ''; + + 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 <literal>null</literal> 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/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" <<EOF + from setuptools import setup + setup(name="nixos-taskserver", + py_modules=["main"], + install_requires=["Click"], + entry_points="[console_scripts]\\nnixos-taskserver=main:cli") + EOF + ''; + + propagatedBuildInputs = [ pkgs.pythonPackages.click ]; + }; + +in { + options = { + services.taskserver = { + enable = mkOption { + type = types.bool; + default = false; + example = true; + description = '' + Whether to enable the Taskwarrior server. + + More instructions about NixOS in conjuction with Taskserver can be + found in the NixOS manual at + <olink targetdoc="manual" targetptr="module-taskserver"/>. + ''; + }; + + 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 <link xlink:href="${url}"/> 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 <option>users</option> and + <option>groups</option>. + ''; + }; + + 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 <citerefentry> + <refentrytitle>listen</refentrytitle> + <manvolnum>2</manvolnum> + </citerefentry>. + ''; + }; + + 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 <literal>task 2.3.0</literal>). + + The values <literal>all</literal> or <literal>none</literal> have + special meaning. Overidden by any entry in the option + <option>services.taskserver.disallowedClientIDs</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 <literal>task 2.3.0</literal>). + + The values <literal>all</literal> or <literal>none</literal> have + special meaning. Any entry here overrides those in + <option>services.taskserver.allowedClientIDs</option>. + ''; + }; + + 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 <literal>localhost</literal> the + port defined by <option>listenPort</option> is automatically added to + <option>networking.firewall.allowedTCPPorts</option>. + ''; + }; + + 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 <literal>allow all</literal> performs no client + certificate validation. This is not recommended. The value + <literal>strict</literal> 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 000000000000..48591129264a --- /dev/null +++ b/nixos/modules/services/misc/taskserver/doc.xml @@ -0,0 +1,144 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + version="5.0" + xml:id="module-taskserver"> + + <title>Taskserver</title> + + <para> + Taskserver is the server component of + <link xlink:href="https://taskwarrior.org/">Taskwarrior</link>, a free and + open source todo list application. + </para> + + <para> + <emphasis>Upstream documentation:</emphasis> + <link xlink:href="https://taskwarrior.org/docs/#taskd"/> + </para> + + <section> + <title>Configuration</title> + + <para> + 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 + <quote>server certificates</quote>. + </para> + + <para> + So in order to make it easier to handle your own CA, there is a helper + tool called <command>nixos-taskserver</command> which manages the custom + CA along with Taskserver organisations, users and groups. + </para> + + <para> + 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. + </para> + + <para> + With <command>nixos-taskserver</command> 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. + </para> + </section> + + <section> + <title>The nixos-taskserver tool</title> + + <para> + Because Taskserver by default only provides scripts to setup users + imperatively, the <command>nixos-taskserver</command> tool is used for + addition and deletion of organisations along with users and groups defined + by <option>services.taskserver.organisations</option> and as well for + imperative set up. + </para> + + <para> + The tool is designed to not interfere if the command is used to manually + set up some organisations, users or groups. + </para> + + <para> + For example if you add a new organisation using + <command>nixos-taskserver org add foo</command>, the organisation is not + modified and deleted no matter what you define in + <option>services.taskserver.organisations</option>, even if you're adding + the same organisation in that option. + </para> + + <para> + The tool is modelled to imitate the official <command>taskd</command> + command, documentation for each subcommand can be shown by using the + <option>--help</option> switch. + </para> + </section> + <section> + <title>Declarative/automatic CA management</title> + + <para> + 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. + </para> + + <para> + This is done using + <command>nixos-taskserver user export $orgname $username</command> 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. + </para> + + <para> + For example, let's say you have the following configuration: +<screen> +{ + services.taskserver.enable = true; + services.taskserver.fqdn = "server"; + services.taskserver.listenHost = "::"; + services.taskserver.organisations.my-company.users = [ "alice" ]; +} +</screen> + This creates an organisation called <literal>my-company</literal> with the + user <literal>alice</literal>. + </para> + + <para> + Now in order to import the <literal>alice</literal> user to another + machine <literal>alicebox</literal>, all we need to do is something like + this: +<screen> +$ ssh server nixos-taskserver user export my-company alice | sh +</screen> + Of course, if no SSH daemon is available on the server you can also copy + & paste it directly into a shell. + </para> + + <para> + After this step the user should be set up and you can start synchronising + your tasks for the first time with <command>task sync init</command> on + <literal>alicebox</literal>. + </para> + + <para> + Subsequent synchronisation requests merely require the command + <command>task sync</command> after that stage. + </para> + </section> + <section> + <title>Manual CA management</title> + + <para> + If you set any options within + <option>service.taskserver.pki.manual.*</option>, the automatic user and + CA management by the <command>nixos-taskserver</command> is disabled and + you need to create certificates and keys by yourself. + </para> + </section> +</chapter> 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 000000000000..03e7cdf8987a --- /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 > "{}" <<EOF\n{}EOF'.format(path, keydata) + cmd = 'task config taskd.{} -- "{}"'.format(cfg, path) + return heredoc + "\n" + cmd + + +class User(object): + def __init__(self, org, name, key): + self.__org = org + self.name = name + self.key = key + + def export(self): + pubcert = getkey(self.__org, self.name, "public.cert") + privkey = getkey(self.__org, self.name, "private.key") + cacert = getkey("ca.cert") + + keydir = "${TASKDATA:-$HOME/.task}/keys" + + credentials = '/'.join([self.__org, self.name, self.key]) + allow_unquoted = string.ascii_letters + string.digits + "/-_." + if not all((c in allow_unquoted) for c in credentials): + credentials = "'" + credentials.replace("'", r"'\''") + "'" + + script = [ + "umask 0077", + 'mkdir -p "{}"'.format(keydir), + mktaskkey("certificate", os.path.join(keydir, "public.cert"), + pubcert), + mktaskkey("key", os.path.join(keydir, "private.key"), privkey), + mktaskkey("ca", os.path.join(keydir, "ca.cert"), cacert), + "task config taskd.credentials -- {}".format(credentials) + ] + + return "\n".join(script) + "\n" + + +class Group(object): + def __init__(self, org, name): + self.__org = org + self.name = name + + +class Organisation(object): + def __init__(self, name, ignore_imperative): + self.name = name + self.ignore_imperative = ignore_imperative + + def add_user(self, name): + """ + Create a new user along with a certificate and key. + + Returns a 'User' object or None if the user already exists. + """ + if self.ignore_imperative and is_imperative(self.name): + return None + if name not in self.users.keys(): + output = taskd_cmd("add", "user", self.name, name, + capture_stdout=True) + key = RE_USERKEY.search(output) + if key is None: + msg = "Unable to find key while creating user {}." + raise TaskdError(msg.format(name)) + + generate_key(self.name, name) + newuser = User(self.name, name, key.group(1)) + self._lazy_users[name] = newuser + return newuser + return None + + def del_user(self, name): + """ + Delete a user and revoke its keys. + """ + if name in self.users.keys(): + user = self.get_user(name) + if self.ignore_imperative and \ + is_imperative(self.name, "users", user.key): + return + + # Work around https://bug.tasktools.org/browse/TD-40: + rmtree(mkpath(self.name, "users", user.key)) + + revoke_key(self.name, name) + del self._lazy_users[name] + + def add_group(self, name): + """ + Create a new group. + + Returns a 'Group' object or None if the group already exists. + """ + if self.ignore_imperative and is_imperative(self.name): + return None + if name not in self.groups.keys(): + taskd_cmd("add", "group", self.name, name) + newgroup = Group(self.name, name) + self._lazy_groups[name] = newgroup + return newgroup + return None + + def del_group(self, name): + """ + Delete a group. + """ + if name in self.users.keys(): + if self.ignore_imperative and \ + is_imperative(self.name, "groups", name): + return + taskd_cmd("remove", "group", self.name, name) + del self._lazy_groups[name] + + def get_user(self, name): + return self.users.get(name) + + @lazyprop + def users(self): + result = {} + for key in os.listdir(mkpath(self.name, "users")): + user = fetch_username(self.name, key) + if user is not None: + result[user] = User(self.name, user, key) + return result + + def get_group(self, name): + return self.groups.get(name) + + @lazyprop + def groups(self): + result = {} + for group in os.listdir(mkpath(self.name, "groups")): + result[group] = Group(self.name, group) + return result + + +class Manager(object): + def __init__(self, ignore_imperative=False): + """ + Instantiates an organisations manager. + + If ignore_imperative is True, all actions that modify data are checked + whether they're created imperatively and if so, they will result in no + operation. + """ + self.ignore_imperative = ignore_imperative + + def add_org(self, name): + """ + Create a new organisation. + + Returns an 'Organisation' object or None if the organisation already + exists. + """ + if name not in self.orgs.keys(): + taskd_cmd("add", "org", name) + neworg = Organisation(name, self.ignore_imperative) + self._lazy_orgs[name] = neworg + return neworg + return None + + def del_org(self, name): + """ + Delete and revoke keys of an organisation with all its users and + groups. + """ + org = self.get_org(name) + if org is not None: + if self.ignore_imperative and is_imperative(name): + return + for user in org.users.keys(): + org.del_user(user) + for group in org.groups.keys(): + org.del_group(group) + taskd_cmd("remove", "org", name) + del self._lazy_orgs[name] + + def get_org(self, name): + return self.orgs.get(name) + + @lazyprop + def orgs(self): + result = {} + for org in os.listdir(mkpath()): + result[org] = Organisation(org, self.ignore_imperative) + return result + + +class OrganisationType(click.ParamType): + name = 'organisation' + + def convert(self, value, param, ctx): + org = Manager().get_org(value) + if org is None: + self.fail("Organisation {} does not exist.".format(value)) + return org + +ORGANISATION = OrganisationType() + + +@click.group() +@click.pass_context +def cli(ctx): + """ + Manage Taskserver users and certificates + """ + for path in (CA_KEY, CA_CERT, CRL_FILE): + if not os.path.exists(path): + msg = "CA setup not done or incomplete, missing file {}." + ctx.fail(msg.format(path)) + + +@cli.group("org") +def org_cli(): + """ + Manage organisations + """ + pass + + +@cli.group("user") +def user_cli(): + """ + Manage users + """ + pass + + +@cli.group("group") +def group_cli(): + """ + Manage groups + """ + pass + + +@user_cli.command("list") +@click.argument("organisation", type=ORGANISATION) +def list_users(organisation): + """ + List all users belonging to the specified organisation. + """ + label("The following users exists for {}:".format(organisation.name)) + for user in organisation.users.values(): + sys.stdout.write(user.name + "\n") + + +@group_cli.command("list") +@click.argument("organisation", type=ORGANISATION) +def list_groups(organisation): + """ + List all users belonging to the specified organisation. + """ + label("The following users exists for {}:".format(organisation.name)) + for group in organisation.groups.values(): + sys.stdout.write(group.name + "\n") + + +@org_cli.command("list") +def list_orgs(): + """ + List available organisations + """ + label("The following organisations exist:") + for org in Manager().orgs: + sys.stdout.write(org.name + "\n") + + +@user_cli.command("getkey") +@click.argument("organisation", type=ORGANISATION) +@click.argument("user") +def get_uuid(organisation, user): + """ + Get the UUID of the specified user belonging to the specified organisation. + """ + userobj = organisation.get_user(user) + if userobj is None: + msg = "User {} doesn't exist in organisation {}." + sys.exit(msg.format(userobj.name, organisation.name)) + + label("User {} has the following UUID:".format(userobj.name)) + sys.stdout.write(user.key + "\n") + + +@user_cli.command("export") +@click.argument("organisation", type=ORGANISATION) +@click.argument("user") +def export_user(organisation, user): + """ + Export user of the specified organisation as a series of shell commands + that can be used on the client side to easily import the certificates. + + Note that the private key will be exported as well, so use this with care! + """ + userobj = organisation.get_user(user) + if userobj is None: + msg = "User {} doesn't exist in organisation {}." + sys.exit(msg.format(userobj.name, organisation.name)) + + sys.stdout.write(userobj.export()) + + +@org_cli.command("add") +@click.argument("name") +def add_org(name): + """ + Create an organisation with the specified name. + """ + if os.path.exists(mkpath(name)): + msg = "Organisation with name {} already exists." + sys.exit(msg.format(name)) + + taskd_cmd("add", "org", name) + mark_imperative(name) + + +@org_cli.command("remove") +@click.argument("name") +def del_org(name): + """ + Delete the organisation with the specified name. + + All of the users and groups will be deleted as well and client certificates + will be revoked. + """ + Manager().del_org(name) + msg = ("Organisation {} deleted. Be sure to restart the Taskserver" + " using 'systemctl restart taskserver.service' in order for" + " the certificate revocation to apply.") + click.echo(msg.format(name), err=True) + + +@user_cli.command("add") +@click.argument("organisation", type=ORGANISATION) +@click.argument("user") +def add_user(organisation, user): + """ + Create a user for the given organisation along with a client certificate + and print the key of the new user. + + The client certificate along with it's public key can be shown via the + 'user export' subcommand. + """ + userobj = organisation.add_user(user) + if userobj is None: + msg = "User {} already exists in organisation {}." + sys.exit(msg.format(user, organisation)) + else: + mark_imperative(organisation.name, "users", userobj.key) + + +@user_cli.command("remove") +@click.argument("organisation", type=ORGANISATION) +@click.argument("user") +def del_user(organisation, user): + """ + Delete a user from the given organisation. + + This will also revoke the client certificate of the given user. + """ + organisation.del_user(user) + msg = ("User {} deleted. Be sure to restart the Taskserver using" + " 'systemctl restart taskserver.service' in order for the" + " certificate revocation to apply.") + click.echo(msg.format(user), err=True) + + +@group_cli.command("add") +@click.argument("organisation", type=ORGANISATION) +@click.argument("group") +def add_group(organisation, group): + """ + Create a group for the given organisation. + """ + groupobj = organisation.add_group(group) + if groupobj is None: + msg = "Group {} already exists in organisation {}." + sys.exit(msg.format(group, organisation)) + else: + mark_imperative(organisation.name, "groups", groupobj.name) + + +@group_cli.command("remove") +@click.argument("organisation", type=ORGANISATION) +@click.argument("group") +def del_group(organisation, group): + """ + Delete a group from the given organisation. + """ + organisation.del_group(group) + click("Group {} deleted.".format(group), err=True) + + +def add_or_delete(old, new, add_fun, del_fun): + """ + Given an 'old' and 'new' list, figure out the intersections and invoke + 'add_fun' against every element that is not in the 'old' list and 'del_fun' + against every element that is not in the 'new' list. + + Returns a tuple where the first element is the list of elements that were + added and the second element consisting of elements that were deleted. + """ + old_set = set(old) + new_set = set(new) + to_delete = old_set - new_set + to_add = new_set - old_set + for elem in to_delete: + del_fun(elem) + for elem in to_add: + add_fun(elem) + return to_add, to_delete + + +@cli.command("process-json") +@click.argument('json-file', type=click.File('rb')) +def process_json(json_file): + """ + Create and delete users, groups and organisations based on a JSON file. + + The structure of this file is exactly the same as the + 'services.taskserver.organisations' option of the NixOS module and is used + for declaratively adding and deleting users. + + Hence this subcommand is not recommended outside of the scope of the NixOS + module. + """ + data = json.load(json_file) + + mgr = Manager(ignore_imperative=True) + add_or_delete(mgr.orgs.keys(), data.keys(), mgr.add_org, mgr.del_org) + + for org in mgr.orgs.values(): + if is_imperative(org.name): + continue + add_or_delete(org.users.keys(), data[org.name]['users'], + org.add_user, org.del_user) + add_or_delete(org.groups.keys(), data[org.name]['groups'], + org.add_group, org.del_group) + + +if __name__ == '__main__': + cli() diff --git a/nixos/release.nix b/nixos/release.nix index 8a01b2685a78..2bccef1fd34b 100644 --- a/nixos/release.nix +++ b/nixos/release.nix @@ -253,6 +253,7 @@ in rec { tests.sddm = callTest tests/sddm.nix {}; tests.sddm-kde5 = callTest tests/sddm-kde5.nix {}; tests.simple = callTest tests/simple.nix {}; + tests.taskserver = callTest tests/taskserver.nix {}; tests.tomcat = callTest tests/tomcat.nix {}; tests.udisks2 = callTest tests/udisks2.nix {}; tests.virtualbox = callSubTests tests/virtualbox.nix { system = "x86_64-linux"; }; diff --git a/nixos/tests/taskserver.nix b/nixos/tests/taskserver.nix new file mode 100644 index 000000000000..d770b20a7757 --- /dev/null +++ b/nixos/tests/taskserver.nix @@ -0,0 +1,166 @@ +import ./make-test.nix { + name = "taskserver"; + + nodes = rec { + server = { + services.taskserver.enable = true; + services.taskserver.listenHost = "::"; + services.taskserver.fqdn = "server"; + services.taskserver.organisations = { + testOrganisation.users = [ "alice" "foo" ]; + anotherOrganisation.users = [ "bob" ]; + }; + }; + + client1 = { pkgs, ... }: { + environment.systemPackages = [ pkgs.taskwarrior pkgs.gnutls ]; + users.users.alice.isNormalUser = true; + users.users.bob.isNormalUser = true; + users.users.foo.isNormalUser = true; + users.users.bar.isNormalUser = true; + }; + + client2 = client1; + }; + + testScript = { nodes, ... }: let + cfg = nodes.server.config.services.taskserver; + portStr = toString cfg.listenPort; + in '' + sub su ($$) { + my ($user, $cmd) = @_; + my $esc = $cmd =~ s/'/'\\${"'"}'/gr; + return "su - $user -c '$esc'"; + } + + sub setupClientsFor ($$) { + my ($org, $user) = @_; + + for my $client ($client1, $client2) { + $client->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"; + }; + ''; +} |