Skip to content

Commit df71ff7

Browse files
committed
Add WebSocket relay with token & UI
Introduce a downlink-only WebSocket relay feature and status UI controls. Changes include: - README: document new relay-related env vars and Cloudflare Tunnel usage for exposing wss://. - deploy/.env.macbook: add placeholder RELAY_TOKEN and cloudflared config paths. - docker-compose.macbook-base.yml: expose port 9089, enable relay envs, and add a cloudflared service to publish the relay via a tunnel. - src/ws_relay.py: make the relay read a live token (from /app/relay_token or env), require tokens per LAN policy, and apply the live token to auth checks. - src/status_server.py: add /relay-info GET and /relay-token POST endpoints; persist token to /app/relay_token so UI changes take effect immediately. - status/index.html: add relay card to status UI (port, token input, copy/save), replace the old packet-rate / seq-tape visuals with a 60s health mosaic, and wire UI to new endpoints (load/save token). - src/data.py: adjust status_map pruning range and status_buffer slice to cover the larger window used by the UI mosaic. These changes enable remote viewers to connect to a base-station WebSocket (port 9089) with an optional token, and document a recommended secure exposure path via Cloudflare Tunnel. UI changes allow operators to view and update the token without restarting services.
1 parent 80c06f7 commit df71ff7

7 files changed

Lines changed: 290 additions & 77 deletions

File tree

universal-telemetry-software/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ Images are built for both `linux/amd64` and `linux/arm64` (Raspberry Pi).
202202
| `ENABLE_VIDEO` | `false` | Enable video streaming (car: push RTSP; base: unused) |
203203
| `ENABLE_AUDIO` | `false` | Enable audio streaming |
204204
| `ENABLE_TIMESCALE_LOGGING` | `false` | Log telemetry to server TimescaleDB (direct write) |
205+
| `ENABLE_WS_RELAY` | `false` | Enable downlink-only WebSocket relay for remote viewers |
206+
| `RELAY_TOKEN` | unset | Optional passcode for relay connections (`?token=...`) |
207+
| `RELAY_LISTEN_PORT` | `9089` | Local port for the relay WebSocket server |
208+
| `RELAY_UPSTREAM_WS` | `ws://127.0.0.1:9080` | Upstream base-station WebSocket consumed by the relay |
209+
| `RELAY_REQUIRE_TOKEN_ON_LAN` | `false` | Require token for LAN clients too, not just loopback/public clients |
205210
| `RTSP_PORT` | `8554` | Port on base station where MediaMTX accepts RTSP push |
206211
| `VIDEO_STREAM_NAME` | `car-camera` | RTSP/WebRTC stream path name |
207212
| `VIDEO_WIDTH` | `848` | Capture width (overridden by quality preset) |
@@ -219,6 +224,7 @@ Images are built for both `linux/amd64` and `linux/arm64` (Raspberry Pi).
219224
| 6379 | TCP | Redis (internal) |
220225
| 8080 | HTTP | Status monitoring page |
221226
| 9080 | WebSocket | PECAN dashboard feed |
227+
| 9089 | WebSocket | Downlink-only relay for remote viewers |
222228
| 3000 | HTTP | PECAN dashboard UI |
223229
| 8081 | HTTP | Video quality control (car only, when ENABLE_VIDEO=true) |
224230
| 8554 | TCP | RTSP — car pushes H.264 to MediaMTX on base |
@@ -283,6 +289,80 @@ v4l2-ctl --device /dev/video0 --set-ctrl focus_absolute=40
283289

284290
---
285291

