Refactor protected rooms. (#371)

* Attempt to factor out protected rooms from Mjolnir.

This is useful to the appservice because it means we don't
have to wrap a Mjolnir that is designed to sync.

It's also useful if we later on want to have specific
settings per space.

It's also just a nice seperation between Mjolnir's needs while
syncing via client-server and the behaviour of syncing policy rooms.

 ### Things that have changed

- `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`.
- `ManagementRoomOutput` class gets created to handle logging back to the management room.
- Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`.
- Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been.
- `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after
  member bans.
- ApplyServerAcls moved to `ProtectedRooms`
- ApplyMemberBans move to `ProtectedRooms`
- `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`.
- `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive.


* ProtectedRooms is not supposed to track joined rooms.

The reason is because it is supposed to represent a specific
set of rooms to protect, not do horrible logic
for working out what rooms mjolnir is supposed to protect.
This commit is contained in:
Gnuxie 2022-09-29 14:49:09 +01:00 committed by GitHub
parent f108935d07
commit 77ad40e27a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1312 additions and 1080 deletions

View File

@ -22,35 +22,54 @@ const TRIGGER_INTERVALS: { [key: string]: number } = {
[ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes [ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes
}; };
/**
* The ErrorCache is used to suppress the same error messages for the same error state.
* An example would be when mjolnir has been told to protect a room but is missing some permission such as the ability to send `m.room.server_acl`.
* Each time `Mjolnir` synchronizes policies to protected rooms Mjolnir will try to log to the management room that Mjolnir doesn't have permission to send `m.room.server_acl`.
* The ErrorCache is an attempt to make sure the error is reported only once.
*/
export default class ErrorCache { export default class ErrorCache {
private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {}; private roomsToErrors: Map<string/*room id*/, Map<string /*error kind*/, number>> = new Map();
private constructor() { constructor() {
} }
public static resetError(roomId: string, kind: string) { /**
if (!ErrorCache.roomsToErrors[roomId]) { * Reset the error cache for a room/kind in the situation where circumstances have changed e.g. if Mjolnir has been informed via sync of a `m.room.power_levels` event in the room, we would want to clear `ERROR_KIND_PERMISSION`
ErrorCache.roomsToErrors[roomId] = {}; * so that a user can see if their changes worked.
* @param roomId The room to reset the error cache for.
* @param kind The kind of error we are resetting.
*/
public resetError(roomId: string, kind: string) {
if (!this.roomsToErrors.has(roomId)) {
this.roomsToErrors.set(roomId, new Map());
} }
ErrorCache.roomsToErrors[roomId][kind] = 0; this.roomsToErrors.get(roomId)?.set(kind, 0);
} }
public static triggerError(roomId: string, kind: string): boolean { /**
if (!ErrorCache.roomsToErrors[roomId]) { * Register the error with the cache.
ErrorCache.roomsToErrors[roomId] = {}; * @param roomId The room where the error is occuring or related to.
* @param kind What kind of error, either `ERROR_KIND_PERMISSION` or `ERROR_KIND_FATAL`.
* @returns True if the error kind has been triggered in that room,
* meaning it has been longer than the time specified in `TRIGGER_INTERVALS` since the last trigger (or the first trigger). Otherwise false.
*/
public triggerError(roomId: string, kind: string): boolean {
if (!this.roomsToErrors.get(roomId)) {
this.roomsToErrors.set(roomId, new Map());
} }
const triggers = ErrorCache.roomsToErrors[roomId]; const triggers = this.roomsToErrors.get(roomId)!;
if (!triggers[kind]) { if (!triggers.has(kind)) {
triggers[kind] = 0; triggers?.set(kind, 0);
} }
const lastTriggerTime = triggers[kind]; const lastTriggerTime = triggers.get(kind)!;
const now = new Date().getTime(); const now = new Date().getTime();
const interval = TRIGGER_INTERVALS[kind]; const interval = TRIGGER_INTERVALS[kind];
if ((now - lastTriggerTime) >= interval) { if ((now - lastTriggerTime) >= interval) {
triggers[kind] = now; triggers.set(kind, now);
return true; return true;
} else { } else {
return false; return false;

123
src/ManagementRoomOutput.ts Normal file
View File

@ -0,0 +1,123 @@
/*
Copyright 2019, 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, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config";
import { htmlEscape } from "./utils";
const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug,
[LogLevel.INFO.toString()]: LogService.info,
[LogLevel.WARN.toString()]: LogService.warn,
[LogLevel.ERROR.toString()]: LogService.error,
};
/**
* Allows the different componenets of mjolnir to send messages back to the management room without introducing a dependency on the entirity of a `Mjolnir` instance.
*/
export default class ManagementRoomOutput {
constructor(
private readonly managementRoomId: string,
private readonly client: MatrixClient,
private readonly config: IConfig,
) {
}
/**
* Take an arbitrary string and a set of room IDs, and return a
* TextualMessageEventContent whose plaintext component replaces those room
* IDs with their canonical aliases, and whose html component replaces those
* room IDs with their matrix.to room pills.
*
* @param client The matrix client on which to query for room aliases
* @param text An arbitrary string to rewrite with room aliases and pills
* @param roomIds A set of room IDs to find and replace in `text`
* @param msgtype The desired message type of the returned TextualMessageEventContent
* @returns A TextualMessageEventContent with replaced room IDs
*/
private async replaceRoomIdsWithPills(text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
const content: TextualMessageEventContent = {
body: text,
formatted_body: htmlEscape(text),
msgtype: msgtype,
format: "org.matrix.custom.html",
};
// Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us.
const escapeRegex = (v: string): string => {
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const roomId of roomIds) {
let alias = roomId;
try {
alias = (await this.client.getPublishedAlias(roomId)) || roomId;
} catch (e) {
// This is a recursive call, so tell the function not to try and call us
await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
LogService.warn("utils", extractRequestError(e));
}
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
content.body = content.body.replace(regexRoomId, alias);
if (content.formatted_body) {
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${htmlEscape(alias)}</a>`);
}
}
return content;
}
/**
* Log a message to the management room and the console, replaces any room ids in additionalRoomIds with pills.
*
* @param level Used to determine whether to hide the message or not depending on `config.verboseLogging`.
* @param module Used to help find where in the source the message is coming from (when logging to the console).
* @param message The message we want to log.
* @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills.
* @param isRecursive Whether logMessage is being called from logMessage.
*/
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const client = this.client;
const roomIds = [this.managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = {
body: message,
formatted_body: htmlEscape(message),
msgtype: "m.notice",
format: "org.matrix.custom.html",
};
if (!isRecursive) {
evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice");
}
await client.sendMessage(this.managementRoomId, evContent);
}
levelToFn[level.toString()](module, message);
}
}

View File

