at 23.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 = mkOption { 108 type = types.package; 109 default = pkgs.wordpress; 110 defaultText = literalExpression "pkgs.wordpress"; 111 description = lib.mdDoc "Which WordPress package to use."; 112 }; 113 114 uploadsDir = mkOption { 115 type = types.path; 116 default = "/var/lib/wordpress/${name}/uploads"; 117 description = lib.mdDoc '' 118 This directory is used for uploads of pictures. The directory passed here is automatically 119 created and permissions adjusted as required. 120 ''; 121 }; 122 123 fontsDir = mkOption { 124 type = types.path; 125 default = "/var/lib/wordpress/${name}/fonts"; 126 description = lib.mdDoc '' 127 This directory is used to download fonts from a remote location, e.g. 128 to host google fonts locally. 129 ''; 130 }; 131 132 plugins = mkOption { 133 type = with types; coercedTo 134 (listOf path) 135 (l: warn "setting this option with a list is deprecated" 136 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l)) 137 (attrsOf path); 138 default = {}; 139 description = lib.mdDoc '' 140 Path(s) to respective plugin(s) which are copied from the 'plugins' directory. 141 142 ::: {.note} 143 These plugins need to be packaged before use, see example. 144 ::: 145 ''; 146 example = literalExpression '' 147 { 148 inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin; 149 } 150 ''; 151 }; 152 153 themes = mkOption { 154 type = with types; coercedTo 155 (listOf path) 156 (l: warn "setting this option with a list is deprecated" 157 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l)) 158 (attrsOf path); 159 default = { inherit (pkgs.wordpressPackages.themes) twentytwentythree; }; 160 defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentythree; }"; 161 description = lib.mdDoc '' 162 Path(s) to respective theme(s) which are copied from the 'theme' directory. 163 164 ::: {.note} 165 These themes need to be packaged before use, see example. 166 ::: 167 ''; 168 example = literalExpression '' 169 { 170 inherit (pkgs.wordpressPackages.themes) responsive-theme; 171 } 172 ''; 173 }; 174 175 languages = mkOption { 176 type = types.listOf types.path; 177 default = []; 178 description = lib.mdDoc '' 179 List of path(s) to respective language(s) which are copied from the 'languages' directory. 180 ''; 181 example = literalExpression '' 182 [( 183 # Let's package the German language. 184 # For other languages try to replace language and country code in the download URL with your desired one. 185 # Reference https://translate.wordpress.org for available translations and 186 # codes. 187 language-de = pkgs.stdenv.mkDerivation { 188 name = "language-de"; 189 src = pkgs.fetchurl { 190 url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz"; 191 # Name is required to invalidate the hash when wordpress is updated 192 name = "wordpress-''${pkgs.wordpress.version}-language-de" 193 sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0="; 194 }; 195 installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/"; 196 }; 197 )]; 198 ''; 199 }; 200 201 database = { 202 host = mkOption { 203 type = types.str; 204 default = "localhost"; 205 description = lib.mdDoc "Database host address."; 206 }; 207 208 port = mkOption { 209 type = types.port; 210 default = 3306; 211 description = lib.mdDoc "Database host port."; 212 }; 213 214 name = mkOption { 215 type = types.str; 216 default = "wordpress"; 217 description = lib.mdDoc "Database name."; 218 }; 219 220 user = mkOption { 221 type = types.str; 222 default = "wordpress"; 223 description = lib.mdDoc "Database user."; 224 }; 225 226 passwordFile = mkOption { 227 type = types.nullOr types.path; 228 default = null; 229 example = "/run/keys/wordpress-dbpassword"; 230 description = lib.mdDoc '' 231 A file containing the password corresponding to 232 {option}`database.user`. 233 ''; 234 }; 235 236 tablePrefix = mkOption { 237 type = types.str; 238 default = "wp_"; 239 description = lib.mdDoc '' 240 The $table_prefix is the value placed in the front of your database tables. 241 Change the value if you want to use something other than wp_ for your database 242 prefix. Typically this is changed if you are installing multiple WordPress blogs 243 in the same database. 244 245 See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>. 246 ''; 247 }; 248 249 socket = mkOption { 250 type = types.nullOr types.path; 251 default = null; 252 defaultText = literalExpression "/run/mysqld/mysqld.sock"; 253 description = lib.mdDoc "Path to the unix socket file to use for authentication."; 254 }; 255 256 createLocally = mkOption { 257 type = types.bool; 258 default = true; 259 description = lib.mdDoc "Create the database and database user locally."; 260 }; 261 }; 262 263 virtualHost = mkOption { 264 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); 265 example = literalExpression '' 266 { 267 adminAddr = "webmaster@example.org"; 268 forceSSL = true; 269 enableACME = true; 270 } 271 ''; 272 description = lib.mdDoc '' 273 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. 274 ''; 275 }; 276 277 poolConfig = mkOption { 278 type = with types; attrsOf (oneOf [ str int bool ]); 279 default = { 280 "pm" = "dynamic"; 281 "pm.max_children" = 32; 282 "pm.start_servers" = 2; 283 "pm.min_spare_servers" = 2; 284 "pm.max_spare_servers" = 4; 285 "pm.max_requests" = 500; 286 }; 287 description = lib.mdDoc '' 288 Options for the WordPress PHP pool. See the documentation on `php-fpm.conf` 289 for details on configuration directives. 290 ''; 291 }; 292 293 settings = mkOption { 294 type = types.attrsOf types.anything; 295 default = {}; 296 description = lib.mdDoc '' 297 Structural Wordpress configuration. 298 Refer to <https://developer.wordpress.org/apis/wp-config-php> 299 for details and supported values. 300 ''; 301 example = literalExpression '' 302 { 303 WP_DEFAULT_THEME = "twentytwentytwo"; 304 WP_SITEURL = "https://example.org"; 305 WP_HOME = "https://example.org"; 306 WP_DEBUG = true; 307 WP_DEBUG_DISPLAY = true; 308 WPLANG = "de_DE"; 309 FORCE_SSL_ADMIN = true; 310 AUTOMATIC_UPDATER_DISABLED = true; 311 } 312 ''; 313 }; 314 315 mergedConfig = mkOption { 316 readOnly = true; 317 default = mergeConfig config; 318 defaultText = literalExpression '' 319 { 320 DISALLOW_FILE_EDIT = true; 321 AUTOMATIC_UPDATER_DISABLED = true; 322 } 323 ''; 324 description = lib.mdDoc '' 325 Read only representation of the final configuration. 326 ''; 327 }; 328 329 extraConfig = mkOption { 330 type = types.lines; 331 default = ""; 332 description = lib.mdDoc '' 333 Any additional text to be appended to the wp-config.php 334 configuration file. This is a PHP script. For configuration 335 settings, see <https://codex.wordpress.org/Editing_wp-config.php>. 336 337 **Note**: Please pass structured settings via 338 `services.wordpress.sites.${name}.settings` instead. 339 ''; 340 example = '' 341 @ini_set( 'log_errors', 'Off' ); 342 @ini_set( 'display_errors', 'On' ); 343 ''; 344 }; 345 346 }; 347 348 config.virtualHost.hostName = mkDefault name; 349 }; 350in 351{ 352 # interface 353 options = { 354 services.wordpress = { 355 356 sites = mkOption { 357 type = types.attrsOf (types.submodule siteOpts); 358 default = {}; 359 description = lib.mdDoc "Specification of one or more WordPress sites to serve"; 360 }; 361 362 webserver = mkOption { 363 type = types.enum [ "httpd" "nginx" "caddy" ]; 364 default = "httpd"; 365 description = lib.mdDoc '' 366 Whether to use apache2 or nginx for virtual host management. 367 368 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`. 369 See [](#opt-services.nginx.virtualHosts) for further information. 370 371 Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`. 372 See [](#opt-services.httpd.virtualHosts) for further information. 373 ''; 374 }; 375 376 }; 377 }; 378 379 # implementation 380 config = mkIf (eachSite != {}) (mkMerge [{ 381 382 assertions = 383 (mapAttrsToList (hostName: cfg: 384 { assertion = cfg.database.createLocally -> cfg.database.user == user; 385 message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; 386 }) eachSite) ++ 387 (mapAttrsToList (hostName: cfg: 388 { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; 389 message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.''; 390 }) eachSite); 391 392 393 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { 394 enable = true; 395 package = mkDefault pkgs.mariadb; 396 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; 397 ensureUsers = mapAttrsToList (hostName: cfg: 398 { name = cfg.database.user; 399 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; 400 } 401 ) eachSite; 402 }; 403 404 services.phpfpm.pools = mapAttrs' (hostName: cfg: ( 405 nameValuePair "wordpress-${hostName}" { 406 inherit user; 407 group = webserver.group; 408 settings = { 409 "listen.owner" = webserver.user; 410 "listen.group" = webserver.group; 411 } // cfg.poolConfig; 412 } 413 )) eachSite; 414 415 } 416 417 (mkIf (cfg.webserver == "httpd") { 418 services.httpd = { 419 enable = true; 420 extraModules = [ "proxy_fcgi" ]; 421 virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost { 422 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress"; 423 extraConfig = '' 424 <Directory "${pkg hostName cfg}/share/wordpress"> 425 <FilesMatch "\.php$"> 426 <If "-f %{REQUEST_FILENAME}"> 427 SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/" 428 </If> 429 </FilesMatch> 430 431 # standard wordpress .htaccess contents 432 <IfModule mod_rewrite.c> 433 RewriteEngine On 434 RewriteBase / 435 RewriteRule ^index\.php$ - [L] 436 RewriteCond %{REQUEST_FILENAME} !-f 437 RewriteCond %{REQUEST_FILENAME} !-d 438 RewriteRule . /index.php [L] 439 </IfModule> 440 441 DirectoryIndex index.php 442 Require all granted 443 Options +FollowSymLinks -Indexes 444 </Directory> 445 446 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php 447 <Files wp-config.php> 448 Require all denied 449 </Files> 450 ''; 451 } ]) eachSite; 452 }; 453 }) 454 455 { 456 systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [ 457 "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -" 458 "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" 459 "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" 460 "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" 461 "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" 462 ]) eachSite); 463 464 systemd.services = mkMerge [ 465 (mapAttrs' (hostName: cfg: ( 466 nameValuePair "wordpress-init-${hostName}" { 467 wantedBy = [ "multi-user.target" ]; 468 before = [ "phpfpm-wordpress-${hostName}.service" ]; 469 after = optional cfg.database.createLocally "mysql.service"; 470 script = secretsScript (stateDir hostName); 471 472 serviceConfig = { 473 Type = "oneshot"; 474 User = user; 475 Group = webserver.group; 476 }; 477 })) eachSite) 478 479 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { 480 httpd.after = [ "mysql.service" ]; 481 }) 482 ]; 483 484 users.users.${user} = { 485 group = webserver.group; 486 isSystemUser = true; 487 }; 488 } 489 490 (mkIf (cfg.webserver == "nginx") { 491 services.nginx = { 492 enable = true; 493 virtualHosts = mapAttrs (hostName: cfg: { 494 serverName = mkDefault hostName; 495 root = "${pkg hostName cfg}/share/wordpress"; 496 extraConfig = '' 497 index index.php; 498 ''; 499 locations = { 500 "/" = { 501 priority = 200; 502 extraConfig = '' 503 try_files $uri $uri/ /index.php$is_args$args; 504 ''; 505 }; 506 "~ \\.php$" = { 507 priority = 500; 508 extraConfig = '' 509 fastcgi_split_path_info ^(.+\.php)(/.+)$; 510 fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}; 511 fastcgi_index index.php; 512 include "${config.services.nginx.package}/conf/fastcgi.conf"; 513 fastcgi_param PATH_INFO $fastcgi_path_info; 514 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; 515 # Mitigate https://httpoxy.org/ vulnerabilities 516 fastcgi_param HTTP_PROXY ""; 517 fastcgi_intercept_errors off; 518 fastcgi_buffer_size 16k; 519 fastcgi_buffers 4 16k; 520 fastcgi_connect_timeout 300; 521 fastcgi_send_timeout 300; 522 fastcgi_read_timeout 300; 523 ''; 524 }; 525 "~ /\\." = { 526 priority = 800; 527 extraConfig = "deny all;"; 528 }; 529 "~* /(?:uploads|files)/.*\\.php$" = { 530 priority = 900; 531 extraConfig = "deny all;"; 532 }; 533 "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = { 534 priority = 1000; 535 extraConfig = '' 536 expires max; 537 log_not_found off; 538 ''; 539 }; 540 }; 541 }) eachSite; 542 }; 543 }) 544 545 (mkIf (cfg.webserver == "caddy") { 546 services.caddy = { 547 enable = true; 548 virtualHosts = mapAttrs' (hostName: cfg: ( 549 nameValuePair "http://${hostName}" { 550 extraConfig = '' 551 root * /${pkg hostName cfg}/share/wordpress 552 file_server 553 554 php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket} 555 556 @uploads { 557 path_regexp path /uploads\/(.*)\.php 558 } 559 rewrite @uploads / 560 561 @wp-admin { 562 path not ^\/wp-admin/* 563 } 564 rewrite @wp-admin {path}/index.php?{query} 565 ''; 566 } 567 )) eachSite; 568 }; 569 }) 570 571 572 ]); 573}