292+
## Remote Viewing via WebSocket Relay
293+
294+
The base station can publish the live telemetry WebSocket through a downlink-only relay on port `9089`. The relay connects upstream to the normal local WebSocket (`9080`) and rebroadcasts frames to viewers; downstream messages are not forwarded back to the car or base station.
295+
296+
### Enable the relay
297+
298+
For the MacBook base stack this is already enabled in `deploy/docker-compose.macbook-base.yml`:
299+
300+
```yaml
301+
ENABLE_WS_RELAY=true
302+
RELAY_UPSTREAM_WS=ws://127.0.0.1:9080
303+
RELAY_LISTEN_PORT=9089
304+
RELAY_TOKEN=<optional passcode>
305+
```
306+
307+
You can set or change the token from the status page at `http://localhost:8080`. Tokens saved from the UI take effect for new relay connections immediately.
308+
309+
Local viewers can connect to:
310+
311+
```text
312+
ws://<base-station-ip>:9089?token=<token>
313+
```
314+
315+
### Forward with Cloudflare Tunnel
316+
317+
Cloudflare Tunnel is the recommended way to expose the relay as secure `wss://` without opening inbound firewall ports.
318+
319+
Install and log in:
320+
321+
```bash
322+
brew install cloudflared
323+
cloudflared tunnel login
324+
```
325+
326+
Create a tunnel:
327+
328+
```bash
329+
cloudflared tunnel create daq-ws-relay
330+
```
331+
332+
Create `~/.cloudflared/config.yml`:
333+
334+
```yaml
335+
tunnel: <tunnel-id-or-name>
336+
credentials-file: /Users/<you>/.cloudflared/<tunnel-id>.json
337+
338+
ingress:
339+
- hostname: daq-relay.example.com
340+
service: http://localhost:9089
341+
- service: http_status:404
342+
```
343+
344+
Route DNS:
345+
346+
```bash
347+
cloudflared tunnel route dns daq-ws-relay daq-relay.example.com
348+
```
349+
350+
Run the tunnel while the base station stack is up:
351+
352+
```bash
353+
cloudflared tunnel run daq-ws-relay
354+
```
355+
356+
Remote viewers connect with:
357+
358+
```text
359+
wss://daq-relay.example.com?token=<token>
360+
```
361+
362+
This same relay can be published by any reverse tunnel provider; Cloudflare is only the documented default.
363+
364+
---
365+
286366
## Redis Channels
287367

288368
### `can_messages`
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
# MacBook base station env
1+
# MacBook base station env template
22
# Usage: docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook up -d
3+
# Put real team credentials/paths in a private env file and pass that with --env-file.
34

45
REMOTE_IP=10.71.1.10
56
TIMESCALE_TABLE=WFR26test
67
DBC_HOST_PATH=./example.dbc
78
GRAFANA_ADMIN_PASSWORD=admin
9+
RELAY_TOKEN=
10+
CLOUDFLARED_CONFIG=./cloudflared/config.yml
11+
CLOUDFLARED_CREDENTIALS=./cloudflared/credentials.json

universal-telemetry-software/deploy/docker-compose.macbook-base.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ services:
2020
restart: unless-stopped
2121
ports:
2222
- "9080:9080" # WebSocket (Pecan connects here)
23+
- "9089:9089" # WS relay (external access via Cloudflare tunnel)
2324
- "8080:8080" # Status page
2425
- "5005:5005/udp"
2526
- "5006:5006/tcp"
@@ -39,6 +40,10 @@ services:
3940
- POSTGRES_DSN=postgresql://wfr:wfr_password@timescaledb:5432/wfr
4041
- TIMESCALE_TABLE=${TIMESCALE_TABLE:-WFR26test}_base
4142
- DBC_FILE_PATH=/app/active.dbc
43+
- ENABLE_WS_RELAY=true
44+
- RELAY_TOKEN=${RELAY_TOKEN:-}
45+
- RELAY_REQUIRE_TOKEN_ON_LAN=true
46+
- RELAY_UPSTREAM_WS=ws://127.0.0.1:9080
4247
volumes:
4348
- ${DBC_HOST_PATH:-./example.dbc}:/app/active.dbc:ro
4449
- raw_can_data:/app/raw_can_logs
@@ -75,6 +80,19 @@ services:
7580
networks:
7681
- datalink
7782

