diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 773aa4121..0212c50fe 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -187,6 +187,13 @@ export interface UrlConfiguration { */ allowIceFallback: boolean; + /** + * Whether the app may make client `.well-known` lookups against the user's + * homeserver `server_name` (the post-login well-known poll and the legacy + * MatrixRTC foci `.well-known` fallback). Defaults to true. + */ + enableClientWellKnownLookups: boolean; + /** * Whether the app should use per participant keys for E2EE. */ @@ -371,6 +378,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { showControls: true, hideScreensharing: false, allowIceFallback: true, + enableClientWellKnownLookups: true, perParticipantE2EE: true, controlledAudioDevices: platform === "desktop" ? false : true, skipLobby: true, @@ -426,6 +434,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { showControls: true, hideScreensharing: false, allowIceFallback: false, + enableClientWellKnownLookups: true, perParticipantE2EE: false, controlledAudioDevices: false, skipLobby: false, @@ -472,6 +481,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { showControls: parser.getFlag("showControls"), hideScreensharing: parser.getFlag("hideScreensharing"), allowIceFallback: parser.getFlag("allowIceFallback"), + enableClientWellKnownLookups: parser.getFlag("enableClientWellKnownLookups"), perParticipantE2EE: parser.getFlag("perParticipantE2EE"), controlledAudioDevices: parser.getFlag("controlledAudioDevices"), skipLobby: isWidget ? parser.getFlag("skipLobby") : false, diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 10ea79c44..91eae4fc8 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -44,6 +44,7 @@ import { } from "../../../livekit/openIDSFU.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { customLivekitUrl } from "../../../settings/settings.ts"; +import { getUrlParams } from "../../../UrlParams.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; const logger = rootLogger.getChild("[LocalTransport]"); @@ -147,6 +148,7 @@ export const createLocalTransport$ = ({ const transportDiscovery = new RtcTransportAutoDiscovery({ client: client, resolvedConfig: Config.get(), + enableClientWellKnownLookups: getUrlParams().enableClientWellKnownLookups, wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery), logger: logger, }); diff --git a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts index 9314b9932..cf4fe654d 100644 --- a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts +++ b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts @@ -38,6 +38,11 @@ const wellKnownTransport: LivekitTransportConfig = { livekit_service_url: "https://well-known.example.org", }; +const configTransport: LivekitTransportConfig = { + type: "livekit", + livekit_service_url: "https://config.example.org", +}; + function makeClient(): MockedObject { return { getDomain: vi.fn().mockReturnValue("example.org"), @@ -65,10 +70,29 @@ function makeWellKnown(rtcFoci?: Transport[]): IClientWellKnown { } as unknown as IClientWellKnown; } +// Error returned by a homeserver that does not implement the endpoint. This is +// the only failure that permits falling back to the legacy `.well-known` lookup. +const notImplementedError = new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404); + +function makeDiscovery( + overrides: Partial & { + client: DiscoveryClient; + wellKnownFetcher: RtcTransportAutoDiscoveryProps["wellKnownFetcher"]; + }, +): RtcTransportAutoDiscovery { + return new RtcTransportAutoDiscovery({ + resolvedConfig: makeResolvedConfig("https://config.example.org"), + enableClientWellKnownLookups: true, + logger: rootLogger, + ...overrides, + }); +} + describe("RtcTransportAutoDiscovery", () => { beforeEach(() => { vi.clearAllMocks(); }); + const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [ { transports: [backendTransport] }, // will pick the first livekit transport in the list, even if there are other non-livekit transports @@ -77,7 +101,6 @@ describe("RtcTransportAutoDiscovery", () => { it.each(VALID_TEST_CASES)( "prefers backend transport over well-known and app config $transports", async ({ transports }) => { - // it("prefers backend transport over well-known and app config", async () => { const client = makeClient(); client._unstable_getRTCTransports.mockResolvedValue(transports); @@ -85,12 +108,7 @@ describe("RtcTransportAutoDiscovery", () => { .fn<(domain: string) => Promise>() .mockResolvedValue(makeWellKnown([wellKnownTransport])); - const discovery = new RtcTransportAutoDiscovery({ - client, - resolvedConfig: makeResolvedConfig("https://config.example.org"), - wellKnownFetcher, - logger: rootLogger, - }); + const discovery = makeDiscovery({ client, wellKnownFetcher }); await expect( discovery.discoverPreferredTransport(), @@ -120,12 +138,7 @@ describe("RtcTransportAutoDiscovery", () => { .fn<(domain: string) => Promise>() .mockResolvedValue(makeWellKnown([wellKnownTransport])); - const discovery = new RtcTransportAutoDiscovery({ - client, - resolvedConfig: makeResolvedConfig("https://config.example.org"), - wellKnownFetcher, - logger: rootLogger, - }); + const discovery = makeDiscovery({ client, wellKnownFetcher }); await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( backendTransport, @@ -135,12 +148,15 @@ describe("RtcTransportAutoDiscovery", () => { expect(wellKnownFetcher).not.toHaveBeenCalled(); }); - const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [ + // A homeserver that implements the endpoint but returns no usable livekit + // transport has answered authoritatively, so we must NOT leak to the legacy + // `.well-known` lookup. Instead we fall straight through to app config. + const AUTHORITATIVE_EMPTY_CASES: Array<{ transports: Transport[] }> = [ { transports: [] }, { transports: [{ type: "not_livekit" }] }, ]; - it.each(INVALID_TEST_CASES)( - "falls back to well-known when backend has no (valid) livekit transports $transports", + it.each(AUTHORITATIVE_EMPTY_CASES)( + "does not fall back to well-known when the backend answers without a livekit transport $transports", async ({ transports }) => { const client = makeClient(); client._unstable_getRTCTransports.mockResolvedValue(transports); @@ -149,21 +165,54 @@ describe("RtcTransportAutoDiscovery", () => { .fn<(domain: string) => Promise>() .mockResolvedValue(makeWellKnown([wellKnownTransport])); - const discovery = new RtcTransportAutoDiscovery({ - client, - resolvedConfig: makeResolvedConfig("https://config.example.org"), - wellKnownFetcher, - logger: rootLogger, - }); + const discovery = makeDiscovery({ client, wellKnownFetcher }); await expect( discovery.discoverPreferredTransport(), - ).resolves.toStrictEqual(wellKnownTransport); + ).resolves.toStrictEqual(configTransport); - expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); + expect(wellKnownFetcher).not.toHaveBeenCalled(); }, ); + it("falls back to well-known only when the backend returns 404 M_UNRECOGNIZED", async () => { + const client = makeClient(); + client._unstable_getRTCTransports.mockRejectedValue(notImplementedError); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = makeDiscovery({ client, wellKnownFetcher }); + + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + wellKnownTransport, + ); + + expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); + }); + + it("does not fall back to well-known when the backend fails with a non-M_UNRECOGNIZED errcode", async () => { + const client = makeClient(); + // Same 404 status as the not-implemented case, but a different errcode: only + // M_UNRECOGNIZED is treated as "endpoint not implemented". + client._unstable_getRTCTransports.mockRejectedValue( + new MatrixError({ errcode: "M_UNKNOWN" }, 404), + ); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = makeDiscovery({ client, wellKnownFetcher }); + + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + configTransport, + ); + + expect(wellKnownFetcher).not.toHaveBeenCalled(); + }); + it("skips backend discovery in widget mode and uses well-known", async () => { const client = makeClient(); // widget mode is detected by the absence of an access token @@ -173,12 +222,7 @@ describe("RtcTransportAutoDiscovery", () => { .fn<(domain: string) => Promise>() .mockResolvedValue(makeWellKnown([wellKnownTransport])); - const discovery = new RtcTransportAutoDiscovery({ - client, - resolvedConfig: makeResolvedConfig("https://config.example.org"), - wellKnownFetcher, - logger: rootLogger, - }); + const discovery = makeDiscovery({ client, wellKnownFetcher }); await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( wellKnownTransport, @@ -188,44 +232,58 @@ describe("RtcTransportAutoDiscovery", () => { expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); }); - it("falls back to app config when backend fails and well-known has no rtc_foci", async () => { + it("does not make well-known lookups when disabled by config, even in widget mode", async () => { const client = makeClient(); - client._unstable_getRTCTransports.mockRejectedValue( - new MatrixError({ errcode: "M_UNKNOWN" }, 404), + // widget mode: backend endpoint is not attempted + client.getAccessToken.mockReturnValue(null); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = makeDiscovery({ + client, + wellKnownFetcher, + enableClientWellKnownLookups: false, + }); + + // The legacy well-known fallback is skipped entirely; only app config is used. + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + configTransport, ); + expect(wellKnownFetcher).not.toHaveBeenCalled(); + }); + + it("falls back to app config when the backend is not implemented and well-known has no rtc_foci", async () => { + const client = makeClient(); + client._unstable_getRTCTransports.mockRejectedValue(notImplementedError); + const wellKnownFetcher = vi .fn<(domain: string) => Promise>() .mockResolvedValue({} as IClientWellKnown); - const discovery = new RtcTransportAutoDiscovery({ - client, - resolvedConfig: makeResolvedConfig("https://config.example.org"), - wellKnownFetcher, - logger: rootLogger, - }); + const discovery = makeDiscovery({ client, wellKnownFetcher }); await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( - { - type: "livekit", - livekit_service_url: "https://config.example.org", - }, + configTransport, ); + + expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); }); it("returns null when backend, well-known and config are all unavailable", async () => { const client = makeClient(); - client._unstable_getRTCTransports.mockResolvedValue([]); + client._unstable_getRTCTransports.mockRejectedValue(notImplementedError); const wellKnownFetcher = vi .fn<(domain: string) => Promise>() .mockResolvedValue({} as IClientWellKnown); - const discovery = new RtcTransportAutoDiscovery({ + const discovery = makeDiscovery({ client, - resolvedConfig: makeResolvedConfig(undefined), wellKnownFetcher, - logger: rootLogger, + resolvedConfig: makeResolvedConfig(undefined), }); await expect(discovery.discoverPreferredTransport()).resolves.toBeNull(); diff --git a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts index e639cd3b5..dc7cc9ff7 100644 --- a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts +++ b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts @@ -8,7 +8,11 @@ import { isLivekitTransportConfig, type LivekitTransportConfig, } from "matrix-js-sdk/lib/matrixrtc"; -import { type IClientWellKnown, type MatrixClient } from "matrix-js-sdk"; +import { + type IClientWellKnown, + type MatrixClient, + MatrixError, +} from "matrix-js-sdk"; import { type Logger } from "matrix-js-sdk/lib/logger"; import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts"; @@ -22,13 +26,36 @@ type TransportDiscoveryClient = Pick< export interface RtcTransportAutoDiscoveryProps { client: TransportDiscoveryClient; resolvedConfig: ResolvedConfigOptions; + /** + * Whether client `.well-known` lookups against the homeserver's `server_name` + * are allowed. When false, the legacy MatrixRTC foci `.well-known` fallback is + * skipped entirely and only the backend endpoint and app config are used. + */ + enableClientWellKnownLookups: boolean; wellKnownFetcher: (domain: string) => Promise; logger: Logger; } +/** + * The outcome of querying the backend `/rtc/transports` endpoint. + */ +interface BackendTransportResult { + /** The livekit transport found via the backend endpoint, if any. */ + transport: LivekitTransportConfig | null; + /** + * Whether the caller may fall back to the legacy `.well-known` lookup. True + * only when the backend endpoint is unavailable, i.e. it was not attempted + * (widget mode) or the homeserver does not implement it (404 M_UNRECOGNIZED). + * False when the endpoint answered authoritatively (a response without a + * livekit transport) or failed with any other error. + */ + mayFallBackToWellKnown: boolean; +} + export class RtcTransportAutoDiscovery { private readonly client: TransportDiscoveryClient; private readonly resolvedConfig: ResolvedConfigOptions; + private readonly enableClientWellKnownLookups: boolean; private readonly wellKnownFetcher: ( domain: string, ) => Promise; @@ -37,38 +64,53 @@ export class RtcTransportAutoDiscovery { public constructor({ client, resolvedConfig, + enableClientWellKnownLookups, wellKnownFetcher, logger, }: RtcTransportAutoDiscoveryProps) { this.client = client; this.resolvedConfig = resolvedConfig; + this.enableClientWellKnownLookups = enableClientWellKnownLookups; this.wellKnownFetcher = wellKnownFetcher; this.logger = logger.getChild("[RtcTransportAutoDiscovery]"); } public async discoverPreferredTransport(): Promise { // 1) backend transports - const backendTransport = await this.tryBackendTransports(); - if (backendTransport) { + const backend = await this.tryBackendTransports(); + if (backend.transport) { this.logger.info( - `Found backend transport: ${backendTransport.livekit_service_url}`, + `Found backend transport: ${backend.transport.livekit_service_url}`, ); - return backendTransport; + return backend.transport; } - this.logger.info("No backend transport found, falling back to well-known"); // 2) .well-known transports - const wellKnownTransport = await this.tryWellKnownTransports(); - if (wellKnownTransport) { + // Only consulted when the backend endpoint was inconclusive (not attempted + // or not implemented) and client `.well-known` lookups are enabled. This + // avoids contacting the homeserver's `server_name` both when a modern + // endpoint has already given an authoritative answer and when lookups are + // disabled by config. + if (backend.mayFallBackToWellKnown && this.enableClientWellKnownLookups) { this.logger.info( - `Found .well-known transport: ${wellKnownTransport.livekit_service_url}`, + "No backend transport found, falling back to well-known", + ); + const wellKnownTransport = await this.tryWellKnownTransports(); + if (wellKnownTransport) { + this.logger.info( + `Found .well-known transport: ${wellKnownTransport.livekit_service_url}`, + ); + return wellKnownTransport; + } + } else { + this.logger.info( + this.enableClientWellKnownLookups + ? "Skipping .well-known lookup: backend endpoint gave an authoritative response" + : "Skipping .well-known lookup: client well-known lookups are disabled", ); - return wellKnownTransport; } - this.logger.info( - "No .well-known transport found, falling back to app config", - ); + this.logger.info("Falling back to app config"); // 3) app config URL const configTransport = this.tryConfigTransport(); @@ -87,7 +129,7 @@ export class RtcTransportAutoDiscovery { * This will not throw errors, but instead just log them and return null if the expected config is not found or malformed. * @private */ - private async tryBackendTransports(): Promise { + private async tryBackendTransports(): Promise { const client = this.client; // MSC4143: Attempt to fetch transports from backend. // TODO: Workaround for an issue in the js-sdk RoomWidgetClient that @@ -104,21 +146,36 @@ export class RtcTransportAutoDiscovery { ); const first = transportList.find(isLivekitTransportConfig); if (first) { - return first; - } else { - this.logger.info( - `No livekit transport found in getRTCTransports end point`, - transportList, - ); + return { transport: first, mayFallBackToWellKnown: false }; } + // The homeserver implements the endpoint but returned no livekit + // transport. This is an authoritative answer, so we do not fall back + // to the legacy `.well-known` lookup. + this.logger.info( + `No livekit transport found in getRTCTransports end point`, + transportList, + ); + return { transport: null, mayFallBackToWellKnown: false }; } catch (ex) { - this.logger.info(`Failed to use getRTCTransports end point: ${ex}`); + // Only a 404 M_UNRECOGNIZED means the homeserver does not implement the + // endpoint, which is the one case where falling back to the legacy + // `.well-known` lookup is appropriate. Any other error is transient or + // unexpected, so we do not fall back. + if (ex instanceof MatrixError && ex.errcode === "M_UNRECOGNIZED") { + this.logger.info( + "getRTCTransports end point not implemented by homeserver", + ); + return { transport: null, mayFallBackToWellKnown: true }; + } + this.logger.warn(`Failed to use getRTCTransports end point: ${ex}`); + return { transport: null, mayFallBackToWellKnown: false }; } - } else { - this.logger.debug(`getRTCTransports end point not available`); } - return null; + // Not attempted (e.g. widget mode with no access token). Preserve the + // existing behaviour of allowing the legacy `.well-known` lookup. + this.logger.debug(`getRTCTransports end point not available`); + return { transport: null, mayFallBackToWellKnown: true }; } /** diff --git a/src/utils/matrix.ts b/src/utils/matrix.ts index 4e3ae3c32..2ea7aea76 100644 --- a/src/utils/matrix.ts +++ b/src/utils/matrix.ts @@ -112,7 +112,7 @@ export async function initClient( // the values around, but we initialise the matrix client in // many different places so we'd have to pass it into all of // them. - const { e2eEnabled } = getUrlParams(); + const { e2eEnabled, enableClientWellKnownLookups } = getUrlParams(); if (!e2eEnabled) { logger.info("Disabling E2E: group call signalling will NOT be encrypted."); } @@ -166,7 +166,11 @@ export async function initClient( // Otherwise, a sync may complete before the listener gets applied, // and we will miss it. const syncPromise = waitForSync(client); - await client.startClient({ clientWellKnownPollPeriod: 60 * 10 }); + // Leaving `clientWellKnownPollPeriod` unset stops the SDK polling the + // homeserver's `server_name` `.well-known` after login. + await client.startClient({ + clientWellKnownPollPeriod: enableClientWellKnownLookups ? 60 * 10 : undefined, + }); await syncPromise; return client; diff --git a/src/widget.ts b/src/widget.ts index 462fc6e05..6c4c80903 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -82,6 +82,7 @@ export const initializeWidget = ( baseUrl, e2eEnabled, allowIceFallback, + enableClientWellKnownLookups, } = getUrlParams(); if (!roomId) throw new Error("Room ID must be supplied"); @@ -195,7 +196,13 @@ export const initializeWidget = ( // Wait for the config file to be ready (we load very early on so it might not // be otherwise) await Config.init(); - await client.startClient({ clientWellKnownPollPeriod: 60 * 10 }); + // Leaving `clientWellKnownPollPeriod` unset stops the SDK polling the + // homeserver's `server_name` `.well-known` after login. + await client.startClient({ + clientWellKnownPollPeriod: enableClientWellKnownLookups + ? 60 * 10 + : undefined, + }); return client; };