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