at 24.11-pre 18 kB view raw
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}