Skip to content

Commit d6fe9e2

Browse files
committed
Set and sync policy server configuration in protected rooms
Desired behaviour: * When syncing policy list changes, ensure the policy server is set in the rooms (if applicable). * Allow the policy server to be set in the config to avoid unsetting it on startup in already-protected rooms. * Allow the policy server to be changed with commands. This enables operators to pick a different policy server than the config at runtime. * Don't migrate to stable event types if only the unstable event type is sent. It's assumed that an external tool will take care of migrating. * Works with `protectAllJoinedRooms` to set the policy server state event on sync. This does *not* ensure that the policy server is actually joined to each of the rooms. A later PR/feature will make that work.
1 parent 97341cd commit d6fe9e2

8 files changed

Lines changed: 237 additions & 2 deletions

File tree

config/default.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ protectedRooms:
134134
# Explicitly add these rooms as a protected room list if you want them protected.
135135
protectAllJoinedRooms: false
136136

137+
# Uncomment and populate this to set the default policy server to use. This can be
138+
# overridden with the `!mjolnir policy_server` command. For an example policy server,
139+
# see https://github.com/matrix-org/policyserv
140+
#defaultPolicyServer: "beta2.matrix.org"
141+
137142
# Increase this delay to have Mjölnir wait longer between two consecutive backgrounded
138143
# operations. The total duration of operations will be longer, but the homeserver won't
139144
# be affected as much. Conversely, decrease this delay to have Mjölnir chain operations

src/Mjolnir.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { OpenMetrics } from "./webapis/OpenMetrics";
4545
import { LRUCache } from "lru-cache";
4646
import { ModCache } from "./ModCache";
4747
import { MASClient } from "./MASClient";
48+
import { PolicyServer } from "./PolicyServer";
4849

4950
export const STATE_NOT_STARTED = "not_started";
5051
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@@ -57,6 +58,11 @@ export const STATE_RUNNING = "running";
5758
*/
5859
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";
5960

