diff options
Diffstat (limited to 'nixpkgs/nixos/modules/services/misc/taskserver')
3 files changed, 1391 insertions, 0 deletions
diff --git a/nixpkgs/nixos/modules/services/misc/taskserver/default.nix b/nixpkgs/nixos/modules/services/misc/taskserver/default.nix new file mode 100644 index 000000000000..8a57277fafe7 --- /dev/null +++ b/nixpkgs/nixos/modules/services/misc/taskserver/default.nix @@ -0,0 +1,568 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.taskserver; + + taskd = "${pkgs.taskserver}/bin/taskd"; + + 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 val == null 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 (x: x == null) (findPkiDefinitions [] manualPkiOptions); + + orgOptions = { ... }: { + 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. + ''; + }; + }; + + certtool = "${pkgs.gnutls.bin}/bin/certtool"; + + nixos-taskserver = pkgs.pythonPackages.buildPythonApplication { + name = "nixos-taskserver"; + + src = pkgs.runCommand "nixos-taskserver-src" { preferLocalBuild = true; } '' + 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; + isAutoConfig = if needToCreateCA then "True" else "False"; + }}" > "$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; + 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; either str (listOf 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; either str (listOf 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; + + config = mkOption { + type = types.attrs; + example.client.cert = "/tmp/debugging.cert"; + description = '' + Configuration options to pass to Taskserver. + + The options here are the same as described in <citerefentry> + <refentrytitle>taskdrc</refentrytitle> + <manvolnum>5</manvolnum> + </citerefentry>, but with one difference: + + The <literal>server</literal> option is + <literal>server.listen</literal> here, because the + <literal>server</literal> option would collide with other options + like <literal>server.cert</literal> and we would run in a type error + (attribute set versus string). + + Nix types like integers or booleans are automatically converted to + the right values Taskserver would expect. + ''; + apply = let + mkKey = path: if path == ["server" "listen"] then "server" + else concatStringsSep "." path; + recurse = path: attrs: let + mapper = name: val: let + newPath = path ++ [ name ]; + scalar = if val == true then "true" + else if val == false then "false" + else toString val; + in if isAttrs val then recurse newPath val + else [ "${mkKey newPath}=${scalar}" ]; + in concatLists (mapAttrsToList mapper attrs); + in recurse []; + }; + }; + }; + + imports = [ + (mkRemovedOptionModule ["services" "taskserver" "extraConfig"] '' + This option was removed in favor of `services.taskserver.config` with + different semantics (it's now a list of attributes instead of lines). + + Please look up the documentation of `services.taskserver.config' to get + more information about the new way to pass additional configuration + options. + '') + ]; + + config = mkMerge [ + (mkIf cfg.enable { + environment.systemPackages = [ 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; + }; + + services.taskserver.config = { + # systemd related + daemon = false; + log = "-"; + + # logging + debug = cfg.debug; + ip.log = cfg.ipLog; + + # general + ciphers = cfg.ciphers; + confirmation = cfg.confirmation; + extensions = cfg.extensions; + queue.size = cfg.queueSize; + request.limit = cfg.requestLimit; + + # client + client.allow = cfg.allowedClientIDs; + client.deny = cfg.disallowedClientIDs; + + # server + trust = cfg.trust; + server = { + listen = "${cfg.listenHost}:${toString cfg.listenPort}"; + } // (if needToCreateCA then { + cert = "${cfg.dataDir}/keys/server.cert"; + key = "${cfg.dataDir}/keys/server.key"; + crl = "${cfg.dataDir}/keys/server.crl"; + } else { + cert = "${cfg.pki.manual.server.cert}"; + key = "${cfg.pki.manual.server.key}"; + ${mapNullable (_: "crl") cfg.pki.manual.server.crl} = "${cfg.pki.manual.server.crl}"; + }); + + ca.cert = if needToCreateCA then "${cfg.dataDir}/keys/ca.cert" + else "${cfg.pki.manual.ca.cert}"; + }; + + 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 + 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 = let + mkCfgFlag = flag: escapeShellArg "--${flag}"; + cfgFlags = concatMapStringsSep " " mkCfgFlag cfg.config; + in "@${taskd} taskd server ${cfgFlags}"; + ExecReload = "${pkgs.coreutils}/bin/kill -USR1 $MAINPID"; + Restart = "on-failure"; + PermissionsStartOnly = true; + PrivateTmp = true; + PrivateDevices = true; + User = cfg.user; + Group = cfg.group; + }; + }; + }) + (mkIf (cfg.enable && 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.enable && cfg.listenHost != "localhost") { + networking.firewall.allowedTCPPorts = [ cfg.listenPort ]; + }) + ]; + + meta.doc = ./doc.xml; +} diff --git a/nixpkgs/nixos/modules/services/misc/taskserver/doc.xml b/nixpkgs/nixos/modules/services/misc/taskserver/doc.xml new file mode 100644 index 000000000000..5656bb85b373 --- /dev/null +++ b/nixpkgs/nixos/modules/services/misc/taskserver/doc.xml @@ -0,0 +1,135 @@ +<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 xml:id="module-services-taskserver-configuration"> + <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 xml:id="module-services-taskserver-nixos-taskserver-tool"> + <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 <xref linkend="opt-services.taskserver.organisations"/> 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 xml:id="module-services-taskserver-declarative-ca-management"> + <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> +{ + <xref linkend="opt-services.taskserver.enable"/> = true; + <xref linkend="opt-services.taskserver.fqdn"/> = "server"; + <xref linkend="opt-services.taskserver.listenHost"/> = "::"; + <link linkend="opt-services.taskserver.organisations._name_.users">services.taskserver.organisations.my-company.users</link> = [ "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> +<prompt>$ </prompt>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 xml:id="module-services-taskserver-manual-ca-management"> + <title>Manual CA management</title> + + <para> + If you set any options within + <link linkend="opt-services.taskserver.pki.manual.ca.cert">service.taskserver.pki.manual</link>.*, + <command>nixos-taskserver</command> won't issue certificates, but you can + still use it for adding or removing user accounts. + </para> + </section> +</chapter> diff --git a/nixpkgs/nixos/modules/services/misc/taskserver/helper-tool.py b/nixpkgs/nixos/modules/services/misc/taskserver/helper-tool.py new file mode 100644 index 000000000000..22a3d8d5311b --- /dev/null +++ b/nixpkgs/nixos/modules/services/misc/taskserver/helper-tool.py @@ -0,0 +1,688 @@ +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 + +IS_AUTO_CONFIG = @isAutoConfig@ # NOQA +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): + if not IS_AUTO_CONFIG: + msg = "Automatic PKI handling is disabled, you need to " \ + "manually issue a client certificate for user {}.\n" + sys.stderr.write(msg.format(user)) + return + + 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): + 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 = [] + + if IS_AUTO_CONFIG: + 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" + + 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) + ] + + script.append( + "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 + """ + if not IS_AUTO_CONFIG: + return + 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(user, 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() |