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.nullOr (
224 lib.types.submodule (
225 lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
226 }
227 )
228 );
229 default = { };
230 example = ''
231 {
232 serverAliases = [
233 "dav.''${config.networking.domain}"
234 ];
235 # To enable encryption and let let's encrypt take care of certificate
236 forceSSL = true;
237 enableACME = true;
238 }
239 '';
240 description = ''
241 Use this option to customize an nginx virtual host. To disable the nginx set this to null.
242 '';
243 };
244
245 poolConfig = lib.mkOption {
246 type = lib.types.attrsOf (
247 lib.types.oneOf [
248 lib.types.str
249 lib.types.int
250 lib.types.bool
251 ]
252 );
253 default = {
254 "pm" = "dynamic";
255 "pm.max_children" = 32;
256 "pm.start_servers" = 2;
257 "pm.min_spare_servers" = 2;
258 "pm.max_spare_servers" = 4;
259 "pm.max_requests" = 500;
260 };
261 description = ''
262 Options for the davis PHP pool. See the documentation on <literal>php-fpm.conf</literal>
263 for details on configuration directives.
264 '';
265 };
266 };
267
268 config =
269 let
270 defaultServiceConfig = {
271 ReadWritePaths = "${cfg.dataDir}";
272 User = user;
273 UMask = 77;
274 DeviceAllow = "";
275 LockPersonality = true;
276 NoNewPrivileges = true;
277 PrivateDevices = true;
278 PrivateTmp = true;
279 PrivateUsers = true;
280 ProcSubset = "pid";
281 ProtectClock = true;
282 ProtectControlGroups = true;
283 ProtectHome = true;
284 ProtectHostname = true;
285 ProtectKernelLogs = true;
286 ProtectKernelModules = true;
287 ProtectKernelTunables = true;
288 ProtectProc = "invisible";
289 ProtectSystem = "strict";
290 RemoveIPC = true;
291 RestrictNamespaces = true;
292 RestrictRealtime = true;
293 RestrictSUIDSGID = true;
294 SystemCallArchitectures = "native";
295 SystemCallFilter = [
296 "@system-service"
297 "~@resources"
298 "~@privileged"
299 ];
300 WorkingDirectory = "${cfg.package}/";
301 };
302 in
303 lib.mkIf cfg.enable {
304 assertions = [
305 {
306 assertion = db.createLocally -> db.urlFile == null;
307 message = "services.davis.database.urlFile must be unset if services.davis.database.createLocally is set true.";
308 }
309 {
310 assertion = db.createLocally || db.urlFile != null;
311 message = "One of services.davis.database.urlFile or services.davis.database.createLocally must be set.";
312 }
313 {
314 assertion = !(mail.dsn != null && mail.dsnFile != null);
315 message = "services.davis.mail.dsn and services.davis.mail.dsnFile cannot both be set.";
316 }
317 ];
318 services.davis.config = {
319 APP_ENV = "prod";
320 APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
321 APP_LOG_DIR = "${cfg.dataDir}/var/log";
322 LOG_FILE_PATH = "%kernel.logs_dir%/%kernel.environment%.log";
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 // (
334 if mail.dsn != null then
335 { MAILER_DSN = mail.dsn; }
336 else if mail.dsnFile != null then
337 { MAILER_DSN._secret = mail.dsnFile; }
338 else
339 { }
340 )
341 // (
342 if db.createLocally then
343 {
344 DATABASE_URL =
345 if db.driver == "sqlite" then
346 "sqlite:///${cfg.dataDir}/davis.db" # note: sqlite needs 4 slashes for an absolute path
347 else if
348 pgsqlLocal
349 # note: davis expects a non-standard postgres uri (due to the underlying doctrine library)
350 # specifically the dummy hostname which is overridden by the host query parameter
351 then
352 "postgres://${user}@localhost/${db.name}?host=/run/postgresql"
353 else if mysqlLocal then
354 "mysql://${user}@localhost/${db.name}?socket=/run/mysqld/mysqld.sock"
355 else
356 null;
357 }
358 else
359 { DATABASE_URL._secret = db.urlFile; }
360 );
361
362 users = {
363 users = lib.mkIf (user == "davis") {
364 davis = {
365 description = "Davis service user";
366 group = cfg.group;
367 isSystemUser = true;
368 home = cfg.dataDir;
369 };
370 };
371 groups = lib.mkIf (group == "davis") { davis = { }; };
372 };
373
374 systemd.tmpfiles.rules = [
375 "d ${cfg.dataDir} 0710 ${user} ${group} - -"
376 "d ${cfg.dataDir}/var 0700 ${user} ${group} - -"
377 "d ${cfg.dataDir}/var/log 0700 ${user} ${group} - -"
378 "d ${cfg.dataDir}/var/cache 0700 ${user} ${group} - -"
379 ];
380
381 services.phpfpm.pools.davis = {
382 inherit user group;
383 phpOptions = ''
384 log_errors = on
385 '';
386 phpEnv = {
387 ENV_DIR = "${cfg.dataDir}";
388 APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
389 APP_LOG_DIR = "${cfg.dataDir}/var/log";
390 };
391 phpPackage = lib.mkDefault cfg.package.passthru.php;
392 settings = {
393 "listen.mode" = "0660";
394 "pm" = "dynamic";
395 "pm.max_children" = 256;
396 "pm.start_servers" = 10;
397 "pm.min_spare_servers" = 5;
398 "pm.max_spare_servers" = 20;
399 }
400 // (
401 if cfg.nginx != null then
402 {
403 "listen.owner" = config.services.nginx.user;
404 "listen.group" = config.services.nginx.group;
405 }
406 else
407 { }
408 )
409 // cfg.poolConfig;
410 };
411
412 # Reading the user-provided secret files requires root access
413 systemd.services.davis-env-setup = {
414 description = "Setup davis environment";
415 before = [
416 "phpfpm-davis.service"
417 "davis-db-migrate.service"
418 ];
419 wantedBy = [ "multi-user.target" ];
420 serviceConfig = {
421 Type = "oneshot";
422 RemainAfterExit = true;
423 };
424 path = [ pkgs.replace-secret ];
425 restartTriggers = [
426 cfg.package
427 davisEnv
428 ];
429 script = ''
430 # error handling
431 set -euo pipefail
432 # create .env file with the upstream values
433 install -T -m 0600 -o ${user} ${cfg.package}/env-upstream "${cfg.dataDir}/.env"
434 # create .env.local file with the user-provided values
435 install -T -m 0600 -o ${user} ${davisEnv} "${cfg.dataDir}/.env.local"
436 ${secretReplacements}
437 '';
438 };
439
440 systemd.services.davis-db-migrate = {
441 description = "Migrate davis database";
442 before = [ "phpfpm-davis.service" ];
443 after =
444 lib.optional mysqlLocal "mysql.service"
445 ++ lib.optional pgsqlLocal "postgresql.target"
446 ++ [ "davis-env-setup.service" ];
447 requires =
448 lib.optional mysqlLocal "mysql.service"
449 ++ lib.optional pgsqlLocal "postgresql.target"
450 ++ [ "davis-env-setup.service" ];
451 wantedBy = [ "multi-user.target" ];
452 serviceConfig = defaultServiceConfig // {
453 Type = "oneshot";
454 RemainAfterExit = true;
455 Environment = [
456 "ENV_DIR=${cfg.dataDir}"
457 "APP_CACHE_DIR=${cfg.dataDir}/var/cache"
458 "APP_LOG_DIR=${cfg.dataDir}/var/log"
459 ];
460 EnvironmentFile = "${cfg.dataDir}/.env.local";
461 };
462 restartTriggers = [
463 cfg.package
464 davisEnv
465 ];
466 script = ''
467 set -euo pipefail
468 ${cfg.package}/bin/console cache:clear --no-debug
469 ${cfg.package}/bin/console cache:warmup --no-debug
470 ${cfg.package}/bin/console doctrine:migrations:migrate
471 '';
472 };
473
474 systemd.services.phpfpm-davis.after = [
475 "davis-env-setup.service"
476 "davis-db-migrate.service"
477 ];
478 systemd.services.phpfpm-davis.requires = [
479 "davis-env-setup.service"
480 "davis-db-migrate.service"
481 ]
482 ++ lib.optional mysqlLocal "mysql.service"
483 ++ lib.optional pgsqlLocal "postgresql.target";
484 systemd.services.phpfpm-davis.serviceConfig.ReadWritePaths = [ cfg.dataDir ];
485
486 services.nginx = lib.mkIf (cfg.nginx != null) {
487 enable = lib.mkDefault true;
488 virtualHosts = {
489 "${cfg.hostname}" = lib.mkMerge [
490 cfg.nginx
491 {
492 root = lib.mkForce "${cfg.package}/public";
493 extraConfig = ''
494 charset utf-8;
495 index index.php;
496 '';
497 locations = {
498 "/" = {
499 extraConfig = ''
500 try_files $uri $uri/ /index.php$is_args$args;
501 '';
502 };
503 "~* ^/.well-known/(caldav|carddav)$" = {
504 extraConfig = ''
505 return 302 https://$host/dav/;
506 '';
507 };
508 "~ ^(.+\\.php)(.*)$" = {
509 extraConfig = ''
510 try_files $fastcgi_script_name =404;
511 include ${config.services.nginx.package}/conf/fastcgi_params;
512 include ${config.services.nginx.package}/conf/fastcgi.conf;
513 fastcgi_pass unix:${config.services.phpfpm.pools.davis.socket};
514 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
515 fastcgi_param PATH_INFO $fastcgi_path_info;
516 fastcgi_split_path_info ^(.+\.php)(.*)$;
517 fastcgi_param X-Forwarded-Proto https;
518 fastcgi_param X-Forwarded-Port $http_x_forwarded_port;
519 '';
520 };
521 "~ /(\\.ht)" = {
522 extraConfig = ''
523 deny all;
524 return 404;
525 '';
526 };
527 };
528 }
529 ];
530 };
531 };
532
533 services.mysql = lib.mkIf mysqlLocal {
534 enable = true;
535 package = lib.mkDefault pkgs.mariadb;
536 ensureDatabases = [ db.name ];
537 ensureUsers = [
538 {
539 name = user;
540 ensurePermissions = {
541 "${db.name}.*" = "ALL PRIVILEGES";
542 };
543 }
544 ];
545 };
546
547 services.postgresql = lib.mkIf pgsqlLocal {
548 enable = true;
549 ensureDatabases = [ db.name ];
550 ensureUsers = [
551 {
552 name = user;
553 ensureDBOwnership = true;
554 }
555 ];
556 };
557 };
558
559 meta = {
560 doc = ./davis.md;
561 maintainers = pkgs.davis.meta.maintainers;
562 };
563}