at master 17 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7 8let 9 inherit (lib) 10 any 11 attrValues 12 flatten 13 literalExpression 14 mapAttrs 15 mapAttrs' 16 mapAttrsToList 17 mkDefault 18 mkEnableOption 19 mkIf 20 mkMerge 21 mkOption 22 mkPackageOption 23 nameValuePair 24 optionalAttrs 25 types 26 ; 27 inherit (pkgs) 28 mariadb 29 stdenv 30 writeShellScript 31 ; 32 cfg = config.services.drupal; 33 eachSite = cfg.sites; 34 user = "drupal"; 35 webserver = config.services.${cfg.webserver}; 36 37 pkg = 38 hostName: cfg: 39 stdenv.mkDerivation (finalAttrs: { 40 pname = "drupal-${hostName}"; 41 name = "drupal-${hostName}"; 42 src = cfg.package; 43 44 installPhase = '' 45 runHook preInstall 46 47 mkdir -p $out 48 cp -r * $out/ 49 50 runHook postInstall 51 ''; 52 53 postInstall = '' 54 ln -s ${cfg.filesDir} $out/share/php/drupal/sites/default/files 55 ln -s ${cfg.stateDir}/sites/default/settings.php $out/share/php/drupal/sites/default/settings.php 56 ln -s ${cfg.modulesDir} $out/share/php/drupal/modules 57 ln -s ${cfg.themesDir} $out/share/php/drupal/themes 58 ''; 59 }); 60 61 drupalSettings = 62 hostName: cfg: 63 pkgs.writeTextFile { 64 name = "settings.nixos-${hostName}.php"; 65 text = '' 66 <?php 67 68 // NixOS automatically generated settings 69 $settings['file_private_path'] = '${cfg.privateFilesDir}'; 70 $settings['config_sync_directory'] = '${cfg.configSyncDir}'; 71 72 // Extra config 73 ${cfg.extraConfig} 74 ''; 75 checkPhase = "${pkgs.php}/bin/php --syntax-check $target"; 76 }; 77 78 appendSettings = 79 hostName: 80 pkgs.writeTextFile { 81 name = "append-drupal-settings-${hostName}"; 82 text = '' 83 84 // NixOS settings file import. 85 require dirname(__FILE__) . '/settings.nixos-${hostName}.php'; 86 87 ''; 88 }; 89 90 siteOpts = 91 { 92 options, 93 config, 94 lib, 95 name, 96 ... 97 }: 98 { 99 options = { 100 enable = mkEnableOption "Drupal web application"; 101 package = mkPackageOption pkgs "drupal" { }; 102 103 filesDir = mkOption { 104 type = types.path; 105 default = "/var/lib/drupal/${name}/sites/default/files"; 106 defaultText = "/var/lib/drupal/<name>/sites/default/files"; 107 description = '' 108 The location of the Drupal files directory. 109 ''; 110 }; 111 112 privateFilesDir = mkOption { 113 type = types.path; 114 default = "/var/lib/drupal/${name}/private"; 115 defaultText = "/var/lib/drupal/<name>/private"; 116 description = "The location of the Drupal private files directory."; 117 }; 118 119 configSyncDir = mkOption { 120 type = types.path; 121 default = "/var/lib/drupal/${name}/config/sync"; 122 defaultText = "/var/lib/drupal/<name>/config/sync"; 123 description = "The location of the Drupal config sync directory."; 124 }; 125 126 extraConfig = mkOption { 127 type = types.lines; 128 default = ""; 129 description = '' 130 Extra configuration values that you want to insert into settings.php. 131 All configuration must be written as PHP script. 132 ''; 133 example = '' 134 $config['user.settings']['anonymous'] = 'Visitor'; 135 $settings['entity_update_backup'] = TRUE; 136 ''; 137 }; 138 139 stateDir = mkOption { 140 type = types.path; 141 default = "/var/lib/drupal/${name}"; 142 defaultText = "/var/lib/drupal/<name>"; 143 description = "The location of the Drupal site state directory."; 144 }; 145 146 modulesDir = mkOption { 147 type = types.path; 148 default = "/var/lib/drupal/${name}/modules"; 149 defaultText = "/var/lib/drupal/<name>/modules"; 150 description = "The location for users to install Drupal modules."; 151 }; 152 153 themesDir = mkOption { 154 type = types.path; 155 default = "/var/lib/drupal/${name}/themes"; 156 defaultText = "/var/lib/drupal/<name>/themes"; 157 description = "The location for users to install Drupal themes."; 158 }; 159 160 phpOptions = mkOption { 161 type = types.attrsOf types.str; 162 default = { }; 163 description = '' 164 Options for PHP's php.ini file for this Drupal site. 165 ''; 166 example = literalExpression '' 167 { 168 "opcache.interned_strings_buffer" = "8"; 169 "opcache.max_accelerated_files" = "10000"; 170 "opcache.memory_consumption" = "128"; 171 "opcache.revalidate_freq" = "15"; 172 "opcache.fast_shutdown" = "1"; 173 } 174 ''; 175 }; 176 177 database = { 178 host = mkOption { 179 type = types.str; 180 default = "localhost"; 181 description = "Database host address."; 182 }; 183 184 port = mkOption { 185 type = types.port; 186 default = 3306; 187 description = "Database host port."; 188 }; 189 190 name = mkOption { 191 type = types.str; 192 default = "drupal"; 193 description = "Database name."; 194 }; 195 196 user = mkOption { 197 type = types.str; 198 default = "drupal"; 199 description = "Database user."; 200 }; 201 202 passwordFile = mkOption { 203 type = types.nullOr types.path; 204 default = null; 205 example = "/run/keys/database-dbpassword"; 206 description = '' 207 A file containing the password corresponding to 208 {option}`database.user`. 209 ''; 210 }; 211 212 tablePrefix = mkOption { 213 type = types.str; 214 default = "dp_"; 215 description = '' 216 The $table_prefix is the value placed in the front of your database tables. 217 Change the value if you want to use something other than dp_ for your database 218 prefix. Typically this is changed if you are installing multiple Drupal sites 219 in the same database. 220 ''; 221 }; 222 223 socket = mkOption { 224 type = types.nullOr types.path; 225 default = null; 226 defaultText = literalExpression "/run/mysqld/mysqld.sock"; 227 description = "Path to the unix socket file to use for authentication."; 228 }; 229 230 createLocally = mkOption { 231 type = types.bool; 232 default = true; 233 description = "Create the database and database user locally."; 234 }; 235 }; 236 237 virtualHost = mkOption { 238 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); 239 example = literalExpression '' 240 { 241 adminAddr = "webmaster@example.org"; 242 forceSSL = true; 243 enableACME = true; 244 } 245 ''; 246 description = '' 247 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. 248 ''; 249 }; 250 251 poolConfig = mkOption { 252 type = 253 with types; 254 attrsOf (oneOf [ 255 str 256 int 257 bool 258 ]); 259 default = { 260 "pm" = "dynamic"; 261 "pm.max_children" = 32; 262 "pm.start_servers" = 2; 263 "pm.min_spare_servers" = 2; 264 "pm.max_spare_servers" = 4; 265 "pm.max_requests" = 500; 266 }; 267 description = '' 268 Options for the Drupal PHP pool. See the documentation on `php-fpm.conf` 269 for details on configuration directives. 270 ''; 271 }; 272 }; 273 274 config.virtualHost.hostName = mkDefault name; 275 }; 276in 277{ 278 options = { 279 services.drupal = { 280 enable = mkEnableOption "drupal"; 281 package = mkPackageOption pkgs "drupal" { }; 282 283 sites = mkOption { 284 type = types.attrsOf (types.submodule siteOpts); 285 default = { 286 "localhost" = { 287 enable = true; 288 }; 289 }; 290 description = "Specification of one or more Drupal sites to serve"; 291 }; 292 293 webserver = mkOption { 294 type = types.enum [ 295 "nginx" 296 "caddy" 297 ]; 298 default = "nginx"; 299 description = '' 300 Whether to use nginx or caddy for virtual host management. 301 302 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`. 303 See [](#opt-services.nginx.virtualHosts) for further information. 304 305 Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`. 306 See [](#opt-services.caddy.virtualHosts) for further information. 307 ''; 308 }; 309 }; 310 }; 311 312 config = mkIf (cfg.enable) (mkMerge [ 313 { 314 315 assertions = 316 (mapAttrsToList (hostName: cfg: { 317 assertion = cfg.database.createLocally -> cfg.database.user == user; 318 message = ''services.drupal.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; 319 }) eachSite) 320 ++ (mapAttrsToList (hostName: cfg: { 321 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; 322 message = ''services.drupal.sites."${hostName}".database.passwordFile cannot be specified if services.drupal.sites."${hostName}".database.createLocally is set to true.''; 323 }) eachSite); 324 325 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { 326 enable = true; 327 package = mkDefault mariadb; 328 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; 329 ensureUsers = mapAttrsToList (hostName: cfg: { 330 name = cfg.database.user; 331 ensurePermissions = { 332 "${cfg.database.name}.*" = "ALL PRIVILEGES"; 333 }; 334 }) eachSite; 335 }; 336 337 services.phpfpm.pools = mapAttrs' ( 338 hostName: cfg: 339 (nameValuePair "drupal-${hostName}" { 340 inherit user; 341 group = webserver.group; 342 settings = { 343 "listen.owner" = webserver.user; 344 "listen.group" = webserver.group; 345 } 346 // cfg.poolConfig; 347 }) 348 ) eachSite; 349 } 350 351 { 352 systemd.tmpfiles.rules = flatten ( 353 mapAttrsToList (hostName: cfg: [ 354 "d '${cfg.stateDir}' 0750 ${user} ${webserver.group} - -" 355 "d '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" 356 "Z '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" 357 "d '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" 358 "Z '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" 359 "d '${cfg.privateFilesDir}' 0750 ${user} ${webserver.group} - -" 360 "d '${cfg.configSyncDir}' 0750 ${user} ${webserver.group} - -" 361 ]) eachSite 362 ); 363 364 users.users.${user} = { 365 group = webserver.group; 366 isSystemUser = true; 367 }; 368 } 369 370 { 371 # Run a service that prepares the state directory. 372 systemd.services = mkMerge [ 373 (mapAttrs' ( 374 hostName: cfg: 375 (nameValuePair "drupal-state-init-${hostName}" { 376 wantedBy = [ "multi-user.target" ]; 377 before = [ "nginx.service" ]; 378 after = [ "local-fs.target" ]; 379 380 serviceConfig = { 381 Type = "oneshot"; 382 User = "root"; 383 RemainAfterExit = true; 384 385 ExecStart = writeShellScript "drupal-state-init-${hostName}" '' 386 set -e 387 388 if [ ! -d "${cfg.stateDir}/sites" ]; then 389 echo "Preparing sites directory..." 390 cp -r "${cfg.package}/share/php/drupal/sites" "${cfg.stateDir}" 391 fi 392 393 if [ ! -d "${cfg.filesDir}" ]; then 394 echo "Preparing files directory..." 395 mkdir -p "${cfg.filesDir}" 396 chown -R ${user}:${webserver.group} ${cfg.filesDir} 397 fi 398 399 settings_file="${cfg.stateDir}/sites/default/settings.php" 400 default_settings="${cfg.package}/share/php/drupal/sites/default/default.settings.php" 401 402 if [ ! -f "$settings_file" ]; then 403 echo "Preparing settings.php for ${hostName}..." 404 cp "$default_settings" "$settings_file" 405 cat < ${appendSettings hostName} >> "$settings_file" 406 chmod 644 "$settings_file" 407 fi 408 409 # Link the NixOS-managed settings file to the state directory. 410 ln -sf ${drupalSettings hostName cfg} ${cfg.stateDir}/sites/default/settings.nixos-${hostName}.php 411 412 # Set or reset file permissions so that the web user and webserver owns them. 413 chown -R ${user}:${webserver.group} ${cfg.stateDir} 414 ''; 415 }; 416 417 # Rerun this service if certain settings were updated 418 reloadTriggers = [ 419 cfg.extraConfig 420 cfg.privateFilesDir 421 cfg.configSyncDir 422 ]; 423 }) 424 ) eachSite) 425 426 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { 427 httpd.after = [ "mysql.service" ]; 428 }) 429 ]; 430 } 431 432 (mkIf (cfg.webserver == "nginx") { 433 services.nginx = { 434 enable = true; 435 virtualHosts = mapAttrs (hostName: cfg: { 436 serverName = mkDefault hostName; 437 root = "${pkg hostName cfg}/share/php/drupal"; 438 extraConfig = '' 439 index index.php; 440 ''; 441 locations = { 442 "~ '\.php$|^/update.php'" = { 443 extraConfig = '' 444 fastcgi_split_path_info ^(.+\.php)(/.+)$; 445 fastcgi_pass unix:${config.services.phpfpm.pools."drupal-${hostName}".socket}; 446 fastcgi_index index.php; 447 include "${config.services.nginx.package}/conf/fastcgi.conf"; 448 fastcgi_param PATH_INFO $fastcgi_path_info; 449 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; 450 # Mitigate https://httpoxy.org/ vulnerabilities 451 fastcgi_param HTTP_PROXY ""; 452 fastcgi_intercept_errors off; 453 fastcgi_buffer_size 16k; 454 fastcgi_buffers 4 16k; 455 fastcgi_connect_timeout 300; 456 fastcgi_send_timeout 300; 457 fastcgi_read_timeout 300; 458 ''; 459 }; 460 "= /favicon.ico" = { 461 extraConfig = '' 462 log_not_found off; 463 access_log off; 464 ''; 465 }; 466 "= /robots.txt" = { 467 extraConfig = '' 468 allow all; 469 log_not_found off; 470 access_log off; 471 ''; 472 }; 473 "~ \..*/.*\.php$" = { 474 extraConfig = '' 475 return 403; 476 ''; 477 }; 478 "~ ^/sites/.*/private/" = { 479 extraConfig = '' 480 return 403; 481 ''; 482 }; 483 "~ ^/sites/[^/]+/files/.*\.php$" = { 484 extraConfig = '' 485 deny all; 486 ''; 487 }; 488 "~* ^/.well-known/" = { 489 extraConfig = '' 490 allow all; 491 ''; 492 }; 493 "/" = { 494 extraConfig = '' 495 try_files $uri /index.php?$query_string; 496 ''; 497 }; 498 "@rewrite" = { 499 extraConfig = '' 500 rewrite ^ /index.php; 501 ''; 502 }; 503 "~ /vendor/.*\.php$" = { 504 extraConfig = '' 505 deny all; 506 return 404; 507 ''; 508 }; 509 "~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = { 510 extraConfig = '' 511 try_files $uri @rewrite; 512 expires max; 513 log_not_found off; 514 ''; 515 }; 516 "~ ^/sites/.*/files/styles/" = { 517 extraConfig = '' 518 try_files $uri @rewrite; 519 ''; 520 }; 521 "~ ^(/[a-z\-]+)?/system/files/" = { 522 extraConfig = '' 523 try_files $uri /index.php?$query_string; 524 ''; 525 }; 526 }; 527 }) eachSite; 528 }; 529 }) 530 531 (mkIf (cfg.webserver == "caddy") { 532 services.caddy = { 533 enable = true; 534 virtualHosts = mapAttrs' ( 535 hostName: cfg: 536 (nameValuePair hostName { 537 extraConfig = '' 538 root * ${pkg hostName cfg}/share/php/drupal 539 file_server 540 541 encode zstd gzip 542 php_fastcgi unix/${config.services.phpfpm.pools."drupal-${hostName}".socket} 543 ''; 544 }) 545 ) cfg.sites; 546 }; 547 }) 548 549 ]); 550}