Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Incorperate: Rework kick command for ACL clean+glob kick. #82

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/harness.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ protectedRooms: []
# Manually add these rooms to the protected rooms list if you want them protected.
protectAllJoinedRooms: false

# Increase this delay to have Mjölnir wait longer between two consecutive backgrounded
# operations. The total duration of operations will be longer, but the homeserver won't
# be affected as much. Conversely, decrease this delay to have Mjölnir chain operations
# faster. The total duration of operations will generally be shorter, but the performance
# of the homeserver may be more impacted.
backgroundDelayMS: 10

# Server administration commands, these commands will only work if Mjolnir is
# a global server administrator
admin:
Expand Down
4 changes: 3 additions & 1 deletion src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from "./ProtectionsCommands";
import { execSetPowerLevelCommand } from "./SetPowerLevelCommand";
import { execResolveCommand } from "./ResolveAlias";
import { execKickCommand } from "./KickCommand";
import { execKickCommand, execServerAclCleanCommand } from "./KickCommand";
import { parse as tokenize } from "shell-quote";
import { execSinceCommand } from "./SinceCommand";
import { readCommand } from "./interface-manager/CommandReader";
Expand Down Expand Up @@ -120,6 +120,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
return await execSinceCommand(roomId, event, mjolnir, tokens);
} else if (parts[1] === 'kick' && parts.length > 2) {
return await execKickCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'acl-clean') {
return await execServerAclCleanCommand(roomId, event, mjolnir, parts);
} else {
const readItems = readCommand(cmd).slice(1); // remove "!mjolnir"
const stream = new ArgumentStream(readItems);
Expand Down
1 change: 1 addition & 0 deletions src/commands/Help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const oldHelpMenu = "" +
"!mjolnir redact <user ID> [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" +
"!mjolnir redact <event permalink> - Redacts a message by permalink\n" +
"!mjolnir kick <glob> [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" +
"!mjolnir acl-clean [room alias/ID] - Kicks all of the users from the room or all protected rooms that whose servers are banned by the room's server ACL.\n" +
"!mjolnir sync - Force updates of all lists and re-apply rules\n" +
"!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" +
"!mjolnir list create <shortcode> <alias localpart> - Creates a new ban list with the given shortcode and alias\n" +
Expand Down
181 changes: 136 additions & 45 deletions src/commands/KickCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,64 +26,155 @@
*/

import { Mjolnir } from "../Mjolnir";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import { LogLevel, MatrixGlob, MembershipEvent, RichReply, UserID } from "matrix-bot-sdk";
import { ServerAcl } from "../models/ServerAcl";

// !mjolnir kick <user|filter> [room] [reason]
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let force = false;

export async function execKickCommand(commandRoomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const force = parts[parts.length - 1] === "--force";
const glob = parts[2];
let rooms = mjolnir.protectedRoomsTracker.getProtectedRooms();

if (parts[parts.length - 1] === "--force") {
force = true;
parts.pop();
}

if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
return;
}

const kickRule = new MatrixGlob(glob);

let reason: string | undefined;
if (parts.length > 3) {
let reasonIndex = 3;
if (parts[3].startsWith("#") || parts[3].startsWith("!")) {
rooms = [await mjolnir.client.resolveRoom(parts[3])];
reasonIndex = 4;
const kickRuleHasGlob = /[*?]/.test(glob);
const rooms = await (async () => {
// if they provide a room then use that, otherwise use all protected rooms.
if (parts.length > 3) {
if (parts[3].startsWith("#") || parts[3].startsWith("!")) {
return [await mjolnir.client.resolveRoom(parts[3])];
}
}
reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>';
}
if (!reason) reason = '<none supplied>';
return [...Object.keys(mjolnir.protectedRooms)];

Check failure on line 45 in src/commands/KickCommand.ts

View workflow job for this annotation

GitHub Actions / Build & Lint

Property 'protectedRooms' does not exist on type 'Mjolnir'. Did you mean 'addProtectedRoom'?
})();
const reason = (rooms.length === 1 ?
// we don't forget to remove the `--force` argument from the reason.
parts.slice(4, force ? -1 : undefined).join(' ') :
parts.slice(3, force ? -1 : undefined).join(' ')
)
|| '<no reason supplied>';

for (const protectedRoomId of rooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);

for (const member of members) {
const victim = member.membershipFor;
const membersToKick = await filterMembers(
mjolnir,
protectedRoomId,
membership => kickRule.test(membership.membershipFor) ? KickOutcome.Remove : KickOutcome.Keep
);
if (kickRuleHasGlob && (!mjolnir.config.commands.confirmWildcardBan || !force)) {
let replyMessage = `The wildcard command would have removed ${membersToKick.length} ${membersToKick.length === 1 ? 'member' : 'members'} from ${protectedRoomId}`;
replyMessage += "Wildcard bans need to be explicitly enabled in the config and require an addition `--force` argument to confirm";
const reply = RichReply.createFor(commandRoomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(commandRoomId, reply);
// We don't want to even tell them who is being kicked if it hasn't been enabled.
if (!mjolnir.config.commands.confirmWildcardBan) {
return;
}
}
await kickMembers(mjolnir, protectedRoomId, membersToKick, force, reason);
}

if (kickRule.test(victim)) {
await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
return mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}

if (!mjolnir.config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
});
} catch (e) {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
/**
* A command to remove users whose server is banned by server ACL from a room.
* @param commandRoomId The room the command was sent from.
* @param event The event containing the command.
* @param mjolnir A mjolnir instance.
* @param parts The parts of the command.
* @returns When the users have been removed and the command has been marked as complete.
*/
export async function execServerAclCleanCommand(commandRoomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const force = parts[parts.length - 1] === "--force";
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
// If they say all, clean all protected rooms, otherwise they gave a room id/alias/pill.
const roomsToClean = parts[2] === 'all' ? [...Object.keys(mjolnir.protectedRooms)] : [await mjolnir.client.resolveRoom(parts[2])]

Check failure on line 89 in src/commands/KickCommand.ts

View workflow job for this annotation

GitHub Actions / Build & Lint

Property 'protectedRooms' does not exist on type 'Mjolnir'. Did you mean 'addProtectedRoom'?
for (const roomToClean of roomsToClean) {
const currentAcl = new ServerAcl(serverName).fromACL(await mjolnir.client.getRoomStateEvent(roomToClean, "m.room.server_acl", ""));
const membersToKick = await filterMembers(
mjolnir,
roomToClean,
membership => {
const memberId = new UserID(membership.membershipFor);
// check the user's server isn't on the deny list.
for (const deny of currentAcl.safeAclContent().deny) {
const rule = new MatrixGlob(deny);
if (rule.test(memberId.domain)) {
return KickOutcome.Remove;
}
} else {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
// check the user's server is allowed.
for (const allow of currentAcl.safeAclContent().allow) {
const rule = new MatrixGlob(allow);
if (rule.test(memberId.domain)) {
return KickOutcome.Keep;
}
}
// if they got here it means their server was not explicitly allowed.
return KickOutcome.Remove;
}
);

/// Instead of having --force on commands like these were confirmation is required after some context,
/// wouldn't it be better if we showed what would happen and then ask yes/no to confirm?
if (!force) {
let replyMessage = `The ACL clean command would have removed ${membersToKick.length} ${membersToKick.length === 1 ? 'member' : 'members'} from ${roomToClean}`;
replyMessage += "The ACL clean command needs an additional `--force` argument to confirm";
const reply = RichReply.createFor(commandRoomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(commandRoomId, reply);
}
await kickMembers(mjolnir, roomToClean, membersToKick, force, "User's server is banned by the room's server ACL event.")
}
return mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}

/**
* Filter room members using a user specified predicate.
* @param mjolnir Mjolnir instance to fetch room members with.
* @param roomId The room to fetch members from.
* @param predicate A function accepting a membership event's content and returns a `KickOutcome`. See `MembershipEvent`.
* @returns A list of user ids who are members of the room who have been marked as `KickOutcome.Remove`.
*/
async function filterMembers(
mjolnir: Mjolnir,
roomId: string,
predicate: (member: MembershipEvent) => KickOutcome
): Promise<string[]> {
const members = await mjolnir.client.getRoomMembers(roomId, undefined, ["join"], ["ban", "leave"]);
const filteredMembers = [];
for (const member of members) {
if (predicate(member) === KickOutcome.Remove) {
filteredMembers.push(member.membershipFor);
}
}
return filteredMembers;
}

/**
* Whether to remove a user from a room or not.
*/
enum KickOutcome {
Remove,
Keep,
}

return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
async function kickMembers(mjolnir: Mjolnir, roomId: string, membersToKick: string[], force: boolean, reason: string) {
// I do not think it makes much sense to notify who was kicked like this.
// It should really be reconsidered with https://github.com/matrix-org/mjolnir/issues/294
// and whether we want to produce reports or something like that.
for (const member of membersToKick) {
if (mjolnir.config.noop) {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${member} in ${roomId} but the bot is running in no-op mode.`);
} else if (!force) {
await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Would have kicked ${member} in ${roomId} but --force was not given with the command.`);
} else {
await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${member} in ${roomId}`);
try {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(member, roomId, reason);
});
} catch (e) {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${member}: ${e}`);
}
}
}
}
24 changes: 24 additions & 0 deletions src/models/ServerAcl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ export class ServerAcl {

}

/**
* Initialize the ServerACL with the rules from an existing ACL object retrieved from the room state.
* @param acl The content of a `m.room.server_acl` event.
*/
public fromACL(acl: any): ServerAcl {
if (this.allowedServers.size !== 0 || this.deniedServers.size !== 0) {
throw new TypeError(`Expected this ACL to be uninitialized.`);
}
const allow = acl['allow'];
const deny = acl['deny'];
const ips = acl['allow_ip_literals'];

if (Array.isArray(allow)) {
allow.forEach(this.allowServer, this);
}
if (Array.isArray(deny)) {
deny.forEach(this.denyServer, this);
}
if (Boolean(ips)) {
this.allowIpAddresses();
}
return this;
}

/**
* Checks the ACL for any entries that might ban ourself.
* @returns A list of deny entries that will not ban our own homeserver.
Expand Down
46 changes: 46 additions & 0 deletions test/integration/commands/kickCommandTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { strict as assert } from "assert";
import { newTestUser } from "../clientHelper";
import { getFirstReaction } from "./commandUtils";
import { Mjolnir } from "../../../src/Mjolnir";

describe("Test: The redaction command", function () {
// If a test has a timeout while awaitng on a promise then we never get given control back.
afterEach(function() { this.moderator?.stop(); });

it("Kicks users matching ACL", async function () {
// How tf
})

it("Kicks users matching a glob", async function () {
this.timeout(120000)
// create a bunch of users with a pattern in the name.
const usersToRemove = await Promise.all([...Array(20)].map(_ => newTestUser({ name: { contains: "remove-me"}})));

Check failure on line 17 in test/integration/commands/kickCommandTest.ts

View workflow job for this annotation

GitHub Actions / Build & Lint

Expected 2 arguments, but got 1.
const usersToKeep = await Promise.all([...Array(20)].map(_ => newTestUser({ name: { contains: "keep-me"}})));

Check failure on line 18 in test/integration/commands/kickCommandTest.ts

View workflow job for this annotation

GitHub Actions / Build & Lint

Expected 2 arguments, but got 1.
// FIXME: Does our kick command kick from all protected rooms or just one room???
const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) {
const room = await this.mjolnir.client.createRoom({ preset: "public_chat" });
await this.mjolnir!.addProtectedRoom(room);
protectedRooms.push(room);
await Promise.all([...usersToKeep, ...usersToRemove].map(client => {
return Promise.all(protectedRooms.map(r => client.joinRoom(r)));
}));
}
// issue the glob kick
await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => {
return await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir kick *remove-me* --force` });
});
// make sure no one else got kicked
await Promise.all(protectedRooms.map(async room => {
const mjolnir: Mjolnir = this.mjolnir!;
const members = await mjolnir.client.getJoinedRoomMembers(room);
await Promise.all(usersToKeep.map(async client => {
assert.equal(members.includes(await client.getUserId()), true);
}));
await Promise.all(usersToRemove.map(async client => {
assert.equal(members.includes(await client.getUserId()), false);
}));
}));
})

});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@
"./test/integration/protectionSettingsTest.ts",
"./test/integration/banPropagationTest.ts",
"./test/integration/protectedRoomsConfigTest.ts",
"./test/integration/commands/kickCommandTest.ts"
]
}
Loading