about summary refs log tree commit diff
diff options
context:
space:
mode:
authortoastal <toastal@posteo.net>2024-03-22 23:49:50 +0700
committertoastal <toastal@posteo.net>2024-04-11 23:26:11 +0700
commitfcc7c53e9c833a9ee40b790c62bcbc0543170d50 (patch)
tree2e6e06fad8bdbe39cb80fc204a0c82a5c73444c4
parent47918937b8b017764161116c0346a9f09f3f83ab (diff)
nixos/movim: add service module
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/services/web-apps/movim.nix602
-rw-r--r--nixos/tests/all-tests.nix1
-rw-r--r--nixos/tests/web-apps/movim/default.nix8
-rw-r--r--nixos/tests/web-apps/movim/standard.nix102
-rw-r--r--pkgs/by-name/mo/movim/package.nix20
6 files changed, 734 insertions, 0 deletions
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 9fc036f9213a5..db0f0f0870e78 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1363,6 +1363,7 @@
   ./services/web-apps/miniflux.nix
   ./services/web-apps/monica.nix
   ./services/web-apps/moodle.nix
+  ./services/web-apps/movim.nix
   ./services/web-apps/netbox.nix
   ./services/web-apps/nextcloud.nix
   ./services/web-apps/nextcloud-notify_push.nix
