Merge pull request #120440 from dotlambda/radicale-settings

nixos/radicale: add settings option
This commit is contained in:
Robert Schütz 2021-05-14 15:37:26 +02:00 committed by GitHub
commit e611d663f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 281 additions and 168 deletions

View File

@ -715,6 +715,20 @@ environment.systemPackages = [
The <package>yadm</package> dotfile manager has been updated from 2.x to 3.x, which has new (XDG) default locations for some data/state files. Most yadm commands will fail and print a legacy path warning (which describes how to upgrade/migrate your repository). If you have scripts, daemons, scheduled jobs, shell profiles, etc. that invoke yadm, expect them to fail or misbehave until you perform this migration and prepare accordingly. The <package>yadm</package> dotfile manager has been updated from 2.x to 3.x, which has new (XDG) default locations for some data/state files. Most yadm commands will fail and print a legacy path warning (which describes how to upgrade/migrate your repository). If you have scripts, daemons, scheduled jobs, shell profiles, etc. that invoke yadm, expect them to fail or misbehave until you perform this migration and prepare accordingly.
</para> </para>
</listitem> </listitem>
<listitem>
<para>
Instead of determining <option>services.radicale.package</option>
automatically based on <option>system.stateVersion</option>, the latest
version is always used because old versions are not officially supported.
</para>
<para>
Furthermore, Radicale's systemd unit was hardened which might break some
deployments. In particular, a non-default
<literal>filesystem_folder</literal> has to be added to
<option>systemd.services.radicale.serviceConfig.ReadWritePaths</option> if
the deprecated <option>services.radicale.config</option> is used.
</para>
</listitem>
</itemizedlist> </itemizedlist>
</section> </section>

View File

@ -3,56 +3,103 @@
with lib; with lib;
let let
cfg = config.services.radicale; cfg = config.services.radicale;
confFile = pkgs.writeText "radicale.conf" cfg.config; format = pkgs.formats.ini {
listToValue = concatMapStringsSep ", " (generators.mkValueStringDefault { });
defaultPackage = if versionAtLeast config.system.stateVersion "20.09" then {
pkg = pkgs.radicale3;
text = "pkgs.radicale3";
} else if versionAtLeast config.system.stateVersion "17.09" then {
pkg = pkgs.radicale2;
text = "pkgs.radicale2";
} else {
pkg = pkgs.radicale1;
text = "pkgs.radicale1";
}; };
in
{ pkg = if isNull cfg.package then
pkgs.radicale
else
cfg.package;
options = { confFile = if cfg.settings == { } then
services.radicale.enable = mkOption { pkgs.writeText "radicale.conf" cfg.config
type = types.bool; else
default = false; format.generate "radicale.conf" cfg.settings;
description = ''
Enable Radicale CalDAV and CardDAV server. rightsFile = format.generate "radicale.rights" cfg.rights;
'';
bindLocalhost = cfg.settings != { } && !hasAttrByPath [ "server" "hosts" ] cfg.settings;
in {
options.services.radicale = {
enable = mkEnableOption "Radicale CalDAV and CardDAV server";
package = mkOption {
description = "Radicale package to use.";
# Default cannot be pkgs.radicale because non-null values suppress
# warnings about incompatible configuration and storage formats.
type = with types; nullOr package // { inherit (package) description; };
default = null;
defaultText = "pkgs.radicale";
}; };
services.radicale.package = mkOption { config = mkOption {
type = types.package;
default = defaultPackage.pkg;
defaultText = defaultPackage.text;
description = ''
Radicale package to use. This defaults to version 1.x if
<literal>system.stateVersion &lt; 17.09</literal>, version 2.x if
<literal>17.09 system.stateVersion &lt; 20.09</literal>, and
version 3.x otherwise.
'';
};
services.radicale.config = mkOption {
type = types.str; type = types.str;
default = ""; default = "";
description = '' description = ''
Radicale configuration, this will set the service Radicale configuration, this will set the service
configuration file. configuration file.
This option is mutually exclusive with <option>settings</option>.
This option is deprecated. Use <option>settings</option> instead.
''; '';
}; };
services.radicale.extraArgs = mkOption { settings = mkOption {
type = format.type;
default = { };
description = ''
Configuration for Radicale. See
<link xlink:href="https://radicale.org/3.0.html#documentation/configuration" />.
This option is mutually exclusive with <option>config</option>.
'';
example = literalExample ''
server = {
hosts = [ "0.0.0.0:5232" "[::]:5232" ];
};
auth = {
type = "htpasswd";
htpasswd_filename = "/etc/radicale/users";
htpasswd_encryption = "bcrypt";
};
storage = {
filesystem_folder = "/var/lib/radicale/collections";
};
'';
};
rights = mkOption {
type = format.type;
description = ''
Configuration for Radicale's rights file. See
<link xlink:href="https://radicale.org/3.0.html#documentation/authentication-and-rights" />.
This option only works in conjunction with <option>settings</option>.
Setting this will also set <option>settings.rights.type</option> and
<option>settings.rights.file</option> to approriate values.
'';
default = { };
example = literalExample ''
root = {
user = ".+";
collection = "";
permissions = "R";
};
principal = {
user = ".+";
collection = "{user}";
permissions = "RW";
};
calendars = {
user = ".+";
collection = "{user}/[^/]+";
permissions = "rw";
};
'';
};
extraArgs = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = []; default = [];
description = "Extra arguments passed to the Radicale daemon."; description = "Extra arguments passed to the Radicale daemon.";
@ -60,33 +107,94 @@ in
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment.systemPackages = [ cfg.package ]; assertions = [
{
assertion = cfg.settings == { } || cfg.config == "";
message = ''
The options services.radicale.config and services.radicale.settings
are mutually exclusive.
'';
}
];
users.users.radicale = warnings = optional (isNull cfg.package && versionOlder config.system.stateVersion "17.09") ''
{ uid = config.ids.uids.radicale; The configuration and storage formats of your existing Radicale
description = "radicale user"; installation might be incompatible with the newest version.
home = "/var/lib/radicale"; For upgrade instructions see
createHome = true; https://radicale.org/2.1.html#documentation/migration-from-1xx-to-2xx.
}; Set services.radicale.package to suppress this warning.
'' ++ optional (isNull cfg.package && versionOlder config.system.stateVersion "20.09") ''
The configuration format of your existing Radicale installation might be
incompatible with the newest version. For upgrade instructions see
https://github.com/Kozea/Radicale/blob/3.0.6/NEWS.md#upgrade-checklist.
Set services.radicale.package to suppress this warning.
'' ++ optional (cfg.config != "") ''
The option services.radicale.config is deprecated.
Use services.radicale.settings instead.
'';
users.groups.radicale = services.radicale.settings.rights = mkIf (cfg.rights != { }) {
{ gid = config.ids.gids.radicale; }; type = "from_file";
file = toString rightsFile;
};
environment.systemPackages = [ pkg ];
users.users.radicale.uid = config.ids.uids.radicale;
users.groups.radicale.gid = config.ids.gids.radicale;
systemd.services.radicale = { systemd.services.radicale = {
description = "A Simple Calendar and Contact Server"; description = "A Simple Calendar and Contact Server";
after = [ "network.target" ]; after = [ "network.target" ];
requires = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
ExecStart = concatStringsSep " " ([ ExecStart = concatStringsSep " " ([
"${cfg.package}/bin/radicale" "-C" confFile "${pkg}/bin/radicale" "-C" confFile
] ++ ( ] ++ (
map escapeShellArg cfg.extraArgs map escapeShellArg cfg.extraArgs
)); ));
User = "radicale"; User = "radicale";
Group = "radicale"; Group = "radicale";
StateDirectory = "radicale/collections";
StateDirectoryMode = "0750";
# Hardening
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "/dev/stdin" ];
DevicePolicy = "strict";
IPAddressAllow = mkIf bindLocalhost "localhost";
IPAddressDeny = mkIf bindLocalhost "any";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
ReadWritePaths = lib.optional
(hasAttrByPath [ "storage" "filesystem_folder" ] cfg.settings)
cfg.settings.storage.filesystem_folder;
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
UMask = "0027";
}; };
}; };
}; };
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ]; meta.maintainers = with lib.maintainers; [ aneeshusa infinisil dotlambda ];
} }

