1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.davis;
10 db = cfg.database;
11 mail = cfg.mail;
12
13 mysqlLocal = db.createLocally && db.driver == "mysql";
14 pgsqlLocal = db.createLocally && db.driver == "postgresql";
15
16 user = cfg.user;
17 group = cfg.group;
18
19 isSecret = v: lib.isAttrs v && v ? _secret && (lib.isString v._secret || builtins.isPath v._secret);
20 davisEnvVars = lib.generators.toKeyValue {
21 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
22 mkValueString =
23 v:
24 if builtins.isInt v then
25 toString v
26 else if lib.isString v then
27 "\"${v}\""
28 else if true == v then
29 "true"
30 else if false == v then
31 "false"
32 else if null == v then
33 ""
34 else if isSecret v then
35 if (lib.isString v._secret) then
36 builtins.hashString "sha256" v._secret
37 else
38 builtins.hashString "sha256" (builtins.readFile v._secret)
39 else
40 throw "unsupported type ${builtins.typeOf v}: ${(lib.generators.toPretty { }) v}";
41 };
42 };
43 secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
44 mkSecretReplacement = file: ''
45 replace-secret ${
46 lib.escapeShellArgs [
47 (
48 if (lib.isString file) then
49 builtins.hashString "sha256" file
50 else
51 builtins.hashString "sha256" (builtins.readFile file)
52 )
53 file
54 "${cfg.dataDir}/.env.local"
55 ]
56 }
57 '';
58 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
59 filteredConfig = lib.converge (lib.filterAttrsRecursive (
60 _: v:
61 !lib.elem v [
62 { }
63 null
64 ]
65 )) cfg.config;
66 davisEnv = pkgs.writeText "davis.env" (davisEnvVars filteredConfig);
67in
68{
69 options.services.davis = {
70 enable = lib.mkEnableOption "Davis is a caldav and carddav server";
71
72 user = lib.mkOption {
73 default = "davis";
74 description = "User davis runs as.";
75 type = lib.types.str;
76 };
77
78 group = lib.mkOption {
79 default = "davis";
80 description = "Group davis runs as.";
81 type = lib.types.str;
82 };
83
84 package = lib.mkPackageOption pkgs "davis" { };
85
86 dataDir = lib.mkOption {
87 type = lib.types.path;
88 default = "/var/lib/davis";
89 description = ''
90 Davis data directory.
91 '';
92 };
93
94 hostname = lib.mkOption {
95 type = lib.types.str;
96 example = "davis.yourdomain.org";
97 description = ''
98 Domain of the host to serve davis under. You may want to change it if you
99 run Davis on a different URL than davis.yourdomain.
100 '';
101 };
102
103 config = lib.mkOption {
104 type = lib.types.attrsOf (
105 lib.types.nullOr (
106 lib.types.either
107 (lib.types.oneOf [
108 lib.types.bool
109 lib.types.int
110 lib.types.port
111 lib.types.path
112 lib.types.str
113 ])
114 (
115 lib.types.submodule {
116 options = {
117 _secret = lib.mkOption {
118 type = lib.types.nullOr (
119 lib.types.oneOf [
120 lib.types.str
121 lib.types.path
122 ]
123 );
124 description = ''
125 The path to a file containing the value the
126 option should be set to in the final
127 configuration file.
128 '';
129 };
130 };
131 }
132 )
133 )
134 );
135 default = { };
136
137 example = '''';
138 description = '''';
139 };
140
141 adminLogin = lib.mkOption {
142 type = lib.types.str;
143 default = "root";
144 description = ''
145 Username for the admin account.
146 '';
147 };
148 adminPasswordFile = lib.mkOption {
149 type = lib.types.path;
150 description = ''
151 The full path to a file that contains the admin's password. Must be
152 readable by the user.
153 '';
154 example = "/run/secrets/davis-admin-pass";
155 };
156
157 appSecretFile = lib.mkOption {
158 type = lib.types.path;
159 description = ''
160 A file containing the Symfony APP_SECRET - Its value should be a series
161 of characters, numbers and symbols chosen randomly and the recommended
162 length is around 32 characters. Can be generated with <code>cat
163 /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1</code>.
164 '';
165 example = "/run/secrets/davis-appsecret";
166 };
167
168 database = {
169 driver = lib.mkOption {
170 type = lib.types.enum [
171 "sqlite"
172 "postgresql"
173 "mysql"
174 ];
175 default = "sqlite";
176 description = "Database type, required in all circumstances.";
177 };
178 urlFile = lib.mkOption {
179 type = lib.types.nullOr lib.types.path;
180 default = null;
181 example = "/run/secrets/davis-db-url";
182 description = ''
183 A file containing the database connection url. If set then it
184 overrides all other database settings (except driver). This is
185 mandatory if you want to use an external database, that is when
186 `services.davis.database.createLocally` is `false`.
187 '';
188 };
189 name = lib.mkOption {
190 type = lib.types.nullOr lib.types.str;
191 default = "davis";
192 description = "Database name, only used when the databse is created locally.";
193 };
194 createLocally = lib.mkOption {
195 type = lib.types.bool;
196 default = true;
197 description = "Create the database and database user locally.";
198 };
199 };
200
201 mail = {
202 dsn = lib.mkOption {
203 type = lib.types.nullOr lib.types.str;
204 default = null;
205 description = "Mail DSN for sending emails. Mutually exclusive with `services.davis.mail.dsnFile`.";
206 example = "smtp://username:password@example.com:25";
207 };
208 dsnFile = lib.mkOption {
209 type = lib.types.nullOr lib.types.str;
210 default = null;
211 example = "/run/secrets/davis-mail-dsn";
212 description = "A file containing the mail DSN for sending emails. Mutually exclusive with `servies.davis.mail.dsn`.";
213 };
214 inviteFromAddress = lib.mkOption {
215 type = lib.types.nullOr lib.types.str;
216 default = null;
217 description = "Email address to send invitations from.";
218 example = "no-reply@dav.example.com";
219 };
220 };
221
222 nginx = lib.mkOption {
223 type = lib.types.submodule (
224 lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { }
225 );
226 default = null;
227 example = ''
228 {
229 serverAliases = [
230 "dav.''${config.networking.domain}"
231 ];
232 # To enable encryption and let let's encrypt take care of certificate
233 forceSSL = true;
234 enableACME = true;
235 }
236 '';
237 description = ''
238 With this option, you can customize the nginx virtualHost settings.
239 '';
240 };
241
242 poolConfig = lib.mkOption {
243 type = lib.types.attrsOf (
244 lib.types.oneOf [
245 lib.types.str
246 lib.types.int
247 lib.types.bool
248 ]
249 );
250 default = {
251 "pm" = "dynamic";
252 "pm.max_children" = 32;
253 "pm.start_servers" = 2;
254 "pm.min_spare_servers" = 2;
255 "pm.max_spare_servers" = 4;
256 "pm.max_requests" = 500;
257 };
258 description = ''
259 Options for the davis PHP pool. See the documentation on <literal>php-fpm.conf</literal>
260 for details on configuration directives.
261 '';
262 };
263 };
264
265 config =
266 let
267 defaultServiceConfig = {
268 ReadWritePaths = "${cfg.dataDir}";
269 User = user;
270 UMask = 77;
271 DeviceAllow = "";
272 LockPersonality = true;
273 NoNewPrivileges = true;
274 PrivateDevices = true;
275 PrivateTmp = true;
276 PrivateUsers = true;
277 ProcSubset = "pid";
278 ProtectClock = true;
279 ProtectControlGroups = true;
280 ProtectHome = true;
281 ProtectHostname = true;
282 ProtectKernelLogs = true;
283 ProtectKernelModules = true;
284 ProtectKernelTunables = true;
285 ProtectProc = "invisible";
286 ProtectSystem = "strict";
287 RemoveIPC = true;
288 RestrictNamespaces = true;
289 RestrictRealtime = true;
290 RestrictSUIDSGID = true;
291 SystemCallArchitectures = "native";
292 SystemCallFilter = [
293 "@system-service"
294 "~@resources"
295 "~@privileged"
296 ];
297 WorkingDirectory = "${cfg.package}/";
298 };
299 in
300 lib.mkIf cfg.enable {
301 assertions = [
302 {
303 assertion = db.createLocally -> db.urlFile == null;
304 message = "services.davis.database.urlFile must be unset if services.davis.database.createLocally is set true.";
305 }
306 {
307 assertion = db.createLocally || db.urlFile != null;
308 message = "One of services.davis.database.urlFile or services.davis.database.createLocally must be set.";
309 }
310 {
311 assertion = (mail.dsn != null) != (mail.dsnFile != null);
312 message = "One of (and only one of) services.davis.mail.dsn or services.davis.mail.dsnFile must be set.";
313 }
314 ];
315 services.davis.config =
316 {
317 APP_ENV = "prod";
318 APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
319 # note: we do not need the log dir (we log to stdout/journald), by davis/symfony will try to create it, and the default value is one in the nix-store
320 # so we set it to a path under dataDir to avoid something like: Unable to create the "logs" directory (/nix/store/5cfskz0ybbx37s1161gjn5klwb5si1zg-davis-4.4.1/var/log).
321 APP_LOG_DIR = "${cfg.dataDir}/var/log";
322 LOG_FILE_PATH = "/dev/stdout";
323 DATABASE_DRIVER = db.driver;
324 INVITE_FROM_ADDRESS = mail.inviteFromAddress;
325 APP_SECRET._secret = cfg.appSecretFile;
326 ADMIN_LOGIN = cfg.adminLogin;
327 ADMIN_PASSWORD._secret = cfg.adminPasswordFile;
328 APP_TIMEZONE = config.time.timeZone;
329 WEBDAV_ENABLED = false;
330 CALDAV_ENABLED = true;
331 CARDDAV_ENABLED = true;
332 }
333 // (if mail.dsn != null then { MAILER_DSN = mail.dsn; } else { MAILER_DSN._secret = mail.dsnFile; })
334 // (
335 if db.createLocally then
336 {
337 DATABASE_URL =
338 if db.driver == "sqlite" then
339 "sqlite:///${cfg.dataDir}/davis.db" # note: sqlite needs 4 slashes for an absolute path
340 else if
341 pgsqlLocal
342 # note: davis expects a non-standard postgres uri (due to the underlying doctrine library)
343 # specifically the dummy hostname which is overriden by the host query parameter
344 then
345 "postgres://${user}@localhost/${db.name}?host=/run/postgresql"
346 else if mysqlLocal then
347 "mysql://${user}@localhost/${db.name}?socket=/run/mysqld/mysqld.sock"
348 else
349 null;
350 }
351 else
352 { DATABASE_URL._secret = db.urlFile; }
353 );
354
355 users = {
356 users = lib.mkIf (user == "davis") {
357 davis = {
358 description = "Davis service user";
359 group = cfg.group;
360 isSystemUser = true;
361 home = cfg.dataDir;
362 };
363 };
364 groups = lib.mkIf (group == "davis") { davis = { }; };
365 };
366
367 systemd.tmpfiles.rules = [
368 "d ${cfg.dataDir} 0710 ${user} ${group} - -"
369 "d ${cfg.dataDir}/var 0700 ${user} ${group} - -"
370 "d ${cfg.dataDir}/var/log 0700 ${user} ${group} - -"
371 "d ${cfg.dataDir}/var/cache 0700 ${user} ${group} - -"
372 ];
373
374 services.phpfpm.pools.davis = {
375 inherit user group;
376 phpOptions = ''
377 log_errors = on
378 '';
379 phpEnv = {
380 ENV_DIR = "${cfg.dataDir}";
381 APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
382 APP_LOG_DIR = "${cfg.dataDir}/var/log";
383 };
384 settings =
385 {
386 "listen.mode" = "0660";
387 "pm" = "dynamic";
388 "pm.max_children" = 256;
389 "pm.start_servers" = 10;
390 "pm.min_spare_servers" = 5;
391 "pm.max_spare_servers" = 20;
392 }
393 // (
394 if cfg.nginx != null then
395 {
396 "listen.owner" = config.services.nginx.user;
397 "listen.group" = config.services.nginx.group;
398 }
399 else
400 { }
401 )
402 // cfg.poolConfig;
403 };
404
405 # Reading the user-provided secret files requires root access
406 systemd.services.davis-env-setup = {
407 description = "Setup davis environment";
408 before = [
409 "phpfpm-davis.service"
410 "davis-db-migrate.service"
411 ];
412 wantedBy = [ "multi-user.target" ];
413 serviceConfig = {
414 Type = "oneshot";
415 RemainAfterExit = true;
416 };
417 path = [ pkgs.replace-secret ];
418 restartTriggers = [
419 cfg.package
420 davisEnv
421 ];
422 script = ''
423 # error handling
424 set -euo pipefail
425 # create .env file with the upstream values
426 install -T -m 0600 -o ${user} ${cfg.package}/env-upstream "${cfg.dataDir}/.env"
427 # create .env.local file with the user-provided values
428 install -T -m 0600 -o ${user} ${davisEnv} "${cfg.dataDir}/.env.local"
429 ${secretReplacements}
430 '';
431 };
432
433 systemd.services.davis-db-migrate = {
434 description = "Migrate davis database";
435 before = [ "phpfpm-davis.service" ];
436 after =
437 lib.optional mysqlLocal "mysql.service"
438 ++ lib.optional pgsqlLocal "postgresql.service"
439 ++ [ "davis-env-setup.service" ];
440 requires =
441 lib.optional mysqlLocal "mysql.service"
442 ++ lib.optional pgsqlLocal "postgresql.service"
443 ++ [ "davis-env-setup.service" ];
444 wantedBy = [ "multi-user.target" ];
445 serviceConfig = defaultServiceConfig // {
446 Type = "oneshot";
447 RemainAfterExit = true;
448 Environment = [
449 "ENV_DIR=${cfg.dataDir}"
450 "APP_CACHE_DIR=${cfg.dataDir}/var/cache"
451 "APP_LOG_DIR=${cfg.dataDir}/var/log"
452 ];
453 EnvironmentFile = "${cfg.dataDir}/.env.local";
454 };
455 restartTriggers = [
456 cfg.package
457 davisEnv
458 ];
459 script = ''
460 set -euo pipefail
461 ${cfg.package}/bin/console cache:clear --no-debug
462 ${cfg.package}/bin/console cache:warmup --no-debug
463 ${cfg.package}/bin/console doctrine:migrations:migrate
464 '';
465 };
466
467 systemd.services.phpfpm-davis.after = [
468 "davis-env-setup.service"
469 "davis-db-migrate.service"
470 ];
471 systemd.services.phpfpm-davis.requires = [
472 "davis-env-setup.service"
473 "davis-db-migrate.service"
474 ] ++ lib.optional mysqlLocal "mysql.service" ++ lib.optional pgsqlLocal "postgresql.service";
475 systemd.services.phpfpm-davis.serviceConfig.ReadWritePaths = [ cfg.dataDir ];
476
477 services.nginx = lib.mkIf (cfg.nginx != null) {
478 enable = lib.mkDefault true;
479 virtualHosts = {
480 "${cfg.hostname}" = lib.mkMerge [
481 cfg.nginx
482 {
483 root = lib.mkForce "${cfg.package}/public";
484 extraConfig = ''
485 charset utf-8;
486 index index.php;
487 '';
488 locations = {
489 "/" = {
490 extraConfig = ''
491 try_files $uri $uri/ /index.php$is_args$args;
492 '';
493 };
494 "~* ^/.well-known/(caldav|carddav)$" = {
495 extraConfig = ''
496 return 302 $http_x_forwarded_proto://$host/dav/;
497 '';
498 };
499 "~ ^(.+\.php)(.*)$" = {
500 extraConfig = ''
501 try_files $fastcgi_script_name =404;
502 include ${config.services.nginx.package}/conf/fastcgi_params;
503 include ${config.services.nginx.package}/conf/fastcgi.conf;
504 fastcgi_pass unix:${config.services.phpfpm.pools.davis.socket};
505 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
506 fastcgi_param PATH_INFO $fastcgi_path_info;
507 fastcgi_split_path_info ^(.+\.php)(.*)$;
508 fastcgi_param X-Forwarded-Proto $http_x_forwarded_proto;
509 fastcgi_param X-Forwarded-Port $http_x_forwarded_port;
510 '';
511 };
512 "~ /(\\.ht)" = {
513 extraConfig = ''
514 deny all;
515 return 404;
516 '';
517 };
518 };
519 }
520 ];
521 };
522 };
523
524 services.mysql = lib.mkIf mysqlLocal {
525 enable = true;
526 package = lib.mkDefault pkgs.mariadb;
527 ensureDatabases = [ db.name ];
528 ensureUsers = [
529 {
530 name = user;
531 ensurePermissions = {
532 "${db.name}.*" = "ALL PRIVILEGES";
533 };
534 }
535 ];
536 };
537
538 services.postgresql = lib.mkIf pgsqlLocal {
539 enable = true;
540 ensureDatabases = [ db.name ];
541 ensureUsers = [
542 {
543 name = user;
544 ensureDBOwnership = true;
545 }
546 ];
547 };
548 };
549
550 meta = {
551 doc = ./davis.md;
552 maintainers = pkgs.davis.meta.maintainers;
553 };
554}