From ac7311dff58edab4d3ed056e2895aa6486c03d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferass=20El=C2=A0Hafidi?= Date: Mon, 17 Jun 2024 18:02:39 +0200 Subject: [PATCH 1/3] Initial support for bridging Matrix bans to IRC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge used to *not* bridge Matrix bans to IRC. As such, when someone banned an IRC user from Matrix, it would only prevent Matrix users from seeing messages coming from said user, but would not ban that user from IRC at all. This resulted in Matrix channel moderators being confused when other IRC users are reporting spam that simply isn't bridged at all as a result. This commit adds support for bridging Matrix bans to IRC. Currently, it just bans on IRC based on the IRC user's nickname, but this could change in the future, and most importantly is better than not bridging the ban at all. Signed-off-by: Ferass El Hafidi --- src/bridge/IrcBridge.ts | 14 ++++--- src/bridge/MatrixHandler.ts | 82 ++++++++++++++++++++++++++++++++++++- src/irc/BridgedClient.ts | 20 +++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/src/bridge/IrcBridge.ts b/src/bridge/IrcBridge.ts index 0aa94780f..8cd71bd46 100644 --- a/src/bridge/IrcBridge.ts +++ b/src/bridge/IrcBridge.ts @@ -1254,18 +1254,20 @@ export class IrcBridge { else if (event.content.membership === "join") { await this.matrixHandler.onJoin(request, memberEvent as unknown as OnMemberEventData, target); } - else if (["ban", "leave"].includes(event.content.membership as string)) { - // Given a "self-kick" is a leave, and you can't ban yourself, - // if the 2 IDs are different then we know it is either a kick - // or a ban (or a rescinded invite) - const isKickOrBan = target.getId() !== sender.getId(); - if (isKickOrBan) { + else if (event.content.membership === "leave") { + // Given a "self-kick" is a leave, if the 2 IDs are different then + // we know it is a kick (or a rescinded invite) + const isKick = target.getId() !== sender.getId(); + if (isKick) { await this.matrixHandler.onKick(request, memberEvent as unknown as MatrixEventKick, sender, target); } else { await this.matrixHandler.onLeave(request, memberEvent, target); } } + else if (event.content.membership === "ban") { + await this.matrixHandler.onBan(request, memberEvent as unknown as MatrixEventKick, sender, target); + } } else if (event.type === "m.room.power_levels" && event.state_key === "") { this.ircHandler.roomAccessSyncer.onMatrixPowerlevelEvent(event); diff --git a/src/bridge/MatrixHandler.ts b/src/bridge/MatrixHandler.ts index 4e4616732..22d5535e0 100644 --- a/src/bridge/MatrixHandler.ts +++ b/src/bridge/MatrixHandler.ts @@ -652,7 +652,7 @@ export class MatrixHandler { private async _onKick(req: BridgeRequest, event: MatrixEventKick, kicker: MatrixUser, kickee: MatrixUser) { req.log.info( - "onKick %s is kicking/banning %s from %s (reason: %s)", + "onKick %s is kicking %s from %s (reason: %s)", kicker.getId(), kickee.getId(), event.room_id, event.content.reason || "none" ); this._onMemberEvent(req, event); @@ -741,7 +741,81 @@ export class MatrixHandler { // If we aren't joined this will no-op. await client.leaveChannel( ircRoom.channel, - `Kicked by ${kicker.getId()} ` + + `Kicked by ${kicker.getId()}` + + (event.content.reason ? ` : ${event.content.reason}` : "") + ); + }))); + } + } + + private async _onBan(req: BridgeRequest, event: MatrixEventKick, sender: MatrixUser, banned: MatrixUser) { + req.log.info( + "onBan %s is banning %s from %s (reason: %s)", + sender.getId(), banned.getId(), event.room_id, event.content.reason || "none" + ); + this._onMemberEvent(req, event); + + const ircRooms = await this.ircBridge.getStore().getIrcChannelsForRoomId(event.room_id); + // do we have an active connection for the banned? This tells us if they are real + // or virtual. + const bannedClients = this.ircBridge.getBridgedClientsForUserId(banned.getId()); + + if (bannedClients.length === 0) { + // Matrix on IRC banning, work out which IRC user to ban. + let server = null; + for (let i = 0; i < ircRooms.length; i++) { + if (ircRooms[i].server.claimsUserId(banned.getId())) { + server = ircRooms[i].server; + break; + } + } + if (!server) { + return; // kicking a bogus user + } + const bannedNick = server.getNickFromUserId(banned.getId()); + if (!bannedNick) { + return; // bogus virtual user ID + } + // work out which client will do the kicking + const senderClient = this.ircBridge.getIrcUserFromCache(server, sender.getId()); + if (!senderClient) { + // well this is awkward.. whine about it and bail. + req.log.warn( + "%s has no client instance to send kick from. Cannot kick.", + sender.getId() + ); + return; + } + // we may be bridging this matrix room into many different IRC channels, and we want + // to kick this user from all of them. + for (let i = 0; i < ircRooms.length; i++) { + if (ircRooms[i].server.domain !== server.domain) { + return; + } + senderClient.ban(bannedNick, ircRooms[i].channel); + senderClient.kick( + bannedNick, ircRooms[i].channel, + `Banned by ${sender.getId()}` + + (event.content.reason ? ` : ${event.content.reason}` : "") + ); + } + } + else { + // Matrix on Matrix banning: part the channel. + const bannedServerLookup: {[serverDomain: string]: BridgedClient} = {}; + bannedClients.forEach((ircClient) => { + bannedServerLookup[ircClient.server.domain] = ircClient; + }); + await Promise.all(ircRooms.map((async (ircRoom) => { + // Make the connected IRC client leave the channel. + const client = bannedServerLookup[ircRoom.server.domain]; + if (!client) { + return; // not connected to this server + } + // If we aren't joined this will no-op. + await client.leaveChannel( + ircRoom.channel, + `Banned by ${sender.getId()}` + (event.content.reason ? ` : ${event.content.reason}` : "") ); }))); @@ -1464,6 +1538,10 @@ export class MatrixHandler { return reqHandler(req, this._onKick(req, event, kicker, kickee)); } + public onBan(req: BridgeRequest, event: MatrixEventKick, sender: MatrixUser, banned: MatrixUser) { + return reqHandler(req, this._onBan(req, event, sender, banned)); + } + public onMessage(req: BridgeRequest, event: MatrixMessageEvent) { return reqHandler(req, this._onMessage(req, event)); } diff --git a/src/irc/BridgedClient.ts b/src/irc/BridgedClient.ts index 932bceb47..238746adf 100644 --- a/src/irc/BridgedClient.ts +++ b/src/irc/BridgedClient.ts @@ -510,6 +510,26 @@ export class BridgedClient extends EventEmitter { await c.send("KICK", channel, nick, reason); } + public async ban(nick: string, channel: string): Promise { + if (this.state.status !== BridgedClientStatus.CONNECTED) { + return; // we were never connected to the network. + } + if (!this.state.client.chans.has(channel)) { + // we were never joined to it. We need to be joined to it to kick people. + return; + } + if (!channel.startsWith("#")) { + return; // PM room + } + + const c = this.state.client; + + this.log.debug("Banning %s from channel %s", nick, channel); + + // best effort ban + await c.send("MODE", channel, "+b", nick + "!*@*"); + } + public sendAction(room: IrcRoom, action: IrcAction) { this.keepAlive(); let expiryTs = 0; From ce67a19af4a76e3aa158da4b4b0ec7fcb9349ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferass=20El=C2=A0Hafidi?= Date: Tue, 6 Aug 2024 18:23:58 +0200 Subject: [PATCH 2/3] spec/integ/kicking: add ban tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ferass El Hafidi --- spec/integ/kicking.spec.js | 112 +++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/spec/integ/kicking.spec.js b/spec/integ/kicking.spec.js index 56aa31ea9..f3ec39098 100644 --- a/spec/integ/kicking.spec.js +++ b/spec/integ/kicking.spec.js @@ -176,6 +176,118 @@ describe("Kicking", () => { }); +describe("Banning", () => { + + const {env, config, test} = envBundle(); + + const mxUser = { + id: "@flibble:wibble", + nick: "M-flibble" + }; + + const ircUser = { + nick: "bob", + localpart: config._server + "_bob", + id: `@${config._server}_bob:${config.homeserver.domain}` + }; + + const ircUserKicker = { + nick: "KickerNick", + localpart: config._server + "_KickerNick", + id: "@" + config._server + "_KickerNick:" + config.homeserver.domain + }; + + beforeEach(async () => { + await test.beforeEach(env); + + // accept connection requests from eeeeeeeeveryone! + env.ircMock._autoConnectNetworks( + config._server, mxUser.nick, config._server + ); + env.ircMock._autoConnectNetworks( + config._server, ircUser.nick, config._server + ); + env.ircMock._autoConnectNetworks( + config._server, config._botnick, config._server + ); + // accept join requests from eeeeeeeeveryone! + env.ircMock._autoJoinChannels( + config._server, mxUser.nick, config._chan + ); + env.ircMock._autoJoinChannels( + config._server, ircUser.nick, config._chan + ); + env.ircMock._autoJoinChannels( + config._server, config._botnick, config._chan + ); + + // we also don't care about registration requests for the irc user + env.clientMock._intent(ircUser.id)._onHttpRegister({ + expectLocalpart: ircUser.localpart, + returnUserId: ircUser.id + }); + + await test.initEnv(env); + + // make the matrix user be on IRC + await env.mockAppService._trigger("type:m.room.message", { + content: { + body: "let me in", + msgtype: "m.text" + }, + user_id: mxUser.id, + room_id: config._roomid, + type: "m.room.message" + }); + const botIrcClient = await env.ircMock._findClientAsync(config._server, config._botnick); + // make the IRC user be on Matrix + botIrcClient.emit("message", ircUser.nick, config._chan, "let me in"); + }); + + afterEach(async () => test.afterEach(env)); + + describe("IRC users on Matrix", () => { + it("should make the virtual IRC client set MODE +b and KICK the real IRC user", async () => { + let reason = "Get some help."; + let userBannedPromise = new Promise(function(resolve, reject) { + env.ircMock._whenClient(config._server, mxUser.nick, "send", + function(client, cmd, chan, arg1, arg2) { + expect(client.nick).toEqual(mxUser.nick); + expect(client.addr).toEqual(config._server); + expect(chan).toEqual(config._chan); + if (cmd !== "KICK") { + // We sent a MODE + expect(cmd).toEqual("MODE"); + expect(arg1).toEqual("+b"); // mode +b => ban + expect(arg2).toEqual(`${ircUser.nick}!*@*`); // argument to +b + } + else { + expect(cmd).toEqual("KICK"); + expect(arg1).toEqual(ircUser.nick); // nick + expect(arg2.indexOf(reason)).not.toEqual(-1, // kick reason + `kick reason was not mirrored to IRC. Got '${arg2}', + expected '${reason}'.`); + } + resolve(); + }); + }); + + await env.mockAppService._trigger("type:m.room.member", { + content: { + reason: reason, + membership: "ban" + }, + user_id: mxUser.id, + state_key: ircUser.id, + room_id: config._roomid, + type: "m.room.member" + }); + await userBannedPromise; + }); + }); +}); + + describe("Kicking on IRC join", () => { const {env, config, test} = envBundle(); From b2add7300123050a6a541191830e0c6fa604f77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeusz=20=E2=80=9Etadzik=E2=80=9D=20So=C5=9Bnierz?= Date: Thu, 12 Sep 2024 11:59:45 +0200 Subject: [PATCH 3/3] changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ferass El Hafidi --- changelog.d/1821.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1821.feature diff --git a/changelog.d/1821.feature b/changelog.d/1821.feature new file mode 100644 index 000000000..515408f2a --- /dev/null +++ b/changelog.d/1821.feature @@ -0,0 +1 @@ +Initial support for bridging bans to IRC.