diff --git a/config/default.yaml b/config/default.yaml index e9c001bf..d4570ccc 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -211,6 +211,23 @@ protections: roomStateBackingStore: enabled: false +# Safe mode provides recovery options for some failure modes when Draupnir +# fails to start. For example, if the bot fails to resolve a room alias in +# a watched list, or if the server has parted from a protected room and can't +# find a way back in. Safe mode will provide different options to recover from +# these. Such as unprotecting the room or unwatching the policy list. +# By default Draupnir will boot into safe mode only when the failure mode +# is recoverable. +# It may be desirable to prevent the bot from starting into safe mode if you have +# a pager system when Draupnir is down, as Draupnir could prevent your monitoring +# system from identifying a failure to start. +#safeMode: +# # The option for entering safe mode when Draupnir fails to start up. +# # - "RecoveryOnly" will only start the bot in safe mode when there are recovery options available. This is the default. +# # - "Never" will never start the bot in safe mode when Draupnir fails to start normally. +# # - "Always" will always start the bot in safe mode when Draupnir fails to start normally. +# bootOption: RecoveryOnly + # Options for advanced monitoring of the health of the bot. health: # healthz options. These options are best for use in container environments diff --git a/package.json b/package.json index d9cb6cf5..e097f182 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@sentry/node": "^7.17.2", "@sentry/tracing": "^7.17.2", "@sinclair/typebox": "0.32.34", - "@the-draupnir-project/interface-manager": "2.4.0", + "@the-draupnir-project/interface-manager": "2.4.1", "@the-draupnir-project/matrix-basic-types": "^0.2.0", "await-lock": "^2.2.2", "better-sqlite3": "^9.4.3", @@ -69,8 +69,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@1.4.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.4.0", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@1.5.1", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.5.1", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/Draupnir.ts b/src/Draupnir.ts index ef867b10..08e0f360 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -164,11 +164,19 @@ export class Draupnir implements Client, MatrixAdaptorContext { } this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.capabilityMessageRenderer = new DraupnirRendererMessageCollector( this.clientPlatform.toRoomMessageSender(), diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 654c4617..5288c810 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -18,6 +18,7 @@ import { Logger, ActionException, ActionExceptionKind, + ConfigRecoverableError, } from "matrix-protection-suite"; import { BotSDKLogServiceLogger, @@ -39,14 +40,16 @@ import { StringUserID, MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; -import { Result, isError } from "@gnuxie/typescript-result"; +import { Err, Ok, Result, isError } from "@gnuxie/typescript-result"; import { + DraupnirRestartError, SafeModeToggle, SafeModeToggleOptions, } from "./safemode/SafeModeToggle"; import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode"; import { ResultError } from "@gnuxie/typescript-result"; import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause"; +import { SafeModeBootOption } from "./safemode/BootOption"; setGlobalLoggerProvider(new BotSDKLogServiceLogger()); @@ -70,6 +73,10 @@ interface BotModeTogle extends SafeModeToggle { startFromScratch( options?: SafeModeToggleOptions ): Promise>; + maybeRecoverWithSafeMode( + error: ResultError, + options?: SafeModeToggleOptions + ): Promise>; } export class DraupnirBotModeToggle implements BotModeTogle { @@ -168,12 +175,13 @@ export class DraupnirBotModeToggle implements BotModeTogle { } public async switchToDraupnir( options?: SafeModeToggleOptions - ): Promise> { + ): Promise> { if (this.draupnir !== null) { return ResultError.Result( `There is a draupnir for ${this.clientUserID} already running` ); } + this.stopSafeModeDraupnir(); const draupnirResult = await this.draupnirFactory.makeDraupnir( this.clientUserID, this.managementRoom, @@ -181,7 +189,18 @@ export class DraupnirBotModeToggle implements BotModeTogle { this ); if (isError(draupnirResult)) { - return draupnirResult; + const safeModeResult = await this.maybeRecoverWithSafeMode( + draupnirResult.error, + options + ); + if (isError(safeModeResult)) { + return safeModeResult; + } else { + return DraupnirRestartError.Result( + "Draupnir failed to start, switching to safe mode.", + { safeModeDraupnir: safeModeResult.ok } + ); + } } this.draupnir = draupnirResult.ok; this.draupnir.start(); @@ -192,7 +211,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { await this.webAPIs.start(); } catch (e) { if (e instanceof Error) { - this.stopDraupnir(); + await this.stopDraupnir(); log.error("Failed to start webAPIs", e); return ActionException.Result("Failed to start webAPIs", { exceptionKind: ActionExceptionKind.Unknown, @@ -203,7 +222,6 @@ export class DraupnirBotModeToggle implements BotModeTogle { } } } - this.stopSafeModeDraupnir(); return draupnirResult; } public async switchToSafeMode( @@ -225,7 +243,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { if (isError(safeModeResult)) { return safeModeResult; } - this.stopDraupnir(); + await this.stopDraupnir(); this.safeModeDraupnir = safeModeResult.ok; this.safeModeDraupnir.start(); if (options?.sendStatusOnStart) { @@ -239,18 +257,8 @@ export class DraupnirBotModeToggle implements BotModeTogle { ): 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 ?? {} - ); + if (draupnirResult.error instanceof DraupnirRestartError) { + return Ok(draupnirResult.error.safeModeDraupnir); } else { return draupnirResult; } @@ -258,6 +266,33 @@ export class DraupnirBotModeToggle implements BotModeTogle { return draupnirResult; } + public async maybeRecoverWithSafeMode( + error: ResultError, + options?: SafeModeToggleOptions | undefined + ): Promise> { + switch (this.config.safeMode?.bootOption) { + case SafeModeBootOption.Never: + return Err(error); + case SafeModeBootOption.RecoveryOnly: + if (!(error instanceof ConfigRecoverableError)) { + return Err(error); + } + // fallthrough + default: + log.error( + "Failed to start draupnir, switching to safe mode as configured", + error + ); + return await this.switchToSafeMode( + { + reason: SafeModeReason.InitializationError, + error: error, + }, + options ?? {} + ); + } + } + public async encryptionInitialized(): Promise { if (this.draupnir !== null) { try { @@ -265,7 +300,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { await this.webAPIs.start(); await this.draupnir.startupComplete(); } catch (e) { - this.stopEverything(); + await this.stopEverything(); throw e; } } else if (this.safeModeDraupnir !== null) { @@ -273,10 +308,10 @@ export class DraupnirBotModeToggle implements BotModeTogle { } } - private stopDraupnir(): void { + private async stopDraupnir(): Promise { this.draupnir?.stop(); this.draupnir = null; - this.webAPIs?.stop(); + await this.webAPIs?.stop(); this.webAPIs = null; } @@ -285,8 +320,8 @@ export class DraupnirBotModeToggle implements BotModeTogle { this.safeModeDraupnir = null; } - public stopEverything(): void { - this.stopDraupnir(); + public async stopEverything(): Promise { + await this.stopDraupnir(); this.stopSafeModeDraupnir(); } } diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 2a929099..ab975bd6 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -75,11 +75,19 @@ export class AppserviceCommandHandler { ); this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt( + this.adminRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault( + this.adminRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.JSInterfaceDispatcher = makeAppserviceJSCommandDispatcher( this.appserviceContext diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index e9e4aebf..3fc47008 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 -import { Logger, Value, isError } from "matrix-protection-suite"; +import { Logger, Task, Value, isError } from "matrix-protection-suite"; import { MatrixReactionHandler, ReactionListener, @@ -24,6 +24,7 @@ import { readCommand, } from "@the-draupnir-project/interface-manager"; import { Ok, Result } from "@gnuxie/typescript-result"; +import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; const log = new Logger("MatrixPromptForAccept"); @@ -49,7 +50,8 @@ function continueCommandAcceptingPrompt( eventContext: MatrixEventContext, promptContext: PromptContext, serializedPrompt: string, - commandDispatcher: MatrixInterfaceCommandDispatcher + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): void { const stream = new StandardPresentationArgumentStream( readCommand( @@ -61,14 +63,25 @@ function continueCommandAcceptingPrompt( ) ); commandDispatcher.handleCommandFromPresentationStream(eventContext, stream); + void Task( + reactionHandler.completePrompt( + eventContext.roomID, + eventContext.event.event_id + ) + ); } export const DEFAUILT_ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.default_argument_prompt"; export function makeListenerForPromptDefault( - commandDispatcher: MatrixInterfaceCommandDispatcher + commandRoomID: StringRoomID, + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { + if (annotatedEvent.room_id !== commandRoomID) { + return; + } if (item !== "ok") { return; } @@ -84,7 +97,8 @@ export function makeListenerForPromptDefault( { event: annotatedEvent, roomID: annotatedEvent.room_id }, promptContext.ok, item, - commandDispatcher + commandDispatcher, + reactionHandler ); }; } @@ -92,9 +106,14 @@ export function makeListenerForPromptDefault( export const ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.argument_prompt"; export function makeListenerForArgumentPrompt( - commandDispatcher: MatrixInterfaceCommandDispatcher + commandRoomID: StringRoomID, + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { + if (annotatedEvent.room_id !== commandRoomID) { + return; + } const promptContext = Value.Decode(PromptContext, context); if (isError(promptContext)) { log.error( @@ -107,7 +126,8 @@ export function makeListenerForArgumentPrompt( { event: annotatedEvent, roomID: annotatedEvent.room_id }, promptContext.ok, item, - commandDispatcher + commandDispatcher, + reactionHandler ); }; } diff --git a/src/config.ts b/src/config.ts index 668cc0ce..4c5dfa2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ import { load } from "js-yaml"; import { MatrixClient, LogService } from "matrix-bot-sdk"; import Config from "config"; import path from "path"; +import { SafeModeBootOption } from "./safemode/BootOption"; /** * The configuration, as read from production.yaml @@ -90,7 +91,7 @@ export interface IConfig { }; }; safeMode?: { - bootIntoOnStartupFailure: boolean; + bootOption: SafeModeBootOption; }; health: { healthz: { @@ -192,7 +193,7 @@ const defaultConfig: IConfig = { }, }, safeMode: { - bootIntoOnStartupFailure: false, + bootOption: SafeModeBootOption.RecoveryOnly, }, health: { healthz: { @@ -287,6 +288,7 @@ export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig { "automaticallyRedactForReasons", "protectAllJoinedRooms", "backgroundDelayMS", + "safeMode", ]; const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir. const unusedKeys = Object.keys(configTemplate).filter( diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index ce0c99d4..e2bf4e91 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -16,14 +16,11 @@ import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MissingProtectionCB, - MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, - MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, - MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, @@ -42,7 +39,7 @@ import { isError, } from "matrix-protection-suite"; import { - BotSDKMatrixAccountData, + BotSDKAccountDataConfigBackend, BotSDKMatrixStateData, MatrixSendClient, } from "matrix-protection-suite-for-matrix-bot-sdk"; @@ -64,10 +61,9 @@ async function makePolicyListConfig( roomJoiner: RoomJoiner ): Promise> { const result = await MjolnirPolicyRoomsConfig.createFromStore( - new BotSDKMatrixAccountData( - MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, - MjolnirWatchedPolicyRoomsEvent, - client + new BotSDKAccountDataConfigBackend( + client, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE ), policyRoomManager, roomJoiner @@ -81,10 +77,9 @@ async function makeProtectedRoomsConfig( loggableConfigTracker: LoggableConfigTracker ): Promise> { return await MjolnirProtectedRoomsConfig.createFromStore( - new BotSDKMatrixAccountData( - MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, - MjolnirProtectedRoomsEvent, - client + new BotSDKAccountDataConfigBackend( + client, + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE ), roomResolver, loggableConfigTracker @@ -116,10 +111,9 @@ async function makeProtectionsManager( return result; } const protectionsConfigResult = await MjolnirProtectionsConfig.create( - new BotSDKMatrixAccountData( - MjolnirEnabledProtectionsEventType, - MjolnirEnabledProtectionsEvent, - client + new BotSDKAccountDataConfigBackend( + client, + MjolnirEnabledProtectionsEventType ), loggableConfigTracker, { diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index dac0b5cb..adb17b89 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -8,7 +8,13 @@ // https://github.com/matrix-org/mjolnir // -import { ActionError, ActionResult, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + ConfigRecoverableError, + Logger, + isError, +} from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; @@ -17,8 +23,15 @@ import { MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; -import { SafeModeCause } from "../safemode/SafeModeCause"; -import { SafeModeToggle } from "../safemode/SafeModeToggle"; +import { SafeModeCause, SafeModeReason } from "../safemode/SafeModeCause"; +import { + DraupnirRestartError, + SafeModeToggle, +} from "../safemode/SafeModeToggle"; +import { ResultError } from "@gnuxie/typescript-result"; +import { SafeModeBootOption } from "../safemode/BootOption"; + +const log = new Logger("StandardDraupnirManager"); export class StandardDraupnirManager { private readonly draupnir = new Map(); @@ -61,7 +74,8 @@ export class StandardDraupnirManager { clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig - ): Promise> { + ): Promise> { + this.safeModeDraupnir.delete(clientUserID); const draupnir = await this.draupnirFactory.makeDraupnir( clientUserID, managementRoom, @@ -79,11 +93,41 @@ export class StandardDraupnirManager { draupnir.error, clientUserID ); - return draupnir; + switch (config.safeMode?.bootOption) { + case SafeModeBootOption.Never: + return draupnir; + case SafeModeBootOption.RecoveryOnly: + if (!(draupnir.error instanceof ConfigRecoverableError)) { + return draupnir; + } + // fallthrough + default: { + log.error( + `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, + draupnir.error + ); + const safeModeResult = await this.makeSafeModeDraupnir( + clientUserID, + managementRoom, + config, + { + reason: SafeModeReason.InitializationError, + error: draupnir.error, + } + ); + if (isError(safeModeResult)) { + return safeModeResult; + } else { + return DraupnirRestartError.Result( + `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, + { safeModeDraupnir: safeModeResult.ok } + ); + } + } + } } this.draupnir.set(clientUserID, draupnir.ok); this.failedDraupnir.delete(clientUserID); - this.safeModeDraupnir.delete(clientUserID); draupnir.ok.start(); return draupnir; } diff --git a/src/index.ts b/src/index.ts index b006bf26..c1ae8aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,7 +111,7 @@ void (async function () { console.error( `Failed to setup mjolnir from the config ${config.dataPath}: ${err}` ); - bot?.stopEverything(); + await bot?.stopEverything(); throw err; } try { @@ -120,7 +120,7 @@ void (async function () { healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); - bot.stopEverything(); + await bot.stopEverything(); throw err; } })(); diff --git a/src/safemode/BootOption.ts b/src/safemode/BootOption.ts new file mode 100644 index 00000000..7f8083db --- /dev/null +++ b/src/safemode/BootOption.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +export enum SafeModeBootOption { + RecoveryOnly = "RecoveryOnly", + AllErrors = "AllErrors", + Never = "Never", +} diff --git a/src/safemode/DraupnirSafeMode.ts b/src/safemode/DraupnirSafeMode.ts index ad297a58..17bb4ae5 100644 --- a/src/safemode/DraupnirSafeMode.ts +++ b/src/safemode/DraupnirSafeMode.ts @@ -9,10 +9,7 @@ import { RoomEvent, Task, } from "matrix-protection-suite"; -import { - MatrixAdaptorContext, - sendMatrixEventsFromDeadDocument, -} from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { MatrixAdaptorContext } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; import { StringUserID, StringRoomID, @@ -40,6 +37,7 @@ import { safeModeStatusInfo, } from "./commands/StatusCommand"; import { wrapInRoot } from "../commands/interface-manager/MatrixHelpRenderer"; +import { sendAndAnnotateWithRecoveryOptions } from "./commands/RecoverCommand"; export class SafeModeDraupnir implements MatrixAdaptorContext { public reactionHandler: MatrixReactionHandler; @@ -73,16 +71,29 @@ export class SafeModeDraupnir implements MatrixAdaptorContext { ); this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); } handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { this.commandDispatcherTimelineListener(roomID, event); + void Task( + (async () => { + await this.reactionHandler.handleEvent(roomID, event); + })() + ); } handleEventReport(_report: EventReport): void { throw new Error("Method not implemented."); @@ -107,12 +118,11 @@ export class SafeModeDraupnir implements MatrixAdaptorContext { public startupComplete(): void { void Task( - sendMatrixEventsFromDeadDocument( - this.clientPlatform.toRoomMessageSender(), - this.commandRoomID, + sendAndAnnotateWithRecoveryOptions( + this, wrapInRoot(renderSafeModeStatusInfo(safeModeStatusInfo(this))), {} - ) as Promise> + ) ); } diff --git a/src/safemode/RecoveryOptions.tsx b/src/safemode/RecoveryOptions.tsx new file mode 100644 index 00000000..6e92fab0 --- /dev/null +++ b/src/safemode/RecoveryOptions.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + DeadDocumentJSX, + DocumentNode, +} from "@the-draupnir-project/interface-manager"; +import { ConfigRecoverableError } from "matrix-protection-suite"; +import { SafeModeCause, SafeModeReason } from "./SafeModeCause"; + +export function renderRecoveryOptions(cause: SafeModeCause): DocumentNode { + const recoveryOptions = + cause.reason === SafeModeReason.ByRequest + ? [] + : cause.error instanceof ConfigRecoverableError + ? cause.error.recoveryOptions + : []; + if (recoveryOptions.length === 0) { + return ( + + No recovery options are available for this failure mode. + + ); + } + return ( + + Recovery options are available for this failure mode: +
    + {recoveryOptions.map((option) => ( +
  1. {option.description}
  2. + ))} +