@ -20,54 +20,35 @@ import {
LogLevel, LogLevel,
LogService, LogService,
MatrixClient, MatrixClient,
MatrixGlob,
MembershipEvent, MembershipEvent,
Permalinks, Permalinks,
UserID,
TextualMessageEventContent
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule";
import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
import { applyUserBans } from "./actions/ApplyBan";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { Protection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections";
import { Consequence } from "./protections/consequence";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils"; import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager"; import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller"; import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs"; import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils";
import RuleServer from "./models/RuleServer"; import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import { IConfig } from "./config"; import { IConfig } from "./config";
import PolicyList, { ListRuleChange } from "./models/PolicyList"; import PolicyList from "./models/PolicyList";
import { ProtectedRooms } from "./ProtectedRooms";
const levelToFn = { import ManagementRoomOutput from "./ManagementRoomOutput";
[LogLevel.DEBUG.toString()]: LogService.debug, import { ProtectionManager } from "./protections/ProtectionManager";
[LogLevel.INFO.toString()]: LogService.info, import { RoomMemberManager } from "./RoomMembers";
[LogLevel.WARN.toString()]: LogService.warn,
[LogLevel.ERROR.toString()]: LogService.error,
};
export const STATE_NOT_STARTED = "not_started"; export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
export const STATE_SYNCING = "syncing"; export const STATE_SYNCING = "syncing";
export const STATE_RUNNING = "running"; export const STATE_RUNNING = "running";
const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; 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 * Synapse will tell us where we last got to on polling reports, so we need
* to store that for pagination on further polls * to store that for pagination on further polls
@ -79,18 +60,11 @@ export class Mjolnir {
private localpart: string; private localpart: string;
private currentState: string = STATE_NOT_STARTED; private currentState: string = STATE_NOT_STARTED;
public readonly roomJoins: RoomMemberManager; public readonly roomJoins: RoomMemberManager;
public protections = new Map<string /* protection name */, Protection>();
/** /**
* This is for users who are not listed on a watchlist, * This is for users who are not listed on a watchlist,
* but have been flagged by the automatic spam detection as suispicous * but have been flagged by the automatic spam detection as suispicous
*/ */
private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
/**
* This is a queue for redactions to process after mjolnir
* has finished applying ACL and bans when syncing.
*/
private eventRedactionQueue = new EventRedactionQueue();
private automaticRedactionReasons: MatrixGlob[] = [];
/** /**
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`. * Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
*/ */
@ -99,23 +73,31 @@ export class Mjolnir {
* 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`. * 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 explicitlyProtectedRoomIds: string[] = [];
/**
* These are rooms that we have joined to watch the list, but don't have permission to protect.
* These are eventually are exluded from `protectedRooms` in `applyUnprotectedRooms` via `resyncJoinedRooms`.
*/
private unprotectedWatchedListRooms: string[] = []; private unprotectedWatchedListRooms: string[] = [];
public readonly protectedRoomsTracker: ProtectedRooms;
private webapis: WebAPIs; private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue; public taskQueue: ThrottlingQueue;
/** /**
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list. * Reporting back to the management room.
* This is because requests operating with rules from an older version of the list that are slow
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
* which would cause several requests to a room to send a new m.room.server_acl event.
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
*/ */
public aclChain: Promise<void> = Promise.resolve(); public readonly managementRoomOutput: ManagementRoomOutput;
/* /*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/ */
private reportPoller?: ReportPoller; private reportPoller?: ReportPoller;
/**
* Store the protections being used by Mjolnir.
*/
public readonly protectionManager: ProtectionManager;
/**
* Handle user reports from the homeserver.
*/
public readonly reportManager: ReportManager;
/** /**
* Adds a listener to the client that will automatically accept invitations. * Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client * @param {MatrixClient} client
@ -151,7 +133,7 @@ export class Mjolnir {
const spaceUserIds = await client.getJoinedRoomMembers(spaceId) const spaceUserIds = await client.getJoinedRoomMembers(spaceId)
.catch(async e => { .catch(async e => {
if (e.body?.errcode === "M_FORBIDDEN") { if (e.body?.errcode === "M_FORBIDDEN") {
await mjolnir.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`);
await client.joinRoom(spaceId); await client.joinRoom(spaceId);
return await client.getJoinedRoomMembers(spaceId); return await client.getJoinedRoomMembers(spaceId);
} else { } else {
@ -196,14 +178,15 @@ export class Mjolnir {
} }
const ruleServer = config.web.ruleServer ? new RuleServer() : null; const ruleServer = config.web.ruleServer ? new RuleServer() : null;
const mjolnir = new Mjolnir(client, managementRoomId, config, protectedRooms, policyLists, ruleServer); const mjolnir = new Mjolnir(client, await client.getUserId(), managementRoomId, config, protectedRooms, policyLists, ruleServer);
await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
Mjolnir.addJoinOnInviteListener(mjolnir, client, config); Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
return mjolnir; return mjolnir;
} }
constructor( constructor(
public readonly client: MatrixClient, public readonly client: MatrixClient,
private readonly clientUserId: string,
public readonly managementRoomId: string, public readonly managementRoomId: string,
public readonly config: IConfig, public readonly config: IConfig,
/* /*
@ -217,10 +200,6 @@ export class Mjolnir {
) { ) {
this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms); this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms);
for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
// Setup bot. // Setup bot.
client.on("room.event", this.handleEvent.bind(this)); client.on("room.event", this.handleEvent.bind(this));
@ -278,20 +257,22 @@ export class Mjolnir {
} }
}); });
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
// Setup Web APIs // Setup Web APIs
console.log("Creating Web APIs"); console.log("Creating Web APIs");
const reportManager = new ReportManager(this); this.reportManager = new ReportManager(this);
reportManager.on("report.new", this.handleReport.bind(this)); this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer);
this.webapis = new WebAPIs(reportManager, this.config, this.ruleServer);
if (config.pollReports) { if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager); this.reportPoller = new ReportPoller(this, this.reportManager);
} }
// Setup join/leave listener // Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client); this.roomJoins = new RoomMemberManager(this.client);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
this.protectionManager = new ProtectionManager(this);
this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config);
const protections = new ProtectionManager(this);
this.protectedRoomsTracker = new ProtectedRooms(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config);
} }
public get lists(): PolicyList[] { public get lists(): PolicyList[] {
@ -302,10 +283,6 @@ export class Mjolnir {
return this.currentState; return this.currentState;
} }
public get enabledProtections(): Protection[] {
return [...this.protections.values()].filter(p => p.enabled);
}
/** /**
* Returns the handler to flag a user for redaction, removing any future messages that they send. * Returns the handler to flag a user for redaction, removing any future messages that they send.
* Typically this is used by the flooding or image protection on users that have not been banned from a list yet. * Typically this is used by the flooding or image protection on users that have not been banned from a list yet.
@ -315,10 +292,6 @@ export class Mjolnir {
return this.unlistedUserRedactionQueue; return this.unlistedUserRedactionQueue;
} }
public get automaticRedactGlobs(): MatrixGlob[] {
return this.automaticRedactionReasons;
}
/** /**
* Start Mjölnir. * Start Mjölnir.
*/ */
@ -339,7 +312,7 @@ export class Mjolnir {
if (err.body?.errcode !== "M_NOT_FOUND") { if (err.body?.errcode !== "M_NOT_FOUND") {
throw err; throw err;
} else { } else {
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
} }
} }
this.reportPoller.start(reportPollSetting.from); this.reportPoller.start(reportPollSetting.from);
@ -348,7 +321,7 @@ export class Mjolnir {
// Load the state. // Load the state.
this.currentState = STATE_CHECKING_PERMISSIONS; this.currentState = STATE_CHECKING_PERMISSIONS;
await this.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
await this.resyncJoinedRooms(false); await this.resyncJoinedRooms(false);
try { try {
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
@ -356,7 +329,6 @@ export class Mjolnir {
for (const roomId of data['rooms']) { for (const roomId of data['rooms']) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.explicitlyProtectedRoomIds.push(roomId); this.explicitlyProtectedRoomIds.push(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
} }
} }
} catch (e) { } catch (e) {
@ -364,27 +336,27 @@ export class Mjolnir {
} }
await this.buildWatchedPolicyLists(); await this.buildWatchedPolicyLists();
this.applyUnprotectedRooms(); this.applyUnprotectedRooms();
await this.protectionManager.start();
if (this.config.verifyPermissionsOnStartup) { if (this.config.verifyPermissionsOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(this.config.verboseLogging); await this.protectedRoomsTracker.verifyPermissions(this.config.verboseLogging);
} }
this.currentState = STATE_SYNCING; this.currentState = STATE_SYNCING;
if (this.config.syncOnStartup) { if (this.config.syncOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(this.config.verboseLogging); await this.protectedRoomsTracker.syncLists(this.config.verboseLogging);
await this.registerProtections();
} }
this.currentState = STATE_RUNNING; this.currentState = STATE_RUNNING;
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
} catch (err) { } catch (err) {
try { try {
LogService.error("Mjolnir", "Error during startup:"); LogService.error("Mjolnir", "Error during startup:");
LogService.error("Mjolnir", extractRequestError(err)); LogService.error("Mjolnir", extractRequestError(err));
this.stop(); this.stop();
await this.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); await this.managementRoomOutput.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
throw err; throw err;
} catch (e) { } catch (e) {
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`); LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
@ -403,39 +375,10 @@ export class Mjolnir {
this.reportPoller?.stop(); this.reportPoller?.stop();
} }
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const client = this.client;
const roomIds = [this.managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = {
body: message,
formatted_body: htmlEscape(message),
msgtype: "m.notice",
format: "org.matrix.custom.html",
};
if (!isRecursive) {
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
}
await client.sendMessage(this.managementRoomId, evContent);
}
levelToFn[level.toString()](module, message);
}
public async addProtectedRoom(roomId: string) { public async addProtectedRoom(roomId: string) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.roomJoins.addRoom(roomId); this.roomJoins.addRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId); this.protectedRoomsTracker.addProtectedRoom(roomId);
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
@ -450,13 +393,12 @@ export class Mjolnir {
const rooms = (additionalProtectedRooms?.rooms ?? []); const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId); rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms }); await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
await this.syncLists(this.config.verboseLogging);
} }
public async removeProtectedRoom(roomId: string) { public async removeProtectedRoom(roomId: string) {
delete this.protectedRooms[roomId]; delete this.protectedRooms[roomId];
this.roomJoins.removeRoom(roomId); this.roomJoins.removeRoom(roomId);
this.protectedRoomActivityTracker.removeProtectedRoom(roomId); this.protectedRoomsTracker.removeProtectedRoom(roomId);
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId); const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1); if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
@ -471,194 +413,40 @@ export class Mjolnir {
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
} }
// See https://github.com/matrix-org/mjolnir/issues/370.
private async resyncJoinedRooms(withSync = true) { private async resyncJoinedRooms(withSync = true) {
if (!this.config.protectAllJoinedRooms) return; if (!this.config.protectAllJoinedRooms) return;
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId); const joinedRoomIds = (await this.client.getJoinedRooms())
.filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r));
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds); const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
const joinedRoomIdsSet = new Set(joinedRoomIds); const joinedRoomIdsSet = new Set(joinedRoomIds);
// Remove every room id that we have joined from `this.protectedRooms`. // find every room that we have left (since last time)
for (const roomId of this.protectedJoinedRoomIds) { for (const roomId of oldRoomIdsSet.keys()) {
delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
if (!joinedRoomIdsSet.has(roomId)) { if (!joinedRoomIdsSet.has(roomId)) {
// Then we have left this room.
delete this.protectedRooms[roomId];
this.roomJoins.removeRoom(roomId); this.roomJoins.removeRoom(roomId);
} }
} }
this.protectedJoinedRoomIds = joinedRoomIds; // find every room that we have joined (since last time).
// Add all joined rooms back to the permalink object for (const roomId of joinedRoomIdsSet.keys()) {
for (const roomId of joinedRoomIds) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
if (!oldRoomIdsSet.has(roomId)) { if (!oldRoomIdsSet.has(roomId)) {
// Then we have joined this room
this.roomJoins.addRoom(roomId); this.roomJoins.addRoom(roomId);
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
} }
} }
// update our internal representation of joined rooms.
this.protectedJoinedRoomIds = joinedRoomIds;
this.applyUnprotectedRooms(); this.applyUnprotectedRooms();
if (withSync) { if (withSync) {
await this.syncLists(this.config.verboseLogging); await this.protectedRoomsTracker.syncLists(this.config.verboseLogging);
} }
} }
/*
* Take all the builtin protections, register them to set their enabled (or not) state and
* update their settings with any saved non-default values
*/
private async registerProtections() {
for (const protection of PROTECTIONS) {
try {
await this.registerProtection(protection);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
}
}
/*
* Make a list of the names of enabled protections and save them in a state event
*/
private async saveEnabledProtections() {
const protections = this.enabledProtections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
}
/*
* Enable a protection by name and persist its enable state in to a state event
*
* @param name The name of the protection whose settings we're enabling
*/
public async enableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = true;
await this.saveEnabledProtections();
}
}
/*
* Disable a protection by name and remove it from the persistent list of enabled protections
*
* @param name The name of the protection whose settings we're disabling
*/
public async disableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = false;
await this.saveEnabledProtections();
}
}
/*
* Read org.matrix.mjolnir.setting state event, find any saved settings for
* the requested protectionName, then iterate and validate against their parser
* counterparts in Protection.settings and return those which validate
*
* @param protectionName The name of the protection whose settings we're reading
* @returns Every saved setting for this protectionName that has a valid value
*/
public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> {
let savedSettings: { [setting: string]: any } = {}
try {
savedSettings = await this.client.getRoomStateEvent(
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName
);
} catch {
// setting does not exist, return empty object
return {};
}
const settingDefinitions = this.protections.get(protectionName)?.settings ?? {};
const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) {
if (
// is this a setting name with a known parser?
key in settingDefinitions
// is the datatype of this setting's value what we expect?
&& typeof (settingDefinitions[key].value) === typeof (value)
// is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value)
) {
validatedSettings[key] = value;
} else {
await this.logMessage(
LogLevel.WARN,
"getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
);
}
}
return validatedSettings;
}
/*
* Takes an object of settings we want to change and what their values should be,
* check that their values are valid, combine them with current saved settings,
* then save the amalgamation to a state event
*
* @param protectionName Which protection these settings belong to
* @param changedSettings The settings to change and their values
*/
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<any> {
const protection = this.protections.get(protectionName);
if (protection === undefined) {
return;
}
const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName);
for (let [key, value] of Object.entries(changedSettings)) {
if (!(key in protection.settings)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
}
if (typeof (protection.settings[key].value) !== typeof (value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`);
}
if (!protection.settings[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
}
validatedSettings[key] = value;
}
await this.client.sendStateEvent(
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings
);
}
/*
* Given a protection object; add it to our list of protections, set whether it is enabled
* and update its settings with any saved non-default values.
*
* @param protection The protection object we want to register
*/
public async registerProtection(protection: Protection) {
this.protections.set(protection.name, protection)
let enabledProtections: { enabled: string[] } | null = null;
try {
enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch {
// this setting either doesn't exist, or we failed to read it (bad network?)
// TODO: retry on certain failures?
}
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
const savedSettings = await this.getProtectionSettings(protection.name);
for (let [key, value] of Object.entries(savedSettings)) {
// this.getProtectionSettings() validates this data for us, so we don't need to
protection.settings[key].setValue(value);
}
}
/*
* Given a protection object; remove it from our list of protections.
*
* @param protection The protection object we want to unregister
*/
public unregisterProtection(protectionName: string) {
if (!(protectionName in this.protections)) {
throw new Error("Failed to find protection by name: " + protectionName);
}
this.protections.delete(protectionName);
}
/** /**
* Helper for constructing `PolicyList`s and making sure they have the right listeners set up. * Helper for constructing `PolicyList`s and making sure they have the right listeners set up.
@ -668,22 +456,13 @@ export class Mjolnir {
private async addPolicyList(roomId: string, roomRef: string): Promise<PolicyList> { private async addPolicyList(roomId: string, roomRef: string): Promise<PolicyList> {
const list = new PolicyList(roomId, roomRef, this.client); const list = new PolicyList(roomId, roomRef, this.client);
this.ruleServer?.watch(list); this.ruleServer?.watch(list);
list.on('PolicyList.batch', this.syncWithPolicyList.bind(this)); list.on('PolicyList.batch', (...args) => this.protectedRoomsTracker.syncWithPolicyList(...args));
await list.updateList(); await list.updateList();
this.policyLists.push(list); this.policyLists.push(list);
this.protectedRoomsTracker.watchList(list);
return list; return list;
} }
/**
* Get a protection by name.
*
* @return If there is a protection with this name *and* it is enabled,
* return the protection.
*/
public getProtection(protectionName: string): Protection | null {
return this.protections.get(protectionName) ?? null;
}
public async watchList(roomRef: string): Promise<PolicyList | null> { public async watchList(roomRef: string): Promise<PolicyList | null> {
const joinedRooms = await this.client.getJoinedRooms(); const joinedRooms = await this.client.getJoinedRooms();
const permalink = Permalinks.parseUrl(roomRef); const permalink = Permalinks.parseUrl(roomRef);
@ -716,6 +495,7 @@ export class Mjolnir {
if (list) { if (list) {
this.policyLists.splice(this.policyLists.indexOf(list), 1); this.policyLists.splice(this.policyLists.indexOf(list), 1);
this.ruleServer?.unwatch(list); this.ruleServer?.unwatch(list);
this.protectedRoomsTracker.unwatchList(list);
} }
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
@ -741,14 +521,18 @@ export class Mjolnir {
// Ignore - probably haven't warned about it yet // Ignore - probably haven't warned about it yet
} }
await this.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId); await this.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true }); await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
} }
/**
* So this is called to retroactively remove protected rooms from Mjolnir's internal model of joined rooms.
* This is really shit and needs to be changed asap. Unacceptable even.
*/
private applyUnprotectedRooms() { private applyUnprotectedRooms() {
for (const roomId of this.unprotectedWatchedListRooms) { for (const roomId of this.unprotectedWatchedListRooms) {
delete this.protectedRooms[roomId]; delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId); this.protectedRoomsTracker.removeProtectedRoom(roomId);
} }
} }
@ -777,221 +561,6 @@ export class Mjolnir {
} }
} }
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
for (const roomId of Object.keys(this.protectedRooms)) {
errors.push(...(await this.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.client.getUserId();
const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
if (!powerLevels) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Missing power levels state event");
}
function plDefault(val: number | undefined | null, def: number): number {
if (!val && val !== 0) return def;
return val;
}
const users = powerLevels['users'] || {};
const events = powerLevels['events'] || {};
const usersDefault = plDefault(powerLevels['users_default'], 0);
const stateDefault = plDefault(powerLevels['state_default'], 50);
const ban = plDefault(powerLevels['ban'], 50);
const kick = plDefault(powerLevels['kick'], 50);
const redact = plDefault(powerLevels['redact'], 50);
const userLevel = plDefault(users[ownUserId], usersDefault);
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
// Wants: ban, kick, redact, m.room.server_acl
if (userLevel < ban) {
errors.push({
roomId,
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < kick) {
errors.push({
roomId,
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < redact) {
errors.push({
roomId,
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < aclLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
// 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));
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
}
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.
*/
public async syncLists(verbose = true) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, 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:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
const changes = await policyList.updateList();
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, 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:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors) {
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
await this.printBanlistChanges(changes, policyList, true);
}
private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) {
for (const consequence of consequences) {
try {
if (consequence.name === "alert") {
/* take no additional action, just print the below message to management room */
} else if (consequence.name === "ban") {
await this.client.banUser(sender, roomId, "abuse detected");
} else if (consequence.name === "redact") {
await this.client.redactEvent(roomId, eventId, "abuse detected");
} else {
throw new Error(`unknown consequence ${consequence.name}`);
}
let message = `protection ${protection.name} enacting`
+ ` ${consequence.name}`
+ ` against ${htmlEscape(sender)}`
+ ` in ${htmlEscape(roomId)}`
+ ` (reason: ${htmlEscape(consequence.reason)})`;
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: message,
[CONSEQUENCE_EVENT_DATA]: {
who: sender,
room: roomId,
types: [consequence.name],
}
});
} catch (e) {
await this.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`);
}
}
}
private async handleEvent(roomId: string, event: any) { private async handleEvent(roomId: string, event: any) {
// Check for UISI errors // Check for UISI errors
if (roomId === this.managementRoomId) { if (roomId === this.managementRoomId) {
@ -1014,139 +583,11 @@ export class Mjolnir {
} }
} }
if (roomId in this.protectedRooms) { if (event.sender !== this.clientUserId) {
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves this.protectedRoomsTracker.handleEvent(roomId, event);
// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
let consequences: Consequence[] | undefined = undefined;
try {
consequences = await protection.handleEvent(this, roomId, event);
} catch (e) {
const eventPermalink = Permalinks.forEvent(roomId, event['event_id']);
LogService.error("Mjolnir", "Error handling protection: " + protection.name);
LogService.error("Mjolnir", "Failed event: " + eventPermalink);
LogService.error("Mjolnir", extractRequestError(e));
await this.client.sendNotice(this.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
continue;
}
if (consequences !== undefined) {
await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this);
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
}
return;
} else if (event['type'] === "m.room.member") {
// The reason we have to apply bans on each member change is because
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await applyUserBans(this.policyLists, [roomId], this);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
}
} }
} }
/**
* Print the changes to a banlist to the management room.
* @param changes A list of changes that have been made to a particular ban list.
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);
}
if (changes.length <= 0) return false;
let html = "";
let text = "";
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
for (const change of changes) {
const rule = change.rule;
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>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
if (!logAnyways) {
errors = errors.filter(e => ErrorCache.triggerError(e.roomId, e.errorKind));
if (errors.length <= 0) {
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
return true;
}
}
let html = "";
let text = "";
const htmlTitle = title ? `${title}<br />` : '';
const textTitle = title ? `${title}\n` : '';
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const error of errors) {
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
const url = Permalinks.forRoom(alias, viaServers);
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
}
html += "</ul>";
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
public async isSynapseAdmin(): Promise<boolean> { public async isSynapseAdmin(): Promise<boolean> {
try { try {
const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`;
@ -1189,26 +630,4 @@ export class Mjolnir {
return extractRequestError(e); return extractRequestError(e);
} }
} }
public queueRedactUserMessagesIn(userId: string, roomId: string) {
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
}
/**
* Process all queued redactions, this is usually called at the end of the sync process,
* after all users have been banned and ACLs applied.
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
* We then carry on processing the next redactions.
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
* @returns The list of errors encountered, for reporting to the management room.
*/
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
return await this.eventRedactionQueue.process(this, roomId);
}
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);
}
}
} }

