From c86d91cf6d47d194644dcdc412acef1428ce0064 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Fri, 14 Aug 2026 18:16:15 +0100 Subject: [PATCH 1/5] Add design plan for Envoy IS-08 Channel Mapping proxying Document Connection-style HTTP bridge targets for cm-ctrl controls, including DELETE on the allow-list and Forced/Auto client remap. --- .../docs/channelmapping-proxy-plan.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 ConnectionBridge/docs/channelmapping-proxy-plan.md diff --git a/ConnectionBridge/docs/channelmapping-proxy-plan.md b/ConnectionBridge/docs/channelmapping-proxy-plan.md new file mode 100644 index 0000000..1e342da --- /dev/null +++ b/ConnectionBridge/docs/channelmapping-proxy-plan.md @@ -0,0 +1,135 @@ +# Design plan: Envoy proxying for IS-08 Channel Mapping API + +Status: proposal (not implemented). Extends the Connection API Bridge in +`ConnectionBridge/README.md`, which today proxies browser HTTP only for Device +Connection controls (`urn:x-nmos:control:sr-ctrl/{version}`). + +## Motivation + +IS-08 Channel Mapping is advertised on Devices as +`urn:x-nmos:control:cm-ctrl/{version}` with an HTTP `href` (nmos-cpp: +`http://host:port/x-nmos/channelmapping/{version}`). nmos-js already loads +Channel Mapping data from those hrefs for the Device UI, but it does **not** +use the Connection Bridge. In deployments where the browser cannot reach Device +networks, IS-05 works via `/x-nmos-bridge/...` while IS-08 still fails. + +Channel Mapping is the same reachability class as Connection: HTTP Device +control API, Registry as source of truth via Device `controls`, no open URL +proxy. + +## Decision: same pattern as Connection (HTTP) + +| | Connection (today) | Channel Mapping (proposed) | +| --- | --- | --- | +| Control type | `urn:x-nmos:control:sr-ctrl/{ver}` | `urn:x-nmos:control:cm-ctrl/{ver}` | +| Upstream | control `href` | control `href` | +| Public path | `/x-nmos-bridge/v1.0/devices/{id}/connection/{ver}/…` | `/x-nmos-bridge/v1.0/devices/{id}/channelmapping/{ver}/…` | +| Transport | HTTP | HTTP | +| Discovery | Device Query subscription (adapter) | Same | +| Client remap | Forced / Auto Bridge | Same modes and Bridge API origin | + +Semantically: downstream bridge URL identifies Device + API version; Envoy +proxies to that control's `href` (`prefix_rewrite` to `basePath`). + +Cluster naming, e.g.: + +```text +nmos_bridge_device_{safe_device_id}_channelmapping_{safe_version} +``` + +Do not merge Connection and Channel Mapping clusters. Candidate priorities: +same private-IP / private-DNS / other ordering as Connection. Keep one Bridge +API prefix; distinguish by `…/connection/…` vs `…/channelmapping/…`. + +## Adapter changes + +- Parse `urn:x-nmos:control:cm-ctrl/(v\d+\.\d+)`. +- Allow `http:` upstreams (same as Connection Phase 1); skip non-http until + HTTPS is in scope. +- Require `basePath` consistent with `/x-nmos/channelmapping/{version}` + (parallel to Connection's `/x-nmos/connection/{version}` check). +- Emit routes like Connection: `path_separated_prefix`, `prefix_rewrite` to + `basePath`, GET retry policy, no automatic retry on mutating methods, + `Location` rewrite using this target's `basePath` (reuse existing Lua + policy). +- **Health checks:** HTTP on `basePath/` works (unlike NCP WebSocket). +- Log and skip malformed / inconsistent hrefs (same as Connection). + +Generalizing "HTTP Device control" collection for `sr-ctrl` and `cm-ctrl` in +one helper is fine if it stays small; do not over-abstract for speculative +control types. + +## Method allow-list + +IS-08 (nmos-cpp) uses roughly: + +- **GET** — catalogue, inputs/outputs, maps, activations +- **POST** — `/map/activations/` +- **DELETE** — cancel `/map/activations/{id}` + +Today's bridge allow-list is GET, HEAD, POST, PATCH, OPTIONS (Connection). + +**Add DELETE** (and CORS `allow_methods`) when Channel Mapping routes land. +PATCH remains for Connection; Channel Mapping does not need it but sharing one +CORS policy is fine. + +Access logging: log POST and DELETE on Channel Mapping routes (and keep +POST/PATCH for Connection), consistent with "mutating methods are logged." + +## Client changes (nmos-js) + +Channel Mapping in `dataProvider` currently gathers control hrefs and fetches +them directly (`getChannelMappingEndPoints`). It does not consult Connection +Bridge Mode. + +- When Bridge Mode is **Forced**, use + `{bridge}/devices/{deviceId}/channelmapping/{version}` instead of raw hrefs + (same Bridge API base as Connection). +- When **Auto**, try direct hrefs then fall back to bridge (same 5s / cache + behaviour as Connection if practical). +- When **No Bridge**, unchanged. + +Ensure redirects and relative links under the Channel Mapping API stay on the +bridge via existing `Location` handling. + +## Deployment notes + +- Envoy must reach Device **Channel Mapping** ports (`channelmapping_port` on + nmos-cpp may differ from the Connection API port). +- No change to Query HTTP convenience routes or adapter Device discovery. +- README: document Channel Mapping under the bridge public API and method + list; keep "Connection API Bridge" naming until a broader rename is decided + (optional later: "Device control bridge"). + +## Non-goals + +- WebSocket proxying (Query / NCP) — separate plan: + `docs/websocket-proxy-plan.md` on `feature/envoy-websocket-proxy`. +- HTTPS upstreams, auth translation, mTLS. +- Open proxying of arbitrary Channel Mapping URLs. +- Changing IS-08 or requiring Node/Registry changes. + +## Sequencing + +| Step | Work | +| --- | --- | +| 1 | Adapter: collect `cm-ctrl` targets; routes + DELETE on allow-list / CORS | +| 2 | Confirm `Location` rewrite with Channel Mapping `basePath` (reuse tests / add cases if needed) | +| 3 | nmos-js: Forced (then Auto) remap for Channel Mapping fetches | +| 4 | README + Easy-NMOS note: browser need not reach Device channelmapping ports | + +## Acceptance + +- With Device networks blocked from the browser, Forced Bridge can GET IS-08 + resources and POST/DELETE activations through Envoy against a node only + Envoy can reach. +- Unknown device id → 404 on the bridge path; non-allow-listed methods → 405. +- Connection bridge behaviour unchanged aside from shared DELETE/CORS if + applied globally. +- No open proxy: only registered `cm-ctrl` hrefs become targets. + +## References + +- `ConnectionBridge/README.md` — Connection bridge mapping and deployment +- IS-08 Channel Mapping / `urn:x-nmos:control:cm-ctrl` +- nmos-js `dataProvider` Channel Mapping paths (direct hrefs today) From cddbaa615e5ec154e3db9aacc2dcf11bc2ce441a Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Tue, 18 Aug 2026 17:18:54 +0100 Subject: [PATCH 2/5] Bridge IS-08 Channel Mapping alongside IS-05 Connection Make the proxied Device API a property of each target, so cm-ctrl controls produce their own routes and clusters. Targets are keyed by Device, API and version: IS-05 and IS-08 both have a v1.0, and nmos-cpp advertises them at the same host and port, so keying without the API would merge them and drop one as an inconsistent candidate. Allow DELETE, which IS-08 uses to cancel a pending activation, and log it like other mutating methods. Which methods a resource supports remains the Device's answer to give. --- ConnectionBridge/README.md | 35 ++++++----- ConnectionBridge/adapter/index.js | 60 ++++++++++++------- ConnectionBridge/envoy/envoy.yaml | 7 ++- ConnectionBridge/envoy/location_rewrite.lua | 2 +- .../envoy/location_rewrite_test.lua | 36 +++++++++++ 5 files changed, 103 insertions(+), 37 deletions(-) diff --git a/ConnectionBridge/README.md b/ConnectionBridge/README.md index 1ba5b1f..306021c 100644 --- a/ConnectionBridge/README.md +++ b/ConnectionBridge/README.md @@ -1,13 +1,13 @@ # NMOS Connection API Bridge -Provides browser-accessible proxy access to [AMWA IS-05](https://specs.amwa.tv/is-05/) Connection 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 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. 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. ## Public Bridge API ```text -/x-nmos-bridge/v1.0/devices/{device_id}/connection/{version}/{sub-path} +/x-nmos-bridge/v1.0/devices/{device_id}/{api}/{version}/{sub-path} ``` proxies to: @@ -16,7 +16,14 @@ proxies to: {href}/{sub-path} ``` -where `href` is taken from the Device resource `controls` entry matching `urn:x-nmos:control:sr-ctrl/{version}`. The bridge API version (`v1.0`) is independent of the Connection API version (`{version}`). +where `href` is taken from the Device resource `controls` entry matching the control type for `{api}`: + +| `{api}` | Control type | Device API | +| --- | --- | --- | +| `connection` | `urn:x-nmos:control:sr-ctrl/{version}` | IS-05 Connection | +| `channelmapping` | `urn:x-nmos:control:cm-ctrl/{version}` | IS-08 Channel Mapping | + +`{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: @@ -30,7 +37,7 @@ is proxied to: PATCH http://device.example.local/x-nmos/connection/v1.1/single/receivers/{receiver_id}/staged ``` -Methods are restricted to `GET`, `POST`, `PATCH` and `OPTIONS`. Query strings, methods and request bodies are preserved. `GET` requests may be retried; `POST` and `PATCH` are never automatically retried. +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. ## Architecture @@ -63,20 +70,20 @@ Adapter (server-side; not on the browser path) The Connection API Bridge consists of Envoy and the adapter service: -- **Envoy** proxies browser HTTP to Device Connection 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 `POST` and `PATCH` 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 Connection 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). 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 Connection and Channel Mapping 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. 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. ### Mapping -Each unique combination of Device ID and Connection API version is a separate bridge target, producing one route and one cluster with deterministic names: +Each unique combination of Device ID, API and version is a separate bridge target, producing one route and one cluster with deterministic names: ```text -nmos_bridge_device_{safe_device_id}_connection_{safe_connection_version} +nmos_bridge_device_{safe_device_id}_{api}_{safe_version} ``` -where characters outside `[A-Za-z0-9_]` are replaced by `_` (e.g. `v1.1` becomes `v1_1`). Separate Connection API versions are never merged: a `v1.0` route cannot fail over to a `v1.1` href. +where characters outside `[A-Za-z0-9_]` are replaced by `_` (e.g. `v1.1` becomes `v1_1`). Separate APIs and versions are never merged: a `v1.0` route cannot fail over to a `v1.1` href, and a Connection API route cannot fail over to a Channel Mapping href even when the Device advertises both at the same host and port. If multiple eligible hrefs exist for the same Device and version, they become candidates of a single cluster, prioritized as: @@ -218,13 +225,13 @@ The nmos-js client offers a **Connection Bridge Mode** and a separate Phase 1 is implemented, plus health checking and multi-endpoint failover from Phase 2: - HTTP browser and upstream access, file-based dynamic configuration -- `GET`/`HEAD`/`POST`/`PATCH` +- `GET`/`HEAD`/`POST`/`PATCH`/`DELETE` - Upstream 3xx `Location` handling (see below) Not yet implemented: response size limits, HTTPS upstreams, authentication translation, mTLS, and an xDS control plane. -`Location` handling uses each target's Connection API `base_path` (the path of the Device control `href`, typically `/x-nmos/connection/v1.1` or similar): +`Location` handling uses each target's `base_path` (the path of the Device control `href`, typically `/x-nmos/connection/v1.1`, `/x-nmos/channelmapping/v1.0` or similar): -- Absolute or scheme-relative (filled with the client scheme) Locations whose scheme and authority match a Device Connection API candidate and whose path stays under that `base_path` are rewritten onto the bridge; other absolute Locations are forwarded unchanged (including candidate URLs outside `base_path`, e.g. `http://device/x-manifest/...`). -- Path-relative and root-relative Locations are resolved against the upstream Connection API path and rewritten onto the bridge when they stay under `base_path`; relatives outside `base_path` are rejected with `502` and an NMOS error body (`x-nmos-bridge-error` describes the unsupported Location), since an absolute Device URL cannot be reconstructed without knowing which candidate Envoy selected. -- Envoy internal redirects are not used: absolute Device Locations under `/x-nmos/` would be matched by path (e.g. `/x-nmos/query/` onto the Query cluster) rather than treated as Device Connection API targets. +- Absolute or scheme-relative (filled with the client scheme) Locations whose scheme and authority match a candidate for that target and whose path stays under that `base_path` are rewritten onto the bridge; other absolute Locations are forwarded unchanged (including candidate URLs outside `base_path`, e.g. `http://device/x-manifest/...`). +- Path-relative and root-relative Locations are resolved against the upstream API path and rewritten onto the bridge when they stay under `base_path`; relatives outside `base_path` are rejected with `502` and an NMOS error body (`x-nmos-bridge-error` describes the unsupported Location), since an absolute Device URL cannot be reconstructed without knowing which candidate Envoy selected. +- Envoy internal redirects are not used: absolute Device Locations under `/x-nmos/` would be matched by path (e.g. `/x-nmos/query/` onto the Query cluster) rather than treated as Device API targets. diff --git a/ConnectionBridge/adapter/index.js b/ConnectionBridge/adapter/index.js index 23e737e..cb11a6c 100644 --- a/ConnectionBridge/adapter/index.js +++ b/ConnectionBridge/adapter/index.js @@ -3,10 +3,10 @@ // NMOS Connection API Bridge - Envoy Adapter // // Converts Registry state into Envoy configuration. Tracks Devices through a -// Query API WebSocket subscription, extracts their Connection 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 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. const crypto = require('crypto'); const fs = require('fs'); @@ -48,7 +48,15 @@ const BRIDGE_PREFIX = '/x-nmos-bridge/v1.0'; // Phase 1 supports HTTP upstreams only const ALLOWED_PROTOCOLS = ['http:']; -const CONNECTION_CONTROL = /^urn:x-nmos:control:sr-ctrl\/(v\d+\.\d+)$/; +// the proxied Device APIs; each api is the path segment both in the advertised +// href and in the bridge path +const CONTROL_TYPES = [ + { pattern: /^urn:x-nmos:control:sr-ctrl\/(v\d+\.\d+)$/, api: 'connection' }, + { + pattern: /^urn:x-nmos:control:cm-ctrl\/(v\d+\.\d+)$/, + api: 'channelmapping', + }, +]; if (!REGISTRY_QUERY_URL) { console.error( @@ -102,14 +110,21 @@ const priorityFor = host => { return 1; }; -// one bridge target per unique Device ID + Connection API version +// one bridge target per unique Device ID + API + version const collectTargets = devices => { const targets = new Map(); for (const device of devices) { for (const control of device.controls || []) { - const match = CONNECTION_CONTROL.exec(control.type || ''); - if (!match) continue; - const version = match[1]; + let api; + let version; + for (const controlType of CONTROL_TYPES) { + const match = controlType.pattern.exec(control.type || ''); + if (!match) continue; + api = controlType.api; + version = match[1]; + break; + } + if (!version) continue; let href; try { href = new URL(control.href); @@ -127,16 +142,17 @@ const collectTargets = devices => { } // the href path must correspond to the advertised version const basePath = href.pathname.replace(/\/$/, ''); - if (!basePath.endsWith(`/x-nmos/connection/${version}`)) { + if (!basePath.endsWith(`/x-nmos/${api}/${version}`)) { logOnce( - `skipping href inconsistent with ${version} for Device ${device.id}: ${control.href}` + `skipping href inconsistent with ${api} ${version} for Device ${device.id}: ${control.href}` ); continue; } - const key = `${device.id}/${version}`; + const key = `${device.id}/${api}/${version}`; if (!targets.has(key)) { targets.set(key, { deviceId: device.id, + api, version, candidates: [], }); @@ -176,7 +192,7 @@ const collectTargets = devices => { for (const c of target.candidates) { if (c.scheme !== scheme || c.basePath !== basePath) { logOnce( - `dropping candidate with differing scheme or base path for Device ${target.deviceId} ${target.version}: ${c.scheme}://${c.host}:${c.port}${c.basePath}` + `dropping candidate with differing scheme or base path for Device ${target.deviceId} ${target.api} ${target.version}: ${c.scheme}://${c.host}:${c.port}${c.basePath}` ); } } @@ -187,14 +203,16 @@ const collectTargets = devices => { target.basePath = basePath; } return [...targets.values()].sort((a, b) => - `${a.deviceId}/${a.version}`.localeCompare(`${b.deviceId}/${b.version}`) + `${a.deviceId}/${a.api}/${a.version}`.localeCompare( + `${b.deviceId}/${b.api}/${b.version}` + ) ); }; // --- Envoy configuration --- const clusterName = target => - `nmos_bridge_device_${safeName(target.deviceId)}_connection_${safeName( + `nmos_bridge_device_${safeName(target.deviceId)}_${target.api}_${safeName( target.version )}`; @@ -293,9 +311,9 @@ const bridgeRoutes = target => { // path_separated_prefix matches the version path exactly or with a // following '/...' (Envoy 1.22+; compose pins v1.31). That preserves // whatever the client sent after the version (nothing, '/', or a - // sub-path) when rewriting onto the Device Connection API basePath, - // so trailing-slash handling stays with the upstream per IS-04/IS-05. - const pathPrefix = `${BRIDGE_PREFIX}/devices/${target.deviceId}/connection/${target.version}`; + // 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}`; // 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. @@ -331,7 +349,7 @@ const bridgeRoutes = target => { timeout: `${ROUTE_TIMEOUT_SECONDS}s`, }; return [ - // GET and HEAD may be retried; POSTs and PATCHes must not be + // GET and HEAD may be retried; mutating methods must not be { match: { path_separated_prefix: pathPrefix, @@ -360,7 +378,7 @@ const bridgeRoutes = target => { { name: ':method', string_match: { - safe_regex: { regex: 'POST|PATCH|OPTIONS' }, + safe_regex: { regex: 'POST|PATCH|DELETE|OPTIONS' }, }, }, ], @@ -390,7 +408,7 @@ const routeConfiguration = targets => ({ allow_origin_string_match: [ { safe_regex: { regex: '.*' } }, ], - allow_methods: 'GET, HEAD, POST, PATCH, OPTIONS', + allow_methods: 'GET, HEAD, POST, PATCH, DELETE, OPTIONS', // Request-Timeout: NMOS clients (e.g. nmos-js Query / // DNS-SD) send this on long-poll style requests; not // CORS-safelisted. See sony/nmos-js@6d0e783. diff --git a/ConnectionBridge/envoy/envoy.yaml b/ConnectionBridge/envoy/envoy.yaml index ace3c89..62a32ab 100644 --- a/ConnectionBridge/envoy/envoy.yaml +++ b/ConnectionBridge/envoy/envoy.yaml @@ -27,7 +27,7 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: nmos_bridge - # log POST and PATCH requests + # log mutating requests access_log: - name: envoy.access_loggers.stdout filter: @@ -43,6 +43,11 @@ static_resources: name: ":method" string_match: exact: "PATCH" + - header_filter: + header: + name: ":method" + string_match: + exact: "DELETE" typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog rds: diff --git a/ConnectionBridge/envoy/location_rewrite.lua b/ConnectionBridge/envoy/location_rewrite.lua index dd8ef0a..fd72789 100644 --- a/ConnectionBridge/envoy/location_rewrite.lua +++ b/ConnectionBridge/envoy/location_rewrite.lua @@ -7,7 +7,7 @@ -- -- Policy: -- - Path-relative or root-relative (path-absolute) whose resolved path is --- under this target's Connection API base_path: rewrite onto the bridge. +-- under this target's base_path: rewrite onto the bridge. -- - Absolute http(s) (or scheme-relative resolved with the client scheme) -- whose host/port matches a candidate and whose path is under base_path: -- rewrite onto the bridge (client scheme/host/port). diff --git a/ConnectionBridge/envoy/location_rewrite_test.lua b/ConnectionBridge/envoy/location_rewrite_test.lua index 1c42aad..e15f42c 100644 --- a/ConnectionBridge/envoy/location_rewrite_test.lua +++ b/ConnectionBridge/envoy/location_rewrite_test.lua @@ -378,6 +378,42 @@ test("handle_location_scheme_and_malformed", function() assert_eq("reject", handle("http:///x-nmos/connection/v1.1/"), "absolute missing authority -> reject") end) +-- Channel Mapping target, where a Device may redirect a collection request to +-- its trailing-slash form +local CM_BASE = "/x-nmos/channelmapping/v1.0" +local CM_BRIDGE = "/x-nmos-bridge/v1.0/devices/d1/channelmapping/v1.0" +local CM = { + base_path = CM_BASE, + bridge_path = CM_BRIDGE, + downstream_path = CM_BRIDGE .. "/map/activations", +} + +test("handle_location_channelmapping_target", function() + assert_eq( + CM_BRIDGE .. "/map/activations/", + handle(CM_BASE .. "/map/activations/", CM), + "root-relative trailing-slash redirect -> bridge path" + ) + assert_eq( + "http://controller.example:8080" .. CM_BRIDGE .. "/map/activations/", + handle( + "http://device.local" .. CM_BASE .. "/map/activations/", + CM + ), + "absolute candidate trailing-slash redirect -> bridge absolute" + ) + assert_eq( + CM_BRIDGE .. "/map/activations/", + handle("activations/", CM), + "path-relative trailing-slash redirect -> bridge path" + ) + assert_eq( + "reject", + handle("/x-nmos/connection/v1.1/single/senders/s1", CM), + "root-relative outside this target's base_path -> reject" + ) +end) + -- --------------------------------------------------------------------------- -- Envoy entry points (mock handles) -- --------------------------------------------------------------------------- From 64fb9e1c876c1ba2eefab6c25646e22cae7beac8 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Tue, 18 Aug 2026 17:21:09 +0100 Subject: [PATCH 3/5] Answer for the whole bridge namespace, not just v1.0 sub-paths The fall-through only matched /x-nmos-bridge/v1.0/, so the bridge API root and unknown bridge versions matched no route at all. Where APP_URL is set they reached the app catch-all on /, serving the SPA for paths inside the bridge's own namespace; otherwise they got a body-less Envoy 404 rather than an NMOS error. Match the namespace with path_separated_prefix, and list the bridge API and its version like the /x-nmos listing. Devices are still not listed: that would be a discovery surface the Registry already provides. --- ConnectionBridge/README.md | 2 ++ ConnectionBridge/adapter/index.js | 28 +++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ConnectionBridge/README.md b/ConnectionBridge/README.md index 306021c..00fe7e5 100644 --- a/ConnectionBridge/README.md +++ b/ConnectionBridge/README.md @@ -39,6 +39,8 @@ PATCH http://device.example.local/x-nmos/connection/v1.1/single/receivers/{recei 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. Every other path under `/x-nmos-bridge`, including other bridge API versions, returns `404` with an NMOS error body, so nothing in the bridge namespace falls through to the optional app route on `/`. + ## Architecture ```text diff --git a/ConnectionBridge/adapter/index.js b/ConnectionBridge/adapter/index.js index cb11a6c..fa16ee2 100644 --- a/ConnectionBridge/adapter/index.js +++ b/ConnectionBridge/adapter/index.js @@ -43,7 +43,9 @@ const RECONNECT_MAX_MS = Number(process.env.RECONNECT_MAX_MS) || 30000; // when set, use this scheme and authority while preserving the subscription path const REGISTRY_QUERY_WS_URL = process.env.REGISTRY_QUERY_WS_URL || ''; -const BRIDGE_PREFIX = '/x-nmos-bridge/v1.0'; +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:']; @@ -417,10 +419,30 @@ const routeConfiguration = targets => ({ }, routes: [ ...targets.flatMap(bridgeRoutes), + // listings of the bridge API itself, like /x-nmos below; + // Devices are not listed, the Registry answers that + { + match: { path: BRIDGE_ROOT }, + ...directResponse(200, [`${BRIDGE_VERSION}/`]), + }, + { + match: { path: `${BRIDGE_ROOT}/` }, + ...directResponse(200, [`${BRIDGE_VERSION}/`]), + }, + { + match: { path: BRIDGE_PREFIX }, + ...directResponse(200, ['devices/']), + }, + { + match: { path: `${BRIDGE_PREFIX}/` }, + ...directResponse(200, ['devices/']), + }, // arbitrary URLs are forbidden; only registered Device - // controls produce routes, everything else stops here + // controls produce routes. The whole bridge namespace stops + // here, including other bridge API versions, so no request + // for it reaches the app catch-all below. { - match: { prefix: `${BRIDGE_PREFIX}/` }, + match: { path_separated_prefix: BRIDGE_ROOT }, ...directErrorResponse(404, 'Unknown bridge target'), }, // Query API (host/port from REGISTRY_QUERY_URL; path is not From 5b207e034e2042e6578a4511a869bb8efbe46c43 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Tue, 18 Aug 2026 17:35:21 +0100 Subject: [PATCH 4/5] List the APIs and versions the bridge proxies for a Device A Device ID from the Registry was previously opaque: every path short of a complete target answered "Unknown bridge target", so there was no way to tell a mistyped ID from a control the bridge never mapped, short of reading the generated Envoy configuration. List the mapped APIs at /devices/{device_id} and their versions one level down, and say why the Device collection itself is not listed. These are direct responses generated from the same targets, so they cannot disagree with the routes, and they add no cluster, health check or per-route Lua. --- ConnectionBridge/README.md | 4 ++- ConnectionBridge/adapter/index.js | 53 +++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/ConnectionBridge/README.md b/ConnectionBridge/README.md index 00fe7e5..d3470b8 100644 --- a/ConnectionBridge/README.md +++ b/ConnectionBridge/README.md @@ -39,7 +39,9 @@ PATCH http://device.example.local/x-nmos/connection/v1.1/single/receivers/{recei 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. Every other path under `/x-nmos-bridge`, including other bridge API versions, returns `404` with an NMOS error body, so nothing in the bridge namespace falls through to the optional app route on `/`. +`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. + +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 `/`. ## Architecture diff --git a/ConnectionBridge/adapter/index.js b/ConnectionBridge/adapter/index.js index fa16ee2..93f427b 100644 --- a/ConnectionBridge/adapter/index.js +++ b/ConnectionBridge/adapter/index.js @@ -396,6 +396,45 @@ const bridgeRoutes = target => { ]; }; +// what the bridge proxies for one Device, so a client holding a Device ID from +// the Registry can see which APIs and versions became targets +const deviceListingRoutes = targets => { + const devices = new Map(); + for (const target of targets) { + if (!devices.has(target.deviceId)) { + devices.set(target.deviceId, new Map()); + } + const apis = devices.get(target.deviceId); + if (!apis.has(target.api)) apis.set(target.api, []); + apis.get(target.api).push(`${target.version}/`); + } + const routes = []; + // targets are sorted, so the listings are too + for (const [deviceId, apis] of devices) { + const devicePath = `${BRIDGE_PREFIX}/devices/${deviceId}`; + const deviceListing = directResponse( + 200, + [...apis.keys()].map(api => `${api}/`) + ); + routes.push({ match: { path: devicePath }, ...deviceListing }); + routes.push({ match: { path: `${devicePath}/` }, ...deviceListing }); + for (const [api, versions] of apis) { + const apiListing = directResponse(200, versions); + routes.push({ + match: { path: `${devicePath}/${api}` }, + ...apiListing, + }); + routes.push({ + match: { path: `${devicePath}/${api}/` }, + ...apiListing, + }); + } + } + return routes; +}; + +const DEVICES_NOT_LISTED = `Devices are not listed; request a specific device at ${BRIDGE_PREFIX}/devices/{deviceId}`; + const routeConfiguration = targets => ({ '@type': 'type.googleapis.com/envoy.config.route.v3.RouteConfiguration', name: 'nmos_bridge_routes', @@ -419,8 +458,18 @@ const routeConfiguration = targets => ({ }, routes: [ ...targets.flatMap(bridgeRoutes), - // listings of the bridge API itself, like /x-nmos below; - // Devices are not listed, the Registry answers that + ...deviceListingRoutes(targets), + // the Device collection is not listed, the Registry answers + // which Devices exist + { + match: { path: `${BRIDGE_PREFIX}/devices` }, + ...directErrorResponse(404, DEVICES_NOT_LISTED), + }, + { + match: { path: `${BRIDGE_PREFIX}/devices/` }, + ...directErrorResponse(404, DEVICES_NOT_LISTED), + }, + // listings of the bridge API itself, like /x-nmos below { match: { path: BRIDGE_ROOT }, ...directResponse(200, [`${BRIDGE_VERSION}/`]), From 4c3ff96df247f5223d6d6be5dcfb8d2a34e6bd08 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Tue, 18 Aug 2026 18:26:17 +0100 Subject: [PATCH 5/5] Honour Bridge Mode for Channel Mapping fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection already remaps Forced/Auto through the bridge; Channel Mapping still used Device control hrefs only, so Forced Bridge could not reach IS-08 when the browser could not reach the Device. Reuse the same attempt order and per-Device access-path cache, with bridge URLs under …/channelmapping/{version}. --- ConnectionBridge/README.md | 7 +-- Development/src/dataProvider.js | 77 ++++++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/ConnectionBridge/README.md b/ConnectionBridge/README.md index d3470b8..e9c9a5c 100644 --- a/ConnectionBridge/README.md +++ b/ConnectionBridge/README.md @@ -216,13 +216,14 @@ and are not included here. ## Browser Application Behavior The nmos-js client offers a **Connection Bridge Mode** and a separate -**Connection Bridge API**: +**Connection Bridge API**. The same mode applies to Device Connection (IS-05) +and Channel Mapping (IS-08) fetches: - **No Bridge** (default): use the Device control hrefs directly, never the bridge. -- **Auto Bridge**: the preferred access sequence. Use the Device control href directly; if inaccessible, use the bridge URL; cache the successful access path per Device. Note that on first access to a Device that is not directly reachable, the browser must wait for the direct attempt to fail (up to 5 seconds) before falling back; the cached path avoids this on subsequent accesses. +- **Auto Bridge**: the preferred access sequence. Use the Device control href directly; if inaccessible, use the bridge URL; cache the successful access path per Device (shared across Connection and Channel Mapping). Note that on first access to a Device that is not directly reachable, the browser must wait for the direct attempt to fail (up to 5 seconds) before falling back; the cached path avoids this on subsequent accesses. - **Forced Bridge**: always use the bridge, skipping direct attempts entirely. Useful when it is known that no Device is reachable from the browser. -`POST` and `PATCH` requests are not automatically retried via alternate paths; they follow whichever path was resolved for the Device. Bridge requests use the configured Connection Bridge API (default: SPA origin + `/x-nmos-bridge/v1.0`). +`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 Connection Bridge API (default: SPA origin + `/x-nmos-bridge/v1.0`). ## Status diff --git a/Development/src/dataProvider.js b/Development/src/dataProvider.js index 3d52419..ce87997 100644 --- a/Development/src/dataProvider.js +++ b/Development/src/dataProvider.js @@ -29,12 +29,12 @@ import { } from './settings'; // the Connection API Bridge (see ../../ConnectionBridge) makes Device -// Connection APIs available at a configured base URL for deployments where -// the browser cannot reach the Device directly -const bridgeAddress = (deviceId, version) => +// Connection and Channel Mapping APIs available at a configured base URL for +// deployments where the browser cannot reach the Device directly +const bridgeAddress = (deviceId, api, version) => concatUrl( apiUrl(CONNECTION_BRIDGE_API), - `/devices/${deviceId}/connection/${version}` + `/devices/${deviceId}/${api}/${version}` ); // which access path, direct or bridge, most recently worked for each Device @@ -919,7 +919,13 @@ const convertHTTPResponseToDataProvider = async ( ) { attempts.push([ 'bridge', - [bridgeAddress(deviceId, connectionVersion)], + [ + bridgeAddress( + deviceId, + 'connection', + connectionVersion + ), + ], ]); } if ( @@ -994,16 +1000,67 @@ const convertHTTPResponseToDataProvider = async ( .sort() .reverse(); + const deviceId = deviceJSONData.id; + const bridgeMode = connectionBridgeMode(); + let endpointData; + let accessPath; for (let version of versions) { - try { - endpointData = await getChannelMappingEndPoints( + // e.g. 'urn:x-nmos:control:cm-ctrl/v1_0' -> 'v1.0' + const channelmappingVersion = version + .split('/') + .slice(-1)[0] + .replace('_', '.'); + // same access sequence as Connection: direct hrefs, then + // bridge; start with whichever path last worked for this + // Device unless Forced + const attempts = []; + if (bridgeMode !== BRIDGE_FORCED) { + attempts.push([ + 'direct', channelmappingAddresses[version], - ['io', 'map/active', 'map/activations'] - ); - } catch (e) {} + ]); + } + if ( + bridgeMode === BRIDGE_AUTO || + bridgeMode === BRIDGE_FORCED + ) { + attempts.push([ + 'bridge', + [ + bridgeAddress( + deviceId, + 'channelmapping', + channelmappingVersion + ), + ], + ]); + } + if ( + bridgeMode === BRIDGE_AUTO && + deviceAccessPaths.get(deviceId) === 'bridge' + ) { + attempts.reverse(); + } + for (const [path, addresses] of attempts) { + try { + endpointData = await getChannelMappingEndPoints( + addresses, + ['io', 'map/active', 'map/activations'] + ); + } catch (e) {} + if (endpointData) { + accessPath = path; + break; + } + } if (endpointData) break; } + if (endpointData) { + deviceAccessPaths.set(deviceId, accessPath); + } else { + deviceAccessPaths.delete(deviceId); + } // just return IS-04 data if no Channel Mapping API was able to connect if (endpointData === undefined) {