Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ protectedRooms:
# Explicitly add these rooms as a protected room list if you want them protected.
protectAllJoinedRooms: false

# Uncomment and populate this to set the default policy server to use. This can be
# overridden with the `!mjolnir policy_server` command. For an example policy server,
# see https://github.com/matrix-org/policyserv
#defaultPolicyServer: "beta2.matrix.org"

# Increase this delay to have Mjölnir wait longer between two consecutive backgrounded
# operations. The total duration of operations will be longer, but the homeserver won't
# be affected as much. Conversely, decrease this delay to have Mjölnir chain operations
Expand Down
42 changes: 42 additions & 0 deletions src/Mjolnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { OpenMetrics } from "./webapis/OpenMetrics";
import { LRUCache } from "lru-cache";
import { ModCache } from "./ModCache";
import { MASClient } from "./MASClient";
import { PolicyServer } from "./PolicyServer";

export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
Expand All @@ -57,6 +58,11 @@ export const STATE_RUNNING = "running";
*/
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";

/**
* The account data type which stores the policy server name (if set).
*/
const POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE = "org.matrix.mjolnir.policy_server_config";

export class Mjolnir {
private displayName: string;
private localpart: string;
Expand Down Expand Up @@ -374,6 +380,33 @@ export class Mjolnir {
console.log("Starting web server");
await this.webapis.start();

// Get policy server configuration
try {
const policyServerData = await this.client.getAccountData<{ name: string | undefined }>(
POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE,
);
await this.protectedRoomsTracker.setPolicyServer(
policyServerData.name ? new PolicyServer(policyServerData.name) : undefined,
true,
);
} catch (e) {
if (e.body?.errcode !== "M_NOT_FOUND") {
throw e;
}

// else account data wasn't found - use default from config
await this.protectedRoomsTracker.setPolicyServer(
this.config.defaultPolicyServer ? new PolicyServer(this.config.defaultPolicyServer) : undefined,
true,
);
}
LogService.info("Mjolnir", `Policy server name set to: ${this.protectedRoomsTracker.policyServer?.name}`);
// We log the key primarily to seed the cache before doing work with it.
LogService.info(
"Mjolnir",
`Policy server public key is: ${await this.protectedRoomsTracker.policyServer?.getEd25519Key()}`,
);

if (this.reportPoller) {
let reportPollSetting: { from: number } = { from: 0 };
try {
Expand Down Expand Up @@ -471,6 +504,15 @@ export class Mjolnir {
return this.protectedRoomsConfig.getExplicitlyProtectedRooms();
}

/**
* Sets the policy server to use in all protected rooms. This will cause it to be applied immediately.
* @param server The policy server to use.
*/
public async setPolicyServer(server: PolicyServer | undefined): Promise<void> {
await this.client.setAccountData(POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE, { name: server?.name });
return this.protectedRoomsTracker.setPolicyServer(server);
}

/**
* Explicitly protect this room, adding it to the account data.
* Should NOT be used to protect a room to implement e.g. `config.protectAllJoinedRooms`,
Expand Down
83 changes: 83 additions & 0 deletions src/PolicyServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { LogService } from "@vector-im/matrix-bot-sdk";

export class PolicyServer {
private ed25519Key: string | undefined;
private lastCheck: Date;
private serverNameOverride: string | undefined;

constructor(private serverName: string) {
// Check for HTTP URIs in the server name, just in case we're running a test
if (this.serverName.startsWith("http://")) {
const uri = new URL(this.serverName);
this.serverNameOverride = uri.hostname;
}

this.lastCheck = new Date(0);
}

public get name(): string {
if (this.serverNameOverride) {
return this.serverNameOverride;
}
return this.serverName;
}

public async getEd25519Key(): Promise<string | undefined> {
const keyStillFresh = this.lastCheck.getTime() + 1000 * 60 * 60 * 24 > Date.now(); // valid for 24 hours
if (this.ed25519Key && keyStillFresh) {
return this.ed25519Key;
}

const errorStillFresh = this.lastCheck.getTime() + 1000 * 60 * 60 > Date.now(); // errors are valid for 1 hour
if (!this.ed25519Key && errorStillFresh) {
return undefined;
}

this.lastCheck = new Date();

// As per spec/MSC4284
// We allow HTTP URIs in the server name for testing purposes
let schemeAndHostname = `https://${this.name}`; // will be the hostname if an HTTP link, per constructor
if (this.serverName.startsWith("http://")) {
// this is the unnormalized name
LogService.warn("PolicyServer", "Using non-HTTP URI for policy server: " + this.serverName);
schemeAndHostname = this.serverName;
}
const response = await fetch(`${schemeAndHostname}/.well-known/matrix/policy_server`);
if (!response.ok) {
LogService.warn("PolicyServer", `Failed to fetch ed25519 key for ${this.name}: ${response.statusText}`);
this.ed25519Key = undefined;
return undefined;
}

const keyInfo = await response.json();
if (
typeof keyInfo !== "object" ||
typeof keyInfo.public_keys !== "object" ||
typeof keyInfo.public_keys.ed25519 !== "string"
) {
LogService.warn("PolicyServer", `Failed to parse ed25519 key for ${this.name}: invalid response or no key`);
this.ed25519Key = undefined;
return undefined;
}

this.ed25519Key = keyInfo.public_keys.ed25519;
return this.ed25519Key;
}
}
95 changes: 94 additions & 1 deletion src/ProtectedRoomsSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQu
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { getMXCsInMessage, htmlEscape } from "./utils";
import { ModCache } from "./ModCache";
import { PolicyServer } from "./PolicyServer";

const KEEP_MEDIA_EVENTS_FOR_MS = 4 * 24 * 60 * 60 * 1000;

Expand Down Expand Up @@ -104,6 +105,8 @@ export class ProtectedRoomsSet {
/** The last revision we used to sync protected rooms. */ Revision
>();

private enabledPolicyServer: PolicyServer | undefined;

/**
* whether the mjolnir instance is server admin
*/
Expand Down Expand Up @@ -139,6 +142,21 @@ export class ProtectedRoomsSet {
this.listUpdateListener = this.syncWithUpdatedPolicyList.bind(this);
}

public async setPolicyServer(server: PolicyServer | undefined, skipSync = false): Promise<void> {
if (server?.name !== this.enabledPolicyServer?.name) {
this.enabledPolicyServer = server;
}

if (!skipSync) {
const errors = await this.applyPolicyServerConfig(this.protectedRoomsByActivity());
await this.printActionResult(errors, "Errors updating policy server config:");
}
}

public get policyServer(): PolicyServer | undefined {
return this.enabledPolicyServer;
}

/**
* Queue a user's messages in a room for redaction once we have stopped synchronizing bans
* over the protected rooms.
Expand Down Expand Up @@ -261,14 +279,16 @@ export class ProtectedRoomsSet {
*/
private async syncRoomsWithPolicies() {
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
const [aclErrors, banErrors, psErrors] = await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.protectedRoomsByActivity()),
this.applyPolicyServerConfig(this.protectedRoomsByActivity()),
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || (await this.printActionResult(aclErrors, "Errors updating server ACLs:"));
hadErrors = hadErrors || (await this.printActionResult(banErrors, "Errors updating member bans:"));
hadErrors = hadErrors || (await this.printActionResult(redactionErrors, "Error updating redactions:"));
hadErrors = hadErrors || (await this.printActionResult(psErrors, "Error updating policy server config:"));

if (!hadErrors) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
Expand Down Expand Up @@ -340,6 +360,79 @@ export class ProtectedRoomsSet {
await this.printBanlistChanges(changes, policyList);
}

private async applyPolicyServerConfig(roomIds: string[]): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
await this.managementRoomOutput.logMessage(
LogLevel.DEBUG,
"ApplyPolicyServerConfig",
`Checking policy server config in ${roomId}`,
roomId,
);

try {
let currentPolicyServerName: string | undefined | null = null; // string=name, undefined=explict not set, null=unknown
try {
const content = await this.client.getRoomStateEventContent(roomId, "m.room.policy", "");
currentPolicyServerName = content["via"] as string | undefined;
} catch (e) {
// ignore error and fall back to unstable type
try {
const content = await this.client.getRoomStateEventContent(
roomId,
"org.matrix.msc4284.policy",
"",
);
currentPolicyServerName = content["via"] as string | undefined;
} catch (e) {
// ignore - assume no policy server config
}
}

// Because we use null to represent unknown, this won't trigger when we're unable to get the current state.
if (currentPolicyServerName === this.enabledPolicyServer?.name) {
await this.managementRoomOutput.logMessage(
LogLevel.DEBUG,
"ApplyPolicyServerConfig",
`Skipping policy server config in ${roomId} because the server name already matches`,
roomId,
);
continue;
}

await this.managementRoomOutput.logMessage(
LogLevel.DEBUG,
"ApplyPolicyServerConfig",
`Updating policy server config in ${roomId}`,
roomId,
);
if (this.enabledPolicyServer) {
// We expect the homeserver to deduplicate the state event setting for us.
const ed25519Key = await this.enabledPolicyServer.getEd25519Key();
await this.client.sendStateEvent(roomId, "m.room.policy", "", {
via: this.enabledPolicyServer.name,
public_keys: {
ed25519: ed25519Key,
},
});
// We also set the unstable, though this is less important as time goes on
await this.client.sendStateEvent(roomId, "org.matrix.msc4284.policy", "", {
via: this.enabledPolicyServer.name,
public_key: ed25519Key,
});
} else {
// "Remove" the policy server by unsetting the state events
await this.client.sendStateEvent(roomId, "m.room.policy", "", {});
await this.client.sendStateEvent(roomId, "org.matrix.msc4284.policy", "", {});
}
} catch (e) {
errors.push({ roomId, errorMessage: e.message, errorKind: ERROR_KIND_FATAL });
}
}

return errors;
}

/**
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
Expand Down
6 changes: 5 additions & 1 deletion src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand";
import { execLockCommand } from "./LockCommand";
import { execUnlockCommand } from "./UnlockCommand";
import { execQuarantineMediaCommand } from "./QuarantineMediaCommand";
import { execSetPolicyServerCommand } from "./SetPolicyServerCommand";

export const COMMAND_PREFIX = "!mjolnir";

Expand Down Expand Up @@ -155,6 +156,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
return await execLockCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "unlock") {
return await execUnlockCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "policy_server") {
return await execSetPolicyServerCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "help") {
// Help menu
const protectionMenu =
Expand Down Expand Up @@ -198,7 +201,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir default <shortcode> - Sets the default list for commands\n" +
"!mjolnir rules - Lists the rules currently in use by Mjolnir\n" +
"!mjolnir rules matching <user|room|server> - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user\n" +
"!mjolnir sync - Force updates of all lists and re-apply rules\n";
"!mjolnir sync - Force updates of all lists and re-apply rules\n" +
"!mjolnir policy_server <name or 'unset'> - Sets the policy server name in protected rooms, or removes it if 'unset' is given\n";

const roomsMenu =
"" +
Expand Down
44 changes: 44 additions & 0 deletions src/commands/SetPolicyServerCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { Mjolnir } from "../Mjolnir";
import { RichReply } from "@vector-im/matrix-bot-sdk";
import { PolicyServer } from "../PolicyServer";

// !mjolnir policy_server <name or "unset">
export async function execSetPolicyServerCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
if (parts.length !== 3) {
await mjolnir.client.replyNotice(roomId, event, "Usage: !mjolnir policy_server <name or 'unset'>");
return;
}

const name = parts[2].toLowerCase();
const server = name === "unset" ? undefined : new PolicyServer(name);

if (server) {
const key = await server.getEd25519Key();
if (!key) {
const replyText = "Could not find a valid key for the policy server.";
const reply = RichReply.createFor(roomId, event, replyText, replyText);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
return;
}
}

await mjolnir.setPolicyServer(server);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅");
}
3 changes: 3 additions & 0 deletions src/commands/StatusCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) {
html += `<b>Protected rooms: </b> ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}<br/>`;
text += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}\n`;

html += `<b>Policy server name: </b> ${mjolnir.protectedRoomsTracker.policyServer?.name}<br/>`;
text += `Policy server name: ${mjolnir.protectedRoomsTracker.policyServer?.name}\n`;

// Append list information
const renderPolicyLists = (header: string, lists: PolicyList[]) => {
html += `<b>${header}:</b><br><ul>`;
Expand Down
Loading
Loading