524
src/ProtectedRooms.ts Normal file
View File

@ -0,0 +1,524 @@
/*
Copyright 2019, 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 { LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import ManagementRoomOutput from "./ManagementRoomOutput";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import PolicyList, { ListRuleChange } from "./models/PolicyList";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { ServerAcl } from "./models/ServerAcl";
import { ProtectionManager } from "./protections/ProtectionManager";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { htmlEscape } from "./utils";
/**
* This class aims to synchronize `m.ban` rules in a set of policy lists with
* a set of rooms by applying member bans and server ACL to them.
*
* It is important to understand that the use of `m.ban` in the lists that `ProtectedRooms` watch
* are interpreted to be the final decision about whether to ban a user and are a synchronization tool.
* This is different to watching a community curated list to be informed about reputation information and then making
* some sort of decision and is not the purpose of this class (as of writing, Mjolnir does not have a way to do this, we want it to).
* The outcome of that decision process (which should take place in other components)
* will likely be whether or not to create an `m.ban` rule in a list watched by
* your protected rooms.
*
* It is also important not to tie this to the one group of rooms that a mjolnir may watch
* as in future we might want to borrow this class to represent a space https://github.com/matrix-org/mjolnir/issues/283.
*/
export class ProtectedRooms {
private protectedRooms = new Set</* room id */string>();
/**
* These are the `m.bans` we want to synchronize across this set of rooms.
*/
private policyLists: PolicyList[] = [];
/**
* Tracks the rooms so that the most recently active rooms can be synchronized first.
*/
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
/**
* This is a queue for redactions to process after mjolnir
* has finished applying ACL and bans when syncing.
*/
private readonly eventRedactionQueue = new EventRedactionQueue();
private readonly errorCache = new ErrorCache();
/**
* These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an
* `m.ban` recommendation against a user.
* If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact
* all of the messages from that user.
*/
private automaticRedactionReasons: MatrixGlob[] = [];
/**
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
* This is because requests operating with rules from an older version of the list that are slow
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
* which would cause several requests to a room to send a new m.room.server_acl event.
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
*/
private aclChain: Promise<void> = Promise.resolve();
constructor(
private readonly client: MatrixClient,
private readonly clientUserId: string,
private readonly managementRoomId: string,
private readonly managementRoomOutput: ManagementRoomOutput,
/**
* The protection manager is only used to verify the permissions
* that the protection manager requires are correct for this set of rooms.
* The protection manager is not really compatible with this abstraction yet
* because of a direct dependency on the protection manager in Mjolnir commands.
*/
private readonly protectionManager: ProtectionManager,
private readonly config: IConfig,
) {
for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker();
}
/**
* Queue a user's messages in a room for redaction once we have stopped synchronizing bans
* over the protected rooms.
*
* @param userId The user whose messages we want to redact.
* @param roomId The room we want to redact them in.
*/
public redactUser(userId: string, roomId: string) {
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
}
/**
* These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an
* `m.ban` recommendation against a user.
* If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact
* all of the messages from that user.
*/
public get automaticRedactGlobs(): Readonly<MatrixGlob[]> {
return this.automaticRedactionReasons;
}
public getProtectedRooms () {
return [...this.protectedRooms.keys()]
}
public isProtectedRoom(roomId: string): boolean {
return this.protectedRooms.has(roomId);
}
public watchList(policyList: PolicyList): void {
if (!this.policyLists.includes(policyList)) {
this.policyLists.push(policyList);
}
}
public unwatchList(policyList: PolicyList): void {
this.policyLists = this.policyLists.filter(list => list.roomId !== policyList.roomId);
}
/**
* Process all queued redactions, this is usually called at the end of the sync process,
* after all users have been banned and ACLs applied.
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
* We then carry on processing the next redactions.
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
* @returns The list of errors encountered, for reporting to the management room.
*/
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
return await this.eventRedactionQueue.process(this.client, this.managementRoomOutput, roomId);
}
/**
* @returns The protected rooms ordered by the most recently active first.
*/
public protectedRoomsByActivity(): string[] {
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
}
public async handleEvent(roomId: string, event: any) {
if (event['sender'] === this.clientUserId) {
throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir.");
}
this.protectedRoomActivityTracker.handleEvent(roomId, event);
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.protectionManager.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
}
return;
} else if (event['type'] === "m.room.member") {
// The reason we have to apply bans on each member change is because
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await this.applyUserBans(this.policyLists, [roomId]);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
}
}
/**
* 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.
*/
public async syncLists(verbose = true) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
]);
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:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
public async addProtectedRoom(roomId: string): Promise<void> {
if (this.protectedRooms.has(roomId)) {
// we need to protect ourselves form syncing all the lists unnecessarily
// as Mjolnir does call this method repeatedly.
return;
}
this.protectedRooms.add(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
await this.syncLists(this.config.verboseLogging);
}
public removeProtectedRoom(roomId: string): void {
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
this.protectedRooms.delete(roomId);
}
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
public async syncWithPolicyList(policyList: PolicyList): Promise<void> {
// this bit can move away into a listener.
const changes = await policyList.updateList();
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
]);
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:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors) {
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
await this.printBanlistChanges(changes, policyList, true);
}
/**
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL.
* @param {PolicyList[]} lists The lists to construct ACLs from.
* @param {string[]} roomIds The room IDs to apply the ACLs in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
// finish out of order and therefore leave the room out of sync with the policy lists.
return new Promise((resolve, reject) => {
this.aclChain = this.aclChain
.then(() => this._applyServerAcls(lists, roomIds))
.then(resolve, reject);
});
}
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
const serverName: string = new UserID(await this.client.getUserId()).domain;
// Construct a server ACL first
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (const list of lists) {
for (const rule of list.serverRules) {
acl.denyServer(rule.entity);
}
}
const finalAcl = acl.safeAclContent();
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
}
if (this.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML
await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
try {
const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
if (acl.matches(currentAcl)) {
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
continue;
}
} catch (e) {
// ignore - assume no ACL
}
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
if (!this.config.noop) {
await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
} else {
await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({ roomId, errorMessage: message, errorKind: kind });
}
}
return errors;
}
/**
* Applies the member bans represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* @param {PolicyList[]} lists The lists to determine bans from.
* @param {string[]} roomIds The room IDs to apply the bans in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/
private async applyUserBans(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
// We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
let members: { userId: string, membership: string }[];
if (this.config.fasterMembershipChecks) {
const memberIds = await this.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => {
return { userId: u, membership: "join" };
});
} else {
const state = await this.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
});
}
for (const member of members) {
if (member.membership === 'ban') {
continue; // user already banned
}
let banned = false;
for (const list of lists) {
for (const userRule of list.userRules) {
if (userRule.isMatch(member.userId)) {
// User needs to be banned
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoomOutput.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
if (!this.config.noop) {
await this.client.banUser(member.userId, roomId, userRule.reason);
if (this.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
this.redactUser(member.userId, roomId);
}
} else {
await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
banned = true;
break;
}
}
if (banned) break;
}
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push({
roomId,
errorMessage: message,
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
});
}
}
return errors;
}
/**
* Print the changes to a banlist to the management room.
* @param changes A list of changes that have been made to a particular ban list.
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);
}
if (changes.length <= 0) return false;
let html = "";
let text = "";
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
for (const change of changes) {
const rule = change.rule;
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>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
if (!logAnyways) {
errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind));
if (errors.length <= 0) {
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
return true;
}
}
let html = "";
let text = "";
const htmlTitle = title ? `${title}<br />` : '';
const textTitle = title ? `${title}\n` : '';
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const error of errors) {
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
const url = Permalinks.forRoom(alias, viaServers);
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
}
html += "</ul>";
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
public requiredProtectionPermissions() {
throw new TypeError("Unimplemented, need to put protections into here too.")
}
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
for (const roomId of this.protectedRooms) {
errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
}

View File

@ -1,95 +0,0 @@
/*
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
import { ServerAcl } from "../models/ServerAcl";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, UserID } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/**
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL.
* @param {PolicyList[]} lists The lists to construct ACLs from.
* @param {string[]} roomIds The room IDs to apply the ACLs in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/
export async function applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
// finish out of order and therefore leave the room out of sync with the policy lists.
return new Promise((resolve, reject) => {
mjolnir.aclChain = mjolnir.aclChain
.then(() => _applyServerAcls(lists, roomIds, mjolnir))
.then(resolve, reject);
});
}
async function _applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
// Construct a server ACL first
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (const list of lists) {
for (const rule of list.serverRules) {
acl.denyServer(rule.entity);
}
}
const finalAcl = acl.safeAclContent();
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
}
if (mjolnir.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
try {
const currentAcl = await mjolnir.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
if (acl.matches(currentAcl)) {
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
continue;
}
} catch (e) {
// ignore - assume no ACL
}
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
} else {
await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({ roomId, errorMessage: message, errorKind: kind });
}
}
return errors;
}

View File

@ -1,93 +0,0 @@
/*
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/**
* Applies the member bans represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* @param {PolicyList[]} lists The lists to determine bans from.
* @param {string[]} roomIds The room IDs to apply the bans in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/
export async function applyUserBans(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
// We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
let members: { userId: string, membership: string }[];
if (mjolnir.config.fasterMembershipChecks) {
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => {
return { userId: u, membership: "join" };
});
} else {
const state = await mjolnir.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
});
}
for (const member of members) {
if (member.membership === 'ban') {
continue; // user already banned
}
let banned = false;
for (const list of lists) {
for (const userRule of list.userRules) {
if (userRule.isMatch(member.userId)) {
// User needs to be banned
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(member.userId, roomId, userRule.reason);
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
mjolnir.queueRedactUserMessagesIn(member.userId, roomId);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
banned = true;
break;
}
}
if (banned) break;
}
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push({
roomId,
errorMessage: message,
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
});
}
}
return errors;
}

View File

@ -32,7 +32,7 @@ export async function execRemoveProtectedRoom(roomId: string, event: any, mjolni
await mjolnir.client.leaveRoom(protectedRoomId); await mjolnir.client.leaveRoom(protectedRoomId);
} catch (e) { } catch (e) {
LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e)); LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e));
await mjolnir.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId);
} }
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
} }

View File

@ -22,7 +22,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
let force = false; let force = false;
const glob = parts[2]; const glob = parts[2];
let rooms = [...Object.keys(mjolnir.protectedRooms)]; let rooms = mjolnir.protectedRoomsTracker.getProtectedRooms();
if (parts[parts.length - 1] === "--force") { if (parts[parts.length - 1] === "--force") {
force = true; force = true;
@ -57,7 +57,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
const victim = member.membershipFor; const victim = member.membershipFor;
if (kickRule.test(victim)) { if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
try { try {
@ -65,10 +65,10 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
return mjolnir.client.kickUser(victim, protectedRoomId, reason); return mjolnir.client.kickUser(victim, protectedRoomId, reason);
}); });
} catch (e) { } catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
} }
} else { } 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.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
} }
} }
} }

View File

@ -15,18 +15,19 @@ limitations under the License.
*/ */
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk"; import { Permalinks, RichReply } from "matrix-bot-sdk";
// !mjolnir rooms // !mjolnir rooms
export async function execListProtectedRooms(roomId: string, event: any, mjolnir: Mjolnir) { export async function execListProtectedRooms(roomId: string, event: any, mjolnir: Mjolnir) {
let html = `<b>Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):</b><br/><ul>`; const rooms = mjolnir.protectedRoomsTracker.getProtectedRooms();
let text = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):\n`; let html = `<b>Protected rooms (${rooms.length}):</b><br/><ul>`;
let text = `Protected rooms (${rooms.length}):\n`;
let hasRooms = false; let hasRooms = false;
for (const protectedRoomId in mjolnir.protectedRooms) { for (const protectedRoomId in rooms) {
hasRooms = true; hasRooms = true;
const roomUrl = mjolnir.protectedRooms[protectedRoomId]; const roomUrl = Permalinks.forRoom(protectedRoomId);
html += `<li><a href="${roomUrl}">${protectedRoomId}</a></li>`; html += `<li><a href="${roomUrl}">${protectedRoomId}</a></li>`;
text += `* ${roomUrl}\n`; text += `* ${roomUrl}\n`;
} }

View File

@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir";
// !mjolnir verify // !mjolnir verify
export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) { export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) {
return mjolnir.verifyPermissions(true, true); return mjolnir.protectedRoomsTracker.verifyPermissions(true, true);
} }

View File

@ -22,7 +22,7 @@ import { isListSetting } from "../protections/ProtectionSettings";
// !mjolnir enable <protection> // !mjolnir enable <protection>
export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
try { try {
await mjolnir.enableProtection(parts[2]); await mjolnir.protectionManager.enableProtection(parts[2]);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
} catch (e) { } catch (e) {
LogService.error("ProtectionsCommands", extractRequestError(e)); LogService.error("ProtectionsCommands", extractRequestError(e));
@ -50,8 +50,8 @@ enum ConfigAction {
*/ */
async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise<string> { async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise<string> {
const [protectionName, ...settingParts] = parts[0].split("."); const [protectionName, ...settingParts] = parts[0].split(".");
const protection = mjolnir.protections.get(protectionName); const protection = mjolnir.protectionManager.getProtection(protectionName);
if (protection === undefined) { if (!protection) {
return `Unknown protection ${protectionName}`; return `Unknown protection ${protectionName}`;
} }
@ -83,7 +83,7 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac
} }
try { try {
await mjolnir.setProtectionSettings(protectionName, { [settingName]: value }); await mjolnir.protectionManager.setProtectionSettings(protectionName, { [settingName]: value });
} catch (e) { } catch (e) {
return `Failed to set setting: ${e.message}`; return `Failed to set setting: ${e.message}`;
} }
@ -139,7 +139,7 @@ export async function execConfigRemoveProtection(roomId: string, event: any, mjo
* !mjolnir get [protection name] * !mjolnir get [protection name]
*/ */
export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let pickProtections = Object.keys(mjolnir.protections); let pickProtections = Object.keys(mjolnir.protectionManager.protections);
if (parts.length < 3) { if (parts.length < 3) {
// no specific protectionName provided, show all of them. // no specific protectionName provided, show all of them.
@ -163,7 +163,7 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni
let anySettings = false; let anySettings = false;
for (const protectionName of pickProtections) { for (const protectionName of pickProtections) {
const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {}; const protectionSettings = mjolnir.protectionManager.getProtection(protectionName)?.settings ?? {};
if (Object.keys(protectionSettings).length === 0) { if (Object.keys(protectionSettings).length === 0) {
continue; continue;
@ -196,18 +196,18 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni
// !mjolnir disable <protection> // !mjolnir disable <protection>
export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
await mjolnir.disableProtection(parts[2]); await mjolnir.protectionManager.disableProtection(parts[2]);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
} }
// !mjolnir protections // !mjolnir protections
export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const enabledProtections = mjolnir.enabledProtections.map(p => p.name); const enabledProtections = mjolnir.protectionManager.enabledProtections.map(p => p.name);
let html = "Available protections:<ul>"; let html = "Available protections:<ul>";
let text = "Available protections:\n"; let text = "Available protections:\n";
for (const [protectionName, protection] of mjolnir.protections) { for (const [protectionName, protection] of mjolnir.protectionManager.protections) {
const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)'; const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)';
html += `<li>${emoji} <code>${protectionName}</code> - ${protection.description}</li>`; html += `<li>${emoji} <code>${protectionName}</code> - ${protection.description}</li>`;
text += `* ${emoji} ${protectionName} - ${protection.description}\n`; text += `* ${emoji} ${protectionName} - ${protection.description}\n`;

View File

@ -45,8 +45,8 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo
return; return;
} }
const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); const targetRoomIds = roomAlias ? [roomAlias] : mjolnir.protectedRoomsTracker.getProtectedRooms();
await redactUserMessagesIn(mjolnir, userId, targetRoomIds, limit); await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing');

View File

@ -23,14 +23,14 @@ export async function execSetPowerLevelCommand(roomId: string, event: any, mjoln
const level = Math.round(Number(parts[3])); const level = Math.round(Number(parts[3]));
const inRoom = parts[4]; const inRoom = parts[4];
let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : Object.keys(mjolnir.protectedRooms); let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : mjolnir.protectedRoomsTracker.getProtectedRooms();
for (const targetRoomId of targetRooms) { for (const targetRoomId of targetRooms) {
try { try {
await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level); await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level);
} catch (e) { } catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>'); const message = e.message || (e.body ? e.body.error : '<no message>');
await mjolnir.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId);
LogService.error("SetPowerLevelCommand", extractRequestError(e)); LogService.error("SetPowerLevelCommand", extractRequestError(e));
} }
} }

View File

@ -100,7 +100,7 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj
let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens); let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens);
if ("error" in result) { if ("error" in result) {
mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌');
mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error); mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", result.error);
const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error)); const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error));
reply["msgtype"] = "m.notice"; reply["msgtype"] = "m.notice";
/* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply); /* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply);
@ -185,6 +185,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
// Now list affected rooms. // Now list affected rooms.
const rooms: Set</* room id */string> = new Set(); const rooms: Set</* room id */string> = new Set();
let reasonParts: string[] | undefined; let reasonParts: string[] | undefined;
const protectedRooms = new Set(mjolnir.protectedRoomsTracker.getProtectedRooms());
for (let token of optionalTokens) { for (let token of optionalTokens) {
const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token); const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token);
if ("error" in maybeArg) { if ("error" in maybeArg) {
@ -194,14 +195,14 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
if (!reasonParts) { if (!reasonParts) {
// If we haven't reached the reason yet, attempt to use `maybeRoom` as a room. // If we haven't reached the reason yet, attempt to use `maybeRoom` as a room.
if (maybeRoom === "*") { if (maybeRoom === "*") {
for (let roomId of Object.keys(mjolnir.protectedRooms)) { for (let roomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) {
rooms.add(roomId); rooms.add(roomId);
} }
continue; continue;
} else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) { } else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) {
const roomId = await mjolnir.client.resolveRoom(maybeRoom); const roomId = await mjolnir.client.resolveRoom(maybeRoom);
if (!(roomId in mjolnir.protectedRooms)) { if (!protectedRooms.has(roomId)) {
return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); return mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`);
} }
rooms.add(roomId); rooms.add(roomId);
continue; continue;

