1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 inherit (lib)
9 concatStringsSep
10 filterAttrs
11 getExe
12 hasPrefix
13 hasSuffix
14 isString
15 literalExpression
16 maintainers
17 mapAttrs
18 mkEnableOption
19 mkIf
20 mkOption
21 mkPackageOption
22 optional
23 optionalString
24 types
25 ;
26
27 cfg = config.services.umami;
28
29 nonFileSettings = filterAttrs (k: _: !hasSuffix "_FILE" k) cfg.settings;
30in
31{
32 options.services.umami = {
33 enable = mkEnableOption "umami";
34
35 package = mkPackageOption pkgs "umami" { } // {
36 apply =
37 pkg:
38 pkg.override {
39 databaseType = cfg.settings.DATABASE_TYPE;
40 collectApiEndpoint = optionalString (
41 cfg.settings.COLLECT_API_ENDPOINT != null
42 ) cfg.settings.COLLECT_API_ENDPOINT;
43 trackerScriptNames = cfg.settings.TRACKER_SCRIPT_NAME;
44 basePath = cfg.settings.BASE_PATH;
45 };
46 };
47
48 createPostgresqlDatabase = mkOption {
49 type = types.bool;
50 default = true;
51 example = false;
52 description = ''
53 Whether to automatically create the database for Umami using PostgreSQL.
54 Both the database name and username will be `umami`, and the connection is
55 made through unix sockets using peer authentication.
56 '';
57 };
58
59 settings = mkOption {
60 description = ''
61 Additional configuration (environment variables) for Umami, see
62 <https://umami.is/docs/environment-variables> for supported values.
63 '';
64
65 type = types.submodule {
66 freeformType =
67 with types;
68 attrsOf (oneOf [
69 bool
70 int
71 str
72 ]);
73
74 options = {
75 APP_SECRET_FILE = mkOption {
76 type = types.nullOr (
77 types.str
78 // {
79 # We don't want users to be able to pass a path literal here but
80 # it should look like a path.
81 check = it: isString it && types.path.check it;
82 }
83 );
84 default = null;
85 example = "/run/secrets/umamiAppSecret";
86 description = ''
87 A file containing a secure random string. This is used for signing user sessions.
88 The contents of the file are read through systemd credentials, therefore the
89 user running umami does not need permissions to read the file.
90 If you wish to set this to a string instead (not recommended since it will be
91 placed world-readable in the Nix store), you can use the APP_SECRET option.
92 '';
93 };
94 DATABASE_URL = mkOption {
95 type = types.nullOr (
96 types.str
97 // {
98 check =
99 it:
100 isString it
101 && ((hasPrefix "postgresql://" it) || (hasPrefix "postgres://" it) || (hasPrefix "mysql://" it));
102 }
103 );
104 # For some reason, Prisma requires the username in the connection string
105 # and can't derive it from the current user.
106 default =
107 if cfg.createPostgresqlDatabase then
108 "postgresql://umami@localhost/umami?host=/run/postgresql"
109 else
110 null;
111 defaultText = literalExpression ''if config.services.umami.createPostgresqlDatabase then "postgresql://umami@localhost/umami?host=/run/postgresql" else null'';
112 example = "postgresql://root:root@localhost/umami";
113 description = ''
114 Connection string for the database. Must start with `postgresql://`, `postgres://`
115 or `mysql://`.
116 '';
117 };
118 DATABASE_URL_FILE = mkOption {
119 type = types.nullOr (
120 types.str
121 // {
122 # We don't want users to be able to pass a path literal here but
123 # it should look like a path.
124 check = it: isString it && types.path.check it;
125 }
126 );
127 default = null;
128 example = "/run/secrets/umamiDatabaseUrl";
129 description = ''
130 A file containing a connection string for the database. The connection string
131 must start with `postgresql://`, `postgres://` or `mysql://`.
132 If using this, then DATABASE_TYPE must be set to the appropriate value.
133 The contents of the file are read through systemd credentials, therefore the
134 user running umami does not need permissions to read the file.
135 '';
136 };
137 DATABASE_TYPE = mkOption {
138 type = types.nullOr (
139 types.enum [
140 "postgresql"
141 "mysql"
142 ]
143 );
144 default =
145 if cfg.settings.DATABASE_URL != null && hasPrefix "mysql://" cfg.settings.DATABASE_URL then
146 "mysql"
147 else
148 "postgresql";
149 defaultText = literalExpression ''if config.services.umami.settings.DATABASE_URL != null && hasPrefix "mysql://" config.services.umami.settings.DATABASE_URL then "mysql" else "postgresql"'';
150 example = "mysql";
151 description = ''
152 The type of database to use. This is automatically inferred from DATABASE_URL, but
153 must be set manually if you are using DATABASE_URL_FILE.
154 '';
155 };
156 COLLECT_API_ENDPOINT = mkOption {
157 type = types.nullOr types.str;
158 default = null;
159 example = "/api/alternate-send";
160 description = ''
161 Allows you to send metrics to a location different than the default `/api/send`.
162 '';
163 };
164 TRACKER_SCRIPT_NAME = mkOption {
165 type = types.listOf types.str;
166 default = [ ];
167 example = [ "tracker.js" ];
168 description = ''
169 Allows you to assign a custom name to the tracker script different from the default `script.js`.
170 '';
171 };
172 BASE_PATH = mkOption {
173 type = types.str;
174 default = "";
175 example = "/analytics";
176 description = ''
177 Allows you to host Umami under a subdirectory.
178 You may need to update your reverse proxy settings to correctly handle the BASE_PATH prefix.
179 '';
180 };
181 DISABLE_UPDATES = mkOption {
182 type = types.bool;
183 default = true;
184 example = false;
185 description = ''
186 Disables the check for new versions of Umami.
187 '';
188 };
189 DISABLE_TELEMETRY = mkOption {
190 type = types.bool;
191 default = false;
192 example = true;
193 description = ''
194 Umami collects completely anonymous telemetry data in order help improve the application.
195 You can choose to disable this if you don't want to participate.
196 '';
197 };
198 HOSTNAME = mkOption {
199 type = types.str;
200 default = "127.0.0.1";
201 example = "0.0.0.0";
202 description = ''
203 The address to listen on.
204 '';
205 };
206 PORT = mkOption {
207 type = types.port;
208 default = 3000;
209 example = 3010;
210 description = ''
211 The port to listen on.
212 '';
213 };
214 };
215 };
216
217 default = { };
218
219 example = {
220 APP_SECRET_FILE = "/run/secrets/umamiAppSecret";
221 DISABLE_TELEMETRY = true;
222 };
223 };
224 };
225
226 config = mkIf cfg.enable {
227 assertions = [
228 {
229 assertion = (cfg.settings.APP_SECRET_FILE != null) != (cfg.settings ? APP_SECRET);
230 message = "One (and only one) of services.umami.settings.APP_SECRET_FILE and services.umami.settings.APP_SECRET must be set.";
231 }
232 {
233 assertion = (cfg.settings.DATABASE_URL_FILE != null) != (cfg.settings.DATABASE_URL != null);
234 message = "One (and only one) of services.umami.settings.DATABASE_URL_FILE and services.umami.settings.DATABASE_URL must be set.";
235 }
236 {
237 assertion =
238 cfg.createPostgresqlDatabase
239 -> cfg.settings.DATABASE_URL == "postgresql://umami@localhost/umami?host=/run/postgresql";
240 message = "The option config.services.umami.createPostgresqlDatabase is enabled, but config.services.umami.settings.DATABASE_URL has been modified.";
241 }
242 ];
243
244 services.postgresql = mkIf cfg.createPostgresqlDatabase {
245 enable = true;
246 ensureDatabases = [ "umami" ];
247 ensureUsers = [
248 {
249 name = "umami";
250 ensureDBOwnership = true;
251 ensureClauses.login = true;
252 }
253 ];
254 };
255
256 systemd.services.umami = {
257 environment = mapAttrs (_: toString) nonFileSettings;
258
259 description = "Umami: a simple, fast, privacy-focused alternative to Google Analytics";
260 after = [ "network.target" ] ++ (optional (cfg.createPostgresqlDatabase) "postgresql.service");
261 wantedBy = [ "multi-user.target" ];
262
263 script =
264 let
265 loadCredentials =
266 (optional (
267 cfg.settings.APP_SECRET_FILE != null
268 ) ''export APP_SECRET="$(systemd-creds cat appSecret)"'')
269 ++ (optional (
270 cfg.settings.DATABASE_URL_FILE != null
271 ) ''export DATABASE_URL="$(systemd-creds cat databaseUrl)"'');
272 in
273 ''
274 ${concatStringsSep "\n" loadCredentials}
275 ${getExe cfg.package}
276 '';
277
278 serviceConfig = {
279 Type = "simple";
280 Restart = "on-failure";
281 RestartSec = 3;
282 DynamicUser = true;
283
284 LoadCredential =
285 (optional (cfg.settings.APP_SECRET_FILE != null) "appSecret:${cfg.settings.APP_SECRET_FILE}")
286 ++ (optional (
287 cfg.settings.DATABASE_URL_FILE != null
288 ) "databaseUrl:${cfg.settings.DATABASE_URL_FILE}");
289
290 # Hardening
291 CapabilityBoundingSet = "";
292 NoNewPrivileges = true;
293 PrivateUsers = true;
294 PrivateTmp = true;
295 PrivateDevices = true;
296 PrivateMounts = true;
297 ProtectClock = true;
298 ProtectControlGroups = true;
299 ProtectHome = true;
300 ProtectHostname = true;
301 ProtectKernelLogs = true;
302 ProtectKernelModules = true;
303 ProtectKernelTunables = true;
304 RestrictAddressFamilies = (optional cfg.createPostgresqlDatabase "AF_UNIX") ++ [
305 "AF_INET"
306 "AF_INET6"
307 ];
308 RestrictNamespaces = true;
309 RestrictRealtime = true;
310 RestrictSUIDSGID = true;
311 };
312 };
313 };
314
315 meta.maintainers = with maintainers; [ diogotcorreia ];
316}