diff --git a/nixos/modules/services/web-apps/movim.nix b/nixos/modules/services/web-apps/movim.nix
new file mode 100644
index 0000000000000..c9314e28e9493
--- /dev/null
+++ b/nixos/modules/services/web-apps/movim.nix
@@ -0,0 +1,602 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib)
+    filterAttrsRecursive
+    generators
+    literalExpression
+    mkDefault
+    mkIf
+    mkOption
+    mkEnableOption
+    mkPackageOption
+    mkMerge
+    pipe
+    types
+    ;
+
+  cfg = config.services.movim;
+
+  defaultPHPCfg = {
+    "output_buffering" = 0;
+    "error_reporting" = "E_ALL & ~E_DEPRECATED & ~E_STRICT";
+    "opcache.enable_cli" = 1;
+    "opcache.interned_strings_buffer" = 8;
+    "opcache.max_accelerated_files" = 6144;
+    "opcache.memory_consumption" = 128;
+    "opcache.revalidate_freq" = 2;
+    "opcache.fast_shutdown" = 1;
+  };
+
+  phpCfg = generators.toKeyValue
+    { mkKeyValue = generators.mkKeyValueDefault { } " = "; }
+    (defaultPHPCfg // cfg.phpCfg);
+
+  podConfigFlags =
+    let
+      bevalue = a: lib.escapeShellArg (generators.mkValueStringDefault { } a);
+    in
+    lib.concatStringsSep " "
+      (lib.attrsets.foldlAttrs
+        (acc: k: v: acc ++ lib.optional (v != null) "--${k}=${bevalue v}")
+        [ ]
+        cfg.podConfig);
+
+  package =
+    let
+      p = cfg.package.override {
+        inherit phpCfg;
+        withPgsql = cfg.database.type == "pgsql";
+        withMysql = cfg.database.type == "mysql";
+      };
+    in
+    p.overrideAttrs (finalAttrs: prevAttrs:
+      let
+        appDir = "$out/share/php/${finalAttrs.pname}";
+
+        stateDirectories = ''
+          # Symlinking in our state directories
+          rm -rf $out/.env $out/cache ${appDir}/public/cache
+          ln -s ${cfg.dataDir}/.env ${appDir}/.env
+          ln -s ${cfg.dataDir}/public/cache ${appDir}/public/cache
+          ln -s ${cfg.logDir} ${appDir}/log
+          ln -s ${cfg.runtimeDir}/cache ${appDir}/cache
+        '';
+
+        exposeComposer = ''
+          # Expose PHP Composer for scripts
+          mkdir -p $out/bin
+          echo "#!${lib.getExe pkgs.dash}" > $out/bin/movim-composer
+          echo "${finalAttrs.php.packages.composer}/bin/composer --working-dir="${appDir}" \"\$@\"" >> $out/bin/movim-composer
+          chmod +x $out/bin/movim-composer
+        '';
+
+        podConfigInputDisableReplace = lib.optionalString (podConfigFlags != "")
+          (lib.concatStringsSep "\n"
+            (lib.attrsets.foldlAttrs
+              (acc: k: v:
+                acc ++ lib.optional (v != null)
+                  # Disable all Admin panel options that were set in the
+                  # `cfg.podConfig` to prevent confusing situtions where the
+                  # values are rewritten on server reboot
+                  ''
+                    substituteInPlace ${appDir}/app/widgets/AdminMain/adminmain.tpl \
+                      --replace-warn 'name="${k}"' 'name="${k}" disabled'
+                  '')
+              [ ]
+              cfg.podConfig));
+      in
+      {
+        postInstall = lib.concatStringsSep "\n\n" [
+          prevAttrs.postInstall
+          stateDirectories
+          exposeComposer
+          podConfigInputDisableReplace
+        ];
+      });
+
+  configFile = pipe cfg.settings [
+    (filterAttrsRecursive (_: v: v != null))
+    (generators.toKeyValue { })
+    (pkgs.writeText "movim-env")
+  ];
+
+  pool = "movim";
+  fpm = config.services.phpfpm.pools.${pool};
+  phpExecutionUnit = "phpfpm-${pool}";
+
+  dbService = {
+    "postgresql" = "postgresql.service";
+    "mysql" = "mysql.service";
+  }.${cfg.database.type};
+in
+{
+  options.services = {
+    movim = {
+      enable = mkEnableOption "a Movim instance";
+      package = mkPackageOption pkgs "movim" { };
+      phpPackage = mkPackageOption pkgs "php" { };
+
+      phpCfg = mkOption {
+        type = with types; attrsOf (oneOf [ int str bool ]);
+        defaultText = literalExpression (generators.toPretty { } defaultPHPCfg);
+        default = { };
+        description = "Extra PHP INI options such as `memory_limit`, `max_execution_time`, etc.";
+      };
+
+      user = mkOption {
+        type = types.nonEmptyStr;
+        default = "movim";
+        description = "User running Movim service";
+      };
+
+      group = mkOption {
+        type = types.nonEmptyStr;
+        default = "movim";
+        description = "Group running Movim service";
+      };
+
+      dataDir = mkOption {
+        type = types.nonEmptyStr;
+        default = "/var/lib/movim";
+        description = "State directory of the `movim` user which holds the application’s state & data.";
+      };
+
+      logDir = mkOption {
+        type = types.nonEmptyStr;
+        default = "/var/log/movim";
+        description = "Log directory of the `movim` user which holds the application’s logs.";
+      };
+
+      runtimeDir = mkOption {
+        type = types.nonEmptyStr;
+        default = "/run/movim";
+        description = "Runtime directory of the `movim` user which holds the application’s caches & temporary files.";
+      };
+
+      domain = mkOption {
+        type = types.nonEmptyStr;
+        description = "Fully-qualified domain name (FQDN) for the Movim instance.";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 8080;
+        description = "Movim daemon port.";
+      };
+
+      debug = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Debugging logs.";
+      };
+
+      verbose = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Verbose logs.";
+      };
+
+      podConfig = mkOption {
+        type = types.submodule {
+          options = {
+            info = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "Content of the info box on the login page";
+            };
+
+            description = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "General description of the instance";
+            };
+
+            timezone = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "The server timezone";
+            };
+
+            restrictsuggestions = mkOption {
+              type = with types; nullOr bool;
+              default = null;
+              description = "Only suggest chatrooms, Communities and other contents that are available on the user XMPP server and related services";
+            };
+
+            chatonly = mkOption {
+              type = with types; nullOr bool;
+              default = null;
+              description = "Disable all the social feature (Communities, Blog…) and keep only the chat ones";
+            };
+
+            disableregistration = mkOption {
+              type = with types; nullOr bool;
+              default = null;
+              description = "Remove the XMPP registration flow and buttons from the interface";
+            };
+
+            loglevel = mkOption {
+              type = with types; nullOr (ints.between 0 3);
+              default = null;
+              description = "The server loglevel";
+            };
+
+            locale = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "The server main locale";
+            };
+
+            xmppdomain = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "The default XMPP server domain";
+            };
+
+            xmppdescription = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "The default XMPP server description";
+            };
+
+            xmppwhitelist = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = "The allowlisted XMPP servers";
+            };
+          };
+        };
+        default = { };
+        description = ''
+          Pod configuration (values from `php daemon.php config --help`).
+          Note that these values will now be disabled in the admin panel.
+        '';
+      };
+
+      settings = mkOption {
+        type = with types; attrsOf (nullOr (oneOf [ int str bool ]));
+        default = { };
+        description = ".env settings for Movim. Secrets should use `secretFile` option instead. `null`s will be culled.";
+      };
+
+      secretFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        description = "The secret file to be sourced for the .env settings.";
+      };
+
+      database = {
+        type = mkOption {
+          type = types.enum [ "mysql" "postgresql" ];
+          example = "mysql";
+          default = "postgresql";
+          description = "Database engine to use.";
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "movim";
+          description = "Database name.";
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "movim";
+          description = "Database username.";
+        };
+
+        createLocally = mkOption {
+          type = types.bool;
+          default = true;
+          description = "local database using UNIX socket authentication";
+        };
+      };
+
+      nginx = mkOption {
+        type = with types; nullOr (submodule
+          (import ../web-servers/nginx/vhost-options.nix {
+            inherit config lib;
+          }));
+        default = null;
+        example = lib.literalExpression /* nginx */ ''
+          {
+            serverAliases = [
+              "pics.''${config.networking.domain}"
+            ];
+            enableACME = true;
+            forceHttps = true;
+          }
+        '';
+        description = ''
+          With this option, you can customize an nginx virtual host which already has sensible defaults for Movim.
+          Set to `{ }` if you do not need any customization to the virtual host.
+          If enabled, then by default, the {option}`serverName` is `''${domain}`,
+          If this is set to null (the default), no nginx virtualHost will be configured.
+        '';
+      };
+
+      poolConfig = mkOption {
+        type = with types; attrsOf (oneOf [ int str bool ]);
+        default = { };
+        description = "Options for Movim’s PHP-FPM pool.";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    environment.systemPackages = [ cfg.package ];
+
+    users = {
+      users = {
+        movim = mkIf (cfg.user == "movim") {
+          isSystemUser = true;
+          group = cfg.group;
+        };
+        "${config.services.nginx.user}".extraGroups = [ cfg.group ];
+      };
+      groups = {
+        ${cfg.group} = { };
+      };
+    };
+
+    services = {
+      movim = {
+        settings = mkMerge [
+          {
+            DAEMON_URL = "//${cfg.domain}";
+            DAEMON_PORT = cfg.port;
+            DAEMON_INTERFACE = "127.0.0.1";
+            DAEMON_DEBUG = cfg.debug;
+            DAEMON_VERBOSE = cfg.verbose;
+          }
+          (mkIf cfg.database.createLocally {
+            DB_DRIVER = {
+              "postgresql" = "pgsql";
+              "mysql" = "mysql";
+            }.${cfg.database.type};
+            DB_HOST = "localhost";
+            DB_PORT = config.services.${cfg.database.type}.settings.port;
+            DB_DATABASE = cfg.database.name;
+            DB_USERNAME = cfg.database.user;
+            DB_PASSWORD = "";
+          })
+        ];
+
+        poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
+          "pm" = "dynamic";
+          "php_admin_value[error_log]" = "stderr";
+          "php_admin_flag[log_errors]" = true;
+          "catch_workers_output" = true;
+          "pm.max_children" = 32;
+          "pm.start_servers" = 2;
+          "pm.min_spare_servers" = 2;
+          "pm.max_spare_servers" = 8;
+          "pm.max_requests" = 500;
+        };
+      };
+
+      nginx = mkIf (cfg.nginx != null) {
+        enable = true;
+        recommendedOptimisation = true;
+        recommendedGzipSettings = true;
+        recommendedBrotliSettings = true;
+        recommendedProxySettings = true;
+        # TODO: recommended cache options already in Nginx⁇
+        appendHttpConfig = /* nginx */ ''
+          fastcgi_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginx_cache:100m inactive=60m;
+          fastcgi_cache_key "$scheme$request_method$host$request_uri";
+        '';
+        virtualHosts."${cfg.domain}" = mkMerge [
+          cfg.nginx
+          {
+            root = lib.mkForce "${package}/share/php/movim/public";
+            locations = {
+              "/favicon.ico" = {
+                priority = 100;
+                extraConfig = /* nginx */ ''
+                  access_log off;
+                  log_not_found off;
+                '';
+              };
+              "/robots.txt" = {
+                priority = 100;
+                extraConfig = /* nginx */ ''
+                  access_log off;
+                  log_not_found off;
+                '';
+              };
+              "~ /\\.(?!well-known).*" = {
+                priority = 210;
+                extraConfig = /* nginx */ ''
+                  deny all;
+                '';
+              };
+              # Ask nginx to cache every URL starting with "/picture"
+              "/picture" = {
+                priority = 400;
+                tryFiles = "$uri $uri/ /index.php$is_args$args";
+                extraConfig = /* nginx */ ''
+                  set $no_cache 0; # Enable cache only there
+                '';
+              };
+              "/" = {
+                priority = 490;
+                tryFiles = "$uri $uri/ /index.php$is_args$args";
+                extraConfig = /* nginx */ ''
+                  # https://github.com/movim/movim/issues/314
+                  add_header Content-Security-Policy "default-src 'self'; img-src 'self' aesgcm: https:; media-src 'self' aesgcm: https:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';";
+                  set $no_cache 1;
+                '';
+              };
+              "~ \\.php$" = {
+                priority = 500;
+                tryFiles = "$uri =404";
+                extraConfig = /* nginx */ ''
+                  include ${config.services.nginx.package}/conf/fastcgi.conf;
+                  add_header X-Cache $upstream_cache_status;
+                  fastcgi_ignore_headers "Cache-Control" "Expires" "Set-Cookie";
+                  fastcgi_cache nginx_cache;
+                  fastcgi_cache_valid any 7d;
+                  fastcgi_cache_bypass $no_cache;
+                  fastcgi_no_cache $no_cache;
+                  fastcgi_split_path_info ^(.+\.php)(/.+)$;
+                  fastcgi_index index.php;
+                  fastcgi_pass unix:${fpm.socket};
+                '';
+              };
+              "/ws/" = {
+                priority = 900;
+                proxyPass = "http://${cfg.settings.DAEMON_INTERFACE}:${builtins.toString cfg.port}/";
+                proxyWebsockets = true;
+                recommendedProxySettings = true;
+                extraConfig = /* nginx */ ''
+                  proxy_set_header X-Forwarded-Proto $scheme;
+                  proxy_redirect off;
+                '';
+              };
+            };
+            extraConfig = /* ngnix */ ''
+              index index.php;
+            '';
+          }
+        ];
+      };
+
+      mysql = mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
+        enable = mkDefault true;
+        package = mkDefault pkgs.mariadb;
+        ensureDatabases = [ cfg.database.name ];
+        ensureUsers = [{
+          name = cfg.user;
+          ensureDBOwnership = true;
+        }];
+      };
+
+      postgresql = mkIf (cfg.database.createLocally && cfg.database.type == "postgresql") {
+        enable = mkDefault true;
+        ensureDatabases = [ cfg.database.name ];
+        ensureUsers = [{
+          name = cfg.user;
+          ensureDBOwnership = true;
+        }];
+        authentication = ''
+          host ${cfg.database.name} ${cfg.database.user} localhost trust
+        '';
+      };
+
+      phpfpm.pools.${pool} =
+        let
+          socketOwner =
+            if (cfg.nginx != null)
+            then config.services.nginx.user
+            else cfg.user;
+        in
+        {
+          phpPackage = package.php;
+          user = cfg.user;
+          group = cfg.group;
+
+          phpOptions = ''
+            error_log = 'stderr'
+            log_errors = on
+          '';
+
+          settings = {
+            "listen.owner" = socketOwner;
+            "listen.group" = cfg.group;
+            "listen.mode" = "0660";
+            "catch_workers_output" = true;
+          } // cfg.poolConfig;
+        };
+    };
+
+    systemd = {
+      services.movim-data-setup = {
+        description = "Movim setup: .env file, databases init, cache reload";
+        wantedBy = [ "multi-user.target" ];
+        requiredBy = [ "${phpExecutionUnit}.service" ];
+        before = [ "${phpExecutionUnit}.service" ];
+        after = lib.optional cfg.database.createLocally dbService;
+        requires = lib.optional cfg.database.createLocally dbService;
+
+        serviceConfig = {
+          Type = "oneshot";
+          User = cfg.user;
+          Group = cfg.group;
+          UMask = "077";
+        } // lib.optionalAttrs (cfg.secretFile != null) {
+          LoadCredential = "env-secrets:${cfg.secretFile}";
+        };
+
+        script = ''
+          # Env vars
+          rm -f ${cfg.dataDir}/.env
+          cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
+          echo -e '\n' >> ${cfg.dataDir}/.env
+          if [[ -f "$CREDENTIALS_DIRECTORY/env-secrets"  ]]; then
+            cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
+            echo -e '\n' >> ${cfg.dataDir}/.env
+          fi
+
+          # Caches, logs
+          mkdir -p ${cfg.dataDir}/public/cache ${cfg.logDir} ${cfg.runtimeDir}/cache
+          chmod -R ug+rw ${cfg.dataDir}/public/cache
+          chmod -R ug+rw ${cfg.logDir}
+          chmod -R ug+rwx ${cfg.runtimeDir}/cache
+
+          # Migrations
+          MOVIM_VERSION="${package.version}"
+          if [[ ! -f "${cfg.dataDir}/.migration-version" ]] || [[ "$MOVIM_VERSION" != "$(<${cfg.dataDir}/.migration-version)" ]]; then
+            ${package}/bin/movim-composer movim:migrate && echo $MOVIM_VERSION > ${cfg.dataDir}/.migration-version
+          fi
+        ''
+        + lib.optionalString (podConfigFlags != "") (
+          let
+            flags = lib.concatStringsSep " "
+              ([ "--no-interaction" ]
+                ++ lib.optional cfg.debug "-vvv"
+                ++ lib.optional (!cfg.debug && cfg.verbose) "-v");
+          in
+          ''
+            ${lib.getExe package} config ${podConfigFlags}
+          ''
+        );
+      };
+
+      services.movim = {
+        description = "Movim daemon";
+        wantedBy = [ "multi-user.target" ];
+        after = [ "movim-data-setup.service" ];
+        requires = [ "movim-data-setup.service" ]
+          ++ lib.optional cfg.database.createLocally dbService;
+        environment = {
+          PUBLIC_URL = "//${cfg.domain}";
+          WS_PORT = builtins.toString cfg.port;
+        };
+
+        serviceConfig = {
+          User = cfg.user;
+          Group = cfg.group;
+          WorkingDirectory = "${package}/share/php/movim";
+          ExecStart = "${lib.getExe package} start";
+        };
+      };
+
+      services.${phpExecutionUnit} = {
+        after = [ "movim-data-setup.service" ];
+        requires = [ "movim-data-setup.service" ]
+          ++ lib.optional cfg.database.createLocally dbService;
+      };
+
+      tmpfiles.settings."10-movim" = with cfg; {
+        "${dataDir}".d = { inherit user group; mode = "0710"; };
+        "${dataDir}/public".d = { inherit user group; mode = "0750"; };
+        "${dataDir}/public/cache".d = { inherit user group; mode = "0750"; };
+        "${runtimeDir}".d = { inherit user group; mode = "0700"; };
+        "${runtimeDir}/cache".d = { inherit user group; mode = "0700"; };
+        "${logDir}".d = { inherit user group; mode = "0700"; };
+      };
+    };
+  };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 7d120d6bc09e8..909eea38b35e0 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -558,6 +558,7 @@ in {
   morty = handleTest ./morty.nix {};
   mosquitto = handleTest ./mosquitto.nix {};
   moosefs = handleTest ./moosefs.nix {};
+  movim = discoverTests (import ./web-apps/movim { inherit handleTestOn; });
   mpd = handleTest ./mpd.nix {};
   mpv = handleTest ./mpv.nix {};
   mtp = handleTest ./mtp.nix {};
diff --git a/nixos/tests/web-apps/movim/default.nix b/nixos/tests/web-apps/movim/default.nix
new file mode 100644
index 0000000000000..5d6314e2b41be
--- /dev/null
+++ b/nixos/tests/web-apps/movim/default.nix
@@ -0,0 +1,8 @@
+{ system ? builtins.currentSystem, handleTestOn }:
+
+let
+  supportedSystems = [ "x86_64-linux" "i686-linux" ];
+in
+{
+  standard = handleTestOn supportedSystems ./standard.nix { inherit system; };
+}
diff --git a/nixos/tests/web-apps/movim/standard.nix b/nixos/tests/web-apps/movim/standard.nix
new file mode 100644
index 0000000000000..470d81d8f7229
--- /dev/null
+++ b/nixos/tests/web-apps/movim/standard.nix
@@ -0,0 +1,102 @@
+import ../../make-test-python.nix ({ lib, pkgs, ... }:
+
+let
+  movim = {
+    domain = "movim.local";
+    info = "No ToS in tests";
+    description = "NixOS testing server";
+  };
+  xmpp = {
+    domain = "xmpp.local";
+    admin = rec {
+      JID = "${username}@${xmpp.domain}";
+      username = "romeo";
+      password = "juliet";
+    };
+  };
+in
+{
+  name = "movim-standard";
+
+  meta = {
+    maintainers = with pkgs.lib.maintainers; [ toastal ];
+  };
+
+  nodes = {
+    server = { pkgs, ... }: {
+      services.movim = {
+        inherit (movim) domain;
+        enable = true;
+        verbose = true;
+        podConfig = {
+          inherit (movim) description info;
+          xmppdomain = xmpp.domain;
+        };
+        nginx = { };
+      };
+
+      services.prosody = {
+        enable = true;
+        xmppComplianceSuite = false;
+        disco_items = [
+          { url = "upload.${xmpp.domain}"; description = "File Uploads"; }
+        ];
+        virtualHosts."${xmpp.domain}" = {
+          inherit (xmpp) domain;
+          enabled = true;
+          extraConfig = ''
+            Component "pubsub.${xmpp.domain}" "pubsub"
+                pubsub_max_items = 10000
+                expose_publisher = true
+
+            Component "upload.${xmpp.domain}" "http_file_share"
+                http_external_url = "http://upload.${xmpp.domain}"
+                http_file_share_expires_after = 300 * 24 * 60 * 60
+                http_file_share_size_limit = 1024 * 1024 * 1024
+                http_file_share_daily_quota = 4 * 1024 * 1024 * 1024
+          '';
+        };
+        extraConfig = ''
+          pep_max_items = 10000
+
+          http_paths = {
+              file_share = "/";
+          }
+        '';
+      };
+
+      networking.extraHosts = ''
+        127.0.0.1 ${movim.domain}
+        127.0.0.1 ${xmpp.domain}
+      '';
+    };
+  };
+
+  testScript = /* python */ ''
+    server.wait_for_unit("phpfpm-movim.service")
+    server.wait_for_unit("nginx.service")
+    server.wait_for_open_port(80)
+
+    server.wait_for_unit("prosody.service")
+    server.succeed('prosodyctl status | grep "Prosody is running"')
+    server.succeed("prosodyctl register ${xmpp.admin.username} ${xmpp.domain} ${xmpp.admin.password}")
+
+    server.wait_for_unit("movim.service")
+
+    # Test unauthenticated
+    server.fail("curl -L --fail-with-body --max-redirs 0 http://${movim.domain}/chat")
+
+    # Test basic Websocket
+    server.succeed("echo \"\" | ${lib.getExe pkgs.websocat} 'ws://${movim.domain}/ws/?path=login&offset=0' --origin 'http://${movim.domain}'")
+
+    # Test login + create cookiejar
+    login_html = server.succeed("curl --fail-with-body -c /tmp/cookies http://${movim.domain}/login")
+    assert "${movim.description}" in login_html
+    assert "${movim.info}" in login_html
+
+    # Test authentication POST
+    server.succeed("curl --fail-with-body -b /tmp/cookies -X POST --data-urlencode 'username=${xmpp.admin.JID}' --data-urlencode 'password=${xmpp.admin.password}' http://${movim.domain}/login")
+
+    server.succeed("curl -L --fail-with-body --max-redirs 1 -b /tmp/cookies http://${movim.domain}/chat")
+  '';
+})
diff --git a/pkgs/by-name/mo/movim/package.nix b/pkgs/by-name/mo/movim/package.nix
index 0225dbc980188..04695835710fe 100644
--- a/pkgs/by-name/mo/movim/package.nix
+++ b/pkgs/by-name/mo/movim/package.nix
@@ -1,10 +1,12 @@
 { lib
+, fetchpatch
 , fetchFromGitHub
 , dash
 , php
 , phpCfg ? null
 , withPgsql ? true # “strongly recommended” according to docs
 , withMysql ? false
+, nixosTests
 }:
 
 php.buildComposerProject (finalAttrs: {
@@ -36,6 +38,20 @@ php.buildComposerProject (finalAttrs: {
   vendorHash = "sha256-RFIi1I+gcagRgkDpgQeR1oGJeBGA7z9q3DCfW+ZDr2Y=";
 
   postPatch = ''
+    # Our modules are already wrapped, removes missing *.so warnings;
+    # replacing `$configuration` with actually-used flags.
+    substituteInPlace src/Movim/Daemon/Session.php \
+      --replace-fail "exec php ' . \$configuration " "exec php -dopcache.enable=1 -dopcache.enable_cli=1 ' "
+
+    # Point to PHP + PHP INI in the Nix store
+    substituteInPlace src/Movim/{Console/DaemonCommand.php,Daemon/Session.php} \
+      --replace-fail "exec php " "exec ${lib.getExe finalAttrs.php} "
+    substituteInPlace src/Movim/Console/DaemonCommand.php \
+      --replace-fail "<info>php vendor/bin/phinx migrate</info>" \
+        "<info>${lib.getBin finalAttrs.php} vendor/bin/phinx migrate</info>" \
+      --replace-fail "<info>php daemon.php setAdmin {jid}</info>" \
+        "<info>${finalAttrs.meta.mainProgram} setAdmin {jid}</info>"
+
     # BUGFIX: Imagick API Changes for 7.x+
     # See additionally: https://github.com/movim/movim/pull/1122
     substituteInPlace src/Movim/Image.php \
@@ -56,6 +72,10 @@ php.buildComposerProject (finalAttrs: {
     chmod +x $out/share/{bash-completion/completion/movim.bash,fish/vendor_completions.d/movim.fish,zsh/site-functions/_movim}
   '';
 
+  passthru = {
+    tests = { inherit (nixosTests) movim; };
+  };
+
   meta = {
     description = "a federated blogging & chat platform that acts as a web front end for the XMPP protocol";
     homepage = "https://movim.eu";