From 8a8b892cc1e1401e8b56aef259d1361bc5846760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=B9=82=E0=B8=97=E0=B8=AA=E0=B8=BA=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=A5?= Date: Mon, 31 Mar 2025 23:42:12 +0700 Subject: [PATCH] nixos/movim: add H2O support + testing with ejabberd --- nixos/modules/services/web-apps/movim.nix | 168 ++++++++++-- nixos/tests/web-apps/movim/default.nix | 1 + nixos/tests/web-apps/movim/ejabberd-h2o.nix | 274 ++++++++++++++++++++ 3 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 nixos/tests/web-apps/movim/ejabberd-h2o.nix diff --git a/nixos/modules/services/web-apps/movim.nix b/nixos/modules/services/web-apps/movim.nix index b8dda419a26e..78c3001753d5 100644 --- a/nixos/modules/services/web-apps/movim.nix +++ b/nixos/modules/services/web-apps/movim.nix @@ -175,6 +175,37 @@ let "mysql" = "mysql.service"; } .${cfg.database.type}; + + # exclusivity asserted in `assertions` + webServerService = + if cfg.h2o != null then + "h2o.service" + else if cfg.nginx != null then + "nginx.service" + else + null; + + socketOwner = + if cfg.h2o != null then + config.services.h2o.user + else if cfg.nginx != null then + config.services.nginx.user + else + cfg.user; + + # Movim needs a lot of unsafe values to function at this time. Perhaps if + # this is ever addressed in the future, the PHP application will send up the + # proper directive. For now this fairly conservative CSP will restrict a lot + # of potentially bad stuff as well as take in inventory of the features used. + # + # See: https://github.com/movim/movim/issues/314 + movimCSP = lib.concatStringsSep "; " [ + "default-src 'self'" + "img-src 'self' aesgcm: data: https:" + "media-src 'self' aesgcm: https:" + "script-src 'self' 'unsafe-eval' 'unsafe-inline'" + "style-src 'self' 'unsafe-inline'" + ]; in { options.services = { @@ -475,6 +506,31 @@ in }; }; + h2o = mkOption { + type = types.nullOr ( + types.submodule (import ../web-servers/h2o/vhost-options.nix { inherit config lib; }) + ); + default = null; + example = + lib.literalExpression # nix + '' + { + serverAliases = [ + "pics.''${config.movim.domain}" + ]; + acme.enable = true; + tls.policy = "force"; + } + ''; + description = '' + With this option, you can customize an H2O 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 H2O `hosts` will be configured. + ''; + }; + nginx = mkOption { type = types.nullOr ( types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) @@ -515,6 +571,25 @@ in }; config = mkIf cfg.enable { + assertions = [ + ( + let + webServers = [ + "h2o" + "nginx" + ]; + checkConfigs = lib.concatMapStringsSep ", " (ws: "services.movim.${ws}") webServers; + in + { + assertion = builtins.length (lib.lists.filter (ws: cfg.${ws} != null) webServers) <= 1; + message = '' + At most 1 web server virtual host configuration should be enabled + for Movim at a time. Check ${checkConfigs}. + ''; + } + ) + ]; + environment.systemPackages = [ package ]; users = { @@ -525,6 +600,9 @@ in group = cfg.group; }; } + // lib.optionalAttrs (cfg.h2o != null) { + "${config.services.h2o.user}".extraGroups = [ cfg.group ]; + } // lib.optionalAttrs (cfg.nginx != null) { "${config.services.nginx.user}".extraGroups = [ cfg.group ]; }; @@ -571,6 +649,51 @@ in }; }; + h2o = mkIf (cfg.h2o != null) { + enable = true; + hosts."${cfg.domain}" = mkMerge [ + { + settings = { + paths = { + "/ws/" = { + "proxy.preserve-host" = "ON"; + "proxy.tunnel" = "ON"; + "proxy.reverse.url" = "http://${cfg.settings.DAEMON_INTERFACE}:${builtins.toString cfg.port}/"; + }; + "/" = + { + "file.dir" = "${package}/share/php/movim/public"; + "file.index" = [ + "index.php" + "index.html" + ]; + redirect = { + url = "/index.php/"; + internal = "YES"; + status = 307; + }; + "header.set" = [ + "Content-Security-Policy: ${movimCSP}" + ]; + } + // lib.optionalAttrs (with cfg.precompressStaticFiles; brotli.enable || gzip.enable) { + "file.send-compressed" = "ON"; + }; + }; + "file.custom-handler" = { + extension = [ ".php" ]; + "fastcgi.document_root" = package; + "fastcgi.connect" = { + port = fpm.socket; + type = "unix"; + }; + }; + }; + } + cfg.h2o + ]; + }; + nginx = mkIf (cfg.nginx != null) ( { enable = true; @@ -624,8 +747,7 @@ in 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';"; + add_header Content-Security-Policy "${movimCSP}"; set $no_cache 1; ''; }; @@ -699,27 +821,23 @@ in ''; }; - 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; + phpfpm.pools.${pool} = { + phpPackage = package.php; + user = cfg.user; + group = cfg.group; - phpOptions = '' - error_log = 'stderr' - log_errors = on - ''; + phpOptions = '' + error_log = 'stderr' + log_errors = on + ''; - settings = { - "listen.owner" = socketOwner; - "listen.group" = cfg.group; - "listen.mode" = "0660"; - "catch_workers_output" = true; - } // cfg.poolConfig; - }; + settings = { + "listen.owner" = socketOwner; + "listen.group" = cfg.group; + "listen.mode" = "0660"; + "catch_workers_output" = true; + } // cfg.poolConfig; + }; }; systemd = { @@ -781,9 +899,9 @@ in }; services.${phpExecutionUnit} = { - wantedBy = lib.optional (cfg.nginx != null) "nginx.service"; + wantedBy = lib.optional (webServerService != null) webServerService; requiredBy = [ "movim.service" ]; - before = [ "movim.service" ] ++ lib.optional (cfg.nginx != null) "nginx.service"; + before = [ "movim.service" ] ++ lib.optional (webServerService != null) webServerService; wants = [ "network.target" ]; requires = [ "movim-data-setup.service" ] ++ lib.optional cfg.database.createLocally dbService; after = [ "movim-data-setup.service" ] ++ lib.optional cfg.database.createLocally dbService; @@ -802,14 +920,14 @@ in "${phpExecutionUnit}.service" ] ++ lib.optional cfg.database.createLocally dbService - ++ lib.optional (cfg.nginx != null) "nginx.service"; + ++ lib.optional (webServerService != null) webServerService; after = [ "movim-data-setup.service" "${phpExecutionUnit}.service" ] ++ lib.optional cfg.database.createLocally dbService - ++ lib.optional (cfg.nginx != null) "nginx.service"; + ++ lib.optional (webServerService != null) webServerService; environment = { PUBLIC_URL = "//${cfg.domain}"; WS_PORT = builtins.toString cfg.port; diff --git a/nixos/tests/web-apps/movim/default.nix b/nixos/tests/web-apps/movim/default.nix index b9d273f2c5eb..7da1e7d88e67 100644 --- a/nixos/tests/web-apps/movim/default.nix +++ b/nixos/tests/web-apps/movim/default.nix @@ -1,5 +1,6 @@ { recurseIntoAttrs, runTest }: recurseIntoAttrs { + ejabberd-h2o = runTest ./ejabberd-h2o.nix; prosody-nginx = runTest ./prosody-nginx.nix; } diff --git a/nixos/tests/web-apps/movim/ejabberd-h2o.nix b/nixos/tests/web-apps/movim/ejabberd-h2o.nix new file mode 100644 index 000000000000..399d7a3b9574 --- /dev/null +++ b/nixos/tests/web-apps/movim/ejabberd-h2o.nix @@ -0,0 +1,274 @@ +{ hostPkgs, lib, ... }: + +let + movim = { + domain = "movim.local"; + port = 8080; + info = "No ToS in tests"; + description = "NixOS testing server"; + }; + ejabberd = { + domain = "ejabberd.local"; + ports = { + c2s = 5222; + s2s = 5269; + http = 5280; + }; + spoolDir = "/var/lib/ejabberd"; + admin = rec { + JID = "${username}@${ejabberd.domain}"; + username = "romeo"; + password = "juliet"; + }; + }; + + # START OF EJABBERD CONFIG ################################################## + # + # Ejabberd has sparse defaults as it is a generic XMPP server. As such this + # config might be longer than expected for a test. + # + # Movim suggests: https://github.com/movim/movim/wiki/Configure ejabberd + # + # In the future this may be the default setup + # See: https://github.com/NixOS/nixpkgs/pull/312316 + ejabberd_config_file = + let + settingsFormat = hostPkgs.formats.yaml { }; + in + settingsFormat.generate "ejabberd.yml" { + loglevel = "info"; + hide_sensitive_log_data = false; + hosts = [ ejabberd.domain ]; + default_db = "mnesia"; + acme.auto = false; + s2s_access = "s2s"; + s2s_use_starttls = false; + new_sql_schema = true; + acl = { + admin = [ + { user = ejabberd.admin.JID; } + ]; + local.user_regexp = ""; + loopback.ip = [ + "127.0.0.1/8" + "::1/128" + ]; + }; + access_rules = { + c2s = { + deny = "blocked"; + allow = "all"; + }; + s2s = { + allow = "all"; + }; + local.allow = "local"; + announce.allow = "admin"; + configure.allow = "admin"; + pubsub_createnode.allow = "local"; + trusted_network.allow = "loopback"; + }; + api_permissions = { + "console commands" = { + from = [ "ejabberd_ctl" ]; + who = "all"; + what = "*"; + }; + }; + shaper = { + normal = { + rate = 3000; + burst_size = 20000; + }; + fast = 100000; + }; + modules = { + mod_caps = { }; + mod_disco = { }; + mod_mam = { }; + mod_http_upload = { + docroot = "${ejabberd.spoolDir}/uploads"; + dir_mode = "0755"; + file_mode = "0644"; + get_url = "http://@HOST@/upload"; + put_url = "http://@HOST@/upload"; + max_size = 65536; + custom_headers = { + Access-Control-Allow-Origin = "http://@HOST@,http://${movim.domain}"; + Access-Control-Allow-Methods = "GET,HEAD,PUT,OPTIONS"; + Access-Control-Allow-Headers = "Content-Type"; + }; + }; + # This PubSub block is required for Movim to work. + # + # See: https://github.com/movim/movim/wiki/Configure ejabberd#pubsub + mod_pubsub = { + hosts = [ "pubsub.@HOST@" ]; + access_createnode = "pubsub_createnode"; + ignore_pep_from_offline = false; + last_item_cache = false; + max_items_node = 2048; + default_node_config = { + max_items = 2048; + }; + plugins = [ + "flat" + "pep" + ]; + force_node_config = { + "storage:bookmarks".access_model = "whitelist"; + "eu.siacs.conversations.axolotl.*".access_model = "open"; + "urn:xmpp:bookmarks:0" = { + access_model = "whitelist"; + send_last_published_item = "never"; + max_items = "infinity"; + persist_items = true; + }; + "urn:xmpp:bookmarks:1" = { + access_model = "whitelist"; + send_last_published_item = "never"; + max_items = "infinity"; + persist_items = true; + }; + "urn:xmpp:pubsub:movim-public-subscription" = { + access_model = "whitelist"; + max_items = "infinity"; + persist_items = true; + }; + "urn:xmpp:microblog:0" = { + notify_retract = true; + max_items = "infinity"; + persist_items = true; + }; + "urn:xmpp:microblog:0:comments*" = { + access_model = "open"; + notify_retract = true; + max_items = "infinity"; + persist_items = true; + }; + }; + }; + mod_stream_mgmt = { }; + }; + listen = [ + { + module = "ejabberd_c2s"; + port = ejabberd.ports.c2s; + max_stanza_size = 262144; + access = "c2s"; + starttls_required = false; + } + { + module = "ejabberd_s2s_in"; + port = ejabberd.ports.s2s; + max_stanza_size = 524288; + shaper = "fast"; + } + { + module = "ejabberd_http"; + port = ejabberd.ports.http; + request_handlers = { + "/upload" = "mod_http_upload"; + }; + } + ]; + }; + # END OF EJABBERD CONFIG ################################################## +in +{ + name = "movim-ejabberd-h2o"; + + meta = { + maintainers = with lib.maintainers; [ toastal ]; + }; + + nodes = { + server = + { pkgs, ... }: + { + environment.systemPackages = [ + # For testing + pkgs.websocat + ]; + + services.movim = { + inherit (movim) domain port; + enable = true; + verbose = true; + podConfig = { + inherit (movim) description info; + xmppdomain = ejabberd.domain; + }; + database = { + type = "postgresql"; + createLocally = true; + }; + h2o = { }; + }; + + services.ejabberd = { + inherit (ejabberd) spoolDir; + enable = true; + configFile = ejabberd_config_file; + imagemagick = false; + }; + + services.h2o.settings = { + compress = "ON"; + }; + + systemd.services.ejabberd = { + serviceConfig = { + # Certain misconfigurations can cause RAM usage to swell before + # crashing; fail sooner with more-than-liberal memory limits + StartupMemoryMax = "1G"; + MemoryMax = "512M"; + }; + }; + + networking = { + firewall.allowedTCPPorts = with ejabberd.ports; [ + c2s + s2s + ]; + extraHosts = '' + 127.0.0.1 ${movim.domain} + 127.0.0.1 ${ejabberd.domain} + ''; + }; + }; + }; + + testScript = # python + '' + ejabberdctl = "su ejabberd -s $(which ejabberdctl) " + + server.wait_for_unit("phpfpm-movim.service") + server.wait_for_unit("h2o.service") + server.wait_for_open_port(${builtins.toString movim.port}) + server.wait_for_open_port(80) + + server.wait_for_unit("ejabberd.service") + ejabberd_status = server.succeed(ejabberdctl + "status") + assert "status: started" in ejabberd_status + server.succeed(ejabberdctl + "register ${ejabberd.admin.username} ${ejabberd.domain} ${ejabberd.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 | websocat --origin 'http://${movim.domain}' 'ws://${movim.domain}/ws/?path=login&offset=0'") + + # 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=${ejabberd.admin.JID}' --data-urlencode 'password=${ejabberd.admin.password}' http://${movim.domain}/login") + + server.succeed("curl -L --fail-with-body --max-redirs 1 -b /tmp/cookies http://${movim.domain}/chat") + ''; +}