diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 22ff4b3f..07024c0e 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -23,10 +23,8 @@ import { RoomEvent, RoomMembershipManager, RoomMembershipRevisionIssuer, - RoomMessage, RoomStateManager, Task, - TextMessageContent, Value, isError, } from "matrix-protection-suite"; @@ -67,6 +65,8 @@ import { sendMatrixEventsFromDeadDocument, } from "./commands/interface-manager/MPSMatrixInterfaceAdaptor"; import { makeDraupnirCommandDispatcher } from "./commands/DraupnirCommandDispatcher"; +import { SafeModeToggle } from "./safemode/SafeModeToggle"; +import { makeCommandDispatcherTimelineListener } from "./safemode/ManagementRoom"; const log = new Logger("Draupnir"); @@ -106,6 +106,13 @@ export class Draupnir implements Client, MatrixAdaptorContext { public readonly capabilityMessageRenderer: RendererMessageCollector; + private readonly commandDispatcherTimelineListener = + makeCommandDispatcherTimelineListener( + this.managementRoom, + this.client, + this.commandDispatcher + ); + private constructor( public readonly client: MatrixSendClient, public readonly clientUserID: StringUserID, @@ -121,6 +128,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { /** Mjolnir has a feature where you can choose to accept invitations from a space and not just the management room. */ public readonly acceptInvitesFromRoom: MatrixRoomID, public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer, + public readonly safeModeToggle: SafeModeToggle, public readonly synapseAdminClient?: SynapseAdminClient ) { this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); @@ -169,7 +177,8 @@ export class Draupnir implements Client, MatrixAdaptorContext { policyRoomManager: PolicyRoomManager, roomMembershipManager: RoomMembershipManager, config: IConfig, - loggableConfigTracker: LoggableConfigTracker + loggableConfigTracker: LoggableConfigTracker, + safeModeToggle: SafeModeToggle ): Promise> { const acceptInvitesFromRoom = await (async () => { if (config.autojoinOnlyIfManager) { @@ -225,6 +234,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { loggableConfigTracker, acceptInvitesFromRoom.ok, acceptInvitesFromRoomIssuer.ok, + safeModeToggle, new SynapseAdminClient(client, clientUserID) ); const loadResult = await protectedRoomsSet.protections.loadProtections( @@ -303,42 +313,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { if (roomID !== this.managementRoomID) { return; } - if ( - Value.Check(RoomMessage, event) && - Value.Check(TextMessageContent, event.content) - ) { - if ( - event.content.body === - "** Unable to decrypt: The sender's device has not sent us the keys for this message. **" - ) { - log.info( - `Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${this.managementRoom.toPermalink()}.` - ); - void Task( - this.client.unstableApis - .addReactionToEvent(roomID, event.event_id, "⚠") - .then((_) => Ok(undefined)) - ); - void Task( - this.client.unstableApis - .addReactionToEvent(roomID, event.event_id, "UISI") - .then((_) => Ok(undefined)) - ); - void Task( - this.client.unstableApis - .addReactionToEvent(roomID, event.event_id, "🚨") - .then((_) => Ok(undefined)) - ); - return; - } - this.commandDispatcher.handleCommandMessageEvent( - { - event, - roomID, - }, - event.content.body - ); - } + this.commandDispatcherTimelineListener(roomID, event); this.reportManager.handleTimelineEvent(roomID, event); } @@ -347,14 +322,17 @@ export class Draupnir implements Client, MatrixAdaptorContext { * This will not start the appservice from listening and responding * to events. Nor will it start any syncing client. */ - public async start(): Promise { + public start(): void { + // to avoid handlers getting out of sync on clientRooms and leaking + // when draupnir keeps being started and restarted, we can basically + // clear all listeners each time and add the factory listener back. this.clientRooms.on("timeline", this.timelineEventListener); if (this.reportPoller) { - const reportPollSetting = await ReportPoller.getReportPollSetting( + // allow this to crash draupnir if it fails, since we need to know. + void this.reportPoller.startFromStoredSetting( this.client, this.managementRoomOutput ); - this.reportPoller.start(reportPollSetting); } } diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 7d9b6b76..654c4617 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -13,6 +13,11 @@ import { DefaultEventDecoder, setGlobalLoggerProvider, RoomStateBackingStore, + ClientsInRoomMap, + Task, + Logger, + ActionException, + ActionExceptionKind, } from "matrix-protection-suite"; import { BotSDKLogServiceLogger, @@ -20,7 +25,7 @@ import { MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, - resolveRoomReferenceSafe, + joinedRoomsSafe, } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { Draupnir } from "./Draupnir"; @@ -32,85 +37,256 @@ import { isStringRoomID, MatrixRoomReference, StringUserID, + MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; +import { Result, isError } from "@gnuxie/typescript-result"; +import { + SafeModeToggle, + SafeModeToggleOptions, +} from "./safemode/SafeModeToggle"; +import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode"; +import { ResultError } from "@gnuxie/typescript-result"; +import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause"; setGlobalLoggerProvider(new BotSDKLogServiceLogger()); +const log = new Logger("DraupnirBotMode"); + export function constructWebAPIs(draupnir: Draupnir): WebAPIs { return new WebAPIs(draupnir.reportManager, draupnir.config); } /** - * This is a file for providing default concrete implementations - * for all things to bootstrap Draupnir in 'bot mode'. - * However, people should be encouraged to make their own when - * APIs are stable as the protection-suite makes Draupnir - * almost completely modular and customizable. + * The bot mode toggle allows the entrypoint, either src/index.ts or + * manual test scripts, to setup and control Draupnir. + * This includes the webAPIS that accompany Draupnir in bot mode. + * + * The appservice also implements `SafeModeToggle` but has different requirements. + * This interface is exlusively used by the entrypoints to draupnir's bot mode. */ +interface BotModeTogle extends SafeModeToggle { + encryptionInitialized(): Promise; + stopEverything(): void; + startFromScratch( + options?: SafeModeToggleOptions + ): Promise>; +} -export async function makeDraupnirBotModeFromConfig( - client: MatrixSendClient, - matrixEmitter: SafeMatrixEmitter, - config: IConfig, - backingStore?: RoomStateBackingStore -): Promise { - const clientUserId = await client.getUserId(); - if (!isStringUserID(clientUserId)) { - throw new TypeError(`${clientUserId} is not a valid mxid`); - } - if ( - !isStringRoomAlias(config.managementRoom) && - !isStringRoomID(config.managementRoom) +export class DraupnirBotModeToggle implements BotModeTogle { + private draupnir: Draupnir | null = null; + private safeModeDraupnir: SafeModeDraupnir | null = null; + private webAPIs: WebAPIs | null = null; + + private constructor( + private readonly clientUserID: StringUserID, + private readonly managementRoom: MatrixRoomID, + private readonly clientsInRoomMap: ClientsInRoomMap, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly draupnirFactory: DraupnirFactory, + private readonly matrixEmitter: SafeMatrixEmitter, + private readonly config: IConfig ) { - throw new TypeError( - `${config.managementRoom} is not a valid room id or alias` + this.matrixEmitter.on("room.invite", (roomID, event) => { + this.clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + this.matrixEmitter.on("room.event", (roomID, event) => { + this.roomStateManagerFactory.handleTimelineEvent(roomID, event); + this.clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + } + public static async create( + client: MatrixSendClient, + matrixEmitter: SafeMatrixEmitter, + config: IConfig, + backingStore?: RoomStateBackingStore + ): Promise { + const clientUserID = await client.getUserId(); + if (!isStringUserID(clientUserID)) { + throw new TypeError(`${clientUserID} is not a valid mxid`); + } + if ( + !isStringRoomAlias(config.managementRoom) && + !isStringRoomID(config.managementRoom) + ) { + throw new TypeError( + `${config.managementRoom} is not a valid room id or alias` + ); + } + const configManagementRoomReference = MatrixRoomReference.fromRoomIDOrAlias( + config.managementRoom + ); + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientCapabilityFactory = new ClientCapabilityFactory( + clientsInRoomMap, + DefaultEventDecoder + ); + // needed to have accurate join infomration. + ( + await clientsInRoomMap.makeClientRooms(clientUserID, async () => + joinedRoomsSafe(client) + ) + ).expect("Unable to create ClientRooms"); + const clientPlatform = clientCapabilityFactory.makeClientPlatform( + clientUserID, + client + ); + const managementRoom = ( + await clientPlatform + .toRoomResolver() + .resolveRoom(configManagementRoomReference) + ).expect("Unable to resolve Draupnir's management room"); + (await clientPlatform.toRoomJoiner().joinRoom(managementRoom)).expect( + "Unable to join Draupnir's management room" + ); + const clientProvider = async (userID: StringUserID) => { + if (userID !== clientUserID) { + throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); + } + return client; + }; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + DefaultEventDecoder, + backingStore + ); + const draupnirFactory = new DraupnirFactory( + clientsInRoomMap, + clientCapabilityFactory, + clientProvider, + roomStateManagerFactory + ); + return new DraupnirBotModeToggle( + clientUserID, + managementRoom, + clientsInRoomMap, + roomStateManagerFactory, + draupnirFactory, + matrixEmitter, + config + ); + } + public async switchToDraupnir( + options?: SafeModeToggleOptions + ): Promise> { + if (this.draupnir !== null) { + return ResultError.Result( + `There is a draupnir for ${this.clientUserID} already running` + ); + } + const draupnirResult = await this.draupnirFactory.makeDraupnir( + this.clientUserID, + this.managementRoom, + this.config, + this ); + if (isError(draupnirResult)) { + return draupnirResult; + } + this.draupnir = draupnirResult.ok; + this.draupnir.start(); + if (options?.sendStatusOnStart) { + void Task(this.draupnir.startupComplete()); + try { + this.webAPIs = constructWebAPIs(this.draupnir); + await this.webAPIs.start(); + } catch (e) { + if (e instanceof Error) { + this.stopDraupnir(); + log.error("Failed to start webAPIs", e); + return ActionException.Result("Failed to start webAPIs", { + exceptionKind: ActionExceptionKind.Unknown, + exception: e, + }); + } else { + throw new TypeError("Someone is throwing garbage."); + } + } + } + this.stopSafeModeDraupnir(); + return draupnirResult; + } + public async switchToSafeMode( + cause: SafeModeCause, + options?: SafeModeToggleOptions + ): Promise> { + if (this.safeModeDraupnir !== null) { + return ResultError.Result( + `There is a safe mode draupnir for ${this.clientUserID} already running` + ); + } + const safeModeResult = await this.draupnirFactory.makeSafeModeDraupnir( + this.clientUserID, + this.managementRoom, + this.config, + cause, + this + ); + if (isError(safeModeResult)) { + return safeModeResult; + } + this.stopDraupnir(); + this.safeModeDraupnir = safeModeResult.ok; + this.safeModeDraupnir.start(); + if (options?.sendStatusOnStart) { + this.safeModeDraupnir.startupComplete(); + } + return safeModeResult; } - const configManagementRoomReference = MatrixRoomReference.fromRoomIDOrAlias( - config.managementRoom - ); - const managementRoom = ( - await resolveRoomReferenceSafe(client, configManagementRoomReference) - ).expect("Unable to resolve Draupnir's management room"); - await client.joinRoom( - managementRoom.toRoomIDOrAlias(), - managementRoom.getViaServers() - ); - const clientsInRoomMap = new StandardClientsInRoomMap(); - const clientProvider = async (userID: StringUserID) => { - if (userID !== clientUserId) { - throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); + public async startFromScratch( + options?: SafeModeToggleOptions + ): Promise> { + const draupnirResult = await this.switchToDraupnir(options ?? {}); + if (isError(draupnirResult)) { + if (this.config.safeMode?.bootIntoOnStartupFailure) { + log.error( + "Failed to start draupnir, switching to safe mode as configured", + draupnirResult.error + ); + return await this.switchToSafeMode( + { + reason: SafeModeReason.InitializationError, + error: draupnirResult.error, + }, + options ?? {} + ); + } else { + return draupnirResult; + } } - return client; - }; - const roomStateManagerFactory = new RoomStateManagerFactory( - clientsInRoomMap, - clientProvider, - DefaultEventDecoder, - backingStore - ); - const clientCapabilityFactory = new ClientCapabilityFactory( - clientsInRoomMap, - DefaultEventDecoder - ); - const draupnirFactory = new DraupnirFactory( - clientsInRoomMap, - clientCapabilityFactory, - clientProvider, - roomStateManagerFactory - ); - const draupnir = await draupnirFactory.makeDraupnir( - clientUserId, - managementRoom, - config - ); - matrixEmitter.on("room.invite", (roomID, event) => { - clientsInRoomMap.handleTimelineEvent(roomID, event); - }); - matrixEmitter.on("room.event", (roomID, event) => { - roomStateManagerFactory.handleTimelineEvent(roomID, event); - clientsInRoomMap.handleTimelineEvent(roomID, event); - }); - return draupnir.expect("Unable to create Draupnir"); + return draupnirResult; + } + + public async encryptionInitialized(): Promise { + if (this.draupnir !== null) { + try { + this.webAPIs = constructWebAPIs(this.draupnir); + await this.webAPIs.start(); + await this.draupnir.startupComplete(); + } catch (e) { + this.stopEverything(); + throw e; + } + } else if (this.safeModeDraupnir !== null) { + this.safeModeDraupnir.startupComplete(); + } + } + + private stopDraupnir(): void { + this.draupnir?.stop(); + this.draupnir = null; + this.webAPIs?.stop(); + this.webAPIs = null; + } + + private stopSafeModeDraupnir(): void { + this.safeModeDraupnir?.stop(); + this.safeModeDraupnir = null; + } + + public stopEverything(): void { + this.stopDraupnir(); + this.stopSafeModeDraupnir(); + } } diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index bcd3d0f0..ea9b4fc7 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -134,7 +134,6 @@ export class AppServiceDraupnirManager { if (isError(managedDraupnir)) { return managedDraupnir; } - this.baseManager.startDraupnir(mxid); incrementGaugeValue(this.instanceCountGauge, "offline", localPart); decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); incrementGaugeValue(this.instanceCountGauge, "online", localPart); @@ -288,7 +287,7 @@ export class AppServiceDraupnirManager { mjolnirRecord: MjolnirRecord ): Promise> { const clientUserID = this.draupnirMXID(mjolnirRecord); - if (this.baseManager.isDraupnirListening(clientUserID)) { + if (this.baseManager.isDraupnirAvailable(clientUserID)) { throw new TypeError( `${mjolnirRecord.local_part} is already running, we cannot start it.` ); diff --git a/src/commands/DraupnirCommands.ts b/src/commands/DraupnirCommands.ts index 9ff0f252..997177c5 100644 --- a/src/commands/DraupnirCommands.ts +++ b/src/commands/DraupnirCommands.ts @@ -47,6 +47,7 @@ import { DraupnirWatchPolicyRoomCommand, } from "./WatchUnwatchCommand"; import { DraupnirTopLevelCommands } from "./DraupnirCommandTable"; +import { DraupnirSafeModeCommand } from "./SafeModeCommand"; // TODO: These commands should all be moved to subdirectories tbh and this // should be split like an index file for each subdirectory. @@ -88,6 +89,7 @@ const DraupnirCommands = new StandardCommandTable("draupnir") .internCommand(DraupnirRoomsRemoveCommand, ["rooms", "remove"]) .internCommand(DraupnirListRulesCommand, ["rules"]) .internCommand(DraupnirRulesMatchingCommand, ["rules", "matching"]) + .internCommand(DraupnirSafeModeCommand, ["safe", "mode"]) .internCommand(DraupnirDisplaynameCommand, ["displayname"]) .internCommand(DraupnirSetPowerLevelCommand, ["powerlevel"]) .internCommand(DraupnirStatusCommand, ["status"]) diff --git a/src/commands/SafeModeCommand.ts b/src/commands/SafeModeCommand.ts new file mode 100644 index 00000000..648c2653 --- /dev/null +++ b/src/commands/SafeModeCommand.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + BasicInvocationInformation, + describeCommand, +} from "@the-draupnir-project/interface-manager"; +import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; +import { Result } from "@gnuxie/typescript-result"; +import { Draupnir } from "../Draupnir"; +import { SafeModeReason } from "../safemode/SafeModeCause"; +import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; + +export const DraupnirSafeModeCommand = describeCommand({ + summary: "Enter into safe mode.", + parameters: [], + async executor( + { safeModeToggle }: Draupnir, + info: BasicInvocationInformation + ): Promise> { + return safeModeToggle.switchToSafeMode( + { + reason: SafeModeReason.ByRequest, + user: info.commandSender, + }, + { sendStatusOnStart: true } + ); + }, +}); + +DraupnirInterfaceAdaptor.describeRenderer(DraupnirSafeModeCommand, { + isAlwaysSupposedToUseDefaultRenderer: true, +}); diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 3a38a39c..34e6bde4 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -370,3 +370,7 @@ export function renderTableHelp(table: CommandTable): DocumentNode { ); } + +export function wrapInRoot(node: DocumentNode): DocumentNode { + return {node}; +} diff --git a/src/config.ts b/src/config.ts index 8888939c..668cc0ce 100644 --- a/src/config.ts +++ b/src/config.ts @@ -89,6 +89,9 @@ export interface IConfig { redactReason: string; }; }; + safeMode?: { + bootIntoOnStartupFailure: boolean; + }; health: { healthz: { enabled: boolean; @@ -188,6 +191,9 @@ const defaultConfig: IConfig = { "You have mentioned too many users in this message, so we have had to redact it.", }, }, + safeMode: { + bootIntoOnStartupFailure: false, + }, health: { healthz: { enabled: false, diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 5567f014..f12ace19 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -5,6 +5,7 @@ import { ActionResult, ClientsInRoomMap, + Ok, StandardLoggableConfigTracker, isError, } from "matrix-protection-suite"; @@ -21,6 +22,9 @@ import { StringUserID, MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; +import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; +import { SafeModeCause } from "../safemode/SafeModeCause"; +import { SafeModeToggle } from "../safemode/SafeModeToggle"; export class DraupnirFactory { public constructor( @@ -35,7 +39,8 @@ export class DraupnirFactory { public async makeDraupnir( clientUserID: StringUserID, managementRoom: MatrixRoomID, - config: IConfig + config: IConfig, + toggle: SafeModeToggle ): Promise> { const client = await this.clientProvider(clientUserID); const clientRooms = await this.clientsInRoomMap.makeClientRooms( @@ -81,7 +86,41 @@ export class DraupnirFactory { policyRoomManager, roomMembershipManager, config, - configLogTracker + configLogTracker, + toggle + ); + } + + public async makeSafeModeDraupnir( + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + config: IConfig, + cause: SafeModeCause, + toggle: SafeModeToggle + ): Promise> { + const client = await this.clientProvider(clientUserID); + const clientRooms = await this.clientsInRoomMap.makeClientRooms( + clientUserID, + async () => joinedRoomsSafe(client) + ); + if (isError(clientRooms)) { + return clientRooms; + } + const clientPlatform = this.clientCapabilityFactory.makeClientPlatform( + clientUserID, + client + ); + return Ok( + new SafeModeDraupnir( + cause, + client, + clientUserID, + clientPlatform, + managementRoom, + clientRooms.ok, + toggle, + config + ) ); } } diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index 2c76f99f..254ece75 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -8,12 +8,7 @@ // https://github.com/matrix-org/mjolnir // -import { - ActionError, - ActionResult, - Task, - isError, -} from "matrix-protection-suite"; +import { ActionError, ActionResult, isError } from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; @@ -21,16 +16,47 @@ import { StringUserID, MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; +import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; +import { SafeModeCause } from "../safemode/SafeModeCause"; +import { SafeModeToggle } from "../safemode/SafeModeToggle"; export class StandardDraupnirManager { - private readonly readyDraupnirs = new Map(); - private readonly listeningDraupnirs = new Map(); - private readonly failedDraupnirs = new Map(); + private readonly draupnir = new Map(); + private readonly failedDraupnir = new Map(); + private readonly safeModeDraupnir = new Map(); public constructor(protected readonly draupnirFactory: DraupnirFactory) { // nothing to do. } + public makeSafeModeToggle( + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + config: IConfig + ): SafeModeToggle { + // We need to alias to make the toggle frankly. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const draupnirManager = this; + const toggle: SafeModeToggle = Object.freeze({ + async switchToSafeMode(cause: SafeModeCause) { + return draupnirManager.makeSafeModeDraupnir( + clientUserID, + managementRoom, + config, + cause + ); + }, + async switchToDraupnir() { + return draupnirManager.makeDraupnir( + clientUserID, + managementRoom, + config + ); + }, + }); + return toggle; + } + public async makeDraupnir( clientUserID: StringUserID, managementRoom: MatrixRoomID, @@ -39,13 +65,10 @@ export class StandardDraupnirManager { const draupnir = await this.draupnirFactory.makeDraupnir( clientUserID, managementRoom, - config + config, + this.makeSafeModeToggle(clientUserID, managementRoom, config) ); - if (this.isDraupnirReady(clientUserID)) { - return ActionError.Result( - `There is a draupnir for ${clientUserID} already waiting to be started` - ); - } else if (this.isDraupnirListening(clientUserID)) { + if (this.isDraupnirAvailable(clientUserID)) { return ActionError.Result( `There is a draupnir for ${clientUserID} already running` ); @@ -58,21 +81,55 @@ export class StandardDraupnirManager { ); return draupnir; } - this.readyDraupnirs.set(clientUserID, draupnir.ok); - this.failedDraupnirs.delete(clientUserID); + this.draupnir.set(clientUserID, draupnir.ok); + this.failedDraupnir.delete(clientUserID); + draupnir.ok.start(); return draupnir; } - public isDraupnirReady(draupnirClientID: StringUserID): boolean { - return this.readyDraupnirs.has(draupnirClientID); + public async makeSafeModeDraupnir( + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + config: IConfig, + cause: SafeModeCause + ): Promise> { + if (this.isDraupnirAvailable(clientUserID)) { + return ActionError.Result( + `There is a draupnir for ${clientUserID} already running` + ); + } + const safeModeDraupnir = await this.draupnirFactory.makeSafeModeDraupnir( + clientUserID, + managementRoom, + config, + cause, + this.makeSafeModeToggle(clientUserID, managementRoom, config) + ); + if (isError(safeModeDraupnir)) { + this.reportUnstartedDraupnir( + DraupnirFailType.InitializationError, + safeModeDraupnir.error, + clientUserID + ); + return safeModeDraupnir; + } + safeModeDraupnir.ok.start(); + this.safeModeDraupnir.set(clientUserID, safeModeDraupnir.ok); + return safeModeDraupnir; } - public isDraupnirListening(draupnirClientID: StringUserID): boolean { - return this.listeningDraupnirs.has(draupnirClientID); + /** + * Whether the draupnir is available to the user, either normally or via safe mode. + */ + public isDraupnirAvailable(draupnirClientID: StringUserID): boolean { + return ( + this.draupnir.has(draupnirClientID) || + this.safeModeDraupnir.has(draupnirClientID) + ); } public isDraupnirFailed(draupnirClientID: StringUserID): boolean { - return this.failedDraupnirs.has(draupnirClientID); + return this.failedDraupnir.has(draupnirClientID); } public reportUnstartedDraupnir( @@ -80,50 +137,35 @@ export class StandardDraupnirManager { cause: unknown, draupnirClientID: StringUserID ): void { - this.failedDraupnirs.set( + this.failedDraupnir.set( draupnirClientID, new UnstartedDraupnir(draupnirClientID, failType, cause) ); } public getUnstartedDraupnirs(): UnstartedDraupnir[] { - return [...this.failedDraupnirs.values()]; + return [...this.failedDraupnir.values()]; } public findUnstartedDraupnir( draupnirClientID: StringUserID ): UnstartedDraupnir | undefined { - return this.failedDraupnirs.get(draupnirClientID); + return this.failedDraupnir.get(draupnirClientID); } public findRunningDraupnir( draupnirClientID: StringUserID ): Draupnir | undefined { - return this.listeningDraupnirs.get(draupnirClientID); - } - - public startDraupnir(clientUserID: StringUserID): void { - const draupnir = this.readyDraupnirs.get(clientUserID); - if (draupnir === undefined) { - throw new TypeError( - `Trying to start a draupnir that hasn't been created ${clientUserID}` - ); - } - // FIXME: This is a little more than suspect that there are no handlers if starting fails? - // unclear to me what can fail though. - void Task(draupnir.start()); - this.listeningDraupnirs.set(clientUserID, draupnir); - this.readyDraupnirs.delete(clientUserID); + return this.draupnir.get(draupnirClientID); } public stopDraupnir(clientUserID: StringUserID): void { - const draupnir = this.listeningDraupnirs.get(clientUserID); + const draupnir = this.draupnir.get(clientUserID); if (draupnir === undefined) { return; } else { draupnir.stop(); - this.listeningDraupnirs.delete(clientUserID); - this.readyDraupnirs.set(clientUserID, draupnir); + this.draupnir.delete(clientUserID); } } } diff --git a/src/index.ts b/src/index.ts index b0fe4609..b006bf26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,7 @@ // import * as path from "path"; - import { Healthz } from "./health/healthz"; - import { LogLevel, LogService, @@ -24,14 +22,9 @@ import { import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { read as configRead } from "./config"; import { initializeSentry, patchMatrixClient } from "./utils"; -import { - constructWebAPIs, - makeDraupnirBotModeFromConfig, -} from "./DraupnirBotMode"; -import { Draupnir } from "./Draupnir"; +import { DraupnirBotModeToggle } from "./DraupnirBotMode"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DefaultEventDecoder, Task } from "matrix-protection-suite"; -import { WebAPIs } from "./webapis/WebAPIs"; +import { DefaultEventDecoder } from "matrix-protection-suite"; import { SqliteRoomStateBackingStore } from "./backingstore/better-sqlite3/SqliteRoomStateBackingStore"; void (async function () { @@ -54,8 +47,7 @@ void (async function () { healthz.listen(); } - let bot: Draupnir | null = null; - let apis: WebAPIs | null = null; + let bot: DraupnirBotModeToggle | null = null; try { const storagePath = path.isAbsolute(config.dataPath) ? config.dataPath @@ -104,29 +96,31 @@ void (async function () { eventDecoder ) : undefined; - bot = await makeDraupnirBotModeFromConfig( + bot = await DraupnirBotModeToggle.create( client, new SafeMatrixEmitterWrapper(client, eventDecoder), config, store ); - apis = constructWebAPIs(bot); + + // We don't want to send the status on start, as we need to initialize e2ee first (using client.start); + (await bot.startFromScratch({ sendStatusOnStart: false })).expect( + "Failed to start Draupnir" + ); } catch (err) { console.error( `Failed to setup mjolnir from the config ${config.dataPath}: ${err}` ); + bot?.stopEverything(); throw err; } try { - await bot.start(); await config.RUNTIME.client.start(); - void Task(bot.startupComplete()); - await apis.start(); + await bot.encryptionInitialized(); healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); - bot.stop(); - apis.stop(); + bot.stopEverything(); throw err; } })(); diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 3fc9f893..463089d3 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -207,6 +207,18 @@ export class ReportPoller { } return reportPollSetting; } + + public async startFromStoredSetting( + client: MatrixSendClient, + managementRoomOutput: ManagementRoomOutput + ): Promise { + const reportPollSetting = await ReportPoller.getReportPollSetting( + client, + managementRoomOutput + ); + this.start(reportPollSetting); + } + public start({ from: startFrom }: ReportPollSetting) { if (this.timeout === null) { this.from = startFrom; diff --git a/src/safemode/DraupnirSafeMode.ts b/src/safemode/DraupnirSafeMode.ts new file mode 100644 index 00000000..994e1969 --- /dev/null +++ b/src/safemode/DraupnirSafeMode.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + ClientPlatform, + ClientRooms, + EventReport, + RoomEvent, + Task, +} from "matrix-protection-suite"; +import { + MatrixAdaptorContext, + sendMatrixEventsFromDeadDocument, +} from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { + StringUserID, + StringRoomID, + MatrixRoomID, +} from "@the-draupnir-project/matrix-basic-types"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixReactionHandler } from "../commands/interface-manager/MatrixReactionHandler"; +import { IConfig } from "../config"; +import { SafeModeCause } from "./SafeModeCause"; +import { makeSafeModeCommandDispatcher } from "./SafeModeCommandDispatcher"; +import { + ARGUMENT_PROMPT_LISTENER, + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt, + makeListenerForPromptDefault, +} from "../commands/interface-manager/MatrixPromptForAccept"; +import { makeCommandDispatcherTimelineListener } from "./ManagementRoom"; +import { SafeModeToggle } from "./SafeModeToggle"; +import { Result } from "@gnuxie/typescript-result"; +import { + renderSafeModeStatusInfo, + safeModeStatusInfo, +} from "./commands/StatusCommand"; +import { wrapInRoot } from "../commands/interface-manager/MatrixHelpRenderer"; + +export class SafeModeDraupnir implements MatrixAdaptorContext { + public reactionHandler: MatrixReactionHandler; + private readonly timelineEventListener = this.handleTimelineEvent.bind(this); + private readonly commandDispatcher = makeSafeModeCommandDispatcher(this); + private readonly commandDispatcherTimelineListener = + makeCommandDispatcherTimelineListener( + this.managementRoom, + this.client, + this.commandDispatcher + ); + public constructor( + public readonly cause: SafeModeCause, + public readonly client: MatrixSendClient, + public readonly clientUserID: StringUserID, + public readonly clientPlatform: ClientPlatform, + public readonly managementRoom: MatrixRoomID, + private readonly clientRooms: ClientRooms, + public readonly safeModeToggle: SafeModeToggle, + public readonly config: IConfig + //private readonly roomStateManager: RoomStateManager, + //private readonly policyRoomManager: PolicyRoomManager, + //private readonly roomMembershipManager: RoomMembershipManager, + ) { + this.reactionHandler = new MatrixReactionHandler( + managementRoom.toRoomIDOrAlias(), + client, + this.clientUserID, + this.clientPlatform + ); + this.reactionHandler.on( + ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt(this.commandDispatcher) + ); + this.reactionHandler.on( + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForPromptDefault(this.commandDispatcher) + ); + } + + handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + this.commandDispatcherTimelineListener(roomID, event); + } + handleEventReport(_report: EventReport): void { + throw new Error("Method not implemented."); + } + + public get commandRoomID(): StringRoomID { + return this.managementRoom.toRoomIDOrAlias(); + } + + /** + * Start responding to events. + * This will not start the appservice from listening and responding + * to events. Nor will it start any syncing client. + */ + public start(): void { + this.clientRooms.on("timeline", this.timelineEventListener); + } + + public stop(): void { + this.clientRooms.off("timeline", this.timelineEventListener); + } + + public startupComplete(): void { + void Task( + sendMatrixEventsFromDeadDocument( + this.clientPlatform.toRoomMessageSender(), + this.commandRoomID, + wrapInRoot(renderSafeModeStatusInfo(safeModeStatusInfo(this))), + {} + ) as Promise> + ); + } +} diff --git a/src/safemode/ManagementRoom.ts b/src/safemode/ManagementRoom.ts new file mode 100644 index 00000000..8e16a2f5 --- /dev/null +++ b/src/safemode/ManagementRoom.ts @@ -0,0 +1,77 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { MatrixInterfaceCommandDispatcher } from "@the-draupnir-project/interface-manager"; +import { + MatrixRoomID, + StringRoomID, +} from "@the-draupnir-project/matrix-basic-types"; +import { + Logger, + Ok, + RoomEvent, + RoomMessage, + Task, + TextMessageContent, + Value, +} from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixEventContext } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; + +const log = new Logger("ManagementRoom"); + +export function makeCommandDispatcherTimelineListener( + managementRoom: MatrixRoomID, + client: MatrixSendClient, + dispatcher: MatrixInterfaceCommandDispatcher +): (roomID: StringRoomID, event: RoomEvent) => void { + const managementRoomID = managementRoom.toRoomIDOrAlias(); + return function (roomID, event): void { + if (roomID !== managementRoomID) { + return; + } + if ( + Value.Check(RoomMessage, event) && + Value.Check(TextMessageContent, event.content) + ) { + if ( + event.content.body === + "** Unable to decrypt: The sender's device has not sent us the keys for this message. **" + ) { + log.info( + `Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${managementRoom.toPermalink()}.` + ); + void Task( + client.unstableApis + .addReactionToEvent(roomID, event.event_id, "⚠") + .then((_) => Ok(undefined)) + ); + void Task( + client.unstableApis + .addReactionToEvent(roomID, event.event_id, "UISI") + .then((_) => Ok(undefined)) + ); + void Task( + client.unstableApis + .addReactionToEvent(roomID, event.event_id, "🚨") + .then((_) => Ok(undefined)) + ); + return; + } + dispatcher.handleCommandMessageEvent( + { + event, + roomID, + }, + event.content.body + ); + } + }; +} diff --git a/src/safemode/SafeModeCause.ts b/src/safemode/SafeModeCause.ts new file mode 100644 index 00000000..19e94122 --- /dev/null +++ b/src/safemode/SafeModeCause.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { ResultError } from "@gnuxie/typescript-result"; +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; + +export enum SafeModeReason { + InitializationError = "InitializationError", + ByRequest = "ByRequest", +} + +export type SafeModeCause = + | { reason: SafeModeReason.ByRequest; user: StringUserID } + | { reason: SafeModeReason.InitializationError; error: ResultError }; diff --git a/src/safemode/SafeModeCommandDispatcher.ts b/src/safemode/SafeModeCommandDispatcher.ts new file mode 100644 index 00000000..b0ae6d46 --- /dev/null +++ b/src/safemode/SafeModeCommandDispatcher.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + CommandPrefixExtractor, + MatrixInterfaceCommandDispatcher, + StandardMatrixInterfaceCommandDispatcher, +} from "@the-draupnir-project/interface-manager"; +import { SafeModeDraupnir } from "./DraupnirSafeMode"; +import { + MPSCommandDispatcherCallbacks, + MatrixEventContext, + invocationInformationFromMatrixEventcontext, +} from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { userLocalpart } from "@the-draupnir-project/matrix-basic-types"; +import { SafeModeCommands } from "./commands/SafeModeCommands"; +import { SafeModeHelpCommand } from "./commands/HelpCommand"; +import { SafeModeInterfaceAdaptor } from "./commands/SafeModeAdaptor"; + +function makePrefixExtractor( + safeModeDraupnir: SafeModeDraupnir +): CommandPrefixExtractor { + const plainPrefixes = [ + "!draupnir", + userLocalpart(safeModeDraupnir.clientUserID), + safeModeDraupnir.clientUserID, + ]; + const allPossiblePrefixes = [ + ...plainPrefixes.map((p) => `!${p}`), + ...plainPrefixes.map((p) => `${p}:`), + ...plainPrefixes, + ...(safeModeDraupnir.config.commands.allowNoPrefix ? ["!"] : []), + ]; + return (body) => { + const isPrefixUsed = allPossiblePrefixes.find((p) => + body.toLowerCase().startsWith(p.toLowerCase()) + ); + return isPrefixUsed ? "draupnir" : undefined; + }; +} + +export function makeSafeModeCommandDispatcher( + safeModeDraupnir: SafeModeDraupnir +): MatrixInterfaceCommandDispatcher { + return new StandardMatrixInterfaceCommandDispatcher( + SafeModeInterfaceAdaptor, + safeModeDraupnir, + SafeModeCommands, + SafeModeHelpCommand, + invocationInformationFromMatrixEventcontext, + { + ...MPSCommandDispatcherCallbacks, + prefixExtractor: makePrefixExtractor(safeModeDraupnir), + } + ); +} diff --git a/src/safemode/SafeModeToggle.ts b/src/safemode/SafeModeToggle.ts new file mode 100644 index 00000000..ddc831fc --- /dev/null +++ b/src/safemode/SafeModeToggle.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Result } from "@gnuxie/typescript-result"; +import { Draupnir } from "../Draupnir"; +import { SafeModeDraupnir } from "./DraupnirSafeMode"; +import { SafeModeCause } from "./SafeModeCause"; + +export type SafeModeToggleOptions = { sendStatusOnStart?: boolean }; + +export interface SafeModeToggle { + /** + * Switch the bot to Draupnir mode. + * We expect that the result represents the entire conversion. + * We expect that the same matrix client is shared between the bots. + * That means that by the command responds with ticks and crosses, + * draupnir will be running or we will still be in safe mode. + */ + switchToDraupnir(options?: SafeModeToggleOptions): Promise>; + switchToSafeMode( + cause: SafeModeCause, + options?: SafeModeToggleOptions + ): Promise>; +} diff --git a/src/safemode/commands/HelpCommand.tsx b/src/safemode/commands/HelpCommand.tsx new file mode 100644 index 00000000..748e1fbc --- /dev/null +++ b/src/safemode/commands/HelpCommand.tsx @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { SafeModeCommands } from "./SafeModeCommands"; +import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; + +import { Result } from "@gnuxie/typescript-result"; +import { + DeadDocumentJSX, + describeCommand, + TopPresentationSchema, + CommandTable, +} from "@the-draupnir-project/interface-manager"; +import { Ok, isError } from "matrix-protection-suite"; +import { renderTableHelp } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { safeModeHeader } from "./StatusCommand"; + +export const SafeModeHelpCommand = describeCommand({ + rest: { + name: "command parts", + acceptor: TopPresentationSchema, + }, + summary: "Display this message", + executor: async function ( + _context, + _keywords + ): Promise> { + return Ok(SafeModeCommands); + }, + parameters: [], +}); + +SafeModeInterfaceAdaptor.describeRenderer(SafeModeHelpCommand, { + JSXRenderer(result) { + if (isError(result)) { + throw new TypeError("This should never fail"); + } + return Ok( + + {safeModeHeader()} + {renderTableHelp(result.ok)} + + ); + }, +}); diff --git a/src/safemode/commands/RestartDraupnirCommand.ts b/src/safemode/commands/RestartDraupnirCommand.ts new file mode 100644 index 00000000..e40048a2 --- /dev/null +++ b/src/safemode/commands/RestartDraupnirCommand.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { describeCommand } from "@the-draupnir-project/interface-manager"; +import { Draupnir } from "../../Draupnir"; +import { SafeModeDraupnir } from "../DraupnirSafeMode"; +import { Result } from "@gnuxie/typescript-result"; +import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; + +export const SafeModeRestartCommand = describeCommand({ + summary: "Restart Draupnir, quitting safe mode.", + parameters: [], + async executor({ + safeModeToggle, + }: SafeModeDraupnir): Promise> { + return safeModeToggle.switchToDraupnir({ sendStatusOnStart: true }); + }, +}); + +SafeModeInterfaceAdaptor.describeRenderer(SafeModeRestartCommand, { + isAlwaysSupposedToUseDefaultRenderer: true, +}); diff --git a/src/safemode/commands/SafeModeAdaptor.ts b/src/safemode/commands/SafeModeAdaptor.ts new file mode 100644 index 00000000..46b0480c --- /dev/null +++ b/src/safemode/commands/SafeModeAdaptor.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + StandardAdaptorContextToCommandContextTranslator, + StandardMatrixInterfaceAdaptor, +} from "@the-draupnir-project/interface-manager"; +import { + MatrixEventContext, + invocationInformationFromMatrixEventcontext, + MPSMatrixInterfaceAdaptorCallbacks, + MPSCommandDispatcherCallbacks, +} from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { SafeModeDraupnir } from "../DraupnirSafeMode"; + +export const SafeModeContextToCommandContextTranslator = + new StandardAdaptorContextToCommandContextTranslator(); + +export const SafeModeInterfaceAdaptor = new StandardMatrixInterfaceAdaptor< + SafeModeDraupnir, + MatrixEventContext +>( + SafeModeContextToCommandContextTranslator, + invocationInformationFromMatrixEventcontext, + MPSMatrixInterfaceAdaptorCallbacks, + MPSCommandDispatcherCallbacks +); diff --git a/src/safemode/commands/SafeModeCommands.tsx b/src/safemode/commands/SafeModeCommands.tsx new file mode 100644 index 00000000..c7ab6426 --- /dev/null +++ b/src/safemode/commands/SafeModeCommands.tsx @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StandardCommandTable } from "@the-draupnir-project/interface-manager"; +import { SafeModeHelpCommand } from "./HelpCommand"; +import { SafeModeStatusCommand } from "./StatusCommand"; +import { SafeModeRestartCommand } from "./RestartDraupnirCommand"; + +export const SafeModeCommands = new StandardCommandTable("safe mode") + .internCommand(SafeModeHelpCommand, ["draupnir", "help"]) + .internCommand(SafeModeStatusCommand, ["draupnir", "status"]) + .internCommand(SafeModeRestartCommand, ["draupnir", "restart"]); diff --git a/src/safemode/commands/StatusCommand.tsx b/src/safemode/commands/StatusCommand.tsx new file mode 100644 index 00000000..2f0d0458 --- /dev/null +++ b/src/safemode/commands/StatusCommand.tsx @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Result, ResultError } from "@gnuxie/typescript-result"; +import { + DeadDocumentJSX, + DocumentNode, + describeCommand, +} from "@the-draupnir-project/interface-manager"; +import { ActionException, Ok, isError } from "matrix-protection-suite"; +import { SafeModeDraupnir } from "../DraupnirSafeMode"; +import { SafeModeCause, SafeModeReason } from "../SafeModeCause"; +import { + DOCUMENTATION_URL, + PACKAGE_JSON, + SOFTWARE_VERSION, +} from "../../config"; +import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; + +export function safeModeHeader(): DocumentNode { + return ( + + ⚠️ Draupnir is in safe mode (see status command) ⚠️ +
+
+ ); +} + +function renderSafeModeCauseError(error: ResultError): DocumentNode { + if (error instanceof ActionException) { + return ( + + Draupnir is in safe mode because Draupnir failed to start. +
+ {error.mostRelevantElaboration} +
+ Details can be found by providing the reference{" "} + {error.uuid} + to an administrator. +
{error.toReadableString()}
+
+ ); + } else { + return ( + + Draupnir is in safe mode because Draupnir failed to start. +
+ {error.mostRelevantElaboration} +
{error.toReadableString()}
+
+ ); + } +} +function renderSafeModeCause(safeModeCause: SafeModeCause): DocumentNode { + if (safeModeCause.reason === SafeModeReason.ByRequest) { + return ( + + Draupnir is in safe mode by request of {safeModeCause.user}. + + ); + } else { + return renderSafeModeCauseError(safeModeCause.error); + } +} + +export interface SafeModeStatusInfo { + safeModeCause: SafeModeCause; + documentationURL: string; + version: string; + repository: string; +} + +export function renderSafeModeStatusInfo( + info: SafeModeStatusInfo, + { showDocumentationURL = true }: { showDocumentationURL?: boolean } = {} +): DocumentNode { + return ( + + ⚠️ Draupnir is in safe mode ⚠️ + +
+ {renderSafeModeCause(info.safeModeCause)} +
+
+
+ Version: + {info.version} +
+ Repository: + {info.repository} +
+ {showDocumentationURL ? ( + + Documentation: {" "} + {info.documentationURL} + + ) : ( + + )} +
+
+ To attempt to restart, use !draupnir restart +
+ ); +} + +export function safeModeStatusInfo( + safeModeDraupnir: SafeModeDraupnir +): SafeModeStatusInfo { + return { + safeModeCause: safeModeDraupnir.cause, + documentationURL: DOCUMENTATION_URL, + version: SOFTWARE_VERSION, + repository: PACKAGE_JSON["repository"] ?? "Unknown", + }; +} + +export const SafeModeStatusCommand = describeCommand({ + summary: + "Display the status of safe mode, including the reason Draupnir started in safe mode.", + parameters: [], + async executor( + safeModeDraupnir: SafeModeDraupnir + ): Promise> { + return Ok(safeModeStatusInfo(safeModeDraupnir)); + }, +}); + +SafeModeInterfaceAdaptor.describeRenderer(SafeModeStatusCommand, { + JSXRenderer(result) { + if (isError(result)) { + return Ok(undefined); + } + return Ok({renderSafeModeStatusInfo(result.ok)}); + }, +}); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index efaca9a5..b8a182a6 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -12,13 +12,13 @@ import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, } from "matrix-protection-suite"; -import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { read as configRead } from "../../src/config"; import { patchMatrixClient } from "../../src/utils"; import { DraupnirTestContext, + draupnir, draupnirClient, - makeMjolnir, + makeBotModeToggle, teardownManagementRoom, } from "./mjolnirSetupUtils"; import { MatrixRoomReference } from "@the-draupnir-project/matrix-basic-types"; @@ -46,35 +46,23 @@ export const mochaHooks = { this.timeout(30000); const config = (this.config = configRead()); this.managementRoomAlias = config.managementRoom; - this.draupnir = await makeMjolnir(config); + this.toggle = await makeBotModeToggle(config, { eraseAccountData: true }); + this.draupnir = draupnir(); const draupnirMatrixClient = draupnirClient(); if (draupnirMatrixClient === null) { throw new TypeError(`setup code is broken`); } config.RUNTIME.client = draupnirMatrixClient; - await Promise.all([ - this.draupnir.client.setAccountData( - MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, - { rooms: [] } - ), - this.draupnir.client.setAccountData( - MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, - { references: [] } - ), - ]); - await this.draupnir.start(); - this.apis = constructWebAPIs(this.draupnir); - await this.apis.start(); await draupnirClient()?.start(); + await this.toggle.encryptionInitialized(); console.log("mochaHooks.beforeEach DONE"); }, ], afterEach: [ async function (this: DraupnirTestContext) { this.timeout(10000); - this.apis?.stop(); + this.toggle?.stopEverything(); draupnirClient()?.stop(); - this.draupnir?.stop(); // remove alias from management room and leave it. if (this.draupnir !== undefined) { diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 199270a7..313ddfc3 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -12,26 +12,21 @@ * This file is used to launch mjolnir for manual testing, creating a user and management room automatically if it doesn't already exist. */ -import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; +import { draupnirClient, makeBotModeToggle } from "./mjolnirSetupUtils"; import { read as configRead } from "../../src/config"; -import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { SqliteRoomStateBackingStore } from "../../src/backingstore/better-sqlite3/SqliteRoomStateBackingStore"; import path from "path"; -import { DefaultEventDecoder, Task } from "matrix-protection-suite"; +import { DefaultEventDecoder } from "matrix-protection-suite"; void (async () => { const config = configRead(); - const mjolnir = await makeMjolnir( - config, - new SqliteRoomStateBackingStore( + const toggle = await makeBotModeToggle(config, { + backingStore: new SqliteRoomStateBackingStore( path.join(config.dataPath, "room-state-backing-store.db"), DefaultEventDecoder - ) - ); - console.info(`management room ${mjolnir.managementRoom.toPermalink()}`); - await mjolnir.start(); - const apis = constructWebAPIs(mjolnir); + ), + allowSafeMode: true, + }); await draupnirClient()?.start(); - await apis.start(); - void Task(mjolnir.startupComplete()); + await toggle.encryptionInitialized(); })(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index c08c07c8..0b962f19 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -20,16 +20,18 @@ import { overrideRatelimitForUser, registerUser } from "./clientHelper"; import { initializeSentry, patchMatrixClient } from "../../src/utils"; import { IConfig } from "../../src/config"; import { Draupnir } from "../../src/Draupnir"; -import { makeDraupnirBotModeFromConfig } from "../../src/DraupnirBotMode"; +import { DraupnirBotModeToggle } from "../../src/DraupnirBotMode"; import { SafeMatrixEmitter, SafeMatrixEmitterWrapper, } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder, + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, RoomStateBackingStore, } from "matrix-protection-suite"; -import { WebAPIs } from "../../src/webapis/WebAPIs"; +import { SafeModeDraupnir } from "../../src/safemode/DraupnirSafeMode"; patchMatrixClient(); @@ -49,7 +51,7 @@ export type SafeMochaContext = Pick< export interface DraupnirTestContext extends SafeMochaContext { draupnir?: Draupnir; managementRoomAlias?: string; - apis?: WebAPIs; + toggle?: DraupnirBotModeToggle; config: IConfig; } @@ -100,7 +102,10 @@ async function configureMjolnir(config: IConfig) { } } -export function draupnir(): Draupnir | null { +export function draupnir(): Draupnir { + if (globalMjolnir === null) { + throw new TypeError("Setup code didn't run before you called `draupnir()`"); + } return globalMjolnir; } export function draupnirClient(): MatrixClient | null { @@ -119,10 +124,18 @@ let globalSafeEmitter: SafeMatrixEmitter | undefined; /** * Return a test instance of Mjolnir. */ -export async function makeMjolnir( +export async function makeBotModeToggle( config: IConfig, - backingStore?: RoomStateBackingStore -): Promise { + { + backingStore, + eraseAccountData, + allowSafeMode, + }: { + backingStore?: RoomStateBackingStore; + eraseAccountData?: boolean; + allowSafeMode?: boolean; + } = {} +): Promise { await configureMjolnir(config); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); @@ -135,21 +148,41 @@ export async function makeMjolnir( config.pantalaimon.username, config.pantalaimon.password ); + if (eraseAccountData) { + await Promise.all([ + client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), + client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { + references: [], + }), + ]); + } await overrideRatelimitForUser( config.homeserverUrl, await client.getUserId() ); await ensureAliasedRoomExists(client, config.managementRoom); - const mj = await makeDraupnirBotModeFromConfig( + const toggle = await DraupnirBotModeToggle.create( client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config, backingStore ); + // we don't want to send status on startup incase we want to test e2ee from the manual launch script. + const mj = ( + await toggle.startFromScratch({ sendStatusOnStart: false }) + ).expect("Could not create Draupnir"); + if (mj instanceof SafeModeDraupnir && !allowSafeMode) { + throw new TypeError( + "Setup code is wrong, shouldn't be booting into safe mode" + ); + } globalClient = client; - globalMjolnir = mj; + if (mj instanceof Draupnir) { + globalMjolnir = mj; + } + console.info(`management room ${mj.managementRoom.toPermalink()}`); globalSafeEmitter = new SafeMatrixEmitterWrapper(client, DefaultEventDecoder); - return mj; + return toggle; } /**