nixos/movim: add service module

This commit is contained in:
toastal 2024-03-22 23:49:50 +07:00
parent 47918937b8
commit fcc7c53e9c
6 changed files with 734 additions and 0 deletions

View file

@ -1363,6 +1363,7 @@
./services/web-apps/miniflux.nix
./services/web-apps/monica.nix
./services/web-apps/moodle.nix
./services/web-apps/movim.nix
./services/web-apps/netbox.nix
./services/web-apps/nextcloud.nix
./services/web-apps/nextcloud-notify_push.nix

View file

@ -0,0 +1,602 @@
{ config, lib, pkgs, ... }:
let
inherit (lib)
filterAttrsRecursive
generators
literalExpression
mkDefault
mkIf
mkOption
mkEnableOption
mkPackageOption
mkMerge
pipe
types
;
cfg = config.services.movim;
defaultPHPCfg = {
"output_buffering" = 0;
"error_reporting" = "E_ALL & ~E_DEPRECATED & ~E_STRICT";
"opcache.enable_cli" = 1;
"opcache.interned_strings_buffer" = 8;
"opcache.max_accelerated_files" = 6144;
"opcache.memory_consumption" = 128;
"opcache.revalidate_freq" = 2;
"opcache.fast_shutdown" = 1;
};
phpCfg = generators.toKeyValue
{ mkKeyValue = generators.mkKeyValueDefault { } " = "; }
(defaultPHPCfg // cfg.phpCfg);
podConfigFlags =
let
bevalue = a: lib.escapeShellArg (generators.mkValueStringDefault { } a);
in
lib.concatStringsSep " "
(lib.attrsets.foldlAttrs
(acc: k: v: acc ++ lib.optional (v != null) "--${k}=${bevalue v}")
[ ]
cfg.podConfig);
package =
let
p = cfg.package.override {
inherit phpCfg;
withPgsql = cfg.database.type == "pgsql";
withMysql = cfg.database.type == "mysql";
};
in
p.overrideAttrs (finalAttrs: prevAttrs:
let
appDir = "$out/share/php/${finalAttrs.pname}";
stateDirectories = ''
# Symlinking in our state directories
rm -rf $out/.env $out/cache ${appDir}/public/cache
ln -s ${cfg.dataDir}/.env ${appDir}/.env
ln -s ${cfg.dataDir}/public/cache ${appDir}/public/cache
ln -s ${cfg.logDir} ${appDir}/log
ln -s ${cfg.runtimeDir}/cache ${appDir}/cache
'';
exposeComposer = ''
# Expose PHP Composer for scripts
mkdir -p $out/bin
echo "#!${lib.getExe pkgs.dash}" > $out/bin/movim-composer
echo "${finalAttrs.php.packages.composer}/bin/composer --working-dir="${appDir}" \"\$@\"" >> $out/bin/movim-composer
chmod +x $out/bin/movim-composer
'';
podConfigInputDisableReplace = lib.optionalString (podConfigFlags != "")
(lib.concatStringsSep "\n"
(lib.attrsets.foldlAttrs
(acc: k: v:
acc ++ lib.optional (v != null)
# Disable all Admin panel options that were set in the
# `cfg.podConfig` to prevent confusing situtions where the
# values are rewritten on server reboot
''
substituteInPlace ${appDir}/app/widgets/AdminMain/adminmain.tpl \
--replace-warn 'name="${k}"' 'name="${k}" disabled'
'')
[ ]
cfg.podConfig));
in
{
postInstall = lib.concatStringsSep "\n\n" [
prevAttrs.postInstall
stateDirectories
exposeComposer
podConfigInputDisableReplace
];
});
configFile = pipe cfg.settings [
(filterAttrsRecursive (_: v: v != null))
(generators.toKeyValue { })
(pkgs.writeText "movim-env")
];
pool = "movim";
fpm = config.services.phpfpm.pools.${pool};
phpExecutionUnit = "phpfpm-${pool}";
dbService = {
"postgresql" = "postgresql.service";
"mysql" = "mysql.service";
}.${cfg.database.type};
in
{
options.services = {
movim = {
enable = mkEnableOption "a Movim instance";
package = mkPackageOption pkgs "movim" { };
phpPackage = mkPackageOption pkgs "php" { };
phpCfg = mkOption {
type = with types; attrsOf (oneOf [ int str bool ]);
defaultText = literalExpression (generators.toPretty { } defaultPHPCfg);
default = { };
description = "Extra PHP INI options such as `memory_limit`, `max_execution_time`, etc.";
};
user = mkOption {
type = types.nonEmptyStr;
default = "movim";
description = "User running Movim service";
};
group = mkOption {
type = types.nonEmptyStr;
default = "movim";
description = "Group running Movim service";
};
dataDir = mkOption {
type = types.nonEmptyStr;
default = "/var/lib/movim";
description = "State directory of the `movim` user which holds the applications state & data.";
};
logDir = mkOption {
type = types.nonEmptyStr;
default = "/var/log/movim";
description = "Log directory of the `movim` user which holds the applications logs.";
};
runtimeDir = mkOption {
type = types.nonEmptyStr;
default = "/run/movim";
description = "Runtime directory of the `movim` user which holds the applications caches & temporary files.";
};
domain = mkOption {
type = types.nonEmptyStr;
description = "Fully-qualified domain name (FQDN) for the Movim instance.";
};
port = mkOption {
type = types.port;
default = 8080;
description = "Movim daemon port.";
};
debug = mkOption {
type = types.bool;
default = false;
description = "Debugging logs.";
};
verbose = mkOption {
type = types.bool;
default = false;
description = "Verbose logs.";
};
podConfig = mkOption {
type = types.submodule {
options = {
info = mkOption {
type = with types; nullOr str;
default = null;
description = "Content of the info box on the login page";
};
description = mkOption {
type = with types; nullOr str;
default = null;
description = "General description of the instance";
};
timezone = mkOption {
type = with types; nullOr str;
default = null;
description = "The server timezone";
};
restrictsuggestions = mkOption {
type = with types; nullOr bool;
default = null;
description = "Only suggest chatrooms, Communities and other contents that are available on the user XMPP server and related services";
};
chatonly = mkOption {
type = with types; nullOr bool;
default = null;
description = "Disable all the social feature (Communities, Blog) and keep only the chat ones";
};
disableregistration = mkOption {
type = with types; nullOr bool;
default = null;
description = "Remove the XMPP registration flow and buttons from the interface";
};
loglevel = mkOption {
type = with types; nullOr (ints.between 0 3);
default = null;
description = "The server loglevel";
};
locale = mkOption {
type = with types; nullOr str;
default = null;
description = "The server main locale";
};
xmppdomain = mkOption {
type = with types; nullOr str;
default = null;
description = "The default XMPP server domain";
};
xmppdescription = mkOption {
type = with types; nullOr str;
default = null;
description = "The default XMPP server description";
};
xmppwhitelist = mkOption {
type = with types; nullOr str;
default = null;
description = "The allowlisted XMPP servers";
};
};
};
default = { };
description = ''
Pod configuration (values from `php daemon.php config --help`).
Note that these values will now be disabled in the admin panel.
'';
};
settings = mkOption {
type = with types; attrsOf (nullOr (oneOf [ int str bool ]));
default = { };
description = ".env settings for Movim. Secrets should use `secretFile` option instead. `null`s will be culled.";
};
secretFile = mkOption {
type = with types; nullOr path;
default = null;
description = "The secret file to be sourced for the .env settings.";
};
database = {
type = mkOption {
type = types.enum [ "mysql" "postgresql" ];
example = "mysql";
default = "postgresql";
description = "Database engine to use.";
};
name = mkOption {
type = types.str;
default = "movim";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = "movim";
description = "Database username.";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = "local database using UNIX socket authentication";
};
};
nginx = mkOption {
type = with types; nullOr (submodule
(import ../web-servers/nginx/vhost-options.nix {
inherit config lib;
}));
default = null;
example = lib.literalExpression /* nginx */ ''
{
serverAliases = [
"pics.''${config.networking.domain}"
];
enableACME = true;
forceHttps = true;
}
'';
description = ''
With this option, you can customize an nginx 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 nginx virtualHost will be configured.
'';
};
poolConfig = mkOption {
type = with types; attrsOf (oneOf [ int str bool ]);
default = { };
description = "Options for Movims PHP-FPM pool.";
};
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
users = {
users = {
movim = mkIf (cfg.user == "movim") {
isSystemUser = true;
group = cfg.group;
};
"${config.services.nginx.user}".extraGroups = [ cfg.group ];
};
groups = {
${cfg.group} = { };
};
};
services = {
movim = {
settings = mkMerge [
{
DAEMON_URL = "//${cfg.domain}";
DAEMON_PORT = cfg.port;
DAEMON_INTERFACE = "127.0.0.1";
DAEMON_DEBUG = cfg.debug;
DAEMON_VERBOSE = cfg.verbose;
}
(mkIf cfg.database.createLocally {
DB_DRIVER = {
"postgresql" = "pgsql";
"mysql" = "mysql";
}.${cfg.database.type};
DB_HOST = "localhost";
DB_PORT = config.services.${cfg.database.type}.settings.port;
DB_DATABASE = cfg.database.name;
DB_USERNAME = cfg.database.user;
DB_PASSWORD = "";
})
];
poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
"pm" = "dynamic";
"php_admin_value[error_log]" = "stderr";
"php_admin_flag[log_errors]" = true;
"catch_workers_output" = true;
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 8;
"pm.max_requests" = 500;
};
};
nginx = mkIf (cfg.nginx != null) {
enable = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedBrotliSettings = true;
recommendedProxySettings = true;
# TODO: recommended cache options already in Nginx⁇
appendHttpConfig = /* nginx */ ''
fastcgi_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginx_cache:100m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
'';
virtualHosts."${cfg.domain}" = mkMerge [
cfg.nginx
{
root = lib.mkForce "${package}/share/php/movim/public";
locations = {
"/favicon.ico" = {
priority = 100;
extraConfig = /* nginx */ ''
access_log off;
log_not_found off;
'';
};
"/robots.txt" = {
priority = 100;
extraConfig = /* nginx */ ''
access_log off;
log_not_found off;
'';
};
"~ /\\.(?!well-known).*" = {
priority = 210;
extraConfig = /* nginx */ ''
deny all;
'';
};
# Ask nginx to cache every URL starting with "/picture"
"/picture" = {
priority = 400;
tryFiles = "$uri $uri/ /index.php$is_args$args";
extraConfig = /* nginx */ ''
set $no_cache 0; # Enable cache only there
'';
};
"/" = {
priority = 490;
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';";
set $no_cache 1;
'';
};
"~ \\.php$" = {
priority = 500;
tryFiles = "$uri =404";
extraConfig = /* nginx */ ''
include ${config.services.nginx.package}/conf/fastcgi.conf;
add_header X-Cache $upstream_cache_status;
fastcgi_ignore_headers "Cache-Control" "Expires" "Set-Cookie";
fastcgi_cache nginx_cache;
fastcgi_cache_valid any 7d;
fastcgi_cache_bypass $no_cache;
fastcgi_no_cache $no_cache;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_index index.php;
fastcgi_pass unix:${fpm.socket};
'';
};
"/ws/" = {
priority = 900;
proxyPass = "http://${cfg.settings.DAEMON_INTERFACE}:${builtins.toString cfg.port}/";
proxyWebsockets = true;
recommendedProxySettings = true;
extraConfig = /* nginx */ ''
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
'';
};
};
extraConfig = /* ngnix */ ''
index index.php;
'';
}
];
};
mysql = mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
enable = mkDefault true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.user;
ensureDBOwnership = true;
}];
};
postgresql = mkIf (cfg.database.createLocally && cfg.database.type == "postgresql") {
enable = mkDefault true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.user;
ensureDBOwnership = true;
}];
authentication = ''
host ${cfg.database.name} ${cfg.database.user} localhost trust
'';
};
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;
phpOptions = ''
error_log = 'stderr'
log_errors = on
'';
settings = {
"listen.owner" = socketOwner;
"listen.group" = cfg.group;
"listen.mode" = "0660";
"catch_workers_output" = true;
} // cfg.poolConfig;
};
};
systemd = {
services.movim-data-setup = {
description = "Movim setup: .env file, databases init, cache reload";
wantedBy = [ "multi-user.target" ];
requiredBy = [ "${phpExecutionUnit}.service" ];
before = [ "${phpExecutionUnit}.service" ];
after = lib.optional cfg.database.createLocally dbService;
requires = lib.optional cfg.database.createLocally dbService;
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
UMask = "077";
} // lib.optionalAttrs (cfg.secretFile != null) {
LoadCredential = "env-secrets:${cfg.secretFile}";
};
script = ''
# Env vars
rm -f ${cfg.dataDir}/.env
cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
echo -e '\n' >> ${cfg.dataDir}/.env
if [[ -f "$CREDENTIALS_DIRECTORY/env-secrets" ]]; then
cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
echo -e '\n' >> ${cfg.dataDir}/.env
fi
# Caches, logs
mkdir -p ${cfg.dataDir}/public/cache ${cfg.logDir} ${cfg.runtimeDir}/cache
chmod -R ug+rw ${cfg.dataDir}/public/cache
chmod -R ug+rw ${cfg.logDir}
chmod -R ug+rwx ${cfg.runtimeDir}/cache
# Migrations
MOVIM_VERSION="${package.version}"
if [[ ! -f "${cfg.dataDir}/.migration-version" ]] || [[ "$MOVIM_VERSION" != "$(<${cfg.dataDir}/.migration-version)" ]]; then
${package}/bin/movim-composer movim:migrate && echo $MOVIM_VERSION > ${cfg.dataDir}/.migration-version
fi
''
+ lib.optionalString (podConfigFlags != "") (
let
flags = lib.concatStringsSep " "
([ "--no-interaction" ]
++ lib.optional cfg.debug "-vvv"
++ lib.optional (!cfg.debug && cfg.verbose) "-v");
in
''
${lib.getExe package} config ${podConfigFlags}
''
);
};
services.movim = {
description = "Movim daemon";
wantedBy = [ "multi-user.target" ];
after = [ "movim-data-setup.service" ];
requires = [ "movim-data-setup.service" ]
++ lib.optional cfg.database.createLocally dbService;
environment = {
PUBLIC_URL = "//${cfg.domain}";
WS_PORT = builtins.toString cfg.port;
};
serviceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "${package}/share/php/movim";
ExecStart = "${lib.getExe package} start";
};
};
services.${phpExecutionUnit} = {
after = [ "movim-data-setup.service" ];
requires = [ "movim-data-setup.service" ]
++ lib.optional cfg.database.createLocally dbService;
};
tmpfiles.settings."10-movim" = with cfg; {
"${dataDir}".d = { inherit user group; mode = "0710"; };
"${dataDir}/public".d = { inherit user group; mode = "0750"; };
"${dataDir}/public/cache".d = { inherit user group; mode = "0750"; };
"${runtimeDir}".d = { inherit user group; mode = "0700"; };
"${runtimeDir}/cache".d = { inherit user group; mode = "0700"; };
"${logDir}".d = { inherit user group; mode = "0700"; };
};
};
};
}

