about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorRobert Schütz2021-05-14 15:37:26 +0200
committerGitHub2021-05-14 15:37:26 +0200
commite611d663f4cbfc2bc577e833ca9db2a0422756be (patch)
treeff8d45042c8a4b27b4b9d04b7cea309bb634354f /nixos
parent89cc391728876b394c04cf53c984260c3034617e (diff)
parent762be5c86d51b192831855f6c27de9f910c6d2b8 (diff)
Merge pull request #120440 from dotlambda/radicale-settings
nixos/radicale: add settings option
Diffstat (limited to 'nixos')
-rw-r--r--nixos/doc/manual/release-notes/rl-2105.xml14
-rw-r--r--nixos/modules/services/networking/radicale.nix196
-rw-r--r--nixos/tests/radicale.nix209
3 files changed, 248 insertions, 171 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2105.xml b/nixos/doc/manual/release-notes/rl-2105.xml
index 924fe7ae038e..e3328cbecd1d 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 5af035fd59e0..8c632c319d3c 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 &lt; 17.09</literal>, version 2.x if
-        <literal>17.09 ≤ system.stateVersion &lt; 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 1d3679c82a20..5101628a682c 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 :-}"
+  '';
 })