Apply members and server bans to the most recently active rooms first. (#274)

* Apply members and server bans to the most recently active rooms first.

https://github.com/matrix-org/mjolnir/issues/273
This commit is contained in:
Gnuxie 2022-05-03 12:36:53 +01:00 committed by GitHub
parent cc9f393ed7
commit c8caf744c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 204 additions and 14 deletions

View File

@ -47,6 +47,7 @@ import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils";
import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug,
@ -83,10 +84,17 @@ 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;
/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client
@ -167,6 +175,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,6 +247,9 @@ 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);
@ -293,6 +308,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) {
@ -372,9 +388,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;
@ -392,6 +409,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);
@ -412,15 +430,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);
}
@ -662,7 +684,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 {
@ -677,8 +699,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);
}
}
@ -798,6 +821,13 @@ export class Mjolnir {
return errors;
}
/**
* @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.
@ -809,9 +839,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:");
@ -839,8 +870,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:");

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

@ -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";
/**
@ -363,3 +363,80 @@ describe('Test: unbaning entities via the BanList.', function () {
assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore');
})
})
describe.only('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(6000000000)
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

@ -21,6 +21,7 @@
"include": [
"./src/**/*",
"./test/integration/manualLaunchScript.ts",
"./test/integration/roomMembersTest.ts"
"./test/integration/roomMembersTest.ts",
"./test/integration/banListTest.ts"
]
}