1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.radicale;
7
8 format = pkgs.formats.ini {
9 listToValue = concatMapStringsSep ", " (generators.mkValueStringDefault { });
10 };
11
12 pkg = if cfg.package == null then
13 pkgs.radicale
14 else
15 cfg.package;
16
17 confFile = if cfg.settings == { } then
18 pkgs.writeText "radicale.conf" cfg.config
19 else
20 format.generate "radicale.conf" cfg.settings;
21
22 rightsFile = format.generate "radicale.rights" cfg.rights;
23
24 bindLocalhost = cfg.settings != { } && !hasAttrByPath [ "server" "hosts" ] cfg.settings;
25
26in {
27 options.services.radicale = {
28 enable = mkEnableOption (lib.mdDoc "Radicale CalDAV and CardDAV server");
29
30 package = mkOption {
31 description = lib.mdDoc "Radicale package to use.";
32 # Default cannot be pkgs.radicale because non-null values suppress
33 # warnings about incompatible configuration and storage formats.
34 type = with types; nullOr package // { inherit (package) description; };
35 default = null;
36 defaultText = literalExpression "pkgs.radicale";
37 };
38
39 config = mkOption {
40 type = types.str;
41 default = "";
42 description = lib.mdDoc ''
43 Radicale configuration, this will set the service
44 configuration file.
45 This option is mutually exclusive with {option}`settings`.
46 This option is deprecated. Use {option}`settings` instead.
47 '';
48 };
49
50 settings = mkOption {
51 type = format.type;
52 default = { };
53 description = lib.mdDoc ''
54 Configuration for Radicale. See
55 <https://radicale.org/3.0.html#documentation/configuration>.
56 This option is mutually exclusive with {option}`config`.
57 '';
58 example = literalExpression ''
59 server = {
60 hosts = [ "0.0.0.0:5232" "[::]:5232" ];
61 };
62 auth = {
63 type = "htpasswd";
64 htpasswd_filename = "/etc/radicale/users";
65 htpasswd_encryption = "bcrypt";
66 };
67 storage = {
68 filesystem_folder = "/var/lib/radicale/collections";
69 };
70 '';
71 };
72
73 rights = mkOption {
74 type = format.type;
75 description = lib.mdDoc ''
76 Configuration for Radicale's rights file. See
77 <https://radicale.org/3.0.html#documentation/authentication-and-rights>.
78 This option only works in conjunction with {option}`settings`.
79 Setting this will also set {option}`settings.rights.type` and
80 {option}`settings.rights.file` to appropriate values.
81 '';
82 default = { };
83 example = literalExpression ''
84 root = {
85 user = ".+";
86 collection = "";
87 permissions = "R";
88 };
89 principal = {
90 user = ".+";
91 collection = "{user}";
92 permissions = "RW";
93 };
94 calendars = {
95 user = ".+";
96 collection = "{user}/[^/]+";
97 permissions = "rw";
98 };
99 '';
100 };
101
102 extraArgs = mkOption {
103 type = types.listOf types.str;
104 default = [];
105 description = lib.mdDoc "Extra arguments passed to the Radicale daemon.";
106 };
107 };
108
109 config = mkIf cfg.enable {
110 assertions = [
111 {
112 assertion = cfg.settings == { } || cfg.config == "";
113 message = ''
114 The options services.radicale.config and services.radicale.settings
115 are mutually exclusive.
116 '';
117 }
118 ];
119
120 warnings = optional (cfg.package == null && versionOlder config.system.stateVersion "17.09") ''
121 The configuration and storage formats of your existing Radicale
122 installation might be incompatible with the newest version.
123 For upgrade instructions see
124 https://radicale.org/2.1.html#documentation/migration-from-1xx-to-2xx.
125 Set services.radicale.package to suppress this warning.
126 '' ++ optional (cfg.package == null && versionOlder config.system.stateVersion "20.09") ''
127 The configuration format of your existing Radicale installation might be
128 incompatible with the newest version. For upgrade instructions see
129 https://github.com/Kozea/Radicale/blob/3.0.6/NEWS.md#upgrade-checklist.
130 Set services.radicale.package to suppress this warning.
131 '' ++ optional (cfg.config != "") ''
132 The option services.radicale.config is deprecated.
133 Use services.radicale.settings instead.
134 '';
135
136 services.radicale.settings.rights = mkIf (cfg.rights != { }) {
137 type = "from_file";
138 file = toString rightsFile;
139 };
140
141 environment.systemPackages = [ pkg ];
142
143 users.users.radicale = {
144 isSystemUser = true;
145 group = "radicale";
146 };
147
148 users.groups.radicale = {};
149
150 systemd.services.radicale = {
151 description = "A Simple Calendar and Contact Server";
152 after = [ "network.target" ];
153 requires = [ "network.target" ];
154 wantedBy = [ "multi-user.target" ];
155 serviceConfig = {
156 ExecStart = concatStringsSep " " ([
157 "${pkg}/bin/radicale" "-C" confFile
158 ] ++ (
159 map escapeShellArg cfg.extraArgs
160 ));
161 User = "radicale";
162 Group = "radicale";
163 StateDirectory = "radicale/collections";
164 StateDirectoryMode = "0750";
165 # Hardening
166 CapabilityBoundingSet = [ "" ];
167 DeviceAllow = [ "/dev/stdin" "/dev/urandom" ];
168 DevicePolicy = "strict";
169 IPAddressAllow = mkIf bindLocalhost "localhost";
170 IPAddressDeny = mkIf bindLocalhost "any";
171 LockPersonality = true;
172 MemoryDenyWriteExecute = true;
173 NoNewPrivileges = true;
174 PrivateDevices = true;
175 PrivateTmp = true;
176 PrivateUsers = true;
177 ProcSubset = "pid";
178 ProtectClock = true;
179 ProtectControlGroups = true;
180 ProtectHome = true;
181 ProtectHostname = true;
182 ProtectKernelLogs = true;
183 ProtectKernelModules = true;
184 ProtectKernelTunables = true;
185 ProtectProc = "invisible";
186 ProtectSystem = "strict";
187 ReadWritePaths = lib.optional
188 (hasAttrByPath [ "storage" "filesystem_folder" ] cfg.settings)
189 cfg.settings.storage.filesystem_folder;
190 RemoveIPC = true;
191 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
192 RestrictNamespaces = true;
193 RestrictRealtime = true;
194 RestrictSUIDSGID = true;
195 SystemCallArchitectures = "native";
196 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
197 UMask = "0027";
198 WorkingDirectory = "/var/lib/radicale";
199 };
200 };
201 };
202
203 meta.maintainers = with lib.maintainers; [ infinisil dotlambda ];
204}