+
+ ); +} diff --git a/src/safemode/SafeModeToggle.ts b/src/safemode/SafeModeToggle.ts index ddc831fc..f5913bbd 100644 --- a/src/safemode/SafeModeToggle.ts +++ b/src/safemode/SafeModeToggle.ts @@ -2,13 +2,29 @@ // // SPDX-License-Identifier: AFL-3.0 -import { Result } from "@gnuxie/typescript-result"; +import { Err, Result, ResultError } from "@gnuxie/typescript-result"; import { Draupnir } from "../Draupnir"; import { SafeModeDraupnir } from "./DraupnirSafeMode"; import { SafeModeCause } from "./SafeModeCause"; export type SafeModeToggleOptions = { sendStatusOnStart?: boolean }; +export class DraupnirRestartError extends ResultError { + constructor( + message: string, + public readonly safeModeDraupnir: SafeModeDraupnir + ) { + super(message); + } + + public static Result( + message: string, + options: { safeModeDraupnir: SafeModeDraupnir } + ): Result { + return Err(new DraupnirRestartError(message, options.safeModeDraupnir)); + } +} + export interface SafeModeToggle { /** * Switch the bot to Draupnir mode. @@ -17,7 +33,9 @@ export interface SafeModeToggle { * 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>; + switchToDraupnir( + options?: SafeModeToggleOptions + ): Promise>; switchToSafeMode( cause: SafeModeCause, options?: SafeModeToggleOptions diff --git a/src/safemode/commands/RecoverCommand.tsx b/src/safemode/commands/RecoverCommand.tsx new file mode 100644 index 00000000..a50766c0 --- /dev/null +++ b/src/safemode/commands/RecoverCommand.tsx @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result"; +import { + DeadDocumentJSX, + DocumentNode, + StringPresentationType, + describeCommand, + tuple, +} from "@the-draupnir-project/interface-manager"; +import { SafeModeDraupnir } from "../DraupnirSafeMode"; +import { SafeModeReason } from "../SafeModeCause"; +import { + ConfigRecoverableError, + ConfigRecoveryOption, + RoomEvent, +} from "matrix-protection-suite"; +import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; +import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { ARGUMENT_PROMPT_LISTENER } from "../../commands/interface-manager/MatrixPromptForAccept"; +import { MatrixReactionHandler } from "../../commands/interface-manager/MatrixReactionHandler"; + +export const SafeModeRecoverCommand = describeCommand({ + summary: "Select an availale recovery option to enact.", + parameters: tuple({ + name: "recovery option", + acceptor: StringPresentationType, + description: "The recovery option to enact, e.g. 1.", + }), + async executor( + safeModeDraupnir: SafeModeDraupnir, + _info, + _keywords, + _rest, + optionDesignator + ): Promise> { + const optionNumber = (() => { + try { + return Ok(parseInt(optionDesignator)); + } catch { + return ResultError.Result( + `Unable to parse the recovery option from ${optionDesignator}` + ); + } + })(); + if (isError(optionNumber)) { + return optionNumber; + } + const options = getRecoveryOptions(safeModeDraupnir); + const selectedOption = options[optionNumber.ok - 1]; + if (selectedOption === undefined) { + return ResultError.Result( + `No recovery option with the number ${optionNumber.ok} exists.` + ); + } + const recoveryResult = await selectedOption.recover(); + if (isError(recoveryResult)) { + return recoveryResult; + } + return Ok(selectedOption); + }, +}); + +SafeModeInterfaceAdaptor.describeRenderer(SafeModeRecoverCommand, { + JSXRenderer(result) { + if (isError(result)) { + return Ok(undefined); + } + return Ok( + + + The recovery following recovery option has sucessfully been applied: +
+ {result.ok.description} +
+ You may now restart Draupnir with !draupnir restart +
+
+ ); + }, +}); + +export function getRecoveryOptions( + safeModeDraupnir: SafeModeDraupnir +): ConfigRecoveryOption[] { + return safeModeDraupnir.cause.reason === SafeModeReason.ByRequest + ? [] + : safeModeDraupnir.cause.error instanceof ConfigRecoverableError + ? safeModeDraupnir.cause.error.recoveryOptions + : []; +} + +export async function sendAndAnnotateWithRecoveryOptions( + safeModeDraupnir: SafeModeDraupnir, + document: DocumentNode, + { replyToEvent }: { replyToEvent?: RoomEvent } +): Promise> { + const reactionMap = MatrixReactionHandler.createItemizedReactionMap( + getRecoveryOptions(safeModeDraupnir).map((_option, index) => + String(index + 1) + ) + ); + const sendResult = await sendMatrixEventsFromDeadDocument( + safeModeDraupnir.clientPlatform.toRoomMessageSender(), + safeModeDraupnir.commandRoomID, + document, + { + replyToEvent, + additionalContent: safeModeDraupnir.reactionHandler.createAnnotation( + ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + command_designator: ["draupnir", "recover"], + read_items: [], + } + ), + } + ); + if (isError(sendResult)) { + return sendResult; + } + if (sendResult.ok[0] === undefined) { + throw new TypeError(`Something is really wrong with the code`); + } + await safeModeDraupnir.reactionHandler.addReactionsToEvent( + safeModeDraupnir.client, + safeModeDraupnir.commandRoomID, + sendResult.ok[0], + reactionMap + ); + return Ok(undefined); +} diff --git a/src/safemode/commands/SafeModeCommands.tsx b/src/safemode/commands/SafeModeCommands.tsx index c7ab6426..d5b04cea 100644 --- a/src/safemode/commands/SafeModeCommands.tsx +++ b/src/safemode/commands/SafeModeCommands.tsx @@ -6,8 +6,10 @@ import { StandardCommandTable } from "@the-draupnir-project/interface-manager"; import { SafeModeHelpCommand } from "./HelpCommand"; import { SafeModeStatusCommand } from "./StatusCommand"; import { SafeModeRestartCommand } from "./RestartDraupnirCommand"; +import { SafeModeRecoverCommand } from "./RecoverCommand"; export const SafeModeCommands = new StandardCommandTable("safe mode") .internCommand(SafeModeHelpCommand, ["draupnir", "help"]) .internCommand(SafeModeStatusCommand, ["draupnir", "status"]) + .internCommand(SafeModeRecoverCommand, ["draupnir", "recover"]) .internCommand(SafeModeRestartCommand, ["draupnir", "restart"]); diff --git a/src/safemode/commands/StatusCommand.tsx b/src/safemode/commands/StatusCommand.tsx index 2f0d0458..f83e9834 100644 --- a/src/safemode/commands/StatusCommand.tsx +++ b/src/safemode/commands/StatusCommand.tsx @@ -17,6 +17,8 @@ import { SOFTWARE_VERSION, } from "../../config"; import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; +import { renderRecoveryOptions } from "../RecoveryOptions"; +import { sendAndAnnotateWithRecoveryOptions } from "./RecoverCommand"; export function safeModeHeader(): DocumentNode { return ( @@ -84,6 +86,8 @@ export function renderSafeModeStatusInfo(

+ {renderRecoveryOptions(info.safeModeCause)} +
Version: {info.version}
@@ -128,10 +132,14 @@ export const SafeModeStatusCommand = describeCommand({ }); SafeModeInterfaceAdaptor.describeRenderer(SafeModeStatusCommand, { - JSXRenderer(result) { - if (isError(result)) { + async arbritraryRenderer(context, eventContext, commandResult) { + if (isError(commandResult)) { return Ok(undefined); } - return Ok({renderSafeModeStatusInfo(result.ok)}); + return await sendAndAnnotateWithRecoveryOptions( + context, + {renderSafeModeStatusInfo(commandResult.ok)}, + { replyToEvent: eventContext.event } + ); }, }); diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 1a309f32..e6d33512 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -17,7 +17,9 @@ import { StringRoomID, StringEventID, } from "@the-draupnir-project/matrix-basic-types"; -import { Task } from "matrix-protection-suite"; +import { Logger, Task } from "matrix-protection-suite"; + +const log = new Logger("WebAPIs"); /** * A common prefix for all web-exposed APIs. @@ -41,18 +43,22 @@ export class WebAPIs { /** * Start accepting requests to the Web API. */ - public async start() { + public async start(): Promise { if (!this.config.web.enabled) { return; } - this.httpServer = this.webController.listen( - this.config.web.port, - this.config.web.address - ); - + await new Promise((resolve) => { + this.httpServer = this.webController.listen( + this.config.web.port, + this.config.web.address, + () => { + resolve(undefined); + } + ); + }); // configure /report API. if (this.config.web.abuseReporting.enabled) { - console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); + log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); this.webController.options( `${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { @@ -70,7 +76,7 @@ export class WebAPIs { this.webController.post( `${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { - console.debug( + log.debug( `Received a message on ${API_PREFIX}/report/:room_id/:event_id`, request.params ); @@ -91,16 +97,31 @@ export class WebAPIs { ); } ); - console.log( - `configuring ${API_PREFIX}/report/:room_id/:event_id... DONE` - ); + log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`); } } - public stop() { + public async stop(): Promise { if (this.httpServer) { - console.log("Stopping WebAPIs."); - this.httpServer.close(); + log.info("Stopping WebAPIs."); + await new Promise((resolve, reject) => { + if (this.httpServer === undefined) { + throw new TypeError("There is some kind of weird race going on here"); + } + this.httpServer.close((error) => { + if (error === undefined) { + resolve(undefined); + } else if ( + "code" in error && + error.code === "ERR_SERVER_NOT_RUNNING" + ) { + resolve(undefined); + } else { + log.error("Error when stopping WebAPIs", error); + reject(error); + } + }); + }); this.httpServer = undefined; } } @@ -226,7 +247,7 @@ export class WebAPIs { // Match the spec behavior of `/report`: return 200 and an empty JSON. response.status(200).json({}); } catch (ex) { - console.warn("Error responding to an abuse report", roomID, eventID, ex); + log.warn("Error responding to an abuse report", roomID, eventID, ex); response.status(503); } } diff --git a/test/appservice/integration/safeModeRecoverTest.ts b/test/appservice/integration/safeModeRecoverTest.ts new file mode 100644 index 00000000..06c017ba --- /dev/null +++ b/test/appservice/integration/safeModeRecoverTest.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; +import { MjolnirAppService } from "../../../src/appservice/AppService"; +import { newTestUser } from "../../integration/clientHelper"; +import { StandardProvisionHelper } from "../utils/ProvisionHelper"; +import { setupHarness } from "../utils/harness"; +import { testRecoverAndRestart } from "../../integration/commands/recoverCommandDetail"; + +interface Context extends Mocha.Context { + appservice?: MjolnirAppService; +} + +describe("Test safe mode recovery on a provisioned Draupnir", function () { + beforeEach(async function (this: Context) { + this.appservice = await setupHarness(); + }); + afterEach(function (this: Context) { + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + it("Provisioned draupnir can be recovered from safe mode.", async function (this: Context) { + const appservice = this.appservice; + if (appservice === undefined) { + throw new TypeError(`Test setup failed`); + } + const provisionHelper = new StandardProvisionHelper(appservice); + const moderator = await newTestUser(appservice.config.homeserver.url, { + name: { contains: "moderator" }, + }); + const moderatorUserID = (await moderator.getUserId()) as StringUserID; + const initialDraupnir = ( + await provisionHelper.provisionDraupnir(moderatorUserID) + ).expect("Failed to provision a draupnir for the test"); + await testRecoverAndRestart(moderatorUserID, initialDraupnir); + }); +}); diff --git a/test/integration/commands/recoverCommandDetail.ts b/test/integration/commands/recoverCommandDetail.ts new file mode 100644 index 00000000..ba40651e --- /dev/null +++ b/test/integration/commands/recoverCommandDetail.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + MjolnirEnabledProtectionsEventType, + MjolnirProtectedRoomsEvent, +} from "matrix-protection-suite"; +import { Draupnir } from "../../../src/Draupnir"; +import { + StringRoomID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { isOk } from "@gnuxie/typescript-result"; +import { SafeModeDraupnir } from "../../../src/safemode/DraupnirSafeMode"; +import { DraupnirRestartError } from "../../../src/safemode/SafeModeToggle"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; + +async function clobberProtectedRooms(client: MatrixSendClient): Promise { + const existingAccountData = + await client.getAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE + ); + existingAccountData.rooms.push("clobbered" as StringRoomID); + await client.setAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + existingAccountData + ); +} + +async function clobberWatchedLists(client: MatrixSendClient): Promise { + await client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { + references: "cheese", + }); +} + +async function clobberEnabledProtections( + client: MatrixSendClient +): Promise { + await client.setAccountData(MjolnirEnabledProtectionsEventType, { + enabled: 34, + }); +} + +async function goToSafeMode( + sender: StringUserID, + draupnir: Draupnir +): Promise { + return ( + await draupnir.sendTextCommand( + sender, + "!draupnir safe mode" + ) + ).expect("Failed to switch to safe mode to setup the test"); +} + +async function recoverAndRestart( + sender: StringUserID, + initialDraupnir: SafeModeDraupnir +): Promise { + const badRestartResult = await initialDraupnir.sendTextCommand( + sender, + "!draupnir restart" + ); + if (isOk(badRestartResult)) { + throw new TypeError( + "The restat command should have failed because we clobbered account data." + ); + } + const safeModeDraupnirWithRecoveryOptions = (() => { + if (badRestartResult.error instanceof DraupnirRestartError) { + return badRestartResult.error.safeModeDraupnir; + } else { + throw new TypeError("Expected a DraupnirRestartError"); + } + })(); + ( + await safeModeDraupnirWithRecoveryOptions.sendTextCommand( + sender, + "!draupnir recover 1" + ) + ).expect("Failed to recover the draupnir"); + return ( + await safeModeDraupnirWithRecoveryOptions.sendTextCommand( + sender, + "!draupnir restart" + ) + ).expect("Failed to restart the draupnir after recovery was attempted"); +} + +type ClobberEffectDescription = { + effect: (client: MatrixSendClient) => Promise; + description: string; +}; + +export async function testClobberEffect( + sender: StringUserID, + initialDraupnir: Draupnir, + effect: ClobberEffectDescription +): Promise { + console.log(`Testing clobber effect: ${effect.description}`); + const initialSafeModeDraupnir = await goToSafeMode(sender, initialDraupnir); + await effect.effect(initialSafeModeDraupnir.client); + return await recoverAndRestart(sender, initialSafeModeDraupnir); +} + +async function testAllClobberEffects( + sender: StringUserID, + draupnir: Draupnir, + effects: ClobberEffectDescription[] +): Promise { + for (const effect of effects) { + await testClobberEffect(sender, draupnir, effect); + } +} + +export const ClobberEffects = [ + { + effect: clobberProtectedRooms, + description: "ProtectedRooms", + }, + { + effect: clobberWatchedLists, + description: "WatchedLists", + }, + { + effect: clobberEnabledProtections, + description: "EnabledProtections", + }, +]; + +export async function testRecoverAndRestart( + sender: StringUserID, + draupnir: Draupnir +): Promise { + await testAllClobberEffects(sender, draupnir, ClobberEffects); +} diff --git a/test/integration/commands/recoverCommandTest.ts b/test/integration/commands/recoverCommandTest.ts new file mode 100644 index 00000000..1dc2bed7 --- /dev/null +++ b/test/integration/commands/recoverCommandTest.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; +import { newTestUser } from "../clientHelper"; +import { DraupnirTestContext, draupnir } from "../mjolnirSetupUtils"; +import { testRecoverAndRestart } from "./recoverCommandDetail"; + +describe("We should be able to restart and recover draupnir when it has bad account data", function () { + it("Recovering protected rooms", async function (this: DraupnirTestContext) { + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + await draupnir().client.inviteUser( + await moderator.getUserId(), + draupnir().managementRoomID + ); + await moderator.joinRoom(draupnir().managementRoomID); + await testRecoverAndRestart( + (await moderator.getUserId()) as StringUserID, + draupnir() + ); + } as unknown as Mocha.AsyncFunc); +}); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index b8a182a6..dde80671 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -61,7 +61,7 @@ export const mochaHooks = { afterEach: [ async function (this: DraupnirTestContext) { this.timeout(10000); - this.toggle?.stopEverything(); + await this.toggle?.stopEverything(); draupnirClient()?.stop(); // remove alias from management room and leave it. diff --git a/yarn.lock b/yarn.lock index bb5b30fb..02f2cb2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,10 +256,10 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@the-draupnir-project/interface-manager@2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@the-draupnir-project/interface-manager/-/interface-manager-2.4.0.tgz#7734309e8cdc709d4f4a63557ac6b192dcb188a0" - integrity sha512-0iKB5nHHUBRMysKOw77/YmPaSrA6d7YtrMZQd7fAE0eqNTZLgHu3vS0fnBQ1AcVM2016Io512up3WYTUVqAqkA== +"@the-draupnir-project/interface-manager@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@the-draupnir-project/interface-manager/-/interface-manager-2.4.1.tgz#fc93eac495cf89fee0c87786b405a2fbae826b49" + integrity sha512-7L9QzsH/BAgdPSoNaB78ENJKAqbD4sqA0J7LmAUDiM7ygLS1yVrrI2VRN9aCdguVcbuD//fnOCJhSlfL2vr6YQ== dependencies: "@gnuxie/super-cool-stream" "^0.2.1" "@gnuxie/typescript-result" "^1.0.0" @@ -2526,17 +2526,17 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-1.4.0.tgz#0745358810e03959344e791733e6ccb7e811689d" - integrity sha512-xGkBaBdZ3u5Z27HqG2IEPxQ8Z5slFtWCihnFzocPBtRcLDJMFXnoF1+bpWElnPomDQtw/u0gvDwSC+f4pmTqyQ== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-1.5.1.tgz#ff01f3c81a28b7ea7391d9df06cac0613ad61b50" + integrity sha512-cPG46fPDlROSsGPY79XZpjp6AIUBvk2rGZkPUr1Cbxl1FIntOzDNIKePLu3CEEAY4RppPDmmjsm0bmRt6zbYBA== dependencies: "@gnuxie/typescript-result" "^1.0.0" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-1.4.0.tgz#0cce6f9dec22cf41b8ffc26861e5ae5c7cf463db" - integrity sha512-/1+L/Tf/fJx9aeNvyIfF4dqkS6YtTkRLMvSiqFNIpCq3JxOwyM2nqVBBNG5rbVPPdrxGlaFP+GuPxAUm54SdSA== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-1.5.1.tgz#243068169f8ff71e1e3fe5ae61d53c08995de520" + integrity sha512-A4xaONgW4asZ22cZeZl+Rk4lWWd44WOWFOoZjRFSmve8ismU+HQQ+PA2jLacfARvlqi/puqiYAb6VN7GIbtz/w== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2"