base16-schemes: unstable-2024-06-21 -> unstable-2024-11-12
[NixPkgs.git] / nixos / modules / services / matrix / maubot.nix
blob7aea88bd273d5df27f3ea3bcf461f742e62888a0
1 { lib
2 , config
3 , pkgs
4 , ...
5 }:
7 let
8   cfg = config.services.maubot;
10   wrapper1 =
11     if cfg.plugins == [ ]
12     then cfg.package
13     else cfg.package.withPlugins (_: cfg.plugins);
15   wrapper2 =
16     if cfg.pythonPackages == [ ]
17     then wrapper1
18     else wrapper1.withPythonPackages (_: cfg.pythonPackages);
20   settings = lib.recursiveUpdate cfg.settings {
21     plugin_directories.trash =
22       if cfg.settings.plugin_directories.trash == null
23       then "delete"
24       else cfg.settings.plugin_directories.trash;
25     server.unshared_secret = "generate";
26   };
28   finalPackage = wrapper2.withBaseConfig settings;
30   isPostgresql = db: builtins.isString db && lib.hasPrefix "postgresql://" db;
31   isLocalPostgresDB = db: isPostgresql db && builtins.any (x: lib.hasInfix x db) [
32     "@127.0.0.1/"
33     "@::1/"
34     "@[::1]/"
35     "@localhost/"
36   ];
37   parsePostgresDB = db:
38     let
39       noSchema = lib.removePrefix "postgresql://" db;
40     in {
41       username = builtins.head (lib.splitString "@" noSchema);
42       database = lib.last (lib.splitString "/" noSchema);
43     };
45   postgresDBs = builtins.filter isPostgresql [
46     cfg.settings.database
47     cfg.settings.crypto_database
48     cfg.settings.plugin_databases.postgres
49   ];
51   localPostgresDBs = builtins.filter isLocalPostgresDB postgresDBs;
53   parsedLocalPostgresDBs = map parsePostgresDB localPostgresDBs;
54   parsedPostgresDBs = map parsePostgresDB postgresDBs;
56   hasLocalPostgresDB = localPostgresDBs != [ ];
59   options.services.maubot = with lib; {
60     enable = mkEnableOption "maubot";
62     package = lib.mkPackageOption pkgs "maubot" { };
64     plugins = mkOption {
65       type = types.listOf types.package;
66       default = [ ];
67       example = literalExpression ''
68         with config.services.maubot.package.plugins; [
69           xyz.maubot.reactbot
70           xyz.maubot.rss
71         ];
72       '';
73       description = ''
74         List of additional maubot plugins to make available.
75       '';
76     };
78     pythonPackages = mkOption {
79       type = types.listOf types.package;
80       default = [ ];
81       example = literalExpression ''
82         with pkgs.python3Packages; [
83           aiohttp
84         ];
85       '';
86       description = ''
87         List of additional Python packages to make available for maubot.
88       '';
89     };
91     dataDir = mkOption {
92       type = types.str;
93       default = "/var/lib/maubot";
94       description = ''
95         The directory where maubot stores its stateful data.
96       '';
97     };
99     extraConfigFile = mkOption {
100       type = types.str;
101       default = "./config.yaml";
102       defaultText = literalExpression ''"''${config.services.maubot.dataDir}/config.yaml"'';
103       description = ''
104         A file for storing secrets. You can pass homeserver registration keys here.
105         If it already exists, **it must contain `server.unshared_secret`** which is used for signing API keys.
106         If `configMutable` is not set to true, **maubot user must have write access to this file**.
107       '';
108     };
110     configMutable = mkOption {
111       type = types.bool;
112       default = false;
113       description = ''
114         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!**
115       '';
116     };
118     settings = mkOption {
119       default = { };
120       description = ''
121         YAML settings for maubot. See the
122         [example configuration](https://github.com/maubot/maubot/blob/master/maubot/example-config.yaml)
123         for more info.
125         Secrets should be passed in by using `extraConfigFile`.
126       '';
127       type = with types; submodule {
128         options = {
129           database = mkOption {
130             type = str;
131             default = "sqlite:maubot.db";
132             example = "postgresql://username:password@hostname/dbname";
133             description = ''
134               The full URI to the database. SQLite and Postgres are fully supported.
135               Other DBMSes supported by SQLAlchemy may or may not work.
136             '';
137           };
139           crypto_database = mkOption {
140             type = str;
141             default = "default";
142             example = "postgresql://username:password@hostname/dbname";
143             description = ''
144               Separate database URL for the crypto database. By default, the regular database is also used for crypto.
145             '';
146           };
148           database_opts = mkOption {
149             type = types.attrs;
150             default = { };
151             description = ''
152               Additional arguments for asyncpg.create_pool() or sqlite3.connect()
153             '';
154           };
156           plugin_directories = mkOption {
157             default = { };
158             description = "Plugin directory paths";
159             type = submodule {
160               options = {
161                 upload = mkOption {
162                   type = types.str;
163                   default = "./plugins";
164                   defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
165                   description = ''
166                     The directory where uploaded new plugins should be stored.
167                   '';
168                 };
169                 load = mkOption {
170                   type = types.listOf types.str;
171                   default = [ "./plugins" ];
172                   defaultText = literalExpression ''[ "''${config.services.maubot.dataDir}/plugins" ]'';
173                   description = ''
174                     The directories from which plugins should be loaded. Duplicate plugin IDs will be moved to the trash.
175                   '';
176                 };
177                 trash = mkOption {
178                   type = with types; nullOr str;
179                   default = "./trash";
180                   defaultText = literalExpression ''"''${config.services.maubot.dataDir}/trash"'';
181                   description = ''
182                     The directory where old plugin versions and conflicting plugins should be moved. Set to null to delete files immediately.
183                   '';
184                 };
185               };
186             };
187           };
189           plugin_databases = mkOption {
190             description = "Plugin database settings";
191             default = { };
192             type = submodule {
193               options = {
194                 sqlite = mkOption {
195                   type = types.str;
196                   default = "./plugins";
197                   defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
198                   description = ''
199                     The directory where SQLite plugin databases should be stored.
200                   '';
201                 };
203                 postgres = mkOption {
204                   type = types.nullOr types.str;
205                   default = if isPostgresql cfg.settings.database then "default" else null;
206                   defaultText = literalExpression ''if isPostgresql config.services.maubot.settings.database then "default" else null'';
207                   description = ''
208                     The connection URL for plugin database. See [example config](https://github.com/maubot/maubot/blob/master/maubot/example-config.yaml) for exact format.
209                   '';
210                 };
212                 postgres_max_conns_per_plugin = mkOption {
213                   type = types.nullOr types.int;
214                   default = 3;
215                   description = ''
216                     Maximum number of connections per plugin instance.
217                   '';
218                 };
220                 postgres_opts = mkOption {
221                   type = types.attrs;
222                   default = { };
223                   description = ''
224                     Overrides for the default database_opts when using a non-default postgres connection URL.
225                   '';
226                 };
227               };
228             };
229           };
231           server = mkOption {
232             default = { };
233             description = "Listener config";
234             type = submodule {
235               options = {
236                 hostname = mkOption {
237                   type = types.str;
238                   default = "127.0.0.1";
239                   description = ''
240                     The IP to listen on
241                   '';
242                 };
243                 port = mkOption {
244                   type = types.port;
245                   default = 29316;
246                   description = ''
247                     The port to listen on
248                   '';
249                 };
250                 public_url = mkOption {
251                   type = types.str;
252                   default = "http://${cfg.settings.server.hostname}:${toString cfg.settings.server.port}";
253                   defaultText = literalExpression ''"http://''${config.services.maubot.settings.server.hostname}:''${toString config.services.maubot.settings.server.port}"'';
254                   description = ''
255                     Public base URL where the server is visible.
256                   '';
257                 };
258                 ui_base_path = mkOption {
259                   type = types.str;
260                   default = "/_matrix/maubot";
261                   description = ''
262                     The base path for the UI.
263                   '';
264                 };
265                 plugin_base_path = mkOption {
266                   type = types.str;
267                   default = "${config.services.maubot.settings.server.ui_base_path}/plugin/";
268                   defaultText = literalExpression ''
269                     "''${config.services.maubot.settings.server.ui_base_path}/plugin/"
270                   '';
271                   description = ''
272                     The base path for plugin endpoints. The instance ID will be appended directly.
273                   '';
274                 };
275                 override_resource_path = mkOption {
276                   type = types.nullOr types.str;
277                   default = null;
278                   description = ''
279                     Override path from where to load UI resources.
280                   '';
281                 };
282               };
283             };
284           };
286           homeservers = mkOption {
287             type = types.attrsOf (types.submodule {
288               options = {
289                 url = mkOption {
290                   type = types.str;
291                   description = ''
292                     Client-server API URL
293                   '';
294                 };
295               };
296             });
297             default = {
298               "matrix.org" = {
299                 url = "https://matrix-client.matrix.org";
300               };
301             };
302             description = ''
303               Known homeservers. This is required for the `mbc auth` command and also allows more convenient access from the management UI.
304               If you want to specify registration secrets, pass this via extraConfigFile instead.
305             '';
306           };
308           admins = mkOption {
309             type = types.attrsOf types.str;
310             default = { root = ""; };
311             description = ''
312               List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
313               to prevent normal login. Root is a special user that can't have a password and will always exist.
314             '';
315           };
317           api_features = mkOption {
318             type = types.attrsOf bool;
319             default = {
320               login = true;
321               plugin = true;
322               plugin_upload = true;
323               instance = true;
324               instance_database = true;
325               client = true;
326               client_proxy = true;
327               client_auth = true;
328               dev_open = true;
329               log = true;
330             };
331             description = ''
332               API feature switches.
333             '';
334           };
336           logging = mkOption {
337             type = types.attrs;
338             description = ''
339               Python logging configuration. See [section 16.7.2 of the Python
340               documentation](https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema)
341               for more info.
342             '';
343             default = {
344               version = 1;
345               formatters = {
346                 colored = {
347                   "()" = "maubot.lib.color_log.ColorFormatter";
348                   format = "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s";
349                 };
350                 normal = {
351                   format = "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s";
352                 };
353               };
354               handlers = {
355                 file = {
356                   class = "logging.handlers.RotatingFileHandler";
357                   formatter = "normal";
358                   filename = "./maubot.log";
359                   maxBytes = 10485760;
360                   backupCount = 10;
361                 };
362                 console = {
363                   class = "logging.StreamHandler";
364                   formatter = "colored";
365                 };
366               };
367               loggers = {
368                 maubot = {
369                   level = "DEBUG";
370                 };
371                 mau = {
372                   level = "DEBUG";
373                 };
374                 aiohttp = {
375                   level = "INFO";
376                 };
377               };
378               root = {
379                 level = "DEBUG";
380                 handlers = [ "file" "console" ];
381               };
382             };
383           };
384         };
385       };
386     };
387   };
389   config = lib.mkIf cfg.enable {
390     warnings = lib.optional (builtins.any (x: x.username != x.database) parsedLocalPostgresDBs) ''
391       The Maubot database username doesn't match the database name! This means the user won't be automatically
392       granted ownership of the database. Consider changing either the username or the database name.
393     '';
394     assertions = [
395       {
396         assertion = builtins.all (x: !lib.hasInfix ":" x.username) parsedPostgresDBs;
397         message = ''
398           Putting database passwords in your Nix config makes them world-readable. To securely put passwords
399           in your Maubot config, change /var/lib/maubot/config.yaml after running Maubot at least once as
400           described in the NixOS manual.
401         '';
402       }
403       {
404         assertion = hasLocalPostgresDB -> config.services.postgresql.enable;
405         message = ''
406           Cannot deploy maubot with a configuration for a local postgresql database and a missing postgresql service.
407         '';
408       }
409     ];
411     services.postgresql = lib.mkIf hasLocalPostgresDB {
412       enable = true;
413       ensureDatabases = map (x: x.database) parsedLocalPostgresDBs;
414       ensureUsers = lib.flip map parsedLocalPostgresDBs (x: {
415         name = x.username;
416         ensureDBOwnership = lib.mkIf (x.username == x.database) true;
417       });
418     };
420     users.users.maubot = {
421       group = "maubot";
422       home = cfg.dataDir;
423       # otherwise StateDirectory is enough
424       createHome = lib.mkIf (cfg.dataDir != "/var/lib/maubot") true;
425       isSystemUser = true;
426     };
428     users.groups.maubot = { };
430     systemd.services.maubot = rec {
431       description = "maubot - a plugin-based Matrix bot system written in Python";
432       after = [ "network.target" ] ++ wants ++ lib.optional hasLocalPostgresDB "postgresql.service";
433       # all plugins get automatically disabled if maubot starts before synapse
434       wants = lib.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit;
435       wantedBy = [ "multi-user.target" ];
437       preStart = ''
438         if [ ! -f "${cfg.extraConfigFile}" ]; then
439           echo "server:" > "${cfg.extraConfigFile}"
440           echo "    unshared_secret: $(head -c40 /dev/random | base32 | ${pkgs.gawk}/bin/awk '{print tolower($0)}')" > "${cfg.extraConfigFile}"
441           chmod 640 "${cfg.extraConfigFile}"
442         fi
443       '';
445       serviceConfig = {
446         ExecStart = "${finalPackage}/bin/maubot --config ${cfg.extraConfigFile}" + lib.optionalString (!cfg.configMutable) " --no-update";
447         User = "maubot";
448         Group = "maubot";
449         Restart = "on-failure";
450         RestartSec = "10s";
451         StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/maubot") "maubot";
452         WorkingDirectory = cfg.dataDir;
453       };
454     };
455   };
457   meta.maintainers = with lib.maintainers; [ chayleaf ];
458   meta.doc = ./maubot.md;