diff --git a/ConnectionBridge/README.md b/ConnectionBridge/README.md index 1ba5b1f..e9c9a5c 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,11 @@ 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. + +`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 @@ -63,20 +74,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: @@ -205,26 +216,27 @@ 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 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..93f427b 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'); @@ -43,12 +43,22 @@ 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:']; -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 +112,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 +144,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 +194,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 +205,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 +313,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 +351,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 +380,7 @@ const bridgeRoutes = target => { { name: ':method', string_match: { - safe_regex: { regex: 'POST|PATCH|OPTIONS' }, + safe_regex: { regex: 'POST|PATCH|DELETE|OPTIONS' }, }, }, ], @@ -376,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', @@ -390,7 +449,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. @@ -399,10 +458,40 @@ const routeConfiguration = targets => ({ }, routes: [ ...targets.flatMap(bridgeRoutes), + ...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}/`]), + }, + { + 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 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) 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) -- --------------------------------------------------------------------------- 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) {