View file

@ -558,6 +558,7 @@ in {
morty = handleTest ./morty.nix {};
mosquitto = handleTest ./mosquitto.nix {};
moosefs = handleTest ./moosefs.nix {};
movim = discoverTests (import ./web-apps/movim { inherit handleTestOn; });
mpd = handleTest ./mpd.nix {};
mpv = handleTest ./mpv.nix {};
mtp = handleTest ./mtp.nix {};

View file

@ -0,0 +1,8 @@
{ system ? builtins.currentSystem, handleTestOn }:
let
supportedSystems = [ "x86_64-linux" "i686-linux" ];
in
{
standard = handleTestOn supportedSystems ./standard.nix { inherit system; };
}

View file

@ -0,0 +1,102 @@
import ../../make-test-python.nix ({ lib, pkgs, ... }:
let
movim = {
domain = "movim.local";
info = "No ToS in tests";
description = "NixOS testing server";
};
xmpp = {
domain = "xmpp.local";
admin = rec {
JID = "${username}@${xmpp.domain}";
username = "romeo";
password = "juliet";
};
};
in
{
name = "movim-standard";
meta = {
maintainers = with pkgs.lib.maintainers; [ toastal ];
};
nodes = {
server = { pkgs, ... }: {
services.movim = {
inherit (movim) domain;
enable = true;
verbose = true;
podConfig = {
inherit (movim) description info;
xmppdomain = xmpp.domain;
};
nginx = { };
};
services.prosody = {
enable = true;
xmppComplianceSuite = false;
disco_items = [
{ url = "upload.${xmpp.domain}"; description = "File Uploads"; }
];
virtualHosts."${xmpp.domain}" = {
inherit (xmpp) domain;
enabled = true;
extraConfig = ''
Component "pubsub.${xmpp.domain}" "pubsub"
pubsub_max_items = 10000
expose_publisher = true
Component "upload.${xmpp.domain}" "http_file_share"
http_external_url = "http://upload.${xmpp.domain}"
http_file_share_expires_after = 300 * 24 * 60 * 60
http_file_share_size_limit = 1024 * 1024 * 1024
http_file_share_daily_quota = 4 * 1024 * 1024 * 1024
'';
};
extraConfig = ''
pep_max_items = 10000
http_paths = {
file_share = "/";
}
'';
};
networking.extraHosts = ''
127.0.0.1 ${movim.domain}
127.0.0.1 ${xmpp.domain}
'';
};
};
testScript = /* python */ ''
server.wait_for_unit("phpfpm-movim.service")
server.wait_for_unit("nginx.service")
server.wait_for_open_port(80)
server.wait_for_unit("prosody.service")
server.succeed('prosodyctl status | grep "Prosody is running"')
server.succeed("prosodyctl register ${xmpp.admin.username} ${xmpp.domain} ${xmpp.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 \"\" | ${lib.getExe pkgs.websocat} 'ws://${movim.domain}/ws/?path=login&offset=0' --origin 'http://${movim.domain}'")
# 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=${xmpp.admin.JID}' --data-urlencode 'password=${xmpp.admin.password}' http://${movim.domain}/login")
server.succeed("curl -L --fail-with-body --max-redirs 1 -b /tmp/cookies http://${movim.domain}/chat")
'';
})

View file

@ -1,10 +1,12 @@
{ lib
, fetchpatch
, fetchFromGitHub
, dash
, php
, phpCfg ? null
, withPgsql ? true # “strongly recommended” according to docs
, withMysql ? false
, nixosTests
}:
php.buildComposerProject (finalAttrs: {
@ -36,6 +38,20 @@ php.buildComposerProject (finalAttrs: {
vendorHash = "sha256-RFIi1I+gcagRgkDpgQeR1oGJeBGA7z9q3DCfW+ZDr2Y=";
postPatch = ''
# Our modules are already wrapped, removes missing *.so warnings;
# replacing `$configuration` with actually-used flags.
substituteInPlace src/Movim/Daemon/Session.php \
--replace-fail "exec php ' . \$configuration " "exec php -dopcache.enable=1 -dopcache.enable_cli=1 ' "
# Point to PHP + PHP INI in the Nix store
substituteInPlace src/Movim/{Console/DaemonCommand.php,Daemon/Session.php} \
--replace-fail "exec php " "exec ${lib.getExe finalAttrs.php} "
substituteInPlace src/Movim/Console/DaemonCommand.php \
--replace-fail "<info>php vendor/bin/phinx migrate</info>" \
"<info>${lib.getBin finalAttrs.php} vendor/bin/phinx migrate</info>" \
--replace-fail "<info>php daemon.php setAdmin {jid}</info>" \
"<info>${finalAttrs.meta.mainProgram} setAdmin {jid}</info>"
# BUGFIX: Imagick API Changes for 7.x+
# See additionally: https://github.com/movim/movim/pull/1122
substituteInPlace src/Movim/Image.php \
@ -56,6 +72,10 @@ php.buildComposerProject (finalAttrs: {
chmod +x $out/share/{bash-completion/completion/movim.bash,fish/vendor_completions.d/movim.fish,zsh/site-functions/_movim}
'';
passthru = {
tests = { inherit (nixosTests) movim; };
};
meta = {
description = "a federated blogging & chat platform that acts as a web front end for the XMPP protocol";
homepage = "https://movim.eu";