about summary refs log tree commit diff
path: root/nixos/modules/services/matrix/maubot.nix
blob: 7aea88bd273d5df27f3ea3bcf461f742e62888a0 (plain) (blame)
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
{ lib
, config
, pkgs
, ...
}:

let
  cfg = config.services.maubot;

  wrapper1 =
    if cfg.plugins == [ ]
    then cfg.package
    else cfg.package.withPlugins (_: cfg.plugins);

  wrapper2 =
    if cfg.pythonPackages == [ ]
    then wrapper1
    else wrapper1.withPythonPackages (_: cfg.pythonPackages);

  settings = lib.recursiveUpdate cfg.settings {
    plugin_directories.trash =
      if cfg.settings.plugin_directories.trash == null
      then "delete"
      else cfg.settings.plugin_directories.trash;
    server.unshared_secret = "generate";
  };

  finalPackage = wrapper2.withBaseConfig settings;

  isPostgresql = db: builtins.isString db && lib.hasPrefix "postgresql://" db;
  isLocalPostgresDB = db: isPostgresql db && builtins.any (x: lib.hasInfix x db) [
    "@127.0.0.1/"
    "@::1/"
    "@[::1]/"
    "@localhost/"
  ];
  parsePostgresDB = db:
    let
      noSchema = lib.removePrefix "postgresql://" db;
    in {
      username = builtins.head (lib.splitString "@" noSchema);
      database = lib.last (lib.splitString "/" noSchema);
    };

  postgresDBs = builtins.filter isPostgresql [
    cfg.settings.database
    cfg.settings.crypto_database
    cfg.settings.plugin_databases.postgres
  ];

  localPostgresDBs = builtins.filter isLocalPostgresDB postgresDBs;

  parsedLocalPostgresDBs = map parsePostgresDB localPostgresDBs;
  parsedPostgresDBs = map parsePostgresDB postgresDBs;

  hasLocalPostgresDB = localPostgresDBs != [ ];
