From 026e7e0afc5c7cb2e2682373e8a71152030179d9 Mon Sep 17 00:00:00 2001 From: e1mo Date: Thu, 25 Jun 2026 15:27:49 +0200 Subject: [PATCH] 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; + ''; + }; + }; + }; + }; + }; +}