diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 8b277d45b999..4323a34ac97f 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -23,6 +23,8 @@ - [Fediwall](https://fediwall.social), a web application for live displaying toots from mastodon, inspired by mastowall. Available as [services.fediwall](#opt-services.fediwall.enable). +- [umami](https://github.com/umami-software/umami), a simple, fast, privacy-focused alternative to Google Analytics. Available with [services.umami](#opt-services.umami.enable). + - [FileBrowser](https://filebrowser.org/), a web application for managing and sharing files. Available as [services.filebrowser](#opt-services.filebrowser.enable). - Options under [networking.getaddrinfo](#opt-networking.getaddrinfo.enable) are now allowed to declaratively configure address selection and sorting behavior of `getaddrinfo` in dual-stack networks. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 8f0139622c37..0d91b467bff9 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1690,6 +1690,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 ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 1f1bb7286b16..652e0fd503e9 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1509,6 +1509,7 @@ in ucarp = runTest ./ucarp.nix; udisks2 = runTest ./udisks2.nix; ulogd = runTest ./ulogd/ulogd.nix; + umami = runTest ./web-apps/umami.nix; umurmur = runTest ./umurmur.nix; unbound = runTest ./unbound.nix; unifi = runTest ./unifi.nix; diff --git a/nixos/tests/web-apps/umami.nix b/nixos/tests/web-apps/umami.nix new file mode 100644 index 000000000000..6004ae68226b --- /dev/null +++ b/nixos/tests/web-apps/umami.nix @@ -0,0 +1,45 @@ +{ lib, ... }: +{ + name = "umami-nixos"; + + meta.maintainers = with lib.maintainers; [ diogotcorreia ]; + + nodes.machine = + { pkgs, ... }: + { + services.umami = { + enable = true; + settings = { + APP_SECRET = "very_secret"; + }; + }; + }; + + testScript = '' + import json + + machine.wait_for_unit("umami.service") + + machine.wait_for_open_port(3000) + machine.succeed("curl --fail http://localhost:3000/") + machine.succeed("curl --fail http://localhost:3000/script.js") + + res = machine.succeed(""" + curl -f --json '{ "username": "admin", "password": "umami" }' http://localhost:3000/api/auth/login + """) + token = json.loads(res)['token'] + + res = machine.succeed(""" + curl -f -H 'Authorization: Bearer %s' --json '{ "domain": "localhost", "name": "Test" }' http://localhost:3000/api/websites + """ % token) + print(res) + websiteId = json.loads(res)['id'] + + res = machine.succeed(""" + curl -f -H 'Authorization: Bearer %s' http://localhost:3000/api/websites/%s + """ % (token, websiteId)) + website = json.loads(res) + assert website["name"] == "Test" + assert website["domain"] == "localhost" + ''; +} diff --git a/pkgs/by-name/um/umami/package.nix b/pkgs/by-name/um/umami/package.nix new file mode 100644 index 000000000000..bcbc6140f01b --- /dev/null +++ b/pkgs/by-name/um/umami/package.nix @@ -0,0 +1,201 @@ +{ + lib, + stdenvNoCC, + fetchFromGitHub, + fetchurl, + makeWrapper, + nixosTests, + nodejs, + pnpm_10, + prisma-engines, + openssl, + rustPlatform, + # build variables + databaseType ? "postgresql", + collectApiEndpoint ? "", + trackerScriptNames ? [ ], + basePath ? "", +}: +let + sources = lib.importJSON ./sources.json; + pnpm = pnpm_10; + + geocities = stdenvNoCC.mkDerivation { + pname = "umami-geocities"; + version = sources.geocities.date; + src = fetchurl { + url = "https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/${sources.geocities.rev}/redist/GeoLite2-City.tar.gz"; + inherit (sources.geocities) hash; + }; + + doBuild = false; + + installPhase = '' + mkdir -p $out + cp ./GeoLite2-City.mmdb $out/GeoLite2-City.mmdb + ''; + + meta.license = lib.licenses.cc-by-40; + }; + + # Pin the specific version of prisma to the one used by upstream + # to guarantee compatibility. + prisma-engines' = prisma-engines.overrideAttrs (old: rec { + version = "6.7.0"; + src = fetchFromGitHub { + owner = "prisma"; + repo = "prisma-engines"; + tag = version; + hash = "sha256-Ty8BqWjZluU6a5xhSAVb2VoTVY91UUj6zoVXMKeLO4o="; + }; + cargoHash = "sha256-HjDoWa/JE6izUd+hmWVI1Yy3cTBlMcvD9ANsvqAoHBI="; + + cargoDeps = rustPlatform.fetchCargoVendor { + inherit (old) pname; + inherit src version; + hash = cargoHash; + }; + }); +in +stdenvNoCC.mkDerivation (finalAttrs: { + pname = "umami"; + version = "2.19.0"; + + nativeBuildInputs = [ + makeWrapper + nodejs + pnpm.configHook + ]; + + src = fetchFromGitHub { + owner = "umami-software"; + repo = "umami"; + tag = "v${finalAttrs.version}"; + hash = "sha256-luiwGmCujbFGWANSCOiHIov56gsMQ6M+Bj0stcz9he8="; + }; + + # install dev dependencies as well, for rollup + pnpmInstallFlags = [ "--prod=false" ]; + + pnpmDeps = pnpm.fetchDeps { + inherit (finalAttrs) + pname + pnpmInstallFlags + version + src + ; + fetcherVersion = 2; + hash = "sha256-2GiCeCt/mU5Dm5YHQgJF3127WPHq5QLX8JRcUv6B6lE="; + }; + + env.CYPRESS_INSTALL_BINARY = "0"; + env.NODE_ENV = "production"; + env.NEXT_TELEMETRY_DISABLED = "1"; + + # copy-db-files uses this variable to decide which Prisma schema to use + env.DATABASE_TYPE = databaseType; + + env.COLLECT_API_ENDPOINT = collectApiEndpoint; + env.TRACKER_SCRIPT_NAME = lib.concatStringsSep "," trackerScriptNames; + env.BASE_PATH = basePath; + + # Allow prisma-cli to find prisma-engines without having to download them + env.PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines'}/lib/libquery_engine.node"; + env.PRISMA_SCHEMA_ENGINE_BINARY = "${prisma-engines'}/bin/schema-engine"; + + buildPhase = '' + runHook preBuild + + pnpm copy-db-files + pnpm build-db-client # prisma generate + + pnpm build-tracker + pnpm build-app + + runHook postBuild + ''; + + checkPhase = '' + runHook preCheck + + pnpm test + + runHook postCheck + ''; + + doCheck = true; + + installPhase = '' + runHook preInstall + + mv .next/standalone $out + mv .next/static $out/.next/static + + # Include prisma cli in next standalone build. + # This is preferred to using the prisma in nixpkgs because it guarantees + # the version matches. + # See https://nextjs-forum.com/post/1280550687998083198 + # and https://nextjs.org/docs/pages/api-reference/config/next-config-js/output#caveats + # Unfortunately, using outputFileTracingIncludes doesn't work because of pnpm's symlink structure, + # so we just copy the files manually. + mkdir -p $out/node_modules/.bin + cp node_modules/.bin/prisma $out/node_modules/.bin + cp -a node_modules/prisma $out/node_modules + cp -a node_modules/.pnpm/@prisma* $out/node_modules/.pnpm + cp -a node_modules/.pnpm/prisma* $out/node_modules/.pnpm + # remove broken symlinks (some dependencies that are not relevant for running migrations) + find "$out"/node_modules/.pnpm/@prisma* -xtype l -exec rm {} \; + find "$out"/node_modules/.pnpm/prisma* -xtype l -exec rm {} \; + + cp -R public $out/public + cp -R prisma $out/prisma + + ln -s ${geocities} $out/geo + + mkdir -p $out/bin + # Run database migrations before starting umami. + # Add openssl to PATH since it is required for prisma to make SSL connections. + # Force working directory to $out because umami assumes many paths are relative to it (e.g., prisma and geolite). + makeWrapper ${nodejs}/bin/node $out/bin/umami-server \ + --set NODE_ENV production \ + --set NEXT_TELEMETRY_DISABLED 1 \ + --set PRISMA_QUERY_ENGINE_LIBRARY "${prisma-engines'}/lib/libquery_engine.node" \ + --set PRISMA_SCHEMA_ENGINE_BINARY "${prisma-engines'}/bin/schema-engine" \ + --prefix PATH : ${ + lib.makeBinPath [ + openssl + nodejs + ] + } \ + --chdir $out \ + --run "$out/node_modules/.bin/prisma migrate deploy" \ + --add-flags "$out/server.js" + + runHook postInstall + ''; + + passthru = { + tests = { + inherit (nixosTests) umami; + }; + inherit + sources + geocities + ; + prisma-engines = prisma-engines'; + updateScript = ./update.sh; + }; + + meta = with lib; { + changelog = "https://github.com/umami-software/umami/releases/tag/v${finalAttrs.version}"; + description = "Simple, easy to use, self-hosted web analytics solution"; + homepage = "https://umami.is/"; + license = with lib.licenses; [ + mit + cc-by-40 # geocities + ]; + platforms = lib.platforms.linux; + mainProgram = "umami-server"; + maintainers = with maintainers; [ diogotcorreia ]; + }; +}) diff --git a/pkgs/by-name/um/umami/sources.json b/pkgs/by-name/um/umami/sources.json new file mode 100644 index 000000000000..6406373132a5 --- /dev/null +++ b/pkgs/by-name/um/umami/sources.json @@ -0,0 +1,7 @@ +{ + "geocities": { + "rev": "0817bc800279e26e9ff045b7b129385e5b23012e", + "date": "2025-07-29", + "hash": "sha256-Rw9UEvUu7rtXFvHEqKza6kn9LwT6C17zJ/ljoN+t6Ek=" + } +} diff --git a/pkgs/by-name/um/umami/update.sh b/pkgs/by-name/um/umami/update.sh new file mode 100755 index 000000000000..adfec921a26f --- /dev/null +++ b/pkgs/by-name/um/umami/update.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p curl jq prefetch-yarn-deps nix-prefetch-github coreutils nix-update +# shellcheck shell=bash + +# This script exists to update geocities version and pin prisma-engines version + +set -euo pipefail +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" + +old_version=$(nix-instantiate --eval -A 'umami.version' default.nix | tr -d '"' || echo "0.0.1") +version=$(curl -s "https://api.github.com/repos/umami-software/umami/releases/latest" | jq -r ".tag_name") +version="${version#v}" + +echo "Updating to $version" + +if [[ "$old_version" == "$version" ]]; then + echo "Already up to date!" + exit 0 +fi + +nix-update --version "$version" umami + +echo "Fetching geolite" +geocities_rev_date=$(curl https://api.github.com/repos/GitSquared/node-geolite2-redist/branches/master | jq -r ".commit.sha, .commit.commit.author.date") +geocities_rev=$(echo "$geocities_rev_date" | head -1) +geocities_date=$(echo "$geocities_rev_date" | tail -1 | sed 's/T.*//') + +# upstream is kind enough to provide a file with the hash of the tar.gz archive +geocities_hash=$(curl -s "https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/$geocities_rev/redist/GeoLite2-City.tar.gz.sha256") +geocities_hash_sri=$(nix-hash --to-sri --type sha256 "$geocities_hash") + +cat < "$SCRIPT_DIR/sources.json" +{ + "geocities": { + "rev": "$geocities_rev", + "date": "$geocities_date", + "hash": "$geocities_hash_sri" + } +} +EOF + +echo "Pinning Prisma version" +upstream_src="https://raw.githubusercontent.com/umami-software/umami/v$version" + +lock=$(mktemp) +curl -s -o "$lock" "$upstream_src/pnpm-lock.yaml" +prisma_version=$(grep "@prisma/engines@" "$lock" | head -n1 | awk -F"[@']" '{print $4}') +rm "$lock" + +nix-update --version "$prisma_version" umami.prisma-engines