83+
cloudflared:
84+
image: cloudflare/cloudflared:latest
85+
container_name: daq-cloudflared
86+
restart: unless-stopped
87+
command: tunnel --config /etc/cloudflared/config.yml run daq-ws-relay
88+
volumes:
89+
- ${CLOUDFLARED_CONFIG:-./cloudflared/config.yml}:/etc/cloudflared/config.yml:ro
90+
- ${CLOUDFLARED_CREDENTIALS:-./cloudflared/credentials.json}:/etc/cloudflared/credentials.json:ro
91+
depends_on:
92+
- telemetry
93+
networks:
94+
- datalink
95+
7896
mediamtx:
7997
image: bluenviron/mediamtx:latest
8098
container_name: daq-mediamtx

universal-telemetry-software/src/data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ async def udp_receiver():
485485
self.latest_seq = seq
486486
# Prune status map to last 3000 sequences
487487
if len(self.status_map) > 3000:
488-
min_seq = self.latest_seq - 2000
488+
min_seq = self.latest_seq - 2999
489489
self.status_map = {s: v for s, v in self.status_map.items() if s >= min_seq}
490490
elif seq in self.status_map and self.status_map[seq] == 0:
491491
self.status_map[seq] = 1 # Out-of-order UDP arrival
@@ -641,7 +641,7 @@ async def stats_publisher():
641641
"base_clock_bad": self._base_clock_bad,
642642
"last_udp_time": self.last_udp_time,
643643
"car_alive": (time.time() - self.last_udp_time) < 5 if self.last_udp_time else False,
644-
"status_buffer": [self.status_map.get(s, 0) for s in range(max(0, self.latest_seq - 1000), self.latest_seq + 1)] if self.latest_seq != -1 else [],
644+
"status_buffer": [self.status_map.get(s, 0) for s in range(max(0, self.latest_seq - 2999), self.latest_seq + 1)] if self.latest_seq != -1 else [],
645645
"own_git_hash": self._own_git_hash,
646646
"car_git_hash": self._car_git_hash,
647647
"remote_ip": os.getenv("REMOTE_IP", "unknown"),

universal-telemetry-software/src/status_server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@
1919
logger = logging.getLogger("StatusServer")
2020

