Files
2026-06-26 19:53:49 +02:00

488 lines
15 KiB
Nix

{
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
<https://github.com/freescout-help-desk/freescout/blob/master/.env.example>
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:<base64 encoded key> 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;
'';
};
};
};
};
};
}