about summary refs log tree commit diff
path: root/overlays
diff options
context:
space:
mode:
authorsefidel <contact@sefidel.net>2023-03-29 20:54:19 +0900
committersefidel <contact@sefidel.net>2023-04-03 18:32:29 +0900
commitce06f43476863da90dc60dcee606d2b6c5a89a8e (patch)
tree5d14946330cb09ff0ebd97bee59407fccee4d860 /overlays
downloadinfra-ce06f43476863da90dc60dcee606d2b6c5a89a8e.tar.gz
infra-ce06f43476863da90dc60dcee606d2b6c5a89a8e.zip
project: initial commit
Diffstat (limited to 'overlays')
-rw-r--r--overlays/README.md4
-rw-r--r--overlays/default.nix4
-rw-r--r--overlays/git-daemon-module.nix137
-rw-r--r--overlays/mautrix-signal-module.nix196
-rw-r--r--overlays/mautrix-whatsapp-module.nix192
-rw-r--r--overlays/mjolnir-module/default.nix242
-rw-r--r--overlays/mjolnir-module/mjolnir.md110
-rw-r--r--overlays/mjolnir-module/pantalaimon-options.nix70
-rw-r--r--overlays/mjolnir-package/default.nix80
-rw-r--r--overlays/mjolnir-package/package.json69
-rw-r--r--overlays/mjolnir-package/pin.json5
-rwxr-xr-xoverlays/mjolnir-package/update.sh36
-rw-r--r--overlays/sliding-sync-module.nix87
-rw-r--r--overlays/sliding-sync.nix22
-rw-r--r--overlays/soju-module.nix132
15 files changed, 1386 insertions, 0 deletions
diff --git a/overlays/README.md b/overlays/README.md
new file mode 100644
index 0000000..4d257f8
--- /dev/null
+++ b/overlays/README.md
@@ -0,0 +1,4 @@
+infra->overlays
+===============
+
+Nixpkgs overlays or module replacements.
diff --git a/overlays/default.nix b/overlays/default.nix
new file mode 100644
index 0000000..353d5bd
--- /dev/null
+++ b/overlays/default.nix
@@ -0,0 +1,4 @@
+self: super: {
+  mjolnir = super.callPackage ./mjolnir-package { };
+  sliding-sync = super.callPackage ./sliding-sync.nix { };
+}
diff --git a/overlays/git-daemon-module.nix b/overlays/git-daemon-module.nix
new file mode 100644
index 0000000..76b395e
--- /dev/null
+++ b/overlays/git-daemon-module.nix
@@ -0,0 +1,137 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+
+  cfg = config.services.gitDaemon;
+
+in
+{
+
+  ###### interface
+
+  options = {
+    services.gitDaemon = {
+
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Enable Git daemon, which allows public hosting of git repositories
+          without any access controls. This is mostly intended for read-only access.
+
+          You can allow write access by setting daemon.receivepack configuration
+          item of the repository to true. This is solely meant for a closed LAN setting
+          where everybody is friendly.
+
+          If you need any access controls, use something else.
+        '';
+      };
+
+      basePath = mkOption {
+        type = types.str;
+        default = "";
+        example = "/srv/git/";
+        description = lib.mdDoc ''
+          Remap all the path requests as relative to the given path. For example,
+          if you set base-path to /srv/git, then if you later try to pull
+          git://example.com/hello.git, Git daemon will interpret the path as /srv/git/hello.git.
+        '';
+      };
+
+      exportAll = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Publish all directories that look like Git repositories (have the objects
+          and refs subdirectories), even if they do not have the git-daemon-export-ok file.
+
+          If disabled, you need to touch .git/git-daemon-export-ok in each repository
+          you want the daemon to publish.
+
+          Warning: enabling this without a repository whitelist or basePath
+          publishes every git repository you have.
+        '';
+      };
+
+      repositories = mkOption {
+        type = types.listOf types.str;
+        default = [ ];
+        example = [ "/srv/git" "/home/user/git/repo2" ];
+        description = lib.mdDoc ''
+          A whitelist of paths of git repositories, or directories containing repositories
+          all of which would be published. Paths must not end in "/".
+
+          Warning: leaving this empty and enabling exportAll publishes all
+          repositories in your filesystem or basePath if specified.
+        '';
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "";
+        example = "example.com";
+        description = lib.mdDoc "Listen on a specific IP address or hostname.";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 9418;
+        description = lib.mdDoc "Port to listen on.";
+      };
+
+      options = mkOption {
+        type = types.str;
+        default = "";
+        description = lib.mdDoc "Extra configuration options to be passed to Git daemon.";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "User under which Git daemon would be running.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "Group under which Git daemon would be running.";
+      };
+
+      createUserAndGroup = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc ''
+          Create the specified group and user.
+          Disable this option if you want to use the existing user
+        '';
+      };
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+
+    users.users.${cfg.user} = optionalAttrs (cfg.createUserAndGroup == true) {
+      uid = config.ids.uids.git;
+      group = cfg.group;
+      description = "Git daemon user";
+    };
+
+    users.groups.${cfg.group} = optionalAttrs (cfg.createUserAndGroup == true) {
+      gid = config.ids.gids.git;
+    };
+
+    systemd.services.git-daemon = {
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      script = "${pkgs.git}/bin/git daemon --reuseaddr "
+        + (optionalString (cfg.basePath != "") "--base-path=${cfg.basePath} ")
+        + (optionalString (cfg.listenAddress != "") "--listen=${cfg.listenAddress} ")
+        + "--port=${toString cfg.port} --user=${cfg.user} --group=${cfg.group} ${cfg.options} "
+        + "--verbose " + (optionalString cfg.exportAll "--export-all ") + concatStringsSep " " cfg.repositories;
+    };
+
+  };
+
+}
diff --git a/overlays/mautrix-signal-module.nix b/overlays/mautrix-signal-module.nix
new file mode 100644
index 0000000..22abbb5
--- /dev/null
+++ b/overlays/mautrix-signal-module.nix
@@ -0,0 +1,196 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  dataDir = "/var/lib/mautrix-signal";
+  registrationFile = "${dataDir}/signal-registration.yaml";
+  cfg = config.services.mautrix-signal;
+  settingsFormat = pkgs.formats.json { };
+  settingsFile =
+    settingsFormat.generate "mautrix-signal-config.json" cfg.settings;
+
+in
+{
+  options = {
+    services.mautrix-signal = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-Signal, a Matrix-Signal puppeting bridge.");
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = "sqlite:///${dataDir}/mautrix-signal.db";
+            database_opts = { };
+            hostname = "0.0.0.0";
+            port = 8080;
+            address = "http://localhost:${toString port}";
+          };
+
+          signal.socket_path = config.services.signald.socketPath;
+
+          bridge = {
+            permissions."*" = "relay";
+            relay.whitelist = [ ];
+            double_puppet_server_map = { };
+            login_shared_secret_map = { };
+          };
+
+          logging = {
+            version = 1;
+
+            formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s";
+
+            handlers.console = {
+              class = "logging.StreamHandler";
+              formatter = "precise";
+            };
+
+            loggers = {
+              mau.level = "INFO";
+
+              # prevent tokens from leaking in the logs:
+              # https://github.com/tulir/mautrix-telegram/issues/351
+              aiohttp.level = "WARNING";
+            };
+
+            # log to console/systemd instead of file
+            root = {
+              level = "INFO";
+              handlers = [ "console" ];
+            };
+          };
+        };
+        example = literalExpression ''
+          {
+            homeserver = {
+              address = "http://localhost:8008";
+              domain = "public-domain.tld";
+            };
+
+            appservice.public = {
+              prefix = "/public";
+              external = "https://public-appservice-address/public";
+            };
+
+            bridge.permissions = {
+              "example.com" = "full";
+              "@admin:example.com" = "admin";
+            };
+          }
+        '';
+        description = lib.mdDoc ''
+          {file}`config.yaml` configuration as a Nix attribute set.
+          Configuration options should match those described in
+          [example-config.yaml](https://github.com/mautrix/signal/blob/master/signal/example-config.yaml).
+
+          Secret tokens should be specified using {option}`environmentFile`
+          instead of this world-readable attribute set.
+        '';
+      };
+
+      environmentFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = lib.mdDoc ''
+          File containing environment variables to be passed to the mautrix-signal service,
+          in which secret tokens can be specified securely by defining values for e.g.
+          `MAUTRIX_SIGNAL_APPSERVICE_AS_TOKEN`,
+          `MAUTRIX_SIGNAL_APPSERVICE_HS_TOKEN`,
+
+          These environment variables can also be used to set other options by
+          replacing hierarchy levels by `.`, converting the name to uppercase
+          and prepending `MAUTRIX_SIGNAL_`.
+          For example, the first value above maps to
+          {option}`settings.appservice.as_token`.
+
+          The environment variable values can be prefixed with `json::` to have
+          them be parsed as JSON. For example, `login_shared_secret_map` can be
+          set as follows:
+          `MAUTRIX_SIGNAL_BRIDGE_LOGIN_SHARED_SECRET_MAP=json::{"example.com":"secret"}`.
+        '';
+      };
+
+      serviceDependencies = mkOption {
+        type = with types; listOf str;
+        default = optional config.services.matrix-synapse.enable "matrix-synapse.service";
+        defaultText = literalExpression ''
+          optional config.services.matrix-synapse.enable "matrix-synapse.service"
+        '';
+        description = lib.mdDoc ''
+          List of Systemd services to require and wait for when starting the application service.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.signald.enable = true;
+
+    systemd.services.mautrix-signal = {
+      description = "Mautrix-Signal, a Matrix-Signal puppeting bridge.";
+
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      after = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      path = [ pkgs.lottieconverter pkgs.ffmpeg-full ];
+
+      # mautrix-signal tries to generate a dotfile in the home directory of
+      # the running user if using a postgresql database:
+      #
+      #  File "python3.10/site-packages/asyncpg/connect_utils.py", line 257, in _dot_postgre>
+      #    return (pathlib.Path.home() / '.postgresql' / filename).resolve()
+      #  File "python3.10/pathlib.py", line 1000, in home
+      #    return cls("~").expanduser()
+      #  File "python3.10/pathlib.py", line 1440, in expanduser
+      #    raise RuntimeError("Could not determine home directory.")
+      # RuntimeError: Could not determine home directory.
+      environment.HOME = dataDir;
+
+      preStart = ''
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${pkgs.mautrix-signal}/bin/mautrix-signal \
+            --generate-registration \
+            --base-config='${pkgs.mautrix-signal}/${pkgs.mautrix-signal.pythonModule.sitePackages}/mautrix_signal/example-config.yaml' \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+      '' + lib.optionalString (pkgs.mautrix-signal ? alembic) ''
+        # run automatic database init and migration scripts
+        ${pkgs.mautrix-signal.alembic}/bin/alembic -x config='${settingsFile}' upgrade head
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+
+        DynamicUser = true;
+        SupplementaryGroups = [ "signald" ];
+        PrivateTmp = true;
+        WorkingDirectory = pkgs.mautrix-signal; # necessary for the database migration scripts to be found
+        StateDirectory = baseNameOf dataDir;
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${pkgs.mautrix-signal}/bin/mautrix-signal \
+            --config='${settingsFile}'
+        '';
+      };
+    };
+  };
+
+  # meta.maintainers = with maintainers; [ boppyt ];
+}
diff --git a/overlays/mautrix-whatsapp-module.nix b/overlays/mautrix-whatsapp-module.nix
new file mode 100644
index 0000000..4cebcb6
--- /dev/null
+++ b/overlays/mautrix-whatsapp-module.nix
@@ -0,0 +1,192 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  dataDir = "/var/lib/mautrix-whatsapp";
+  registrationFile = "${dataDir}/whatsapp-registration.yaml";
+  cfg = config.services.mautrix-whatsapp;
+  settingsFormat = pkgs.formats.json { };
+  settingsFile =
+    settingsFormat.generate "mautrix-whatsapp-config.json" cfg.settings;
+in
+{
+  options = {
+    services.mautrix-whatsapp = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-Whatsapp, a Matrix-Whatsapp puppeting bridge.");
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = {
+              type = "sqlite";
+              uri = "sqlite:///${dataDir}/mautrix-whatsapp.db";
+            };
+            hostname = "0.0.0.0";
+            port = 8080;
+            address = "http://localhost:${toString port}";
+            as_token = "$MAUTRIX_WHATSAPP_APPSERVICE_AS_TOKEN";
+            hs_token = "$MAUTRIX_WHATSAPP_APPSERVICE_HS_TOKEN";
+          };
+
+          bridge = {
+            permissions."*" = "relay";
+            relay.whitelist = [ ];
+            double_puppet_server_map = { };
+            login_shared_secret_map = { };
+          };
+
+          logging = {
+            version = 1;
+
+            formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s";
+
+            handlers.console = {
+              class = "logging.StreamHandler";
+              formatter = "precise";
+            };
+
+            # log to console/systemd instead of file
+            file_name_format = null;
+
+            loggers = {
+              mau.level = "INFO";
+              telethon.level = "INFO";
+
+              # prevent tokens from leaking in the logs:
+              # https://github.com/tulir/mautrix-telegram/issues/351
+              aiohttp.level = "WARNING";
+            };
+          };
+        };
+        example = literalExpression ''
+          {
+            homeserver = {
+              address = "http://localhost:8008";
+              domain = "public-domain.tld";
+            };
+
+            appservice.public = {
+              prefix = "/public";
+              external = "https://public-appservice-address/public";
+            };
+
+            bridge.permissions = {
+              "example.com" = "full";
+              "@admin:example.com" = "admin";
+            };
+          }
+        '';
+        description = lib.mdDoc ''
+          {file}`config.yaml` configuration as a Nix attribute set.
+          Configuration options should match those described in
+          [example-config.yaml](https://github.com/mautrix/whatsapp/blob/master/mautrix_whatsapp/example-config.yaml).
+
+          Secret tokens should be specified using {option}`environmentFile`
+          instead of this world-readable attribute set.
+        '';
+      };
+
+      environmentFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = lib.mdDoc ''
+          File containing environment variables to be passed to the mautrix-whatsapp service,
+          in which secret tokens can be specified securely by defining values for e.g.
+          `MAUTRIX_WHATSAPP_APPSERVICE_AS_TOKEN`,
+          `MAUTRIX_WHATSAPP_APPSERVICE_HS_TOKEN`,
+
+          For Mautrix-Whatsapp, only AS_TOKEN and HS_TOKEN is available.
+        '';
+      };
+
+      serviceDependencies = mkOption {
+        type = with types; listOf str;
+        default = optional config.services.matrix-synapse.enable "matrix-synapse.service";
+        defaultText = literalExpression ''
+          optional config.services.matrix-synapse.enable "matrix-synapse.service"
+        '';
+        description = lib.mdDoc ''
+          List of Systemd services to require and wait for when starting the application service.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.mautrix-whatsapp = {
+      description = "Mautrix-Whatsapp, a Matrix-Whatsapp puppeting bridge.";
+
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      after = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      path = [ pkgs.lottieconverter pkgs.ffmpeg-full ];
+
+      # mautrix-whatsapp tries to generate a dotfile in the home directory of
+      # the running user if using a postgresql database:
+      #
+      #  File "python3.10/site-packages/asyncpg/connect_utils.py", line 257, in _dot_postgre>
+      #    return (pathlib.Path.home() / '.postgresql' / filename).resolve()
+      #  File "python3.10/pathlib.py", line 1000, in home
+      #    return cls("~").expanduser()
+      #  File "python3.10/pathlib.py", line 1440, in expanduser
+      #    raise RuntimeError("Could not determine home directory.")
+      # RuntimeError: Could not determine home directory.
+      environment.HOME = dataDir;
+
+      preStart = ''
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${pkgs.mautrix-whatsapp}/bin/mautrix-whatsapp \
+            --generate-registration \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+
+        ${pkgs.envsubst}/bin/envsubst \
+          -i ${settingsFile} \
+          -o /run/mautrix-whatsapp/config.json
+
+        # wait until dendrite grabs the config
+        sleep 5
+      '' + lib.optionalString (pkgs.mautrix-whatsapp ? alembic) ''
+        # run automatic database init and migration scripts
+        ${pkgs.mautrix-whatsapp.alembic}/bin/alembic -x config='${settingsFile}' upgrade head
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+
+        DynamicUser = true;
+        Group = "mautrix-whatsapp";
+        PrivateTmp = true;
+        WorkingDirectory = pkgs.mautrix-whatsapp; # necessary for the database migration scripts to be found
+        StateDirectory = baseNameOf dataDir;
+        RuntimeDirectory = "mautrix-whatsapp";
+        RuntimeDirectoryMode = "0700";
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${pkgs.mautrix-whatsapp}/bin/mautrix-whatsapp \
+            --config='/run/mautrix-whatsapp/config.json'
+        '';
+      };
+    };
+  };
+
+  # meta.maintainers = with maintainers; [ boppyt ];
+}
diff --git a/overlays/mjolnir-module/default.nix b/overlays/mjolnir-module/default.nix
new file mode 100644
index 0000000..87ed761
--- /dev/null
+++ b/overlays/mjolnir-module/default.nix
@@ -0,0 +1,242 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.mjolnir;
+
+  yamlConfig = {
+    inherit (cfg) dataPath managementRoom protectedRooms;
+
+    accessToken = "@ACCESS_TOKEN@"; # will be replaced in "generateConfig"
+    homeserverUrl =
+      if cfg.pantalaimon.enable then
+        "http://${cfg.pantalaimon.options.listenAddress}:${toString cfg.pantalaimon.options.listenPort}"
+      else
+        cfg.homeserverUrl;
+
+    rawHomeserverUrl = cfg.homeserverUrl;
+
+    pantalaimon = {
+      inherit (cfg.pantalaimon) username;
+
+      use = cfg.pantalaimon.enable;
+      password = "@PANTALAIMON_PASSWORD@"; # will be replaced in "generateConfig"
+    };
+  };
+
+  moduleConfigFile = pkgs.writeText "module-config.yaml" (
+    generators.toYAML { } (filterAttrs (_: v: v != null)
+      (fold recursiveUpdate { } [ yamlConfig cfg.settings ])));
+
+  # these config files will be merged one after the other to build the final config
+  configFiles = [
+    "${pkgs.mjolnir}/libexec/mjolnir/deps/mjolnir/config/default.yaml"
+    moduleConfigFile
+  ];
+
+  # this will generate the default.yaml file with all configFiles as inputs and
+  # replace all secret strings using replace-secret
+  generateConfig = pkgs.writeShellScript "mjolnir-generate-config" (
+    let
+      yqEvalStr = concatImapStringsSep " * " (pos: _: "select(fileIndex == ${toString (pos - 1)})") configFiles;
+      yqEvalArgs = concatStringsSep " " configFiles;
+    in
+    ''
+      set -euo pipefail
+
+      umask 077
+
+      # mjolnir will try to load a config from "./config/default.yaml" in the working directory
+      # -> let's place the generated config there
+      mkdir -p ${cfg.dataPath}/config
+
+      # merge all config files into one, overriding settings of the previous one with the next config
+      # e.g. "eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' filea.yaml fileb.yaml" will merge filea.yaml with fileb.yaml
+      ${pkgs.yq-go}/bin/yq eval-all -P '${yqEvalStr}' ${yqEvalArgs} > ${cfg.dataPath}/config/default.yaml
+
+      ${optionalString (cfg.accessTokenFile != null) ''
+        ${pkgs.replace-secret}/bin/replace-secret '@ACCESS_TOKEN@' '${cfg.accessTokenFile}' ${cfg.dataPath}/config/default.yaml
+      ''}
+      ${optionalString (cfg.pantalaimon.passwordFile != null) ''
+        ${pkgs.replace-secret}/bin/replace-secret '@PANTALAIMON_PASSWORD@' '${cfg.pantalaimon.passwordFile}' ${cfg.dataPath}/config/default.yaml
+      ''}
+    ''
+  );
+in
+{
+  options.services.mjolnir = {
+    enable = mkEnableOption (lib.mdDoc "Mjolnir, a moderation tool for Matrix");
+
+    homeserverUrl = mkOption {
+      type = types.str;
+      default = "https://matrix.org";
+      description = lib.mdDoc ''
+        Where the homeserver is located (client-server URL).
+
+        If `pantalaimon.enable` is `true`, this option will become the homeserver to which `pantalaimon` connects.
+        The listen address of `pantalaimon` will then become the `homeserverUrl` of `mjolnir`.
+      '';
+    };
+
+    accessTokenFile = mkOption {
+      type = with types; nullOr path;
+      default = null;
+      description = lib.mdDoc ''
+        File containing the matrix access token for the `mjolnir` user.
+      '';
+    };
+
+    pantalaimon = mkOption {
+      description = lib.mdDoc ''
+        `pantalaimon` options (enables E2E Encryption support).
+
+        This will create a `pantalaimon` instance with the name "mjolnir".
+      '';
+      default = { };
+      type = types.submodule {
+        options = {
+          enable = mkEnableOption (lib.mdDoc ''
+            If true, accessToken is ignored and the username/password below will be
+            used instead. The access token of the bot will be stored in the dataPath.
+          '');
+
+          username = mkOption {
+            type = types.str;
+            description = lib.mdDoc "The username to login with.";
+          };
+
+          passwordFile = mkOption {
+            type = with types; nullOr path;
+            default = null;
+            description = lib.mdDoc ''
+              File containing the matrix password for the `mjolnir` user.
+            '';
+          };
+
+          options = mkOption {
+            type = types.submodule (import ./pantalaimon-options.nix);
+            default = { };
+            description = lib.mdDoc ''
+              passthrough additional options to the `pantalaimon` service.
+            '';
+          };
+        };
+      };
+    };
+
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/mjolnir";
+      description = lib.mdDoc ''
+        The directory the bot should store various bits of information in.
+      '';
+    };
+
+    managementRoom = mkOption {
+      type = types.str;
+      default = "#moderators:example.org";
+      description = lib.mdDoc ''
+        The room ID where people can use the bot. The bot has no access controls, so
+        anyone in this room can use the bot - secure your room!
+        This should be a room alias or room ID - not a matrix.to URL.
+        Note: `mjolnir` is fairly verbose - expect a lot of messages from it.
+      '';
+    };
+
+    protectedRooms = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = literalExpression ''
+        [
+          "https://matrix.to/#/#yourroom:example.org"
+          "https://matrix.to/#/#anotherroom:example.org"
+        ]
+      '';
+      description = lib.mdDoc ''
+        A list of rooms to protect (matrix.to URLs).
+      '';
+    };
+
+    settings = mkOption {
+      default = { };
+      type = (pkgs.formats.yaml { }).type;
+      example = literalExpression ''
+        {
+          autojoinOnlyIfManager = true;
+          automaticallyRedactForReasons = [ "spam" "advertising" ];
+        }
+      '';
+      description = lib.mdDoc ''
+        Additional settings (see [mjolnir default config](https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml) for available settings). These settings will override settings made by the module config.
+      '';
+    };
+  };
+
+  config = mkIf config.services.mjolnir.enable {
+    assertions = [
+      {
+        assertion = !(cfg.pantalaimon.enable && cfg.pantalaimon.passwordFile == null);
+        message = "Specify pantalaimon.passwordFile";
+      }
+      {
+        assertion = !(cfg.pantalaimon.enable && cfg.accessTokenFile != null);
+        message = "Do not specify accessTokenFile when using pantalaimon";
+      }
+      {
+        assertion = !(!cfg.pantalaimon.enable && cfg.accessTokenFile == null);
+        message = "Specify accessTokenFile when not using pantalaimon";
+      }
+    ];
+
+    services.pantalaimon-headless.instances."mjolnir" = mkIf cfg.pantalaimon.enable
+      {
+        homeserver = cfg.homeserverUrl;
+      } // cfg.pantalaimon.options;
+
+    systemd.services.mjolnir = {
+      description = "mjolnir - a moderation tool for Matrix";
+      wants = [ "network-online.target" ] ++ optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
+      after = [ "network-online.target" ] ++ optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = ''${pkgs.mjolnir}/bin/mjolnir'';
+        ExecStartPre = [ generateConfig ];
+        WorkingDirectory = cfg.dataPath;
+        StateDirectory = "mjolnir";
+        StateDirectoryMode = "0700";
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        User = "mjolnir";
+        Restart = "on-failure";
+
+        /* TODO: wait for #102397 to be resolved. Then load secrets from $CREDENTIALS_DIRECTORY+"/NAME"
+        DynamicUser = true;
+        LoadCredential = [] ++
+          optionals (cfg.accessTokenFile != null) [
+            "access_token:${cfg.accessTokenFile}"
+          ] ++
+          optionals (cfg.pantalaimon.passwordFile != null) [
+            "pantalaimon_password:${cfg.pantalaimon.passwordFile}"
+          ];
+        */
+      };
+    };
+
+    users = {
+      users.mjolnir = {
+        group = "mjolnir";
+        isSystemUser = true;
+      };
+      groups.mjolnir = { };
+    };
+  };
+
+  meta = {
+    doc = ./mjolnir.md;
+    maintainers = with maintainers; [ jojosch ];
+  };
+}
diff --git a/overlays/mjolnir-module/mjolnir.md b/overlays/mjolnir-module/mjolnir.md
new file mode 100644
index 0000000..f6994ee
--- /dev/null
+++ b/overlays/mjolnir-module/mjolnir.md
@@ -0,0 +1,110 @@
+# Mjolnir (Matrix Moderation Tool) {#module-services-mjolnir}
+
+This chapter will show you how to set up your own, self-hosted
+[Mjolnir](https://github.com/matrix-org/mjolnir) instance.
+
+As an all-in-one moderation tool, it can protect your server from
+malicious invites, spam messages, and whatever else you don't want.
+In addition to server-level protection, Mjolnir is great for communities
+wanting to protect their rooms without having to use their personal
+accounts for moderation.
+
+The bot by default includes support for bans, redactions, anti-spam,
+server ACLs, room directory changes, room alias transfers, account
+deactivation, room shutdown, and more.
+
+See the [README](https://github.com/matrix-org/mjolnir#readme)
+page and the [Moderator's guide](https://github.com/matrix-org/mjolnir/blob/main/docs/moderators.md)
+for additional instructions on how to setup and use Mjolnir.
+
+For [additional settings](#opt-services.mjolnir.settings)
+see [the default configuration](https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml).
+
+## Mjolnir Setup {#module-services-mjolnir-setup}
+
+First create a new Room which will be used as a management room for Mjolnir. In
+this room, Mjolnir will log possible errors and debugging information. You'll
+need to set this Room-ID in [services.mjolnir.managementRoom](#opt-services.mjolnir.managementRoom).
+
+Next, create a new user for Mjolnir on your homeserver, if not present already.
+
+The Mjolnir Matrix user expects to be free of any rate limiting.
+See [Synapse #6286](https://github.com/matrix-org/synapse/issues/6286)
+for an example on how to achieve this.
+
+If you want Mjolnir to be able to deactivate users, move room aliases, shutdown rooms, etc.
+you'll need to make the Mjolnir user a Matrix server admin.
+
+Now invite the Mjolnir user to the management room.
+
+It is recommended to use [Pantalaimon](https://github.com/matrix-org/pantalaimon),
+so your management room can be encrypted. This also applies if you are looking to moderate an encrypted room.
+
+To enable the Pantalaimon E2E Proxy for mjolnir, enable
+[services.mjolnir.pantalaimon](#opt-services.mjolnir.pantalaimon.enable). This will
+autoconfigure a new Pantalaimon instance, which will connect to the homeserver
+set in [services.mjolnir.homeserverUrl](#opt-services.mjolnir.homeserverUrl) and Mjolnir itself
+will be configured to connect to the new Pantalaimon instance.
+
+```
+{
+  services.mjolnir = {
+    enable = true;
+    homeserverUrl = "https://matrix.domain.tld";
+    pantalaimon = {
+       enable = true;
+       username = "mjolnir";
+       passwordFile = "/run/secrets/mjolnir-password";
+    };
+    protectedRooms = [
+      "https://matrix.to/#/!xxx:domain.tld"
+    ];
+    managementRoom = "!yyy:domain.tld";
+  };
+}
+```
+
+### Element Matrix Services (EMS) {#module-services-mjolnir-setup-ems}
+
+If you are using a managed ["Element Matrix Services (EMS)"](https://ems.element.io/)
+server, you will need to consent to the terms and conditions. Upon startup, an error
+log entry with a URL to the consent page will be generated.
+
+## Synapse Antispam Module {#module-services-mjolnir-matrix-synapse-antispam}
+
+A Synapse module is also available to apply the same rulesets the bot
+uses across an entire homeserver.
+
+To use the Antispam Module, add `matrix-synapse-plugins.matrix-synapse-mjolnir-antispam`
+to the Synapse plugin list and enable the `mjolnir.Module` module.
+
+```
+{
+  services.matrix-synapse = {
+    plugins = with pkgs; [
+      matrix-synapse-plugins.matrix-synapse-mjolnir-antispam
+    ];
+    extraConfig = ''
+      modules:
+        - module: mjolnir.Module
+          config:
+            # Prevent servers/users in the ban lists from inviting users on this
+            # server to rooms. Default true.
+            block_invites: true
+            # Flag messages sent by servers/users in the ban lists as spam. Currently
+            # this means that spammy messages will appear as empty to users. Default
+            # false.
+            block_messages: false
+            # Remove users from the user directory search by filtering matrix IDs and
+            # display names by the entries in the user ban list. Default false.
+            block_usernames: false
+            # The room IDs of the ban lists to honour. Unlike other parts of Mjolnir,
+            # this list cannot be room aliases or permalinks. This server is expected
+            # to already be joined to the room - Mjolnir will not automatically join
+            # these rooms.
+            ban_lists:
+              - "!roomid:example.org"
+    '';
+  };
+}
+```
diff --git a/overlays/mjolnir-module/pantalaimon-options.nix b/overlays/mjolnir-module/pantalaimon-options.nix
new file mode 100644
index 0000000..3945a70
--- /dev/null
+++ b/overlays/mjolnir-module/pantalaimon-options.nix
@@ -0,0 +1,70 @@
+{ config, lib, name, ... }:
+
+with lib;
+{
+  options = {
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/pantalaimon-${name}";
+      description = lib.mdDoc ''
+        The directory where `pantalaimon` should store its state such as the database file.
+      '';
+    };
+
+    logLevel = mkOption {
+      type = types.enum [ "info" "warning" "error" "debug" ];
+      default = "warning";
+      description = lib.mdDoc ''
+        Set the log level of the daemon.
+      '';
+    };
+
+    homeserver = mkOption {
+      type = types.str;
+      example = "https://matrix.org";
+      description = lib.mdDoc ''
+        The URI of the homeserver that the `pantalaimon` proxy should
+        forward requests to, without the matrix API path but including
+        the http(s) schema.
+      '';
+    };
+
+    ssl = mkOption {
+      type = types.bool;
+      default = true;
+      description = lib.mdDoc ''
+        Whether or not SSL verification should be enabled for outgoing
+        connections to the homeserver.
+      '';
+    };
+
+    listenAddress = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = lib.mdDoc ''
+        The address where the daemon will listen to client connections
+        for this homeserver.
+      '';
+    };
+
+    listenPort = mkOption {
+      type = types.port;
+      default = 8009;
+      description = lib.mdDoc ''
+        The port where the daemon will listen to client connections for
+        this homeserver. Note that the listen address/port combination
+        needs to be unique between different homeservers.
+      '';
+    };
+
+    extraSettings = mkOption {
+      type = types.attrs;
+      default = { };
+      description = lib.mdDoc ''
+        Extra configuration options. See
+        [pantalaimon(5)](https://github.com/matrix-org/pantalaimon/blob/master/docs/man/pantalaimon.5.md)
+        for available options.
+      '';
+    };
+  };
+}
diff --git a/overlays/mjolnir-package/default.nix b/overlays/mjolnir-package/default.nix
new file mode 100644
index 0000000..833124b
--- /dev/null
+++ b/overlays/mjolnir-package/default.nix
@@ -0,0 +1,80 @@
+{ lib
+, nixosTests
+, mkYarnPackage
+, fetchYarnDeps
+, fetchFromGitHub
+, makeWrapper
+, nodejs
+, pkgs
+, matrix-sdk-crypto-nodejs
+}:
+
+let
+  pin = lib.importJSON ./pin.json;
+in
+mkYarnPackage rec {
+  pname = "mjolnir";
+  inherit (pin) version;
+
+  src = fetchFromGitHub {
+    owner = "matrix-org";
+    repo = "mjolnir";
+    rev = "v${version}";
+    sha256 = pin.srcSha256;
+  };
+
+  packageJSON = ./package.json;
+  offlineCache = fetchYarnDeps {
+    yarnLock = "${src}/yarn.lock";
+    sha256 = pin.yarnSha256;
+  };
+
+  packageResolutions = {
+    "@matrix-org/matrix-sdk-crypto-nodejs" = "${matrix-sdk-crypto-nodejs}/lib/node_modules/@matrix-org/matrix-sdk-crypto-nodejs";
+  };
+
+  nativeBuildInputs = [
+    makeWrapper
+  ];
+
+  buildPhase = ''
+    runHook preBuild
+    yarn --offline build
+    runHook postBuild
+  '';
+
+  postInstall = ''
+    makeWrapper ${nodejs}/bin/node $out/bin/mjolnir \
+      --add-flags $out/libexec/mjolnir/deps/mjolnir/lib/index.js
+  '';
+
+  doDist = false;
+
+  passthru = {
+    tests = {
+      inherit (nixosTests) mjolnir;
+    };
+    updateScript = ./update.sh;
+  };
+
+  meta = with lib; {
+    description = "A moderation tool for Matrix";
+    homepage = "https://github.com/matrix-org/mjolnir";
+    longDescription = ''
+      As an all-in-one moderation tool, it can protect your server from
+      malicious invites, spam messages, and whatever else you don't want.
+      In addition to server-level protection, Mjolnir is great for communities
+      wanting to protect their rooms without having to use their personal
+      accounts for moderation.
+
+      The bot by default includes support for bans, redactions, anti-spam,
+      server ACLs, room directory changes, room alias transfers, account
+      deactivation, room shutdown, and more.
+
+      A Synapse module is also available to apply the same rulesets the bot
+      uses across an entire homeserver.
+    '';
+    license = licenses.asl20;
+    maintainers = with maintainers; [ jojosch ];
+  };
+}
diff --git a/overlays/mjolnir-package/package.json b/overlays/mjolnir-package/package.json
new file mode 100644
index 0000000..f7ed5ab
--- /dev/null
+++ b/overlays/mjolnir-package/package.json
@@ -0,0 +1,69 @@
+{
+  "name": "mjolnir",
+  "version": "1.6.3",
+  "description": "A moderation tool for Matrix",
+  "main": "lib/index.js",
+  "repository": "git@github.com:matrix-org/mjolnir.git",
+  "author": "The Matrix.org Foundation C.I.C.",
+  "license": "Apache-2.0",
+  "private": true,
+  "scripts": {
+    "build": "tsc",
+    "postbuild": "rm -rf lib/test/ && cp -r lib/src/* lib/ && rm -rf lib/src/",
+    "lint": "tslint --project ./tsconfig.json -t stylish",
+    "start:dev": "yarn build && node --async-stack-traces lib/index.js",
+    "test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
+    "test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
+    "test:integration:single": "NODE_ENV=harness npx ts-mocha --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json",
+    "test:appservice:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --timeout 300000 --project ./tsconfig.json \"test/appservice/integration/**/*Test.ts\"",
+    "test:appservice:integration:single": "NODE_ENV=harness npx ts-mocha --timeout 300000 --project ./tsconfig.json",
+    "test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts",
+    "version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
+  },
+  "devDependencies": {
+    "@types/config": "^3.3.0",
+    "@types/crypto-js": "^4.0.2",
+    "@types/express": "^4.17.13",
+    "@types/html-to-text": "^8.0.1",
+    "@types/humanize-duration": "^3.27.1",
+    "@types/js-yaml": "^4.0.5",
+    "@types/jsdom": "^16.2.11",
+    "@types/mocha": "^9.0.0",
+    "@types/nedb": "^1.8.12",
+    "@types/node": "^16.7.10",
+    "@types/pg": "^8.6.5",
+    "@types/request": "^2.48.8",
+    "@types/shell-quote": "1.7.1",
+    "crypto-js": "^4.1.1",
+    "eslint": "^7.32",
+    "expect": "^27.0.6",
+    "mocha": "^9.0.1",
+    "ts-mocha": "^9.0.2",
+    "tslint": "^6.1.3",
+    "typescript": "^4.8.4",
+    "typescript-formatter": "^7.2"
+  },
+  "dependencies": {
+    "@sentry/node": "^7.17.2",
+    "@sentry/tracing": "^7.17.2",
+    "await-lock": "^2.2.2",
+    "body-parser": "^1.20.1",
+    "config": "^3.3.8",
+    "express": "^4.17",
+    "html-to-text": "^8.0.0",
+    "humanize-duration": "^3.27.1",
+    "humanize-duration-ts": "^2.1.1",
+    "js-yaml": "^4.1.0",
+    "jsdom": "^16.6.0",
+    "matrix-appservice-bridge": "8.0.0",
+    "parse-duration": "^1.0.2",
+    "pg": "^8.8.0",
+    "prom-client": "^14.1.0",
+    "shell-quote": "^1.7.3",
+    "ulidx": "^0.3.0",
+    "yaml": "^2.1.1"
+  },
+  "engines": {
+    "node": ">=16.0.0"
+  }
+}
diff --git a/overlays/mjolnir-package/pin.json b/overlays/mjolnir-package/pin.json
new file mode 100644
index 0000000..73953b8
--- /dev/null
+++ b/overlays/mjolnir-package/pin.json
@@ -0,0 +1,5 @@
+{
+  "version": "1.6.4",
+  "srcSha256": "sha256-/vnojWLpu/fktqPUhAdL1QTESxDwFrBVYAkyF79Fj9w=",
+  "yarnSha256": "sha256-B4s0CYr5Ihoh4gkckwZ3z0Nb4LMET48WvRXuhk3fpQM="
+}
diff --git a/overlays/mjolnir-package/update.sh b/overlays/mjolnir-package/update.sh
new file mode 100755
index 0000000..1ada429
--- /dev/null
+++ b/overlays/mjolnir-package/update.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p nix curl jq prefetch-yarn-deps nix-prefetch-github
+
+if [ "$#" -gt 1 ] || [[ "$1" == -* ]]; then
+  echo "Regenerates packaging data for mjolnir."
+  echo "Usage: $0 [git release tag]"
+  exit 1
+fi
+
+version=$1
+
+set -euo pipefail
+
+if [ -z "$version" ]; then
+  version=$(curl "https://api.github.com/repos/matrix-org/mjolnir/releases/latest" | jq -r '.tag_name')
+fi
+
+src="https://raw.githubusercontent.com/matrix-org/mjolnir/$version"
+src_hash=$(nix-prefetch-github matrix-org mjolnir --rev ${version} | jq -r .sha256)
+
+tmpdir=$(mktemp -d)
+trap 'rm -rf "$tmpdir"' EXIT
+
+pushd $tmpdir
+curl -O "$src/yarn.lock"
+yarn_hash=$(prefetch-yarn-deps yarn.lock)
+popd
+
+curl -O "$src/package.json"
+cat > pin.json << EOF
+{
+  "version": "$version",
+  "srcHash": "$src_hash",
+  "yarnHash": "$yarn_hash"
+}
+EOF
diff --git a/overlays/sliding-sync-module.nix b/overlays/sliding-sync-module.nix
new file mode 100644
index 0000000..692818b
--- /dev/null
+++ b/overlays/sliding-sync-module.nix
@@ -0,0 +1,87 @@
+{ config, lib, pkgs, ... }:
+let
+  cfg = config.services.sliding-sync;
+in
+{
+  # TODO: add default values
+  options.services.sliding-sync = {
+    enable = lib.mkEnableOption (lib.mdDoc "matrix.org sliding-sync");
+    server = lib.mkOption {
+      type = lib.types.str;
+      # default = "https://matrix-client.matrix.org" # TODO: required?
+      description = lib.mdDoc ''
+        The destination homeserver to talk to (CS API HTTPS URL)
+      '';
+    };
+    db = lib.mkOption {
+      type = lib.types.str;
+      description = lib.mdDoc ''
+        The postgres connection string: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
+      '';
+    };
+    bindAddr = lib.mkOption {
+      type = lib.types.str;
+      default = "0.0.0.0:8008";
+      description = lib.mdDoc ''
+        The interface and port to listen on.
+      '';
+    };
+    secret = lib.mkOption {
+      type = lib.types.str;
+      description = lib.mdDoc ''
+        A secret to use to encrypt access tokens.
+        Must remain the same for the lifetime of the database.
+      '';
+    };
+    pprof = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The bind addr for pprof debugging e.g ':6060'.
+        If not set, does not listen.
+      '';
+    };
+    prom = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The bind addr for Prometheus metrics,
+        which will be accessible at /metrics at this address.
+      '';
+    };
+    jaegerUrl = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The Jaeger URL to send spans to e.g http://localhost:14268/api/traces
+        If unset does not send OTLP traces.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.sliding-sync = {
+      description = "MSC3575 Matrix Sliding Sync Proxy";
+      after = [
+        "network.target"
+      ];
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = {
+        Type = "simple";
+        DynamicUser = true;
+        ExecStart =
+          "${pkgs.sliding-sync}/bin/syncv3";
+        Restart = "on-failure";
+      };
+      environment = {
+        SYNCV3_SERVER = cfg.server;
+        SYNCV3_DB = cfg.db;
+        SYNCV3_SECRET = cfg.secret;
+        SYNCV3_BINDADDR = cfg.bindAddr;
+        SYNCV3_PPROF = cfg.pprof;
+        SYNCV3_PROM = cfg.prom;
+        SYNCV3_JAEGER_URL = cfg.jaegerUrl;
+      };
+    };
+  };
+}
diff --git a/overlays/sliding-sync.nix b/overlays/sliding-sync.nix
new file mode 100644
index 0000000..50c058d
--- /dev/null
+++ b/overlays/sliding-sync.nix
@@ -0,0 +1,22 @@
+{ lib, buildGoModule, fetchFromGitHub }:
+
+# TODO: needs:
+# - tests
+# - `meta` attribute
+
+buildGoModule rec {
+  pname = "sliding-sync";
+  version = "0.99.1";
+  src = fetchFromGitHub {
+    owner = "matrix-org";
+    repo = "sliding-sync";
+    rev = "v${version}";
+    sha256 = "sha256-g1yMGb8taToEFG6N057yPcdZB855r0f6EwnJ98FIiic=";
+  };
+
+  vendorHash = "sha256-FmibAVjKeJUrMSlhoE7onLoa4EVjQvjDI4oU4PB5LBE=";
+
+  subPackages = [
+    "cmd/syncv3"
+  ];
+}
diff --git a/overlays/soju-module.nix b/overlays/soju-module.nix
new file mode 100644
index 0000000..d14082c
--- /dev/null
+++ b/overlays/soju-module.nix
@@ -0,0 +1,132 @@
+# Not an overlay, module replacement
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.soju;
+  stateDir = "/var/lib/soju";
+  listenCfg = concatMapStringsSep "\n" (l: "listen ${l}") cfg.listen;
+  tlsCfg = optionalString (cfg.tlsCertificate != null)
+    "tls ${cfg.tlsCertificate} ${cfg.tlsCertificateKey}";
+  logCfg = optionalString cfg.enableMessageLogging
+    "log fs ${stateDir}/logs";
+
+  configFile = pkgs.writeText "soju.conf" ''
+    ${listenCfg}
+    hostname ${cfg.hostName}
+    ${tlsCfg}
+    db sqlite3 ${stateDir}/soju.db
+    ${logCfg}
+    http-origin ${concatStringsSep " " cfg.httpOrigins}
+    accept-proxy-ip ${concatStringsSep " " cfg.acceptProxyIP}
+
+    ${cfg.extraConfig}
+  '';
+in
+{
+  ###### interface
+
+  options.services.soju = {
+    enable = mkEnableOption (lib.mdDoc "soju");
+
+    listen = mkOption {
+      type = types.listOf types.str;
+      default = [ ":6697" ];
+      description = lib.mdDoc ''
+        Where soju should listen for incoming connections. See the
+        `listen` directive in
+        {manpage}`soju(1)`.
+      '';
+    };
+
+    hostName = mkOption {
+      type = types.str;
+      default = config.networking.hostName;
+      defaultText = literalExpression "config.networking.hostName";
+      description = lib.mdDoc "Server hostname.";
+    };
+
+    tlsCertificate = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.cert";
+      description = lib.mdDoc "Path to server TLS certificate.";
+    };
+
+    tlsCertificateKey = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.key";
+      description = lib.mdDoc "Path to server TLS certificate key.";
+    };
+
+    enableMessageLogging = mkOption {
+      type = types.bool;
+      default = true;
+      description = lib.mdDoc "Whether to enable message logging.";
+    };
+
+    httpOrigins = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        List of allowed HTTP origins for WebSocket listeners. The parameters are
+        interpreted as shell patterns, see
+        {manpage}`glob(7)`.
+      '';
+    };
+
+    acceptProxyIP = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        Allow the specified IPs to act as a proxy. Proxys have the ability to
+        overwrite the remote and local connection addresses (via the X-Forwarded-\*
+        HTTP header fields). The special name "localhost" accepts the loopback
+        addresses 127.0.0.0/8 and ::1/128. By default, all IPs are rejected.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = types.lines;
+      default = "";
+      description = lib.mdDoc "Lines added verbatim to the configuration file.";
+    };
+
+    extraGroups = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc "Extra groups for the dynamic user.";
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = (cfg.tlsCertificate != null) == (cfg.tlsCertificateKey != null);
+        message = ''
+          services.soju.tlsCertificate and services.soju.tlsCertificateKey
+          must both be specified to enable TLS.
+        '';
+      }
+    ];
+
+    systemd.services.soju = {
+      description = "soju IRC bouncer";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network-online.target" ];
+      serviceConfig = {
+        DynamicUser = true;
+        SupplementaryGroups = cfg.extraGroups;
+        Restart = "always";
+        ExecStart = "${pkgs.soju}/bin/soju -config ${configFile}";
+        StateDirectory = "soju";
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ malvo ];
+}