Merge pull request #120440 from dotlambda/radicale-settings

nixos/radicale: add settings option
master
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.
</para>
</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>
</section>

View File

@ -3,56 +3,103 @@
with lib;
let
cfg = config.services.radicale;
confFile = pkgs.writeText "radicale.conf" cfg.config;
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";
format = pkgs.formats.ini {
listToValue = concatMapStringsSep ", " (generators.mkValueStringDefault { });
};
in
{
pkg = if isNull cfg.package then
pkgs.radicale
else
cfg.package;
options = {
services.radicale.enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable Radicale CalDAV and CardDAV server.
'';
confFile = if cfg.settings == { } then
pkgs.writeText "radicale.conf" cfg.config
else
format.generate "radicale.conf" cfg.settings;
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 {
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 {
config = mkOption {
type = types.str;
default = "";
description = ''
Radicale configuration, this will set the service
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;
default = [];
description = "Extra arguments passed to the Radicale daemon.";
@ -60,33 +107,94 @@ in
};
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 =
{ uid = config.ids.uids.radicale;
description = "radicale user";
home = "/var/lib/radicale";
createHome = true;
};
warnings = optional (isNull cfg.package && versionOlder config.system.stateVersion "17.09") ''
The configuration and storage formats of your existing Radicale
installation might be incompatible with the newest version.
For upgrade instructions see
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 =
{ gid = config.ids.gids.radicale; };
services.radicale.settings.rights = mkIf (cfg.rights != { }) {
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 = {
description = "A Simple Calendar and Contact Server";
after = [ "network.target" ];
requires = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = concatStringsSep " " ([
"${cfg.package}/bin/radicale" "-C" confFile
"${pkg}/bin/radicale" "-C" confFile
] ++ (
map escapeShellArg cfg.extraArgs
));
User = "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
user = "someuser";
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 = {
enable = true;
config = ''
[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/htpasswd
htpasswd_encryption = bcrypt
[storage]
filesystem_folder = /tmp/collections
'';
settings = {
auth = {
type = "htpasswd";
htpasswd_filename = "/etc/radicale/users";
htpasswd_encryption = "bcrypt";
};
storage = {
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!
# 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}
'';
};
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: {
name = "radicale";
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ];
with subtest("Test calendar and event creation"):
machine.succeed(
"${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 {
radicale = radicale1; # Make the test script read more nicely
radicale1 = lib.recursiveUpdate (common args) {
nixpkgs.overlays = [
(self: super: {
radicale1 = super.radicale1.overrideAttrs (oldAttrs: {
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";
};
};
with subtest("Test rights file"):
machine.fail(
"${cli} --caldav-url http://localhost:${port}/${user} calendar create sub/cal"
)
machine.fail(
"${cli} --caldav-url http://localhost:${port}/otheruser calendar create cal"
)
# This tests whether the web interface is accessible to an authenticated user
testScript = { nodes }: let
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("Test web interface"):
machine.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
with subtest("Export data in Radicale 2 format"):
radicale.succeed("systemctl stop radicale")
radicale.succeed("ls -al /tmp/collections")
radicale.fail("ls -al /tmp/collections-new")
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/")
'';
with subtest("Test security"):
output = machine.succeed("systemd-analyze security radicale.service")
machine.log(output)
assert output[-9:-1] == "SAFE :-}"
'';
})

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; };
};
calendar-cli = callPackage ../tools/networking/calendar-cli { };
candle = libsForQt5.callPackage ../applications/misc/candle { };
capstone = callPackage ../development/libraries/capstone { };