From 223d142ea085c2a8f1afcd5a0a6885c749b41635 Mon Sep 17 00:00:00 2001 From: Andrew Benbow Date: Mon, 2 Jun 2025 16:56:22 -0400 Subject: [PATCH] nixos/drupal: init --- nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/drupal.nix | 478 +++++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/drupal.nix | 27 ++ 4 files changed, 507 insertions(+) create mode 100644 nixos/modules/services/web-apps/drupal.nix create mode 100644 nixos/tests/drupal.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 79f5c22f5b98..6b2e1ae03f44 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1533,6 +1533,7 @@ ./services/web-apps/documize.nix ./services/web-apps/dokuwiki.nix ./services/web-apps/dolibarr.nix + ./services/web-apps/drupal.nix ./services/web-apps/echoip.nix ./services/web-apps/eintopf.nix ./services/web-apps/engelsystem.nix diff --git a/nixos/modules/services/web-apps/drupal.nix b/nixos/modules/services/web-apps/drupal.nix new file mode 100644 index 000000000000..3d6726c7393b --- /dev/null +++ b/nixos/modules/services/web-apps/drupal.nix @@ -0,0 +1,478 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + any + attrValues + flatten + literalExpression + mapAttrs + mapAttrs' + mapAttrsToList + mkDefault + mkEnableOption + mkIf + mkMerge + mkOption + mkPackageOption + nameValuePair + optionalAttrs + types + ; + inherit (pkgs) + mariadb + stdenv + writeShellScript + ; + cfg = config.services.drupal; + eachSite = cfg.sites; + user = "drupal"; + webserver = config.services.${cfg.webserver}; + + pkg = + hostName: cfg: + stdenv.mkDerivation (finalAttrs: { + pname = "drupal-${hostName}"; + name = "drupal-${hostName}"; + src = cfg.package; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -r * $out/ + + runHook postInstall + ''; + + postInstallPhase = '' + ln -s ${cfg.filesDir} $out/share/php/drupal/sites/default/files + ln -s ${cfg.stateDir}/sites/default/settings.php $out/share/php/drupal/sites/default/settings.php + ln -s ${cfg.modulesDir} $out/share/php/drupal/modules + ln -s ${cfg.themesDir} $out/share/php/drupal/themes + ''; + }); + + siteOpts = + { + options, + config, + lib, + name, + ... + }: + { + options = { + enable = mkEnableOption "Drupal web application"; + package = mkPackageOption pkgs "drupal" { }; + + filesDir = mkOption { + type = types.path; + default = "/var/lib/drupal/${name}/sites/default/files"; + defaultText = "/var/lib/drupal//sites/default/files"; + description = '' + The location of the Drupal files directory. + ''; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/drupal/${name}"; + defaultText = "/var/lib/drupal/"; + description = "The location of the Drupal site state directory."; + }; + + modulesDir = mkOption { + type = types.path; + default = "/var/lib/drupal/${name}/modules"; + defaultText = "/var/lib/drupal//modules"; + description = "The location of Drupal modules."; + }; + + themesDir = mkOption { + type = types.path; + default = "/var/lib/drupal/${name}/themes"; + defaultText = "/varlib/drupal//themes"; + description = "The location of Drupal themes."; + }; + + phpOptions = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Options for PHP's php.ini file for this Drupal site. + ''; + example = literalExpression '' + { + "opcache.interned_strings_buffer" = "8"; + "opcache.max_accelerated_files" = "10000"; + "opcache.memory_consumption" = "128"; + "opcache.revalidate_freq" = "15"; + "opcache.fast_shutdown" = "1"; + } + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "drupal"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "drupal"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/database-dbpassword"; + description = '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + tablePrefix = mkOption { + type = types.str; + default = "dp_"; + description = '' + The $table_prefix is the value placed in the front of your database tables. + Change the value if you want to use something other than dp_ for your database + prefix. Typically this is changed if you are installing multiple Drupal sites + in the same database. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. + ''; + }; + + poolConfig = mkOption { + type = + with types; + attrsOf (oneOf [ + str + int + bool + ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the Drupal PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + }; + + config.virtualHost.hostName = mkDefault name; + }; +in +{ + options = { + services.drupal = { + enable = mkEnableOption "drupal"; + package = mkPackageOption pkgs "drupal" { }; + + sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = { + "localhost" = { + enable = true; + }; + }; + description = "Specification of one or more Drupal sites to serve"; + }; + + webserver = mkOption { + type = types.enum [ + "nginx" + "caddy" + ]; + default = "nginx"; + description = '' + Whether to use nginx or caddy for virtual host management. + + Further nginx configuration can be done by adapting `services.nginx.virtualHosts.`. + See [](#opt-services.nginx.virtualHosts) for further information. + + Further caddy configuration can be done by adapting `services.caddy.virtualHosts.`. + See [](#opt-services.caddy.virtualHosts) for further information. + ''; + }; + }; + }; + + config = mkIf (cfg.enable) (mkMerge [ + { + + assertions = + (mapAttrsToList (hostName: cfg: { + assertion = cfg.database.createLocally -> cfg.database.user == user; + message = ''services.drupal.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; + }) eachSite) + ++ (mapAttrsToList (hostName: cfg: { + assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = ''services.drupal.sites."${hostName}".database.passwordFile cannot be specified if services.drupal.sites."${hostName}".database.createLocally is set to true.''; + }) eachSite); + + services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { + enable = true; + package = mkDefault mariadb; + ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; + ensureUsers = mapAttrsToList (hostName: cfg: { + name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "ALL PRIVILEGES"; + }; + }) eachSite; + }; + + services.phpfpm.pools = mapAttrs' ( + hostName: cfg: + (nameValuePair "drupal-${hostName}" { + inherit user; + group = webserver.group; + settings = { + "listen.owner" = webserver.user; + "listen.group" = webserver.group; + } // cfg.poolConfig; + }) + ) eachSite; + } + + { + systemd.tmpfiles.rules = flatten ( + mapAttrsToList (hostName: cfg: [ + "d '${cfg.stateDir}' 0750 ${user} ${webserver.group} - -" + "d '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" + "Z '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" + "d '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" + "Z '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" + ]) eachSite + ); + + users.users.${user} = { + group = webserver.group; + isSystemUser = true; + }; + } + + { + # Run a service that prepares the state directory. + systemd.services = mkMerge [ + (mapAttrs' ( + hostName: cfg: + (nameValuePair "drupal-state-init-${hostName}" { + wantedBy = [ "multi-user.target" ]; + before = [ "nginx.service" ]; + after = [ "local-fs.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = "root"; + ExecStart = writeShellScript "drupal-state-init-${hostName}" '' + set -e + + if [ ! -d "${cfg.stateDir}/sites" ]; then + echo "Preparing sites directory..." + cp -r "${cfg.package}/share/php/drupal/sites" "${cfg.stateDir}" + fi + + if [ ! -d "${cfg.filesDir}" ]; then + echo "Preparing files directory..." + mkdir -p "${cfg.filesDir}" + chown -R ${user}:${webserver.group} ${cfg.filesDir} + fi + + settings="${cfg.stateDir}/sites/default/settings.php" + defaultSettings="${cfg.package}/share/php/drupal/sites/default/default.settings.php" + + if [ ! -f "$settings" ]; then + echo "Preparing settings.php for ${hostName}..." + cp "$defaultSettings" "$settings" + chmod 644 "$settings" + fi + + # Set or reset file permissions so that the web user and webserver owns them. + chown -R ${user}:${webserver.group} ${cfg.stateDir} + ''; + }; + }) + ) eachSite) + + (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { + httpd.after = [ "mysql.service" ]; + }) + ]; + } + + (mkIf (cfg.webserver == "nginx") { + services.nginx = { + enable = true; + virtualHosts = mapAttrs (hostName: cfg: { + serverName = mkDefault hostName; + root = "${pkg hostName cfg}/share/php/drupal"; + extraConfig = '' + index index.php; + ''; + locations = { + "~ '\.php$|^/update.php'" = { + extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools."drupal-${hostName}".socket}; + fastcgi_index index.php; + include "${config.services.nginx.package}/conf/fastcgi.conf"; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; + # Mitigate https://httpoxy.org/ vulnerabilities + fastcgi_param HTTP_PROXY ""; + fastcgi_intercept_errors off; + fastcgi_buffer_size 16k; + fastcgi_buffers 4 16k; + fastcgi_connect_timeout 300; + fastcgi_send_timeout 300; + fastcgi_read_timeout 300; + ''; + }; + "= /favicon.ico" = { + extraConfig = '' + log_not_found off; + access_log off; + ''; + }; + "= /robots.txt" = { + extraConfig = '' + allow all; + log_not_found off; + access_log off; + ''; + }; + "~ \..*/.*\.php$" = { + extraConfig = '' + return 403; + ''; + }; + "~ ^/sites/.*/private/" = { + extraConfig = '' + return 403; + ''; + }; + "~ ^/sites/[^/]+/files/.*\.php$" = { + extraConfig = '' + deny all; + ''; + }; + "~* ^/.well-known/" = { + extraConfig = '' + allow all; + ''; + }; + "/" = { + extraConfig = '' + try_files $uri /index.php?$query_string; + ''; + }; + "@rewrite" = { + extraConfig = '' + rewrite ^ /index.php; + ''; + }; + "~ /vendor/.*\.php$" = { + extraConfig = '' + deny all; + return 404; + ''; + }; + "~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = { + extraConfig = '' + try_files $uri @rewrite; + expires max; + log_not_found off; + ''; + }; + "~ ^/sites/.*/files/styles/" = { + extraConfig = '' + try_files $uri @rewrite; + ''; + }; + "~ ^(/[a-z\-]+)?/system/files/" = { + extraConfig = '' + try_files $uri /index.php?$query_string; + ''; + }; + }; + }) eachSite; + }; + }) + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' ( + hostName: cfg: + (nameValuePair "http://${hostName}" { + extraConfig = '' + root * ${pkg hostName cfg}/share/php/drupal + file_server + + encode zstd gzip + php_fastcgi unix/${config.services.phpfpm.pools."drupal-${hostName}".socket} + ''; + }) + ) cfg.sites; + }; + }) + + ]); +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 7a0bdb1888ec..b03125926e88 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -410,6 +410,7 @@ in drawterm = discoverTests (import ./drawterm.nix); drbd = runTest ./drbd.nix; druid = handleTestOn [ "x86_64-linux" ] ./druid { }; + drupal = runTest ./drupal.nix; drbd-driver = runTest ./drbd-driver.nix; dublin-traceroute = runTest ./dublin-traceroute.nix; earlyoom = handleTestOn [ "x86_64-linux" ] ./earlyoom.nix { }; diff --git a/nixos/tests/drupal.nix b/nixos/tests/drupal.nix new file mode 100644 index 000000000000..193061a8dc42 --- /dev/null +++ b/nixos/tests/drupal.nix @@ -0,0 +1,27 @@ +{ lib, ... }: + +{ + name = "drupal"; + + nodes = { + machine_default = + { pkgs, ... }: + { + services.drupal = { + enable = true; + }; + }; + }; + + testScript = '' + machine_default.start() + machine_default.wait_for_unit("phpfpm-drupal-localhost.service") + machine_default.wait_for_unit("nginx.service") + machine_default.wait_for_unit("mysql.service") + ''; + + meta.maintainers = [ + lib.maintainers.drupol + lib.maintainers.OulipianSummer + ]; +}