View File

@ -1,140 +1,95 @@
import ./make-test-python.nix ({ lib, pkgs, ... }:
let let
user = "someuser"; user = "someuser";
password = "some_password"; password = "some_password";
port = builtins.toString 5232; port = "5232";
filesystem_folder = "/data/radicale";
common = { pkgs, ... }: { cli = "${pkgs.calendar-cli}/bin/calendar-cli --caldav-user ${user} --caldav-pass ${password}";
in {
name = "radicale3";
meta.maintainers = with lib.maintainers; [ dotlambda ];
machine = { pkgs, ... }: {
services.radicale = { services.radicale = {
enable = true; enable = true;
config = '' settings = {
[auth] auth = {
type = htpasswd type = "htpasswd";
htpasswd_filename = /etc/radicale/htpasswd htpasswd_filename = "/etc/radicale/users";
htpasswd_encryption = bcrypt htpasswd_encryption = "bcrypt";
};
[storage] storage = {
filesystem_folder = /tmp/collections inherit filesystem_folder;
''; hook = "git add -A && (git diff --cached --quiet || git commit -m 'Changes by '%(user)s)";
};
logging.level = "info";
};
rights = {
principal = {
user = ".+";
collection = "{user}";
permissions = "RW";
};
calendars = {
user = ".+";
collection = "{user}/[^/]+";
permissions = "rw";
};
};
}; };
systemd.services.radicale.path = [ pkgs.git ];
environment.systemPackages = [ pkgs.git ];
systemd.tmpfiles.rules = [ "d ${filesystem_folder} 0750 radicale radicale -" ];
# WARNING: DON'T DO THIS IN PRODUCTION! # WARNING: DON'T DO THIS IN PRODUCTION!
# This puts unhashed secrets directly into the Nix store for ease of testing. # This puts unhashed secrets directly into the Nix store for ease of testing.
environment.etc."radicale/htpasswd".source = pkgs.runCommand "htpasswd" {} '' environment.etc."radicale/users".source = pkgs.runCommand "htpasswd" {} ''
${pkgs.apacheHttpd}/bin/htpasswd -bcB "$out" ${user} ${password} ${pkgs.apacheHttpd}/bin/htpasswd -bcB "$out" ${user} ${password}
''; '';
}; };
testScript = ''
machine.wait_for_unit("radicale.service")
machine.wait_for_open_port(${port})
in machine.succeed("sudo -u radicale git -C ${filesystem_folder} init")
machine.succeed(
"sudo -u radicale git -C ${filesystem_folder} config --local user.email radicale@example.com"
)
machine.succeed(
"sudo -u radicale git -C ${filesystem_folder} config --local user.name radicale"
)
import ./make-test-python.nix ({ lib, ... }@args: { with subtest("Test calendar and event creation"):
name = "radicale"; machine.succeed(
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ]; "${cli} --caldav-url http://localhost:${port}/${user} calendar create cal"
)
machine.succeed("test -d ${filesystem_folder}/collection-root/${user}/cal")
machine.succeed('test -z "$(ls ${filesystem_folder}/collection-root/${user}/cal)"')
machine.succeed(
"${cli} --caldav-url http://localhost:${port}/${user}/cal calendar add 2021-04-23 testevent"
)
machine.succeed('test -n "$(ls ${filesystem_folder}/collection-root/${user}/cal)"')
(status, stdout) = machine.execute(
"sudo -u radicale git -C ${filesystem_folder} log --format=oneline | wc -l"
)
assert status == 0, "git log failed"
assert stdout == "3\n", "there should be exactly 3 commits"
nodes = rec { with subtest("Test rights file"):
radicale = radicale1; # Make the test script read more nicely machine.fail(
radicale1 = lib.recursiveUpdate (common args) { "${cli} --caldav-url http://localhost:${port}/${user} calendar create sub/cal"
nixpkgs.overlays = [ )
(self: super: { machine.fail(
radicale1 = super.radicale1.overrideAttrs (oldAttrs: { "${cli} --caldav-url http://localhost:${port}/otheruser calendar create cal"
propagatedBuildInputs = with self.pythonPackages; )
(oldAttrs.propagatedBuildInputs or []) ++ [ passlib ];
});
})
];
system.stateVersion = "17.03";
};
radicale1_export = lib.recursiveUpdate radicale1 {
services.radicale.extraArgs = [
"--export-storage" "/tmp/collections-new"
];
system.stateVersion = "17.03";
};
radicale2_verify = lib.recursiveUpdate radicale2 {
services.radicale.extraArgs = [ "--debug" "--verify-storage" ];
system.stateVersion = "17.09";
};
radicale2 = lib.recursiveUpdate (common args) {
system.stateVersion = "17.09";
};
radicale3 = lib.recursiveUpdate (common args) {
system.stateVersion = "20.09";
};
};
# This tests whether the web interface is accessible to an authenticated user with subtest("Test web interface"):
testScript = { nodes }: let machine.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
switchToConfig = nodeName: let
newSystem = nodes.${nodeName}.config.system.build.toplevel;
in "${newSystem}/bin/switch-to-configuration test";
in ''
with subtest("Check Radicale 1 functionality"):
radicale.succeed(
"${switchToConfig "radicale1"} >&2"
)
radicale.wait_for_unit("radicale.service")
radicale.wait_for_open_port(${port})
radicale.succeed(
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
)
with subtest("Export data in Radicale 2 format"): with subtest("Test security"):
radicale.succeed("systemctl stop radicale") output = machine.succeed("systemd-analyze security radicale.service")
radicale.succeed("ls -al /tmp/collections") machine.log(output)
radicale.fail("ls -al /tmp/collections-new") assert output[-9:-1] == "SAFE :-}"
'';
with subtest("Radicale exits immediately after exporting storage"):
radicale.succeed(
"${switchToConfig "radicale1_export"} >&2"
)
radicale.wait_until_fails("systemctl status radicale")
radicale.succeed("ls -al /tmp/collections")
radicale.succeed("ls -al /tmp/collections-new")
with subtest("Verify data in Radicale 2 format"):
radicale.succeed("rm -r /tmp/collections/${user}")
radicale.succeed("mv /tmp/collections-new/collection-root /tmp/collections")
radicale.succeed(
"${switchToConfig "radicale2_verify"} >&2"
)
radicale.wait_until_fails("systemctl status radicale")
(retcode, logs) = radicale.execute("journalctl -u radicale -n 10")
assert (
retcode == 0 and "Verifying storage" in logs
), "Radicale 2 didn't verify storage"
assert (
"failed" not in logs and "exception" not in logs
), "storage verification failed"
with subtest("Check Radicale 2 functionality"):
radicale.succeed(
"${switchToConfig "radicale2"} >&2"
)
radicale.wait_for_unit("radicale.service")
radicale.wait_for_open_port(${port})
(retcode, output) = radicale.execute(
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
)
assert (
retcode == 0 and "VCALENDAR" in output
), "Could not read calendar from Radicale 2"
radicale.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
with subtest("Check Radicale 3 functionality"):
radicale.succeed(
"${switchToConfig "radicale3"} >&2"
)
radicale.wait_for_unit("radicale.service")
radicale.wait_for_open_port(${port})
(retcode, output) = radicale.execute(
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
)
assert (
retcode == 0 and "VCALENDAR" in output
), "Could not read calendar from Radicale 3"
radicale.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
'';
}) })

