mjolnir/src/Mjolnir.ts

578 lines
23 KiB
TypeScript
Raw Normal View History

2019-09-27 17:15:10 -04:00
/*
Copyright 2019 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 } from "matrix-bot-sdk";
2019-09-27 17:15:10 -04:00
import BanList, { ALL_RULE_TYPES } from "./models/BanList";
import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
2019-09-27 21:54:13 -04:00
import { applyUserBans } from "./actions/ApplyBan";
2019-10-04 23:02:37 -04:00
import config from "./config";
2019-11-06 20:46:49 -05:00
import { logMessage } from "./LogProxy";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { IProtection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections";
import { AutomaticRedactionQueue } from "./queues/AutomaticRedactionQueue";
2019-09-27 17:15:10 -04:00
export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
export const STATE_SYNCING = "syncing";
export const STATE_RUNNING = "running";
2019-10-08 15:58:31 -04:00
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";
2019-10-08 15:58:31 -04:00
2019-09-27 17:15:10 -04:00
export class Mjolnir {
2019-09-27 18:04:08 -04:00
private displayName: string;
private localpart: string;
private currentState: string = STATE_NOT_STARTED;
private protections: IProtection[] = [];
private redactionQueue = new AutomaticRedactionQueue();
private automaticRedactionReasons: MatrixGlob[] = [];
private protectedJoinedRoomIds: string[] = [];
2019-09-27 18:04:08 -04:00
2019-09-27 17:15:10 -04:00
constructor(
public readonly client: MatrixClient,
public readonly protectedRooms: { [roomId: string]: string },
2019-10-08 13:25:57 -04:00
private banLists: BanList[],
2019-09-27 17:15:10 -04:00
) {
for (const reason of config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
2019-09-27 17:15:10 -04:00
client.on("room.event", this.handleEvent.bind(this));
client.on("room.message", async (roomId, event) => {
2019-11-06 20:46:49 -05:00
if (roomId !== config.managementRoom) return;
2019-09-27 17:15:10 -04:00
if (!event['content']) return;
const content = event['content'];
2019-09-27 18:04:08 -04:00
if (content['msgtype'] === "m.text" && content['body']) {
const prefixes = [
COMMAND_PREFIX,
this.localpart + ":",
this.displayName + ":",
await client.getUserId() + ":",
...config.commands.additionalPrefixes.map(p => `!${p}`),
...config.commands.additionalPrefixes.map(p => `${p}:`),
...config.commands.additionalPrefixes,
];
if (config.commands.allowNoPrefix) prefixes.push("!");
const prefixUsed = prefixes.find(p => content['body'].startsWith(p));
if (!prefixUsed) return;
// rewrite the event body to make the prefix uniform (in case the bot has spaces in its display name)
let restOfBody = content['body'].substring(prefixUsed.length);
if (!restOfBody.startsWith(" ")) restOfBody = ` ${restOfBody}`;
event['content']['body'] = COMMAND_PREFIX + restOfBody;
2019-11-06 20:46:49 -05:00
LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`);
2019-09-27 18:04:08 -04:00
2019-09-27 17:15:10 -04:00
await client.sendReadReceipt(roomId, event['event_id']);
return handleCommand(roomId, event, this);
}
});
2019-09-27 18:04:08 -04:00
client.on("room.join", (roomId: string, event: any) => {
LogService.info("Mjolnir", `Joined ${roomId}`);
return this.resyncJoinedRooms();
});
client.on("room.leave", (roomId: string, event: any) => {
LogService.info("Mjolnir", `Left ${roomId}`);
return this.resyncJoinedRooms();
});
2019-09-27 18:04:08 -04:00
client.getUserId().then(userId => {
this.localpart = userId.split(':')[0].substring(1);
return client.getUserProfile(userId);
}).then(profile => {
if (profile['displayname']) {
this.displayName = profile['displayname'];
}
});
2019-09-27 17:15:10 -04:00
}
2019-10-08 13:25:57 -04:00
public get lists(): BanList[] {
return this.banLists;
}
public get state(): string {
return this.currentState;
}
public get enabledProtections(): IProtection[] {
return this.protections;
}
public get redactionHandler(): AutomaticRedactionQueue {
return this.redactionQueue;
}
public get automaticRedactGlobs(): MatrixGlob[] {
return this.automaticRedactionReasons;
}
2019-09-27 17:15:10 -04:00
public start() {
return this.client.start().then(async () => {
this.currentState = STATE_CHECKING_PERMISSIONS;
if (config.verifyPermissionsOnStartup) {
2019-11-06 20:46:49 -05:00
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
2019-10-04 23:38:50 -04:00
await this.verifyPermissions(config.verboseLogging);
}
}).then(async () => {
this.currentState = STATE_SYNCING;
await logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
await this.resyncJoinedRooms(false);
try {
const data = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
if (data && data['rooms']) {
for (const roomId of data['rooms']) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
}
}
} catch (e) {
LogService.warn("Mjolnir", e);
}
2019-10-04 23:02:37 -04:00
if (config.syncOnStartup) {
2019-11-06 20:46:49 -05:00
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
2019-10-08 13:25:57 -04:00
await this.buildWatchedBanLists();
2019-10-04 23:38:50 -04:00
await this.syncLists(config.verboseLogging);
await this.enableProtections();
2019-10-04 23:02:37 -04:00
}
}).then(async () => {
this.currentState = STATE_RUNNING;
2019-11-06 20:46:49 -05:00
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
2019-10-04 23:02:37 -04:00
});
2019-09-27 17:15:10 -04:00
}
public async addProtectedRoom(roomId: string) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
let additionalProtectedRooms;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", e);
}
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
additionalProtectedRooms.rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
await this.syncLists(config.verboseLogging);
}
public async removeProtectedRoom(roomId: string) {
delete this.protectedRooms[roomId];
let additionalProtectedRooms;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", e);
}
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
additionalProtectedRooms.rooms = additionalProtectedRooms.rooms.filter(r => r !== roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
}
private async resyncJoinedRooms(withSync = true) {
if (!config.protectAllJoinedRooms) return;
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== config.managementRoom);
for (const roomId of this.protectedJoinedRoomIds) {
delete this.protectedRooms[roomId];
}
this.protectedJoinedRoomIds = joinedRoomIds;
for (const roomId of joinedRoomIds) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
}
if (withSync) {
await this.syncLists(config.verboseLogging);
}
}
private async getEnabledProtections() {
let enabled: string[] = [];
try {
const protections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
if (protections && protections['enabled']) {
for (const protection of protections['enabled']) {
enabled.push(protection);
}
}
} catch (e) {
LogService.warn("Mjolnir", e);
}
return enabled;
}
private async enableProtections() {
for (const protection of await this.getEnabledProtections()) {
try {
this.enableProtection(protection, false);
} catch (e) {
LogService.warn("Mjolnir", e);
}
}
}
public async enableProtection(protectionName: string, persist = true): Promise<any> {
const definition = PROTECTIONS[protectionName];
if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
const protection = definition.factory();
this.protections.push(protection);
if (persist) {
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
}
}
public async disableProtection(protectionName: string): Promise<any> {
const idx = this.protections.findIndex(p => p.name === protectionName);
if (idx >= 0) this.protections.splice(idx, 1);
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
}
2019-10-08 15:58:31 -04:00
public async watchList(roomRef: string): Promise<BanList> {
2019-10-08 13:25:57 -04:00
const joinedRooms = await this.client.getJoinedRooms();
const permalink = Permalinks.parseUrl(roomRef);
2019-10-08 15:58:31 -04:00
if (!permalink.roomIdOrAlias) return null;
2019-10-08 13:25:57 -04:00
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
if (!joinedRooms.includes(roomId)) {
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
}
2019-10-08 15:58:31 -04:00
if (this.banLists.find(b => b.roomId === roomId)) return null;
2019-10-08 13:25:57 -04:00
const list = new BanList(roomId, roomRef, this.client);
await list.updateList();
this.banLists.push(list);
2019-10-08 15:58:31 -04:00
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
references: this.banLists.map(b => b.roomRef),
});
return list;
2019-10-08 13:25:57 -04:00
}
2019-10-08 15:58:31 -04:00
public async unwatchList(roomRef: string): Promise<BanList> {
2019-10-08 13:25:57 -04:00
const permalink = Permalinks.parseUrl(roomRef);
2019-10-08 15:58:31 -04:00
if (!permalink.roomIdOrAlias) return null;
2019-10-08 13:25:57 -04:00
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
const list = this.banLists.find(b => b.roomId === roomId);
if (list) this.banLists.splice(this.banLists.indexOf(list), 1);
2019-10-08 15:58:31 -04:00
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
references: this.banLists.map(b => b.roomRef),
});
return list;
2019-10-08 13:25:57 -04:00
}
public async buildWatchedBanLists() {
const banLists: BanList[] = [];
const joinedRooms = await this.client.getJoinedRooms();
2019-10-08 15:58:31 -04:00
let watchedListsEvent = {};
try {
watchedListsEvent = await this.client.getAccountData(WATCHED_LISTS_EVENT_TYPE);
} catch (e) {
// ignore - not important
}
for (const roomRef of (watchedListsEvent['references'] || [])) {
2019-10-08 13:25:57 -04:00
const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) continue;
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
if (!joinedRooms.includes(roomId)) {
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
}
const list = new BanList(roomId, roomRef, this.client);
await list.updateList();
banLists.push(list);
}
this.banLists = banLists;
}
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);
2019-10-04 23:38:50 -04:00
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
2019-11-06 20:46:49 -05:00
await this.client.sendMessage(config.managementRoom, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
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,
});
}
// Otherwise OK
} catch (e) {
LogService.error("Mjolnir", e);
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
}
return errors;
}
2019-10-09 06:29:01 -04:00
public async syncLists(verbose = true) {
2019-09-27 22:02:03 -04:00
for (const list of this.banLists) {
await list.updateList();
}
let hadErrors = false;
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
2019-10-04 23:38:50 -04:00
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
2019-10-04 23:38:50 -04:00
const text = "Done updating rooms - no errors";
2019-11-06 20:46:49 -05:00
await this.client.sendMessage(config.managementRoom, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
2019-09-27 22:02:03 -04:00
}
public async syncListForRoom(roomId: string) {
let updated = false;
for (const list of this.banLists) {
if (list.roomId !== roomId) continue;
await list.updateList();
updated = true;
}
if (!updated) return;
let hadErrors = false;
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
if (!hadErrors) {
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
const text = "Done updating rooms - no errors";
2019-11-06 20:46:49 -05:00
await this.client.sendMessage(config.managementRoom, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
2019-09-27 22:02:03 -04:00
}
2019-09-27 17:15:10 -04:00
private async handleEvent(roomId: string, event: any) {
// Check for UISI errors
if (roomId === config.managementRoom) {
if (event['type'] === 'm.room.message' && event['content'] && event['content']['body']) {
if (event['content']['body'] === "** Unable to decrypt: The sender's device has not sent us the keys for this message. **") {
// UISI
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '⚠');
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'UISI');
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '🚨');
}
}
}
// Check for updated ban lists before checking protected rooms - the ban lists might be protected
// themselves.
if (this.banLists.map(b => b.roomId).includes(roomId)) {
if (ALL_RULE_TYPES.includes(event['type'])) {
await this.syncListForRoom(roomId);
}
}
2019-10-18 11:38:19 -04:00
if (Object.keys(this.protectedRooms).includes(roomId)) {
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves
// Iterate all the protections
for (const protection of this.protections) {
try {
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", e);
await this.client.sendNotice(config.managementRoom, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.redactionQueue.handleEvent(roomId, event, this.client);
2019-10-18 11:38:19 -04:00
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
2019-10-18 11:38:19 -04:00
const url = this.protectedRooms[roomId];
await logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${url} - checking permissions...`);
2019-10-18 11:38:19 -04:00
const errors = await this.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
2019-10-18 11:38:19 -04:00
}
return;
} else if (event['type'] === "m.room.member") {
// Only apply bans in the room we're looking at.
const errors = await applyUserBans(this.banLists, [roomId], this);
2019-10-18 11:38:19 -04:00
await this.printActionResult(errors);
}
}
2019-09-27 17:15:10 -04:00
}
private async printActionResult(errors: RoomUpdateError[], title: string = null, logAnyways = false) {
if (errors.length <= 0) return false;
2019-09-27 21:54:13 -04:00
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;
}
}
2019-09-27 17:15:10 -04:00
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`;
2019-09-27 21:54:13 -04:00
for (const error of errors) {
const url = this.protectedRooms[error.roomId] ? this.protectedRooms[error.roomId] : `https://matrix.to/#/${error.roomId}`;
html += `<li><a href="${url}">${error.roomId}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
2019-09-27 17:15:10 -04:00
}
2019-09-27 21:54:13 -04:00
html += "</ul>";
2019-09-27 17:15:10 -04:00
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
2019-11-06 20:46:49 -05:00
await this.client.sendMessage(config.managementRoom, message);
return true;
2019-09-27 17:15:10 -04:00
}
2019-11-14 17:44:13 -05:00
public async isSynapseAdmin(): Promise<boolean> {
try {
const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`;
const response = await this.client.doRequest("GET", endpoint);
return response['admin'];
} catch (e) {
LogService.error("Mjolnir", "Error determining if Mjolnir is a server admin:");
LogService.error("Mjolnir", e);
return false; // assume not
}
}
public async deactivateSynapseUser(userId: string): Promise<any> {
const endpoint = `/_synapse/admin/v1/deactivate/${userId}`;
return await this.client.doRequest("POST", endpoint);
}
2019-09-27 17:15:10 -04:00
}