Merge branch 'main' into gnuxie/log-message

This commit is contained in:
gnuxie 2022-07-26 16:23:15 +01:00
commit 08ea528882
33 changed files with 1758 additions and 398 deletions

View File

@ -11,10 +11,38 @@ env:
jobs:
build:
name: Integration tests
name: Build & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Specifically use node 16 like in the readme.
uses: actions/setup-node@v3
with:
node-version: '16'
- run: yarn install
- run: yarn build
- run: yarn lint
unit:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Specifically use node 16 like in the readme.
uses: actions/setup-node@v3
with:
node-version: '16'
- run: yarn install
- run: yarn test
integration:
name: Integration tests
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
- name: install mx-tester
run: cargo install mx-tester
- name: Setup image

View File

@ -1,4 +1,4 @@
FROM node:14-alpine
FROM node:16-alpine
COPY . /tmp/src
RUN cd /tmp/src \
&& yarn install \

123
README.md
View File

@ -16,57 +16,13 @@ directory changes, room alias transfers, account deactivation, room shutdown, an
A Synapse module is also available to apply the same rulesets the bot uses across an entire
homeserver.
## Bot configuration
## Setting up
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.
See the [setup documentation](docs/setup.md) for first-time setup documentation.
If you aren't using encrypted rooms anywhere, get an access token by opening Riot in an
incognito/private window and log in as the bot. From the Help & Support tab in settings there
is an access token field - copy and paste that into your config. Most importantly: do not log
out and instead just close the window. Logging out will make the token you just copied useless.
See the [configuration sample with documentation](config/default.yaml) for detailed information about Mjolnir's configuration.
**Note**: Mjolnir expects to be free of rate limiting - see [Synapse #6286](https://github.com/matrix-org/synapse/issues/6286)
for information on how to achieve this.
**Note**: To deactivate users, move aliases, shutdown rooms, etc Mjolnir will need to be a server
admin.
## Docker installation (preferred)
Mjolnir is on Docker Hub as [matrixdotorg/mjolnir](https://hub.docker.com/r/matrixdotorg/mjolnir)
but can be built yourself with `docker build -t mjolnir .`.
```bash
git clone https://github.com/matrix-org/mjolnir.git
cd mjolnir
# Copy and edit the config. It is not recommended to change the data path.
mkdir -p /etc/mjolnir/config
cp config/default.yaml /etc/mjolnir/config/production.yaml
nano /etc/mjolnir/config/production.yaml
docker run --rm -it -v /etc/mjolnir:/data matrixdotorg/mjolnir:latest
```
## Build it (alternative installation)
This bot requires `yarn` and Node 14.
```bash
git clone https://github.com/matrix-org/mjolnir.git
cd mjolnir
yarn install
yarn build
# Copy and edit the config. It *is* recommended to change the data path.
cp config/default.yaml config/development.yaml
nano config/development.yaml
node lib/index.js
```
See the [synapse module documentation](docs/synapse_module.md) for information on how to setup Mjolnir's accompanying Synapse Module.
## Quickstart guide
@ -82,73 +38,6 @@ set up:
3. Review the [Moderator's Guide](https://github.com/matrix-org/mjolnir/blob/main/docs/moderators.md).
4. Review `!mjolnir help` to see what else the bot can do.
## Synapse Module
**This requires Synapse 1.53.0 or higher**
Using the bot to manage your rooms is great, however if you want to use your ban lists
(or someone else's) on your server to affect all of your users then a Synapse module
is needed. Primarily meant to block invites from undesired homeservers/users, Mjolnir's
Synapse module is a way to interpret ban lists and apply them to your entire homeserver.
First, install the module to your Synapse python environment:
```
pip install -e "git+https://github.com/matrix-org/mjolnir.git#egg=mjolnir&subdirectory=synapse_antispam"
```
*Note*: Where your python environment is depends on your installation method. Visit
[#synapse:matrix.org](https://matrix.to/#/#synapse:matrix.org) if you're not sure.
Then add the following to your `homeserver.yaml`:
```yaml
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"
#message_max_length:
# Limit the characters in a message (event body) that a client can send in an event on this server.
# By default there is no limit (beyond the the limit the spec enforces on event size).
# Uncomment if you want messages to be limited to 510 characters.
#threshold: 510
# Limit messages only in certain rooms rooms.
# By default all rooms will enforce the limit.
# Uncomment if you want messages to only be subject to character limits in certain rooms.
#rooms:
# - "!vMvyOCeCxHsggkmALd:localhost:9999"
# Also hide messages from remote servers that are over the `message_limit`.
# By default only events from this server will be limited.
# WARNING: Remote users on other servers will still be able to messages over the limit.
# Uncomment to enforce the `message_limit` on events from remote servers.
#remote_servers: true
```
*Note*: Although this is described as a "spam checker", it does much more than fight
spam.
Be sure to change the configuration to match your setup. Your server is expected to
already be participating in the ban lists - if it is not, you will need to have a user
on your homeserver join. The antispam module will not join the rooms for you.
If you change the configuration, you will need to restart Synapse. You'll also need
to restart Synapse to install the plugin.
## Enabling readable abuse reports
Since version 1.2, Mjölnir offers the ability to replace the Matrix endpoint used
@ -198,7 +87,7 @@ Once rust is installed you can install mx-tester like so.
$ cargo install mx-tester
```
Once you have mx-tester installed you we will want to build a synapse image with synapse_antispam from the mjolnir project root.
Once you have mx-tester installed you we will want to build a synapse image with synapse_antispam from the Mjolnir project root.
```
$ mx-tester build
@ -233,4 +122,4 @@ mx-tester down
The integration tests can be run with `yarn test:integration`.
The config that the tests use is in `config/harness.yaml`
and by default this is configured to work with the server specified in `mx-tester.yml`,
but you can configure it however you like to run against your own setup.
but you can configure it however you like to run against your own setup.

View File

@ -1,151 +1,184 @@
# Where the homeserver is located (client-server URL). This should point at
# pantalaimon if you're using that.
# Endpoint URL that Mjolnir uses to interact with the matrix homeserver (client-server API),
# set this to the pantalaimon URL if you're using that.
homeserverUrl: "https://matrix.org"
# Where the homeserver is located (client-server URL). NOT panalaimon.
# Endpoint URL that Mjolnir could use to fetch events related to reports (client-server API and /_synapse/),
# only set this to the public-internet homeserver client API URL, do NOT set this to the pantalaimon URL.
rawHomeserverUrl: "https://matrix.org"
# The access token for the bot to use. Do not populate if using Pantalaimon.
# Matrix Access Token to use, Mjolnir will only use this if pantalaimon.use is false.
accessToken: "YOUR_TOKEN_HERE"
# Pantalaimon options (https://github.com/matrix-org/pantalaimon)
# Options related to Pantalaimon (https://github.com/matrix-org/pantalaimon)
pantalaimon:
# If true, accessToken above is ignored and the username/password below will be
# used instead. The access token of the bot will be stored in the dataPath.
# Whether or not Mjolnir will use pantalaimon to access the matrix homeserver,
# set to `true` if you're using pantalaimon.
#
# Be sure to point homeserverUrl to the pantalaimon instance.
#
# Mjolnir will log in using the given username and password once,
# then store the resulting access token in a file under dataPath.
use: false
# The username to login with.
username: mjolnir
# The password to login with. Can be removed after the bot has logged in once and
# stored the access token.
# The password Mjolnir will login with.
#
# After successfully logging in once, this will be ignored, so this value can be blanked after first startup.
password: your_password
# The directory the bot should store various bits of information in
# The path Mjolnir will store its state/data in, leave default ("/data/storage") when using containers.
dataPath: "/data/storage"
# If true (the default), only users in the `managementRoom` can invite the bot
# to new rooms.
# If true (the default), Mjolnir will only accept invites from users present in managementRoom.
autojoinOnlyIfManager: true
# If `autojoinOnlyIfManager` is false, only the members in this group can invite
# the bot to new rooms.
acceptInvitesFromGroup: '+example:example.org'
acceptInvitesFromGroup: "+example:example.org"
# If the bot is invited to a room and it won't accept the invite (due to the
# conditions above), report it to the management room. Defaults to disabled (no
# reporting).
# Whether Mjolnir should report ignored invites to the management room (if autojoinOnlyIfManager is true).
recordIgnoredInvites: false
# 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!
# The room ID (or room alias) of the management room, anyone in this room can issue commands to Mjolnir.
#
# Mjolnir has no more granular access controls other than this, be sure you trust everyone in this room - secure it!
#
# 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.
#
# Note: By default, Mjolnir is fairly verbose - expect a lot of messages in this room.
# (see verboseLogging to adjust this a bit.)
managementRoom: "#moderators:example.org"
# Set to false to make the management room a bit quieter.
# Whether Mjolnir should log a lot more messages in the room,
# mainly involves "all-OK" messages, and debugging messages for when mjolnir checks bans in a room.
verboseLogging: true
# The log level for the logs themselves. One of DEBUG, INFO, WARN, and ERROR.
# The log level of terminal (or container) output,
# can be one of DEBUG, INFO, WARN and ERROR, in increasing order of importance and severity.
#
# This should be at INFO or DEBUG in order to get support for Mjolnir problems.
logLevel: "INFO"
# Set to false to disable synchronizing the ban lists on startup. If true, this
# is the same as running !mjolnir sync immediately after startup.
# Whether or not Mjolnir should synchronize policy lists immediately after startup.
# Equivalent to running '!mjolnir sync'.
syncOnStartup: true
# Set to false to prevent Mjolnir from checking its permissions on startup. This
# is recommended to be left as "true" to catch room permission problems (state
# resets, etc) before Mjolnir is needed.
# Whether or not Mjolnir should check moderation permissions in all protected rooms on startup.
# Equivalent to running `!mjolnir verify`.
verifyPermissionsOnStartup: true
# If true, Mjolnir won't actually ban users or apply server ACLs, but will
# think it has. This is useful to see what it does in a scenario where the
# bot might not be trusted fully, yet. Default false (do bans/ACLs).
# Whether or not Mjolnir should actually apply bans and policy lists,
# turn on to trial some untrusted configuration or lists.
noop: false
# Set to true to use /joined_members instead of /state to figure out who is
# in the room. Using /state is preferred because it means that users are
# banned when they are invited instead of just when they join. Set this to true
# if the bot is in large rooms or dozens of rooms.
# Whether Mjolnir should check member lists quicker (by using a different endpoint),
# keep in mind that enabling this will miss invited (but not joined) users.
#
# Turn on if your bot is in (very) large rooms, or in large amounts of rooms.
fasterMembershipChecks: false
# A case-insensitive list of ban reasons to automatically redact a user's
# messages for. Typically this is useful to avoid having to type two commands
# to the bot. Use asterisks to represent globs (ie: "spam*testing" would match
# "spam for testing" as well as "spamtesting").
# A case-insensitive list of ban reasons to have the bot also automatically redact the user's messages for.
#
# If the bot sees you ban a user with a reason that is an (exact case-insensitive) match to this list,
# it will also remove the user's messages automatically.
#
# Typically this is useful to avoid having to give two commands to the bot.
# Advanced: Use asterisks to have the reason match using "globs"
# (f.e. "spam*testing" would match "spam for testing" as well as "spamtesting").
#
# See here for more info: https://www.digitalocean.com/community/tools/glob
# Note: Keep in mind that glob is NOT regex!
automaticallyRedactForReasons:
- "spam"
- "advertising"
# A list of rooms to protect (matrix.to URLs)
# A list of rooms to protect. Mjolnir will add this to the list it knows from its account data.
#
# It won't, however, add it to the account data.
# Manually add the room via '!mjolnir rooms add' to have it stay protected regardless if this config value changes.
#
# Note: These must be matrix.to URLs
protectedRooms:
- "https://matrix.to/#/#yourroom:example.org"
# Set this option to true to protect every room the bot is joined to. Note that
# this effectively makes the protectedRooms and associated commands useless because
# the bot by nature must be joined to the room to protect it.
# Whether or not to add all joined rooms to the "protected rooms" list
# (excluding the management room and watched policy list rooms, see below).
#
# Note: the management room is *excluded* from this condition. Add it to the
# protected rooms to protect it.
# Note that this effectively makes the protectedRooms and associated commands useless
# for regular rooms.
#
# Note: ban list rooms the bot is watching but didn't create will not be protected.
# Manually add these rooms to the protected rooms list if you want them protected.
# Note: the management room is *excluded* from this condition.
# Explicitly add it as a protected room to protect it.
#
# Note: Ban list rooms the bot is watching but didn't create will not be protected.
# Explicitly add these rooms as a protected room list if you want them protected.
protectAllJoinedRooms: false
# Increase this delay to have Mjölnir wait longer between two consecutive backgrounded
# operations. The total duration of operations will be longer, but the homeserver won't
# be affected as much. Conversely, decrease this delay to have Mjölnir chain operations
# faster. The total duration of operations will generally be shorter, but the performance
# of the homeserver may be more impacted.
backgroundDelayMS: 500
# Server administration commands, these commands will only work if Mjolnir is
# a global server administrator
# a global server administrator, and the bot's server is a Synapse instance.
admin:
# The `make admin` upgrades the powerlevel of a specified user (or the bot itself)
# of a room to make them admin of the room (powerlevel 100).
# Whether or not Mjolnir can temporarily take control of any eligible account from the local homeserver who's in the room
# (with enough permissions) to "make" a user an admin.
#
# This only works if the room has at least one admin on the local homeserver
# (the homeserver specified in `homeserverUrl` in this file).
# This only works if a local user with enough admin permissions is present in the room.
enableMakeRoomAdminCommand: false
# Misc options for command handling and commands
commands:
# If true, Mjolnir will respond to commands like !help and !ban instead of
# requiring a prefix. This is useful if Mjolnir is the only bot running in
# your management room.
# Whether or not the `!mjolnir` prefix is necessary to submit commands.
#
# Note that Mjolnir can be pinged by display name instead of having to use
# If `true`, will allow commands like `!ban`, `!help`, etc.
#
# Note: Mjolnir can also be pinged by display name instead of having to use
# the !mjolnir prefix. For example, "my_moderator_bot: ban @spammer:example.org"
# will ban a user.
# will address only my_moderator_bot.
allowNoPrefix: false
# In addition to the bot's display name, !mjolnir, and optionally no prefix
# above, the bot will respond to these names. The items here can be used either
# as display names or prefixed with exclamation points.
# Any additional bot prefixes that Mjolnir will listen to. i.e. adding `mod` will allow `!mod help`.
additionalPrefixes:
- "mjolnir_bot"
# If true, ban commands that use wildcard characters require confirmation with
# an extra `--force` argument
# Whether or not commands with a wildcard (*) will require an additional `--force` argument
# in the command to be able to be submitted.
confirmWildcardBan: true
# Configuration specific to certain toggleable protections
# Configuration specific to certain toggle-able protections
protections:
# Configuration for the wordlist plugin, which can ban users based if they say certain
# blocked words shortly after joining.
wordlist:
# A list of words which should be monitored by the bot. These will match if any part
# of the word is present in the message in any case. e.g. "hello" also matches
# "HEllO". Additionally, regular expressions can be used.
# A list of case-insensitive keywords that the WordList protection will watch for from new users.
#
# WordList will ban users who use these words when first joining a room, so take caution when selecting them.
#
# For advanced usage, regex can also be used, see the following links for more information;
# - https://www.digitalocean.com/community/tutorials/an-introduction-to-regular-expressions
# - https://regexr.com/
# - https://regexone.com/
words:
- "CaSe"
- "InSeNsAtIve"
- "WoRd"
- "LiSt"
- "LoReM"
- "IpSuM"
- "DoLoR"
- "aMeT"
# How long after a user joins the server should the bot monitor their messages. After
# this time, users can say words from the wordlist without being banned automatically.
# Set to zero to disable (users will always be banned if they say a bad word)
# For how long (in minutes) the user is "new" to the WordList plugin.
#
# After this time, the user will no longer be banned for using a word in the above wordlist.
#
# Set to zero to disable the timeout and make users *always* appear "new".
# (users will always be banned if they say a bad word)
minutesBeforeTrusting: 20
# Options for monitoring the health of the bot
# Options for advanced monitoring of the health of the bot.
health:
# healthz options. These options are best for use in container environments
# like Kubernetes to detect how healthy the service is. The bot will report
@ -202,3 +235,12 @@ web:
abuseReporting:
# Whether to enable this feature.
enabled: false
# Whether or not to actively poll synapse for abuse reports, to be used
# instead of intercepting client calls to synapse's abuse endpoint, when that
# isn't possible/practical.
pollReports: false
# Whether or not new reports, received either by webapi or polling,
# should be printed to our managementRoom.
displayReports: true

View File

@ -1,6 +1,6 @@
# Moderator's guide to Mjolnir (bot edition)
Moderating a community shouldn't be difficult - Mjolnir gives you the tools to make moderation simple and
Moderating a community shouldn't be difficult - Mjolnir gives you the tools to make moderation simple and
impersonal.
**Note**: This guide does not apply to the Synapse module, which applies rules at the homeserver level. More
@ -11,6 +11,7 @@ information about the Synapse module can be found in the README.
If you're actively dealing with an incident, here's what you need to know:
* Always talk to Mjolnir in your coordination room.
* `!mjolnir room add <room>` will add a room to your "protected rooms", roms where mjolnir will propagate bans.
* `!mjolnir ban <shortcode> user @spammer:example.org` will ban someone.
* `!mjolnir ban <shortcode> server example.org` will ban a whole server.
* `!mjolnir rules` will tell you what the shortcodes are for your ban lists (needed above).
@ -19,6 +20,7 @@ If you're actively dealing with an incident, here's what you need to know:
* `!mjolnir protections` will show you your available protections - green circles mean enabled.
* `!mjolnir enable <protection>` to turn on a protection.
* `!mjolnir move <room alias> <room alias/ID>` Moves a room alias to a new room ID
* `!mjolnir verify` makes sure the bot has all required permissions to enact moderation (in all the protected rooms).
## How Mjolnir works
@ -92,3 +94,26 @@ Adding protected rooms on the fly is as easy as `!mjolnir rooms add <room alias>
which are protected with `!mjolnir rooms`, and remove a room with `!mjolnir rooms remove <room alias>`. Note
that rooms which are listed in the config may be protected again when the bot restarts - to remove these rooms
permanently from protection, remove them from the config.
## Trusted Reporters
Mjolnir has an (optional) system in which it will poll Synapse for new reports, and when it sees sufficient
amounts of reports from trusted users on an given message, it will take an action, such as redacting the message.
The users to trust, the actions to take, and the thresholds needed for those actions are configurable.
Prerequisites:
* `pollReport: true` in Mjolnir config file
* retart Mjolnir
* `!mjolnir enable TrustedReporters`
* `!mjolnir config add TrustedReporters.mxids @trusteduser:example.com`
* `!mjolnir config set TrustedReporters.alertThreshold 3`
TrustedReporters supports 3 different thresholds; `alertThreshold`, `redactThreshold`, and `banThreshold`.
By default, only `alertThreshold` is enabled, and is set to `3`. Mjolnir will only consider reports that
take place in rooms Mjolnir is protecting. `alertThreshold` is separate from Mjolnir's ability to log
each report, which is `displayReports` in Mjolnir's config file.
Make sure that anything you have sat in front of Synapse (e.g. nginx) is correctly configured to forward
`/_synapse/admin/v1/event_reports` and `/_synapse/admin/v1/rooms/${room_id}/context/${revent_id}` to
Synapse, or Mjolnir will not be able to poll for new reports. Mjolnir polls for new reports every 30 seconds.

48
docs/setup.md Normal file
View File

@ -0,0 +1,48 @@
# Setting up Mjolnir
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.
If you aren't using encrypted rooms anywhere, get an access token by opening Element in a
seperate browser profile or incognito tab, and log in as the bot. Then, go to "All Settings", "Help & About", and
click the little triangle next to "Access token". Copy and paste that into your config under `accessToken`.
**Note**: Do not log out, just close the window, otherwise the access token will be invalidated.
It's recommended to setup mjolnir as "close" to your server as possible (latency-wise), so that it
may react swiftly to commands, and quickly apply protections.
It's also recommended to turn off ratelimiting for a mjolnir bot, see [matrix-org/synapse#6286](https://github.com/matrix-org/synapse/issues/6286) and
[the synapse admin API documentation](https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#set-ratelimit) for more information.
**Note**: To deactivate users, move aliases, shutdown rooms, Mjolnir will need to be a server
admin, and the server needs to be Synapse.
See the [sample configuration](../config/default.yaml) for documentation about individual config keys.
## Installation
On a high level, installing Mjolnir works like the following;
1. Creating an account for mjolnir.
(Optional) Disable rate limits for that account.
2. Install mjolnir, see below.
3. Configure mjolnir see [further below](#post-install).
4. Start mjolnir.
Mjolnir can be installed in two ways, via Docker, or building it yourself.
See the below links for corresponding installation documentation;
- [Docker](./setup_docker.md)
- [Building It](./setup_selfbuild.md)
## Post-install
After installation, create a room, and ensure the mjolnir has joined. This will be your "management room".
If you're using pantalaimon, this room can be encrypted. If you're not using pantalaimon, this room **can not** be encrypted.
Acquire the room ID of this room, in Element Web you can find this via `(Room Name) -> Settings -> Advanced -> "Internal Room ID"`.
In your configuration, set `managementRoom` to this Room ID, now Mjolnir will only respond to commands originating from that room. If you want to upgrade your room in the future, you will have to update the configuration with it, or set it to an alias that corresponds to that room ID.
You can now start mjolnir. If everything went well, it should now send a bunch of messages in that room, signalling how it is booting up, and its current status.

78
docs/setup_docker.md Normal file
View File

@ -0,0 +1,78 @@
Mjolnir is available on the Docker Hub as [`matrixdotorg/mjolnir`](https://hub.docker.com/r/matrixdotorg/mjolnir).
Using docker, mjolnir can be setup and ran in either of two ways;
- Docker Run
- Docker Compose
Docker run will fire off a single-use container that is tied to your terminal's lifetime. (if you close the terminal, you shut down the bot)
Docker Compose can manage containers in the background, read a "compose" file, and automatically
recreate/restart relevant containers (upon `docker-compose up -d`) if they diverge from the file. It
can also easily read logs and manage the lifecycle of these containers. (start/stop/restart)
# Prerequisites
Before any other steps, a configuration file must be prepared.
Please go through [the sample configuration file's documentation](../config/default.yaml), download it, and rename it `production.yaml`.
You should go through and edit values to your liking, afterwards, pick a directory that'll be the root of all your mjolnir data files (i.e. `./mjolnir` from the home directory on your server), create a new directory called `config`, place the file there.
In short, please make sure that the mjolnir configuration exists under `./config/production.yaml` relative to the directory you've chosen, else mjolnir will not recognise it.
# Docker Run
Run the following command in your terminal, replace `./mjolnir` with the root directory of your config, if it is in another spot.
```bash
docker run --rm -it -v ./mjolnir:/data matrixdotorg/mjolnir:latest
```
# Docker Compose
Take the following file, and copy-paste it in `docker-compose.yml`;
```yaml
version: "3.3"
services:
mjolnir:
image: matrixdotorg/mjolnir:latest
restart: unless-stopped
volumes:
- ./mjolnir:/data
```
If you have pantalaimon installed, you can include it in this compose file as follows;
```yaml
version: "3.3"
services:
pantalaimon:
build: ./pantalaimon
container_name: pantalaimon
restart: unless-stopped
volumes:
- ./pantalaimon_data:/data
ports:
- 8008:8008
mjolnir:
image: matrixdotorg/mjolnir:latest
restart: unless-stopped
volumes:
- ./mjolnir:/data
```
**Note**: At the moment, pantalaimon does not have a Docker Hub image, so `./pantalaimon` needs to be the checked-out github repository, which you can do with `git clone https://github.com/matrix-org/pantalaimon`.
**Note**: In this configuration, you can access pantalaimon by using `pantalaimon` as a hostname, e.g. `http://pantalaimon:8080/` as `homeserverUrl`.
Replace `./mjolnir` (and optionally `./pantalaimon_data`) with the correct directories.
Then call `docker-compose up -d` while in the same directory as `docker-compose.yml` to pull, create, and start the containers.
- Use `docker-compose stop` to stop all containers, or `docker-compose stop mjolnir` to stop only the `mjolnir` container.
- Use `docker-compose restart mjolnir` to restart the mjolnir container, omit to restart all containers.
- Use `docker-compose down` to stop and remove all containers.
- Use `docker-compose logs` to display container logs, append `-f` to follow the logs in your terminal, append `--tail 100` to only show the latest 100 entries.

15
docs/setup_selfbuild.md Normal file
View File

@ -0,0 +1,15 @@
To build mjolnir, you have to have installed `yarn` 1.x and Node 16.
```bash
git clone https://github.com/matrix-org/mjolnir.git
cd mjolnir
yarn install
yarn build
# Copy and edit the config. It *is* recommended to change the data path.
cp config/default.yaml config/development.yaml
nano config/development.yaml
node lib/index.js
```

64
docs/synapse_module.md Normal file
View File

@ -0,0 +1,64 @@
**This requires Synapse 1.53.0 or higher**
Using the bot to manage your rooms is great, however if you want to use your ban lists
(or someone else's) on your server to affect all of your users then a Synapse module
is needed. Primarily meant to block invites from undesired homeservers/users, Mjolnir's
Synapse module is a way to interpret ban lists and apply them to your entire homeserver.
First, install the module to your Synapse python environment:
```
pip install -e "git+https://github.com/matrix-org/mjolnir.git#egg=mjolnir&subdirectory=synapse_antispam"
```
*Note*: Where your python environment is depends on your installation method. Visit
[#synapse:matrix.org](https://matrix.to/#/#synapse:matrix.org) if you're not sure.
Then add the following to your `homeserver.yaml`:
```yaml
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"
#message_max_length:
# Limit the characters in a message (event body) that a client can send in an event on this server.
# By default there is no limit (beyond the the limit the spec enforces on event size).
# Uncomment if you want messages to be limited to 510 characters.
#threshold: 510
# Limit messages only in certain rooms rooms.
# By default all rooms will enforce the limit.
# Uncomment if you want messages to only be subject to character limits in certain rooms.
#rooms:
# - "!vMvyOCeCxHsggkmALd:localhost:9999"
# Also hide messages from remote servers that are over the `message_limit`.
# By default only events from this server will be limited.
# WARNING: Remote users on other servers will still be able to messages over the limit.
# Uncomment to enforce the `message_limit` on events from remote servers.
#remote_servers: true
```
*Note*: Although this is described as a "spam checker", it does much more than fight
spam.
Be sure to change the configuration to match your setup. Your server is expected to
already be participating in the ban lists - if it is not, you will need to have a user
on your homeserver join. The antispam module will not join the rooms for you.
If you change the configuration, you will need to restart Synapse. You'll also need
to restart Synapse to install the plugin.

View File

@ -1,6 +1,6 @@
{
"name": "mjolnir",
"version": "1.4.1",
"version": "1.5.0",
"description": "A moderation tool for Matrix",
"main": "lib/index.js",
"repository": "git@github.com:matrix-org/mjolnir.git",
@ -13,7 +13,7 @@
"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 --project ./tsconfig.json \"test/integration/**/*Test.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: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"
},
@ -48,6 +48,6 @@
"shell-quote": "^1.7.3"
},
"engines": {
"node": ">=14.0.0"
"node": ">=16.0.0"
}
}

View File

@ -43,10 +43,13 @@ import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils";
import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug,
@ -65,6 +68,11 @@ const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for.";
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
/**
* Synapse will tell us where we last got to on polling reports, so we need
* to store that for pagination on further polls
*/
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";
export class Mjolnir {
private displayName: string;
@ -83,10 +91,22 @@ export class Mjolnir {
*/
private eventRedactionQueue = new EventRedactionQueue();
private automaticRedactionReasons: MatrixGlob[] = [];
/**
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
*/
private protectedJoinedRoomIds: string[] = [];
/**
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
*/
private explicitlyProtectedRoomIds: string[] = [];
private knownUnprotectedRooms: string[] = [];
private unprotectedWatchedListRooms: string[] = [];
private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue;
/*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/
private reportPoller?: ReportPoller;
/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client
@ -167,6 +187,10 @@ export class Mjolnir {
constructor(
public readonly client: MatrixClient,
public readonly managementRoomId: string,
/*
* All the rooms that Mjolnir is protecting and their permalinks.
* If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us).
*/
public readonly protectedRooms: { [roomId: string]: string },
private banLists: BanList[],
// Combines the rules from ban lists so they can be served to a homeserver module or another consumer.
@ -235,14 +259,20 @@ export class Mjolnir {
}
});
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
// Setup Web APIs
console.log("Creating Web APIs");
const reportManager = new ReportManager(this);
reportManager.on("report.new", this.handleReport);
reportManager.on("report.new", this.handleReport.bind(this));
this.webapis = new WebAPIs(reportManager, this.ruleServer);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager);
}
// Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
}
public get lists(): BanList[] {
@ -282,6 +312,20 @@ export class Mjolnir {
console.log("Starting web server");
await this.webapis.start();
if (this.reportPoller) {
let reportPollSetting: { from: number } = { from: 0 };
try {
reportPollSetting = await this.client.getAccountData(REPORT_POLL_EVENT_TYPE);
} catch (err) {
if (err.body?.errcode !== "M_NOT_FOUND") {
throw err;
} else {
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
}
}
this.reportPoller.start(reportPollSetting.from);
}
// Load the state.
this.currentState = STATE_CHECKING_PERMISSIONS;
@ -293,6 +337,7 @@ export class Mjolnir {
for (const roomId of data['rooms']) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.explicitlyProtectedRoomIds.push(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
}
}
} catch (e) {
@ -337,6 +382,7 @@ export class Mjolnir {
LogService.info("Mjolnir", "Stopping Mjolnir...");
this.client.stop();
this.webapis.stop();
this.reportPoller?.stop();
}
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
@ -371,9 +417,10 @@ export class Mjolnir {
public async addProtectedRoom(roomId: string) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.roomJoins.addRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
const unprotectedIdx = this.knownUnprotectedRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this.knownUnprotectedRooms.splice(unprotectedIdx, 1);
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
this.explicitlyProtectedRoomIds.push(roomId);
let additionalProtectedRooms: { rooms?: string[] } | null = null;
@ -391,6 +438,7 @@ export class Mjolnir {
public async removeProtectedRoom(roomId: string) {
delete this.protectedRooms[roomId];
this.roomJoins.removeRoom(roomId);
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
@ -411,15 +459,19 @@ export class Mjolnir {
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId);
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
const joinedRoomIdsSet = new Set(joinedRoomIds);
// Remove every room id that we have joined from `this.protectedRooms`.
for (const roomId of this.protectedJoinedRoomIds) {
delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
if (!joinedRoomIdsSet.has(roomId)) {
this.roomJoins.removeRoom(roomId);
}
}
this.protectedJoinedRoomIds = joinedRoomIds;
// Add all joined rooms back to the permalink object
for (const roomId of joinedRoomIds) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
if (!oldRoomIdsSet.has(roomId)) {
this.roomJoins.addRoom(roomId);
}
@ -661,7 +713,7 @@ export class Mjolnir {
const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", ""));
if (createEvent.creator === await this.client.getUserId()) return; // we created it
if (!this.knownUnprotectedRooms.includes(roomId)) this.knownUnprotectedRooms.push(roomId);
if (!this.unprotectedWatchedListRooms.includes(roomId)) this.unprotectedWatchedListRooms.push(roomId);
this.applyUnprotectedRooms();
try {
@ -676,8 +728,9 @@ export class Mjolnir {
}
private applyUnprotectedRooms() {
for (const roomId of this.knownUnprotectedRooms) {
for (const roomId of this.unprotectedWatchedListRooms) {
delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
}
}
@ -727,6 +780,7 @@ export class Mjolnir {
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.client.getUserId();
@ -784,6 +838,20 @@ export class Mjolnir {
});
}
// Wants: Additional permissions
for (const additionalPermission of additionalPermissions) {
const permLevel = plDefault(events[additionalPermission], stateDefault);
if (userLevel < permLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
}
// Otherwise OK
} catch (e) {
LogService.error("Mjolnir", extractRequestError(e));
@ -797,6 +865,17 @@ export class Mjolnir {
return errors;
}
private requiredProtectionPermissions(): Set<string> {
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
}
/**
* @returns The protected rooms ordered by the most recently active first.
*/
public protectedRoomsByActivity(): string[] {
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
}
/**
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
* @param verbose Whether to report any errors to the management room.
@ -808,9 +887,10 @@ export class Mjolnir {
}
let hadErrors = false;
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
@ -838,8 +918,10 @@ export class Mjolnir {
const changes = await banList.updateList();
let hadErrors = false;
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
@ -1105,7 +1187,7 @@ export class Mjolnir {
return await this.eventRedactionQueue.process(this, roomId);
}
private async handleReport(roomId: string, reporterId: string, event: any, reason?: string) {
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
for (const protection of this.enabledProtections) {
await protection.handleReport(this, roomId, reporterId, event, reason);
}

View File

@ -17,7 +17,7 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir";
import { execStatusCommand } from "./StatusCommand";
import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand";
import { execDumpRulesCommand } from "./DumpRulesCommand";
import { execDumpRulesCommand, execRulesMatchingCommand } from "./DumpRulesCommand";
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk";
import { htmlEscape } from "../utils";
import { execSyncCommand } from "./SyncCommand";
@ -59,6 +59,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
return await execBanCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'unban' && parts.length > 2) {
return await execUnbanCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') {
return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3])
} else if (parts[1] === 'rules') {
return await execDumpRulesCommand(roomId, event, mjolnir);
} else if (parts[1] === 'sync') {
@ -131,8 +133,9 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir unban <list shortcode> <user|room|server> <glob> [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n" +
"!mjolnir redact <user ID> [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" +
"!mjolnir redact <event permalink> - Redacts a message by permalink\n" +
"!mjolnir kick <user ID> [room alias/ID] [reason] - Kicks a user in a particular room or all protected rooms\n" +
"!mjolnir kick <glob> [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" +
"!mjolnir rules - Lists the rules currently in use by Mjolnir\n" +
"!mjolnir rules matching <user|room|server> - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user." +
"!mjolnir sync - Force updates of all lists and re-apply rules\n" +
"!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" +
"!mjolnir list create <shortcode> <alias localpart> - Creates a new ban list with the given shortcode and alias\n" +
@ -157,7 +160,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir alias add <room alias> <target room alias/ID> - Adds <room alias> to <target room>\n" +
"!mjolnir alias remove <room alias> - Deletes the room alias from whatever room it is attached to\n" +
"!mjolnir resolve <room alias> - Resolves a room alias to a room ID\n" +
"!mjolnir since <date>/<duration> <action> <limit> [rooms...] [reason] - Apply an action (kick, ban or just show) to all users who joined a room since a given date (up to <limit> users)\n" +
"!mjolnir since <date>/<duration> <action> <limit> [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since <date>/<duration> (up to <limit> users)\n" +
"!mjolnir shutdown room <room alias/ID> [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" +
"!mjolnir powerlevel <user ID> <power level> [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" +
"!mjolnir make admin <room alias> [user alias/ID] - Make the specified user or the bot itself admin of the room\n" +

View File

@ -14,10 +14,63 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk";
import { Mjolnir } from "../Mjolnir";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../models/BanList";
import { htmlEscape } from "../utils";
/**
* List all of the rules that match a given entity.
* The reason why you want to test against all rules and not just e.g. user or server is because
* there are situations where rules of different types can ban other entities e.g. server ACL can cause users to be banned.
* @param roomId The room the command is from.
* @param event The event containing the command.
* @param mjolnir A mjolnir to fetch the watched lists from.
* @param entity a user, room id or server.
* @returns When a response has been sent to the command.
*/
export async function execRulesMatchingCommand(roomId: string, event: any, mjolnir: Mjolnir, entity: string) {
let html = "";
let text = "";
for (const list of mjolnir.lists) {
const matches = list.rulesMatchingEntity(entity)
if (matches.length === 0) {
continue;
}
const matchesInfo = `Found ${matches.length} ` + (matches.length === 1 ? 'match:' : 'matches:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${matchesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${matchesInfo}:\n`;
for (const rule of matches) {
// If we know the rule kind, we will give it a readable name, otherwise just use its name.
let ruleKind: string = rule.kind;
if (ruleKind === RULE_USER) {
ruleKind = 'user';
} else if (ruleKind === RULE_SERVER) {
ruleKind = 'server';
} else if (ruleKind === RULE_ROOM) {
ruleKind = 'room';
}
html += `<li>${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
html += "</ul>";
}
if (text.length === 0) {
html += `No results for ${htmlEscape(entity)}`;
text += `No results for ${entity}`;
}
const reply = RichReply.createFor(roomId, event, text, html);
reply["msgtype"] = "m.notice";
return mjolnir.client.sendMessage(roomId, reply);
}
// !mjolnir rules
export async function execDumpRulesCommand(roomId: string, event: any, mjolnir: Mjolnir) {
let html = "<b>Rules currently in use:</b><br/>";

View File

@ -15,15 +15,32 @@ limitations under the License.
*/
import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config";
// !mjolnir kick <user|filter> [room] [reason]
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const userId = parts[2];
let force = false;
const glob = parts[2];
let rooms = [...Object.keys(mjolnir.protectedRooms)];
let reason;
if (parts[parts.length - 1] === "--force") {
force = true;
parts.pop();
}
if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
return;
}
const kickRule = new MatrixGlob(glob);
let reason: string | undefined;
if (parts.length > 3) {
let reasonIndex = 3;
if (parts[3].startsWith("#") || parts[3].startsWith("!")) {
@ -32,19 +49,31 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
}
reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>';
}
if (!reason) reason = "<none supplied>";
if (!reason) reason = '<none supplied>';
for (const targetRoomId of rooms) {
const joinedUsers = await mjolnir.client.getJoinedRoomMembers(targetRoomId);
if (!joinedUsers.includes(userId)) continue; // skip
for (const protectedRoomId of rooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
await mjolnir.logMessage(LogLevel.INFO, "KickCommand", `Kicking ${userId} in ${targetRoomId} for ${reason}`, targetRoomId);
if (!config.noop) {
await mjolnir.client.kickUser(userId, targetRoomId, reason);
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${userId} in ${targetRoomId} but the bot is running in no-op mode.`, targetRoomId);
for (const member of members) {
const victim = member.membershipFor;
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
});
} catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
}
}
}
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}

View File

@ -19,6 +19,7 @@ import { LogLevel, LogService, RichReply } from "matrix-bot-sdk";
import { htmlEscape, parseDuration } from "../utils";
import { ParseEntry } from "shell-quote";
import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts";
import { Join } from "../RoomMembers";
const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage();
const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);
@ -26,11 +27,16 @@ const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);
enum Action {
Kick = "kick",
Ban = "ban",
Mute = "mute",
Unmute = "unmute",
Show = "show"
}
type Result<T> = {ok: T} | {error: string};
type userId = string;
type Summary = { succeeded: userId[], failed: userId[] };
/**
* Attempt to parse a `ParseEntry`, as provided by the shell-style parser, using a parsing function.
*
@ -104,6 +110,15 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj
}
}
function formatResult(action: string, targetRoomId: string, recentJoins: Join[], summary: Summary): {html: string, text: string} {
const html = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.<br/>Succeeded ${summary.succeeded.length}: <ul>${summary.succeeded.map(x => `<li>${htmlEscape(x)}</li>`).join("\n")}</ul>.<br/> Failed ${summary.failed.length}: <ul>${summary.succeeded.map(x => `<li>${htmlEscape(x)}</li>`).join("\n")}</ul>`;
const text = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.\nSucceeded ${summary.succeeded.length}: ${summary.succeeded.map(x => `*${htmlEscape(x)}`).join("\n")}\n Failed ${summary.failed.length}:\n${summary.succeeded.map(x => ` * ${htmlEscape(x)}`).join("\n")}`;
return {
html,
text
};
}
// Implementation of `execSinceCommand`, counts on caller to print errors.
//
// This method:
@ -209,46 +224,78 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
for (let targetRoomId of rooms) {
let {html, text} = await (async () => {
let results: Summary = { succeeded: [], failed: []};
const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
switch (action) {
case Action.Show: {
return makeJoinStatus(mjolnir, targetRoomId, maxEntries, minDate, maxAgeMS);
return makeJoinStatus(mjolnir, targetRoomId, maxEntries, minDate, maxAgeMS, recentJoins);
}
case Action.Kick: {
const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
let results = { good: 0, bad: 0};
for (let join of joins) {
for (let join of recentJoins) {
try {
await mjolnir.client.kickUser(join.userId, targetRoomId, reason);
results.good += 1;
results.succeeded.push(join.userId);
} catch (ex) {
LogService.warn("SinceCommand", "Error while attempting to kick user", ex);
results.bad += 1;
results.failed.push(join.userId);
}
}
const text_ = `Attempted to kick ${joins.length} users from room ${targetRoomId}, ${results.good} kicked, ${results.bad} failures`;
return {
html: text_,
text: text_,
}
return formatResult("kick", targetRoomId, recentJoins, results);
}
case Action.Ban: {
const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
let results = { good: 0, bad: 0};
for (let join of joins) {
for (let join of recentJoins) {
try {
await mjolnir.client.banUser(join.userId, targetRoomId, reason);
results.good += 1;
results.succeeded.push(join.userId);
} catch (ex) {
LogService.warn("SinceCommand", "Error while attempting to ban user", ex);
results.bad += 1;
results.failed.push(join.userId);
}
}
const text_ = `Attempted to ban ${joins.length} users from room ${targetRoomId}, ${results.good} kicked, ${results.bad} failures`;
return {
html: text_,
text: text_
return formatResult("ban", targetRoomId, recentJoins, results);
}
case Action.Mute: {
const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record</* userId */ string, number>};
for (let join of recentJoins) {
powerLevels.users[join.userId] = -1;
}
try {
await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels);
for (let join of recentJoins) {
results.succeeded.push(join.userId);
}
} catch (ex) {
LogService.warn("SinceCommand", "Error while attempting to mute users", ex);
for (let join of recentJoins) {
results.failed.push(join.userId);
}
}
return formatResult("mute", targetRoomId, recentJoins, results);
}
case Action.Unmute: {
const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record</* userId */ string, number>, users_default?: number};
for (let join of recentJoins) {
// Restore default powerlevel.
delete powerLevels.users[join.userId];
}
try {
await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels);
for (let join of recentJoins) {
results.succeeded.push(join.userId);
}
} catch (ex) {
LogService.warn("SinceCommand", "Error while attempting to unmute users", ex);
for (let join of recentJoins) {
results.failed.push(join.userId);
}
}
return formatResult("unmute", targetRoomId, recentJoins, results);
}
}
})();
@ -262,7 +309,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
return {ok: undefined};
}
function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number): {html: string, text: string} {
function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number, recentJoins: Join[]): {html: string, text: string} {
const HUMANIZER_OPTIONS = {
// Reduce "1 day" => "1day" to simplify working with CSV.
spacer: "",
@ -270,16 +317,15 @@ function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: numb
largest: 1,
};
const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS);
const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
const htmlFragments = [];
const textFragments = [];
for (let join of joins) {
for (let join of recentJoins) {
const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS);
htmlFragments.push(`<li>${htmlEscape(join.userId)}: ${durationHumanReadable}</li>`);
textFragments.push(`- ${join.userId}: ${durationHumanReadable}`);
}
return {
html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries): <ul> ${htmlFragments.join()} </ul>`,
text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}`
html: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries): <ul> ${htmlFragments.join()} </ul>`,
text: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}`
}
}

