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