diff --git a/Development/src/dataProvider.js b/Development/src/dataProvider.js index 9196519a..1d364496 100644 --- a/Development/src/dataProvider.js +++ b/Development/src/dataProvider.js @@ -13,7 +13,6 @@ import { JsonPointer } from 'json-ptr'; import diff from 'deep-diff'; import { makeBearerAuthHeader } from './authProvider'; import { - BRIDGE_API, BRIDGE_AUTO, BRIDGE_FORCED, DNSSD_API, @@ -24,16 +23,11 @@ import { apiUsingRql, apiVersion, bridgeMode, + bridgeUrl, concatUrl, usingAuth, } from './settings'; -// the NMOS Bridge (see ../../nmos-bridge) makes Device Control APIs -// available at a configured base URL for deployments where the browser -// cannot reach the Device directly -const bridgeAddress = (deviceId, api, version) => - concatUrl(apiUrl(BRIDGE_API), `/devices/${deviceId}/${api}/${version}`); - // which access path, direct or bridge, most recently worked for each Device const deviceAccessPaths = new Map(); @@ -914,7 +908,7 @@ const convertHTTPResponseToDataProvider = async ( attempts.push([ 'bridge', [ - bridgeAddress( + bridgeUrl( deviceId, 'connection', connectionVersion @@ -1019,7 +1013,7 @@ const convertHTTPResponseToDataProvider = async ( attempts.push([ 'bridge', [ - bridgeAddress( + bridgeUrl( deviceId, 'channelmapping', channelmappingVersion diff --git a/Development/src/pages/devices/DevicesShow.js b/Development/src/pages/devices/DevicesShow.js index ba987590..93accb61 100644 --- a/Development/src/pages/devices/DevicesShow.js +++ b/Development/src/pages/devices/DevicesShow.js @@ -34,6 +34,9 @@ import UnsortableDatagrid from '../../components/UnsortableDatagrid'; import UrlField from '../../components/URLField'; import labelize from '../../components/labelize'; import { + BRIDGE_FORCED, + bridgeMode, + bridgeUrl, buildIs12BrowserLaunchUrl, is12BrowserUrl, queryVersion, @@ -118,13 +121,33 @@ const DevicesShowView = props => { ); }; -const ControlAddressField = ({ record, source = 'href', deviceLabel }) => { +const ControlAddressField = ({ + record, + source = 'href', + deviceLabel, + deviceId, +}) => { const href = get(record, source); const isDeviceControlProtocol = unversionedParameter(get(record, 'type')) === 'urn:x-nmos:control:ncp'; if (isDeviceControlProtocol) { - const launchUrl = buildIs12BrowserLaunchUrl(href, deviceLabel); + // Forced Bridge: IS-12 launches against the bridge NCP path, not the + // Device control href. Auto / No Bridge keep the advertised href. + let ncpHref = href; + if (bridgeMode() === BRIDGE_FORCED && deviceId) { + const version = (get(record, 'type') || '').split('/').pop(); + if (version) { + try { + const url = new URL(bridgeUrl(deviceId, 'ncp', version)); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + ncpHref = url.toString(); + } catch (e) { + // fall back to the Device href + } + } + } + const launchUrl = buildIs12BrowserLaunchUrl(ncpHref, deviceLabel); const disabled = !is12BrowserUrl() || !launchUrl; if (disabled) { @@ -146,7 +169,7 @@ const ControlAddressField = ({ record, source = 'href', deviceLabel }) => { href="#" variant="body2" style={{ textDecoration: 'underline', cursor: 'pointer' }} - title={`Open IS-12 Browser\n${href}`} + title={`Open IS-12 Browser\n${ncpHref}`} onClick={event => { event.preventDefault(); window.open(launchUrl, '_blank', 'noopener,noreferrer'); @@ -182,6 +205,7 @@ const ShowSummaryTab = ({ record, ...props }) => { { } }; +// the NMOS Bridge makes Device Control APIs available at a configured base +// URL for deployments where the browser cannot reach the Device directly +export const bridgeUrl = (deviceId, api, version) => + concatUrl(apiUrl(BRIDGE_API), `/devices/${deviceId}/${api}/${version}`); + // version, e.g. 'v1.3', is always the last path component export const apiVersion = api => apiUrl(api).match(/([^/]+)\/?$/g)[0]; diff --git a/nmos-bridge/README.md b/nmos-bridge/README.md index d7af5b34..72e5eb63 100644 --- a/nmos-bridge/README.md +++ b/nmos-bridge/README.md @@ -1,6 +1,6 @@ # NMOS Bridge -Provides browser-accessible proxy access to [AMWA IS-05](https://specs.amwa.tv/is-05/) Connection APIs and [AMWA IS-08](https://specs.amwa.tv/is-08/) Channel Mapping APIs exposed by Devices registered in an NMOS Registry, where the browser may not have network access to the Device APIs directly. +Provides browser-accessible proxy access to [AMWA IS-05](https://specs.amwa.tv/is-05/) Connection APIs, [AMWA IS-08](https://specs.amwa.tv/is-08/) Channel Mapping APIs, and [AMWA IS-12](https://specs.amwa.tv/is-12/) / [BCP-008](https://specs.amwa.tv/bcp-008-01/) Device Control Protocol (NCP) WebSockets exposed by Devices registered in an NMOS Registry, where the browser may not have network access to the Device APIs directly. The bridge must not behave as an open proxy. Targets originate exclusively from registered Device `controls` entries; public requests use Device IDs only and arbitrary URLs are forbidden. The Registry remains the source of truth and requires no changes. @@ -22,24 +22,73 @@ where `href` is taken from the Device resource `controls` entry matching the con | --- | --- | --- | | `connection` | `urn:x-nmos:control:sr-ctrl/{version}` | IS-05 Connection | | `channelmapping` | `urn:x-nmos:control:cm-ctrl/{version}` | IS-08 Channel Mapping | +| `ncp` | `urn:x-nmos:control:ncp/{version}` | IS-12 / BCP-008 NCP (WebSocket) | `{api}` is the same path segment as in the advertised `href` (`/x-nmos/{api}/{version}`). The bridge API version (`v1.0`) is independent of the Device API version (`{version}`). -For example: +For Connection (HTTP), the request: ```text PATCH /x-nmos-bridge/v1.0/devices/{device_id}/connection/v1.1/single/receivers/{receiver_id}/staged ``` -is proxied to: +is proxied to `http://device.example.local` as: ```text -PATCH http://device.example.local/x-nmos/connection/v1.1/single/receivers/{receiver_id}/staged +PATCH /x-nmos/connection/v1.1/single/receivers/{receiver_id}/staged +``` + +For Channel Mapping (HTTP), the request: + +```text +POST /x-nmos-bridge/v1.0/devices/{device_id}/channelmapping/v1.0/map/activations/ +``` + +is proxied to `http://device.example.local` as: + +```text +POST /x-nmos/channelmapping/v1.0/map/activations/ ``` Methods are restricted to `GET`, `HEAD`, `POST`, `PATCH`, `DELETE` and `OPTIONS`, the union of the methods the proxied Device APIs use; which methods a given resource actually supports is up to the Device. Query strings, methods and request bodies are preserved. `GET` and `HEAD` requests may be retried; mutating methods are never automatically retried. -`GET /x-nmos-bridge` and `GET /x-nmos-bridge/v1.0` return listings (`["v1.0/"]` and `["devices/"]`). Devices are not listed; the Registry remains the source of truth for which Devices exist. Given a Device ID from the Registry, `GET …/devices/{device_id}` lists the APIs proxied for that Device (e.g. `["channelmapping/","connection/"]`) and `GET …/devices/{device_id}/{api}` lists the versions, so a client can see what became a bridge target without inspecting Envoy configuration. +For NCP (WebSocket), the handshake: + +```text +GET /x-nmos-bridge/v1.0/devices/{device_id}/ncp/{version} +Upgrade: websocket +Connection: Upgrade +``` + +is proxied to `http://device.example.local:7002` as: + +```text +GET /x-nmos/ncp/{version} +Upgrade: websocket +Connection: Upgrade +``` + +(`http://device.example.local:7002` coming from the Device control `href`.) Upstream schemes are `ws` only for now (parallel to HTTP-only Connection and Channel Mapping). Envoy uses TCP health checks for NCP clusters (HTTP probes return `426` Upgrade Required on nmos-cpp's NCP port so a standard HTTP health check doesn't work). + +`GET /x-nmos-bridge` and `GET /x-nmos-bridge/v1.0` return listings (`["v1.0/"]` and `["devices/","query/"]`). Devices are not listed; the Registry remains the source of truth for which Devices exist. Given a Device ID from the Registry, `GET …/devices/{device_id}` lists the APIs proxied for that Device (e.g. `["channelmapping/","connection/"]`) and `GET …/devices/{device_id}/{api}` lists the versions, so a client can see what became a bridge target without inspecting Envoy configuration. + +Query subscription WebSockets use a canonical bridge path (nmos-cpp `ws_href` path shape). The handshake: + +```text +GET /x-nmos-bridge/v1.0/query/{version}/subscriptions/{id} +Upgrade: websocket +Connection: Upgrade +``` + +is proxied to the Registry Query API WebSocket listener as: + +```text +GET /x-nmos/query/{version}/subscriptions/{id} +Upgrade: websocket +Connection: Upgrade +``` + +Bridge-aware clients build that URL from the Bridge API origin, Query version, and subscription `id`; they do not open the absolute `ws_href` from the subscription resource when using the bridge as the browser-facing proxy. Query **HTTP** remains on `/x-nmos/query/...` (optional convenience). Every other path under `/x-nmos-bridge`, including other bridge API versions and a version or API that is not a target for that Device, returns `404` with an NMOS error body, so nothing in the bridge namespace falls through to the optional app route on `/`. @@ -48,15 +97,20 @@ Every other path under `/x-nmos-bridge`, including other bridge API versions and ```text Browser | - +--(HTTP / WebSocket)------> Registry Query API + +--(HTTP / WebSocket)------> Registry Query API (when reachable directly) | - +--(HTTP)------------------> Envoy + +--(HTTP / WebSocket)------> Envoy | - +--> /x-nmos-bridge/... --> Device Control APIs + +--> /x-nmos-bridge/devices/... --> Device Control APIs + | (HTTP Connection / Channel Mapping; + | WebSocket NCP) + | + +--> /x-nmos-bridge/query/.../subscriptions/{id} + | --(WebSocket)--> Registry Query API | +--> /x-nmos -> ["query/"] (fixed listing) | - +--> /x-nmos/query/... (convenience) + +--> /x-nmos/query/... (HTTP convenience) | --> Registry Query API | +--> /x-dns-sd/... (convenience) @@ -74,8 +128,8 @@ Adapter (server-side; not on the browser path) The NMOS Bridge consists of Envoy and the adapter service: -- **Envoy** proxies browser HTTP to Device Control APIs on `/x-nmos-bridge/...` (required for the bridge). It may also proxy the Query API on `/x-nmos/query/...`, DNS-SD on `/x-dns-sd/...`, and the nmos-js app on `/` as optional convenience. `GET /x-nmos/` returns a fixed listing of `["query/"]` so discovery matches what is actually proxied. Other `/x-nmos/` APIs (Registration, Node, …) are not proxied — they may use different ports. It applies routing, request size limits, timeouts, retry policy, health checking and failover, and access logging of mutating requests. It does not proxy Query API WebSocket subscriptions. -- **The adapter** (`adapter/`) converts Registry state into Envoy configuration. It tracks Devices through a [Query API WebSocket subscription](https://specs.amwa.tv/is-04/branches/v1.3.x/docs/4.2._Behaviour_-_Querying.html) (non-persistent, `resource_path` `/devices`), extracts Device Control API controls, and generates Envoy routes and clusters, atomically replacing the dynamic configuration files (`rds.json`, `cds.json`) which Envoy reloads via filesystem watch. The adapter does not proxy traffic and does not determine runtime health. +- **Envoy** proxies browser HTTP to Device Connection and Channel Mapping APIs on `/x-nmos-bridge/...` (required for the bridge), Device NCP WebSockets on `/x-nmos-bridge/.../ncp/...`, and Query subscription WebSockets on `/x-nmos-bridge/v1.0/query/...` (rewritten to the Registry Query API WebSocket path). It may also proxy Query **HTTP** on `/x-nmos/query/...`, DNS-SD on `/x-dns-sd/...`, and the nmos-js app on `/` as optional convenience. `GET /x-nmos/` returns a fixed listing of `["query/"]` so discovery matches what is actually proxied. Other `/x-nmos/` APIs (Registration, Node, …) are not proxied — they may use different ports. It applies routing, request size limits, timeouts, retry policy, health checking and failover, and access logging of mutating requests. +- **The adapter** (`adapter/`) converts Registry state into Envoy configuration. It tracks Devices through a [Query API WebSocket subscription](https://specs.amwa.tv/is-04/branches/v1.3.x/docs/4.2._Behaviour_-_Querying.html) (non-persistent, `resource_path` `/devices`), extracts Device controls, and generates Envoy routes and clusters, atomically replacing the dynamic configuration files (`rds.json`, `cds.json`) which Envoy reloads via filesystem watch. The adapter does not proxy traffic and does not determine runtime health. On connecting, the Registry sends a sync of all current Devices, then pushes added, modified and removed events; the adapter rebuilds configuration on each change. If the connection is interrupted, the adapter resubscribes with exponential backoff and the fresh sync re-establishes all mappings, including Devices that were removed while disconnected. The last good configuration keeps being served until the new sync arrives. @@ -174,16 +228,9 @@ Logging API: http://controller.example.com:8080/log/v1.0 NMOS Bridge API: http://controller.example.com:8080/x-nmos-bridge/v1.0 ``` -Query API WebSocket subscriptions are not proxied by Envoy. Subscription -`ws_href` values are absolute URIs (`format: uri`) advertised by the -Registry and often use a different port than Query HTTP (for example -nmos-cpp-registry's `query_ws_port`). Browser clients that open those -sockets connect to the Registry (or whatever `ws_href` names), not through -Envoy. The adapter's server-side subscription is separate: it must reach -the Query API and the WebSocket URL from the subscription response. Set -`REGISTRY_QUERY_WS_URL` when the advertised `ws_href` uses a scheme, host -name or port which is not reachable from the adapter, for example -`ws://192.168.6.101:81`. +Query API WebSocket subscriptions for **bridge-aware** clients use `/x-nmos-bridge/v1.0/query/{version}/subscriptions/{id}` through Envoy (static path rewrite to the Registry Query API WebSocket listener). The subscription resource's absolute `ws_href` is unchanged and still names the Registry; clients that only follow `ws_href` need to reach that listener. The adapter's server-side subscription is separate: it must reach the Query API and the WebSocket URL from the subscription response. Set `REGISTRY_QUERY_WS_URL` when the advertised `ws_href` uses a scheme, host name or port which is not reachable from the adapter (and from Envoy), for example `ws://192.168.6.101:81`. That override is also the upstream for the browser-facing Query subscription WebSocket route. + +WebSocket routes use `timeout: 0s` and `WS_IDLE_TIMEOUT_SECONDS` (default `3600`) so long-lived grains are not cut by `ROUTE_TIMEOUT_SECONDS`. Envoy must be able to reach every Device Control API `href` which is to be used through the bridge. This is independent of browser reachability: the @@ -198,8 +245,8 @@ file-based arrangement. A deployment with independently scaled Envoy instances would require an xDS control plane, which is not currently implemented. -If nmos-js is served separately, set Query API, Logging API and Connection -Bridge API as needed (Registry and/or Envoy). Alternatively, set `APP_URL` +If nmos-js is served separately, set Query API, Logging API and NMOS Bridge +API as needed (Registry and/or Envoy). Alternatively, set `APP_URL` and use Envoy as a single origin for nmos-js, Query/DNS-SD/Logging APIs, and the bridge. @@ -224,6 +271,8 @@ The nmos-js client offers a **NMOS Bridge Mode** and a separate `POST`, `PATCH` and `DELETE` requests are not automatically retried via alternate paths; they follow whichever path was resolved for the Device (`$connectionAPI` / `$channelmappingAPI`). Bridge requests use the configured NMOS Bridge API (default: SPA origin + `/x-nmos-bridge/v1.0`). +IS-12 Browser launch (`?uri=`) uses the Device NCP `href` under No Bridge and Auto Bridge. Under **Forced Bridge**, the launch `uri` is the bridge NCP WebSocket URL (`ws`/`wss` on the Bridge API origin, path `…/devices/{id}/ncp/{version}`). + ## Status Phase 1 is implemented, plus health checking and multi-endpoint failover from Phase 2: @@ -231,6 +280,8 @@ Phase 1 is implemented, plus health checking and multi-endpoint failover from Ph - HTTP browser and upstream access, file-based dynamic configuration - `GET`/`HEAD`/`POST`/`PATCH`/`DELETE` - Upstream 3xx `Location` handling (see below) +- Query subscription WebSockets on `/x-nmos-bridge/v1.0/query/...` (static rewrite to the Registry Query API WebSocket listener) +- Device NCP WebSockets on `/x-nmos-bridge/.../ncp/...` (Forced Bridge remaps IS-12 Browser launch) Not yet implemented: response size limits, HTTPS upstreams, authentication translation, mTLS, and an xDS control plane. diff --git a/nmos-bridge/adapter/index.js b/nmos-bridge/adapter/index.js index 56d7835f..a2efb9a4 100644 --- a/nmos-bridge/adapter/index.js +++ b/nmos-bridge/adapter/index.js @@ -3,10 +3,10 @@ // NMOS Bridge - Envoy Adapter // // Converts Registry state into Envoy configuration. Tracks Devices through a -// Query API WebSocket subscription, extracts their Connection and Channel -// Mapping API controls, and generates Envoy route and cluster configuration -// files which Envoy reloads via filesystem watch. The adapter does not proxy -// any traffic itself and does not determine runtime health - Envoy does both. +// Query API WebSocket subscription, extracts their Device controls, and +// generates Envoy route and cluster configuration files which Envoy reloads +// via filesystem watch. The adapter does not proxy any traffic itself and +// does not determine runtime health - Envoy does both. const crypto = require('crypto'); const fs = require('fs'); @@ -35,28 +35,43 @@ const REGISTRY_LOGGING_URL = (process.env.REGISTRY_LOGGING_URL || '').replace( ); const OUTPUT_DIR = process.env.OUTPUT_DIR || '/etc/envoy/dynamic'; const ROUTE_TIMEOUT_SECONDS = Number(process.env.ROUTE_TIMEOUT_SECONDS) || 15; +// long-lived WebSocket routes; must not use the HTTP +// ROUTE_TIMEOUT_SECONDS or upgraded connections are cut after 15s +const WS_IDLE_TIMEOUT_SECONDS = + Number(process.env.WS_IDLE_TIMEOUT_SECONDS) || 3600; // subscription update coalescing and WebSocket reconnect backoff const MAX_UPDATE_RATE_MS = Number(process.env.MAX_UPDATE_RATE_MS) || 100; const RECONNECT_MIN_MS = Number(process.env.RECONNECT_MIN_MS) || 1000; const RECONNECT_MAX_MS = Number(process.env.RECONNECT_MAX_MS) || 30000; // some Registries advertise a ws_href on a host the adapter cannot reach; -// when set, use this scheme and authority while preserving the subscription path +// when set, use this scheme and authority while preserving the subscription +// path; the same origin is the Envoy upstream for browser Query subscription +// WebSockets under /x-nmos-bridge/v1.0/query/... const REGISTRY_QUERY_WS_URL = process.env.REGISTRY_QUERY_WS_URL || ''; const BRIDGE_ROOT = '/x-nmos-bridge'; const BRIDGE_VERSION = 'v1.0'; const BRIDGE_PREFIX = `${BRIDGE_ROOT}/${BRIDGE_VERSION}`; -// Phase 1 supports HTTP upstreams only -const ALLOWED_PROTOCOLS = ['http:']; - // the proxied Device APIs; each api is the path segment both in the advertised -// href and in the bridge path +// href and in the bridge path. protocols lists the allowed href schemes for +// that control type (Connection and Channel Mapping are HTTP; NCP is +// WebSocket). const CONTROL_TYPES = [ - { pattern: /^urn:x-nmos:control:sr-ctrl\/(v\d+\.\d+)$/, api: 'connection' }, + { + pattern: /^urn:x-nmos:control:sr-ctrl\/(v\d+\.\d+)$/, + api: 'connection', + protocols: ['http:'], + }, { pattern: /^urn:x-nmos:control:cm-ctrl\/(v\d+\.\d+)$/, api: 'channelmapping', + protocols: ['http:'], + }, + { + pattern: /^urn:x-nmos:control:ncp\/(v\d+\.\d+)$/, + api: 'ncp', + protocols: ['ws:'], }, ]; @@ -86,6 +101,13 @@ let devices = new Map(); // per-output-file content hashes, so Envoy is only reconfigured on real change const state = {}; +// Envoy's shared Query WebSocket upstream origin (ws://host:port). The adapter +// uses the full resolved ws_href separately. This origin is fixed by +// REGISTRY_QUERY_WS_URL or the first subscription; empty until then, so the +// baseline cluster discovery service (cds.json) and route discovery service +// (rds.json) omit the browser-facing WebSocket route. +let envoyQueryWsOrigin = ''; + // --- mapping --- const safeName = name => name.replace(/[^A-Za-z0-9_]/g, '_'); @@ -119,11 +141,13 @@ const collectTargets = devices => { for (const control of device.controls || []) { let api; let version; + let protocols; for (const controlType of CONTROL_TYPES) { const match = controlType.pattern.exec(control.type || ''); if (!match) continue; api = controlType.api; version = match[1]; + protocols = controlType.protocols; break; } if (!version) continue; @@ -136,7 +160,7 @@ const collectTargets = devices => { ); continue; } - if (!ALLOWED_PROTOCOLS.includes(href.protocol)) { + if (!protocols.includes(href.protocol)) { logOnce( `skipping unsupported scheme for Device ${device.id}: ${control.href}` ); @@ -163,7 +187,9 @@ const collectTargets = devices => { const host = href.hostname; // URL.port is empty when the href omits an explicit port const scheme = href.protocol.replace(/:$/, ''); - const port = Number(href.port) || (scheme === 'https' ? 443 : 80); + const port = + Number(href.port) || + (scheme === 'https' || scheme === 'wss' ? 443 : 80); // de-duplicate normalized hrefs if ( candidates.some( @@ -218,6 +244,11 @@ const clusterName = target => target.version )}`; +const defaultPortFor = protocol => { + if (protocol === 'https:' || protocol === 'wss:') return 443; + return 80; +}; + const staticClusterFromUrl = (name, urlString) => { // Only scheme defaults, hostname, and port are used; any path in // urlString is ignored (Envoy forwards the client request path). @@ -239,9 +270,7 @@ const staticClusterFromUrl = (name, urlString) => { address: url.hostname, port_value: Number(url.port) || - (url.protocol === 'https:' - ? 443 - : 80), + defaultPortFor(url.protocol), }, }, }, @@ -283,15 +312,24 @@ const bridgeCluster = target => { })), }, // Envoy, not the adapter, determines candidate health, so that - // failover between priority levels happens at runtime + // failover between priority levels happens at runtime. NCP listeners + // reject HTTP probes (426); use TCP for ws/wss targets. health_checks: [ - { - timeout: '2s', - interval: '10s', - unhealthy_threshold: 2, - healthy_threshold: 2, - http_health_check: { path: `${target.basePath}/` }, - }, + target.scheme === 'ws' || target.scheme === 'wss' + ? { + timeout: '2s', + interval: '10s', + unhealthy_threshold: 2, + healthy_threshold: 2, + tcp_health_check: {}, + } + : { + timeout: '2s', + interval: '10s', + unhealthy_threshold: 2, + healthy_threshold: 2, + http_health_check: { path: `${target.basePath}/` }, + }, ], }; }; @@ -309,6 +347,18 @@ const directResponse = (status, jsonBody) => ({ const directErrorResponse = (status, error) => directResponse(status, { code: status, error, debug: null }); +// The HTTP buffer filter waits for a full request body; WebSocket upgrades +// never finish that way, so disable it on upgrade routes. +const wsBufferDisabled = { + typed_per_filter_config: { + 'envoy.filters.http.buffer': { + '@type': + 'type.googleapis.com/envoy.extensions.filters.http.buffer.v3.BufferPerRoute', + disabled: true, + }, + }, +}; + const bridgeRoutes = target => { // path_separated_prefix matches the version path exactly or with a // following '/...' (Envoy 1.22+; compose pins v1.31). That preserves @@ -316,6 +366,22 @@ const bridgeRoutes = target => { // sub-path) when rewriting onto the Device API basePath, so // trailing-slash handling stays with the upstream per that API. const pathPrefix = `${BRIDGE_PREFIX}/devices/${target.deviceId}/${target.api}/${target.version}`; + if (target.scheme === 'ws' || target.scheme === 'wss') { + // NCP (and similar): upgrade only; no Location rewrite + return [ + { + match: { path_separated_prefix: pathPrefix }, + route: { + cluster: clusterName(target), + prefix_rewrite: target.basePath, + timeout: '0s', + idle_timeout: `${WS_IDLE_TIMEOUT_SECONDS}s`, + upgrade_configs: [{ upgrade_type: 'websocket' }], + }, + ...wsBufferDisabled, + }, + ]; + } // Envoy 1.31 set_metadata has no per-route config; LuaPerRoute on a // dedicated filter writes Location-rewrite context into dynamic metadata // for location_rewrite.lua. Values are NMOS paths / host:port lists. @@ -435,6 +501,26 @@ const deviceListingRoutes = targets => { const DEVICES_NOT_LISTED = `Devices are not listed; request a specific device at ${BRIDGE_PREFIX}/devices/{deviceId}`; +// Query subscription WebSockets on the bridge path (nmos-cpp path template). +// Separate from /x-nmos/query HTTP so upgrade and long idle timeouts do not +// affect the convenience HTTP routes. +const queryWsRoutes = () => { + if (!envoyQueryWsOrigin) return []; + return [ + { + match: { prefix: `${BRIDGE_PREFIX}/query/` }, + route: { + cluster: 'registry_query_ws', + timeout: '0s', + idle_timeout: `${WS_IDLE_TIMEOUT_SECONDS}s`, + prefix_rewrite: '/x-nmos/query/', + upgrade_configs: [{ upgrade_type: 'websocket' }], + }, + ...wsBufferDisabled, + }, + ]; +}; + const routeConfiguration = targets => ({ '@type': 'type.googleapis.com/envoy.config.route.v3.RouteConfiguration', name: 'nmos_bridge_routes', @@ -480,12 +566,15 @@ const routeConfiguration = targets => ({ }, { match: { path: BRIDGE_PREFIX }, - ...directResponse(200, ['devices/']), + ...directResponse(200, ['devices/', 'query/']), }, { match: { path: `${BRIDGE_PREFIX}/` }, - ...directResponse(200, ['devices/']), + ...directResponse(200, ['devices/', 'query/']), }, + // Query subscription WebSocket before the bridge namespace + // catch-all + ...queryWsRoutes(), // arbitrary URLs are forbidden; only registered Device // controls produce routes. The whole bridge namespace stops // here, including other bridge API versions, so no request @@ -573,6 +662,9 @@ const writeResource = (filename, resources, state) => { const apply = (targets, state) => { const clusters = [ staticClusterFromUrl('registry_query', REGISTRY_QUERY_URL), + ...(envoyQueryWsOrigin + ? [staticClusterFromUrl('registry_query_ws', envoyQueryWsOrigin)] + : []), ...(REGISTRY_DNS_SD_URL ? [staticClusterFromUrl('registry_dns_sd', REGISTRY_DNS_SD_URL)] : []), @@ -631,15 +723,30 @@ const createSubscription = async () => { if (!subscription.ws_href) { throw new Error('subscription response did not include ws_href'); } - if (!REGISTRY_QUERY_WS_URL) return subscription.ws_href; - const wsHref = new URL(subscription.ws_href); - const registryQueryWsUrl = new URL(REGISTRY_QUERY_WS_URL); - wsHref.protocol = registryQueryWsUrl.protocol; - wsHref.host = registryQueryWsUrl.host; - // setting host does not clear the port, so an omitted port would otherwise - // leave the advertised port in place rather than the scheme default - wsHref.port = registryQueryWsUrl.port; - return wsHref.toString(); + let wsHref = subscription.ws_href; + if (REGISTRY_QUERY_WS_URL) { + const advertised = new URL(subscription.ws_href); + const override = new URL(REGISTRY_QUERY_WS_URL); + advertised.protocol = override.protocol; + advertised.host = override.host; + // setting host does not clear the port, so an omitted port would + // otherwise leave the advertised port in place rather than the + // scheme default + advertised.port = override.port; + wsHref = advertised.toString(); + } + // Envoy uses the same origin the adapter uses for its own subscription. + // Static routing assumes all Query subscriptions share one listener; + // reject a later conflicting origin rather than silently changing Envoy. + const resolved = new URL(wsHref); + const origin = `${resolved.protocol}//${resolved.host}`; + if (envoyQueryWsOrigin && envoyQueryWsOrigin !== origin) { + throw new Error( + `subscription ws_href origin changed from ${envoyQueryWsOrigin} to ${origin}; configure REGISTRY_QUERY_WS_URL` + ); + } + envoyQueryWsOrigin = origin; + return wsHref; }; // apply one message's data items to the device set. The first message after a @@ -698,6 +805,9 @@ const run = async () => { for (;;) { try { const wsHref = await createSubscription(); + // publish the browser-facing Query WebSocket route once its + // upstream is known + rebuild(); await runConnection(wsHref); } catch (e) { log(`subscription failed: ${e.message}`); diff --git a/nmos-bridge/docker-compose.yml b/nmos-bridge/docker-compose.yml index 5d41de47..6b04a4b3 100644 --- a/nmos-bridge/docker-compose.yml +++ b/nmos-bridge/docker-compose.yml @@ -12,6 +12,10 @@ services: # Query API used to discover Devices; also upstream for /x-nmos/query/ # (and /x-dns-sd/, /log/ unless overridden below) REGISTRY_QUERY_URL: http://registry:8870/x-nmos/query/v1.3 + # optional: Query WebSocket scheme/authority when ws_href is not reachable + # from the adapter or Envoy (also upstream for /x-nmos-bridge/.../query/ + # subscription WebSockets). Example when query_ws_port is HTTP+1: + # REGISTRY_QUERY_WS_URL: ws://registry:8871 # optional: different host/port for /x-dns-sd/ (default: same as Query) # REGISTRY_DNS_SD_URL: http://registry:3208 # optional: different host/port for /log/ (default: same as Query) diff --git a/nmos-bridge/docs/websocket-proxy-plan.md b/nmos-bridge/docs/websocket-proxy-plan.md new file mode 100644 index 00000000..ca8bf796 --- /dev/null +++ b/nmos-bridge/docs/websocket-proxy-plan.md @@ -0,0 +1,365 @@ +# Design plan: Envoy WebSocket proxying (Query API and Device NCP) + +Status: Query subscription WebSocket proxying implemented; Device NCP WebSocket +proxying implemented (adapter + Forced Bridge IS-12 launch). Envoy WS spike +validated 2026-08-18 (see Investigation notes). Complements the NMOS Bridge in +`nmos-bridge/README.md`. + +## Motivation + +When the browser cannot reach the Registry or Devices directly, Envoy is the +single browser-facing proxy for discovery HTTP and IS-05. Two WebSocket paths +still bypass Envoy: + +| Path | Who opens it | Advertised / used URL | +| --- | --- | --- | +| Registry Query subscription | Browser clients that use grains; adapter (server-side, already OK) | Absolute `ws_href` on the subscription resource | +| Device NCP (IS-12 / BCP-008) | IS-12 browser (`?uri=…`) | Device `controls` entry `urn:x-nmos:control:ncp/{version}` | + +Without proxying those sockets, "Forced Bridge" / single-origin deployments still +require browser reachability to Registry `query_ws_port` and to each Device NCP +`href`. That breaks the same network story the NMOS Bridge solves for +IS-05. + +## Design principles (agreed) + +1. **Bridge-aware clients remap; do not rewrite Registry JSON** — prefer + bridge-style canonical public URLs over Lua/`ws_href` response rewriting. +2. **Downstream path identifies the resource; Envoy proxies to the real + socket** — semantically the upstream is that resource's `ws_href` (Query + subscription) or Device control `href` (NCP). +3. **nmos-cpp Query WS uses static rewrite** — one Registry WS cluster + path + template. Avoid per-subscription Envoy routes (see Case A). +4. **NCP uses per-Device routing like Connection** — many Devices, many + `href`s, driven by the existing Device Query subscription. + +## Non-goals (this plan) + +- Open proxying of arbitrary WebSocket URLs (same rule as Connection: targets + only from Registry-advertised resources / bridge identifiers). +- Changing IS-04 / IS-12 formats or requiring Registry/Node changes. +- HTTPS / WSS upstreams, auth translation, mTLS (later, with Connection Phase + follow-ons). +- Replacing the adapter's own Query subscription (it keeps talking to the + Registry directly, including `REGISTRY_QUERY_WS_URL`). +- Transparent IS-04 clients that only open advertised `ws_href` with no bridge + awareness (would need response rewrite; deferred). + +## Shared Envoy requirements + +Both cases need: + +1. **Upgrade** — `upgrade_configs: [{ upgrade_type: "websocket" }]` on WS + routes. Keep Query **HTTP** on `/x-nmos/query/...` and Query **WS** on the + bridge path (Case A) so HTTP and WS need not share one route match. +2. **Timeouts** — current bridge routes use `ROUTE_TIMEOUT_SECONDS` (default + 15s). That must not apply to upgraded connections. Prefer route + `timeout: 0s` (or equivalent) plus an `idle_timeout` suitable for long-lived + grains / NCP sessions. +3. **Schemes** — Phase 1: `ws://` upstreams only (parallel to Connection's + `http` only). Browser-facing Envoy may be `ws://` or `wss://` depending on + its TLS configuration. +4. **No CORS for WS** — browsers do not CORS-preflight WebSockets; `Origin` is + advisory to the upstream. Unifying under Envoy helps reachability and TLS, + not CORS. + +--- + +## Case A — Registry Query API WebSocket + +### Problem detail + +nmos-cpp-registry builds subscription `ws_href` as: + +- scheme from subscription `secure` / client TLS settings +- **host** from the HTTP request Host used to create the subscription +- **port** from settings `query_ws_port` (often `http_port + 1`), not from the + request +- path `/x-nmos/query/{version}/subscriptions/{id}` + +So even when the browser creates the subscription through Envoy (`Host` = +Envoy), `ws_href` typically points at Envoy's hostname on the **Registry WS +port**, which is not Envoy `:8080` and is often unreachable from the browser. +Absolute `ws_href` (`format: uri` in IS-04) means clients do not derive the +socket from the Query HTTP origin. + +The adapter already works around unreachable advertised hosts with +`REGISTRY_QUERY_WS_URL` (scheme/authority override, path preserved). Browsers +have no equivalent today. + +### Decision: canonical bridge path + client remap (not response rewrite) + +Do **not** rewrite `ws_href` in HTTP responses. Do **not** take the public path +from the advertised `ws_href`. Bridge-aware clients build: + +```text +WS /x-nmos-bridge/v1.0/query/{version}/subscriptions/{id} +``` + +from: + +- configured NMOS Bridge API (or SPA) origin, as `ws` / `wss` +- Query API **version** from the configured Query URL (same version used for + `POST .../subscriptions`) +- subscription **`id`** from the subscription JSON + +Ignore advertised `ws_href` host, port, and path when opening the socket in +bridge mode. Treat `ws_href` as direct Registry access only (No Bridge / +non-bridge-aware clients). + +This matches Connection: identifiers → canonical bridge URL, not "fix an +absolute URI." + +### Upstream: proxy to the subscription's `ws_href` (static for nmos-cpp) + +Semantically Envoy connects to that subscription's `ws_href`. For nmos-cpp every +`ws_href` shares one Query WS listener and a fixed path template, so +**implement with static rewrite**, not per-subscription clusters: + +| Piece | Source | +| --- | --- | +| Upstream authority | `REGISTRY_QUERY_WS_URL` (required when WS port ≠ Query HTTP; already used by the adapter) | +| Upstream path | `/x-nmos/query/{version}/subscriptions/{id}` (nmos-cpp `ws_href` path shape) | + +```text +Browser + | POST /x-nmos/query/v1.3/subscriptions --> registry_query (HTTP) + | <- 200 { id, ws_href: "ws://registry:81/..." } (unchanged) + | WS /x-nmos-bridge/v1.0/query/v1.3/subscriptions/{id} + | --> registry_query_ws + rewrite to + | /x-nmos/query/v1.3/subscriptions/{id} +``` + +- Cluster `registry_query` — unchanged (HTTP convenience). +- Cluster `registry_query_ws` — from `REGISTRY_QUERY_WS_URL`. +- One upgrade route (or prefix rule) under + `/x-nmos-bridge/v1.0/query/` with idle-friendly timeouts and path rewrite. + +**Why not per-subscription routing?** That would need a live `id → ws_href` +map. In nmos-cpp, `subscription` and `grain` are **not** queryable resource +types for grains (`is_queryable_resource`), so clients cannot WebSocket- +subscribe to `/subscriptions`. Tracking would mean **HTTP polling** of +`GET .../subscriptions` to drive CDS/RDS — a poor fit for create/delete churn +and the file-watch adapter. Static rewrite avoids that entirely for nmos-cpp. + +Dynamic per-subscription clusters remain a speculative escape hatch only if +another Registry breaks the shared-listener / path-template assumption. + +### Client impact + +- Bridge-aware clients: after create/GET subscription, open WS at the + canonical bridge URL using `id` + Query version; do not open `ws_href`. +- nmos-js currently manages Query subscriptions over HTTP but does not consume + their WebSocket grains, so no SPA change is required for Case A. +- Subscription UI may still **display** Registry `ws_href` (direct); optional + later: also show the bridge URL when bridge mode is on. +- Adapter keeps using `REGISTRY_QUERY_WS_URL` for its own socket; no need to + subscribe via Envoy. +- Naive clients that only follow `ws_href` still need Registry WS reachability + unless response rewrite is added later (non-goal for now). + +### Risks and open questions + +- **Other Registries** whose `ws_href` path is not + `/x-nmos/query/{ver}/subscriptions/{id}` — static rewrite would be wrong; + document nmos-cpp as the supported shape; escape hatch above. +- **Secure subscriptions** / WSS upstream — defer with WSS. +- Docs should state Query HTTP on `/x-nmos/query` remains optional convenience; + Query WS uses the browser-facing bridge path. + +### Acceptance (Case A) + +- Browser creates a subscription via Envoy Query HTTP (or Registry HTTP); opens + grain WS only via + `/x-nmos-bridge/v1.0/query/{ver}/subscriptions/{id}`; works with Registry + `query_ws_port` blocked from the browser. +- Returned `ws_href` may still name the Registry; bridge-aware client ignores + it for the socket. +- Adapter discovery still works with `REGISTRY_QUERY_WS_URL`. +- Query HTTP convenience routes unchanged. + +--- + +## Case B — Device NCP WebSocket + +### Problem detail + +Device `controls` include `type: urn:x-nmos:control:ncp/{version}` with an +`href` that is a WebSocket URL (e.g. `ws://device:7002/x-nmos/ncp/v1.0` on +nmos-cpp). The IS-12 client connects with `new WebSocket(href)` (launch URL +`?uri=`). The NMOS Bridge only maps `urn:x-nmos:control:sr-ctrl/{version}` +to HTTP under `/x-nmos-bridge/v1.0/devices/{id}/connection/{version}/…`. + +Serving `/admin/is12-client` through Envoy does not help: the socket still goes +to the Device. + +### Decision: same pattern as Connection (per-Device `href`) + +NCP is **not** like Query WS static rewrite. Many Devices advertise many NCP +`href`s; the adapter already receives Device grains. Extend bridge-style +targets: + +**Public API:** + +```text +WS /x-nmos-bridge/v1.0/devices/{device_id}/ncp/{version} +``` + +proxies to that Device's NCP control `href` (host/port/`basePath` from the +advertised URL). Use `path_separated_prefix` + `prefix_rewrite` to `basePath` +(typically `/x-nmos/ncp/{version}`), same as Connection. + +Cluster naming, e.g.: + +```text +nmos_bridge_device_{safe_device_id}_ncp_{safe_version} +``` + +Do not merge Connection and NCP clusters. Candidate priorities: same +private-IP / private-DNS / other ordering as Connection. One Bridge API +setting covers `…/connection/…` and `…/ncp/…`. + +### Adapter changes + +- Parse `urn:x-nmos:control:ncp/(v\d+\.\d+)`. +- Allow `ws:` upstreams (today Connection allows `http:` only); port defaults + when omitted must follow `ws` / `wss`, not assume HTTP. +- Require `basePath` consistent with `/x-nmos/ncp/{version}` (parallel to + Connection's path check). +- Emit upgrade routes, rewrite to `basePath`, long-lived timeouts. +- **Health checks:** Connection's `http_health_check` on `basePath/` does not + fit a WS-only NCP port (`control_protocol_ws_port` is separate on nmos-cpp). + Initial options: TCP health checks, or no active check. Prefer TCP or none; + do not invent an HTTP probe on the NCP listener. +- No `Location` rewrite (not REST). +- Ignore non-`ws` until WSS is in scope (log like unsupported Connection + schemes). + +### Client changes (nmos-js / IS-12 launch) + +- **Forced** Bridge: build launch `uri` from the bridge NCP URL on the + configured Bridge API origin. +- **Auto:** possible via short connect timeout then bridge (IS-12 client + already ~3s); UX-sensitive — Forced is the clear story for locked-down + networks. +- **No Bridge:** Device `href` unchanged. + +Example Forced launch: + +```text +ws://controller.example.com:8080/x-nmos-bridge/v1.0/devices/{id}/ncp/v1.0 +``` + +### Gotchas (vs Connection HTTP) + +| Topic | Note | +| --- | --- | +| Scheme | `ws` / `wss`, not `http` / `https` | +| Ports | Envoy must reach Device **NCP** ports, not only Connection HTTP | +| Health | TCP or none; not HTTP on `basePath/` | +| Timeouts | Long-lived upgrade; not 15s route timeout | +| Auth | `authorization` on controls stays out of scope (same as Connection Phase 1) | +| Sub-path | Usually API root only; still bound rewrite to `basePath` (no open proxy) | + +### Risks and open questions + +- Validate IS-12 through the proxy (idle, message size, ping/pong) with real + nodes and the in-tree IS-12 client. +- Multiple NCP versions on one Device — separate targets (as Connection). +- Events WS (IS-07) is a similar shape; out of scope, but keep the adapter + control-type-generic enough not to hard-code Connection-only forever. +- Product naming — resolved as **NMOS Bridge** / `nmos-bridge/` (rename + branch); no further rename required for NCP. + +### Acceptance (Case B) + +- With Device networks blocked from the browser, Forced Bridge IS-12 launch + through Envoy completes a basic NCP session against a node only Envoy can + reach. +- Unknown device id → no upgrade / 404 on the bridge path. +- Arbitrary WS URLs still impossible. +- Connection HTTP bridge behaviour unchanged. + +--- + +## Contrast: Query WS vs NCP + +| | Query subscription WS | Device NCP WS | +| --- | --- | --- | +| Resource source | Client-created subscription `id` | Device `controls` via Query grains | +| Public URL | `/x-nmos-bridge/v1.0/query/{ver}/subscriptions/{id}` | `/x-nmos-bridge/v1.0/devices/{id}/ncp/{ver}` | +| Envoy upstream | Static Registry WS cluster + path template (`ws_href` shape) | Per-Device cluster from control `href` | +| Why static vs dynamic | Cannot grain-subscribe to `/subscriptions` (nmos-cpp); polling is bad | Devices already streamed to the adapter | +| Client remap | `id` + Query version; ignore `ws_href` | Device id + NCP version; ignore raw `href` when Forced | + +--- + +## Suggested sequencing + +| Step | Work | Depends on | +| --- | --- | --- | +| 1 | Spike: Envoy WS upgrade + idle timeouts (manual cluster to nmos-cpp Query WS) | — | +| 2 | Case A: `registry_query_ws`, bridge route + static path rewrite, nmos-js remap helper, README | Step 1 | +| 3 | Spike: Envoy WS to a Device NCP `href` with path rewrite | Step 1 | +| 4 | Case B: adapter NCP targets + nmos-js Forced (then optional Auto) launch URI | Step 3 | +| 5 | Easy-NMOS / compose docs: browser need not reach `query_ws_port` or Device NCP ports | 2, 4 | + +Case A and Case B are independently shippable after the shared Envoy spike. + +## References + +- `nmos-bridge/README.md` — current HTTP bridge and "Query WS not proxied" +- IS-04 Query subscriptions / `ws_href`; nmos-cpp `query_ws_port` and + non-queryable `subscription` / `grain` types +- IS-12 / `urn:x-nmos:control:ncp` +- Discussion: client remap vs response rewrite; id-based bridge path; static + Query WS rewrite vs per-subscription routing; NCP as Connection-with-`ws` + +## Investigation notes (2026-08-18) + +Spike against local nmos-cpp registry (`query_ws_port` 3213) and virtnode +(`control_protocol_ws_port` 11002 when `http_port` is 11000), Envoy 1.31, +`timeout: 0s` + `idle_timeout: 3600s` + `upgrade_configs: [{ upgrade_type: websocket }]`. + +### Shared Envoy spike (Steps 1 and 3) — resolved workable + +| Check | Result | +| --- | --- | +| Query WS upgrade via Envoy | Works. Sync grain received (same length as direct). | +| Path rewrite Case A | `prefix` `/x-nmos-bridge/v1.0/query/` → `prefix_rewrite: /x-nmos/query/` produces `/x-nmos/query/{ver}/subscriptions/{id}` and matches nmos-cpp `ws_href` path. | +| NCP WS upgrade + `path_separated_prefix` + rewrite to `/x-nmos/ncp/v1.0` | Works. Socket opens; stays up without unsolicited messages (client-driven protocol). | +| NCP request/response through proxy | Works. Identical IS-12 `Command` / `CommandResponse` body vs direct (exercised GetMemberDescriptors; both returned the same 417 missing `recurse` — proves relay, not auth/path mangling). | +| HTTP health on NCP `basePath/` | Returns **426**; confirms plan: do not use `http_health_check` on NCP clusters. Prefer TCP check or none. | +| Default 15s route timeout | Must not apply; spike used `timeout: 0s`. | + +### Case A open questions + +| Item | Status | +| --- | --- | +| Other Registries with non-nmos-cpp `ws_href` paths | **Still open / document-only.** Fixture and nmos-cpp confirm `/x-nmos/query/{ver}/subscriptions/{id}`. Static rewrite is correct for that shape; no second Registry tested. Keep escape hatch; do not block Case A. | +| Secure / WSS upstream | **Deferred** (unchanged). | +| Query HTTP vs Query WS paths | **Confirmed.** Keep HTTP on `/x-nmos/query`; put remapped WS under `/x-nmos-bridge/v1.0/query/…` so upgrade and timeouts do not fight HTTP routes. | + +### Case B open questions + +| Item | Status | +| --- | --- | +| IS-12 through proxy (idle / messages) | **Spike OK** for open + one command round-trip. Full is12-client UI session and long idle still to validate in implementation. Ping/pong: not required for the short spike; Envoy idle_timeout covers silence. | +| Multiple NCP versions | **Unchanged design:** separate targets (already how Connection/Channel Mapping work). | +| Events WS (IS-07) | Out of scope; adapter should stay control-type generic (`CONTROL_TYPES` + scheme allow-list). | +| Product naming | **Done** on `feature/nmos-bridge-rename` (`nmos-bridge/`, NMOS Bridge settings). | + +### Adapter gaps confirmed before Case B + +- `ALLOWED_PROTOCOLS` is `http:` only — must allow `ws:` for NCP. +- Default port when omitted uses HTTP rules (`https` → 443 else 80); fine for `ws`/`wss` defaults (80/443) but nmos-cpp always advertises an explicit port. +- Per-target `http_health_check` on `basePath/` must be skipped (or replaced with TCP) for `ws` targets. + +### Suggested next implementation slice + +Done: Query subscription WebSockets (`registry_query_ws` + bridge path rewrite; +nmos-js does not consume Query grains today). Done: NCP in `CONTROL_TYPES`, +`ws` scheme, TCP health check, Forced Bridge IS-12 launch URI. Optional later: +Auto Bridge NCP fallback; Query grain client remap if the SPA starts using +subscriptions over WebSocket. +