in
{
  options.services.maubot = with lib; {
    enable = mkEnableOption "maubot";

    package = lib.mkPackageOption pkgs "maubot" { };

    plugins = mkOption {
      type = types.listOf types.package;
      default = [ ];
      example = literalExpression ''
        with config.services.maubot.package.plugins; [
          xyz.maubot.reactbot
          xyz.maubot.rss
        ];
      '';
      description = ''
        List of additional maubot plugins to make available.
      '';
    };

    pythonPackages = mkOption {
      type = types.listOf types.package;
      default = [ ];
      example = literalExpression ''
        with pkgs.python3Packages; [
          aiohttp
        ];
      '';
      description = ''
        List of additional Python packages to make available for maubot.
      '';
    };

    dataDir = mkOption {
      type = types.str;
      default = "/var/lib/maubot";
      description = ''
        The directory where maubot stores its stateful data.
      '';
    };

    extraConfigFile = mkOption {
      type = types.str;
      default = "./config.yaml";
      defaultText = literalExpression ''"''${config.services.maubot.dataDir}/config.yaml"'';
      description = ''
        A file for storing secrets. You can pass homeserver registration keys here.
        If it already exists, **it must contain `server.unshared_secret`** which is used for signing API keys.
        If `configMutable` is not set to true, **maubot user must have write access to this file**.
      '';
    };

    configMutable = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Whether maubot should write updated config into `extraConfigFile`. **This will make your Nix module settings have no effect besides the initial config, as extraConfigFile takes precedence over NixOS settings!**
      '';
    };

    settings = mkOption {
      default = { };
      description = ''
        YAML settings for maubot. See the
        [example configuration](https://github.com/maubot/maubot/blob/master/maubot/example-config.yaml)
        for more info.

        Secrets should be passed in by using `extraConfigFile`.
      '';
      type = with types; submodule {
        options = {
          database = mkOption {
            type = str;
            default = "sqlite:maubot.db";
            example = "postgresql://username:password@hostname/dbname";
            description = ''
              The full URI to the database. SQLite and Postgres are fully supported.
              Other DBMSes supported by SQLAlchemy may or may not work.
            '';
          };

          crypto_database = mkOption {
            type = str;
            default = "default";
            example = "postgresql://username:password@hostname/dbname";
            description = ''
              Separate database URL for the crypto database. By default, the regular database is also used for crypto.
            '';
          };

          database_opts = mkOption {
            type = types.attrs;
            default = { };
            description = ''
              Additional arguments for asyncpg.create_pool() or sqlite3.connect()
            '';
          };

          plugin_directories = mkOption {
            default = { };
            description = "Plugin directory paths";
            type = submodule {
              options = {
                upload = mkOption {
                  type = types.str;
                  default = "./plugins";
                  defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
                  description = ''
                    The directory where uploaded new plugins should be stored.
                  '';
                };
                load = mkOption {
                  type = types.listOf types.str;
                  default = [ "./plugins" ];
                  defaultText = literalExpression ''[ "''${config.services.maubot.dataDir}/plugins" ]'';
                  description = ''
                    The directories from which plugins should be loaded. Duplicate plugin IDs will be moved to the trash.
                  '';
                };
                trash = mkOption {
                  type = with types; nullOr str;
                  default = "./trash";
                  defaultText = literalExpression ''"''${config.services.maubot.dataDir}/trash"'';
                  description = ''
                    The directory where old plugin versions and conflicting plugins should be moved. Set to null to delete files immediately.
                  '';
                };
              };
            };
          };

          plugin_databases = mkOption {
            description = "Plugin database settings";
            default = { };
            type = submodule {
              options = {
                sqlite = mkOption {
                  type = types.str;
                  default = "./plugins";
                  defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
                  description = ''
                    The directory where SQLite plugin databases should be stored.
                  '';
                };

                postgres = mkOption {
                  type = types.nullOr types.str;
                  default = if isPostgresql cfg.settings.database then "default" else null;
                  defaultText = literalExpression ''if isPostgresql config.services.maubot.settings.database then "default" else null'';
                  description = ''
                    The connection URL for plugin database. See [example config](https://github.com/maubot/maubot/blob/master/maubot/example-config.yaml) for exact format.
                  '';
                };

                postgres_max_conns_per_plugin = mkOption {
                  type = types.nullOr types.int;
                  default = 3;
                  description = ''
                    Maximum number of connections per plugin instance.
                  '';
                };

                postgres_opts = mkOption {
                  type = types.attrs;
                  default = { };
                  description = ''
                    Overrides for the default database_opts when using a non-default postgres connection URL.
                  '';
                };
              };
            };
          };

          server = mkOption {
            default = { };
            description = "Listener config";
            type = submodule {
              options = {
                hostname = mkOption {
                  type = types.str;
                  default = "127.0.0.1";
                  description = ''
                    The IP to listen on
                  '';
                };
                port = mkOption {
                  type = types.port;
                  default = 29316;
                  description = ''
                    The port to listen on
                  '';
                };
                public_url = mkOption {
                  type = types.str;
                  default = "http://${cfg.settings.server.hostname}:${toString cfg.settings.server.port}";
                  defaultText = literalExpression ''"http://''${config.services.maubot.settings.server.hostname}:''${toString config.services.maubot.settings.server.port}"'';
                  description = ''
                    Public base URL where the server is visible.
                  '';
                };
                ui_base_path = mkOption {
                  type = types.str;
                  default = "/_matrix/maubot";
                  description = ''
                    The base path for the UI.
                  '';
                };
                plugin_base_path = mkOption {
                  type = types.str;
                  default = "${config.services.maubot.settings.server.ui_base_path}/plugin/";
                  defaultText = literalExpression ''
                    "''${config.services.maubot.settings.server.ui_base_path}/plugin/"
                  '';
                  description = ''
                    The base path for plugin endpoints. The instance ID will be appended directly.
                  '';
                };
                override_resource_path = mkOption {
                  type = types.nullOr types.str;
                  default = null;
                  description = ''
                    Override path from where to load UI resources.
                  '';
                };
              };
            };
          };

          homeservers = mkOption {
            type = types.attrsOf (types.submodule {
              options = {
                url = mkOption {
                  type = types.str;
                  description = ''
                    Client-server API URL
                  '';
                };
              };
            });
            default = {
              "matrix.org" = {
                url = "https://matrix-client.matrix.org";
              };
            };
            description = ''
              Known homeservers. This is required for the `mbc auth` command and also allows more convenient access from the management UI.
              If you want to specify registration secrets, pass this via extraConfigFile instead.
            '';
          };

          admins = mkOption {
            type = types.attrsOf types.str;
            default = { root = ""; };
            description = ''
              List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
              to prevent normal login. Root is a special user that can't have a password and will always exist.
            '';
          };

          api_features = mkOption {
            type = types.attrsOf bool;
            default = {
              login = true;
              plugin = true;
              plugin_upload = true;
              instance = true;
              instance_database = true;
              client = true;
              client_proxy = true;
              client_auth = true;
              dev_open = true;
              log = true;
            };
            description = ''
              API feature switches.
            '';
          };

          logging = mkOption {
            type = types.attrs;
            description = ''
              Python logging configuration. See [section 16.7.2 of the Python
              documentation](https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema)
              for more info.
            '';
            default = {
              version = 1;
              formatters = {
                colored = {
                  "()" = "maubot.lib.color_log.ColorFormatter";
                  format = "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s";
                };
                normal = {
                  format = "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s";
                };
              };
              handlers = {
                file = {
                  class = "logging.handlers.RotatingFileHandler";
                  formatter = "normal";
                  filename = "./maubot.log";
                  maxBytes = 10485760;
                  backupCount = 10;
                };
                console = {
                  class = "logging.StreamHandler";
                  formatter = "colored";
                };
              };
              loggers = {
                maubot = {
                  level = "DEBUG";
                };
                mau = {
                  level = "DEBUG";
                };
                aiohttp = {
                  level = "INFO";
                };
              };
              root = {
                level = "DEBUG";
                handlers = [ "file" "console" ];
              };
            };
          };
        };
      };
    };
  };

  config = lib.mkIf cfg.enable {
    warnings = lib.optional (builtins.any (x: x.username != x.database) parsedLocalPostgresDBs) ''
      The Maubot database username doesn't match the database name! This means the user won't be automatically
      granted ownership of the database. Consider changing either the username or the database name.
    '';
    assertions = [
      {
        assertion = builtins.all (x: !lib.hasInfix ":" x.username) parsedPostgresDBs;
        message = ''
          Putting database passwords in your Nix config makes them world-readable. To securely put passwords
          in your Maubot config, change /var/lib/maubot/config.yaml after running Maubot at least once as
          described in the NixOS manual.
        '';
      }
      {
        assertion = hasLocalPostgresDB -> config.services.postgresql.enable;
        message = ''
          Cannot deploy maubot with a configuration for a local postgresql database and a missing postgresql service.
        '';
      }
    ];

    services.postgresql = lib.mkIf hasLocalPostgresDB {
      enable = true;
      ensureDatabases = map (x: x.database) parsedLocalPostgresDBs;
      ensureUsers = lib.flip map parsedLocalPostgresDBs (x: {
        name = x.username;
        ensureDBOwnership = lib.mkIf (x.username == x.database) true;
      });
    };

    users.users.maubot = {
      group = "maubot";
      home = cfg.dataDir;
      # otherwise StateDirectory is enough
      createHome = lib.mkIf (cfg.dataDir != "/var/lib/maubot") true;
      isSystemUser = true;
    };

    users.groups.maubot = { };

    systemd.services.maubot = rec {
      description = "maubot - a plugin-based Matrix bot system written in Python";
      after = [ "network.target" ] ++ wants ++ lib.optional hasLocalPostgresDB "postgresql.service";
      # all plugins get automatically disabled if maubot starts before synapse
      wants = lib.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit;
      wantedBy = [ "multi-user.target" ];

      preStart = ''
        if [ ! -f "${cfg.extraConfigFile}" ]; then
          echo "server:" > "${cfg.extraConfigFile}"
          echo "    unshared_secret: $(head -c40 /dev/random | base32 | ${pkgs.gawk}/bin/awk '{print tolower($0)}')" > "${cfg.extraConfigFile}"
          chmod 640 "${cfg.extraConfigFile}"
        fi
      '';

      serviceConfig = {
        ExecStart = "${finalPackage}/bin/maubot --config ${cfg.extraConfigFile}" + lib.optionalString (!cfg.configMutable) " --no-update";
        User = "maubot";
        Group = "maubot";
        Restart = "on-failure";
        RestartSec = "10s";
        StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/maubot") "maubot";
        WorkingDirectory = cfg.dataDir;
      };
    };
  };

  meta.maintainers = with lib.maintainers; [ chayleaf ];
  meta.doc = ./maubot.md;
}