View File

@ -0,0 +1,34 @@
{ lib
, python3
, fetchFromGitHub
}:
python3.pkgs.buildPythonApplication rec {
pname = "calendar-cli";
version = "0.12.0";
src = fetchFromGitHub {
owner = "tobixen";
repo = "calendar-cli";
rev = "v${version}";
sha256 = "0qjld2m7hl3dx90491pqbjcja82c1f5gwx274kss4lkb8aw0kmlv";
};
propagatedBuildInputs = with python3.pkgs; [
icalendar
caldav
pytz
tzlocal
six
];
# tests require networking
doCheck = false;
meta = with lib; {
description = "Simple command-line CalDav client";
homepage = "https://github.com/tobixen/calendar-cli";
license = licenses.gpl3Plus;
maintainers = with maintainers; [ dotlambda ];
};
}

View File

@ -2012,6 +2012,8 @@ in
boost = pkgs.boost.override { python = python3; }; boost = pkgs.boost.override { python = python3; };
}; };
calendar-cli = callPackage ../tools/networking/calendar-cli { };
candle = libsForQt5.callPackage ../applications/misc/candle { }; candle = libsForQt5.callPackage ../applications/misc/candle { };
capstone = callPackage ../development/libraries/capstone { }; capstone = callPackage ../development/libraries/capstone { };