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. 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(); 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;