From 33a7f368b40cddbd623db0e40c011be499b6c63f Mon Sep 17 00:00:00 2001
From: Edgar B <39066502+Guekka@users.noreply.github.com>
Date: Sun, 16 Apr 2023 11:21:57 +0200
Subject: [PATCH] nixos/monica: init
---
nixos/modules/module-list.nix | 1 +
nixos/modules/services/web-apps/monica.nix | 468 +++++++++++++++++++++
nixos/tests/all-tests.nix | 1 +
nixos/tests/web-apps/monica.nix | 33 ++
4 files changed, 503 insertions(+)
create mode 100644 nixos/modules/services/web-apps/monica.nix
create mode 100644 nixos/tests/web-apps/monica.nix
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index e0e50295abb0..09e92866d4d5 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1182,6 +1182,7 @@
./services/web-apps/mattermost.nix
./services/web-apps/mediawiki.nix
./services/web-apps/miniflux.nix
+ ./services/web-apps/monica.nix
./services/web-apps/moodle.nix
./services/web-apps/netbox.nix
./services/web-apps/nextcloud.nix
diff --git a/nixos/modules/services/web-apps/monica.nix b/nixos/modules/services/web-apps/monica.nix
new file mode 100644
index 000000000000..442044fedb14
--- /dev/null
+++ b/nixos/modules/services/web-apps/monica.nix
@@ -0,0 +1,468 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.services.monica;
+ monica = pkgs.monica.override {
+ dataDir = cfg.dataDir;
+ };
+ db = cfg.database;
+ mail = cfg.mail;
+
+ user = cfg.user;
+ group = cfg.group;
+
+ # shell script for local administration
+ artisan = pkgs.writeScriptBin "monica" ''
+ #! ${pkgs.runtimeShell}
+ cd ${monica}
+ sudo() {
+ if [[ "$USER" != ${user} ]]; then
+ exec /run/wrappers/bin/sudo -u ${user} "$@"
+ else
+ exec "$@"
+ fi
+ }
+ sudo ${pkgs.php}/bin/php artisan "$@"
+ '';
+
+ tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
+in {
+ options.services.monica = {
+ enable = mkEnableOption (lib.mdDoc "monica");
+
+ user = mkOption {
+ default = "monica";
+ description = lib.mdDoc "User monica runs as.";
+ type = types.str;
+ };
+
+ group = mkOption {
+ default = "monica";
+ description = lib.mdDoc "Group monica runs as.";
+ type = types.str;
+ };
+
+ appKeyFile = mkOption {
+ description = lib.mdDoc ''
+ A file containing the Laravel APP_KEY - a 32 character long,
+ base64 encoded key used for encryption where needed. Can be
+ generated with head -c 32 /dev/urandom | base64
.
+ '';
+ example = "/run/keys/monica-appkey";
+ type = types.path;
+ };
+
+ hostname = lib.mkOption {
+ type = lib.types.str;
+ default =
+ if config.networking.domain != null
+ then config.networking.fqdn
+ else config.networking.hostName;
+ defaultText = lib.literalExpression "config.networking.fqdn";
+ example = "monica.example.com";
+ description = lib.mdDoc ''
+ The hostname to serve monica on.
+ '';
+ };
+
+ appURL = mkOption {
+ description = lib.mdDoc ''
+ The root URL that you want to host monica on. All URLs in monica will be generated using this value.
+ If you change this in the future you may need to run a command to update stored URLs in the database.
+ Command example: php artisan monica:update-url https://old.example.com https://new.example.com
+ '';
+ default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
+ defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}'';
+ example = "https://example.com";
+ type = types.str;
+ };
+
+ dataDir = mkOption {
+ description = lib.mdDoc "monica data directory";
+ default = "/var/lib/monica";
+ type = types.path;
+ };
+
+ database = {
+ host = mkOption {
+ type = types.str;
+ default = "localhost";
+ description = lib.mdDoc "Database host address.";
+ };
+ port = mkOption {
+ type = types.port;
+ default = 3306;
+ description = lib.mdDoc "Database host port.";
+ };
+ name = mkOption {
+ type = types.str;
+ default = "monica";
+ description = lib.mdDoc "Database name.";
+ };
+ user = mkOption {
+ type = types.str;
+ default = user;
+ defaultText = lib.literalExpression "user";
+ description = lib.mdDoc "Database username.";
+ };
+ passwordFile = mkOption {
+ type = with types; nullOr path;
+ default = null;
+ example = "/run/keys/monica-dbpassword";
+ description = lib.mdDoc ''
+ A file containing the password corresponding to
+ .
+ '';
+ };
+ createLocally = mkOption {
+ type = types.bool;
+ default = true;
+ description = lib.mdDoc "Create the database and database user locally.";
+ };
+ };
+
+ mail = {
+ driver = mkOption {
+ type = types.enum ["smtp" "sendmail"];
+ default = "smtp";
+ description = lib.mdDoc "Mail driver to use.";
+ };
+ host = mkOption {
+ type = types.str;
+ default = "localhost";
+ description = lib.mdDoc "Mail host address.";
+ };
+ port = mkOption {
+ type = types.port;
+ default = 1025;
+ description = lib.mdDoc "Mail host port.";
+ };
+ fromName = mkOption {
+ type = types.str;
+ default = "monica";
+ description = lib.mdDoc "Mail \"from\" name.";
+ };
+ from = mkOption {
+ type = types.str;
+ default = "mail@monica.com";
+ description = lib.mdDoc "Mail \"from\" email.";
+ };
+ user = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ example = "monica";
+ description = lib.mdDoc "Mail username.";
+ };
+ passwordFile = mkOption {
+ type = with types; nullOr path;
+ default = null;
+ example = "/run/keys/monica-mailpassword";
+ description = lib.mdDoc ''
+ A file containing the password corresponding to
+ .
+ '';
+ };
+ encryption = mkOption {
+ type = with types; nullOr (enum ["tls"]);
+ default = null;
+ description = lib.mdDoc "SMTP encryption mechanism to use.";
+ };
+ };
+
+ maxUploadSize = mkOption {
+ type = types.str;
+ default = "18M";
+ example = "1G";
+ description = lib.mdDoc "The maximum size for uploads (e.g. images).";
+ };
+
+ poolConfig = mkOption {
+ type = with types; attrsOf (oneOf [str int bool]);
+ default = {
+ "pm" = "dynamic";
+ "pm.max_children" = 32;
+ "pm.start_servers" = 2;
+ "pm.min_spare_servers" = 2;
+ "pm.max_spare_servers" = 4;
+ "pm.max_requests" = 500;
+ };
+ description = lib.mdDoc ''
+ Options for the monica PHP pool. See the documentation on php-fpm.conf
+ for details on configuration directives.
+ '';
+ };
+
+ nginx = mkOption {
+ type = types.submodule (
+ recursiveUpdate
+ (import ../web-servers/nginx/vhost-options.nix {inherit config lib;}) {}
+ );
+ default = {};
+ example = ''
+ {
+ serverAliases = [
+ "monica.''${config.networking.domain}"
+ ];
+ # To enable encryption and let let's encrypt take care of certificate
+ forceSSL = true;
+ enableACME = true;
+ }
+ '';
+ description = lib.mdDoc ''
+ With this option, you can customize the nginx virtualHost settings.
+ '';
+ };
+
+ config = mkOption {
+ type = with types;
+ attrsOf
+ (nullOr
+ (either
+ (oneOf [
+ bool
+ int
+ port
+ path
+ str
+ ])
+ (submodule {
+ options = {
+ _secret = mkOption {
+ type = nullOr str;
+ description = lib.mdDoc ''
+ The path to a file containing the value the
+ option should be set to in the final
+ configuration file.
+ '';
+ };
+ };
+ })));
+ default = {};
+ example = ''
+ {
+ ALLOWED_IFRAME_HOSTS = "https://example.com";
+ WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf";
+ AUTH_METHOD = "oidc";
+ OIDC_NAME = "MyLogin";
+ OIDC_DISPLAY_NAME_CLAIMS = "name";
+ OIDC_CLIENT_ID = "monica";
+ OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
+ OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
+ OIDC_ISSUER_DISCOVER = true;
+ }
+ '';
+ description = lib.mdDoc ''
+ monica configuration options to set in the
+ .env file.
+
+ Refer to
+ for details on supported values.
+
+ Settings containing secret data should be set to an attribute
+ set containing the attribute _secret - a
+ string pointing to a file containing the value the option
+ should be set to. See the example to get a better picture of
+ this: in the resulting .env file, the
+ OIDC_CLIENT_SECRET key will be set to the
+ contents of the /run/keys/oidc_secret
+ file.
+ '';
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = db.createLocally -> db.user == user;
+ message = "services.monica.database.user must be set to ${user} if services.monica.database.createLocally is set true.";
+ }
+ {
+ assertion = db.createLocally -> db.passwordFile == null;
+ message = "services.monica.database.passwordFile cannot be specified if services.monica.database.createLocally is set to true.";
+ }
+ ];
+
+ services.monica.config = {
+ APP_ENV = "production";
+ APP_KEY._secret = cfg.appKeyFile;
+ APP_URL = cfg.appURL;
+ DB_HOST = db.host;
+ DB_PORT = db.port;
+ DB_DATABASE = db.name;
+ DB_USERNAME = db.user;
+ MAIL_DRIVER = mail.driver;
+ MAIL_FROM_NAME = mail.fromName;
+ MAIL_FROM = mail.from;
+ MAIL_HOST = mail.host;
+ MAIL_PORT = mail.port;
+ MAIL_USERNAME = mail.user;
+ MAIL_ENCRYPTION = mail.encryption;
+ DB_PASSWORD._secret = db.passwordFile;
+ MAIL_PASSWORD._secret = mail.passwordFile;
+ APP_SERVICES_CACHE = "/run/monica/cache/services.php";
+ APP_PACKAGES_CACHE = "/run/monica/cache/packages.php";
+ APP_CONFIG_CACHE = "/run/monica/cache/config.php";
+ APP_ROUTES_CACHE = "/run/monica/cache/routes-v7.php";
+ APP_EVENTS_CACHE = "/run/monica/cache/events.php";
+ SESSION_SECURE_COOKIE = tlsEnabled;
+ };
+
+ environment.systemPackages = [artisan];
+
+ services.mysql = mkIf db.createLocally {
+ enable = true;
+ package = mkDefault pkgs.mariadb;
+ ensureDatabases = [db.name];
+ ensureUsers = [
+ {
+ name = db.user;
+ ensurePermissions = {"${db.name}.*" = "ALL PRIVILEGES";};
+ }
+ ];
+ };
+
+ services.phpfpm.pools.monica = {
+ inherit user group;
+ phpOptions = ''
+ log_errors = on
+ post_max_size = ${cfg.maxUploadSize}
+ upload_max_filesize = ${cfg.maxUploadSize}
+ '';
+ settings = {
+ "listen.mode" = "0660";
+ "listen.owner" = user;
+ "listen.group" = group;
+ } // cfg.poolConfig;
+ };
+
+ services.nginx = {
+ enable = mkDefault true;
+ recommendedTlsSettings = true;
+ recommendedOptimisation = true;
+ recommendedGzipSettings = true;
+ recommendedBrotliSettings = true;
+ recommendedProxySettings = true;
+ virtualHosts.${cfg.hostname} = mkMerge [
+ cfg.nginx
+ {
+ root = mkForce "${monica}/public";
+ locations = {
+ "/" = {
+ index = "index.php";
+ tryFiles = "$uri $uri/ /index.php?$query_string";
+ };
+ "~ \.php$".extraConfig = ''
+ fastcgi_pass unix:${config.services.phpfpm.pools."monica".socket};
+ '';
+ "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
+ extraConfig = "expires 365d;";
+ };
+ };
+ }
+ ];
+ };
+
+ systemd.services.monica-setup = {
+ description = "Preperation tasks for monica";
+ before = ["phpfpm-monica.service"];
+ after = optional db.createLocally "mysql.service";
+ wantedBy = ["multi-user.target"];
+ serviceConfig = {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ User = user;
+ UMask = 077;
+ WorkingDirectory = "${monica}";
+ RuntimeDirectory = "monica/cache";
+ RuntimeDirectoryMode = 0700;
+ };
+ path = [pkgs.replace-secret];
+ script = let
+ isSecret = v: isAttrs v && v ? _secret && isString v._secret;
+ monicaEnvVars = lib.generators.toKeyValue {
+ mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
+ mkValueString = v:
+ with builtins;
+ if isInt v
+ then toString v
+ else if isString v
+ then v
+ else if true == v
+ then "true"
+ else if false == v
+ then "false"
+ else if isSecret v
+ then hashString "sha256" v._secret
+ else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
+ };
+ };
+ secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
+ mkSecretReplacement = file: ''
+ replace-secret ${escapeShellArgs [(builtins.hashString "sha256" file) file "${cfg.dataDir}/.env"]}
+ '';
+ secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
+ filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{} null])) cfg.config;
+ monicaEnv = pkgs.writeText "monica.env" (monicaEnvVars filteredConfig);
+ in ''
+ # error handling
+ set -euo pipefail
+
+ # create .env file
+ install -T -m 0600 -o ${user} ${monicaEnv} "${cfg.dataDir}/.env"
+ ${secretReplacements}
+ if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
+ sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
+ fi
+
+ # migrate & seed db
+ ${pkgs.php}/bin/php artisan key:generate --force
+ ${pkgs.php}/bin/php artisan setup:production -v --force
+ '';
+ };
+
+ systemd.services.monica-scheduler = {
+ description = "Background tasks for monica";
+ startAt = "minutely";
+ after = ["monica-setup.service"];
+ serviceConfig = {
+ Type = "oneshot";
+ User = user;
+ WorkingDirectory = "${monica}";
+ ExecStart = "${pkgs.php}/bin/php ${monica}/artisan schedule:run -v";
+ };
+ };
+
+ systemd.tmpfiles.rules = [
+ "d ${cfg.dataDir} 0710 ${user} ${group} - -"
+ "d ${cfg.dataDir}/public 0750 ${user} ${group} - -"
+ "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -"
+ "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -"
+ ];
+
+ users = {
+ users = mkIf (user == "monica") {
+ monica = {
+ inherit group;
+ isSystemUser = true;
+ };
+ "${config.services.nginx.user}".extraGroups = [group];
+ };
+ groups = mkIf (group == "monica") {
+ monica = {};
+ };
+ };
+ };
+}
+
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 201dc3d72b50..49793fca2bfa 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -421,6 +421,7 @@ in {
mjolnir = handleTest ./matrix/mjolnir.nix {};
mod_perl = handleTest ./mod_perl.nix {};
molly-brown = handleTest ./molly-brown.nix {};
+ monica = handleTest ./web-apps/monica.nix {};
mongodb = handleTest ./mongodb.nix {};
moodle = handleTest ./moodle.nix {};
moonraker = handleTest ./moonraker.nix {};
diff --git a/nixos/tests/web-apps/monica.nix b/nixos/tests/web-apps/monica.nix
new file mode 100644
index 000000000000..29f5cb85bb13
--- /dev/null
+++ b/nixos/tests/web-apps/monica.nix
@@ -0,0 +1,33 @@
+import ../make-test-python.nix ({pkgs, ...}:
+let
+ cert = pkgs.runCommand "selfSignedCerts" { nativeBuildInputs = [ pkgs.openssl ]; } ''
+ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -subj '/CN=localhost' -days 36500
+ mkdir -p $out
+ cp key.pem cert.pem $out
+ '';
+in
+{
+ name = "monica";
+
+ nodes = {
+ machine = {pkgs, ...}: {
+ services.monica = {
+ enable = true;
+ hostname = "localhost";
+ appKeyFile = "${pkgs.writeText "keyfile" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}";
+ nginx = {
+ forceSSL = true;
+ sslCertificate = "${cert}/cert.pem";
+ sslCertificateKey = "${cert}/key.pem";
+ };
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+ machine.wait_for_unit("monica-setup.service")
+ machine.wait_for_open_port(443)
+ machine.succeed("curl -k --fail https://localhost", timeout=10)
+ '';
+})