View File

@ -91,7 +91,7 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) {
async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const protectionName = parts[0]; const protectionName = parts[0];
const protection = mjolnir.getProtection(protectionName); const protection = mjolnir.protectionManager.getProtection(protectionName);
let text; let text;
let html; let html;
if (!protection) { if (!protection) {

View File

@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir";
// !mjolnir sync // !mjolnir sync
export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) { export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) {
return mjolnir.syncLists(); return mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
} }

View File

@ -131,21 +131,21 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (USER_RULE_TYPES.includes(bits.ruleType!) && bits.reason === 'true') { if (USER_RULE_TYPES.includes(bits.ruleType!) && bits.reason === 'true') {
const rule = new MatrixGlob(bits.entity); const rule = new MatrixGlob(bits.entity);
await mjolnir.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity);
let unbannedSomeone = false; let unbannedSomeone = false;
for (const protectedRoomId of Object.keys(mjolnir.protectedRooms)) { for (const protectedRoomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined); const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined);
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`); await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`);
for (const member of members) { for (const member of members) {
const victim = member.membershipFor; const victim = member.membershipFor;
if (member.membership !== 'ban') continue; if (member.membership !== 'ban') continue;
if (rule.test(victim)) { if (rule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.unbanUser(victim, protectedRoomId); await mjolnir.client.unbanUser(victim, protectedRoomId);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId);
} }
unbannedSomeone = true; unbannedSomeone = true;
@ -154,8 +154,8 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
} }
if (unbannedSomeone) { if (unbannedSomeone) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
await mjolnir.syncLists(mjolnir.config.verboseLogging); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
} }
} }

