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