diff options
author | Robert Schütz <dev@schuetz-co.de> | 2021-05-14 15:37:26 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-14 15:37:26 +0200 |
commit | e611d663f4cbfc2bc577e833ca9db2a0422756be (patch) | |
tree | ff8d45042c8a4b27b4b9d04b7cea309bb634354f | |
parent | 89cc391728876b394c04cf53c984260c3034617e (diff) | |
parent | 762be5c86d51b192831855f6c27de9f910c6d2b8 (diff) |
Merge pull request #120440 from dotlambda/radicale-settings
nixos/radicale: add settings option
-rw-r--r-- | nixos/doc/manual/release-notes/rl-2105.xml | 14 | ||||
-rw-r--r-- | nixos/modules/services/networking/radicale.nix | 196 | ||||
-rw-r--r-- | nixos/tests/radicale.nix | 209 | ||||
-rw-r--r-- | pkgs/tools/networking/calendar-cli/default.nix | 34 | ||||
-rw-r--r-- | pkgs/top-level/all-packages.nix | 2 |
5 files changed, 284 insertions, 171 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2105.xml b/nixos/doc/manual/release-notes/rl-2105.xml index 924fe7ae038e4..e3328cbecd1de 100644 --- a/nixos/doc/manual/release-notes/rl-2105.xml +++ b/nixos/doc/manual/release-notes/rl-2105.xml @@ -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> diff --git a/nixos/modules/services/networking/radicale.nix b/nixos/modules/services/networking/radicale.nix index 5af035fd59e05..8c632c319d3c0 100644 --- a/nixos/modules/services/networking/radicale.nix +++ b/nixos/modules/services/networking/radicale.nix @@ -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; + + 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; - options = { - services.radicale.enable = mkOption { - type = types.bool; - default = false; + 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"; + }; + + config = mkOption { + type = types.str; + default = ""; description = '' - Enable Radicale CalDAV and CardDAV server. + 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.package = mkOption { - type = types.package; - default = defaultPackage.pkg; - defaultText = defaultPackage.text; + settings = mkOption { + type = format.type; + default = { }; description = '' - Radicale package to use. This defaults to version 1.x if - <literal>system.stateVersion < 17.09</literal>, version 2.x if - <literal>17.09 ≤ system.stateVersion < 20.09</literal>, and - version 3.x otherwise. + 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"; + }; ''; }; - services.radicale.config = mkOption { - type = types.str; - default = ""; + rights = mkOption { + type = format.type; description = '' - Radicale configuration, this will set the service - configuration file. + 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"; + }; ''; }; - services.radicale.extraArgs = mkOption { + 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. + ''; + + 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; }; + 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 ]; } diff --git a/nixos/tests/radicale.nix b/nixos/tests/radicale.nix index 1d3679c82a20c..5101628a682c6 100644 --- a/nixos/tests/radicale.nix +++ b/nixos/tests/radicale.nix @@ -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"; + + cli = "${pkgs.calendar-cli}/bin/calendar-cli --caldav-user ${user} --caldav-pass ${password}"; +in { + name = "radicale3"; + meta.maintainers = with lib.maintainers; [ dotlambda ]; - common = { pkgs, ... }: { + 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} ''; }; - -in - - import ./make-test-python.nix ({ lib, ... }@args: { - name = "radicale"; - meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ]; - - 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"; - }; - }; - - # 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("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/") - ''; + testScript = '' + machine.wait_for_unit("radicale.service") + machine.wait_for_open_port(${port}) + + 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" + ) + + 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" + + 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" + ) + + with subtest("Test web interface"): + machine.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 :-}" + ''; }) diff --git a/pkgs/tools/networking/calendar-cli/default.nix b/pkgs/tools/networking/calendar-cli/default.nix new file mode 100644 index 0000000000000..497b77b57ac0a --- /dev/null +++ b/pkgs/tools/networking/calendar-cli/default.nix @@ -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 ]; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index bd951f15f5149..9ae283aa6b395 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -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 { }; |