nixos/freescout: init
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<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;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user