Merge pull request #588 from the-draupnir-project/gnuxie/config-editor safe mode recovery

Safe Mode account data config recovery.
This commit is contained in:
Gnuxie authored and GitHub committed 2024-10-02 17:30:39 +01:00
commit 890f004d3b
23 files changed
+679 -106

No files matched your search

+17
View File
@@ -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
+3 -3
View File
@@ -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",
+10 -2
View File
@@ -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(),
+58 -23
View File
@@ -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<Result<Draupnir | SafeModeDraupnir>>;
maybeRecoverWithSafeMode(
error: ResultError,
options?: SafeModeToggleOptions
): Promise<Result<SafeModeDraupnir>>;
}
export class DraupnirBotModeToggle implements BotModeTogle {
@@ -168,12 +175,13 @@ export class DraupnirBotModeToggle implements BotModeTogle {
}
public async switchToDraupnir(
options?: SafeModeToggleOptions
): Promise<Result<Draupnir>> {
): Promise<Result<Draupnir, DraupnirRestartError | ResultError>> {
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<Result<Draupnir | SafeModeDraupnir>> {
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<Result<SafeModeDraupnir>> {
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<void> {
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<void> {
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<void> {
await this.stopDraupnir();
this.stopSafeModeDraupnir();
}
}
+10 -2
View File
@@ -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
@@ -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<MatrixEventContext>
commandDispatcher: MatrixInterfaceCommandDispatcher<MatrixEventContext>,
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<MatrixEventContext>
commandRoomID: StringRoomID,
commandDispatcher: MatrixInterfaceCommandDispatcher<MatrixEventContext>,
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<MatrixEventContext>
commandRoomID: StringRoomID,
commandDispatcher: MatrixInterfaceCommandDispatcher<MatrixEventContext>,
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
);
};
}
+4 -2
View File
@@ -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(
@@ -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<ActionResult<PolicyListConfig>> {
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<ActionResult<ProtectedRoomsConfig>> {
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<MjolnirEnabledProtectionsEvent>(
MjolnirEnabledProtectionsEventType,
MjolnirEnabledProtectionsEvent,
client
new BotSDKAccountDataConfigBackend(
client,
MjolnirEnabledProtectionsEventType
),
loggableConfigTracker,
{
+50 -6
View File
@@ -8,7 +8,13 @@
// https://github.com/matrix-org/mjolnir
// </text>
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<StringUserID, Draupnir>();
@@ -61,7 +74,8 @@ export class StandardDraupnirManager {
clientUserID: StringUserID,
managementRoom: MatrixRoomID,
config: IConfig
): Promise<ActionResult<Draupnir>> {
): Promise<ActionResult<Draupnir, DraupnirRestartError | ResultError>> {
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;
}
+2 -2
View File
@@ -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;
}
})();
+9
View File
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
export enum SafeModeBootOption {
RecoveryOnly = "RecoveryOnly",
AllErrors = "AllErrors",
Never = "Never",
}
+20 -10
View File
@@ -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<Result<void>>
)
);
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// 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 (
<fragment>
No recovery options are available for this failure mode.
</fragment>
);
}
return (
<fragment>
Recovery options are available for this failure mode:
<ol>
{recoveryOptions.map((option) => (
<li>{option.description}</li>
))}
</ol>
</fragment>
);
}
+20 -2
View File
@@ -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<never> {
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<Result<Draupnir>>;
switchToDraupnir(
options?: SafeModeToggleOptions
): Promise<Result<Draupnir, DraupnirRestartError | ResultError>>;
switchToSafeMode(
cause: SafeModeCause,
options?: SafeModeToggleOptions
+134
View File
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// 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<Result<ConfigRecoveryOption>> {
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(
<root>
<fragment>
The recovery following recovery option has sucessfully been applied:
<br />
{result.ok.description}
<br />
You may now restart Draupnir with <code>!draupnir restart</code>
</fragment>
</root>
);
},
});
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<Result<void>> {
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);
}
@@ -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"]);
+11 -3
View File
@@ -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(
<br />
</span>
<br />
{renderRecoveryOptions(info.safeModeCause)}
<br />
<b>Version: </b>
<code>{info.version}</code>
<br />
@@ -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(<root>{renderSafeModeStatusInfo(result.ok)}</root>);
return await sendAndAnnotateWithRecoveryOptions(
context,
<root>{renderSafeModeStatusInfo(commandResult.ok)}</root>,
{ replyToEvent: eventContext.event }
);
},
});
+37 -16
View File
@@ -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<void> {
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<void> {
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);
}
}
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// 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);
});
});
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// 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<void> {
const existingAccountData =
await client.getAccountData<MjolnirProtectedRoomsEvent>(
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<void> {
await client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, {
references: "cheese",
});
}
async function clobberEnabledProtections(
client: MatrixSendClient
): Promise<void> {
await client.setAccountData(MjolnirEnabledProtectionsEventType, {
enabled: 34,
});
}
async function goToSafeMode(
sender: StringUserID,
draupnir: Draupnir
): Promise<SafeModeDraupnir> {
return (
await draupnir.sendTextCommand<SafeModeDraupnir>(
sender,
"!draupnir safe mode"
)
).expect("Failed to switch to safe mode to setup the test");
}
async function recoverAndRestart(
sender: StringUserID,
initialDraupnir: SafeModeDraupnir
): Promise<Draupnir> {
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<Draupnir>(
sender,
"!draupnir restart"
)
).expect("Failed to restart the draupnir after recovery was attempted");
}
type ClobberEffectDescription = {
effect: (client: MatrixSendClient) => Promise<void>;
description: string;
};
export async function testClobberEffect(
sender: StringUserID,
initialDraupnir: Draupnir,
effect: ClobberEffectDescription
): Promise<Draupnir> {
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<void> {
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<void> {
await testAllClobberEffects(sender, draupnir, ClobberEffects);
}
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
//
// 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);
});
+1 -1
View File
@@ -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.
+12 -12
View File
@@ -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"