diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index cb54a6c5..08dd4a44 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -26,43 +26,17 @@ limitations under the License. */ import { - MjolnirPolicyRoomsConfig, - PolicyListConfig, - PolicyRoomManager, - ProtectedRoomsConfig, - ResolveRoom, - MjolnirProtectedRoomsConfig, - StandardProtectedRoomsSet, isError, - RoomStateManager, - MjolnirProtectionsConfig, - MjolnirEnabledProtectionsEvent, - MjolnirEnabledProtectionsEventType, - MatrixRoomID, - MjolnirProtectionSettingsEventType, - StandardSetMembership, - RoomMembershipManager, - SetMembership, StringUserID, - ProtectedRoomsSet, MatrixRoomReference, isStringUserID, isStringRoomAlias, isStringRoomID, - SetRoomState, - StandardSetRoomState, - StandardClientRooms, StandardClientsInRoomMap, - StandardEventDecoder, DefaultEventDecoder, } from "matrix-protection-suite"; import { - BotSDKMatrixAccountData, - BotSDKMatrixStateData, - BotSDKMjolnirProtectedRoomsStore, - BotSDKMjolnirWatchedPolicyRoomsStore, DefaultStateTrackingMeta, - ManagerManager, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, @@ -114,13 +88,13 @@ export async function makeDraupnirBotModeFromConfig( clientProvider, roomStateManagerFactory ); - const clientRooms = await draupnirFactory.makeDraupnirClientRooms( + const draupnir = await draupnirFactory.makeDraupnir( clientUserId, managementRoom.ok, config ); - if (isError(clientRooms)) { - throw clientRooms.error; + if (isError(draupnir)) { + throw draupnir.error; } - + return draupnir.ok; } diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index 25f97884..3170a36e 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -1,9 +1,10 @@ import request from "request"; import express from "express"; import * as bodyParser from "body-parser"; -import { MjolnirManager } from "./MjolnirManager"; import * as http from "http"; import { Logger } from "matrix-appservice-bridge"; +import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; +import { isError, isStringUserID } from "matrix-protection-suite"; const log = new Logger("Api"); /** @@ -15,7 +16,7 @@ export class Api { constructor( private homeserver: string, - private mjolnirManager: MjolnirManager, + private mjolnirManager: AppServiceDraupnirManager, ) {} /** @@ -88,16 +89,18 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } const mjolnirId = req.body["mxid"]; - if (mjolnirId === undefined) { + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { response.status(400).send("invalid request"); return; } - // TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId. - // https://github.com/matrix-org/mjolnir/issues/408 - const mjolnir = this.mjolnirManager.getMjolnir(mjolnirId, userId); + const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); if (mjolnir === undefined) { response.status(400).send("unknown mjolnir mxid"); return; @@ -122,8 +125,12 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - const existing = this.mjolnirManager.getOwnedMjolnirs(userId) + const existing = this.mjolnirManager.getOwnedDraupnir(userId) response.status(200).json(existing); } @@ -151,12 +158,20 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - // TODO: provisionNewMjolnir will throw if it fails... - // https://github.com/matrix-org/mjolnir/issues/408 - const [mjolnirId, managementRoom] = await this.mjolnirManager.provisionNewMjolnir(userId); - - response.status(200).json({ mxid: mjolnirId, roomId: managementRoom }); + const record = await this.mjolnirManager.provisionNewDraupnir(userId); + if (isError(record)) { + response.status(500).send(record.error.message); + return; + } + response.status(200).json({ + mxid: this.mjolnirManager.draupnirMXID(record.ok), + roomId: record.ok.management_room + }); } /** @@ -177,9 +192,13 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } const mjolnirId = req.body["mxid"]; - if (mjolnirId === undefined) { + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { response.status(400).send("invalid request"); return; } @@ -192,14 +211,14 @@ export class Api { // TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId. // https://github.com/matrix-org/mjolnir/issues/408 - const mjolnir = this.mjolnirManager.getMjolnir(mjolnirId, userId); + const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); if (mjolnir === undefined) { response.status(400).send("unknown mjolnir mxid"); return; } - await mjolnir.joinRoom(roomId); - await mjolnir.addProtectedRoom(roomId); + await mjolnir.client.joinRoom(roomId); + await mjolnir.protectedRoomsSet.protectedRoomsConfig.addRoom(roomId); response.status(200).json({}); } diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 7598484f..cccb4d11 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -26,7 +26,6 @@ limitations under the License. */ import { AppServiceRegistration, Bridge, Request, WeakEvent, MatrixUser, Logger, setBridgeVersion, PrometheusMetrics } from "matrix-appservice-bridge"; -import { MjolnirManager } from ".//MjolnirManager"; import { DataStore } from ".//datastore"; import { PgDataStore } from "./postgres/PgDataStore"; import { Api } from "./Api"; @@ -35,8 +34,9 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; -import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { isError } from "matrix-protection-suite"; +import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); /** @@ -55,12 +55,12 @@ export class MjolnirAppService { private constructor( public readonly config: IConfig, public readonly bridge: Bridge, - public readonly mjolnirManager: MjolnirManager, + public readonly draupnirManager: AppServiceDraupnirManager, public readonly accessControl: AccessControl, private readonly dataStore: DataStore, private readonly prometheusMetrics: PrometheusMetrics ) { - this.api = new Api(config.homeserver.url, mjolnirManager); + this.api = new Api(config.homeserver.url, draupnirManager); this.commands = new AppserviceCommandHandler(this); } @@ -91,7 +91,19 @@ export class MjolnirAppService { if (isError(accessControlRoom)) { throw accessControlRoom.error; } - const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom, bridge); + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientProvider = async (clientUserID: StringUserID) => bridge.getIntent(clientUserID).matrixClient; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + DefaultEventDecoder, + DefaultStateTrackingMeta + ); + const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(bridge.getBot().getUserId() as StringUserID); + const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, bridge); + if (isError(accessControl)) { + throw accessControl.error; + } // Activate /metrics endpoint for Prometheus // This should happen automatically but in testing this didn't happen in the docker image @@ -105,12 +117,13 @@ export class MjolnirAppService { labels: ["status", "uuid"], }); - const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl, instanceCountGauge); + const serverName = config.homeserver.domain; + const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(serverName, dataStore, bridge, accessControl.ok, roomStateManagerFactory, instanceCountGauge); const appService = new MjolnirAppService( config, bridge, mjolnirManager, - accessControl, + accessControl.ok, dataStore, prometheus ); @@ -156,7 +169,7 @@ export class MjolnirAppService { if ('invite' === mxEvent.content['membership'] && mxEvent.state_key === this.bridge.botUserId) { log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir`); try { - await this.mjolnirManager.provisionNewMjolnir(mxEvent.sender) + await this.draupnirManager.provisionNewDraupnir(mxEvent.sender as StringUserID) } catch (e: any) { log.error(`Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e); // continue, we still want to reject this invitation. @@ -169,7 +182,7 @@ export class MjolnirAppService { } } } - this.mjolnirManager.onEvent(request); + this.draupnirManager.onEvent(request); this.commands.handleEvent(mxEvent); } diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts new file mode 100644 index 00000000..1ac6e354 --- /dev/null +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -0,0 +1,293 @@ +/** + * Copyright (C) 2022-2024 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2022 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. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, committed, or licensed under the Apache License. + */ + +import { Bridge, Intent, Logger } from "matrix-appservice-bridge"; +import { getProvisionedMjolnirConfig } from "../config"; +import { MatrixClient } from "matrix-bot-sdk"; +import { DataStore, MjolnirRecord } from "./datastore"; +import { AccessControl } from "./AccessControl"; +import { randomUUID } from "crypto"; +import { Gauge } from "prom-client"; +import { decrementGaugeValue, incrementGaugeValue } from "../utils"; +import { Access, ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, PropagationType, StringRoomID, StringUserID, Task, isError, isStringRoomID, userLocalpart } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DraupnirFailType, StandardDraupnirManager, UnstartedDraupnir } from "../draupnirfactory/StandardDraupnirManager"; +import { DraupnirFactory } from "../draupnirfactory/DraupnirFactory"; + +const log = new Logger('AppServiceDraupnirManager'); + + +/** + * The DraupnirManager is responsible for: + * * Provisioning new draupnir instances. + * * Starting draupnir when the appservice is brought online. + * * Informing draupnir about new events. + */ +export class AppServiceDraupnirManager { + + private readonly baseManager: StandardDraupnirManager; + + private constructor( + private readonly serverName: string, + private readonly dataStore: DataStore, + private readonly bridge: Bridge, + private readonly accessControl: AccessControl, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly instanceCountGauge: Gauge<"status" | "uuid"> + ) { + const clientProvider = this.bridge.getIntent.bind(this.bridge); + const draupnirFactory = new DraupnirFactory( + clientProvider, + this.roomStateManagerFactory + ); + this.baseManager = new StandardDraupnirManager( + draupnirFactory, + roomStateManagerFactory.clientsInRoomMap + ); + } + + public draupnirMXID(mjolnirRecord: MjolnirRecord): StringUserID { + return `${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; + } + + /** + * Create the draupnir manager from the datastore and the access control. + * @param dataStore The data store interface that has the details for provisioned draupnirs. + * @param bridge The bridge abstraction that encapsulates details about the appservice. + * @param accessControl Who has access to the bridge. + * @returns A new mjolnir manager. + */ + public static async makeDraupnirManager( + serverName: string, + dataStore: DataStore, + bridge: Bridge, + accessControl: AccessControl, + roomStateManagerFactory: RoomStateManagerFactory, + instanceCountGauge: Gauge<"status" | "uuid"> + ): Promise { + const draupnirManager = new AppServiceDraupnirManager(serverName, dataStore, bridge, accessControl, roomStateManagerFactory, instanceCountGauge); + await draupnirManager.startDraupnirs(await dataStore.list()); + return draupnirManager; + } + + /** + * Creates a new mjolnir for a user. + * @param requestingUserID The user that is requesting this mjolnir and who will own it. + * @param managementRoomId An existing matrix room to act as the management room. + * @param client A client for the appservice virtual user that the new mjolnir should use. + * @returns A new managed mjolnir. + */ + public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise> { + const mxid = await client.getUserId() as StringUserID; + const managedDraupnir = await this.baseManager.makeDraupnir( + mxid, + MatrixRoomReference.fromRoomID(managementRoomID), + getProvisionedMjolnirConfig(managementRoomID) + ); + if (isError(managedDraupnir)) { + return managedDraupnir; + } + this.baseManager.startDraupnir(mxid); + incrementGaugeValue(this.instanceCountGauge, "offline", localPart); + decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); + incrementGaugeValue(this.instanceCountGauge, "online", localPart); + return managedDraupnir; + } + + /** + * Gets a draupnir for the corresponding mxid that is owned by a specific user. + * @param draupnirID The mxid of the draupnir we are trying to get. + * @param ownerID The owner of the draupnir. We ask for it explicitly to not leak access to another user's draupnir. + * @returns The matching managed draupnir instance. + */ + public async getRunningDraupnir(draupnirClientID: StringUserID, ownerID: StringUserID): Promise { + const records = await this.dataStore.lookupByOwner(ownerID); + if (records.length === 0) { + return undefined; + } + const associatedRecord = records.find(record => record.local_part === userLocalpart(draupnirClientID)); + if (associatedRecord === undefined || associatedRecord.owner !== ownerID) { + return undefined; + } + return this.baseManager.findRunningDraupnir(draupnirClientID); + } + + /** + * Find all of the running Draupnir that are owned by this specific user. + * @param ownerID An owner of multiple draupnir. + * @returns Any draupnir that they own. + */ + public async getOwnedDraupnir(ownerID: StringUserID): Promise { + const records = await this.dataStore.lookupByOwner(ownerID); + return records.map(record => this.draupnirMXID(record)); + } + + /** + * provision a new Draupnir for a matrix user. + * @param requestingUserID The mxid of the user we are creating a Draupnir for. + * @returns The matrix id of the new Draupnir and its management room. + */ + public async provisionNewDraupnir(requestingUserID: StringUserID): Promise> { + const access = this.accessControl.getUserAccess(requestingUserID); + if (access.outcome !== Access.Allowed) { + return ActionError.Result(`${requestingUserID} tried to provision a draupnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`); + } + const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID); + if (provisionedMjolnirs.length === 0) { + const mjolnirLocalPart = `draupnir_${randomUUID()}`; + const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); + + const managementRoomID = await mjIntent.matrixClient.createRoom({ + preset: 'private_chat', + invite: [requestingUserID], + name: `${requestingUserID}'s Draupnir`, + power_level_content_override: { + users: { + [requestingUserID]: 100, + // Give the mjolnir a higher PL so that can avoid issues with managing the management room. + [await mjIntent.matrixClient.getUserId()]: 101 + } + } + }); + if (!isStringRoomID(managementRoomID)) { + throw new TypeError(`${managementRoomID} malformed managmentRoomID`); + } + const draupnir = await this.makeInstance(mjolnirLocalPart, requestingUserID, managementRoomID, mjIntent.matrixClient); + if (isError(draupnir)) { + return draupnir; + } + const policyListResult = await createFirstList(draupnir.ok, requestingUserID, "list"); + if (isError(policyListResult)) { + return policyListResult; + } + const record = { + local_part: mjolnirLocalPart, + owner: requestingUserID, + management_room: managementRoomID, + } as MjolnirRecord; + await this.dataStore.store(record); + return Ok(record); + } else { + return ActionError.Result(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`); + } + } + + public getUnstartedDraupnirs(): UnstartedDraupnir[] { + return this.baseManager.getUnstartedDraupnirs(); + } + + public findUnstartedMjolnir(clientUserID: StringUserID): UnstartedDraupnir | undefined { + return this.baseManager.findUnstartedDraupnir(clientUserID); + } + + /** + * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. + * @param localPart The localpart of the virtual user we need a client for. + * @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient. + */ + private async makeMatrixIntent(localPart: string): Promise { + const mjIntent = this.bridge.getIntentFromLocalpart(localPart); + await mjIntent.ensureRegistered(); + return mjIntent; + } + + /** + * Attempt to start a mjolnir, and notify its management room of any failure to start. + * Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running. + * @param mjolnirRecord The record for the mjolnir that we want to start. + */ + public async startDraupnir(mjolnirRecord: MjolnirRecord): Promise> { + const clientUserID = this.draupnirMXID(mjolnirRecord); + if (this.baseManager.isDraupnirListening(clientUserID)) { + throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`); + } + const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); + const access = this.accessControl.getUserAccess(mjolnirRecord.owner); + if (access.outcome !== Access.Allowed) { + // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. + Task((async () => { + mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); + })()); + this.baseManager.reportUnstartedDraupnir(DraupnirFailType.Unauthorized, access.outcome, clientUserID); + decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); + incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part); + return ActionError.Result(`Tried to start a draupnir that has been disabled by the administrator: ${access.rule?.reason ?? 'no reason supplied'}`); + } else { + const startResult = await this.makeInstance( + mjolnirRecord.local_part, + mjolnirRecord.owner, + mjolnirRecord.management_room, + mjIntent.matrixClient, + ).catch((e) => { + log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); + this.baseManager.reportUnstartedDraupnir(DraupnirFailType.StartError, e, clientUserID); + return ActionException.Result(`Could not start draupnir ${clientUserID} for owner ${mjolnirRecord.owner}`, { + exception: e, + exceptionKind: ActionExceptionKind.Unknown + }) + }); + if (isError(startResult)) { + // Don't await, we don't want to clobber initialization if this fails. + Task((async () => { + mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir could not be started. Please alert the administrator`); + })()); + decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); + incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part); + return startResult; + } + return Ok(undefined); + } + } + + // TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api. + // https://github.com/matrix-org/mjolnir/issues/410 + /** + * Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users. + */ + public async startDraupnirs(mjolnirRecords: MjolnirRecord[]): Promise { + for (const mjolnirRecord of mjolnirRecords) { + await this.startDraupnir(mjolnirRecord); + } + } +} + +async function createFirstList(draupnir: Draupnir, draupnirOwnerID: StringUserID, shortcode: string): Promise> { + const policyRoom = await draupnir.policyRoomManager.createPolicyRoom( + shortcode, + [draupnirOwnerID], + { name: `${draupnirOwnerID}'s policy room` } + ); + if (isError(policyRoom)) { + throw policyRoom.error; + } + const addRoomResult = await draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(policyRoom.ok); + if (isError(addRoomResult)) { + return addRoomResult; + } + return await draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); +} diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts deleted file mode 100644 index 85bd420a..00000000 --- a/src/appservice/MjolnirManager.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { Request, WeakEvent, Bridge, Intent, Logger } from "matrix-appservice-bridge"; -import { getProvisionedMjolnirConfig } from "../config"; -import { MatrixClient, UserID } from "matrix-bot-sdk"; -import { DataStore, MjolnirRecord } from "./datastore"; -import { AccessControl } from "./AccessControl"; -import { randomUUID } from "crypto"; -import EventEmitter from "events"; -import { Gauge } from "prom-client"; -import { decrementGaugeValue, incrementGaugeValue } from "../utils"; -import { makeDraupnirBotModeFromConfig } from "../DraupnirBotMode"; -import { Access, MatrixRoomID, PropagationType, StringRoomID, StringUserID, isError, isStringRoomID } from "matrix-protection-suite"; -import { Draupnir } from "../Draupnir"; -import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; - -const log = new Logger('MjolnirManager'); - -// FIXME: AAAAAAAAaaaaaaaaaaaaa there's some inconsistency between "mjolnir id" "mjolnirRecord.localpart" and "user if of the mjolnir" -// all over this file. - -/** - * The MjolnirManager is responsible for: - * * Provisioning new mjolnir instances. - * * Starting mjolnirs when the appservice is brought online. - * * Informing mjolnirs about new events. - */ -export class MjolnirManager { - private readonly mjolnirs: Map = new Map(); - private readonly unstartedMjolnirs: Map = new Map(); - - private constructor( - private readonly dataStore: DataStore, - private readonly bridge: Bridge, - private readonly accessControl: AccessControl, - private readonly instanceCountGauge: Gauge<"status" | "uuid"> - ) { - - } - - /** - * Create the mjolnir manager from the datastore and the access control. - * @param dataStore The data store interface that has the details for provisioned mjolnirs. - * @param bridge The bridge abstraction that encapsulates details about the appservice. - * @param accessControl Who has access to the bridge. - * @returns A new mjolnir manager. - */ - public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, instanceCountGauge: Gauge<"status" | "uuid">): Promise { - const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl, instanceCountGauge); - await mjolnirManager.startMjolnirs(await dataStore.list()); - return mjolnirManager; - } - - /** - * Creates a new mjolnir for a user. - * @param requestingUserID The user that is requesting this mjolnir and who will own it. - * @param managementRoomId An existing matrix room to act as the management room. - * @param client A client for the appservice virtual user that the new mjolnir should use. - * @returns A new managed mjolnir. - */ - public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise { - const mxid = await client.getUserId(); - const intentListener = new MatrixIntentListener(mxid); - const managedMjolnir = new ManagedDraupnir( - requestingUserID, - await makeDraupnirBotModeFromConfig( - client, - intentListener, - getProvisionedMjolnirConfig(managementRoomID) - ), - intentListener, - ); - await managedMjolnir.start(); - this.mjolnirs.set(mxid, managedMjolnir); - this.unstartedMjolnirs.delete(mxid); - incrementGaugeValue(this.instanceCountGauge, "offline", localPart); - decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); - incrementGaugeValue(this.instanceCountGauge, "online", localPart); - return managedMjolnir; - } - - /** - * Gets a mjolnir for the corresponding mxid that is owned by a specific user. - * @param mjolnirID The mxid of the mjolnir we are trying to get. - * @param ownerID The owner of the mjolnir. We ask for it explicitly to not leak access to another user's mjolnir. - * @returns The matching managed mjolnir instance. - */ - public getMjolnir(mjolnirID: StringUserID, ownerID: StringUserID): ManagedDraupnir | undefined { - const mjolnir = this.mjolnirs.get(mjolnirID); - if (mjolnir) { - if (mjolnir.ownerID !== ownerID) { - throw new Error(`${mjolnirID} is owned by a different user to ${ownerID}`); - } else { - return mjolnir; - } - } else { - return undefined; - } - } - - /** - * Find all of the mjolnirs that are owned by this specific user. - * @param ownerID An owner of multiple mjolnirs. - * @returns Any mjolnirs that they own. - */ - public getOwnedMjolnirs(ownerID: StringUserID): ManagedDraupnir[] { - // TODO we need to use the database for this but also provide the utility - // for going from a MjolnirRecord to a ManagedMjolnir. - // https://github.com/matrix-org/mjolnir/issues/409 - return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerID !== ownerID); - } - - /** - * Listener that should be setup and called by `MjolnirAppService` while listening to the bridge abstraction provided by matrix-appservice-bridge. - */ - public onEvent(request: Request) { - // TODO We need a way to map a room id (that the event is from) to a set of managed mjolnirs that should be informed. - // https://github.com/matrix-org/mjolnir/issues/412 - [...this.mjolnirs.values()].forEach((mj: ManagedDraupnir) => mj.onEvent(request)); - } - - /** - * provision a new mjolnir for a matrix user. - * @param requestingUserID The mxid of the user we are creating a mjolnir for. - * @returns The matrix id of the new mjolnir and its management room. - */ - public async provisionNewMjolnir(requestingUserID: StringUserID): Promise<[StringUserID, StringRoomID]> { - const access = this.accessControl.getUserAccess(requestingUserID); - if (access.outcome !== Access.Allowed) { - throw new Error(`${requestingUserID} tried to provision a mjolnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`); - } - const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID); - if (provisionedMjolnirs.length === 0) { - const mjolnirLocalPart = `draupnir_${randomUUID()}`; - const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); - - const managementRoomID = await mjIntent.matrixClient.createRoom({ - preset: 'private_chat', - invite: [requestingUserID], - name: `${requestingUserID}'s Draupnir`, - power_level_content_override: { - users: { - [requestingUserID]: 100, - // Give the mjolnir a higher PL so that can avoid issues with managing the management room. - [await mjIntent.matrixClient.getUserId()]: 101 - } - } - }); - if (!isStringRoomID(managementRoomID)) { - throw new TypeError(`${managementRoomID} malformed managmentRoomID`); - } - - const mjolnir = await this.makeInstance(mjolnirLocalPart, requestingUserID, managementRoomID, mjIntent.matrixClient); - await mjolnir.createFirstList(requestingUserID, "list"); - - await this.dataStore.store({ - local_part: mjolnirLocalPart, - owner: requestingUserID, - management_room: managementRoomID, - }); - - return [mjIntent.userId as StringUserID, managementRoomID as StringRoomID]; - } else { - throw new Error(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} Draupnirs.`); - } - } - - public reportUnstartedMjolnir(code: UnstartedMjolnir.FailCode, cause: any, mjolnirRecord: MjolnirRecord, mxid: string): void { - this.unstartedMjolnirs.set(mjolnirRecord.local_part, new UnstartedMjolnir(mjolnirRecord, new UserID(mxid), code, cause)); - } - - public getUnstartedMjolnirs(): UnstartedMjolnir[] { - return [...this.unstartedMjolnirs.values()]; - } - - public findUnstartedMjolnir(localPart: string): UnstartedMjolnir | undefined { - return [...this.unstartedMjolnirs.values()].find(unstarted => unstarted.mjolnirRecord.local_part === localPart); - } - - /** - * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. - * @param localPart The localpart of the virtual user we need a client for. - * @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient. - */ - private async makeMatrixIntent(localPart: string): Promise { - const mjIntent = this.bridge.getIntentFromLocalpart(localPart); - await mjIntent.ensureRegistered(); - return mjIntent; - } - - /** - * Attempt to start a mjolnir, and notify its management room of any failure to start. - * Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running. - * @param mjolnirRecord The record for the mjolnir that we want to start. - */ - public async startMjolnir(mjolnirRecord: MjolnirRecord): Promise { - // if a mjolnir is in `this.mjonirs` it is started, as if it is present, it is going to be given Matrix events. - if (this.mjolnirs.has(mjolnirRecord.local_part)) { - throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`); - } - const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); - const access = this.accessControl.getUserAccess(mjolnirRecord.owner); - if (access.outcome !== Access.Allowed) { - // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); - this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.Unauthorized, access.outcome, mjolnirRecord, mjIntent.userId); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part); - } else { - await this.makeInstance( - mjolnirRecord.local_part, - mjolnirRecord.owner, - mjolnirRecord.management_room, - mjIntent.matrixClient, - ).catch((e: any) => { - log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); - // Don't await, we don't want to clobber initialization if this fails. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir could not be started. Please alert the administrator`); - this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.StartError, e, mjolnirRecord, mjIntent.userId); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part); - }); - } - } - - // TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api. - // https://github.com/matrix-org/mjolnir/issues/410 - /** - * Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users. - */ - public async startMjolnirs(mjolnirRecords: MjolnirRecord[]): Promise { - for (const mjolnirRecord of mjolnirRecords) { - await this.startMjolnir(mjolnirRecord); - } - } -} - -// FIXME: This isn't acceptable really, we need a managerManager that -// shares the same caches internally but uses different clients to do things. -// (Within MPS). -export class ManagedDraupnir { - public constructor( - public readonly ownerID: StringUserID, - private readonly draupnir: Draupnir, - private readonly matrixEmitter: MatrixIntentListener, - ) { } - - public async onEvent(request: Request) { - this.matrixEmitter.handleEvent(request.getData()); - } - - public async joinRoom(roomId: string) { - await this.draupnir.client.joinRoom(roomId); - } - public async addProtectedRoom(room: MatrixRoomID) { - await this.draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(room); - } - - public async createFirstList(draupnirOwnerID: StringUserID, shortcode: string) { - const policyRoom = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom( - shortcode, - [draupnirOwnerID], - { name: `${draupnirOwnerID}'s policy room` } - ); - if (isError(policyRoom)) { - throw policyRoom.error; - } - await this.addProtectedRoom(policyRoom.ok); - return await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); - } - - public get managementRoomID(): StringRoomID { - return this.draupnir.managementRoomID; - } - - /** - * Intended to be called by the MjolnirManager to make sure the mjolnir is ready to listen to events. - * This managed mjolnir should not be informed of any events via `onEvent` until `start` is called. - */ - public async start(): Promise { - await this.draupnir.start(); - } -} - -/** - * This is used to listen for events intended for a single mjolnir that resides in the appservice. - * This exists entirely because the Mjolnir class was previously designed only to receive events - * from a syncing matrix-bot-sdk MatrixClient. Since appservices provide a transactional push - * api for all users on the appservice, almost the opposite of sync, we needed to create an - * interface for both. See `MatrixEmitter`. - */ -export class MatrixIntentListener extends EventEmitter implements MatrixEmitter { - constructor(private readonly mjolnirId: string) { - super() - } - - public handleEvent(mxEvent: WeakEvent) { - // These are ordered to be the same as matrix-bot-sdk's MatrixClient - // They shouldn't need to be, but they are just in case it matters. - if (mxEvent['type'] === 'm.room.member' && mxEvent.state_key === this.mjolnirId) { - if (mxEvent['content']['membership'] === 'leave') { - this.emit('room.leave', mxEvent.room_id, mxEvent); - } - if (mxEvent['content']['membership'] === 'invite') { - this.emit('room.invite', mxEvent.room_id, mxEvent); - } - if (mxEvent['content']['membership'] === 'join') { - this.emit('room.join', mxEvent.room_id, mxEvent); - } - } - if (mxEvent.type === 'm.room.message') { - this.emit('room.message', mxEvent.room_id, mxEvent); - } - if (mxEvent.type === 'm.room.tombstone' && mxEvent.state_key === '') { - this.emit('room.archived', mxEvent.room_id, mxEvent); - } - this.emit('room.event', mxEvent.room_id, mxEvent); - - } - - /** - * To be called by `Mjolnir`. - */ - public async start() { - // Nothing to do. - } - - /** - * To be called by `Mjolnir`. - */ - public stop() { - // Nothing to do. - } -} - -export class UnstartedMjolnir { - constructor( - public readonly mjolnirRecord: MjolnirRecord, - public readonly mxid: UserID, - public readonly failCode: UnstartedMjolnir.FailCode, - public readonly cause: any, - ) { - - } -} - -export namespace UnstartedMjolnir { - export enum FailCode { - Unauthorized = "Unauthorized", - StartError = "StartError", - } -} diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index 2cc46067..9a9112f0 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -29,7 +29,7 @@ const listUnstarted = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([]), command: async function () { - return Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs()); + return Ok(this.appservice.draupnirManager.getUnstartedMjolnirs()); }, summary: "List any Mjolnir that failed to start." }); @@ -81,7 +81,7 @@ const restart = defineInterfaceCommand({ } ]), command: async function (this, _keywords, mjolnirId: UserID): Promise> { - const mjolnirManager = this.appservice.mjolnirManager; + const mjolnirManager = this.appservice.draupnirManager; const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart); if (mjolnir?.mjolnirRecord === undefined) { return ActionError.Result(`We can't find the unstarted mjolnir ${mjolnirId}, is it running?`); diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index cb510b72..589379af 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2022-2023 Gnuxie + * Copyright (C) 2022-2024 Gnuxie * All rights reserved. * * This file is modified and is NOT licensed under the Apache License. @@ -25,19 +25,19 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionResult, MatrixRoomID, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, RoomEvent, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; -export abstract class StandardDraupnirManager { +export class StandardDraupnirManager { private readonly readyDraupnirs = new Map(); private readonly listeningDraupnirs = new Map(); - private readonly clientsInRooms = new StandardClientsInRoomMap(); private readonly failedDraupnirs = new Map(); public constructor( - protected readonly draupnirFactory: DraupnirFactory + protected readonly draupnirFactory: DraupnirFactory, + private readonly clientsInRooms: ClientsInRoomMap ) { // nothing to do. } @@ -58,11 +58,11 @@ export abstract class StandardDraupnirManager { return ActionError.Result(`There is a draupnir for ${clientUserID} already running`); } if (isError(draupnir)) { - this.failedDraupnirs.set(clientUserID, new UnstartedDraupnir( - clientUserID, + this.reportUnstartedDraupnir( DraupnirFailType.InitializationError, - draupnir.error - )) + draupnir.error, + clientUserID + ); return draupnir; } this.readyDraupnirs.set(clientUserID, draupnir.ok); @@ -70,6 +70,34 @@ export abstract class StandardDraupnirManager { return draupnir; } + public isDraupnirReady(draupnirClientID: StringUserID): boolean { + return this.readyDraupnirs.has(draupnirClientID); + } + + public isDraupnirListening(draupnirClientID: StringUserID): boolean { + return this.listeningDraupnirs.has(draupnirClientID); + } + + public isDraupnirFailed(draupnirClientID: StringUserID): boolean { + return this.failedDraupnirs.has(draupnirClientID); + } + + public reportUnstartedDraupnir(failType: DraupnirFailType, cause: unknown, draupnirClientID: StringUserID): void { + this.failedDraupnirs.set(draupnirClientID, new UnstartedDraupnir(draupnirClientID, failType, cause)); + } + + public getUnstartedDraupnirs(): UnstartedDraupnir[] { + return [...this.failedDraupnirs.values()]; + } + + public findUnstartedDraupnir(draupnirClientID: StringUserID): UnstartedDraupnir | undefined { + return this.failedDraupnirs.get(draupnirClientID); + } + + public findRunningDraupnir(draupnirClientID: StringUserID): Draupnir | undefined { + return this.listeningDraupnirs.get(draupnirClientID); + } + public startDraupnir( clientUserID: StringUserID ): void { @@ -94,6 +122,10 @@ export abstract class StandardDraupnirManager { this.readyDraupnirs.set(clientUserID, draupnir); } } + + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + this.clientsInRooms.handleTimelineEvent(roomID, event); + } } export class UnstartedDraupnir {