diff --git a/packages/mabr-route-proxy/README.md b/packages/mabr-route-proxy/README.md index 1acd996..8c2d5a2 100644 --- a/packages/mabr-route-proxy/README.md +++ b/packages/mabr-route-proxy/README.md @@ -7,16 +7,20 @@ ROUTE/FDT `contentLocation` as the cache key, and stores DASH/HLS manifests and CMAF objects. Shaka keeps its normal manifest and segment URLs: ```ts -import { createRouteMabrSession } from '@blockcast/mabr-route-proxy'; +import { createRouteMabrSession, discoverSlsEndpoint } from '@blockcast/mabr-route-proxy'; -// ROUTE objects are mounted under baseUrl: an FDT Content-Location of -// `live/manifest.mpd` is served at `${baseUrl}live/manifest.mpd`. -const baseUrl = 'https://player.example/mabr/'; -const manifestUrl = new URL('live/manifest.mpd', baseUrl).href; +const toSls = await discoverSlsEndpoint({ + sltUrl: 'https://live.example', + serviceId: 'generic-service', +}); +if (!toSls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); +// ROUTE Content-Location and the public DS manifest must share this cache +// scope; derive it from the URL signalled by the DS. +const baseUrl = new URL('.', toSls.manifestUrl).href; const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, - // `toSls` is the service SLS endpoint selected by the sender/TO. Use + // `toSls` is the service SLS endpoint selected by the public DS. Use // ingress: 'amt'; with an SSM source the gateway discovers the relay via // RFC 8777 DNS (relay/relayPort are only an explicit override). ingress: 'native', @@ -28,7 +32,7 @@ const proxy = createRouteMabrSession({ const restoreFetch = proxy.installFetch(); await proxy.start(); -await shakaPlayer.load(manifestUrl); // DASH MPD or HLS .m3u8 +await shakaPlayer.load(toSls.manifestUrl); // DASH MPD or HLS .m3u8 ``` `baseUrl` is also the cache scope. Only Content-Location URLs under its @@ -39,26 +43,30 @@ own, never the origin root. A root `baseUrl` lets an on-path injector shadow any same-origin URL. When a network does not carry LLS on the ATSC 3 ASM group, bootstrap the -service SLS over HTTPS. `sltUrl` is the HTTP LLS/SLT authority exposed by the -TO-selected delivery service; it is used only to select the first service SLS -leg. Its hostname is never used as the AMT relay. The browser still receives -ROUTE bytes through DIMT or AMT, and the S-TSID on that -leg supplies the rendition `(S,G,port)` values: +service SLS over HTTPS. `sltUrl` may be the public delivery-service origin or +an explicit `/slt/` URL. For an origin, the library reads the public +`/slt/` listing and fetches the advertised groups; it never assumes a group +number. The selected service's `SvcInetUrl` supplies the DASH or HLS manifest +URL, while its SLS leg supplies the first multicast join. Its hostname is +never used as the AMT relay. The browser still receives ROUTE bytes through +DIMT or AMT, and the S-TSID on that leg supplies the rendition `(S,G,port)` +values. Manifest discovery is HTTPS-only; an HTTP discovery origin can still +be used for local testing, but it does not produce a `manifestUrl`. ```ts +const sls = await discoverSlsEndpoint({sltUrl: 'https://live.example', serviceId: 'generic-service'}); +if (!sls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, ingress: 'native', // use 'amt' for AMT/DRIAD when SLS has an SSM source - sltUrl: 'https://live.example/slt/', - sltGroup: 7, // optional when /slt/ lists one group - serviceId: 'nasa-iss-a', // globalServiceID or numeric serviceId + slsEndpoint: sls, discoverEndpoints: true, - baseUrl, + baseUrl: new URL('.', sls.manifestUrl).href, }); const restoreFetch = proxy.installFetch(); await proxy.start(); -await shakaPlayer.load(manifestUrl); // .mpd or .m3u8 +await shakaPlayer.load(sls.manifestUrl); // .mpd or .m3u8 ``` The client fetches `/slt/` to list access groups, then `/slt/` to read diff --git a/packages/mabr-route-proxy/src/route.test.ts b/packages/mabr-route-proxy/src/route.test.ts index 460fc4e..b8a14eb 100644 --- a/packages/mabr-route-proxy/src/route.test.ts +++ b/packages/mabr-route-proxy/src/route.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createRouteMabrSession, type RouteObject } from './route.ts'; +import { createRouteMabrSession, discoverSlsEndpoint, type RouteObject } from './route.ts'; function fakeWasm(mpd = '#EXTM3U\n# generated by SLS') { const memory = new WebAssembly.Memory({ initial: 18 }); @@ -88,6 +88,17 @@ test('feeds ROUTE objects to DASH and HLS URLs and detaches cleanly', async () = assert.equal(listener, undefined); }); +test('fails loudly when the discovered manifest is outside the cache scope', async () => { + const session = createRouteMabrSession({ + gateway: { async subscribe() { throw new Error('must not subscribe'); } }, + wasm: fakeWasm(), + ingress: 'native', + slsEndpoint: { group: '232.1.1.66', port: 8000, manifestUrl: 'https://ds.example/live/manifest.mpd' }, + baseUrl: 'https://player.example/mabr/', + }); + await assert.rejects(session.start(), /outside baseUrl scope/); +}); + test('preserves completed ROUTE destination metadata for SLS/FDT routing', async () => { let listener: ((event: CustomEvent) => void) | undefined; let index = 0; @@ -308,11 +319,14 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { return new Response('
'); } if (url === 'https://relay.test/slt/7') { - return new Response(``); + return new Response(`/live/manifest.mpd`); } return new Response('missing', { status: 404 }); }) as typeof fetch; try { + const discovered = await discoverSlsEndpoint({sltUrl: 'https://relay.test', serviceId: 'nasa-iss'}); + assert.equal(discovered.manifestUrl, 'https://relay.test/live/manifest.mpd'); + urls.length = 0; const session = createRouteMabrSession({ gateway: { async subscribe(value: object) { config = value as Record; @@ -322,6 +336,7 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { ingress: 'native', sltUrl: 'https://relay.test', serviceId: 'nasa-iss', + baseUrl: 'https://relay.test/live/', }); await session.start(); assert.deepEqual(urls, ['https://relay.test/slt/', 'https://relay.test/slt/7']); @@ -334,6 +349,137 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { } }); +test('discovers an HLS manifest from a DS authority without a fixed SLT group', async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === 'https://ds.example.test/slt/') { + return new Response('7/'); + } + if (url === 'https://ds.example.test/slt/7') { + return new Response(''); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + manifestUrl: 'https://ds.example.test/live/master.m3u8', + }); + assert.deepEqual(urls, [ + 'https://ds.example.test/slt/', + 'https://ds.example.test/slt/7', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('discovers a TO delivery path from the Blockcast SLT extension', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + '/live/manifest.mpd', + )) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test/slt/7', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + manifestUrl: 'https://ds.example.test/live/manifest.mpd', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('accepts dsUrl as the browser discovery bootstrap', async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === 'https://ds.example.test/slt/') return new Response('7'); + return new Response('/live/manifest.mpd'); + }) as typeof fetch; + try { + const endpoint = await discoverSlsEndpoint({dsUrl: 'https://ds.example.test', serviceId: 'generic-service'}); + assert.equal(endpoint.manifestUrl, 'https://ds.example.test/live/manifest.mpd'); + assert.deepEqual(urls, ['https://ds.example.test/slt/', 'https://ds.example.test/slt/7']); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolves relative discovery URLs before comparing their origins', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'http://localhost/slt/7') { + return new Response(''); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: '/slt/7', + dsUrl: 'http://localhost/', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('rejects duplicate SLS legs with conflicting manifest URLs', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'https://ds.example.test/slt/') return new Response('78'); + if (url === 'https://ds.example.test/slt/7') { + return new Response('/live/a.mpd'); + } + if (url === 'https://ds.example.test/slt/8') { + return new Response('/live/b.mpd'); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + await assert.rejects( + discoverSlsEndpoint({sltUrl: 'https://ds.example.test', serviceId: 'generic-service'}), + /HTTP LLS advertised 2 ROUTE SLS endpoints/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('does not return a cross-origin SvcInetUrl as the DS manifest', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + 'https://other.example/live/manifest.mpd', + )) as typeof fetch; + try { + const endpoint = await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test/slt/7', + serviceId: 'generic-service', + }); + assert.equal(endpoint.manifestUrl, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('subscribes to AMT relay bytes', async () => { let config: Record | undefined; const gateway = { @@ -421,7 +567,7 @@ test('rejects a sourceless AMT endpoint instead of emitting a DRIAD join', async assert.equal(subscriptions, 0); }); -test('discovers AMT service bytes through a delivery-service hostname', async () => { +test('discovers AMT service bytes through a public delivery-service authority', async () => { const originalFetch = globalThis.fetch; const urls: string[] = []; const fetchOptions: RequestInit[] = []; @@ -430,11 +576,11 @@ test('discovers AMT service bytes through a delivery-service hostname', async () const url = String(input); urls.push(url); fetchOptions.push(init ?? {}); - if (url === 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/') { + if (url === 'https://ds.example.test/slt/') { return new Response('
'); } - if (url === 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/7') { - return new Response(''); + if (url === 'https://ds.example.test/slt/7') { + return new Response(''); } return new Response('missing', { status: 404 }); }) as typeof fetch; @@ -446,21 +592,21 @@ test('discovers AMT service bytes through a delivery-service hostname', async () } }, wasm: fakeWasm(), ingress: 'amt', - sltUrl: 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net', - serviceId: 'nasa-iss-a', + sltUrl: 'https://ds.example.test', + serviceId: 'generic-service', }); await session.start(); assert.deepEqual(urls, [ - 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/', - 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/7', + 'https://ds.example.test/slt/', + 'https://ds.example.test/slt/7', ]); assert.deepEqual(fetchOptions, [ { credentials: 'omit', mode: 'cors' }, { credentials: 'omit', mode: 'cors' }, ]); assert.deepEqual(config, { - origin: 'amt', group: '232.0.1.3', port: 1024, - source: '69.25.95.198', consume: 'bytes', discovery: 'driad', + origin: 'amt', group: '232.1.1.66', port: 8000, + source: '192.0.2.10', consume: 'bytes', discovery: 'driad', }); await session.stop(); } finally { diff --git a/packages/mabr-route-proxy/src/route.ts b/packages/mabr-route-proxy/src/route.ts index 73069fc..d302cd6 100644 --- a/packages/mabr-route-proxy/src/route.ts +++ b/packages/mabr-route-proxy/src/route.ts @@ -140,6 +140,8 @@ export interface RouteEndpoint { group: string; port: number; source?: string; + /** Optional HTTP manifest advertised by the selected SLT service. */ + manifestUrl?: string; } export interface RouteGateway { @@ -165,6 +167,8 @@ export interface RouteMabrOptions { slsEndpoint?: RouteEndpoint; /** HTTP LLS/SLT endpoint used to discover the service SLS leg. */ sltUrl?: string; + /** Public delivery-service origin; `/slt/` is discovered from this origin. */ + dsUrl?: string; /** Optional LLS group id when `sltUrl` exposes more than one SLT. */ sltGroup?: number; /** Optional numeric A/331 service id or global service id to select from SLT. */ @@ -202,7 +206,31 @@ function normalizeRouteSource(value?: string): string | undefined { return source; } -function parseSltEndpoints(xml: string, serviceId?: string | number): RouteEndpoint[] { +function advertisedManifestUrl(body: string, authority: URL): string | undefined { + const candidates: string[] = []; + const pattern = /<((?:[A-Za-z_][\w.-]*:)?(?:SvcInetUrl|PresentationManifest))\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/gi; + for (const match of body.matchAll(pattern)) { + const text = xmlUnescape(match[3] ?? '').trim(); + const attribute = xmlUnescape(xmlAttribute(match[2]!, 'url') || xmlAttribute(match[2]!, 'href')).trim(); + for (const value of [text, attribute]) { + if (/\.(?:mpd|m3u8)(?:[?#].*)?$/i.test(value)) candidates.push(value); + } + } + for (const candidate of candidates) { + try { + const parsed = new URL(candidate, authority); + // The sender's SvcInetUrl/Blockcast PresentationManifest is consumed as + // the DS's public media URL. Do not allow an untrusted SLS document to + // redirect the page to another origin. + if (parsed.protocol === 'https:' && parsed.origin === authority.origin) return parsed.href; + } catch { + // An optional malformed service URL must not hide a valid SLS leg. + } + } + return undefined; +} + +function parseSltEndpoints(xml: string, serviceId?: string | number, authority?: URL): RouteEndpoint[] { const endpoints: RouteEndpoint[] = []; const servicePattern = /<((?:[A-Za-z_][\w.-]*:)?(?:Service|Server))\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi; let serviceMatch: RegExpExecArray | null; @@ -219,21 +247,47 @@ function parseSltEndpoints(xml: string, serviceId?: string | number): RouteEndpo const source = normalizeRouteSource(xmlUnescape(xmlAttribute(signal[2]!, 'slsSourceIpAddress'))); const port = Number(xmlAttribute(signal[2]!, 'slsDestinationUdpPort')); if (group && Number.isInteger(port) && port > 0 && port <= 65535) { - endpoints.push({ group, port, ...(source ? { source } : {}) }); + const manifestUrl = authority ? advertisedManifestUrl(body, authority) : undefined; + endpoints.push({ group, port, ...(source ? { source } : {}), ...(manifestUrl ? { manifestUrl } : {}) }); } } return endpoints; } +function sltGroups(index: string, sltDirectory: string): number[] { + const directory = new URL(sltDirectory); + const groups: number[] = []; + const links = /]*\bhref\s*=\s*(["'])(.*?)\1/gi; + for (const match of index.matchAll(links)) { + try { + const link = new URL(xmlUnescape(match[2]!), directory); + if (link.origin !== directory.origin) continue; + const prefix = directory.pathname.endsWith('/') ? directory.pathname : `${directory.pathname}/`; + const relative = link.pathname.slice(prefix.length).replace(/\/+$/, ''); + if (/^\d+$/.test(relative) && link.search === '' && link.hash === '') groups.push(Number(relative)); + } catch { + // Ignore non-URL links in a directory listing. + } + } + return [...new Set(groups)]; +} + /** * Resolve the one service SLS bootstrap endpoint advertised by a public DS * SLT/LLS URL. The returned source is the authority for AMT DRIAD; rendition * groups are still learned later from the service S-TSID. */ -export async function discoverSlsEndpoint(options: Pick): Promise { - const raw = options.sltUrl?.trim(); - if (!raw) throw new RangeError('sltUrl is required for HTTP LLS discovery'); - const base = new URL(raw, globalThis.location?.href ?? 'http://localhost/'); +export async function discoverSlsEndpoint(options: Pick): Promise { + const sltRaw = options.sltUrl?.trim(); + const dsRaw = options.dsUrl?.trim(); + if (!sltRaw && !dsRaw) throw new RangeError('sltUrl or dsUrl is required for HTTP LLS discovery'); + const pageBase = globalThis.location?.href ?? 'http://localhost/'; + const slt = sltRaw ? new URL(sltRaw, pageBase) : undefined; + const ds = dsRaw ? new URL(dsRaw, pageBase) : undefined; + if (slt && ds && slt.origin !== ds.origin) { + throw new RangeError('sltUrl and dsUrl must use the same public DS origin'); + } + const base = slt ?? ds!; const basePath = base.pathname.replace(/\/+$/, ''); const get = async (url: string): Promise => { // The DS is public HTTP discovery, not a TO control-plane endpoint. Keep @@ -244,7 +298,7 @@ export async function discoverSlsEndpoint(options: Pick Number(match[1])) + ? sltGroups(index, sltDirectory) : [options.sltGroup]; const documents = groups.length > 0 ? await Promise.all([...new Set(groups)].map((group) => get(new URL(String(group), sltDirectory).href))) : [index]; - const endpoints = documents.flatMap((document) => parseSltEndpoints(document, options.serviceId)); - const unique = [...new Map(endpoints.map((endpoint) => [`${endpoint.source ?? ''}\0${endpoint.group}\0${endpoint.port}`, endpoint])).values()]; + const endpoints = documents.flatMap((document) => parseSltEndpoints(document, options.serviceId, new URL('/', base.origin))); + const unique = [...new Map(endpoints.map((endpoint) => [ + `${endpoint.source ?? ''}\0${endpoint.group}\0${endpoint.port}\0${endpoint.manifestUrl ?? ''}`, + endpoint, + ])).values()]; if (unique.length !== 1) { const selector = options.serviceId === undefined ? 'serviceId' : `serviceId ${String(options.serviceId)}`; throw new Error(`HTTP LLS advertised ${unique.length} ROUTE SLS endpoints for ${selector}; select exactly one`); @@ -721,7 +778,7 @@ export class RouteMabrSession { const ingress = options.ingress ?? 'tuner'; const endpoints = options.slsEndpoint ? [options.slsEndpoint] : options.endpoints ?? (options.group && options.port != null ? [{group: options.group, port: options.port, ...(options.source ? {source: options.source} : {})}] : []); - if ((options.standard ?? 'atsc3') === 'atsc3' && endpoints.length === 0 && !options.sltUrl) { + if ((options.standard ?? 'atsc3') === 'atsc3' && endpoints.length === 0 && !options.sltUrl && !options.dsUrl) { throw new RangeError('group and port (or endpoints or sltUrl) are required for ATSC 3'); } if (endpoints.some((endpoint) => typeof endpoint.group !== 'string' || endpoint.group.trim() === '' || @@ -744,7 +801,7 @@ export class RouteMabrSession { if (ingress === 'amt' && ((!options.relay && options.relayPort !== undefined) || (options.relayPort !== undefined && (!Number.isInteger(options.relayPort) || options.relayPort < 1 || options.relayPort > 65535)) || - (!options.relay && !options.source && !options.sltUrl && + (!options.relay && !options.source && !options.sltUrl && !options.dsUrl && !endpoints.some((endpoint) => normalizeRouteSource(endpoint.source))))) { throw new RangeError('AMT ingress requires relay+relayPort, or an SSM source for RFC 8777 DRIAD'); } @@ -763,16 +820,17 @@ export class RouteMabrSession { const ingress = this.#options.ingress ?? 'tuner'; let endpoints = this.#options.slsEndpoint ? [this.#options.slsEndpoint] : this.#options.endpoints ?? (this.#options.group && this.#options.port != null ? [{group: this.#options.group, port: this.#options.port, ...(this.#options.source ? {source: this.#options.source} : {})}] : []); - if (endpoints.length === 0 && this.#options.sltUrl) { + if (endpoints.length === 0 && (this.#options.sltUrl || this.#options.dsUrl)) { endpoints = [await discoverSlsEndpoint(this.#options)]; } if (endpoints.length === 0) throw new RangeError('group and port (or endpoints or sltUrl) are required for ATSC 3'); + for (const endpoint of endpoints) this.#assertManifestScope(endpoint); if (ingress === 'amt' && !this.#options.relay && !endpoints.some((endpoint) => normalizeRouteSource(endpoint.source))) { throw new RangeError('AMT DRIAD requires an SSM source in the discovered SLT or SLS endpoint'); } const exports = this.#options.wasm.exports; - const discoverEndpoints = this.#options.discoverEndpoints ?? Boolean(this.#options.slsEndpoint || this.#options.sltUrl); + const discoverEndpoints = this.#options.discoverEndpoints ?? Boolean(this.#options.slsEndpoint || this.#options.sltUrl || this.#options.dsUrl); const slsGroup = (this.#options.slsEndpoint?.group ?? endpoints[0]!.group).trim(); // Some senders omit sIpAddr on rendition RS entries even though the SLS // leg has an SSM source. AMT DRIAD is source-based, so carry that source @@ -855,6 +913,18 @@ export class RouteMabrSession { } } + #assertManifestScope(endpoint: RouteEndpoint): void { + if (!endpoint.manifestUrl) return; + const baseUrl = this.#options.baseUrl ?? globalThis.location?.href ?? 'http://localhost/'; + const scope = new URL('.', baseUrl).href; + const manifestUrl = new URL(endpoint.manifestUrl, baseUrl).href; + if (!manifestUrl.startsWith(scope)) { + throw new RangeError( + `ROUTE manifest ${manifestUrl} is outside baseUrl scope ${scope}; set baseUrl to the DS manifest directory`, + ); + } + } + async stop(): Promise { this.#active = false; this.#sessionGeneration++;