2121
PORT = int(os.getenv("STATUS_PORT", 8080))
22+
TOKEN_FILE = "/app/relay_token"
23+
24+
25+
def _read_relay_token() -> str:
26+
try:
27+
with open(TOKEN_FILE) as f:
28+
return f.read().strip()
29+
except FileNotFoundError:
30+
pass
31+
return os.getenv("RELAY_TOKEN", "")
2232
DIRECTORY = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/status"
2333
# SET_TIME_ENABLED must be explicitly set to "true" to allow the /set-time endpoint.
2434
# This prevents unauthenticated callers from modifying the host clock.
@@ -40,6 +50,14 @@ def end_headers(self):
4050
def log_message(self, format, *args):
4151
logger.info("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args))
4252

53+
def do_GET(self):
54+
if self.path == '/relay-info':
55+
token = _read_relay_token()
56+
port = int(os.getenv("RELAY_LISTEN_PORT", "9089"))
57+
self._json_response(200, {"token": token, "port": port, "enabled": True})
58+
return
59+
super().do_GET()
60+
4361
def do_OPTIONS(self):
4462
self.send_response(200)
4563
self.send_header('Access-Control-Allow-Origin', '*')
@@ -48,6 +66,9 @@ def do_OPTIONS(self):
4866
self.end_headers()
4967

5068
def do_POST(self):
69+
if self.path == '/relay-token':
70+
self._handle_relay_token()
71+
return
5172
if self.path == '/set-time':
5273
if not SET_TIME_ENABLED:
5374
self._json_response(403, {"error": "set-time is disabled (set SET_TIME_ENABLED=true)"})
@@ -85,6 +106,19 @@ def _handle_set_time(self):
85106
logger.error(f"/set-time error: {e}")
86107
self._json_response(500, {"error": str(e)})
87108

109+
def _handle_relay_token(self):
110+
try:
111+
length = int(self.headers.get('Content-Length', 0))
112+
body = json.loads(self.rfile.read(length))
113+
token = body.get('token', '').strip()
114+
with open(TOKEN_FILE, 'w') as f:
115+
f.write(token)
116+
logger.info("Relay token updated via UI")
117+
self._json_response(200, {"ok": True, "token": token})
118+
except Exception as e:
119+
logger.error(f"/relay-token error: {e}")
120+
self._json_response(500, {"error": str(e)})
121+
88122
def _json_response(self, code: int, payload: dict):
89123
body = json.dumps(payload).encode()
90124
self.send_response(code)

universal-telemetry-software/src/ws_relay.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@ def _env_bool(name: str, default: str = "false") -> bool:
3939
return os.getenv(name, default).lower() == "true"
4040

4141

42+
TOKEN_FILE = "/app/relay_token"
43+
44+
45+
def _get_live_token() -> str | None:
46+
try:
47+
with open(TOKEN_FILE) as f:
48+
val = f.read().strip()
49+
return val or None
50+
except FileNotFoundError:
51+
pass
52+
return os.getenv("RELAY_TOKEN") or None
53+
54+
4255
def _config() -> dict:
4356
return {
4457
"upstream": os.getenv("RELAY_UPSTREAM_WS", "ws://127.0.0.1:9080"),
4558
"listen_host": os.getenv("RELAY_LISTEN_HOST", "0.0.0.0"),
4659
"listen_port": int(os.getenv("RELAY_LISTEN_PORT", "9089")),
47-
"token": os.getenv("RELAY_TOKEN") or None,
4860
"require_token_on_lan": _env_bool("RELAY_REQUIRE_TOKEN_ON_LAN", "false"),
4961
"reconnect_min": float(os.getenv("RELAY_UPSTREAM_RECONNECT_MIN", "1")),
5062
"reconnect_max": float(os.getenv("RELAY_UPSTREAM_RECONNECT_MAX", "30")),
@@ -98,11 +110,10 @@ def _reject_401() -> Response:
98110
return Response(401, "Unauthorized", Headers(), b"missing or invalid token\n")
99111

100112

101-
def _make_process_request(relay_token: str | None, require_on_lan: bool):
113+
def _make_process_request(get_token, require_on_lan: bool):
102114
async def process_request(connection: ServerConnection, request) -> Response | None:
103-
host = ""
104-
if connection.remote_address:
105-
host = connection.remote_address[0]
115+
host = connection.remote_address[0] if connection.remote_address else ""
116+
relay_token = get_token()
106117
if not token_required_for_peer(host, relay_token, require_on_lan):
107118
return None
108119
provided = _token_from_request_path(request.path)
@@ -119,7 +130,6 @@ async def run_ws_relay(heartbeat_event=None) -> None:
119130
upstream_uri = cfg["upstream"]
120131
host = cfg["listen_host"]
121132
port = cfg["listen_port"]
122-
relay_token = cfg["token"]
123133
require_on_lan = cfg["require_token_on_lan"]
124134
backoff_min = cfg["reconnect_min"]
125135
backoff_max = cfg["reconnect_max"]
@@ -129,7 +139,7 @@ async def run_ws_relay(heartbeat_event=None) -> None:
129139
utils.register_shutdown_signals(loop, shutdown_event, "WS relay")
130140

131141
connected_clients: set = set()
132-
process_request = _make_process_request(relay_token, require_on_lan)
142+
process_request = _make_process_request(_get_live_token, require_on_lan)
133143

134144
async def downstream_handler(connection: ServerConnection) -> None:
135145
peer = connection.remote_address
@@ -203,7 +213,7 @@ async def upstream_loop() -> None:
203213
host,
204214
port,
205215
upstream_uri,
206-
"set" if relay_token else "off",
216+
"set" if _get_live_token() else "off",
207217
require_on_lan,
208218
)
209219

0 commit comments

Comments
 (0)