diff --git a/config.sample.yaml b/config.sample.yaml index ea2fbb686..17d3401b3 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -668,6 +668,10 @@ ircService: # to identify banned users, rooms and servers. rooms: - "#matrix-org-coc-bl:matrix.org" + # Optional: Rooms to sync K-lined users to + serverBanLists: + # Keys are domains matching entries in ircService.servers + irc.example.com: "#exampleircd:matrix.org" # Options here are generally only applicable to large-scale bridges and may have # consequences greater than other options in this configuration file. diff --git a/spec/unit/MatrixBanSync.spec.js b/spec/unit/MatrixBanSync.spec.js index c2b78b29e..9337472ea 100644 --- a/spec/unit/MatrixBanSync.spec.js +++ b/spec/unit/MatrixBanSync.spec.js @@ -35,8 +35,39 @@ const BANNED_SERVER_ENTITY = { describe("MatrixBanSync", () => { let banSync; + const intent = { + join: async (roomOrAlias) => { + if (roomOrAlias.includes('valid:room')) { + return roomOrAlias.replace('#', '!'); + } + throw Error('Room not known'); + }, + roomState: async (roomId) => { + if (roomId === '!valid:room') { + return [ + { + type: 'm.policy.rule.server', + state_key: 'should-not-be-here', + content: { + recommendation: 'still-not-interested', + entity: 'foo.com', + reason: 'foo', + } + }, + BANNED_SERVER_STATE_EVENT, + ]; + } + else if (roomId === '!anothervalid:room') { + return [ + {type: 'not-interested'}, + BANNED_USER_STATE_EVENT, + ]; + } + throw Error('Unknown room'); + }, + }; beforeEach(() => { - banSync = new MatrixBanSync({ rooms: [] }); + banSync = new MatrixBanSync(intent, { rooms: [] }); banSync.interestingRooms = new Set(["!valid:room"]); }) describe("isUserBanned", () => { @@ -106,38 +137,7 @@ describe("MatrixBanSync", () => { describe("syncRules", () => { it("should sync state from a set of rooms", async () => { banSync.config.rooms = ["!valid:room", "#anothervalid:room", "!notvalid:room"]; - const intent = { - join: async (roomOrAlias) => { - if (roomOrAlias.includes('valid:room')) { - return roomOrAlias.replace('#', '!'); - } - throw Error('Room not known'); - }, - roomState: async (roomId) => { - if (roomId === '!valid:room') { - return [ - { - type: 'm.policy.rule.server', - state_key: 'should-not-be-here', - content: { - recommendation: 'still-not-interested', - entity: 'foo.com', - reason: 'foo', - } - }, - BANNED_SERVER_STATE_EVENT, - ]; - } - else if (roomId === '!anothervalid:room') { - return [ - {type: 'not-interested'}, - BANNED_USER_STATE_EVENT, - ]; - } - throw Error('Unknown room'); - }, - } - await banSync.syncRules(intent); + await banSync.syncRules(); expect(banSync.bannedEntites.size).toEqual(2); expect( banSync.bannedEntites.get(`!valid:room:banned-server`) diff --git a/src/bridge/IrcBridge.ts b/src/bridge/IrcBridge.ts index 0aa94780f..66038b563 100644 --- a/src/bridge/IrcBridge.ts +++ b/src/bridge/IrcBridge.ts @@ -90,7 +90,7 @@ export class IrcBridge { public readonly publicitySyncer: PublicitySyncer; public activityTracker: ActivityTracker|null = null; public readonly roomConfigs: RoomConfig; - public readonly matrixBanSyncer?: MatrixBanSync; + public matrixBanSyncer?: MatrixBanSync; private _mediaProxy?: MediaProxy; private clientPool!: ClientPool; // This gets defined in the `run` function private ircServers: IrcServer[] = []; @@ -212,7 +212,6 @@ export class IrcBridge { maxActionDelayMs: 5 * 60 * 1000, // 5 mins, defaultTtlMs: 10 * 60 * 1000, // 10 mins }); - this.matrixBanSyncer = this.config.ircService.banLists && new MatrixBanSync(this.config.ircService.banLists); this.matrixHandler = new MatrixHandler(this, this.config.ircService.matrixHandler, this.membershipQueue); this.privacyProtection = new PrivacyProtection(this); this.ircHandler = new IrcHandler( @@ -289,7 +288,7 @@ export class IrcBridge { this.config.ircService.logging = newConfig.ircService.logging; } - const banSyncPromise = this.matrixBanSyncer?.syncRules(this.bridge.getIntent()); + const banSyncPromise = this.matrixBanSyncer?.syncRules(); await this.dataStore.removeConfigMappings(); @@ -635,7 +634,11 @@ export class IrcBridge { } await this.bridge.initialise(); - await this.matrixBanSyncer?.syncRules(this.bridge.getIntent()); + this.matrixBanSyncer = this.config.ircService.banLists && new MatrixBanSync( + this.bridge.getIntent(), + this.config.ircService.banLists, + ); + await this.matrixBanSyncer?.syncRules(); this.matrixHandler.initialise(); this.activityTracker = new ActivityTracker(this.bridge.getIntent().matrixClient, { diff --git a/src/bridge/MatrixBanSync.ts b/src/bridge/MatrixBanSync.ts index 632d28ecb..46408e786 100644 --- a/src/bridge/MatrixBanSync.ts +++ b/src/bridge/MatrixBanSync.ts @@ -10,6 +10,7 @@ import { getLogger } from "../logging"; const log = getLogger("MatrixBanSync"); export interface MatrixBanSyncConfig { rooms: string[]; + serverBanLists?: { [ircServer: string]: string }; } enum BanEntityType { @@ -53,16 +54,37 @@ const supportedRecommendations = [ export class MatrixBanSync { private bannedEntites = new Map(); private subscribedRooms = new Set(); - constructor(private config: MatrixBanSyncConfig) { } + private ircServerBanRooms = new Map(); + constructor(private intent: Intent, private config: MatrixBanSyncConfig) { } - public async syncRules(intent: Intent) { + public async markUserAsBanned(ircServer: string, mxid: string, reason = "k-lined"): Promise { + this.bannedEntites.set(mxid, { + matcher: new MatrixGlob(mxid), + entityType: BanEntityType.User, + reason: reason, + }); + + const roomId = this.ircServerBanRooms.get(ircServer); + if (!roomId) { + log.warn(`No serverBanList configured for ${ircServer}; Matrix policy will not be created for ${mxid}`); + return; + } + + await this.intent.sendStateEvent(roomId, 'm.policy.rule.user', `rule:${mxid}`, { + "entity": mxid, + "reason": reason, + "recommendation": "m.ban" + }); + } + + public async syncRules() { this.bannedEntites.clear(); this.subscribedRooms.clear(); for (const roomIdOrAlias of this.config.rooms) { try { - const roomId = await intent.join(roomIdOrAlias); + const roomId = await this.intent.join(roomIdOrAlias); this.subscribedRooms.add(roomId); - const roomState = await intent.roomState(roomId, false) as WeakStateEvent[]; + const roomState = await this.intent.roomState(roomId, false) as WeakStateEvent[]; for (const evt of roomState) { this.handleIncomingState(evt, roomId); } @@ -71,6 +93,20 @@ export class MatrixBanSync { log.error(`Failed to read ban list from ${roomIdOrAlias}`, ex); } } + for (const [ircServer, roomIdOrAlias] of Object.entries(this.config.serverBanLists ?? {})) { + try { + const roomId = await this.intent.join(roomIdOrAlias); + this.subscribedRooms.add(roomId); + this.ircServerBanRooms.set(ircServer, roomId); + const roomState = await this.intent.roomState(roomId, false) as WeakStateEvent[]; + for (const evt of roomState) { + this.handleIncomingState(evt, roomId); + } + } + catch (ex) { + log.error(`Failed to sync rules for ${ircServer} in ${roomIdOrAlias}:`, ex); + } + } } /** @@ -139,8 +175,9 @@ export class MatrixBanSync { * @param config The new config. * @param intent The bot user intent. */ - public async updateConfig(config: MatrixBanSyncConfig, intent: Intent) { + // XXX not actually called, ever: hot reloading likely broken + public async updateConfig(config: MatrixBanSyncConfig) { this.config = config; - await this.syncRules(intent); + await this.syncRules(); } } diff --git a/src/irc/ClientPool.ts b/src/irc/ClientPool.ts index 227370644..77b0869d7 100644 --- a/src/irc/ClientPool.ts +++ b/src/irc/ClientPool.ts @@ -288,6 +288,11 @@ export class ClientPool { // Remove client if we failed to connect! this.removeBridgedClient(bridgedClient); } + if (err instanceof IRCConnectionError && err.code === IRCConnectionErrorCode.Banned) { + void this.ircBridge.matrixBanSyncer?.markUserAsBanned(server.domain, userId).catch((mbsErr: Error) => { + log.error(`Failed to mark ${userId} as banned: ${mbsErr.toString}`); + }); + } // If we failed to connect log.error("Couldn't connect virtual user %s (%s) to %s : %s", ircClientConfig.getDesiredNick(), userId, server.domain, JSON.stringify(err)); @@ -676,6 +681,12 @@ export class ClientPool { } if (disconnectReason === "banned" && userId) { + void this.ircBridge.matrixBanSyncer?.markUserAsBanned( + bridgedClient.server.domain, + userId, + ).catch((err: Error) => { + log.error(`Failed to mark ${userId} as banned: ${err.toString}`); + }); const req = new BridgeRequest(this.ircBridge.getAppServiceBridge().getRequestFactory().newRequest()); this.ircBridge.matrixHandler.quitUser( req, userId, [bridgedClient],