View File

@ -62,11 +62,11 @@ export class BasicFlooding extends Protection {
} }
if (messageCount >= this.settings.maxPerMinute.value) { if (messageCount >= this.settings.maxPerMinute.value) {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam"); await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted) if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted)
@ -79,7 +79,7 @@ export class BasicFlooding extends Protection {
await mjolnir.client.redactEvent(roomId, eventId, "spam"); await mjolnir.client.redactEvent(roomId, eventId, "spam");
} }
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
// Free up some memory now that we're ready to handle it elsewhere // Free up some memory now that we're ready to handle it elsewhere

View File

@ -627,7 +627,7 @@ export class DetectFederationLag extends Protection {
roomInfo.latestAlertStart = now; roomInfo.latestAlertStart = now;
// Background-send message. // Background-send message.
const stats = roomInfo.globalStats(); const stats = roomInfo.globalStats();
/* do not await */ mjolnir.logMessage(LogLevel.WARN, "FederationLag", /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FederationLag",
`Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`); `Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`);
// Drop a state event, for the use of potential other bots. // Drop a state event, for the use of potential other bots.
const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, { const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, {
@ -642,7 +642,7 @@ export class DetectFederationLag extends Protection {
} else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value } else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value
|| !isLocalDomainOnAlert) { || !isLocalDomainOnAlert) {
// Stop the alarm! // Stop the alarm!
/* do not await */ mjolnir.logMessage(LogLevel.INFO, "FederationLag", /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "FederationLag",
`Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging` `Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging`
); );
if (roomInfo.warnStateEventId) { if (roomInfo.warnStateEventId) {

View File

@ -56,11 +56,11 @@ export class FirstMessageIsImage extends Protection {
const formattedBody = content['formatted_body'] || ''; const formattedBody = content['formatted_body'] || '';
const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img'); const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img');
if (isMedia && this.justJoined[roomId].includes(event['sender'])) { if (isMedia && this.justJoined[roomId].includes(event['sender'])) {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`);
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam"); await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted) if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted)
@ -71,7 +71,7 @@ export class FirstMessageIsImage extends Protection {
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam"); await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
} }
} }

View File

@ -56,7 +56,7 @@ export class JoinWaveShortCircuit extends Protection {
return; return;
} }
if (!(roomId in mjolnir.protectedRooms)) { if (!mjolnir.protectedRoomsTracker.isProtectedRoom(roomId)) {
// Not a room we are watching. // Not a room we are watching.
return; return;
} }
@ -86,12 +86,12 @@ export class JoinWaveShortCircuit extends Protection {
} }
if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) { 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); await mjolnir.managementRoomOutput.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 (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"}) await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
} }
} }
} }

View File

@ -40,12 +40,12 @@ export class MessageIsMedia extends Protection {
const formattedBody = content['formatted_body'] || ''; const formattedBody = content['formatted_body'] || '';
const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img'); const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img');
if (isMedia) { if (isMedia) {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
// Redact the event // Redact the event
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Images/videos are not permitted here"); await mjolnir.client.redactEvent(roomId, event['event_id'], "Images/videos are not permitted here");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
} }
} }

View File

@ -37,12 +37,12 @@ export class MessageIsVoice extends Protection {
if (event['type'] === 'm.room.message' && event['content']) { if (event['type'] === 'm.room.message' && event['content']) {
if (event['content']['msgtype'] !== 'm.audio') return; if (event['content']['msgtype'] !== 'm.audio') return;
if (event['content']['org.matrix.msc3245.voice'] === undefined) return; if (event['content']['org.matrix.msc3245.voice'] === undefined) return;
await mjolnir.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
// Redact the event // Redact the event
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here"); await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
} }
} }

View File

@ -0,0 +1,397 @@
/*
Copyright 2019 - 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 { FirstMessageIsImage } from "./FirstMessageIsImage";
import { Protection } from "./IProtection";
import { BasicFlooding } from "./BasicFlooding";
import { DetectFederationLag } from "./DetectFederationLag";
import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia";
import { TrustedReporters } from "./TrustedReporters";
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
import { Mjolnir } from "../Mjolnir";
import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk";
import { ProtectionSettingValidationError } from "./ProtectionSettings";
import { Consequence } from "./consequence";
import { htmlEscape } from "../utils";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
new BasicFlooding(),
new WordList(),
new MessageIsVoice(),
new MessageIsMedia(),
new TrustedReporters(),
new DetectFederationLag(),
new JoinWaveShortCircuit(),
];
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
/**
* This is responsible for informing protections about relevant events and handle standard consequences.
*/
export class ProtectionManager {
private _protections = new Map<string /* protection name */, Protection>();
get protections(): Readonly<Map<string /* protection name */, Protection>> {
return this._protections;
}
constructor(private readonly mjolnir: Mjolnir) {
}
/*
* Take all the builtin protections, register them to set their enabled (or not) state and
* update their settings with any saved non-default values
*/
public async start() {
this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this));
this.mjolnir.client.on("room.event", this.handleEvent.bind(this));
for (const protection of PROTECTIONS) {
try {
await this.registerProtection(protection);
} catch (e) {
this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e));
}
}
}
/**
* Given a protection object; add it to our list of protections, set it up if it has been enabled previously (in account data)
* and update its settings with any saved non-default values. See `ENABLED_PROTECTIONS_EVENT_TYPE`.
*
* @param protection The protection object we want to register
*/
public async registerProtection(protection: Protection) {
this._protections.set(protection.name, protection)
let enabledProtections: { enabled: string[] } | null = null;
try {
enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch {
// this setting either doesn't exist, or we failed to read it (bad network?)
// TODO: retry on certain failures?
}
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
const savedSettings = await this.getProtectionSettings(protection.name);
for (let [key, value] of Object.entries(savedSettings)) {
// this.getProtectionSettings() validates this data for us, so we don't need to
protection.settings[key].setValue(value);
}
}
/*
* Given a protection object; remove it from our list of protections.
*
* @param protection The protection object we want to unregister
*/
public unregisterProtection(protectionName: string) {
if (!(this._protections.has(protectionName))) {
throw new Error("Failed to find protection by name: " + protectionName);
}
this._protections.delete(protectionName);
}
/*
* Takes an object of settings we want to change and what their values should be,
* check that their values are valid, combine them with current saved settings,
* then save the amalgamation to a state event
*
* @param protectionName Which protection these settings belong to
* @param changedSettings The settings to change and their values
*/
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<any> {
const protection = this._protections.get(protectionName);
if (protection === undefined) {
return;
}
const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName);
for (let [key, value] of Object.entries(changedSettings)) {
if (!(key in protection.settings)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
}
if (typeof (protection.settings[key].value) !== typeof (value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`);
}
if (!protection.settings[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
}
validatedSettings[key] = value;
}
await this.mjolnir.client.sendStateEvent(
this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings
);
}
/*
* Make a list of the names of enabled protections and save them in a state event
*/
private async saveEnabledProtections() {
const protections = this.enabledProtections.map(p => p.name);
await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
}
/*
* Enable a protection by name and persist its enable state in to a state event
*
* @param name The name of the protection whose settings we're enabling
*/
public async enableProtection(name: string) {
const protection = this._protections.get(name);
if (protection !== undefined) {
protection.enabled = true;
await this.saveEnabledProtections();
}
}
public get enabledProtections(): Protection[] {
return [...this._protections.values()].filter(p => p.enabled);
}
/**
* Get a protection by name.
*
* @return If there is a protection with this name *and* it is enabled,
* return the protection.
*/
public getProtection(protectionName: string): Protection | null {
return this._protections.get(protectionName) ?? null;
}
/*
* Disable a protection by name and remove it from the persistent list of enabled protections
*
* @param name The name of the protection whose settings we're disabling
*/
public async disableProtection(name: string) {
const protection = this._protections.get(name);
if (protection !== undefined) {
protection.enabled = false;
await this.saveEnabledProtections();
}
}
/*
* Read org.matrix.mjolnir.setting state event, find any saved settings for
* the requested protectionName, then iterate and validate against their parser
* counterparts in Protection.settings and return those which validate
*
* @param protectionName The name of the protection whose settings we're reading
* @returns Every saved setting for this protectionName that has a valid value
*/
public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> {
let savedSettings: { [setting: string]: any } = {}
try {
savedSettings = await this.mjolnir.client.getRoomStateEvent(
this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName
);
} catch {
// setting does not exist, return empty object
return {};
}
const settingDefinitions = this._protections.get(protectionName)?.settings ?? {};
const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) {
if (
// is this a setting name with a known parser?
key in settingDefinitions
// is the datatype of this setting's value what we expect?
&& typeof (settingDefinitions[key].value) === typeof (value)
// is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value)
) {
validatedSettings[key] = value;
} else {
await this.mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN,
"getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
);
}
}
return validatedSettings;
}
private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) {
for (const consequence of consequences) {
try {
if (consequence.name === "alert") {
/* take no additional action, just print the below message to management room */
} else if (consequence.name === "ban") {
await this.mjolnir.client.banUser(sender, roomId, "abuse detected");
} else if (consequence.name === "redact") {
await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected");
} else {
throw new Error(`unknown consequence ${consequence.name}`);
}
let message = `protection ${protection.name} enacting`
+ ` ${consequence.name}`
+ ` against ${htmlEscape(sender)}`
+ ` in ${htmlEscape(roomId)}`
+ ` (reason: ${htmlEscape(consequence.reason)})`;
await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, {
msgtype: "m.notice",
body: message,
[CONSEQUENCE_EVENT_DATA]: {
who: sender,
room: roomId,
types: [consequence.name],
}
});
} catch (e) {
await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`);
}
}
}
private async handleEvent(roomId: string, event: any) {
if (this.mjolnir.protectedRoomsTracker.getProtectedRooms().includes(roomId)) {
if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves
// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
let consequences: Consequence[] | undefined = undefined;
try {
consequences = await protection.handleEvent(this.mjolnir, roomId, event);
} catch (e) {
const eventPermalink = Permalinks.forEvent(roomId, event['event_id']);
LogService.error("ProtectionManager", "Error handling protection: " + protection.name);
LogService.error("ProtectionManager", "Failed event: " + eventPermalink);
LogService.error("ProtectionManager", extractRequestError(e));
await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
continue;
}
if (consequences !== undefined) {
await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.mjolnir.unlistedUserRedactionHandler.handleEvent(roomId, event, this.mjolnir); // FIXME: That's rather spaghetti
}
}
private requiredProtectionPermissions(): Set<string> {
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
}
public async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.mjolnir.client.getUserId();
const powerLevels = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
if (!powerLevels) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Missing power levels state event");
}
function plDefault(val: number | undefined | null, def: number): number {
if (!val && val !== 0) return def;
return val;
}
const users = powerLevels['users'] || {};
const events = powerLevels['events'] || {};
const usersDefault = plDefault(powerLevels['users_default'], 0);
const stateDefault = plDefault(powerLevels['state_default'], 50);
const ban = plDefault(powerLevels['ban'], 50);
const kick = plDefault(powerLevels['kick'], 50);
const redact = plDefault(powerLevels['redact'], 50);
const userLevel = plDefault(users[ownUserId], usersDefault);
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
// Wants: ban, kick, redact, m.room.server_acl
if (userLevel < ban) {
errors.push({
roomId,
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < kick) {
errors.push({
roomId,
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < redact) {
errors.push({
roomId,
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < aclLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
// 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));
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
}
return errors;
}
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.mjolnir, roomId, reporterId, event, reason);
}
}
}

View File

@ -92,7 +92,7 @@ export class WordList extends Protection {
"i" "i"
); );
} catch (ex) { } catch (ex) {
await mjolnir.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`) await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`)
} }
} }

View File

@ -1,36 +0,0 @@
/*
Copyright 2019 - 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 { FirstMessageIsImage } from "./FirstMessageIsImage";
import { Protection } from "./IProtection";
import { BasicFlooding } from "./BasicFlooding";
import { DetectFederationLag } from "./DetectFederationLag";
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(),
new BasicFlooding(),
new WordList(),
new MessageIsVoice(),
new MessageIsMedia(),
new TrustedReporters(),
new DetectFederationLag(),
new JoinWaveShortCircuit(),
];

View File

@ -13,11 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { LogLevel } from "matrix-bot-sdk" import { LogLevel, MatrixClient } from "matrix-bot-sdk"
import { ERROR_KIND_FATAL } from "../ErrorCache"; import { ERROR_KIND_FATAL } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { redactUserMessagesIn } from "../utils"; import { redactUserMessagesIn } from "../utils";
import { Mjolnir } from "../Mjolnir"; import ManagementRoomOutput from "../ManagementRoomOutput";
export interface QueuedRedaction { export interface QueuedRedaction {
/** The room which the redaction will take place in. */ /** The room which the redaction will take place in. */
@ -27,7 +27,7 @@ export interface QueuedRedaction {
* Called by the EventRedactionQueue. * Called by the EventRedactionQueue.
* @param client A MatrixClient to use to carry out the redaction. * @param client A MatrixClient to use to carry out the redaction.
*/ */
redact(mjolnir: Mjolnir): Promise<void> redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise<void>
/** /**
* Used to test whether the redaction is the equivalent to another redaction. * Used to test whether the redaction is the equivalent to another redaction.
* @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to.
@ -47,9 +47,9 @@ export class RedactUserInRoom implements QueuedRedaction {
this.roomId = roomId; this.roomId = roomId;
} }
public async redact(mjolnir: Mjolnir) { public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) {
await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`); await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]); await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]);
} }
public redactionEqual(redaction: QueuedRedaction): boolean { public redactionEqual(redaction: QueuedRedaction): boolean {
@ -107,12 +107,12 @@ export class EventRedactionQueue {
* @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed.
* @returns A description of any errors encountered by each QueuedRedaction that was processed. * @returns A description of any errors encountered by each QueuedRedaction that was processed.
*/ */
public async process(mjolnir: Mjolnir, limitToRoomId?: string): Promise<RoomUpdateError[]> { public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = []; const errors: RoomUpdateError[] = [];
const redact = async (currentBatch: QueuedRedaction[]) => { const redact = async (currentBatch: QueuedRedaction[]) => {
for (const redaction of currentBatch) { for (const redaction of currentBatch) {
try { try {
await redaction.redact(mjolnir); await redaction.redact(client, managementRoom);
} catch (e) { } catch (e) {
let roomError: RoomUpdateError; let roomError: RoomUpdateError;
if (e.roomId && e.errorMessage && e.errorKind) { if (e.roomId && e.errorMessage && e.errorKind) {

View File

@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient } from "matrix-bot-sdk";
/** /**
* Used to keep track of protected rooms so they are always ordered for activity. * Used to keep track of protected rooms so they are always ordered for activity.
@ -29,9 +28,6 @@ export class ProtectedRoomActivityTracker {
* A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first.
*/ */
private activeRoomsCache: null|string[] = null 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. * Inform the tracker that a new room is being protected by Mjolnir.
@ -55,6 +51,7 @@ export class ProtectedRoomActivityTracker {
* Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated * 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 roomId The room the new event is in.
* @param event The new event. * @param event The new event.
*
*/ */
public handleEvent(roomId: string, event: any): void { public handleEvent(roomId: string, event: any): void {
const last_origin_server_ts = this.protectedRoomActivities.get(roomId); const last_origin_server_ts = this.protectedRoomActivities.get(roomId);

View File

@ -178,7 +178,7 @@ export class ThrottlingQueue {
try { try {
await task(); await task();
} catch (ex) { } catch (ex) {
await this.mjolnir.logMessage( await this.mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN, LogLevel.WARN,
'Error while executing task', 'Error while executing task',
extractRequestError(ex) extractRequestError(ex)

View File

@ -45,10 +45,10 @@ export class UnlistedUserRedactionQueue {
if (!mjolnir.config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id']); await mjolnir.client.redactEvent(roomId, event['event_id']);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`);
} }
} catch (e) { } catch (e) {
mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`);
LogService.warn("AutomaticRedactionQueue", extractRequestError(e)); LogService.warn("AutomaticRedactionQueue", extractRequestError(e));
} }
} }

View File

@ -75,13 +75,13 @@ export class ReportPoller {
} }
); );
} catch (ex) { } catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
return; return;
} }
const response = response_!; const response = response_!;
for (let report of response.event_reports) { for (let report of response.event_reports) {
if (!(report.room_id in this.mjolnir.protectedRooms)) { if (!this.mjolnir.protectedRoomsTracker.isProtectedRoom(report.room_id)) {
continue; continue;
} }
@ -92,7 +92,7 @@ export class ReportPoller {
`/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1`
)).event; )).event;
} catch (ex) { } catch (ex) {
this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`);
continue; continue;
} }
@ -114,7 +114,7 @@ export class ReportPoller {
try { try {
await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token });
} catch (ex) { } catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
} }
} }
} }
@ -125,7 +125,7 @@ export class ReportPoller {
try { try {
await this.getAbuseReports() await this.getAbuseReports()
} catch (ex) { } catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
} }
this.schedulePoll(); this.schedulePoll();

View File

@ -15,21 +15,16 @@ limitations under the License.
*/ */
import { import {
extractRequestError,
LogLevel, LogLevel,
LogService, LogService,
MatrixClient, MatrixClient,
MatrixGlob, MatrixGlob,
MessageType,
Permalinks,
TextualMessageEventContent,
UserID,
getRequestFn, getRequestFn,
setRequestFn, setRequestFn,
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import { Mjolnir } from "./Mjolnir";
import { ClientRequest, IncomingMessage } from "http"; import { ClientRequest, IncomingMessage } from "http";
import { default as parseDuration } from "parse-duration"; import { default as parseDuration } from "parse-duration";
import ManagementRoomOutput from "./ManagementRoomOutput";
// Define a few aliases to simplify parsing durations. // Define a few aliases to simplify parsing durations.
@ -70,17 +65,29 @@ export function isTrueJoinEvent(event: any): boolean {
return membership === 'join' && prevMembership !== "join"; return membership === 'join' && prevMembership !== "join";
} }
export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) { /**
* Redact a user's messages in a set of rooms.
* See `getMessagesByUserIn`.
*
* @param client Client to redact the messages with.
* @param managementRoom Management room to log messages back to.
* @param userIdOrGlob A mxid or a glob which is applied to the whole sender field of events in the room, which will be redacted if they match.
* See `MatrixGlob` in matrix-bot-sdk.
* @param targetRoomIds Rooms to redact the messages from.
* @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted.
* @param noop Whether to operate in noop mode.
*/
export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) {
for (const targetRoomId of targetRoomIds) { for (const targetRoomId of targetRoomIds) {
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
for (const victimEvent of eventsToRedact) { for (const victimEvent of eventsToRedact) {
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId); await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
if (!mjolnir.config.noop) { if (!noop) {
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']); await client.redactEvent(targetRoomId, victimEvent['event_id']);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
} }
} }
}); });
@ -189,51 +196,6 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
} while (token && processed < limit) } while (token && processed < limit)
} }
/*
* Take an arbitrary string and a set of room IDs, and return a
* TextualMessageEventContent whose plaintext component replaces those room
* IDs with their canonical aliases, and whose html component replaces those
* room IDs with their matrix.to room pills.
*
* @param client The matrix client on which to query for room aliases
* @param text An arbitrary string to rewrite with room aliases and pills
* @param roomIds A set of room IDs to find and replace in `text`
* @param msgtype The desired message type of the returned TextualMessageEventContent
* @returns A TextualMessageEventContent with replaced room IDs
*/
export async function replaceRoomIdsWithPills(mjolnir: Mjolnir, text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
const content: TextualMessageEventContent = {
body: text,
formatted_body: htmlEscape(text),
msgtype: msgtype,
format: "org.matrix.custom.html",
};
const escapeRegex = (v: string): string => {
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};
const viaServers = [(new UserID(await mjolnir.client.getUserId())).domain];
for (const roomId of roomIds) {
let alias = roomId;
try {
alias = (await mjolnir.client.getPublishedAlias(roomId)) || roomId;
} catch (e) {
// This is a recursive call, so tell the function not to try and call us
await mjolnir.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
LogService.warn("utils", extractRequestError(e));
}
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
content.body = content.body.replace(regexRoomId, alias);
if (content.formatted_body) {
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
}
}
return content;
}
let isMatrixClientPatchedForConciseExceptions = false; let isMatrixClientPatchedForConciseExceptions = false;
/** /**

View File

@ -24,8 +24,8 @@ describe("Test: Reporting abuse", async () => {
this.timeout(60000); this.timeout(60000);
// Listen for any notices that show up. // Listen for any notices that show up.
let notices = []; let notices: any[] = [];
matrixClient().on("room.event", (roomId, event) => { matrixClient()!.on("room.event", (roomId, event) => {
if (roomId = this.mjolnir.managementRoomId) { if (roomId = this.mjolnir.managementRoomId) {
notices.push(event); notices.push(event);
} }

View File

@ -247,7 +247,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
} }
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
await Promise.all(protectedRooms.map(async room => { await Promise.all(protectedRooms.map(async room => {
// We're going to need timeline pagination I'm afraid. // We're going to need timeline pagination I'm afraid.
const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", ""); const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "");
@ -269,7 +269,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
} }
// We do this because it should force us to wait until all the ACL events have been applied. // 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... // Even if that does mean the last few events will not go through batching...
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following // At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following
// is a pita. // is a pita.
@ -300,7 +300,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
it('Will remove rules that have legacy types', async function() { it('Will remove rules that have legacy types', async function() {
const mjolnir: Mjolnir = this.mjolnir! const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); const moderator: MatrixClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator; this.moderator = moderator;
await moderator.joinRoom(mjolnir.managementRoomId); await moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.client.getUserId(); const mjolnirId = await mjolnir.client.getUserId();
@ -312,7 +312,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
await mjolnir.addProtectedRoom(protectedRoom); await mjolnir.addProtectedRoom(protectedRoom);
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// If this is not present, then it means the room isn't being protected, which is really bad. // If this is not present, then it means the room isn't being protected, which is really bad.
const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
@ -333,7 +333,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.'); await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.');
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1"); await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1");
// Wait for the ACL event to be applied to our protected room. // Wait for the ACL event to be applied to our protected room.
await this.mjolnir!.syncLists(); await mjolnir.protectedRoomsTracker.syncLists();
await banList.updateList(); await banList.updateList();
// rules are normalized by rule type, that's why there should only be 3. // rules are normalized by rule type, that's why there should only be 3.
@ -359,7 +359,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
} }
// Wait for mjolnir to sync protected rooms to update ACL. // Wait for mjolnir to sync protected rooms to update ACL.
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// Confirm that the server is unbanned. // Confirm that the server is unbanned.
await banList.updateList(); await banList.updateList();
assert.equal(banList.allRules.length, 0); assert.equal(banList.allRules.length, 0);
@ -388,7 +388,7 @@ describe('Test: should apply bans to the most recently active rooms first', func
} }
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
await Promise.all(protectedRooms.map(async room => { await Promise.all(protectedRooms.map(async room => {
const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e)); const roomAcl = await mjolnir.client.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.'); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
@ -399,7 +399,7 @@ describe('Test: should apply bans to the most recently active rooms first', func
await mjolnir.client.joinRoom(banListId); await mjolnir.client.joinRoom(banListId);
await mjolnir.watchList(Permalinks.forRoom(banListId)); await mjolnir.watchList(Permalinks.forRoom(banListId));
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them. // 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--) { for (let i = protectedRooms.length - 1; i > 0; i--) {
@ -408,25 +408,38 @@ describe('Test: should apply bans to the most recently active rooms first', func
} }
// create some activity in the same order. // create some activity in the same order.
for (const roomId of protectedRooms.slice().reverse()) { for (const roomId of protectedRooms.slice().reverse()) {
await mjolnir.client.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' }); await moderator.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' });
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
} }
// check the rooms are in the expected order // check the rooms are in the expected order
for (let i = 0; i < protectedRooms.length; i++) { for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms[i]); assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms[i]);
} }
const badServer = `evil.com`;
// just ban one server // just ban one server
const badServer = `evil.com`;
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer); const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer);
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); // collect all the rooms that received an ACL event.
const aclRooms: any[] = await new Promise(async resolve => {
const rooms: any[] = [];
this.mjolnir.client.on('room.event', (room: string, event: any) => {
if (protectedRooms.includes(room)) {
rooms.push(room);
}
if (rooms.length === protectedRooms.length) {
resolve(rooms)
}
});
// create the rule that will ban the server.
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
})
// Wait until all the ACL events have been applied. // Wait until all the ACL events have been applied.
await mjolnir.syncLists(); await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
for (let i = 0; i < protectedRooms.length; i++) { for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); assert.equal(aclRooms[i], protectedRooms[i], "The ACL should have been applied to the active rooms first.");
} }
// Check that the most recently active rooms got the ACL update first. // Check that the most recently active rooms got the ACL update first.

View File

@ -24,8 +24,8 @@ describe("Test: DetectFederationLag protection", function() {
beforeEach(async function() { beforeEach(async function() {
// Setup an instance of DetectFederationLag // Setup an instance of DetectFederationLag
this.detector = new DetectFederationLag(); this.detector = new DetectFederationLag();
await this.mjolnir.registerProtection(this.detector); await this.mjolnir.protectionManager.registerProtection(this.detector);
await this.mjolnir.enableProtection("DetectFederationLag"); await this.mjolnir.protectionManager.enableProtection("DetectFederationLag");
// Setup a moderator. // Setup a moderator.
this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });

View File

@ -2,7 +2,6 @@ import { strict as assert } from "assert";
import { Mjolnir } from "../../src/Mjolnir"; import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection"; import { IProtection } from "../../src/protections/IProtection";
import { PROTECTIONS } from "../../src/protections/protections";
import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings";
import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings";
import { newTestUser, noticeListener } from "./clientHelper"; import { newTestUser, noticeListener } from "./clientHelper";
@ -20,29 +19,29 @@ describe("Test: Protection settings", function() {
it("Mjolnir refuses to save invalid protection setting values", async function() { it("Mjolnir refuses to save invalid protection setting values", async function() {
this.timeout(20000); this.timeout(20000);
await assert.rejects( await assert.rejects(
async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}), async () => await this.mjolnir.protectionManager.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}),
ProtectionSettingValidationError ProtectionSettingValidationError
); );
}); });
it("Mjolnir successfully saves valid protection setting values", async function() { it("Mjolnir successfully saves valid protection setting values", async function() {
this.timeout(20000); this.timeout(20000);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "05OVMS"; name = "05OVMS";
description = "A test protection"; description = "A test protection";
settings = { test: new NumberProtectionSetting(3) }; settings = { test: new NumberProtectionSetting(3) };
}); });
await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 }); await this.mjolnir.protectionManager.setProtectionSettings("05OVMS", { test: 123 });
assert.equal( assert.equal(
(await this.mjolnir.getProtectionSettings("05OVMS"))["test"], (await this.mjolnir.protectionManager.getProtectionSettings("05OVMS"))["test"],
123 123
); );
}); });
it("Mjolnir should accumulate changed settings", async function() { it("Mjolnir should accumulate changed settings", async function() {
this.timeout(20000); this.timeout(20000);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "HPUjKN"; name = "HPUjKN";
settings = { settings = {
test1: new NumberProtectionSetting(3), test1: new NumberProtectionSetting(3),
@ -50,9 +49,9 @@ describe("Test: Protection settings", function() {
}; };
}); });
await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 }); await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test1: 1 });
await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 }); await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test2: 2 });
const settings = await this.mjolnir.getProtectionSettings("HPUjKN"); const settings = await this.mjolnir.protectionManager.getProtectionSettings("HPUjKN");
assert.equal(settings["test1"], 1); assert.equal(settings["test1"], 1);
assert.equal(settings["test2"], 2); assert.equal(settings["test2"], 2);
}); });
@ -60,7 +59,7 @@ describe("Test: Protection settings", function() {
this.timeout(20000); this.timeout(20000);
await client.joinRoom(this.config.managementRoom); await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "JY2TPN"; name = "JY2TPN";
description = "A test protection"; description = "A test protection";
settings = { test: new StringProtectionSetting() }; settings = { test: new StringProtectionSetting() };
@ -78,14 +77,14 @@ describe("Test: Protection settings", function() {
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"}) await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"})
await reply await reply
const settings = await this.mjolnir.getProtectionSettings("JY2TPN"); const settings = await this.mjolnir.protectionManager.getProtectionSettings("JY2TPN");
assert.equal(settings["test"], "asd"); assert.equal(settings["test"], "asd");
}); });
it("Mjolnir adds a value to a list setting", async function() { it("Mjolnir adds a value to a list setting", async function() {
this.timeout(20000); this.timeout(20000);
await client.joinRoom(this.config.managementRoom); await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "r33XyT"; name = "r33XyT";
description = "A test protection"; description = "A test protection";
settings = { test: new StringListProtectionSetting() }; settings = { test: new StringListProtectionSetting() };
@ -103,13 +102,13 @@ describe("Test: Protection settings", function() {
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"}) await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"})
await reply await reply
assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] }); assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("r33XyT"), { "test": ["asd"] });
}); });
it("Mjolnir removes a value from a list setting", async function() { it("Mjolnir removes a value from a list setting", async function() {
this.timeout(20000); this.timeout(20000);
await client.joinRoom(this.config.managementRoom); await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "oXzT0E"; name = "oXzT0E";
description = "A test protection"; description = "A test protection";
settings = { test: new StringListProtectionSetting() }; settings = { test: new StringListProtectionSetting() };
@ -128,13 +127,13 @@ describe("Test: Protection settings", function() {
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"}) await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"})
await reply(); await reply();
assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] }); assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("oXzT0E"), { "test": [] });
}); });
it("Mjolnir will change a protection setting in-place", async function() { it("Mjolnir will change a protection setting in-place", async function() {
this.timeout(20000); this.timeout(20000);
await client.joinRoom(this.config.managementRoom); await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "d0sNrt"; name = "d0sNrt";
description = "A test protection"; description = "A test protection";
settings = { test: new StringProtectionSetting() }; settings = { test: new StringProtectionSetting() };

View File

@ -16,7 +16,7 @@ describe("Test: Report polling", function() {
const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"});
await new Promise(async resolve => { await new Promise(async resolve => {
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "jYvufI"; name = "jYvufI";
description = "A test protection"; description = "A test protection";
settings = { }; settings = { };
@ -27,7 +27,7 @@ describe("Test: Report polling", function() {
} }
}; };
}); });
await this.mjolnir.enableProtection("jYvufI"); await this.mjolnir.protectionManager.enableProtection("jYvufI");
await client.doRequest( await client.doRequest(
"POST", "POST",
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", {

View File

@ -27,7 +27,7 @@ describe("Test: standard consequences", function() {
await badUser.joinRoom(protectedRoomId); await badUser.joinRoom(protectedRoomId);
await this.mjolnir.addProtectedRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "JY2TPN"; name = "JY2TPN";
description = "A test protection"; description = "A test protection";
settings = { }; settings = { };
@ -37,7 +37,7 @@ describe("Test: standard consequences", function() {
} }
}; };
}); });
await this.mjolnir.enableProtection("JY2TPN"); await this.mjolnir.protectionManager.enableProtection("JY2TPN");
let reply = new Promise(async (resolve, reject) => { let reply = new Promise(async (resolve, reject) => {
const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"});
@ -71,7 +71,7 @@ describe("Test: standard consequences", function() {
await badUser.joinRoom(protectedRoomId); await badUser.joinRoom(protectedRoomId);
await this.mjolnir.addProtectedRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "0LxMTy"; name = "0LxMTy";
description = "A test protection"; description = "A test protection";
settings = { }; settings = { };
@ -81,7 +81,7 @@ describe("Test: standard consequences", function() {
} }
}; };
}); });
await this.mjolnir.enableProtection("0LxMTy"); await this.mjolnir.protectionManager.enableProtection("0LxMTy");
let reply = new Promise(async (resolve, reject) => { let reply = new Promise(async (resolve, reject) => {
const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"});
@ -118,7 +118,7 @@ describe("Test: standard consequences", function() {
await goodUser.joinRoom(protectedRoomId); await goodUser.joinRoom(protectedRoomId);
await this.mjolnir.addProtectedRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId);
await this.mjolnir.registerProtection(new class implements IProtection { await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
name = "95B1Cr"; name = "95B1Cr";
description = "A test protection"; description = "A test protection";
settings = { }; settings = { };
@ -128,7 +128,7 @@ describe("Test: standard consequences", function() {
} }
}; };
}); });
await this.mjolnir.enableProtection("95B1Cr"); await this.mjolnir.protectionManager.enableProtection("95B1Cr");
let reply = new Promise(async (resolve, reject) => { let reply = new Promise(async (resolve, reject) => {
this.mjolnir.client.on('room.message', async (roomId, event) => { this.mjolnir.client.on('room.message', async (roomId, event) => {

View File

@ -1,15 +1,11 @@
import { strict as assert } from "assert"; import { strict as assert } from "assert";
import { LogLevel } from "matrix-bot-sdk";
import { UserID } from "matrix-bot-sdk"; import ManagementRoomOutput from "../../src/ManagementRoomOutput";
import config from "../../src/config";
import { replaceRoomIdsWithPills } from "../../src/utils";
describe("Test: utils", function() { describe("Test: utils", function() {
it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() { it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() {
this.timeout(20000);
const managementRoomAlias = this.config.managementRoom; const managementRoomAlias = this.config.managementRoom;
const managementRoomOutput: ManagementRoomOutput = this.mjolnir.managementRoomOutput;
await this.mjolnir.client.sendStateEvent( await this.mjolnir.client.sendStateEvent(
this.mjolnir.managementRoomId, this.mjolnir.managementRoomId,
"m.room.canonical_alias", "m.room.canonical_alias",
@ -17,15 +13,20 @@ describe("Test: utils", function() {
{ alias: managementRoomAlias } { alias: managementRoomAlias }
); );
const out = await replaceRoomIdsWithPills( const message: any = await new Promise(async resolve => {
this.mjolnir, this.mjolnir.client.on('room.message', (roomId, event) => {
`it's fun here in ${this.mjolnir.managementRoomId}`, if (roomId === this.mjolnir.managementRoomId) {
new Set([this.mjolnir.managementRoomId, "!myfaketestid:example.com"]) if (event.content?.body?.startsWith("it's")) {
); resolve(event);
}
const ourHomeserver = new UserID(await this.mjolnir.client.getUserId()).domain; }
})
await managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test',
`it's fun here in ${this.mjolnir.managementRoomId}`,
[this.mjolnir.managementRoomId, "!myfaketestid:example.com"]);
});
assert.equal( assert.equal(
out.formatted_body, message.content.formatted_body,
`it's fun here in <a href="https://matrix.to/#/${managementRoomAlias}">${managementRoomAlias}</a>` `it's fun here in <a href="https://matrix.to/#/${managementRoomAlias}">${managementRoomAlias}</a>`
); );
}); });