View File

@ -48,6 +48,17 @@ interface IConfig {
fasterMembershipChecks: boolean;
automaticallyRedactForReasons: string[]; // case-insensitive globs
protectAllJoinedRooms: boolean;
/**
* Backgrounded tasks: number of milliseconds to wait between the completion
* of one background task and the start of the next one.
*/
backgroundDelayMS: number;
pollReports: boolean;
/**
* Whether or not new reports, received either by webapi or polling,
* should be printed to our managementRoom.
*/
displayReports: boolean;
admin?: {
enableMakeRoomAdminCommand?: boolean;
}
@ -116,6 +127,9 @@ const defaultConfig: IConfig = {
fasterMembershipChecks: false,
automaticallyRedactForReasons: ["spam", "advertising"],
protectAllJoinedRooms: false,
backgroundDelayMS: 500,
pollReports: false,
displayReports: true,
commands: {
allowNoPrefix: false,
additionalPrefixes: [],

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { extractRequestError, LogService, MatrixClient } from "matrix-bot-sdk";
import { extractRequestError, LogService, MatrixClient, UserID } from "matrix-bot-sdk";
import { EventEmitter } from "events";
import { ListRule, RECOMMENDATION_BAN } from "./ListRule";
@ -182,6 +182,38 @@ class BanList extends EventEmitter {
return [...this.serverRules, ...this.userRules, ...this.roomRules];
}
/**
* Return all of the rules in this list that will match the provided entity.
* If the entity is a user, then we match the domain part against server rules too.
* @param ruleKind The type of rule for the entity e.g. `RULE_USER`.
* @param entity The entity to test e.g. the user id, server name or a room id.
* @returns All of the rules that match this entity.
*/
public rulesMatchingEntity(entity: string, ruleKind?: string): ListRule[] {
const ruleTypeOf: (entityPart: string) => string = (entityPart: string) => {
if (ruleKind) {
return ruleKind;
} else if (entityPart.startsWith("#") || entityPart.startsWith("#")) {
return RULE_ROOM;
} else if (entity.startsWith("@")) {
return RULE_USER;
} else {
return RULE_SERVER;
}
};
if (ruleTypeOf(entity) === RULE_USER) {
// We special case because want to see whether a server ban is preventing this user from participating too.
const userId = new UserID(entity);
return [
...this.userRules.filter(rule => rule.isMatch(entity)),
...this.serverRules.filter(rule => rule.isMatch(userId.domain))
]
} else {
return this.rulesOfKind(ruleTypeOf(entity)).filter(rule => rule.isMatch(entity));
}
}
/**
* Remove all rules in the banList for this entity that have the same state key (as when we ban them)
* by searching for rules that have legacy state types.

View File

@ -19,7 +19,7 @@ import { MatrixGlob } from "matrix-bot-sdk";
export const RECOMMENDATION_BAN = "m.ban";
export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"];
export function recommendationToStable(recommendation: string, unstable = true): string|null {
export function recommendationToStable(recommendation: string, unstable = false): string|null {
if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN;
return null;
}

View File

@ -28,6 +28,7 @@ export abstract class Protection {
abstract readonly name: string
abstract readonly description: string;
enabled = false;
readonly requiredStatePermissions: string[] = [];
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
/*

View File

@ -0,0 +1,137 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {Protection} from "./IProtection";
import {Mjolnir} from "../Mjolnir";
import {NumberProtectionSetting} from "./ProtectionSettings";
import {LogLevel} from "matrix-bot-sdk";
import config from "../config";
const DEFAULT_MAX_PER_TIMESCALE = 50;
const DEFAULT_TIMESCALE_MINUTES = 60;
const ONE_MINUTE = 60_000; // 1min in ms
export class JoinWaveShortCircuit extends Protection {
requiredStatePermissions = ["m.room.join_rules"]
private joinBuckets: {
[roomId: string]: {
lastBucketStart: Date,
numberOfJoins: number,
}
} = {};
settings = {
maxPer: new NumberProtectionSetting(DEFAULT_MAX_PER_TIMESCALE),
timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES)
};
constructor() {
super();
}
public get name(): string {
return "JoinWaveShortCircuit";
}
public get description(): string {
return "If X amount of users join in Y time, set the room to invite-only."
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {
if (event['type'] !== 'm.room.member') {
// Not a join/leave event.
return;
}
if (!(roomId in mjolnir.protectedRooms)) {
// Not a room we are watching.
return;
}
const userId = event['state_key'];
if (!userId) {
// Ill-formed event.
return;
}
const newMembership = event['content']['membership'];
const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || null;
// We look at the previous membership to filter out profile changes
if (newMembership === 'join' && prevMembership !== "join") {
// A new join, fallthrough
} else {
return;
}
// If either the roomId bucket didn't exist, or the bucket has expired, create a new one
if (!this.joinBuckets[roomId] || this.hasExpired(this.joinBuckets[roomId].lastBucketStart)) {
this.joinBuckets[roomId] = {
lastBucketStart: new Date(),
numberOfJoins: 0
}
}
if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId);
if (!config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
}
}
}
private hasExpired(at: Date): boolean {
return ((new Date()).getTime() - at.getTime()) > this.timescaleMilliseconds()
}
private timescaleMilliseconds(): number {
return (this.settings.timescaleMinutes.value * ONE_MINUTE)
}
public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string, text: string }> {
const withExpired = subcommand.includes("withExpired");
const withStart = subcommand.includes("withStart");
let html = `<b>Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes}):</b><br/><ul>`;
let text = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes):\n`;
for (const roomId of Object.keys(this.joinBuckets)) {
const bucket = this.joinBuckets[roomId];
const isExpired = this.hasExpired(bucket.lastBucketStart);
if (isExpired && !withExpired) {
continue;
}
const startText = withStart ? ` (since ${bucket.lastBucketStart})` : "";
const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : "";
html += `<li><a href="https://matrix.to/#/${roomId}">${roomId}</a>: ${bucket.numberOfJoins} joins${startText}${expiredText}.</li>`;
text += `* ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.\n`;
}
html += "</ul>";
return {
html,
text,
}
}
}

View File

@ -22,6 +22,7 @@ import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia";
import { TrustedReporters } from "./TrustedReporters";
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
export const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
@ -31,4 +32,5 @@ export const PROTECTIONS: Protection[] = [
new MessageIsMedia(),
new TrustedReporters(),
new DetectFederationLag(),
new JoinWaveShortCircuit(),
];

View File

@ -0,0 +1,79 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-bot-sdk";
/**
* Used to keep track of protected rooms so they are always ordered for activity.
*
* We use the same method as Element web for this, the major disadvantage being that we sort on each access to the room list (sort by most recently active first).
* We have tried to mitigate this by caching the sorted list until the activity in rooms changes again.
* See https://github.com/matrix-org/matrix-react-sdk/blob/8a0398b632dff1a5f6cfd4bf95d78854aeadc60e/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
*
*/
export class ProtectedRoomActivityTracker {
private protectedRoomActivities = new Map<string/*room id*/, number/*last event timestamp*/>();
/**
* A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first.
*/
private activeRoomsCache: null|string[] = null
constructor(client: MatrixClient) {
client.on('room.event', this.handleEvent.bind(this));
}
/**
* Inform the tracker that a new room is being protected by Mjolnir.
* @param roomId The room Mjolnir is now protecting.
*/
public addProtectedRoom(roomId: string): void {
this.protectedRoomActivities.set(roomId, /* epoch */ 0);
}
/**
* Inform the trakcer that a room is no longer being protected by Mjolnir.
* @param roomId The roomId that is no longer being protected by Mjolnir.
*/
public removeProtectedRoom(roomId: string): void {
this.protectedRoomActivities.delete(roomId);
}
/**
* Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated
* @param roomId The room the new event is in.
* @param event The new event.
*/
public handleEvent(roomId: string, event: any): void {
const last_origin_server_ts = this.protectedRoomActivities.get(roomId);
if (last_origin_server_ts !== undefined && Number.isInteger(event.origin_server_ts)) {
if (event.origin_server_ts > last_origin_server_ts) {
this.activeRoomsCache = null;
this.protectedRoomActivities.set(roomId, event.origin_server_ts);
}
}
}
/**
* @returns A list of protected rooms ids ordered by activity.
*/
public protectedRoomsByActivity(): string[] {
if (!this.activeRoomsCache) {
this.activeRoomsCache = [...this.protectedRoomActivities]
.sort((a, b) => b[1] - a[1])
.map(pair => pair[0]);
}
return this.activeRoomsCache;
}
}

View File

@ -0,0 +1,201 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { extractRequestError, LogLevel } from "matrix-bot-sdk";
import { Mjolnir } from "../Mjolnir";
export type Task<T> = (queue: ThrottlingQueue) => Promise<T>;
/**
* A queue for backgrounding tasks without hammering servers too much.
*/
export class ThrottlingQueue {
/**
* The pending tasks.
*/
private _tasks: (() => Promise<void>)[] | null;
/**
* A timeout for the next task to execute.
*/
private timeout: ReturnType<typeof setTimeout> | null;
/**
* How long we should wait between the completion of a tasks and the start of the next task.
* Any >=0 number is good.
*/
private _delayMS: number;
/**
* Construct an empty queue.
*
* This queue will start executing whenever `push()` is called and stop
* whenever it runs out of tasks to execute.
*
* @param delayMS The default delay between executing two tasks, in ms.
*/
constructor(private mjolnir: Mjolnir, delayMS: number) {
this.timeout = null;
this.delayMS = delayMS;
this._tasks = [];
}
/**
* Stop the queue, make sure we can never use it again.
*/
public dispose() {
this.stop();
this._tasks = null;
}
/**
* The number of tasks waiting to be executed.
*/
get length(): number {
return this.tasks.length;
}
/**
* Push a new task onto the queue.
*
* @param task Some code to execute.
* @return A promise resolved/rejected once `task` is complete.
*/
public push<T>(task: Task<T>): Promise<T> {
// Wrap `task` into a `Promise` to inform enqueuer when
// the task is complete.
return new Promise((resolve, reject) => {
const wrapper = async () => {
try {
const result: T = await task(this);
resolve(result);
} catch (ex) {
reject(ex);
};
};
this.tasks.push(wrapper);
this.start();
});
}
/**
* Block a queue for a number of milliseconds.
*
* This method is meant typically to be used by a `Task` that receives a 429 (Too Many Requests) to reschedule
* itself for later, after giving the server a little room to breathe. If you need this, do not forget to
* re-`push()` with the failing `Task`. You may call `block()` and `push()` in either order.
*
* @param durationMS A number of milliseconds to wait until resuming operations.
*/
public block(durationMS: number) {
if (!this.tasks) {
throw new TypeError("Cannot `block()` on a ThrottlingQueue that has already been disposed of.");
}
this.stop();
this.timeout = setTimeout(async () => this.step(), durationMS);
}
/**
* Start the loop to execute pending tasks.
*
* Does nothing if the loop is already started.
*/
private start() {
if (this.timeout) {
// Already started.
return;
}
if (!this.tasks.length) {
// Nothing to do.
return;
}
this.timeout = setTimeout(async () => this.step(), this._delayMS);
}
/**
* Stop the loop to execute pending tasks.
*
* Does nothing if the loop is already stopped. A loop stopped with `stop()` may be
* resumed by calling `push()` or `start()`.
*/
private stop() {
if (!this.timeout) {
// Already stopped.
return;
}
clearTimeout(this.timeout);
this.timeout = null;
}
/**
* Change the delay between completion of an event and the start of the next event.
*
* This will be used next time a task is completed.
*/
set delayMS(delayMS: number) {
if (delayMS < 0) {
throw new TypeError(`Invalid delay ${delayMS}. Need a non-negative number of ms.`);
}
this._delayMS = delayMS;
}
/**
* Return the delay between completion of an event and the start of the next event.
*/
get delayMS(): number {
return this._delayMS;
}
/**
* Execute one step of the loop, then prepare the following step.
*
* 1. If there is no task, do nothing and stop.
* 2. Otherwise, execute task.
* 3. Once task is complete (whether succeeded or failed), retrigger the loop.
*/
private async step() {
// Pull task.
const task = this.tasks.shift();
if (!task) {
// Nothing to do.
// Stop the loop until we have something to do.
this.stop();
return;
}
try {
await task();
} catch (ex) {
await this.mjolnir.logMessage(
LogLevel.WARN,
'Error while executing task',
extractRequestError(ex)
);
} finally {
this.stop();
this.start();
}
}
/**
* Return `tasks`, unless the queue has been disposed of.
*/
private get tasks(): (() => Promise<void>)[] {
if (this._tasks === null) {
throw new TypeError("This Throttling Queue has been disposed of and shouldn't be used anymore");
}
return this._tasks;
}
}

View File

@ -21,6 +21,7 @@ import { htmlEscape } from "../utils";
import { JSDOM } from 'jsdom';
import { EventEmitter } from 'events';
import config from "../config";
import { Mjolnir } from "../Mjolnir";
/// Regexp, used to extract the action label from an action reaction
@ -114,7 +115,9 @@ export class ReportManager extends EventEmitter {
*/
public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason });
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId });
if (config.displayReports) {
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId });
}
}
/**

145
src/report/ReportPoller.ts Normal file
View File

@ -0,0 +1,145 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir";
import { ReportManager } from './ReportManager';
import { LogLevel } from "matrix-bot-sdk";
class InvalidStateError extends Error {}
/**
* A class to poll synapse's report endpoint, so we can act on new reports
*
* @param mjolnir The running Mjolnir instance
* @param manager The report manager in to which we feed new reports
*/
export class ReportPoller {
/**
* https://matrix-org.github.io/synapse/latest/admin_api/event_reports.html
* "from" is an opaque token that is returned from the API to paginate reports
*/
private from = 0;
/**
* The currently-pending report poll
*/
private timeout: ReturnType<typeof setTimeout> | null = null;
constructor(
private mjolnir: Mjolnir,
private manager: ReportManager,
) { }
private schedulePoll() {
if (this.timeout === null) {
/*
* Important that we use `setTimeout` here, not `setInterval`,
* because if there's networking problems and `getAbuseReports`
* hangs for longer thank the interval, it could cause a stampede
* of requests when networking problems resolve
*/
this.timeout = setTimeout(
this.tryGetAbuseReports.bind(this),
30_000 // a minute in milliseconds
);
} else {
throw new InvalidStateError("poll already scheduled");
}
}
private async getAbuseReports() {
let response_: {
event_reports: { room_id: string, event_id: string, sender: string, reason: string }[],
next_token: number | undefined
} | undefined;
try {
response_ = await this.mjolnir.client.doRequest(
"GET",
"/_synapse/admin/v1/event_reports",
{ from: this.from.toString() }
);
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
return;
}
const response = response_!;
for (let report of response.event_reports) {
if (!(report.room_id in this.mjolnir.protectedRooms)) {
continue;
}
let event: any; // `any` because `handleServerAbuseReport` uses `any`
try {
event = (await this.mjolnir.client.doRequest(
"GET",
`/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1`
)).event;
} catch (ex) {
this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`);
continue;
}
await this.manager.handleServerAbuseReport({
roomId: report.room_id,
reporterId: report.sender,
event: event,
reason: report.reason,
});
}
/*
* This API endpoint returns an opaque `next_token` number that we
* need to give back to subsequent requests for pagination, so here we
* save it in account data
*/
if (response.next_token !== undefined) {
this.from = response.next_token;
try {
await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token });
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
}
}
}
private async tryGetAbuseReports() {
this.timeout = null;
try {
await this.getAbuseReports()
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
}
this.schedulePoll();
}
public start(startFrom: number) {
if (this.timeout === null) {
this.from = startFrom;
this.schedulePoll();
} else {
throw new InvalidStateError("cannot start an already started poll");
}
}
public stop() {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
} else {
throw new InvalidStateError("cannot stop a poll that hasn't started");
}
}
}

View File

@ -122,28 +122,35 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
}
/**
* Note: `rooms/initialSync` is deprecated. However, there is no replacement for this API for the time being.
* While previous versions of this function used `/sync`, experience shows that it can grow extremely
* slow (4-5 minutes long) when we need to sync many large rooms, which leads to timeouts and
* breakage in Mjolnir, see https://github.com/matrix-org/synapse/issues/10842.
* The response returned from `backfill`
* See https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3roomsroomidmessages
* for what the fields mean in detail. You have to read the spec even with the summary.
* The `chunk` contains the events in reverse-chronological order.
* The `end` is a token for the end of the `chunk` (where the older events are).
* The `start` is a token for the beginning of the `chunk` (where the most recent events are).
*/
function roomInitialSync() {
return client.doRequest("GET", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/initialSync`);
interface BackfillResponse {
chunk?: any[],
end?: string,
start: string
}
function backfill(from: string) {
/**
* Call `/messages` "backwards".
* @param from a token that was returned previously from this API to start paginating from or
* if `null`, start from the most recent point in the timeline.
* @returns The response part of the `/messages` API, see `BackfillResponse`.
*/
async function backfill(from: string|null): Promise<BackfillResponse> {
const qs = {
filter: JSON.stringify(roomEventFilter),
from: from,
dir: "b",
... from ? { from } : {}
};
LogService.info("utils", "Backfilling with token: " + from);
return client.doRequest("GET", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages`, qs);
return client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`, qs);
}
// Do an initial sync first to get the batch token
const response = await roomInitialSync();
let processed = 0;
/**
* Filter events from the timeline to events that are from a matching sender and under the limit that can be processed by the callback.
@ -160,35 +167,27 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
}
return messages;
}
// The recommended APIs for fetching events from a room is to use both rooms/initialSync then /messages.
// Unfortunately, this results in code that is rather hard to read, as these two APIs employ very different data structures.
// We prefer discarding the results from rooms/initialSync and reading only from /messages,
// even if it's a little slower, for the sake of code maintenance.
const timeline = response['messages']
if (timeline) {
// The end of the PaginationChunk has the most recent events from rooms/initialSync.
// This token is required be present in the PagintionChunk from rooms/initialSync.
let token = timeline['end']!;
// We check that we have the token because rooms/messages is not required to provide one
// and will not provide one when there is no more history to paginate.
while (token && processed < limit) {
const bfMessages = await backfill(token);
let lastToken = token;
token = bfMessages['end'];
if (lastToken === token) {
LogService.debug("utils", "Backfill returned same end token - returning early.");
return;
}
const events = filterEvents(bfMessages['chunk'] || []);
// If we are using a glob, there may be no relevant events in this chunk.
if (events.length > 0) {
await cb(events);
}
// We check that we have the token because rooms/messages is not required to provide one
// and will not provide one when there is no more history to paginate.
let token: string|null = null;
do {
const bfMessages: BackfillResponse = await backfill(token);
const previousToken: string|null = token;
token = bfMessages['end'] ?? null;
const events = filterEvents(bfMessages['chunk'] || []);
// If we are using a glob, there may be no relevant events in this chunk.
if (events.length > 0) {
await cb(events);
}
} else {
throw new Error(`Internal Error: rooms/initialSync did not return a pagination chunk for ${roomId}, this is not normal and if it is we need to stop using it. See roomInitialSync() for why we are using it.`);
}
// This check exists only because of a Synapse compliance bug https://github.com/matrix-org/synapse/issues/12102.
// We also check after processing events as the `previousToken` can be 'null' if we are at the start of the steam
// and `token` can also be 'null' as we have paginated the entire timeline, but there would be unprocessed events in the
// chunk that was returned in this request.
if (previousToken === token) {
LogService.debug("utils", "Backfill returned same end token - returning early.");
return;
}
} while (token && processed < limit)
}
/*
@ -387,6 +386,9 @@ function patchMatrixClientForRetry() {
// We need to retry.
reject(err);
} else {
if (attempt >= MAX_REQUEST_ATTEMPTS) {
LogService.warn('Mjolnir.client', `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.`);
}
// No need-to-retry error? Lucky us!
// Note that this may very well be an error, just not
// one we need to retry.

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="mjolnir",
version="0.1.0", # version automated in package.json - Do not edit this line, use `yarn version`.
version="1.5.0", # version automated in package.json - Do not edit this line, use `yarn version`.
packages=find_packages(),
description="Mjolnir Antispam",
include_package_data=True,

View File

@ -1,11 +1,11 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { newTestUser, noticeListener } from "./clientHelper";
import { newTestUser } from "./clientHelper";
import { LogService, MatrixClient, Permalinks, UserID } from "matrix-bot-sdk";
import BanList, { ALL_RULE_TYPES, ChangeType, ListRuleChange, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/BanList";
import { ServerAcl, ServerAclContent } from "../../src/models/ServerAcl";
import { createBanList, getFirstReaction } from "./commands/commandUtils";
import { ServerAcl } from "../../src/models/ServerAcl";
import { getFirstReaction } from "./commands/commandUtils";
import { getMessagesByUserIn } from "../../src/utils";
/**
@ -231,7 +231,6 @@ describe('Test: We will not be able to ban ourselves via ACL.', function () {
describe('Test: ACL updates will batch when rules are added in succession.', function () {
it('Will batch ACL updates if we spam rules into a BanList', async function () {
this.timeout(180000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }});
@ -268,6 +267,8 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
// Give them a bit of a spread over time.
await new Promise(resolve => setTimeout(resolve, 5));
}
// give the events a chance to appear in the response to `/state`, since this is a problem.
await new Promise(resolve => setTimeout(resolve, 2000));
// We do this because it should force us to wait until all the ACL events have been applied.
// Even if that does mean the last few events will not go through batching...
@ -363,3 +364,80 @@ describe('Test: unbaning entities via the BanList.', function () {
assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore');
})
})
describe('Test: should apply bans to the most recently active rooms first', function () {
it('Applies bans to the most recently active rooms first', async function () {
this.timeout(180000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }});
moderator.joinRoom(this.mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId();
// Setup some protected rooms so we can check their ACL state later.
const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId]});
await mjolnir.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await this.mjolnir!.addProtectedRoom(room);
protectedRooms.push(room);
}
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await this.mjolnir!.syncLists();
await Promise.all(protectedRooms.map(async room => {
const roomAcl = await mjolnir.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? {deny: []} : Promise.reject(e));
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
}));
// Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms.
const banListId = await moderator.createRoom({ invite: [mjolnirId] });
mjolnir.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId));
await this.mjolnir!.syncLists();
// shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them.
for (let i = protectedRooms.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[protectedRooms[i], protectedRooms[j]] = [protectedRooms[j], protectedRooms[i]];
}
// create some activity in the same order.
for (const roomId of protectedRooms.slice().reverse()) {
await mjolnir.sendMessage(roomId, {body: `activity`, msgtype: 'm.text'});
await new Promise(resolve => setTimeout(resolve, 100));
}
// check the rooms are in the expected order
for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms[i]);
}
const badServer = `evil.com`;
// just ban one server
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer);
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
// Wait until all the ACL events have been applied.
await this.mjolnir!.syncLists();
for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1));
}
// Check that the most recently active rooms got the ACL update first.
let last_event_ts = 0;
for (const roomId of protectedRooms) {
let roomAclEvent: null|any;
// Can't be the best way to get the whole event, but ok.
await getMessagesByUserIn(mjolnir, mjolnirId, roomId, 1, events => roomAclEvent = events[0]);
const roomAcl = roomAclEvent!.content;
if (!acl.matches(roomAcl)) {
assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`)
}
assert.equal(roomAclEvent.origin_server_ts > last_event_ts, true, `This room was more recently active so should have the more recent timestamp`);
last_event_ts = roomAclEvent.origin_server_ts;
}
})
})

View File

@ -0,0 +1,47 @@
import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection";
import { newTestUser } from "./clientHelper";
describe("Test: Report polling", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "protection-settings" }});
})
it("Mjolnir correctly retrieves a report from synapse", async function() {
this.timeout(40000);
let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await client.getUserId()] });
await client.joinRoom(protectedRoomId);
await this.mjolnir.addProtectedRoom(protectedRoomId);
const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"});
await new Promise(async resolve => {
await this.mjolnir.registerProtection(new class implements IProtection {
name = "jYvufI";
description = "A test protection";
settings = { };
handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { };
handleReport = (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) => {
if (reason === "x5h1Je") {
resolve(null);
}
};
});
await this.mjolnir.enableProtection("jYvufI");
await client.doRequest(
"POST",
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", {
reason: "x5h1Je"
}
);
});
// So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26)
// because the promise above gets resolved before we finish awaiting the report sending request on L#31,
// then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond.
// Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling.
// Ok, well apparently that needs a big refactor to change, but if you change the config before running this test,
// then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326.
await new Promise(resolve => setTimeout(resolve, 1000));
});
});

View File

@ -401,25 +401,37 @@ describe("Test: Testing RoomMemberManager", function() {
}
// Create and protect rooms.
// - room 0 remains unprotected, as witness;
// - room 1 is protected but won't be targeted directly, also as witness.
const NUMBER_OF_ROOMS = 14;
const roomIds: string[] = [];
const roomAliases: string[] = [];
//
// We reserve two control rooms:
// - room 0, also known as the "control unprotected room" is unprotected
// (we're not calling `!mjolnir rooms add` for this room), so none
// of the operations of `!mjolnir since` shoud affect it. We are
// using it to control, at the end of each experiment, that none of
// the `!mjolnir since` operations affect it.
// - room 1, also known as the "control protected room" is protected
// (we are calling `!mjolnir rooms add` for this room), but we are
// never directly requesting any `!mjolnir since` action against
// this room. We are using it to control, at the end of each experiment,
// that none of the `!mjolnir since` operations that should target
// one single other room also affect that room. It is, however, affected
// by general operations that are designed to affect all protected rooms.
const NUMBER_OF_ROOMS = 18;
const allRoomIds: string[] = [];
const allRoomAliases: string[] = [];
const mjolnirUserId = await this.mjolnir.client.getUserId();
for (let i = 0; i < NUMBER_OF_ROOMS; ++i) {
const roomId = await this.moderator.createRoom({
invite: [mjolnirUserId, ...goodUserIds, ...badUserIds],
});
roomIds.push(roomId);
allRoomIds.push(roomId);
const alias = `#since-test-${randomUUID()}:localhost:9999`;
await this.moderator.createRoomAlias(alias, roomId);
roomAliases.push(alias);
allRoomAliases.push(alias);
}
for (let i = 1; i < roomIds.length; ++i) {
// Protect all rooms except roomIds[0], as witness.
const roomId = roomIds[i];
for (let i = 1; i < allRoomIds.length; ++i) {
// Protect all rooms except allRoomIds[0], as control.
const roomId = allRoomIds[i];
await this.mjolnir.client.joinRoom(roomId);
await this.moderator.setUserPowerLevel(mjolnirUserId, roomId, 100);
await this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` });
@ -429,8 +441,8 @@ describe("Test: Testing RoomMemberManager", function() {
do {
let protectedRooms = this.mjolnir.protectedRooms;
protectedRoomsUpdated = true;
for (let i = 1; i < roomIds.length; ++i) {
const roomId = roomIds[i];
for (let i = 1; i < allRoomIds.length; ++i) {
const roomId = allRoomIds[i];
if (!(roomId in protectedRooms)) {
protectedRoomsUpdated = false;
await new Promise(resolve => setTimeout(resolve, 1_000));
@ -440,7 +452,7 @@ describe("Test: Testing RoomMemberManager", function() {
// Good users join before cut date.
for (let user of this.goodUsers) {
for (let roomId of roomIds) {
for (let roomId of allRoomIds) {
await user.joinRoom(roomId);
}
}
@ -453,148 +465,269 @@ describe("Test: Testing RoomMemberManager", function() {
// Bad users join after cut date.
for (let user of this.badUsers) {
for (let roomId of roomIds) {
for (let roomId of allRoomIds) {
await user.joinRoom(roomId);
}
}
// Finally, prepare our control rooms and separate them
// from the regular rooms.
const CONTROL_UNPROTECTED_ROOM_ID = allRoomIds[0];
const CONTROL_PROTECTED_ID = allRoomIds[1];
const roomIds = allRoomIds.slice(2);
const roomAliases = allRoomAliases.slice(2);
enum Method {
kick,
ban
ban,
mute,
unmute,
}
const WITNESS_UNPROTECTED_ROOM_ID = roomIds[0];
const WITNESS_ROOM_ID = roomIds[1];
const EXPERIMENTS = [
class Experiment {
// A human-readable name for the command.
readonly name: string;
// If `true`, this command should affect room `CONTROL_PROTECTED_ID`.
// Defaults to `false`.
readonly shouldAffectControlProtected: boolean;
// The actual command-line.
readonly command: (roomId: string, roomAlias: string) => string;
// The number of responses we expect to this command.
// Defaults to `1`.
readonly n: number;
// How affected users should leave the room.
readonly method: Method;
// If `true`, should this experiment look at the same room as the previous one.
// Defaults to `false`.
readonly isSameRoomAsPrevious: boolean;
// The index of the room on which we're acting.
//
// Initialized by `addTo`.
roomIndex: number | undefined;
constructor({name, shouldAffectControlProtected, command, n, method, sameRoom}: {name: string, command: (roomId: string, roomAlias: string) => string, shouldAffectControlProtected?: boolean, n?: number, method: Method, sameRoom?: boolean}) {
this.name = name;
this.shouldAffectControlProtected = typeof shouldAffectControlProtected === "undefined" ? false : shouldAffectControlProtected;
this.command = command;
this.n = typeof n === "undefined" ? 1 : n;
this.method = method;
this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom;
}
// Add an experiment to the list of experiments.
//
// This is how `roomIndex` gets initialized.
addTo(experiments: Experiment[]) {
if (this.isSameRoomAsPrevious) {
this.roomIndex = experiments[experiments.length - 1].roomIndex;
} else if (experiments.length === 0) {
this.roomIndex = 0;
} else {
this.roomIndex = experiments[experiments.length - 1].roomIndex! + 1;
}
experiments.push(this);
}
}
const EXPERIMENTS: Experiment[] = [];
for (let experiment of [
// Kick bad users in one room, using duration syntax, no reason.
{
// A human-readable name for the command.
new Experiment({
name: "kick with duration",
// The actual command-line.
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId}`,
// If `true`, this command should affect room `WITNESS_ROOM_ID`.
shouldAffectWitnessRoom: false,
// The number of responses we expect to this command.
n: 1,
// How affected users should leave the room.
method: Method.kick,
},
}),
// Ban bad users in one room, using duration syntax, no reason.
{
new Experiment({
name: "ban with duration",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId}`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.ban,
},
}),
// Mute bad users in one room, using duration syntax, no reason.
new Experiment({
name: "mute with duration",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId}`,
method: Method.mute,
}),
new Experiment({
name: "unmute with duration",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId}`,
method: Method.unmute,
sameRoom: true,
}),
// Kick bad users in one room, using date syntax, no reason.
{
new Experiment({
name: "kick with date",
command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId}`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.kick,
},
}),
// Ban bad users in one room, using date syntax, no reason.
{
new Experiment({
name: "ban with date",
command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId}`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.ban,
},
}),
// Mute bad users in one room, using date syntax, no reason.
new Experiment({
name: "mute with date",
command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId}`,
method: Method.mute,
}),
new Experiment({
name: "unmute with date",
command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId}`,
method: Method.unmute,
sameRoom: true,
}),
// Kick bad users in one room, using duration syntax, with reason.
{
new Experiment({
name: "kick with duration and reason",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.kick,
},
}),
// Ban bad users in one room, using duration syntax, with reason.
{
new Experiment({
name: "ban with duration and reason",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.ban,
},
}),
// Mute bad users in one room, using duration syntax, with reason.
new Experiment({
name: "mute with duration and reason",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId} bad, bad user`,
method: Method.mute,
}),
new Experiment({
name: "unmute with duration and reason",
command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId} bad, bad user`,
method: Method.unmute,
sameRoom: true,
}),
// Kick bad users in one room, using date syntax, with reason.
{
new Experiment({
name: "kick with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false,
shouldAffectControlProtected: false,
n: 1,
method: Method.kick,
},
}),
// Ban bad users in one room, using date syntax, with reason.
{
new Experiment({
name: "ban with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.ban,
},
}),
// Mute bad users in one room, using date syntax, with reason.
new Experiment({
name: "mute with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId} bad, bad user`,
method: Method.mute,
}),
new Experiment({
name: "unmute with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId} bad, bad user`,
method: Method.unmute,
sameRoom: true,
}),
// Kick bad users in one room, using duration syntax, without reason, using alias.
{
new Experiment({
name: "kick with duration, no reason, alias",
command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias}`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.kick,
},
}),
// Kick bad users in one room, using duration syntax, with reason, using alias.
{
new Experiment({
name: "kick with duration, reason and alias",
command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias} for some reason`,
shouldAffectWitnessRoom: false,
n: 1,
method: Method.kick,
},
}),
// Kick bad users everywhere, no reason
{
new Experiment({
name: "kick with date everywhere",
command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`,
shouldAffectWitnessRoom: true,
shouldAffectControlProtected: true,
n: NUMBER_OF_ROOMS - 1,
method: Method.kick,
}),
]) {
experiment.addTo(EXPERIMENTS);
}
// Just-in-case health check, before starting.
{
const usersInUnprotectedControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID);
const usersInControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID);
for (let userId of goodUserIds) {
assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, good user ${userId} should be in the unprotected control room`);
assert.ok(usersInControlProtected.includes(userId), `Initially, good user ${userId} should be in the control room`);
}
];
for (let userId of badUserIds) {
assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, bad user ${userId} should be in the unprotected control room`);
assert.ok(usersInControlProtected.includes(userId), `Initially, bad user ${userId} should be in the control room`);
}
}
for (let i = 0; i < EXPERIMENTS.length; ++i) {
const experiment = EXPERIMENTS[i];
const roomId = roomIds[i + 2];
const roomAlias = roomAliases[i + 2];
const index = experiment.roomIndex!;
const roomId = roomIds[index];
const roomAlias = roomAliases[index];
const joined = this.mjolnir.roomJoins.getUsersInRoom(roomId, start, 100);
assert.ok(joined.length >= 2 * SAMPLE_SIZE, `We should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`);
console.debug(`Running experiment ${i} "${experiment.name}" in room index ${index} (${roomId} / ${roomAlias}): \`${experiment.command(roomId, roomAlias)}\``);
assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`);
// Run experiment.
await getNthReply(this.mjolnir.client, this.mjolnir.managementRoomId, experiment.n, async () => {
const command = experiment.command(roomId, roomAlias);
let result = await this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: command });
return result;
});
// Check post-conditions.
const usersInRoom = await this.mjolnir.client.getJoinedRoomMembers(roomId);
const usersInUnprotectedWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_UNPROTECTED_ROOM_ID);
const usersInWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_ROOM_ID);
const usersInUnprotectedControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID);
const usersInControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID);
for (let userId of goodUserIds) {
assert.ok(usersInRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in affected room`);
assert.ok(usersInWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in witness room`);
assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected witness room`);
assert.ok(usersInControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in control room (${CONTROL_PROTECTED_ID})`);
assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected control room (${CONTROL_UNPROTECTED_ROOM_ID})`);
}
for (let userId of badUserIds) {
assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`);
assert.equal(usersInWitnessRoom.includes(userId), !experiment.shouldAffectWitnessRoom, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectWitnessRoom ? "NOT" : "still"} be in witness room`);
assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected witness room`);
const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId);
switch (experiment.method) {
case Method.kick:
assert.equal(leaveEvent.membership, "leave");
break;
case Method.ban:
assert.equal(leaveEvent.membership, "ban");
break;
if (experiment.method === Method.mute) {
for (let userId of goodUserIds) {
let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false);
assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`);
}
for (let userId of badUserIds) {
let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false);
assert.ok(!canSpeak, `After a ${experiment.name}, bad user ${userId} should NOT be allowed to speak in the room`);
}
} else if (experiment.method === Method.unmute) {
for (let userId of goodUserIds) {
let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false);
assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`);
}
for (let userId of badUserIds) {
let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false);
assert.ok(canSpeak, `After a ${experiment.name}, bad user ${userId} should AGAIN be allowed to speak in the room`);
}
} else {
for (let userId of badUserIds) {
assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`);
assert.equal(usersInControlProtected.includes(userId), !experiment.shouldAffectControlProtected, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectControlProtected ? "NOT" : "still"} be in control room`);
assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected control room`);
const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId);
switch (experiment.method) {
case Method.kick:
assert.equal(leaveEvent.membership, "leave");
break;
case Method.ban:
assert.equal(leaveEvent.membership, "ban");
break;
}
}
}
}

View File

@ -0,0 +1,84 @@
import { strict as assert } from "assert";
import { UserID } from "matrix-bot-sdk";
import { ThrottlingQueue } from "../../src/queues/ThrottlingQueue";
describe("Test: ThrottlingQueue", function() {
it("Tasks enqueued with `push()` are executed exactly once and in the right order", async function() {
this.timeout(20000);
const queue = new ThrottlingQueue(this.mjolnir, 10);
let state = new Map();
let promises: Promise<void>[] = [];
for (let counter = 0; counter < 10; ++counter) {
const i = counter;
const promise = queue.push(async () => {
if (state.get(i)) {
throw new Error(`We shouldn't have set state[${i}] yet`);
}
state.set(i, true);
for (let j = 0; j < i; ++j) {
if (!state.get(j)) {
throw new Error(`We should have set state[${j}] already`);
}
}
});
promises.push(promise);
}
await Promise.all(promises);
for (let i = 0; i < 10; ++i) {
if (!state.get(i)) {
throw new Error(`This is the end of the test, we should have set state[${i}]`);
}
}
// Give code a little bit more time to trip itself, in case `promises` are accidentally
// resolved too early.
await new Promise(resolve => setTimeout(resolve, 1000));
queue.dispose();
});
it("Tasks enqueued with `push()` are executed exactly once and in the right order, even if we call `block()` at some point", async function() {
this.timeout(20000);
const queue = new ThrottlingQueue(this.mjolnir, 10);
let state = new Map();
let promises: Promise<void>[] = [];
for (let counter = 0; counter < 10; ++counter) {
const i = counter;
promises.push(queue.push(async () => {
if (state.get(i)) {
throw new Error(`We shouldn't have set state[${i}] yet`);
}
state.set(i, true);
for (let j = 0; j < i; ++j) {
queue.block(100);
if (!state.get(j)) {
throw new Error(`We should have set state[${j}] already`);
}
}
if (i % 2 === 0) {
// Arbitrary call to `delay()`.
queue.block(20);
}
}));
}
queue.block(100);
await Promise.all(promises);
for (let i = 0; i < 10; ++i) {
if (!state.get(i)) {
throw new Error(`This is the end of the test, we should have set state[${i}]`);
}
}
// Give code a little bit more time to trip itself, in case `promises` are accidentally
// resolved too early.
await new Promise(resolve => setTimeout(resolve, 1000));
queue.dispose();
});
});

View File

@ -5,17 +5,16 @@ import { getFirstReaction } from "./commands/commandUtils";
describe("Test: throttled users can function with Mjolnir.", function () {
it('throttled users survive being throttled by synapse', async function() {
this.timeout(60000);
let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true });
let throttledUserId = await throttledUser.getUserId();
let targetRoom = await throttledUser.createRoom();
// send enough messages to hit the rate limit.
await Promise.all([...Array(150).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`})));
await Promise.all([...Array(25).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`})));
let messageCount = 0;
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 150, (events) => {
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 25, (events) => {
messageCount += events.length;
});
assert.equal(messageCount, 150, "There should have been 150 messages in this room");
assert.equal(messageCount, 25, "There should have been 25 messages in this room");
})
})
@ -31,7 +30,6 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
})
it('Can still perform and respond to a redaction command', async function () {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
let badUserId = await badUser.getUserId();
@ -45,12 +43,12 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
await badUser.joinRoom(targetRoom);
// Give Mjolnir some work to do and some messages to sync through.
await Promise.all([...Array(100).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
await Promise.all([...Array(50).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
await Promise.all([...Array(25).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
await Promise.all([...Array(25).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`});
await Promise.all([...Array(50).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`})));
await Promise.all([...Array(25).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`})));
try {
await moderator.start();
@ -72,6 +70,6 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
}
})
});
assert.equal(count, 51, "There should be exactly 51 events from the spammer in this room.");
assert.equal(count, 26, "There should be exactly 26 events from the spammer in this room.");
})
})

View File

@ -8,7 +8,7 @@
"newLine": "LF",
"noImplicitReturns": true,
"noUnusedLocals": true,
"target": "es2015",
"target": "es2021",
"noImplicitAny": true,
"sourceMap": true,
"strictNullChecks": true,
@ -21,6 +21,8 @@
"include": [
"./src/**/*",
"./test/integration/manualLaunchScript.ts",
"./test/integration/roomMembersTest.ts"
"./test/integration/roomMembersTest.ts",
"./test/integration/banListTest.ts",
"./test/integration/reportPollingTest"
]
}