diff --git a/config/default.yaml b/config/default.yaml index 6073d9d1..5049f1d3 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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 diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index d94bdf1a..ad174ce9 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -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"; @@ -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; @@ -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 { @@ -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 { + 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`, diff --git a/src/PolicyServer.ts b/src/PolicyServer.ts new file mode 100644 index 00000000..1f50aa5f --- /dev/null +++ b/src/PolicyServer.ts @@ -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 { + 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; + } +} diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index 1a8c6a7e..a4ddde29 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -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; @@ -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 */ @@ -139,6 +142,21 @@ export class ProtectedRoomsSet { this.listUpdateListener = this.syncWithUpdatedPolicyList.bind(this); } + public async setPolicyServer(server: PolicyServer | undefined, skipSync = false): Promise { + 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. @@ -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 = `Done updating rooms - no errors`; @@ -340,6 +360,79 @@ export class ProtectedRoomsSet { await this.printBanlistChanges(changes, policyList); } + private async applyPolicyServerConfig(roomIds: string[]): Promise { + 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. diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 866e93ec..5a98ec23 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -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"; @@ -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 = @@ -198,7 +201,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir default - Sets the default list for commands\n" + "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + "!mjolnir rules matching - 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 - Sets the policy server name in protected rooms, or removes it if 'unset' is given\n"; const roomsMenu = "" + diff --git a/src/commands/SetPolicyServerCommand.ts b/src/commands/SetPolicyServerCommand.ts new file mode 100644 index 00000000..b6436549 --- /dev/null +++ b/src/commands/SetPolicyServerCommand.ts @@ -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 +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 "); + 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"], "✅"); +} diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index 49edf979..0a1728bc 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -72,6 +72,9 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { html += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}
`; text += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}\n`; + html += `Policy server name: ${mjolnir.protectedRoomsTracker.policyServer?.name}
`; + text += `Policy server name: ${mjolnir.protectedRoomsTracker.policyServer?.name}\n`; + // Append list information const renderPolicyLists = (header: string, lists: PolicyList[]) => { html += `${header}:
    `; diff --git a/src/config.ts b/src/config.ts index 2280fd71..8123317e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -106,6 +106,7 @@ export interface IConfig { fasterMembershipChecks: boolean; automaticallyRedactForReasons: string[]; // case-insensitive globs protectAllJoinedRooms: boolean; + defaultPolicyServer: string; /** * Backgrounded tasks: number of milliseconds to wait between the completion * of one background task and the start of the next one. @@ -236,6 +237,7 @@ const defaultConfig: IConfig = { fasterMembershipChecks: false, automaticallyRedactForReasons: ["spam", "advertising"], protectAllJoinedRooms: false, + defaultPolicyServer: "", backgroundDelayMS: 500, pollReports: false, displayReports: true, diff --git a/test/integration/policyServerTest.ts b/test/integration/policyServerTest.ts new file mode 100644 index 00000000..4ac4abd4 --- /dev/null +++ b/test/integration/policyServerTest.ts @@ -0,0 +1,93 @@ +/* +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 "../../src/Mjolnir"; +import * as http from "node:http"; +import { AddressInfo } from "node:net"; +import { strict as assert } from "assert"; + +describe("Test: Policy Servers", function () { + const ed25519Key = "this would be a real unpadded base64 key in production"; + let mjolnir: Mjolnir; + let lookInRoomId: string; + let policyServerUrl: string; + let policyServer: http.Server; + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + beforeEach(async function () { + mjolnir = this.config.RUNTIME.client!; + policyServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + public_keys: { + ed25519: ed25519Key, + }, + }), + ); + }); + // grab any port by not specifying one to listen on + policyServer.listen(() => { + policyServerUrl = `http://localhost:${(policyServer.address()! as AddressInfo).port}`; + }); + + // Create a room we can inspect + lookInRoomId = await mjolnir.client.createRoom(); + await mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${lookInRoomId}`, + }); + }); + + afterEach(async function () { + policyServer.close(); + }); + + it("should set the policy server information on demand", async function () { + this.timeout(15000); + + // Verify the room does *not* have a policy server set + try { + await mjolnir.client.getRoomStateEventContent(lookInRoomId, "m.room.policy", ""); + assert.fail("Room should not have a policy server set"); + } catch (e) { + assert.equal(e.statusCode, 404); + } + + // Set the policy server, wait a bit, then check for it + await mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir policy_server ${policyServerUrl}`, + }); + await delay(1500); + let policyServerContent = await mjolnir.client.getRoomStateEventContent(lookInRoomId, "m.room.policy", ""); + assert.equal(policyServerContent.url, policyServerUrl); + assert.equal((policyServerContent.public_keys! as Record).ed25519, ed25519Key); + + // Now unset it, wait a bit more, then check for lack of server again + await mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir policy_server unset`, + }); + await delay(1500); + policyServerContent = await mjolnir.client.getRoomStateEventContent(lookInRoomId, "m.room.policy", ""); + assert.equal(policyServerContent.url, undefined); + assert.equal(policyServerContent.public_keys, undefined); + }); +});