From d03441e759eaf41bfcafb4fedca60f71bcd3083f Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 15 Nov 2022 16:57:30 +0000 Subject: [PATCH] appservice doc --- docs/appservice.md | 18 ++++---- src/appservice/AccessControl.ts | 12 +++--- src/appservice/Api.ts | 23 ++++++---- src/appservice/AppService.ts | 72 ++++++++++++++++++++++++++------ src/appservice/MjolnirManager.ts | 16 +++---- src/appservice/config/config.ts | 7 ++++ src/appservice/datastore.ts | 36 ++++++++++++++++ 7 files changed, 143 insertions(+), 41 deletions(-) diff --git a/docs/appservice.md b/docs/appservice.md index 8d2d656..bc9ca20 100644 --- a/docs/appservice.md +++ b/docs/appservice.md @@ -12,17 +12,21 @@ This guide assumes you will be using Docker and that you are able to provide a p FIXME: Currently required to be aliased. FIXME: Should really be created and managed by the admin room, but waiting for command refactor before doing that. -2. Decide on a spare local TCP port number to use that will listen for messages from the matrix homeserver. Take care to configure firewalls appropriately. This port will be notated as `$MATRIX_PORT` in the remaining instructions. +2. Decide on a spare local TCP port number to use that will listen for messages from the matrix homeserver. Take care to configure firewalls appropriately. We will call this port `$MATRIX_PORT` in the remaining instructions. -3. Create a config/config.appservice.yaml file that can be copied from the example in `src/appservice/config/config.example.yaml`. Your config file needs to be accessible to the docker container later on. +3. Create a `config/config.appservice.yaml` file that can be copied from the example in `src/appservice/config/config.example.yaml`. Your config file needs to be accessible to the docker container later on. To do this you could create a directory called `mjolnir-data` so we can map it to a volume when we launch the container later on. 4. Generate the appservice registration file. This will be used by both the appservice and your homeserver. - Here, you must specify the direct link the Matrix Homserver can use to access the appservice, including the Matrix port it will send messages through (if this bridge runs on the same machine you can use localhost as the $HOST name): + Here, you must specify the direct link the Matrix Homeserver can use to access the appservice, including the Matrix port it will send messages through (if this bridge runs on the same machine you can use `localhost` as the `$HOST` name): - `docker run -rm -v /mjolnir/data/path:/data matrixdotorg/mjolnir appservice -r -u "http://$HOST:$MATRIX_PORT" -f /data/config/mjolnir-registration.yaml` + `docker run -rm -v /your/path/to/mjolnir-data:/data matrixdotorg/mjolnir appservice -r -u "http://$HOST:$MATRIX_PORT" -f /data/config/mjolnir-registration.yaml` -5. Invite the application service bot (specified in the registration) to the access control room you made earlier. +5. Step 4 created an application service bot. This will be a bot iwth the mxid specified in `mjolnir-registration.yaml` under `sender_localpart`. You now need to invite it in the access control room that you have created in Step 1. -6. Start the application service `docker run -v /path/to/data/:/data/ matrixdotorg/mjolnir appservice -c /data/config/config.appservice.yaml -f /data/config/mjolnir-registration.yaml -p $MATRIX_PORT` +6. Start the application service `docker run -v /your/path/to/mjolnir-data/:/data/ matrixdotorg/mjolnir appservice -c /data/config/config.appservice.yaml -f /data/config/mjolnir-registration.yaml -p $MATRIX_PORT` -7. Copy the `mjolnir-registration.yaml` to your matrix homeserver and refer to it in `homeserver.yaml`. +7. Copy the `mjolnir-registration.yaml` to your matrix homeserver and refer to it in `homeserver.yaml` like so: +``` + app_service_config_files: + - "/data/mjolnir-registration.yaml" +``` diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index 909434e..b7a18bb 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -18,11 +18,6 @@ import { Bridge } from "matrix-appservice-bridge"; import AccessControlUnit, { EntityAccess } from "../models/AccessControlUnit"; import PolicyList from "../models/PolicyList"; import { Permalinks } from "matrix-bot-sdk"; - -// We need to refactor AccessControlUnit so you can have -// previousAccess and currentAccess listener for changes. -// wait that only works for literals not globs... -// i guess when the rule change is a glob we have to scan everything. export class AccessControl { private constructor( @@ -31,7 +26,14 @@ export class AccessControl { ) { } + /** + * Construct and initialize access control for the `MjolnirAppService`. + * @param accessControlListId The room id of a policy list used to manage access to the appservice (who can provision & use mjolniren) + * @param bridge The matrix-appservice-bridge, used to get the appservice bot. + * @returns A new instance of `AccessControl` to be used by `MjolnirAppService`. + */ public static async setupAccessControl( + /** The room id for the access control list. */ accessControlListId: string, bridge: Bridge, ): Promise { diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index 7371695..a279116 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -16,7 +16,12 @@ export class Api { private mjolnirManager: MjolnirManager, ) {} - private resolveAccessToken(accessToken: string): Promise { + /** + * Resolves an open id access token to find a matching user that the token is valid for. + * @param accessToken An openID token. + * @returns The mxid of the user that this token belongs to or null if the token could not be authenticated. + */ + private resolveAccessToken(accessToken: string): Promise { return new Promise((resolve, reject) => { request({ url: `${this.homeserver}/_matrix/federation/v1/openid/userinfo`, @@ -39,8 +44,8 @@ export class Api { }); } - public async close() { - return await new Promise((resolve, reject) => { + public async close(): Promise { + await new Promise((resolve, reject) => { if (!this.httpServer) { throw new TypeError("Server was never started"); } @@ -64,8 +69,8 @@ export class Api { /** * Finds the management room for a mjolnir. - * @param req.body.openId An OpenID token to verify the send of the request with. - * @param req.body.mxid The mjolnir we want to find the management room for. + * @param req.body.openId An OpenID token to verify that the sender of the request owns the mjolnir described in `req.body.mxid`. + * @param req.body.mxid The mxid of the mjolnir we want to find the management room for. */ private async pathGet(req: express.Request, response: express.Response) { const accessToken = req.body["openId"]; @@ -99,7 +104,7 @@ export class Api { /** * Return the mxids of mjolnirs that this user has provisioned. - * @param req.body.openId An OpenID token to verify the send of the request with. + * @param req.body.openId An OpenID token to find the sender of the request with and find their provisioned mjolnirs. */ private async pathList(req: express.Request, response: express.Response) { const accessToken = req.body["openId"]; @@ -122,7 +127,7 @@ export class Api { * Creates a new mjolnir for the requesting user and protects their first room. * @param req.body.roomId The room id that the request to create a mjolnir originates from. * This is so that mjolnir can protect the room once the authenticity of the request has been verified. - * @param req.body.openId An OpenID token to verify the send of the request with. + * @param req.body.openId An OpenID token to find the sender of the request with. */ private async pathCreate(req: express.Request, response: express.Response) { const accessToken = req.body["openId"]; @@ -152,8 +157,8 @@ export class Api { /** * Request a mjolnir to join and protect a room. - * @param req.body.openId An OpenID token to verify the send of the request with. - * @param req.body.mxid The mjolnir that should join the room. + * @param req.body.openId An OpenID token to find the sender of the request with and that they own the mjolnir described in `req.body.mxid`. + * @param req.body.mxid The mxid of the mjolnir that should join the room. * @param req.body.roomId The room that this mjolnir should join and protect. */ private async pathJoin(req: express.Request, response: express.Response) { diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index c664cc4..c51b536 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -21,10 +21,18 @@ import { Api } from "./Api"; import { IConfig } from "./config/config"; import { AccessControl } from "./AccessControl"; +/** + * Responsible for setting up listeners and delegating functionality to a matrix-appservice-bridge `Bridge` for + * the entrypoint of the application. + */ export class MjolnirAppService { private readonly api: Api; + /** + * The constructor is private because we want to ensure intialization steps are followed, + * use `makeMjolnirAppService`. + */ private constructor( public readonly config: IConfig, public readonly bridge: Bridge, @@ -35,6 +43,13 @@ export class MjolnirAppService { this.api = new Api(config.homeserver.url, mjolnirManager); } + /** + * Make and initialize the app service from the config, ready to be started. + * @param config The appservice's config, not mjolnir's, see `src/appservice/config`. + * @param dataStore A datastore to persist infomration about the mjolniren to. + * @param registrationFilePath A file path to the registration file to read the namespace and tokens from. + * @returns A new `MjolnirAppService`. + */ public static async makeMjolnirAppService(config: IConfig, dataStore: DataStore, registrationFilePath: string) { const bridge = new Bridge({ homeserverUrl: config.homeserver.url, @@ -67,36 +82,75 @@ export class MjolnirAppService { return appService; } + /** + * Start the appservice for the end user with the appropriate settings from their config and registration file. + * @param port The port to make the appservice listen for transactions from the homeserver on (usually sourced from the cli). + * @param config The parsed configuration file. + * @param registrationFilePath A path to their homeserver registration file. + */ + public static async run(port: number, config: IConfig, registrationFilePath: string) { + const dataStore = new PgDataStore(config.db.connectionString); + await dataStore.init(); + const service = await MjolnirAppService.makeMjolnirAppService(config, dataStore, registrationFilePath); + // The call to `start` MUST happen last. As it needs the datastore, and the mjolnir manager to be initialized before it can process events from the homeserver. + await service.start(port); + } + public onUserQuery (queriedUser: MatrixUser) { return {}; // auto-provision users with no additonal data } - // is it ok for this to be async? seems a bit dodge. - // it should be BridgeRequestEvent not whatever this is + /** + * Handle an individual event pushed by the homeserver to us. + * This function is async (and anything downstream would be anyway), which does mean that events can be processed out of order. + * Not a huge problem for us, but is something to be aware of. + * @param request A matrix-appservice-bridge request encapsulating a Matrix event. + * @param context Additional context for the Matrix event. + */ public async onEvent(request: Request, context: BridgeContext) { const mxEvent = request.getData(); + // Provision a new mjolnir for the invitee when the appservice bot (designated by this.bridge.botUserId) is invited to a room. + // Acts as an alternative to the web api provided for the widget. if ('m.room.member' === mxEvent.type) { if ('invite' === mxEvent.content['membership'] && mxEvent.state_key === this.bridge.botUserId) { await this.mjolnirManager.provisionNewMjolnir(mxEvent.sender); + // reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir. + this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id).catch(e => { + console.warn("Unable to reject an invite to a room", e) + }); } } this.accessControl.handleEvent(mxEvent['room_id'], mxEvent); this.mjolnirManager.onEvent(request, context); } - public async start(port: number) { - console.log("Matrix-side listening on port %s", port); + /** + * Start the appservice. See `run`. + * @param port The port that the appservice should listen on to receive transactions from the homeserver. + */ + private async start(port: number) { + console.log("Starting MjolnirAppService, Matrix-side to listen on port %s", port); this.api.start(this.config.webAPI.port); await this.bridge.listen(port); + console.log("MjolnirAppService started successfully"); } + /** + * Stop listening to requests from both the homeserver and web api and disconnect from the datastore. + */ public async close(): Promise { await this.bridge.close(); await this.dataStore.close(); await this.api.close(); } - public static generateRegistration(reg: AppServiceRegistration, callback: (finalRegisration: AppServiceRegistration) => void) { + /** + * Generate a registration file for a fresh deployment of the appservice. + * Included to satisfy `matrix-appservice-bridge`'s `Cli` utility which allows a registration file to be registered when setting up a deployment of an appservice. + * @param reg Any existing paramaters to be included in the registration, to be mutated by this method. + * @param callback To call when the registration has been generated with the final registration. + */ + public static generateRegistration(reg: AppServiceRegistration, callback: (finalRegistration: AppServiceRegistration) => void) { reg.setId(AppServiceRegistration.generateToken()); reg.setHomeserverToken(AppServiceRegistration.generateToken()); reg.setAppServiceToken(AppServiceRegistration.generateToken()); @@ -105,12 +159,4 @@ export class MjolnirAppService { reg.setRateLimited(false); callback(reg); } - - public static async run(port: number, config: IConfig, registrationFilePath: string) { - const dataStore = new PgDataStore(config.db.connectionString); - await dataStore.init(); - const service = await MjolnirAppService.makeMjolnirAppService(config, dataStore, registrationFilePath); - // Can't stress how important it is that listen happens last. - await service.start(port); - } } diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts index 67c9489..5f65bc7 100644 --- a/src/appservice/MjolnirManager.ts +++ b/src/appservice/MjolnirManager.ts @@ -43,7 +43,7 @@ export class MjolnirManager { * @param managementRoomId A room that has been created to serve as the mjolnir's management room for the owner. * @returns A config that can be directly used by the new mjolnir. */ - public getDefaultMjolnirConfig(managementRoomId: string): IConfig { + private getDefaultMjolnirConfig(managementRoomId: string): IConfig { let config = configRead(); config.managementRoom = managementRoomId; config.protectedRooms = []; @@ -97,6 +97,9 @@ export class MjolnirManager { return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerId !== ownerId); } + /** + * Listener that should be setup and called by `MjolnirAppService` while listening to the bridge abstraction provided by matrix-appservice-bridge. + */ public onEvent(request: Request, context: BridgeContext) { // TODO We need a way to map a room id (that the event is from) to a set of managed mjolnirs that should be informed. // https://github.com/matrix-org/mjolnir/issues/412 @@ -157,24 +160,22 @@ export class MjolnirManager { */ private async createMjolnirsFromDataStore() { for (const mjolnirRecord of await this.dataStore.list()) { - const [_mjolnirUserId, mjolnirClient] = await this.makeMatrixClient(mjolnirRecord.local_part); + const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); const access = this.accessControl.getUserAccess(mjolnirRecord.owner); if (access.outcome !== Access.Allowed) { // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. - mjolnirClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); + mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); } else { await this.makeInstance( mjolnirRecord.owner, mjolnirRecord.management_room, - mjolnirClient, + mjIntent.matrixClient, ); } } } } -// Isolating this mjolnir is going to require provisioning an access token just for this user to be useful. -// We can use fork and node's IPC to inform the process of matrix evnets. export class ManagedMjolnir { public constructor( public readonly ownerId: string, @@ -182,7 +183,8 @@ export class ManagedMjolnir { ) { } public async onEvent(request: Request) { - // phony sync. + // Emulate the client syncing. + // https://github.com/matrix-org/mjolnir/issues/411 const mxEvent = request.getData(); if (mxEvent['type'] !== undefined) { this.mjolnir.client.emit('room.event', mxEvent.room_id, mxEvent); diff --git a/src/appservice/config/config.ts b/src/appservice/config/config.ts index 36c2617..ea6b44f 100644 --- a/src/appservice/config/config.ts +++ b/src/appservice/config/config.ts @@ -18,16 +18,23 @@ import * as fs from "fs"; import { load } from "js-yaml"; export interface IConfig { + /** Details for the homeserver the appservice will be serving */ homeserver: { + /** The domain of the homeserver that is found at the end of mxids */ domain: string, + /** The url to use to acccess the client server api e.g. "https://matrix-client.matrix.org" */ url: string }, + /** Details for the database backend */ db: { + /** Postgres connection string */ connectionString: string }, + /** Config for the web api used to access the appservice via the widget */ webAPI: { port: number }, + /** A policy room for controlling access to the appservice */ accessControlList: string, } diff --git a/src/appservice/datastore.ts b/src/appservice/datastore.ts index 5a79414..848e210 100644 --- a/src/appservice/datastore.ts +++ b/src/appservice/datastore.ts @@ -1,3 +1,18 @@ +/* +Copyright 2022 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 { Client } from "pg"; export interface MjolnirRecord { @@ -6,17 +21,38 @@ export interface MjolnirRecord { management_room: string, } +/** + * Used to persist mjolnirs that have been provisioned by the mjolnir manager. + */ export interface DataStore { + /** + * Initialize any resources that the datastore needs to function. + */ init(): Promise; + /** + * Close any resources that the datastore is using. + */ close(): Promise; + /** + * List all of the mjolnirs we have provisioned. + */ list(): Promise; + /** + * Persist a new `MjolnirRecord`. + */ store(mjolnirRecord: MjolnirRecord): Promise; + /** + * @param owner The mxid of the user who provisioned this mjolnir. + */ lookupByOwner(owner: string): Promise; + /** + * @param localPart the mxid of the provisioned mjolnir. + */ lookupByLocalPart(localPart: string): Promise; }