Merge pull request #14476 (taskserver)

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

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

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

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

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

aszlig 9ed9e268 0876c2f4

Changed files
+1530
nixos
doc
manual
modules
misc
services
tests
+1
nixos/doc/manual/configuration/configuration.xml
···
<!-- FIXME: auto-include NixOS module docs -->
<xi:include href="postgresql.xml" />
<xi:include href="gitlab.xml" />
<xi:include href="acme.xml" />
<xi:include href="input-methods.xml" />
···
<!-- 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" />
+1
nixos/doc/manual/default.nix
···
chmod -R u+w .
cp ${../../modules/services/databases/postgresql.xml} configuration/postgresql.xml
cp ${../../modules/services/misc/gitlab.xml} configuration/gitlab.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
···
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
+2
nixos/modules/misc/ids.nix
···
syncthing = 237;
mfi = 238;
caddy = 239;
# When adding a uid, make sure it doesn't match an existing gid. And don't use uids above 399!
···
syncthing = 237;
#mfi = 238; # unused
caddy = 239;
# When adding a gid, make sure it doesn't match an existing
# uid. Users and groups with the same name should have equal
···
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!
···
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
+1
nixos/modules/module-list.nix
···
./services/misc/sundtek.nix
./services/misc/svnserve.nix
./services/misc/synergy.nix
./services/misc/uhub.nix
./services/misc/zookeeper.nix
./services/monitoring/apcupsd.nix
···
./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
+541
nixos/modules/services/misc/taskserver/default.nix
···
···
+
{ 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; }
+
];
+
}
+144
nixos/modules/services/misc/taskserver/doc.xml
···
···
+
<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
+
&amp; 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>
+673
nixos/modules/services/misc/taskserver/helper-tool.py
···
···
+
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()
+1
nixos/release.nix
···
tests.sddm = callTest tests/sddm.nix {};
tests.sddm-kde5 = callTest tests/sddm-kde5.nix {};
tests.simple = callTest tests/simple.nix {};
tests.tomcat = callTest tests/tomcat.nix {};
tests.udisks2 = callTest tests/udisks2.nix {};
tests.virtualbox = callSubTests tests/virtualbox.nix { system = "x86_64-linux"; };
···
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"; };
+166
nixos/tests/taskserver.nix
···
···
+
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";
+
};
+
'';
+
}