at 25.11-pre 22 kB view raw
1{ 2 config, 3 pkgs, 4 lib, 5 ... 6}: 7 8with lib; 9 10let 11 cfg = config.services.wordpress; 12 eachSite = cfg.sites; 13 user = "wordpress"; 14 webserver = config.services.${cfg.webserver}; 15 stateDir = hostName: "/var/lib/wordpress/${hostName}"; 16 17 pkg = 18 hostName: cfg: 19 pkgs.stdenv.mkDerivation rec { 20 pname = "wordpress-${hostName}"; 21 version = src.version; 22 src = cfg.package; 23 24 installPhase = '' 25 mkdir -p $out 26 cp -r * $out/ 27 28 # symlink the wordpress config 29 ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php 30 # symlink uploads directory 31 ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads 32 ln -s ${cfg.fontsDir} $out/share/wordpress/wp-content/fonts 33 34 # https://github.com/NixOS/nixpkgs/pull/53399 35 # 36 # Symlinking works for most plugins and themes, but Avada, for instance, fails to 37 # understand the symlink, causing its file path stripping to fail. This results in 38 # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js 39 # Since hard linking directories is not allowed, copying is the next best thing. 40 41 # copy additional plugin(s), theme(s) and language(s) 42 ${concatStringsSep "\n" ( 43 mapAttrsToList ( 44 name: theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${name}" 45 ) cfg.themes 46 )} 47 ${concatStringsSep "\n" ( 48 mapAttrsToList ( 49 name: plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${name}" 50 ) cfg.plugins 51 )} 52 ${concatMapStringsSep "\n" ( 53 language: "cp -r ${language} $out/share/wordpress/wp-content/languages/" 54 ) cfg.languages} 55 ''; 56 }; 57 58 mergeConfig = 59 cfg: 60 { 61 # wordpress is installed onto a read-only file system 62 DISALLOW_FILE_EDIT = true; 63 AUTOMATIC_UPDATER_DISABLED = true; 64 DB_NAME = cfg.database.name; 65 DB_HOST = "${cfg.database.host}:${ 66 if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port 67 }"; 68 DB_USER = cfg.database.user; 69 DB_CHARSET = "utf8"; 70 # Always set DB_PASSWORD even when passwordFile is not set. This is the 71 # default Wordpress behaviour. 72 DB_PASSWORD = 73 if (cfg.database.passwordFile != null) then { _file = cfg.database.passwordFile; } else ""; 74 } 75 // cfg.settings; 76 77 wpConfig = 78 hostName: cfg: 79 let 80 conf_gen = c: mapAttrsToList (k: v: "define('${k}', ${mkPhpValue v});") cfg.mergedConfig; 81 in 82 pkgs.writeTextFile { 83 name = "wp-config-${hostName}.php"; 84 text = '' 85 <?php 86 $table_prefix = '${cfg.database.tablePrefix}'; 87 88 require_once('${stateDir hostName}/secret-keys.php'); 89 90 ${cfg.extraConfig} 91 ${concatStringsSep "\n" (conf_gen cfg.mergedConfig)} 92 93 if ( !defined('ABSPATH') ) 94 define('ABSPATH', dirname(__FILE__) . '/'); 95 96 require_once(ABSPATH . 'wp-settings.php'); 97 ?> 98 ''; 99 checkPhase = "${pkgs.php}/bin/php --syntax-check $target"; 100 }; 101 102 mkPhpValue = 103 v: 104 let 105 isHasAttr = s: isAttrs v && hasAttr s v; 106 # "you're escaped" -> "'you\'re escaped'" 107 # https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.single 108 toPhpString = s: "'${escape [ "'" "\\" ] s}'"; 109 in 110 if isString v then 111 toPhpString v 112 # NOTE: If any value contains a , (comma) this will not get escaped 113 else if isList v && strings.isConvertibleWithToString v then 114 toPhpString (concatMapStringsSep "," toString v) 115 else if isInt v then 116 toString v 117 else if isBool v then 118 boolToString v 119 else if isHasAttr "_file" then 120 "trim(file_get_contents(${toPhpString (toString v._file)}))" 121 else if isHasAttr "_raw" then 122 v._raw 123 else 124 abort "The Wordpress config value ${lib.generators.toPretty { } v} can not be encoded."; 125 126 secretsVars = [ 127 "AUTH_KEY" 128 "SECURE_AUTH_KEY" 129 "LOGGED_IN_KEY" 130 "NONCE_KEY" 131 "AUTH_SALT" 132 "SECURE_AUTH_SALT" 133 "LOGGED_IN_SALT" 134 "NONCE_SALT" 135 ]; 136 secretsScript = hostStateDir: '' 137 # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839 138 grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php" 139 if ! test -e "${hostStateDir}/secret-keys.php"; then 140 umask 0177 141 echo "<?php" >> "${hostStateDir}/secret-keys.php" 142 ${concatMapStringsSep "\n" (var: '' 143 echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php" 144 '') secretsVars} 145 echo "?>" >> "${hostStateDir}/secret-keys.php" 146 chmod 440 "${hostStateDir}/secret-keys.php" 147 fi 148 ''; 149 150 siteOpts = 151 { 152 lib, 153 name, 154 config, 155 ... 156 }: 157 { 158 options = { 159 package = mkPackageOption pkgs "wordpress" { }; 160 161 uploadsDir = mkOption { 162 type = types.path; 163 default = "/var/lib/wordpress/${name}/uploads"; 164 description = '' 165 This directory is used for uploads of pictures. The directory passed here is automatically 166 created and permissions adjusted as required. 167 ''; 168 }; 169 170 fontsDir = mkOption { 171 type = types.path; 172 default = "/var/lib/wordpress/${name}/fonts"; 173 description = '' 174 This directory is used to download fonts from a remote location, e.g. 175 to host google fonts locally. 176 ''; 177 }; 178 179 plugins = mkOption { 180 type = 181 with types; 182 coercedTo (listOf path) ( 183 l: 184 warn "setting this option with a list is deprecated" listToAttrs ( 185 map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l 186 ) 187 ) (attrsOf path); 188 default = { }; 189 description = '' 190 Path(s) to respective plugin(s) which are copied from the 'plugins' directory. 191 192 ::: {.note} 193 These plugins need to be packaged before use, see example. 194 ::: 195 ''; 196 example = literalExpression '' 197 { 198 inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin; 199 } 200 ''; 201 }; 202 203 themes = mkOption { 204 type = 205 with types; 206 coercedTo (listOf path) ( 207 l: 208 warn "setting this option with a list is deprecated" listToAttrs ( 209 map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l 210 ) 211 ) (attrsOf path); 212 default = { inherit (pkgs.wordpressPackages.themes) twentytwentyfive; }; 213 defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentyfive; }"; 214 description = '' 215 Path(s) to respective theme(s) which are copied from the 'theme' directory. 216 217 ::: {.note} 218 These themes need to be packaged before use, see example. 219 ::: 220 ''; 221 example = literalExpression '' 222 { 223 inherit (pkgs.wordpressPackages.themes) responsive-theme; 224 } 225 ''; 226 }; 227 228 languages = mkOption { 229 type = types.listOf types.path; 230 default = [ ]; 231 description = '' 232 List of path(s) to respective language(s) which are copied from the 'languages' directory. 233 ''; 234 example = literalExpression '' 235 [ 236 # Let's package the German language. 237 # For other languages try to replace language and country code in the download URL with your desired one. 238 # Reference https://translate.wordpress.org for available translations and 239 # codes. 240 (pkgs.stdenv.mkDerivation { 241 name = "language-de"; 242 src = pkgs.fetchurl { 243 url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz"; 244 # Name is required to invalidate the hash when wordpress is updated 245 name = "wordpress-''${pkgs.wordpress.version}-language-de"; 246 sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0="; 247 }; 248 installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/"; 249 }) 250 ]; 251 ''; 252 }; 253 254 database = { 255 host = mkOption { 256 type = types.str; 257 default = "localhost"; 258 description = "Database host address."; 259 }; 260 261 port = mkOption { 262 type = types.port; 263 default = 3306; 264 description = "Database host port."; 265 }; 266 267 name = mkOption { 268 type = types.str; 269 default = "wordpress"; 270 description = "Database name."; 271 }; 272 273 user = mkOption { 274 type = types.str; 275 default = "wordpress"; 276 description = "Database user."; 277 }; 278 279 passwordFile = mkOption { 280 type = types.nullOr types.path; 281 default = null; 282 example = "/run/keys/wordpress-dbpassword"; 283 description = '' 284 A file containing the password corresponding to 285 {option}`database.user`. 286 ''; 287 }; 288 289 tablePrefix = mkOption { 290 type = types.str; 291 default = "wp_"; 292 description = '' 293 The $table_prefix is the value placed in the front of your database tables. 294 Change the value if you want to use something other than wp_ for your database 295 prefix. Typically this is changed if you are installing multiple WordPress blogs 296 in the same database. 297 298 See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>. 299 ''; 300 }; 301 302 socket = mkOption { 303 type = types.nullOr types.path; 304 default = null; 305 defaultText = literalExpression "/run/mysqld/mysqld.sock"; 306 description = "Path to the unix socket file to use for authentication."; 307 }; 308 309 createLocally = mkOption { 310 type = types.bool; 311 default = true; 312 description = "Create the database and database user locally."; 313 }; 314 }; 315 316 virtualHost = mkOption { 317 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); 318 example = literalExpression '' 319 { 320 adminAddr = "webmaster@example.org"; 321 forceSSL = true; 322 enableACME = true; 323 } 324 ''; 325 description = '' 326 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. 327 ''; 328 }; 329 330 poolConfig = mkOption { 331 type = 332 with types; 333 attrsOf (oneOf [ 334 str 335 int 336 bool 337 ]); 338 default = { 339 "pm" = "dynamic"; 340 "pm.max_children" = 32; 341 "pm.start_servers" = 2; 342 "pm.min_spare_servers" = 2; 343 "pm.max_spare_servers" = 4; 344 "pm.max_requests" = 500; 345 }; 346 description = '' 347 Options for the WordPress PHP pool. See the documentation on `php-fpm.conf` 348 for details on configuration directives. 349 ''; 350 }; 351 352 settings = mkOption { 353 type = types.attrsOf types.anything; 354 default = { }; 355 description = '' 356 Structural Wordpress configuration. 357 Refer to <https://developer.wordpress.org/apis/wp-config-php> 358 for details and supported values. 359 ''; 360 example = literalExpression '' 361 { 362 WP_DEFAULT_THEME = "twentytwentytwo"; 363 WP_SITEURL = "https://example.org"; 364 WP_HOME = "https://example.org"; 365 WP_DEBUG = true; 366 WP_DEBUG_DISPLAY = true; 367 WPLANG = "de_DE"; 368 FORCE_SSL_ADMIN = true; 369 AUTOMATIC_UPDATER_DISABLED = true; 370 } 371 ''; 372 }; 373 374 mergedConfig = mkOption { 375 readOnly = true; 376 default = mergeConfig config; 377 defaultText = literalExpression '' 378 { 379 DISALLOW_FILE_EDIT = true; 380 AUTOMATIC_UPDATER_DISABLED = true; 381 } 382 ''; 383 description = '' 384 Read only representation of the final configuration. 385 ''; 386 }; 387 388 extraConfig = mkOption { 389 type = types.lines; 390 default = ""; 391 description = '' 392 Any additional text to be appended to the wp-config.php 393 configuration file. This is a PHP script. For configuration 394 settings, see <https://codex.wordpress.org/Editing_wp-config.php>. 395 396 **Note**: Please pass structured settings via 397 `services.wordpress.sites.${name}.settings` instead. 398 ''; 399 example = '' 400 @ini_set( 'log_errors', 'Off' ); 401 @ini_set( 'display_errors', 'On' ); 402 ''; 403 }; 404 405 }; 406 407 config.virtualHost.hostName = mkDefault name; 408 }; 409in 410{ 411 # interface 412 options = { 413 services.wordpress = { 414 415 sites = mkOption { 416 type = types.attrsOf (types.submodule siteOpts); 417 default = { }; 418 description = "Specification of one or more WordPress sites to serve"; 419 }; 420 421 webserver = mkOption { 422 type = types.enum [ 423 "httpd" 424 "nginx" 425 "caddy" 426 ]; 427 default = "httpd"; 428 description = '' 429 Whether to use apache2 or nginx for virtual host management. 430 431 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`. 432 See [](#opt-services.nginx.virtualHosts) for further information. 433 434 Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`. 435 See [](#opt-services.httpd.virtualHosts) for further information. 436 ''; 437 }; 438 439 }; 440 }; 441 442 # implementation 443 config = mkIf (eachSite != { }) (mkMerge [ 444 { 445 446 assertions = 447 (mapAttrsToList (hostName: cfg: { 448 assertion = cfg.database.createLocally -> cfg.database.user == user; 449 message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; 450 }) eachSite) 451 ++ (mapAttrsToList (hostName: cfg: { 452 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; 453 message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.''; 454 }) eachSite); 455 456 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { 457 enable = true; 458 package = mkDefault pkgs.mariadb; 459 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; 460 ensureUsers = mapAttrsToList (hostName: cfg: { 461 name = cfg.database.user; 462 ensurePermissions = { 463 "${cfg.database.name}.*" = "ALL PRIVILEGES"; 464 }; 465 }) eachSite; 466 }; 467 468 services.phpfpm.pools = mapAttrs' ( 469 hostName: cfg: 470 (nameValuePair "wordpress-${hostName}" { 471 inherit user; 472 group = webserver.group; 473 settings = { 474 "listen.owner" = webserver.user; 475 "listen.group" = webserver.group; 476 } // cfg.poolConfig; 477 }) 478 ) eachSite; 479 480 } 481 482 (mkIf (cfg.webserver == "httpd") { 483 services.httpd = { 484 enable = true; 485 extraModules = [ "proxy_fcgi" ]; 486 virtualHosts = mapAttrs ( 487 hostName: cfg: 488 mkMerge [ 489 cfg.virtualHost 490 { 491 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress"; 492 extraConfig = '' 493 <Directory "${pkg hostName cfg}/share/wordpress"> 494 <FilesMatch "\.php$"> 495 <If "-f %{REQUEST_FILENAME}"> 496 SetHandler "proxy:unix:${ 497 config.services.phpfpm.pools."wordpress-${hostName}".socket 498 }|fcgi://localhost/" 499 </If> 500 </FilesMatch> 501 502 # standard wordpress .htaccess contents 503 <IfModule mod_rewrite.c> 504 RewriteEngine On 505 RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 506 RewriteBase / 507 RewriteRule ^index\.php$ - [L] 508 RewriteCond %{REQUEST_FILENAME} !-f 509 RewriteCond %{REQUEST_FILENAME} !-d 510 RewriteRule . /index.php [L] 511 </IfModule> 512 513 DirectoryIndex index.php 514 Require all granted 515 Options +FollowSymLinks -Indexes 516 </Directory> 517 518 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php 519 <Files wp-config.php> 520 Require all denied 521 </Files> 522 ''; 523 } 524 ] 525 ) eachSite; 526 }; 527 }) 528 529 { 530 systemd.tmpfiles.rules = flatten ( 531 mapAttrsToList (hostName: cfg: [ 532 "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -" 533 "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" 534 "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" 535 "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" 536 "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" 537 ]) eachSite 538 ); 539 540 systemd.services = mkMerge [ 541 (mapAttrs' ( 542 hostName: cfg: 543 (nameValuePair "wordpress-init-${hostName}" { 544 wantedBy = [ "multi-user.target" ]; 545 before = [ "phpfpm-wordpress-${hostName}.service" ]; 546 after = optional cfg.database.createLocally "mysql.service"; 547 script = secretsScript (stateDir hostName); 548 549 serviceConfig = { 550 Type = "oneshot"; 551 User = user; 552 Group = webserver.group; 553 }; 554 }) 555 ) eachSite) 556 557 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { 558 httpd.after = [ "mysql.service" ]; 559 }) 560 ]; 561 562 users.users.${user} = { 563 group = webserver.group; 564 isSystemUser = true; 565 }; 566 } 567 568 (mkIf (cfg.webserver == "nginx") { 569 services.nginx = { 570 enable = true; 571 virtualHosts = mapAttrs (hostName: cfg: { 572 serverName = mkDefault hostName; 573 root = "${pkg hostName cfg}/share/wordpress"; 574 extraConfig = '' 575 index index.php; 576 ''; 577 locations = { 578 "/" = { 579 priority = 200; 580 extraConfig = '' 581 try_files $uri $uri/ /index.php$is_args$args; 582 ''; 583 }; 584 "~ \\.php$" = { 585 priority = 500; 586 extraConfig = '' 587 fastcgi_split_path_info ^(.+\.php)(/.+)$; 588 fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}; 589 fastcgi_index index.php; 590 include "${config.services.nginx.package}/conf/fastcgi.conf"; 591 fastcgi_param PATH_INFO $fastcgi_path_info; 592 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; 593 # Mitigate https://httpoxy.org/ vulnerabilities 594 fastcgi_param HTTP_PROXY ""; 595 fastcgi_intercept_errors off; 596 fastcgi_buffer_size 16k; 597 fastcgi_buffers 4 16k; 598 fastcgi_connect_timeout 300; 599 fastcgi_send_timeout 300; 600 fastcgi_read_timeout 300; 601 ''; 602 }; 603 "~ /\\." = { 604 priority = 800; 605 extraConfig = "deny all;"; 606 }; 607 "~* /(?:uploads|files)/.*\\.php$" = { 608 priority = 900; 609 extraConfig = "deny all;"; 610 }; 611 "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = { 612 priority = 1000; 613 extraConfig = '' 614 expires max; 615 log_not_found off; 616 ''; 617 }; 618 }; 619 }) eachSite; 620 }; 621 }) 622 623 (mkIf (cfg.webserver == "caddy") { 624 services.caddy = { 625 enable = true; 626 virtualHosts = mapAttrs' ( 627 hostName: cfg: 628 (nameValuePair hostName { 629 extraConfig = '' 630 root * /${pkg hostName cfg}/share/wordpress 631 file_server 632 633 php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket} 634 635 @uploads { 636 path_regexp path /uploads\/(.*)\.php 637 } 638 rewrite @uploads / 639 640 @wp-admin { 641 path not ^\/wp-admin/* 642 } 643 rewrite @wp-admin {path}/index.php?{query} 644 ''; 645 }) 646 ) eachSite; 647 }; 648 }) 649 650 ]); 651}