grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / davis.nix
blob621f2ff20e2bb7e15c1cd4dbc23511929f756d8c
2   config,
3   lib,
4   pkgs,
5   ...
6 }:
8 let
9   cfg = config.services.davis;
10   db = cfg.database;
11   mail = cfg.mail;
13   mysqlLocal = db.createLocally && db.driver == "mysql";
14   pgsqlLocal = db.createLocally && db.driver == "postgresql";
16   user = cfg.user;
17   group = cfg.group;
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);
69   options.services.davis = {
70     enable = lib.mkEnableOption "Davis is a caldav and carddav server";
72     user = lib.mkOption {
73       default = "davis";
74       description = "User davis runs as.";
75       type = lib.types.str;
76     };
78     group = lib.mkOption {
79       default = "davis";
80       description = "Group davis runs as.";
81       type = lib.types.str;
82     };
84     package = lib.mkPackageOption pkgs "davis" { };
86     dataDir = lib.mkOption {
87       type = lib.types.path;
88       default = "/var/lib/davis";
89       description = ''
90         Davis data directory.
91       '';
92     };
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     };
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 = { };
137       example = '''';
138       description = '''';
139     };
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     };
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     };
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     };
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     };
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     };
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   };
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         );
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       };
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       ];
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       };
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       };
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       };
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 ];
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 https://$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 https;
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       };
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       };
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     };
550   meta = {
551     doc = ./davis.md;
552     maintainers = pkgs.davis.meta.maintainers;
553   };