61+
/**
62+
* The account data type which stores the policy server name (if set).
63+
*/
64+
const POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE = "org.matrix.mjolnir.policy_server_config";
65+
6066
export class Mjolnir {
6167
private displayName: string;
6268
private localpart: string;
@@ -374,6 +380,22 @@ export class Mjolnir {
374380
console.log("Starting web server");
375381
await this.webapis.start();
376382

383+
// Get policy server configuration
384+
try {
385+
const policyServerData = await this.client.getAccountData<{ name: string | undefined }>(POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE);
386+
await this.protectedRoomsTracker.setPolicyServer(policyServerData.name ? new PolicyServer(policyServerData.name) : undefined, true);
387+
} catch (e) {
388+
if (e.body?.errcode !== "M_NOT_FOUND") {
389+
throw e;
390+
}
391+
392+
// else account data wasn't found - use default from config
393+
await this.protectedRoomsTracker.setPolicyServer(this.config.defaultPolicyServer ? new PolicyServer(this.config.defaultPolicyServer) : undefined, true);
394+
}
395+
LogService.info("Mjolnir", `Policy server name set to: ${this.protectedRoomsTracker.policyServer?.name}`);
396+
// We log the key primarily to seed the cache before doing work with it.
397+
LogService.info("Mjolnir", `Policy server public key is: ${await this.protectedRoomsTracker.policyServer?.getEd25519Key()}`);
398+
377399
if (this.reportPoller) {
378400
let reportPollSetting: { from: number } = { from: 0 };
379401
try {
@@ -471,6 +493,15 @@ export class Mjolnir {
471493
return this.protectedRoomsConfig.getExplicitlyProtectedRooms();
472494
}
473495

496+
/**
497+
* Sets the policy server to use in all protected rooms. This will cause it to be applied immediately.
498+
* @param server The policy server to use.
499+
*/
500+
public async setPolicyServer(server: PolicyServer | undefined): Promise<void> {
501+
await this.client.setAccountData(POLICY_SERVER_CONFIG_ACCOUNT_DATA_TYPE, { name: server?.name });
502+
return this.protectedRoomsTracker.setPolicyServer(server);
503+
}
504+
474505
/**
475506
* Explicitly protect this room, adding it to the account data.
476507
* Should NOT be used to protect a room to implement e.g. `config.protectAllJoinedRooms`,

src/PolicyServer.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Copyright 2026 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { LogService } from "@vector-im/matrix-bot-sdk";
18+
19+
export class PolicyServer {
20+
private ed25519Key: string | undefined;
21+
private lastCheck: Date;
22+
23+
constructor(public readonly name: string) {
24+
this.lastCheck = new Date(0);
25+
}
26+
27+
public async getEd25519Key(): Promise<string | undefined> {
28+
const keyStillFresh = (this.lastCheck.getTime() + 1000 * 60 * 60 * 24) > Date.now(); // valid for 24 hours
29+
if (this.ed25519Key && keyStillFresh) {
30+
return this.ed25519Key;
31+
}
32+
33+
const errorStillFresh = (this.lastCheck.getTime() + 1000 * 60 * 60) > Date.now(); // errors are valid for 1 hour
34+
if (!this.ed25519Key && errorStillFresh) {
35+
return undefined;
36+
}
37+
38+
this.lastCheck = new Date();
39+
40+
// As per spec/MSC4284
41+
const response = await fetch(`https://${this.name}/.well-known/matrix/policy_server`);
42+
if (!response.ok) {
43+
LogService.warn("PolicyServer", `Failed to fetch ed25519 key for ${this.name}: ${response.statusText}`);
44+
this.ed25519Key = undefined;
45+
return undefined;
46+
}
47+
48+
const keyInfo = await response.json();
49+
if (typeof keyInfo !== "object" || typeof keyInfo.public_keys !== "object" || typeof keyInfo.public_keys.ed25519 !== "string") {
50+
LogService.warn("PolicyServer", `Failed to parse ed25519 key for ${this.name}: invalid response or no key`);
51+
this.ed25519Key = undefined;
52+
return undefined;
53+
}
54+
55+
this.ed25519Key = keyInfo.public_keys.ed25519;
56+
return this.ed25519Key;
57+
}
58+
}

src/ProtectedRoomsSet.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQu
2828
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
2929
import { getMXCsInMessage, htmlEscape } from "./utils";
3030
import { ModCache } from "./ModCache";
31+
import { PolicyServer } from "./PolicyServer";
3132

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

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

108+
private enabledPolicyServer: PolicyServer | undefined;
109+
107110
/**
108111
* whether the mjolnir instance is server admin
109112
*/
@@ -139,6 +142,21 @@ export class ProtectedRoomsSet {
139142
this.listUpdateListener = this.syncWithUpdatedPolicyList.bind(this);
140143
}
141144

145+
public async setPolicyServer(server: PolicyServer | undefined, skipSync = false): Promise<void> {
146+
if (server?.name !== this.enabledPolicyServer?.name) {
147+
this.enabledPolicyServer = server;
148+
}
149+
150+
if (!skipSync) {
151+
const errors = await this.applyPolicyServerConfig(this.protectedRoomsByActivity());
152+
await this.printActionResult(errors, "Errors updating policy server config:");
153+
}
154+
}
155+
156+
public get policyServer(): PolicyServer | undefined {
157+
return this.enabledPolicyServer;
158+
}
159+
142160
/**
143161
* Queue a user's messages in a room for redaction once we have stopped synchronizing bans
144162
* over the protected rooms.
@@ -261,14 +279,16 @@ export class ProtectedRoomsSet {
261279
*/
262280
private async syncRoomsWithPolicies() {
263281
let hadErrors = false;
264-
const [aclErrors, banErrors] = await Promise.all([
282+
const [aclErrors, banErrors, psErrors] = await Promise.all([
265283
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
266284
this.applyUserBans(this.protectedRoomsByActivity()),
285+
this.applyPolicyServerConfig(this.protectedRoomsByActivity()),
267286
]);
268287
const redactionErrors = await this.processRedactionQueue();
269288
hadErrors = hadErrors || (await this.printActionResult(aclErrors, "Errors updating server ACLs:"));
270289
hadErrors = hadErrors || (await this.printActionResult(banErrors, "Errors updating member bans:"));
271290
hadErrors = hadErrors || (await this.printActionResult(redactionErrors, "Error updating redactions:"));
291+
hadErrors = hadErrors || (await this.printActionResult(psErrors, "Error updating policy server config:"));
272292

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

363+
private async applyPolicyServerConfig(roomIds: string[]): Promise<RoomUpdateError[]> {
364+
const errors: RoomUpdateError[] = [];
365+
for (const roomId of roomIds) {
366+
await this.managementRoomOutput.logMessage(
367+
LogLevel.DEBUG,
368+
"ApplyPolicyServerConfig",
369+
`Checking policy server config in ${roomId}`,
370+
roomId,
371+
);
372+
373+
try {
374+
let currentPolicyServerName: string | undefined | null = null; // string=name, undefined=explict not set, null=unknown
375+
try {
376+
const content = await this.client.getRoomStateEventContent(roomId, "m.room.policy", "");
377+
currentPolicyServerName = content["via"] as string | undefined;
378+
} catch (e) {
379+
// ignore error and fall back to unstable type
380+
try {
381+
const content = await this.client.getRoomStateEventContent(roomId, "org.matrix.msc4284.policy", "");
382+
currentPolicyServerName = content["via"] as string | undefined;
383+
} catch (e) {
384+
// ignore - assume no policy server config
385+
}
386+
}
387+
388+
// Because we use null to represent unknown, this won't trigger when we're unable to get the current state.
389+
if (currentPolicyServerName === this.enabledPolicyServer?.name) {
390+
await this.managementRoomOutput.logMessage(
391+
LogLevel.DEBUG,
392+
"ApplyPolicyServerConfig",
393+
`Skipping policy server config in ${roomId} because the server name already matches`,
394+
roomId,
395+
);
396+
continue;
397+
}
398+
399+
await this.managementRoomOutput.logMessage(
400+
LogLevel.DEBUG,
401+
"ApplyPolicyServerConfig",
402+
`Updating policy server config in ${roomId}`,
403+
roomId,
404+
);
405+
if (this.enabledPolicyServer) {
406+
// We expect the homeserver to deduplicate the state event setting for us.
407+
const ed25519Key = await this.enabledPolicyServer.getEd25519Key();
408+
await this.client.sendStateEvent(roomId, "m.room.policy", "", {
409+
via: this.enabledPolicyServer.name,
410+
public_keys: {
411+
ed25519: ed25519Key,
412+
},
413+
});
414+
// We also set the unstable, though this is less important as time goes on
415+
await this.client.sendStateEvent(roomId, "org.matrix.msc4284.policy", "", {
416+
via: this.enabledPolicyServer.name,
417+
public_key: ed25519Key,
418+
});
419+
} else {
420+
// "Remove" the policy server by unsetting the state events
421+
await this.client.sendStateEvent(roomId, "m.room.policy", "", {});
422+
await this.client.sendStateEvent(roomId, "org.matrix.msc4284.policy", "", {});
423+
}
424+
} catch (e) {
425+
errors.push({ roomId, errorMessage: e.message, errorKind: ERROR_KIND_FATAL });
426+
}
427+
}
428+
429+
return errors;
430+
}
431+
343432
/**
344433
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
345434
* room IDs that could not be updated and their error.

src/commands/CommandHandler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand";
5454
import { execLockCommand } from "./LockCommand";
5555
import { execUnlockCommand } from "./UnlockCommand";
5656
import { execQuarantineMediaCommand } from "./QuarantineMediaCommand";
57+
import {execSetPolicyServerCommand} from "./SetPolicyServerCommand";
5758

5859
export const COMMAND_PREFIX = "!mjolnir";
5960

@@ -155,6 +156,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
155156
return await execLockCommand(roomId, event, mjolnir, parts);
156157
} else if (parts[1] === "unlock") {
157158
return await execUnlockCommand(roomId, event, mjolnir, parts);
159+
} else if (parts[1] === "policy_server") {
160+
return await execSetPolicyServerCommand(roomId, event, mjolnir, parts);
158161
} else if (parts[1] === "help") {
159162
// Help menu
160163
const protectionMenu =
@@ -198,7 +201,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
198201
"!mjolnir default <shortcode> - Sets the default list for commands\n" +
199202
"!mjolnir rules - Lists the rules currently in use by Mjolnir\n" +
200203
"!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" +
201-
"!mjolnir sync - Force updates of all lists and re-apply rules\n";
204+
"!mjolnir sync - Force updates of all lists and re-apply rules\n" +
205+
"!mjolnir policy_server <name or 'unset'> - Sets the policy server name in protected rooms, or removes it if 'unset' is given\n";
202206

203207
const roomsMenu =
204208
"" +
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
Copyright 2026 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Mjolnir } from "../Mjolnir";
18+
import { RichReply } from "@vector-im/matrix-bot-sdk";
19+
import { PolicyServer } from "../PolicyServer";
20+
21+
// !mjolnir policy_server <name or "unset">
22+
export async function execSetPolicyServerCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
23+
if (parts.length !== 3) {
24+
await mjolnir.client.replyNotice(roomId, event, "Usage: !mjolnir policy_server <name or 'unset'>");
25+
return;
26+
}
27+
28+
const name = parts[2].toLowerCase();
29+
const server = name === "unset" ? undefined : new PolicyServer(name);
30+
31+
if (server) {
32+
const key = await server.getEd25519Key();
33+
if (!key) {
34+
const replyText = "Could not find a valid key for the policy server.";
35+
const reply = RichReply.createFor(roomId, event, replyText, replyText);
36+
reply["msgtype"] = "m.notice";
37+
await mjolnir.client.sendMessage(roomId, reply);
38+
return;
39+
}
40+
}
41+
42+
await mjolnir.setPolicyServer(server);
43+
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅");
44+
}

src/commands/StatusCommand.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) {
7272
html += `<b>Protected rooms: </b> ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}<br/>`;
7373
text += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}\n`;
7474

75+
html += `<b>Policy server name: </b> ${mjolnir.protectedRoomsTracker.policyServer?.name}<br/>`;
76+
text += `Policy server name: ${mjolnir.protectedRoomsTracker.policyServer?.name}\n`;
77+
7578
// Append list information
7679
const renderPolicyLists = (header: string, lists: PolicyList[]) => {
7780
html += `<b>${header}:</b><br><ul>`;

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export interface IConfig {
106106
fasterMembershipChecks: boolean;
107107
automaticallyRedactForReasons: string[]; // case-insensitive globs
108108
protectAllJoinedRooms: boolean;
109+
defaultPolicyServer: string;
109110
/**
110111
* Backgrounded tasks: number of milliseconds to wait between the completion
111112
* of one background task and the start of the next one.

0 commit comments

Comments
 (0)