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
6 changes: 6 additions & 0 deletions deps/cloudxr/webxr_client/helpers/controlChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const SERVER_SCRIPT = path.join(
'oob_hub_test_server.py'
);

// Must match TEST_CONTROL_TOKEN in oob_hub_test_server.py.
const HUB_TOKEN = 'oob-hub-test-token';

// Safety net for afterEach cleanup: every hub PID spawned goes in here and comes out once
// confirmed exited. If a test's afterEach is ever skipped (a Jest hook-ordering edge case,
// not something this file's own control flow can prevent), this file's own `exit` handler
Expand Down Expand Up @@ -156,6 +159,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke

channel = new HeadsetControlChannel({
url: `ws://127.0.0.1:${hub.port}`,
token: HUB_TOKEN,
deviceLabel: 'jest-test',
onConfig: (config, configVersion) => {
configs.push({ config, version: configVersion });
Expand Down Expand Up @@ -193,6 +197,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke

channel = new HeadsetControlChannel({
url: `ws://127.0.0.1:${hub.port}`,
token: HUB_TOKEN,
onConfig: () => {},
});
channel.connect();
Expand All @@ -216,6 +221,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke

channel = new HeadsetControlChannel({
url: `ws://127.0.0.1:${hub.port}`,
token: HUB_TOKEN,
onConfig: () => {},
metricsIntervalMs: 20,
getMetricsSnapshot: () => [{ cadence: 'frame', metrics: { StreamingFramerate: 72.5 } }],
Expand Down
36 changes: 23 additions & 13 deletions docs/source/references/oob_teleop_control.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,18 @@ You should see output confirming the hub is running:

If the adb automation fails (e.g. headset not paired), you can manually open
the client URL on the headset browser with **all three** required query
parameters — ``oobEnable``, ``serverIP``, and ``port``:
parameters — ``oobEnable``, ``serverIP``, and ``port`` — plus the hub's
``controlToken``:

.. parsed-literal::

|web_client_url|\ ?oobEnable=1&serverIP=<HOST_IP>&port=48322
|web_client_url|\ ?oobEnable=1&serverIP=<HOST_IP>&port=48322&controlToken=<CONTROL_TOKEN>

Replace ``<HOST_IP>`` with the streaming host's LAN IP. The ``port`` must
match the proxy port (default 48322).
match the proxy port (default 48322). ``<CONTROL_TOKEN>`` is the value of
``CONTROL_TOKEN``, or the generated token printed in the startup banner; the
banner's bookmark shows it as ``<REDACTED>``, so substitute it before pasting.
Without the token the hub rejects the headset and the client does not retry.

.. note::

Expand All @@ -107,7 +111,7 @@ self-signed certificate check):

.. code-block:: bash

curl -k https://<HOST_IP>:48322/api/oob/v1/state
curl -k -H "X-Control-Token: <CONTROL_TOKEN>" https://<HOST_IP>:48322/api/oob/v1/state

You should see the headset listed under ``"headsets"`` with
``"connected": true``:
Expand Down Expand Up @@ -143,7 +147,7 @@ configuration overrides via the HTTP config API:

.. code-block:: bash

curl -k "https://<HOST_IP>:48322/api/oob/v1/config?serverIP=<HOST_IP>&port=48322&codec=av1"
curl -k -H "X-Control-Token: <CONTROL_TOKEN>" "https://<HOST_IP>:48322/api/oob/v1/config?serverIP=<HOST_IP>&port=48322&codec=av1"

See ``GET /api/oob/v1/config`` below for all supported keys.

Expand All @@ -157,7 +161,7 @@ from a PC to collect them:
.. code-block:: bash

# Poll every 2 seconds (adjust to taste)
watch -n 2 'curl -sk https://<HOST_IP>:48322/api/oob/v1/state | python3 -m json.tool'
watch -n 2 'curl -sk -H "X-Control-Token: <CONTROL_TOKEN>" https://<HOST_IP>:48322/api/oob/v1/state | python3 -m json.tool'

The ``metricsByCadence`` field on each headset entry will now contain live streaming metrics.

Expand Down Expand Up @@ -228,7 +232,7 @@ Registration (first message)
"payload": {
"role": "headset",
"deviceLabel": "Quest 3",
"token": "<optional CONTROL_TOKEN>"
"token": "<CONTROL_TOKEN>"
}
}

Expand Down Expand Up @@ -444,7 +448,7 @@ version.

.. code-block:: bash

curl -k https://localhost:48322/api/oob/v1/state
curl -k -H "X-Control-Token: <CONTROL_TOKEN>" https://localhost:48322/api/oob/v1/state

Example response:

Expand Down Expand Up @@ -477,7 +481,7 @@ Push config to connected headsets via query parameters:

.. code-block:: bash

curl -k "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322"
curl -k -H "X-Control-Token: <CONTROL_TOKEN>" "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322"

Example response:

Expand All @@ -497,12 +501,18 @@ if not connected).
Authentication
--------------

Set ``CONTROL_TOKEN=<secret>`` to require a token on all hub operations.
Pass it as:
Every hub operation requires a token. Set ``CONTROL_TOKEN=<secret>`` to choose
it; if it is unset or empty, the hub generates a random token for the session and
prints it in the startup banner. Requests without the token, and WebSocket
``register`` frames without it, are rejected. Pass it as:

- WebSocket: ``"token"`` field in the ``register`` payload
- HTTP: ``?token=<secret>`` query parameter or ``X-Control-Token`` header

The headset bookmark carries the token as ``controlToken`` in the URL query, so
the hosting server and any proxy logs can see it. Treat bookmark URLs as
secrets and prefer a client served from the streaming host (``--host-client``).

Web client integration
----------------------

Expand All @@ -511,7 +521,7 @@ The web client connects to the hub when the page URL contains

.. parsed-literal::

|web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322
|web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322&controlToken=<CONTROL_TOKEN>

The client builds ``wss://{serverIP}:{port}/oob/v1/ws`` and:

Expand Down Expand Up @@ -551,7 +561,7 @@ Environment variables
and signaling share this socket. In ``--usb-local`` mode
``adb reverse`` maps it to the headset.
* - ``CONTROL_TOKEN``
- Optional auth token for hub access
- Auth token for hub access; a random per-session token is generated if unset
* - ``TELEOP_STREAM_SERVER_IP``
- Override the auto-detected LAN IP in hub initial config
* - ``TELEOP_PROXY_HOST``
Expand Down
3 changes: 2 additions & 1 deletion src/python/isaaccapture/cloudxr/oob_teleop_adb.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
client_ui_fields_from_env,
oob_progress,
redact_control_token,
resolve_control_token,
resolve_lan_host_for_oob,
web_client_base_override_from_env,
)
Expand Down Expand Up @@ -604,7 +605,7 @@ def build_teleop_url(
default_base = default_web_client_origin()
web_base = ovr if ovr else default_base

token = os.environ.get("CONTROL_TOKEN") or None
token = resolve_control_token()
Comment thread
gareth-morgan-nv marked this conversation as resolved.
return build_headset_bookmark_url(
web_client_base=web_base,
stream_config=stream_cfg,
Expand Down
42 changes: 36 additions & 6 deletions src/python/isaaccapture/cloudxr/oob_teleop_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import math
import os
import re
import secrets
import socket
import subprocess
import sys
import threading
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from urllib.error import URLError
Expand All @@ -22,6 +24,35 @@
from .oob_teleop_hub import OOB_WS_PATH

log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env")

_generated_control_token: str | None = None
_generated_control_token_lock = threading.Lock()


def control_token_is_generated() -> bool:
"""``True`` when ``CONTROL_TOKEN`` is unset and the hub uses a random token."""
return not os.environ.get("CONTROL_TOKEN")


def resolve_control_token() -> str:
"""Return the hub control token: ``CONTROL_TOKEN``, else a per-process random one.

The hub never runs unauthenticated. Every reader (hub, headset URL, banner)
must call this so they agree; it does not modify ``os.environ``.
"""
global _generated_control_token
# An empty CONTROL_TOKEN counts as unset: it must never mean "no auth".
configured = os.environ.get("CONTROL_TOKEN")
if configured:
return configured
# The proxy runs on a thread, so the lazy generation is lock-guarded to
# keep the hub, headset URL and banner on the same token.
with _generated_control_token_lock:
if _generated_control_token is None:
_generated_control_token = secrets.token_urlsafe(32)
return _generated_control_token


progress_log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env.oob-progress")

WSS_PROXY_DEFAULT_PORT = 48322
Expand Down Expand Up @@ -598,7 +629,7 @@ def print_oob_hub_startup_banner(
port = wss_proxy_port()
backend_port = usb_backend_port()
turn_port = usb_turn_port()
token = os.environ.get("CONTROL_TOKEN") or None
token = resolve_control_token()

if not lan_host:
lan_host = resolve_lan_host_for_oob() if not usb_local else "127.0.0.1"
Expand Down Expand Up @@ -729,11 +760,10 @@ def print_oob_hub_startup_banner(
)
print(f" State: {primary_base}/api/oob/v1/state")
print(f" Config: {primary_base}/api/oob/v1/config?{cfg_q}")
if token:
print()
print(
" CONTROL_TOKEN is set: add ?token=... or header X-Control-Token on OOB HTTP requests."
)
print()
print(" Add header X-Control-Token (or ?token=...) on OOB HTTP requests.")
if control_token_is_generated():
print(f" CONTROL_TOKEN was not set; this session's token is: {token}")
print(bar)
print()

Expand Down
17 changes: 12 additions & 5 deletions src/python/isaaccapture/cloudxr/oob_teleop_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import asyncio
import hmac
import json
import logging
import time
Expand Down Expand Up @@ -213,10 +214,16 @@ async def wait_for_streaming(
await asyncio.sleep(poll_seconds)

def check_token(self, token: str | None) -> bool:
"""Return True if token satisfies the hub's auth requirement."""
if not self._token:
return True
return token == self._token
"""Return True only if *token* matches the hub's control token.

Fails closed: a hub built without a token rejects every caller.
"""
# No configured token or a non-string payload value (JSON can carry ints,
# lists) is never a match; compare_digest would raise on non-str input.
if not self._token or not isinstance(token, str):
return False
# Constant-time compare so response timing does not leak the token.
return hmac.compare_digest(token.encode(), self._token.encode())

async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]:
"""Merge stream config; for OOB HTTP ``GET /api/oob/v1/config``."""
Expand Down Expand Up @@ -260,7 +267,7 @@ async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]:

async def _handle_register(self, ws: Any, client_id: str, payload: dict) -> bool:
"""Validate and register a headset. Returns True on success."""
if self._token and payload.get("token") != self._token:
if not self.check_token(payload.get("token")):
await self._send_error(ws, "UNAUTHORIZED", "Invalid or missing token")
try:
await ws.close(1008, "Unauthorized")
Expand Down
6 changes: 4 additions & 2 deletions src/python/isaaccapture/cloudxr/wss.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
from .env_config import get_env_config
from .oob_teleop_env import (
client_ui_fields_from_env,
control_token_is_generated,
default_initial_stream_config,
redact_control_token,
resolve_control_token,
wss_proxy_port,
)
from .oob_teleop_hub import OOB_WS_PATH
Expand Down Expand Up @@ -666,15 +668,15 @@ async def report_fatal(error: Exception, circumstance: str) -> None:
if setup_oob:
from .oob_teleop_hub import OOBControlHub # noqa: PLC0415

control_token = os.environ.get("CONTROL_TOKEN") or None
control_token = resolve_control_token()
initial = {
**default_initial_stream_config(resolved_port),
**client_ui_fields_from_env(),
}
hub = OOBControlHub(control_token=control_token, initial_config=initial)
log.info(
"Teleop control hub enabled (token=%s) OOB_WS=%s initial_stream=%s",
"set" if control_token else "none",
"generated" if control_token_is_generated() else "set",
OOB_WS_PATH,
initial,
)
Expand Down
4 changes: 3 additions & 1 deletion tests/python/core/cloudxr/oob_hub_test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

from oob_teleop_hub import OOBControlHub # noqa: E402

# controlChannel.test.ts passes the same token.
TEST_CONTROL_TOKEN = "oob-hub-test-token"
POLL_INTERVAL_S = 0.05
_WS_HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
_OPCODE_TEXT, _OPCODE_CLOSE, _OPCODE_PING, _OPCODE_PONG = 0x1, 0x8, 0x9, 0xA
Expand Down Expand Up @@ -178,7 +180,7 @@ def _fingerprint(snapshot: dict) -> tuple:

async def main() -> None:
"""Start the hub, announce readiness, then print a snapshot on every state change."""
hub = OOBControlHub()
hub = OOBControlHub(control_token=TEST_CONTROL_TOKEN)

async def handle_tcp(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
Expand Down
42 changes: 42 additions & 0 deletions tests/python/core/cloudxr/test_oob_teleop_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import logging
import os
from urllib.parse import parse_qs, urlparse

import cloudxr_py_test_ns.oob_teleop_env as oob_teleop_env_under_test
Expand Down Expand Up @@ -612,6 +613,47 @@ def test_startup_banner_hides_control_and_turn_credentials(
assert "turnCredential=<REDACTED>" in out


def test_resolve_control_token_prefers_configured(
clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CONTROL_TOKEN", "configured")
assert oob_teleop_env_under_test.resolve_control_token() == "configured"
assert oob_teleop_env_under_test.control_token_is_generated() is False


def test_resolve_control_token_generates_stable_random_token(
clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None)
first = oob_teleop_env_under_test.resolve_control_token()
assert len(first) >= 32
assert oob_teleop_env_under_test.resolve_control_token() == first
assert oob_teleop_env_under_test.control_token_is_generated() is True
assert "CONTROL_TOKEN" not in os.environ

monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None)
assert oob_teleop_env_under_test.resolve_control_token() != first


def test_resolve_control_token_treats_empty_env_as_unset(
clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None)
monkeypatch.setenv("CONTROL_TOKEN", "")
assert oob_teleop_env_under_test.resolve_control_token()
assert oob_teleop_env_under_test.control_token_is_generated() is True


def test_startup_banner_shows_generated_token_once(
clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch, capsys
) -> None:
monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None)

print_oob_hub_startup_banner(lan_host="127.0.0.1", usb_local=True)
out = capsys.readouterr().out
assert oob_teleop_env_under_test.resolve_control_token() in out


def test_oob_progress_logs_at_info(
monkeypatch: pytest.MonkeyPatch, capsys, caplog
) -> None:
Expand Down
Loading
Loading