at master 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.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}