Unverified Commit 00070cf8 authored by chayleaf's avatar chayleaf
Browse files

nixos/maubot: init

parent e96b8fd9
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -14,7 +14,7 @@ In addition to numerous new and upgraded packages, this release has the followin

<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->

- Create the first release note entry in this section!
- [maubot](https://github.com/maubot/maubot), a plugin-based Matrix bot framework. Available as [services.maubot](#opt-services.maubot.enable).

## Backward Incompatibilities {#sec-release-24.05-incompatibilities}

+1 −0
Original line number Diff line number Diff line
@@ -621,6 +621,7 @@
  ./services/matrix/appservice-irc.nix
  ./services/matrix/conduit.nix
  ./services/matrix/dendrite.nix
  ./services/matrix/maubot.nix
  ./services/matrix/mautrix-facebook.nix
  ./services/matrix/mautrix-telegram.nix
  ./services/matrix/mautrix-whatsapp.nix
+103 −0
Original line number Diff line number Diff line
# Maubot {#module-services-maubot}

[Maubot](https://github.com/maubot/maubot) is a plugin-based bot
framework for Matrix.

## Configuration {#module-services-maubot-configuration}

1. Set [](#opt-services.maubot.enable) to `true`. The service will use
   SQLite by default.
2. If you want to use PostgreSQL instead of SQLite, do this:

   ```nix
   services.maubot.settings.database = "postgresql://maubot@localhost/maubot";
   ```

   If the PostgreSQL connection requires a password, you will have to
   add it later on step 8.
3. If you plan to expose your Maubot interface to the web, do something
   like this:
   ```nix
   services.nginx.virtualHosts."matrix.example.org".locations = {
     "/_matrix/maubot/" = {
       proxyPass = "http://127.0.0.1:${toString config.services.maubot.settings.server.port}";
       proxyWebsockets = true;
     };
   };
   services.maubot.settings.server.public_url = "matrix.example.org";
   # do the following only if you want to use something other than /_matrix/maubot...
   services.maubot.settings.server.ui_base_path = "/another/base/path";
   ```
4. Optionally, set `services.maubot.pythonPackages` to a list of python3
   packages to make available for Maubot plugins.
5. Optionally, set `services.maubot.plugins` to a list of Maubot
   plugins (full list available at https://plugins.maubot.xyz/):
   ```nix
   services.maubot.plugins = with config.services.maubot.package.plugins; [
     reactbot
     # This will only change the default config! After you create a
     # plugin instance, the default config will be copied into that
     # instance's config in Maubot's database, and further base config
     # changes won't affect the running plugin.
     (rss.override {
       base_config = {
         update_interval = 60;
         max_backoff = 7200;
         spam_sleep = 2;
         command_prefix = "rss";
         admins = [ "@chayleaf:pavluk.org" ];
       };
     })
   ];
   # ...or...
   services.maubot.plugins = config.services.maubot.package.plugins.allOfficialPlugins;
   # ...or...
   services.maubot.plugins = config.services.maubot.package.plugins.allPlugins;
   # ...or...
   services.maubot.plugins = with config.services.maubot.package.plugins; [
     (weather.override {
       # you can pass base_config as a string
       base_config = ''
         default_location: New York
         default_units: M
         default_language:
         show_link: true
         show_image: false
       '';
     })
   ];
   ```
6. Start Maubot at least once before doing the following steps (it's
   necessary to generate the initial config).
7. If your PostgreSQL connection requires a password, add
   `database: postgresql://user:password@localhost/maubot`
   to `/var/lib/maubot/config.yaml`. This overrides the Nix-provided
   config. Even then, don't remove the `database` line from Nix config
   so the module knows you use PostgreSQL!
8. To create a user account for logging into Maubot web UI and
   configuring it, generate a password using the shell command
   `mkpasswd -R 12 -m bcrypt`, and edit `/var/lib/maubot/config.yaml`
   with the following:

   ```yaml
   admins:
       admin_username: $2b$12$g.oIStUeUCvI58ebYoVMtO/vb9QZJo81PsmVOomHiNCFbh0dJpZVa
   ```

   Where `admin_username` is your username, and `$2b...` is the bcrypted
   password.
9. Optional: if you want to be able to register new users with the
   Maubot CLI (`mbc`), and your homeserver is private, add your
   homeserver's registration key to `/var/lib/maubot/config.yaml`:

   ```yaml
   homeservers:
       matrix.example.org:
           url: https://matrix.example.org
           secret: your-very-secret-key
   ```
10. Restart Maubot after editing `/var/lib/maubot/config.yaml`,and
    Maubot will be available at
    `https://matrix.example.org/_matrix/maubot`. If you want to use the
    `mbc` CLI, it's available using the `maubot` package (`nix-shell -p
    maubot`).
+459 −0
Original line number Diff line number Diff line
{ 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 = [
    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 (mdDoc "maubot");

    package = lib.mkPackageOptionMD 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 = mdDoc ''
        List of additional maubot plugins to make available.
      '';
    };

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

    dataDir = mkOption {
      type = types.str;
      default = "/var/lib/maubot";
      description = mdDoc ''
        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 = mdDoc ''
        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 = mdDoc ''
        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 = mdDoc ''
        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 = mdDoc ''
              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 = mdDoc ''
              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 = mdDoc ''
              Additional arguments for asyncpg.create_pool() or sqlite3.connect()
            '';
          };

          plugin_directories = mkOption {
            default = { };
            description = mdDoc "Plugin directory paths";
            type = submodule {
              options = {
                upload = mkOption {
                  type = types.str;
                  default = "./plugins";
                  defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
                  description = mdDoc ''
                    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 = mdDoc ''
                    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 = mdDoc ''
                    The directory where old plugin versions and conflicting plugins should be moved. Set to null to delete files immediately.
                  '';
                };
              };
            };
          };

          plugin_databases = mkOption {
            description = mdDoc "Plugin database settings";
            default = { };
            type = submodule {
              options = {
                sqlite = mkOption {
                  type = types.str;
                  default = "./plugins";
                  defaultText = literalExpression ''"''${config.services.maubot.dataDir}/plugins"'';
                  description = mdDoc ''
                    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 = mdDoc ''
                    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 = mdDoc ''
                    Maximum number of connections per plugin instance.
                  '';
                };

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

          server = mkOption {
            default = { };
            description = mdDoc "Listener config";
            type = submodule {
              options = {
                hostname = mkOption {
                  type = types.str;
                  default = "127.0.0.1";
                  description = mdDoc ''
                    The IP to listen on
                  '';
                };
                port = mkOption {
                  type = types.port;
                  default = 29316;
                  description = mdDoc ''
                    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 = mdDoc ''
                    Public base URL where the server is visible.
                  '';
                };
                ui_base_path = mkOption {
                  type = types.str;
                  default = "/_matrix/maubot";
                  description = mdDoc ''
                    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 = mdDoc ''
                    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 = mdDoc ''
                    Override path from where to load UI resources.
                  '';
                };
              };
            };
          };

          homeservers = mkOption {
            type = types.attrsOf (types.submodule {
              options = {
                url = mkOption {
                  type = types.str;
                  description = mdDoc ''
                    Client-server API URL
                  '';
                };
              };
            });
            default = {
              "matrix.org" = {
                url = "https://matrix-client.matrix.org";
              };
            };
            description = mdDoc ''
              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 = mdDoc ''
              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 = mdDoc ''
              API feature switches.
            '';
          };

          logging = mkOption {
            type = types.attrs;
            description = mdDoc ''
              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;
}