diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index 877f4c8a72ad..4b0c67e6603e 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -734,6 +734,15 @@ "module-services-davis-basic-usage": [ "index.html#module-services-davis-basic-usage" ], + "module-services-draupnir": [ + "index.html#module-services-draupnir" + ], + "module-services-draupnir-setup": [ + "index.html#module-services-draupnir-setup" + ], + "module-services-draupnir-setup-ems": [ + "index.html#module-services-draupnir-setup-ems" + ], "module-services-castopod": [ "index.html#module-services-castopod" ], diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 4f6364341609..41080f1d8965 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -755,6 +755,7 @@ ./services/matrix/conduit.nix ./services/matrix/continuwuity.nix ./services/matrix/dendrite.nix + ./services/matrix/draupnir.nix ./services/matrix/hebbot.nix ./services/matrix/hookshot.nix ./services/matrix/lk-jwt-service.nix diff --git a/nixos/modules/services/matrix/draupnir.md b/nixos/modules/services/matrix/draupnir.md new file mode 100644 index 000000000000..1d6bce2c4e63 --- /dev/null +++ b/nixos/modules/services/matrix/draupnir.md @@ -0,0 +1,62 @@ +# Draupnir (Matrix Moderation Bot) {#module-services-draupnir} + +This chapter will show you how to set up your own, self-hosted +[Draupnir](https://github.com/the-draupnir-project/Draupnir) 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, Draupnir 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. (This depends on homeserver configuration and implementation.) + +See the [README](https://github.com/the-draupnir-project/draupnir#readme) +page and the [Moderator's guide](https://the-draupnir-project.github.io/draupnir-documentation/moderator/setting-up-and-configuring) +for additional instructions on how to setup and use Draupnir. + +For [additional settings](#opt-services.draupnir.settings) +see [the default configuration](https://github.com/the-draupnir-project/Draupnir/blob/main/config/default.yaml). + +## Draupnir Setup {#module-services-draupnir-setup} + +First create a new unencrypted, private room which will be used as the management room for Draupnir. +This is the room in which moderators will interact with Draupnir and where it will log possible errors and debugging information. +You'll need to set this room ID or alias in [services.draupnir.settings.managementRoom](#opt-services.draupnir.settings.managementRoom). + +Next, create a new user for Draupnir on your homeserver, if one does not already exist. + +The Draupnir 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 Draupnir to be able to deactivate users, move room aliases, shut down rooms, etc. +you'll need to make the Draupnir user a Matrix server admin. + +Now invite the Draupnir user to the management room. +Draupnir will automatically try to join this room on startup. + +```nix +{ + services.draupnir = { + enable = true; + + settings = { + homeserverUrl = "https://matrix.org"; + managementRoom = "!yyy:example.org"; + }; + + secrets = { + accessToken = "/path/to/secret/containing/access-token"; + }; + }; +} +``` + +### Element Matrix Services (EMS) {#module-services-draupnir-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. diff --git a/nixos/modules/services/matrix/draupnir.nix b/nixos/modules/services/matrix/draupnir.nix new file mode 100644 index 000000000000..e4dfa5d2917b --- /dev/null +++ b/nixos/modules/services/matrix/draupnir.nix @@ -0,0 +1,257 @@ +{ + config, + options, + lib, + pkgs, + ... +}: + +let + cfg = config.services.draupnir; + opt = options.services.draupnir; + + format = pkgs.formats.yaml { }; + configFile = format.generate "draupnir.yaml" cfg.settings; + + inherit (lib) + literalExpression + mkEnableOption + mkOption + mkPackageOption + mkRemovedOptionModule + mkRenamedOptionModule + types + ; +in +{ + imports = [ + # Removed options for those migrating from the Mjolnir module + (mkRenamedOptionModule + [ "services" "draupnir" "dataPath" ] + [ "services" "draupnir" "settings" "dataPath" ] + ) + (mkRenamedOptionModule + [ "services" "draupnir" "homeserverUrl" ] + [ "services" "draupnir" "settings" "homeserverUrl" ] + ) + (mkRenamedOptionModule + [ "services" "draupnir" "managementRoom" ] + [ "services" "draupnir" "settings" "managementRoom" ] + ) + (mkRenamedOptionModule + [ "services" "draupnir" "accessTokenFile" ] + [ "services" "draupnir" "secrets" "accessToken" ] + ) + (mkRemovedOptionModule [ "services" "draupnir" "pantalaimon" ] '' + `services.draupnir.pantalaimon.*` has been removed because it depends on the deprecated and vulnerable + libolm library for end-to-end encryption and upstream support for Pantalaimon in Draupnir is limited. + See for details. + If you nontheless require E2EE via Pantalaimon, you can configure `services.pantalaimon-headless.instances` + yourself and use that with `services.draupnir.settings.pantalaimon` and `services.draupnir.secrets.pantalaimon.password`. + '') + ]; + + options.services.draupnir = { + enable = mkEnableOption "Draupnir, a moderations bot for Matrix"; + + package = mkPackageOption pkgs "draupnir" { }; + + settings = mkOption { + example = literalExpression '' + { + homeserverUrl = "https://matrix.org"; + managementRoom = "#moderators:example.org"; + + autojoinOnlyIfManager = true; + automaticallyRedactForReasons = [ "spam" "advertising" ]; + } + ''; + description = '' + Free-form settings written to Draupnir's configuration file. + See [Draupnir's default configuration](https://github.com/the-draupnir-project/Draupnir/blob/main/config/default.yaml) for available settings. + ''; + default = { }; + type = types.submodule { + freeformType = format.type; + options = { + homeserverUrl = mkOption { + type = types.str; + example = "https://matrix.org"; + description = '' + Base URL of the Matrix homeserver that provides the Client-Server API. + + ::: {.note} + When using Pantalaimon, set this to the Pantalaimon URL and + {option}`${opt.settings}.rawHomeserverUrl` to the public URL. + ::: + ''; + }; + + rawHomeserverUrl = mkOption { + type = types.str; + example = "https://matrix.org"; + default = cfg.settings.homeserverUrl; + defaultText = literalExpression "config.${opt.settings}.homeserverUrl"; + description = '' + Public base URL of the Matrix homeserver that provides the Client-Server API when using the Draupnir's + [Report forwarding feature](https://the-draupnir-project.github.io/draupnir-documentation/bot/homeserver-administration#report-forwarding). + + ::: {.warning} + When using Pantalaimon, do not set this to the Pantalaimon URL! + ::: + ''; + }; + + managementRoom = mkOption { + type = types.str; + example = "#moderators:example.org"; + description = '' + The room ID or alias where moderators can use the bot's functionality. + + The bot has no access controls, so anyone in this room can use the bot - secure this room! + Do not enable end-to-end encryption for this room, unless set up with Pantalaimon. + + ::: {.warning} + When using a room alias, make sure the alias used is on the local homeserver! + This prevents an issue where the control room becomes undefined when the alias can't be resolved. + ::: + ''; + }; + + dataPath = mkOption { + type = types.path; + readOnly = true; + default = "/var/lib/draupnir"; + description = '' + The path Draupnir will store its state/data in. + + ::: {.warning} + This option is read-only. + ::: + + ::: {.note} + If you want to customize where this data is stored, use a bind mount. + ::: + ''; + }; + }; + }; + }; + + secrets = { + accessToken = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + File containing the access token for Draupnir's Matrix account + to be used in place of {option}`${opt.settings}.accessToken`. + ''; + }; + + pantalaimon.password = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + File containing the password for Draupnir's Matrix account when used in + conjunction with Pantalaimon to be used in place of + {option}`${opt.settings}.pantalaimon.password`. + + ::: {.warning} + Take note that upstream has limited Pantalaimon and E2EE support: + and + . + ::: + ''; + }; + + web.synapseHTTPAntispam.authorization = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + File containing the secret token when using the Synapse HTTP Antispam module + to be used in place of + {option}`${opt.settings}.web.synapseHTTPAntispam.authorization`. + + See for details. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + # Removed option for those migrating from the Mjolnir module - mkRemovedOption module does *not* work with submodules. + assertion = !(cfg.settings ? protectedRooms); + message = "Unset ${opt.settings}.protectedRooms, as it is unsupported on Draupnir. Add these rooms via `!draupnir rooms add` instead."; + } + ]; + + systemd.services.draupnir = { + description = "Draupnir - a moderation bot for Matrix"; + wants = [ + "network-online.target" + "matrix-synapse.service" + "conduit.service" + "dendrite.service" + ]; + after = [ + "network-online.target" + "matrix-synapse.service" + "conduit.service" + "dendrite.service" + ]; + wantedBy = [ "multi-user.target" ]; + + startLimitIntervalSec = 0; + serviceConfig = { + ExecStart = toString ( + [ + (lib.getExe cfg.package) + "--draupnir-config" + configFile + ] + ++ lib.optionals (cfg.secrets.accessToken != null) [ + "--access-token-path" + "%d/access_token" + ] + ++ lib.optionals (cfg.secrets.pantalaimon.password != null) [ + "--pantalaimon-password-path" + "%d/pantalaimon_password" + ] + ++ lib.optionals (cfg.secrets.web.synapseHTTPAntispam.authorization != null) [ + "--http-antispam-authorization-path" + "%d/http_antispam_authorization" + ] + ); + + WorkingDirectory = "/var/lib/draupnir"; + StateDirectory = "draupnir"; + StateDirectoryMode = "0700"; + ProtectHome = true; + PrivateDevices = true; + Restart = "on-failure"; + RestartSec = "5s"; + DynamicUser = true; + LoadCredential = + lib.optionals (cfg.secrets.accessToken != null) [ + "access_token:${cfg.secrets.accessToken}" + ] + ++ lib.optionals (cfg.secrets.pantalaimon.password != null) [ + "pantalaimon_password:${cfg.secrets.pantalaimon.password}" + ] + ++ lib.optionals (cfg.secrets.web.synapseHTTPAntispam.authorization != null) [ + "http_antispam_authorization:${cfg.secrets.web.synapseHTTPAntispam.authorization}" + ]; + }; + }; + }; + + meta = { + doc = ./draupnir.md; + maintainers = with lib.maintainers; [ + RorySys + emilylange + ]; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 3be71541b4ed..e27dce1734ce 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -409,6 +409,7 @@ in dovecot = handleTest ./dovecot.nix { }; drawterm = discoverTests (import ./drawterm.nix); drbd = handleTest ./drbd.nix { }; + draupnir = runTest ./matrix/draupnir.nix; druid = handleTestOn [ "x86_64-linux" ] ./druid { }; drbd-driver = handleTest ./drbd-driver.nix { }; dublin-traceroute = handleTest ./dublin-traceroute.nix { }; diff --git a/nixos/tests/matrix/draupnir.nix b/nixos/tests/matrix/draupnir.nix new file mode 100644 index 000000000000..18dae8b2fffb --- /dev/null +++ b/nixos/tests/matrix/draupnir.nix @@ -0,0 +1,150 @@ +{ + lib, + ... +}: + +{ + name = "draupnir"; + meta.maintainers = with lib.maintainers; [ + RorySys + emilylange + ]; + + nodes = { + homeserver = + { pkgs, ... }: + { + services.matrix-synapse = { + enable = true; + log.root.level = "WARNING"; + settings = { + database.name = "sqlite3"; + registration_shared_secret = "supersecret-registration"; + + listeners = [ + { + bind_addresses = [ + "::" + ]; + port = 8008; + resources = [ + { + compress = true; + names = [ "client" ]; + } + { + compress = false; + names = [ "federation" ]; + } + ]; + tls = false; + type = "http"; + x_forwarded = false; + } + ]; + }; + }; + + specialisation.draupnir = { + inheritParentConfig = true; + + configuration.services.draupnir = { + enable = true; + settings = { + homeserverUrl = "http://localhost:8008"; + managementRoom = "#moderators:homeserver"; + }; + secrets = { + accessToken = "/tmp/draupnir-access-token"; + }; + }; + }; + + environment.systemPackages = with pkgs; [ + curl + jq + (writers.writePython3Bin "test_draupnir_in_matrix" + { + libraries = [ python3Packages.matrix-nio ]; + flakeIgnore = [ "E501" ]; + } + '' + import asyncio + from nio import AsyncClient, MatrixRoom, RoomMemberEvent, RoomMessageNotice + + + async def main() -> None: + client = AsyncClient("http://localhost:8008", "moderator") + + async def member_callback(room: MatrixRoom, event: RoomMemberEvent) -> None: + if event.membership == "join" and event.sender == "@draupnir:homeserver": + await client.room_send( + room_id=room.room_id, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": "!draupnir status" + } + ) + + async def message_callback(room: MatrixRoom, event: RoomMessageNotice) -> None: + print(f"{event.sender}: {event.body}") + if event.sender == "@draupnir:homeserver": + await client.close() + exit(0) + + client.add_event_callback(member_callback, RoomMemberEvent) + client.add_event_callback(message_callback, RoomMessageNotice) + + print(await client.login("password")) + + room = await client.room_create( + name="Moderators", + alias="moderators", + invite=["@draupnir:homeserver"], + power_level_override={ + "users": { + "@draupnir:homeserver": 100, + "@moderator:homeserver": 100, + } + } + ) + print(room) + + print(await client.join(room.room_id)) + + await client.sync_forever(timeout=30000) + + + asyncio.run(main()) + '' + ) + ]; + }; + }; + + testScript = + { nodes, ... }: + '' + import json + + homeserver.wait_for_unit("matrix-synapse.service") + homeserver.wait_until_succeeds("curl --fail -L http://localhost:8008/") + + homeserver.succeed("matrix-synapse-register_new_matrix_user -u draupnir -p password --no-admin") + homeserver.succeed("matrix-synapse-register_new_matrix_user -u moderator -p password --no-admin") + + # get draupnir access token + payload = json.dumps({ "type": "m.login.password", "user": "draupnir", "password": "password" }) + homeserver.succeed( + f"curl --fail --json '{payload}' http://localhost:8008/_matrix/client/v3/login" + + " | jq -r .access_token" + + " | tee /tmp/draupnir-access-token" + ) + + homeserver.succeed("${nodes.homeserver.system.build.toplevel}/specialisation/draupnir/bin/switch-to-configuration test") + homeserver.wait_for_unit("draupnir.service") + + print(homeserver.succeed("test_draupnir_in_matrix >&2", timeout=60)) + ''; +} diff --git a/pkgs/by-name/dr/draupnir/package.nix b/pkgs/by-name/dr/draupnir/package.nix index 9fbc2e62e9b9..3670dd5d13bb 100644 --- a/pkgs/by-name/dr/draupnir/package.nix +++ b/pkgs/by-name/dr/draupnir/package.nix @@ -12,6 +12,7 @@ fetchYarnDeps, stdenv, cctools, + nixosTests, }: # docs: https://github.com/NixOS/nixpkgs/blob/master/doc/languages-frameworks/javascript.section.md#yarn2nix-javascript-yarn2nix @@ -95,7 +96,10 @@ mkYarnPackage rec { distPhase = "true"; - passthru.updateScript = ./update.sh; + passthru = { + tests = { inherit (nixosTests) draupnir; }; + updateScript = ./update.sh; + }; meta = with lib; { description = "A moderation tool for Matrix";