summaryrefslogtreecommitdiffstats
path: root/nixos/modules/services/web-apps/umami.nix
blob: 9709d6e8c9f4c89db2995418721525fa4a38074c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
{
  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 {
          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
        <https://umami.is/docs/environment-variables> 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));
              }
            );
            # 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://` or `postgres://`.
            '';
          };
          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://` or `postgres://`.
              The contents of the file are read through systemd credentials, therefore the
              user running umami does not need permissions to read the 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.";
      }
      {
        assertion = cfg.settings.DATABASE_TYPE or null != "mysql";
        message = "Umami only supports PostgreSQL as of 3.0.0. Follow migration instructions if you are using MySQL: https://umami.is/docs/guides/migrate-mysql-postgresql";
      }
    ];

    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 ];
}