Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions Development/src/dataProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();

Expand Down Expand Up @@ -914,7 +908,7 @@ const convertHTTPResponseToDataProvider = async (
attempts.push([
'bridge',
[
bridgeAddress(
bridgeUrl(
deviceId,
'connection',
connectionVersion
Expand Down Expand Up @@ -1019,7 +1013,7 @@ const convertHTTPResponseToDataProvider = async (
attempts.push([
'bridge',
[
bridgeAddress(
bridgeUrl(
deviceId,
'channelmapping',
channelmappingVersion
Expand Down
30 changes: 27 additions & 3 deletions Development/src/pages/devices/DevicesShow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
Expand Down Expand Up @@ -182,6 +205,7 @@ const ShowSummaryTab = ({ record, ...props }) => {
<ControlAddressField
source="href"
label="Address"
deviceId={record?.id}
deviceLabel={record?.label}
/>
<ParameterField
Expand Down
5 changes: 5 additions & 0 deletions Development/src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ export const setApiUrl = (api, url) => {
}
};

// 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];

Expand Down
97 changes: 74 additions & 23 deletions nmos-bridge/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 `/`.

Expand All @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -224,13 +271,17 @@ 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:

- 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.

Expand Down
Loading