From 6d61e7b089e64c81db4abb72a63e5e651bbd285b Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Mon, 10 Feb 2025 00:51:15 +0000 Subject: [PATCH] nixos/umami: init module --- nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/umami.nix | 316 ++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 nixos/modules/services/web-apps/umami.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 45b0b5c1fd7c..572d29b0675c 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1683,6 +1683,7 @@ ./services/web-apps/szurubooru.nix ./services/web-apps/trilium.nix ./services/web-apps/tt-rss.nix + ./services/web-apps/umami.nix ./services/web-apps/vikunja.nix ./services/web-apps/wakapi.nix ./services/web-apps/weblate.nix diff --git a/nixos/modules/services/web-apps/umami.nix b/nixos/modules/services/web-apps/umami.nix new file mode 100644 index 000000000000..41593ed69a57 --- /dev/null +++ b/nixos/modules/services/web-apps/umami.nix @@ -0,0 +1,316 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + concatStringsSep + filterAttrs + getExe + hasPrefix + hasSuffix + isString + literalExpression + maintainers + mapAttrs + mkEnableOption + mkIf + mkOption + mkPackageOption + optional + optionalString + types + ; + + cfg = config.services.umami; + + nonFileSettings = filterAttrs (k: _: !hasSuffix "_FILE" k) cfg.settings; +in +{ + options.services.umami = { + enable = mkEnableOption "umami"; + + package = mkPackageOption pkgs "umami" { } // { + apply = + pkg: + pkg.override { + databaseType = cfg.settings.DATABASE_TYPE; + collectApiEndpoint = optionalString ( + cfg.settings.COLLECT_API_ENDPOINT != null + ) cfg.settings.COLLECT_API_ENDPOINT; + trackerScriptNames = cfg.settings.TRACKER_SCRIPT_NAME; + basePath = cfg.settings.BASE_PATH; + }; + }; + + createPostgresqlDatabase = mkOption { + type = types.bool; + default = true; + example = false; + description = '' + Whether to automatically create the database for Umami using PostgreSQL. + Both the database name and username will be `umami`, and the connection is + made through unix sockets using peer authentication. + ''; + }; + + settings = mkOption { + description = '' + Additional configuration (environment variables) for Umami, see + for supported values. + ''; + + type = types.submodule { + freeformType = + with types; + attrsOf (oneOf [ + bool + int + str + ]); + + options = { + APP_SECRET_FILE = mkOption { + type = types.nullOr ( + types.str + // { + # We don't want users to be able to pass a path literal here but + # it should look like a path. + check = it: isString it && types.path.check it; + } + ); + default = null; + example = "/run/secrets/umamiAppSecret"; + description = '' + A file containing a secure random string. This is used for signing user sessions. + The contents of the file are read through systemd credentials, therefore the + user running umami does not need permissions to read the file. + If you wish to set this to a string instead (not recommended since it will be + placed world-readable in the Nix store), you can use the APP_SECRET option. + ''; + }; + DATABASE_URL = mkOption { + type = types.nullOr ( + types.str + // { + check = + it: + isString it + && ((hasPrefix "postgresql://" it) || (hasPrefix "postgres://" it) || (hasPrefix "mysql://" it)); + } + ); + # For some reason, Prisma requires the username in the connection string + # and can't derive it from the current user. + default = + if cfg.createPostgresqlDatabase then + "postgresql://umami@localhost/umami?host=/run/postgresql" + else + null; + defaultText = literalExpression ''if config.services.umami.createPostgresqlDatabase then "postgresql://umami@localhost/umami?host=/run/postgresql" else null''; + example = "postgresql://root:root@localhost/umami"; + description = '' + Connection string for the database. Must start with `postgresql://`, `postgres://` + or `mysql://`. + ''; + }; + DATABASE_URL_FILE = mkOption { + type = types.nullOr ( + types.str + // { + # We don't want users to be able to pass a path literal here but + # it should look like a path. + check = it: isString it && types.path.check it; + } + ); + default = null; + example = "/run/secrets/umamiDatabaseUrl"; + description = '' + A file containing a connection string for the database. The connection string + must start with `postgresql://`, `postgres://` or `mysql://`. + If using this, then DATABASE_TYPE must be set to the appropriate value. + The contents of the file are read through systemd credentials, therefore the + user running umami does not need permissions to read the file. + ''; + }; + DATABASE_TYPE = mkOption { + type = types.nullOr ( + types.enum [ + "postgresql" + "mysql" + ] + ); + default = + if cfg.settings.DATABASE_URL != null && hasPrefix "mysql://" cfg.settings.DATABASE_URL then + "mysql" + else + "postgresql"; + defaultText = literalExpression ''if config.services.umami.settings.DATABASE_URL != null && hasPrefix "mysql://" config.services.umami.settings.DATABASE_URL then "mysql" else "postgresql"''; + example = "mysql"; + description = '' + The type of database to use. This is automatically inferred from DATABASE_URL, but + must be set manually if you are using DATABASE_URL_FILE. + ''; + }; + COLLECT_API_ENDPOINT = mkOption { + type = types.nullOr types.str; + default = null; + example = "/api/alternate-send"; + description = '' + Allows you to send metrics to a location different than the default `/api/send`. + ''; + }; + TRACKER_SCRIPT_NAME = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "tracker.js" ]; + description = '' + Allows you to assign a custom name to the tracker script different from the default `script.js`. + ''; + }; + BASE_PATH = mkOption { + type = types.str; + default = ""; + example = "/analytics"; + description = '' + Allows you to host Umami under a subdirectory. + You may need to update your reverse proxy settings to correctly handle the BASE_PATH prefix. + ''; + }; + DISABLE_UPDATES = mkOption { + type = types.bool; + default = true; + example = false; + description = '' + Disables the check for new versions of Umami. + ''; + }; + DISABLE_TELEMETRY = mkOption { + type = types.bool; + default = false; + example = true; + description = '' + Umami collects completely anonymous telemetry data in order help improve the application. + You can choose to disable this if you don't want to participate. + ''; + }; + HOSTNAME = mkOption { + type = types.str; + default = "127.0.0.1"; + example = "0.0.0.0"; + description = '' + The address to listen on. + ''; + }; + PORT = mkOption { + type = types.port; + default = 3000; + example = 3010; + description = '' + The port to listen on. + ''; + }; + }; + }; + + default = { }; + + example = { + APP_SECRET_FILE = "/run/secrets/umamiAppSecret"; + DISABLE_TELEMETRY = true; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (cfg.settings.APP_SECRET_FILE != null) != (cfg.settings ? APP_SECRET); + message = "One (and only one) of services.umami.settings.APP_SECRET_FILE and services.umami.settings.APP_SECRET must be set."; + } + { + assertion = (cfg.settings.DATABASE_URL_FILE != null) != (cfg.settings.DATABASE_URL != null); + message = "One (and only one) of services.umami.settings.DATABASE_URL_FILE and services.umami.settings.DATABASE_URL must be set."; + } + { + assertion = + cfg.createPostgresqlDatabase + -> cfg.settings.DATABASE_URL == "postgresql://umami@localhost/umami?host=/run/postgresql"; + message = "The option config.services.umami.createPostgresqlDatabase is enabled, but config.services.umami.settings.DATABASE_URL has been modified."; + } + ]; + + services.postgresql = mkIf cfg.createPostgresqlDatabase { + enable = true; + ensureDatabases = [ "umami" ]; + ensureUsers = [ + { + name = "umami"; + ensureDBOwnership = true; + ensureClauses.login = true; + } + ]; + }; + + systemd.services.umami = { + environment = mapAttrs (_: toString) nonFileSettings; + + description = "Umami: a simple, fast, privacy-focused alternative to Google Analytics"; + after = [ "network.target" ] ++ (optional (cfg.createPostgresqlDatabase) "postgresql.service"); + wantedBy = [ "multi-user.target" ]; + + script = + let + loadCredentials = + (optional ( + cfg.settings.APP_SECRET_FILE != null + ) ''export APP_SECRET="$(systemd-creds cat appSecret)"'') + ++ (optional ( + cfg.settings.DATABASE_URL_FILE != null + ) ''export DATABASE_URL="$(systemd-creds cat databaseUrl)"''); + in + '' + ${concatStringsSep "\n" loadCredentials} + ${getExe cfg.package} + ''; + + serviceConfig = { + Type = "simple"; + Restart = "on-failure"; + RestartSec = 3; + DynamicUser = true; + + LoadCredential = + (optional (cfg.settings.APP_SECRET_FILE != null) "appSecret:${cfg.settings.APP_SECRET_FILE}") + ++ (optional ( + cfg.settings.DATABASE_URL_FILE != null + ) "databaseUrl:${cfg.settings.DATABASE_URL_FILE}"); + + # Hardening + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + PrivateUsers = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateMounts = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictAddressFamilies = (optional cfg.createPostgresqlDatabase "AF_UNIX") ++ [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + }; + }; + }; + + meta.maintainers = with maintainers; [ diogotcorreia ]; +}