From ef3a934db424f44c253f3f114590a0792eb8d73c Mon Sep 17 00:00:00 2001 From: e1mo Date: Wed, 24 Jun 2026 17:17:41 +0200 Subject: [PATCH 1/3] freescout: init at 1.8.225 --- .../0001-settings-catch-unwritable-.env.patch | 30 ++++++++++ pkgs/by-name/fr/freescout/package.nix | 60 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 pkgs/by-name/fr/freescout/0001-settings-catch-unwritable-.env.patch create mode 100644 pkgs/by-name/fr/freescout/package.nix diff --git a/pkgs/by-name/fr/freescout/0001-settings-catch-unwritable-.env.patch b/pkgs/by-name/fr/freescout/0001-settings-catch-unwritable-.env.patch new file mode 100644 index 000000000000..ff9616206f24 --- /dev/null +++ b/pkgs/by-name/fr/freescout/0001-settings-catch-unwritable-.env.patch @@ -0,0 +1,30 @@ +From 1c749a3610fb423e50260a2c807b44a94ef4c69d Mon Sep 17 00:00:00 2001 +From: Nina Fromm +Date: Thu, 25 Jun 2026 16:23:00 +0200 +Subject: [PATCH] settings: catch unwritable .env + +--- + app/Misc/Helper.php | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/app/Misc/Helper.php b/app/Misc/Helper.php +index 2d647b89..3fc2ef84 100644 +--- a/app/Misc/Helper.php ++++ b/app/Misc/Helper.php +@@ -1172,7 +1172,12 @@ class Helper + $contents = $contents."\n{$key}={$value}\n"; + } + } +- \File::put($env_path, $contents); ++ try { ++ \File::put($env_path, $contents); ++ } catch (\Exception $e) { ++ \Helper::logException($e, 'Error updating ' . $key .' in .env file: '); ++ \Session::flash('flash_error_unescaped', "Unable to write settings to $env_path: " . $e->getMessage()); ++ } + } + + /** +-- +2.53.0 + diff --git a/pkgs/by-name/fr/freescout/package.nix b/pkgs/by-name/fr/freescout/package.nix new file mode 100644 index 000000000000..5da1618a0212 --- /dev/null +++ b/pkgs/by-name/fr/freescout/package.nix @@ -0,0 +1,60 @@ +{ + lib, + stdenv, + fetchFromGitHub, +}: +stdenv.mkDerivation (finalAttrs: { + preferLocalBuild = true; + pname = "freescout"; + version = "1.8.225"; + + src = fetchFromGitHub { + owner = "freescout-help-desk"; + repo = "freescout"; + tag = finalAttrs.version; + hash = "sha256-kXZ6bjF36YO1p6q+KTugnBO+KxqQZci5O0RNM7lqecQ="; + }; + + patches = [ + ./0001-settings-catch-unwritable-.env.patch + ]; + + prePatch = '' + rm -rf storage + rm bootstrap/cache/.gitignore + rm public/{css,js}/builds/.htaccess + rm {Modules,public/modules}/.gitkeep + rmdir Modules public/modules bootstrap/cache public/{css,js}/builds + ln -rs data/.env .env + ln -rs data/storage storage + ln -rs data/bootstrap/cache bootstrap/cache + ln -rs data/storage/app/public public/storage + ln -rs data/public/css/builds public/css/builds + ln -rs data/public/js/builds public/js/builds + ln -rs data/Modules Modules + ln -rs data/public/modules public/modules + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/freescout + cp -ar . $out/share/freescout + chmod +x $out/share/freescout/artisan + + runHook postInstall + ''; + + # Because freescout is searching for some folders only relative to it's own source location, we need to have the symlinks to the actual locations in here + dontCheckForBrokenSymlinks = true; + strictDeps = true; + __structuredAttrs = true; + + meta = with lib; { + description = "Free self-hosted help desk & shared mailbox"; + license = licenses.agpl3Only; + homepage = "https://freescout.net/"; + platforms = platforms.all; + maintainers = with maintainers; [ e1mo ]; + }; +}) From 026e7e0afc5c7cb2e2682373e8a71152030179d9 Mon Sep 17 00:00:00 2001 From: e1mo Date: Thu, 25 Jun 2026 15:27:49 +0200 Subject: [PATCH 2/3] nixos/freescout: init --- .../manual/release-notes/rl-2611.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/freescout.nix | 487 ++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 nixos/modules/services/web-apps/freescout.nix diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 5494c3138b03..dd8265f6ec99 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -20,6 +20,8 @@ - [Stump](https://www.stumpapp.dev/), a free and open source comics, manga and digital book server with OPDS support. Available as [services.stump](#opt-services.stump.enable). +- [Freescout](https://freescout.net/), a free, open source Helpdesk and shared mailbox. Available as [services.freescout](#opt-services.freescout.enable). + - [FlapAlerted](https://github.com/Kioubit/FlapAlerted), detects BGP flapping events and provides statistics based on BGP update messages. Available as [services.flap-alerted](#opt-services.flap-alerted.enable). - [Unpackerr](https://unpackerr.zip), extracts downloads for Radarr, Sonarr, Lidarr, Readarr, and/or a Watch folder. Available as [services.unpackerr](#opt-services.unpackerr.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 507eeb3a9ff0..21f99867cf6c 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1662,6 +1662,7 @@ ./services/web-apps/firefly-iii.nix ./services/web-apps/flarum.nix ./services/web-apps/fluidd.nix + ./services/web-apps/freescout.nix ./services/web-apps/freshrss.nix ./services/web-apps/froide-govplan.nix ./services/web-apps/galene.nix diff --git a/nixos/modules/services/web-apps/freescout.nix b/nixos/modules/services/web-apps/freescout.nix new file mode 100644 index 000000000000..7c1339604c5f --- /dev/null +++ b/nixos/modules/services/web-apps/freescout.nix @@ -0,0 +1,487 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + # Simple alias variables + user = "freescout"; + group = user; + cfg = config.services.freescout; + datadir = "/var/lib/freescout"; + cachedir = "/var/cache/freescout"; + fpmService = "phpfpm-${user}"; + + # Generated config and more complex templates / default variables + autoDb = if !cfg.databaseSetup.enable then false else cfg.databaseSetup.kind; + dbService = lib.optional (autoDb != false) ( + if autoDb == "mysql" then "mysql.service" else "postgresql.service" + ); + db_config = lib.optionalAttrs (autoDb != false) ( + if autoDb == "mysql" then + { + DB_CONNECTION = "mysql"; + DB_HOST = ""; + DB_SOCKET = "/run/mysqld/mysqld.sock"; + DB_USERNAME = user; + DB_DATABASE = user; + } + else + { + DB_CONNECTION = "pgsql"; + DB_HOST = "/run/postgresql"; + DB_DATABASE = user; + DB_USERNAME = user; + } + ); + + raw_config = { + APP_ENV = "production"; + APP_FORCE_HTTPS = true; + APP_URL = "https://${cfg.domain}"; + APP_TIMEZONE = config.time.timeZone; + APP_DISABLE_UPDATING = true; + } + // cfg.settings + // db_config; + app_config = dropNull raw_config; + baseService = { + path = [ + pkgs.ps + artisanWrapped + ]; + requires = [ + # Using requires (instead of wants) since a failing config + # is indeed critical and should not allow this service to continue + "freescout-setup.service" + ] + ++ dbService; + serviceConfig = { + User = user; + Group = group; + }; + }; + + # Custom built packages / files / scripts + phpPackage = cfg.phpPackage.buildEnv { + # As of php8.5 opcache is required and automatically compiled in and thus is not available in + # all anymore. To keep compatibility with older versions, still add if available. + extensions = + { all, enabled }: enabled ++ [ all.iconv ] ++ (lib.optional (all ? opcache) all.opcache); + # Don't log anything because we are not sure, if this may leak secrets + # Logging can be increased, if we have time to check the logging library + extraConfig = '' + error_reporting = 0 + ''; + }; + + package = cfg.package.overrideAttrs (prev: { + pname = "${prev.pname}-${cfg.domain}"; + postInstall = prev.postInstall or "" + '' + ln -s ${datadir} $out/share/freescout/data + ''; + }); + + artisanWrapped = pkgs.writeShellApplication { + name = "artisan-wrapped"; + runtimeInputs = with pkgs; [ + util-linux + ]; + text = '' + cd ${datadir} + _runuser='exec' + if [[ "$USER" != ${user} ]]; then + _runuser='exec runuser --user ${user}' + fi + ''${_runuser} ${lib.getExe phpPackage} ${package}/share/freescout/artisan "$@" + ''; + }; + configFile = mkEnvFile "freescout.env" app_config; + allSecrets = lib.catAttrs "_secret" (lib.collect isSecret app_config); + + configSetupScript = pkgs.writeShellScript "freescout-config-setup" '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + PATH=${lib.makeBinPath [ pkgs.replace-secret ]}:$PATH + cp ${configFile} "/tmp/raw.env"; + ${mkSecretsReplacement "/tmp/raw.env" allSecrets} + install -T --mode 400 -o ${user} -g ${group} "/tmp/raw.env" "${datadir}/.env" + rm "/tmp/raw.env" + ''; + + freescoutSetupScript = + let + rwPaths = [ + "storage/app" + "storage/framework" + "storage/framework/sessions" + "storage/framework/views" + "storage/framework/cache/data" + "storage/logs" + "bootstrap/cache" + "public/css/builds" + "public/js/builds" + "Modules" + "public/modules" + ]; + in + '' + set -x + umask 027 + # Working arround https://github.com/freescout-helpdesk/freescout/issues/2547 + # and having to manually clear cache when migrating from something around + # ~1.8.159 (╯°□°)╯︵ ┻━┻ + # See: https://github.com/freescout-help-desk/freescout/issues/4366#issuecomment-2495993397 + rm -f ${datadir}/bootstrap/cache.php ${datadir}/bootstrap/cache/{config,packages,services}.php + + ln -sf "${artisanWrapped}/bin/artisan-wrapped" "${datadir}/artisan" + ${lib.concatMapStringsSep "\n" (p: "mkdir -p ${datadir}/${p}") rwPaths} + + # Migrate database and stuff + # This does migrate, cache:clear, queue:restart + ${lib.getExe artisanWrapped} freescout:after-app-update + ''; + + # Helper functions + isSecret = v: lib.isAttrs v && v ? _secret && lib.strings.isConvertibleWithToString v._secret; + hashSecret = p: builtins.hashString "sha256" (toString p); + dropNull = lib.filterAttrsRecursive ( + _: v: + !lib.elem v [ + null + [ ] + { } + ] + ); + mkEnvVars = lib.generators.toKeyValue { + mkKeyValue = + k: v: + let + value = + with builtins; + if isInt v then + toString v + else if isString v then + v + else if isBool v then + lib.boolToString v + else if isSecret v then + hashSecret v._secret + else + throw "freescout: ${k} has unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}"; + in + "${k}=${value}"; + }; + mkEnvFile = fname: values: pkgs.writeText fname (mkEnvVars values); + mkSecretsReplacement = + filePath: + lib.concatMapStringsSep "\n" ( + sp: + "replace-secret ${ + lib.escapeShellArgs [ + (hashSecret sp) + sp + ] + } ${filePath}" + ); +in +{ + options.services.freescout = with lib; { + enable = mkEnableOption "FreeScout helpdesk application"; + + package = mkPackageOption pkgs "freescout" { }; + + phpPackage = mkOption { + type = types.package; + default = pkgs.php; + description = "The php package to use"; + defaultText = literalExpression "pkgs.php"; + }; + + domain = mkOption { + type = types.str; + description = "Domain the freescout installation will run under"; + example = "support.mydomain.net"; + }; + + settings = mkOption { + type = with types; attrsOf anything; + apply = mapAttrs' ( + k: v: { + name = toUpper k; + value = v; + } + ); + default = { }; + description = '' + Settings to be set in the `.env` file. See + + for reference on available environment variables. + + Will be merged with the shown defaults. + ''; + defaultText = lib.literalExpression '' + { + APP_ENV = "production"; + APP_FORCE_HTTPS = true; + APP_URL = "https://''${config.services.freescout.domain}"; + APP_TIMEZONE = config.time.timeZone; + APP_DISABLE_UPDATING = true; + } + ''; + example = lib.literalExpression '' + { + # NOTE: MUST be 256 bits (32 bytes) in length, the form of base64: is recommended. + # You can generate a valid one using `echo "base64:$(openssl rand -base64 32)"` + APP_KEY_FILE = "/run/secret/freescout/app_key"; + DB_CONNECTION = "mysql"; + DB_HOST = "localhost"; + DB_PORT = 3306; + DB_DATABASE = "freescout"; + DB_USERNAME = "freescout"; + DB_PASSWORD._secret = "/run/secret/freescout/db_pass"; + } + ''; + }; + + poolConfig = mkOption { + type = + with types; + attrsOf (oneOf [ + str + int + bool + ]); + default = { + "pm" = "ondemand"; + "pm.max_children" = 32; + "pm.process_idle_timeout" = "120s"; + "pm.max_requests" = 500; + }; + description = '' + Options for the freescout PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + databaseSetup = { + enable = mkOption { + type = types.bool; + description = "Whether to enable automatic database setup and configuration"; + default = true; + }; + + kind = mkOption { + type = types.enum [ + "mysql" + "pgsql" + ]; + default = "pgsql"; + example = "mysql"; + description = "Type of database to automatically set up"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = (app_config ? "APP_KEY" || app_config ? "APP_KEY_FILE"); + message = "`services.freescout.settings.APP_KEY_FILE` is required!"; + } + ]; + + warnings = + lib.optional (app_config ? "APP_KEY" && lib.isString app_config.APP_KEY) + "`services.freescout.settings.APP_KEY` will be stored in the world readable nix store. Use `APP_KEY._secret` or `APP_KEY_FILE` instead!"; + + users.users.${user} = { + inherit group; + isSystemUser = true; + createHome = true; + home = datadir; + homeMode = "750"; + }; + users.users.${config.services.nginx.user}.extraGroups = [ group ]; + users.groups.${group} = { }; + + services.postgresql = lib.mkIf (autoDb == "pgsql") { + enable = true; + ensureUsers = [ + { + name = user; + ensureDBOwnership = true; + } + ]; + ensureDatabases = [ + app_config.DB_DATABASE + ]; + }; + + services.mysql = lib.mkIf (autoDb == "mysql") { + enable = true; + package = lib.mkDefault pkgs.mariadb; + ensureUsers = [ + { + name = user; + ensurePermissions = { + "${app_config.DB_DATABASE}.*" = "ALL PRIVILEGES"; + }; + } + ]; + ensureDatabases = [ + app_config.DB_DATABASE + ]; + }; + + services.phpfpm.pools.${user} = { + inherit phpPackage user group; + + phpOptions = '' + display_errors = On + display_startup_errors = On + ''; + settings = { + "listen.owner" = user; + "listen.group" = config.services.nginx.group; + "catch_workers_output" = true; + } + // cfg.poolConfig; + }; + + systemd.services.${fpmService} = { + # Somehow the webinterface shows + inherit (baseService) path; + }; + + systemd.services.freescout-setup = lib.recursiveUpdate baseService { + description = "Preparational tasks for freescout"; + requires = dbService; + wantedBy = [ "multi-user.target" ]; + after = dbService; + script = freescoutSetupScript; + serviceConfig = { + PrivateTmp = true; + Type = "oneshot"; + RemainAfterExit = true; + ExecStartPre = "+${configSetupScript}"; + }; + }; + + # This needs to be manually started again and again + # Freescout has its own scheduler built in to ensure tasks run at the desired frequency + # --no-interaction makes sure, that the queue worker is not executed. + # This is needed, because otherweise the queue worker process would continue running + # thus block further schedule invocations until the queue worker terminates. + # See https://github.com/freescout-help-desk/freescout/blob/74fa4b7d4f8288f8d3fb1d343308d3289c4d72e2/app/Console/Kernel.php#L195-L267 + systemd.services."freescout-schedule-run" = baseService // { + startAt = "minutely"; + script = "${lib.getExe artisanWrapped} schedule:run --no-interaction"; + }; + + # This is both long-running but also stops quite frequently. + # Seeing job restart counts in the thousands here is normal. + systemd.services."freescout-queue" = lib.recursiveUpdate baseService { + # Copying the output to storage/logs because it makes + # debugging connection issues easier for the user. + script = '' + ${lib.getExe artisanWrapped} \ + queue:work \ + --queue emails,default \ + --sleep=5 \ + -vv \ + --tries=20 \ + | tee -a ${datadir}/storage/logs/queue-jobs.log + ''; + serviceConfig = { + RestartSec = "15s"; + RuntimeMaxSec = "1h"; + Restart = "always"; + }; + wantedBy = [ "multi-user.target" ]; + after = [ "freescout-setup.service" ] ++ dbService; + }; + + services.nginx = { + enable = true; + virtualHosts.${cfg.domain} = + let + vhostCfg = config.services.nginx.virtualHosts.${cfg.domain}; + optSsl = lib.optionalString (vhostCfg.forceSSL || vhostCfg.onlySSL) "fastcgi_param HTTPS on;"; + in + { + root = lib.mkForce "${package}/share/freescout/public"; + + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php$is_args$args"; + + extraConfig = '' + # Defeats E-Mail open tracking or possibly "real" exploits + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"; + ''; + }; + + "~ \\.php$" = { + tryFiles = "$uri $uri/ =404"; + extraConfig = '' + fastcgi_index index.php; + include ${pkgs.nginx}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:${config.services.phpfpm.pools.${user}.socket}; + ${optSsl} + + # Defeats E-Mail open tracking or possibly "real" exploits + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"; + ''; + }; + + "~* ^/storage/attachment/" = { + tryFiles = "$uri $uri/ /index.php?$query_string"; + extraConfig = '' + expires 1M; + access_log off; + ''; + }; + + "~* ^/(?:css|js)/.*\\.(?:css|js)$".extraConfig = '' + expires 2d; + access_log off; + add_header Cache-Control "public, must-revalidate"; + # Defeats E-Mail open tracking or possibly "real" exploits + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"; + ''; + + "~* ^/(?:css|fonts|img|installer|js|modules)$".extraConfig = '' + expires 1M; + access_log off; + add_header Cache-Control "public, must-revalidate"; + ''; + + "~ /\\.".extraConfig = '' + deny all; + ''; + "^~ /(css|js)/builds/".root = "${cachedir}/public/"; + "^~ /storage/app/attachment/" = { + alias = "${datadir}/storage/app/attachment/"; + extraConfig = '' + internal; + ''; + }; + }; + }; + }; + }; +} From 6814d230d7926387255744c0479fadfee2b4623f Mon Sep 17 00:00:00 2001 From: e1mo Date: Thu, 25 Jun 2026 16:00:53 +0200 Subject: [PATCH 3/3] nixos/tests/freescout: init --- nixos/tests/all-tests.nix | 3 + nixos/tests/freescout/default.nix | 5 + nixos/tests/freescout/integration.nix | 195 ++++++++++++++++++++++++++ nixos/tests/freescout/upgrade.nix | 94 +++++++++++++ pkgs/by-name/fr/freescout/package.nix | 5 + 5 files changed, 302 insertions(+) create mode 100644 nixos/tests/freescout/default.nix create mode 100644 nixos/tests/freescout/integration.nix create mode 100644 nixos/tests/freescout/upgrade.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 09b0a4632d28..e2178a0976e2 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -627,6 +627,9 @@ in forgejoPackage = pkgs.forgejo-lts; }; freenet = runTest ./freenet.nix; + freescout = import ./freescout { + inherit runTest; + }; freeswitch = runTest ./freeswitch.nix; freetube = discoverTests (import ./freetube.nix); freshrss = import ./freshrss { inherit runTest; }; diff --git a/nixos/tests/freescout/default.nix b/nixos/tests/freescout/default.nix new file mode 100644 index 000000000000..603f61131432 --- /dev/null +++ b/nixos/tests/freescout/default.nix @@ -0,0 +1,5 @@ +{ runTest }: +{ + integration = runTest ./integration.nix; + upgrade = runTest ./upgrade.nix; +} diff --git a/nixos/tests/freescout/integration.nix b/nixos/tests/freescout/integration.nix new file mode 100644 index 000000000000..57d28f032c3a --- /dev/null +++ b/nixos/tests/freescout/integration.nix @@ -0,0 +1,195 @@ +# This tests runs freescout and performs the following tests: +# - Create amin user via the CLI +# - Create mailbox, configured for sending and receiving +# - Test if receiving, sending and notifications work +{ pkgs, lib, ... }: + +let + mailDomain = "freemail.local"; + freescoutDomain = "freescout.local"; + sendInitial = pkgs.writeShellScriptBin "send-initial" '' + exec ${pkgs.dovecot}/libexec/dovecot/deliver -d freescout <&2") + + machine.wait_for_unit("nginx") + machine.wait_for_unit("freescout-setup") + + with subtest("Check new API version"): + machine.succeed("curl -fsSL --cookie-jar cjar --cookie cjar 'http://${freescoutDomain}/system/status' | grep ${newVersion}") + ''; +} diff --git a/pkgs/by-name/fr/freescout/package.nix b/pkgs/by-name/fr/freescout/package.nix index 5da1618a0212..4b4b31d90954 100644 --- a/pkgs/by-name/fr/freescout/package.nix +++ b/pkgs/by-name/fr/freescout/package.nix @@ -2,6 +2,7 @@ lib, stdenv, fetchFromGitHub, + nixosTests, }: stdenv.mkDerivation (finalAttrs: { preferLocalBuild = true; @@ -45,6 +46,10 @@ stdenv.mkDerivation (finalAttrs: { runHook postInstall ''; + passthru.tests = { + inherit (nixosTests) freescout; + }; + # Because freescout is searching for some folders only relative to it's own source location, we need to have the symlinks to the actual locations in here dontCheckForBrokenSymlinks = true; strictDeps = true;