diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6962ea9..aacf83c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,9 @@ pip install pytest python -m pytest # fast, no GStreamer/Docker needed (tests/ uses a stubbed gi) ``` -CI runs the same suite plus `bash -n` on every shell script and a docs-link check -([.github/workflows/ci.yml](.github/workflows/ci.yml)). These tests exist to protect +CI runs the same suite plus `node --test tests/js/*.test.js` and `bash -n` on every +shell script ([.github/workflows/ci.yml](.github/workflows/ci.yml)). There is no +automated docs-link check yet — if you touch docs, click through the links you changed. These tests exist to protect the hard-won media core (the restamp/conform behaviour in [`tools/contribution_core.py`](tools/contribution_core.py)) from well-meaning cleanup. **If a test fails, re-read the cited FINDINGS section before changing the test** — the diff --git a/README.md b/README.md index 115be57..cc27718 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ You get a cuttable, keyed, browser-watchable MXL switcher: - ✓ Test-pattern source + file playout - ✓ HTML5 lower-third graphics keyer - ✓ WebRTC program output (watch in any browser) -- ✓ **Two SRT contribution slots** — point Larix / OBS / vMix at `srt://YOUR-IP:8890` +- ✓ **Two SRT contribution slots** — point Larix / OBS / vMix at `srt://YOUR-IP:8890` (Guest 1) or `srt://YOUR-IP:8891` (Guest 2), Caller mode, no stream ID - ✓ **A browser switcher** — preview/program with TAKE + a live multiview of every source Then check it's healthy and cut between sources: diff --git a/SECURITY.md b/SECURITY.md index 17772b9..5ee1f99 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,13 +28,18 @@ down. In particular: that browser's localStorage). Set `MXL_CONTROL_REQUIRE_TOKEN=1` to fail closed (503) if the token is ever missing. `/repair` is also rate limited (default 10 per 60 s per client; tune with `MXL_REPAIR_RATE_MAX` / `MXL_REPAIR_RATE_WINDOW_S`, `0` disables). A shared + **Behind a reverse proxy / tunnel, set `MXL_TRUST_PROXY=1`** so limits key on the real + client (`CF-Connecting-IP` / `X-Forwarded-For`); it is OFF by default because on a + directly exposed server those headers are client-controlled. A shared token is a speed bump, not a substitute for network controls — still keep the VM control ports behind a firewall/NSG. Always serve the backend over HTTPS when a token is in use. -- **The SRT guest slots accept any publisher** that knows the stream id — that's the - point (open contribution), but it means anyone who learns `srt://your-ip:8890` + - `publish:guest1` can push video. Rotate/scope stream ids for anything beyond a demo. - **Optional hardening (off by default):** run the quickstart with a per-guest SRT - passphrase and mediamtx will reject any publisher on that slot that doesn't encrypt with it: +- **The SRT guest slots accept any publisher** — that's the point (open contribution). + In the default `srt-listen` transport each guest owns a UDP port (`8890` = Guest 1, + `8891` = Guest 2) and anyone who can reach that port can push video; the passphrase + options below are **not applied** in this mode, so restrict UDP 8890/8891 to known + source IPs in your cloud firewall for anything beyond a demo. + **Optional hardening (off by default, `MXL_GUEST_TRANSPORT=srt-direct` only):** run the + quickstart with a per-guest SRT passphrase and mediamtx will reject any publisher on that slot that doesn't encrypt with it: ```bash sudo MXL_GUEST1_SRT_PASSPHRASE='long-secret-for-guest-1' \ MXL_GUEST2_SRT_PASSPHRASE='different-secret-guest-2' scripts/quickstart.sh diff --git a/backend/mxl-auth.js b/backend/mxl-auth.js index 381ba70..a7791c4 100644 --- a/backend/mxl-auth.js +++ b/backend/mxl-auth.js @@ -33,14 +33,20 @@ function tokenFromRequest(req) { } // Identify the real client. Behind cloudflared (or most CDNs) req.ip is the tunnel's -// address, so every visitor would share one rate-limit bucket (Review R6a). Prefer the -// CDN's real-client header when present: CF-Connecting-IP (Cloudflare) or the first hop -// of X-Forwarded-For. These are only trustworthy when the request genuinely comes -// through your proxy — which is the intended deployment (the control UI sits behind the -// tunnel) — so this is opt-out via MXL_TRUST_PROXY_HEADERS=0 for a direct-exposure case. +// address, so every visitor would share one rate-limit bucket (Review R6a). When the +// server sits behind a TRUSTED proxy, opt in to the CDN's real-client header: +// CF-Connecting-IP (Cloudflare) or the first hop of X-Forwarded-For. +// +// DEFAULT OFF: those headers are client-controlled. On a directly exposed server an +// attacker could send a fresh X-Forwarded-For on every request, get a new bucket each +// time, and sidestep both the /repair limiter and the failed-auth lockout. Opt in with +// MXL_TRUST_PROXY=1 (alias: MXL_TRUST_PROXY_HEADERS=1) ONLY when every request really +// arrives through your proxy (e.g. a cloudflared tunnel to a localhost-bound backend). +function _truthy(v) { + return ['1', 'true', 'yes', 'on'].includes(String(v ?? '').trim().toLowerCase()); +} function clientKeyFactory(env = process.env) { - const trustHeaders = String(env.MXL_TRUST_PROXY_HEADERS ?? '1').toLowerCase() !== '0' - && String(env.MXL_TRUST_PROXY_HEADERS ?? '1') !== 'false'; + const trustHeaders = _truthy(env.MXL_TRUST_PROXY) || _truthy(env.MXL_TRUST_PROXY_HEADERS); return function _clientKey(req) { const h = req.headers || {}; if (trustHeaders) { diff --git a/companion-module-mxl-switcher/main.js b/companion-module-mxl-switcher/main.js index d524994..665239f 100644 --- a/companion-module-mxl-switcher/main.js +++ b/companion-module-mxl-switcher/main.js @@ -50,7 +50,7 @@ class MXLSwitcherInstance extends InstanceBase { id: 'baseurl', label: 'Switcher base URL', width: 8, - default: 'https://prodbots.com', + default: 'http://127.0.0.1:3100', }, { type: 'textinput', diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5e5d263..7f9af5c 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -34,8 +34,8 @@ and Node loaders both honour them): | `MXL_SSH_KEY` | SSH private key | **required** | | `MXL_SITE_IP` | the one source IP the VM's NSG allows (only used in an error message) | `203.0.113.50` (placeholder; set `MXL_SITE_IP`) | | `MXL_MAKITO_IP` | CAM 2 Makito X4 encoder (informational) | `192.168.8.177` | -| `MXL_BACKEND_URL` | facility backend base URL (kiosk page + `/api/mxl/*`); also exported to the VM-side `run-cam*.sh` as `MXL_REPAIR_URL=$MXL_BACKEND_URL/api/mxl/repair` | `https://prodbots.com` | -| `MXL_FEED_URL` | public WebRTC feed tunnel | `https://mxl-feed.cochran.cloud` | +| `MXL_BACKEND_URL` | facility backend base URL (kiosk page + `/api/mxl/*`); also exported to the VM-side `run-cam*.sh` as `MXL_REPAIR_URL=$MXL_BACKEND_URL/api/mxl/repair` | `http://127.0.0.1:3100` (the local control plane — set it for a real facility) | +| `MXL_FEED_URL` | public WebRTC feed tunnel | `http://127.0.0.1:8889` (local mediamtx — set it for a real facility) | | `MXL_AZ_RESOURCE_GROUP`, `MXL_AZ_VM_NAME` | `az vm start/deallocate` target | **required** (both) | | `MXL_HTML` | path of the deployed kiosk page | `$HOME/prodbots-backend/public/mxl.html` | @@ -43,7 +43,7 @@ and Node loaders both honour them): | Variable | Used by | Default | |---|---|---| -| `MXL_BACKEND_URL` | `audio_pgm.py`, `layout_pgm.py`, `mxl_multiview.py`, `nmos_node.py` (`--facility`), `selector-doctor.sh`, `guest-leg-doctor.sh` | `https://prodbots.com` | +| `MXL_BACKEND_URL` | `audio_pgm.py`, `layout_pgm.py`, `mxl_multiview.py`, `selector-doctor.sh`, `guest-leg-doctor.sh`, `nmos_node.py` (`--facility`) | `http://127.0.0.1:3100` (the local control plane). `nmos_node.py` still carries the original facility default for now — set `MXL_BACKEND_URL` / `--facility` explicitly | | `TAMS_HOST` | `tams_shipper.py`, `backfill-mini.py` — the TAMS/MinIO box | `203.0.113.140` (placeholder; set `TAMS_HOST`) | | `TAMS_URL` | `tams_shipper.py` TAMS API (pre-existing) | `http://$TAMS_HOST:8000` | | `TAMS_S3_ENDPOINT` | `tams_shipper.py`, `backfill-mini.py` MinIO endpoint | `http://$TAMS_HOST:9000` | @@ -74,7 +74,7 @@ report-only and is separate from this watcher. | `MXL_CONTROL_REQUIRE_TOKEN` | same — `1` = answer 503 if no token configured (fail closed) | unset | | `MXL_REPAIR_RATE_MAX`, `MXL_REPAIR_RATE_WINDOW_S` | same — `/repair` calls per window per client (`0` = no limit) | `10`, `60` | | `MXL_AUTH_FAIL_MAX`, `MXL_AUTH_FAIL_WINDOW_S` | `backend/mxl-auth.js` — failed-auth attempts per client before a `429` lockout (throttles brute-forcing the shared token; `0` = no limit) | `20`, `60` | -| `MXL_TRUST_PROXY_HEADERS` | `backend/mxl-auth.js` — use `CF-Connecting-IP` / `X-Forwarded-For` as the rate-limit client key (so visitors behind the tunnel get separate buckets, not the tunnel's one IP). Set `0` only if the server is exposed directly, not behind a trusted proxy | `1` (on) | +| `MXL_TRUST_PROXY` (alias `MXL_TRUST_PROXY_HEADERS`) | `backend/mxl-auth.js` — `1` = use `CF-Connecting-IP` / `X-Forwarded-For` as the rate-limit / failed-auth client key (so visitors behind a tunnel get separate buckets, not the tunnel's one IP). Set it **only** when every request arrives through a trusted proxy (e.g. a cloudflared tunnel to a localhost-bound backend): on a directly exposed server those headers are client-controlled and would let a caller dodge the throttles | unset (off) | | `MXL_GUEST1_SRT_PASSPHRASE`, `MXL_GUEST2_SRT_PASSPHRASE`, `MXL_GUEST_SRT_PASSPHRASE` | `scripts/quickstart.sh` — require an SRT passphrase to publish on a guest slot | unset = open | | `MXL_GRAPHICS_BIND` | `scripts/quickstart.sh` — bind address of the `:8085` graphics server | docker bridge gateway (e.g. `172.17.0.1`); `0.0.0.0` = old behaviour | @@ -97,6 +97,13 @@ and therefore must present the token once the backend enforces it. Each of them ## Not (yet) configurable Intentionally left as literals — flagged for a follow-up, not changed here: the `/home/guy/...` -paths in `guest-leg-doctor.sh`, the static web pages' own hostnames (`web/*.html`: -`mxl-feed.cochran.cloud`, `mxlinfo.cochran.cloud`, canonical/og URLs), the exported Companion -page (`*.companionconfig`) and the Companion module's editable default host. +paths in `guest-leg-doctor.sh`, the backend host in `web/lower-third.html` (it runs inside the +keyer, not same-origin with the backend), canonical/og URLs in the static pages, and the +exported Companion page (`*.companionconfig`). + +Web pages: `web/commentary.html` posts WHIP to the same-origin `/mxlfeed/` proxy (make +sure your reverse proxy forwards WHIP `POST`/`PATCH`/`DELETE` there); the kiosk's DOMAIN +link in `web/mxl.html` points at the serving host on `:9608`; its Larix QR codes come +live from `GET /api/mxl/ingest/qr/guestN.png` (hidden if the backend doesn't serve it). +Neither page takes a host override from the URL, by design. +The Companion module's editable base URL defaults to `http://127.0.0.1:3100`. diff --git a/docs/GCP-BUILD-PLAN.md b/docs/GCP-BUILD-PLAN.md index 5d4a415..e7f52fa 100644 --- a/docs/GCP-BUILD-PLAN.md +++ b/docs/GCP-BUILD-PLAN.md @@ -180,7 +180,7 @@ worth knowing before you try): `/dev/shm`. Pulled a full clean 1080p frame from the domain flow (`gcp-real-ptz-on-gcp.jpg`). - **Makito X4 Cam 2 (192.168.8.177)** repointed Azure→GCP via its REST API - (`PUT /apis/streams/2`, address→34.83.212.72, latency 200) — held **13+ min + (`PUT /apis/streams/2`, address→203.0.113.10, latency 200) — held **13+ min stable, 12.7 Mbps, 1 reconnection**. (Stream 1 "Extra Hours SRT"=YouTube, untouched.) - **SRT latency for WAN:** the Azure 20ms (tuned for ~6ms studio↔VM LAN) was too tight for the studio→GCP-Oregon internet hop; **200ms** made both feeds hold. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index f659fe5..8072f94 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -25,8 +25,10 @@ upstream with `MXL_BLEEDING_EDGE=1`). (the CEF-based keyer alone is 3.3 GB), so the image pull dominates the run time. - **Cloud firewall / NSG open:** `8889/tcp` (WebRTC page + signaling), - `8189/udp` (WebRTC media), and `8890/udp` (SRT guest contribution — the phone - slots are now part of the quickstart, so open this too). The unauthenticated + `8189/udp` (WebRTC media), and **both** `8890/udp` + `8891/udp` (SRT guest + contribution — one listener port per guest in the default `srt-listen` transport: + 8890 = Guest 1, 8891 = Guest 2). Only `8890/udp` is needed if you opt into + `MXL_GUEST_TRANSPORT=srt-direct`. The unauthenticated media-function **control APIs (`9600`–`9605`) bind to `127.0.0.1` only** — drive them from the box (`curl 127.0.0.1:…`) or over an SSH tunnel; never expose them. @@ -107,9 +109,16 @@ The quickstart arms two SRT guest slots. Point any SRT encoder — the free **Larix Broadcaster** app, OBS, or vMix — at the publish point from the banner: ``` -srt://:8890 streamid: publish:guest1 (or publish:guest2) +srt://:8890 (Guest 1) srt://:8891 (Guest 2) +Mode: Caller · Stream ID: leave blank — the port picks the guest ``` +That's the default `srt-listen` transport (the ingest itself is the SRT listener, one +UDP port per guest — open both in your firewall). If you opted into +`MXL_GUEST_TRANSPORT=srt-direct`, both guests share one port and are told apart by +stream id instead: `srt://:8890` with streamid `publish:guest1` (or +`publish:guest2`). + The watcher attaches the slot automatically the moment the stream connects; then cut to it: @@ -118,8 +127,9 @@ cut to it: curl -X POST -H 'Content-Type: application/json' -d '{"slot":2}' http://127.0.0.1:9604/pipeline/active-input ``` -Larix note: free Larix needs the stream id as its dedicated `srtstreamid` field -(not baked in the URL). The banner prints a scannable QR with the exact scheme. +Larix note (srt-direct only): free Larix needs the stream id as its dedicated +`srtstreamid` field (not baked in the URL). In either mode the browser UI's +**Add your camera** button shows a scannable QR for this box with the exact scheme. **Slot numbers are stable.** Guest 1 is always slot 2 and Guest 2 always slot 3 — whether or not the other is streaming. An absent guest's slot is held by a safe diff --git a/scripts/bring-up-mxl.sh b/scripts/bring-up-mxl.sh index ced9cb3..df379a4 100755 --- a/scripts/bring-up-mxl.sh +++ b/scripts/bring-up-mxl.sh @@ -31,9 +31,9 @@ VM_USER=${MXL_VM_SSH_USER:-guy} # SSH user on the VM SSH_KEY=$MXL_SSH_KEY SITE_IP=${MXL_SITE_IP:-203.0.113.50} # placeholder (RFC 5737); your NSG-allowed source IP — set MXL_SITE_IP MAKITO_IP=${MXL_MAKITO_IP:-192.168.8.177} # CAM 2 Makito X4 encoder (informational) -BACKEND_URL=${MXL_BACKEND_URL:-https://prodbots.com} # facility backend (kiosk + /api/mxl/*) +BACKEND_URL=${MXL_BACKEND_URL:-http://127.0.0.1:3100} # facility backend (kiosk + /api/mxl/*) — set MXL_BACKEND_URL for a real facility BACKEND_URL=${BACKEND_URL%/} -FEED_URL=${MXL_FEED_URL:-https://mxl-feed.cochran.cloud} # public WebRTC feed tunnel +FEED_URL=${MXL_FEED_URL:-http://127.0.0.1:8889} # public WebRTC feed (tunnel) — set MXL_FEED_URL for a real facility AZ_RG=$MXL_AZ_RESOURCE_GROUP AZ_VM=$MXL_AZ_VM_NAME SSH="ssh -i $SSH_KEY -o BatchMode=yes -o ConnectTimeout=8 $VM_USER@$VM_IP" @@ -99,7 +99,7 @@ for p in 9600 9601 9602 9603 9604 9605; do # 9606 = parked TG2, don't wait on i for i in $(seq 1 30); do curl -s -m 2 -o /dev/null http://127.0.0.1:$p/pipeline/status && break; sleep 2; done done # mxl-info-gui needs a domain_def.json to recognize the domain; /dev/shm is -# volatile so recreate it every bring-up (2026-09-13; GUI at mxlinfo.cochran.cloud) +# volatile so recreate it every bring-up (2026-09-13; GUI on :9608) echo '{"id":"domain_1"}' | sudo tee /dev/shm/mxl/domain_1/domain_def.json >/dev/null # hands-on extras (mxl-info-gui :9608, spx-server :5660, webrtc2mxl :9609) # are restart-unless-stopped docker containers — nothing to relaunch here @@ -175,7 +175,7 @@ post 9603/pipeline/stop '{}' # The live OHG facility opts INTO the ProdBots repair announce explicitly — the # contribution core now defaults to NO announce (open-core boundary), so this env # var is what keeps the selector auto-reattaching the cam here. (See contribution_core.py.) -sudo docker exec hls2mxl sh -c 'printf "#!/bin/sh\nexport MXL_REPAIR_URL=%s/api/mxl/repair\nwhile :; do python3 /tmp/cam_ingest.py >> /tmp/cam-ingest.log 2>&1; echo RESTART >> /tmp/cam-ingest.log; sleep 2; done\n" "$0" > /tmp/run-cam1.sh && chmod +x /tmp/run-cam1.sh' "${MXL_BACKEND_URL:-https://prodbots.com}" +sudo docker exec hls2mxl sh -c 'printf "#!/bin/sh\nexport MXL_REPAIR_URL=%s/api/mxl/repair\nwhile :; do python3 /tmp/cam_ingest.py >> /tmp/cam-ingest.log 2>&1; echo RESTART >> /tmp/cam-ingest.log; sleep 2; done\n" "$0" > /tmp/run-cam1.sh && chmod +x /tmp/run-cam1.sh' "$BACKEND_URL" sudo docker exec -d hls2mxl /tmp/run-cam1.sh sleep 6 @@ -188,7 +188,7 @@ sleep 6 # (slices=4 broke mediamtx's TS parsing). Runner keeps cmdline pkill-safe. sudo docker cp /srv/mxl-tools/cam2_ingest.py hls2mxl:/tmp/cam2_ingest.py sudo docker exec hls2mxl sh -c 'pkill -f run-cam2.sh; pkill -f cam2_ingest.py; true' -sudo docker exec hls2mxl sh -c 'printf "#!/bin/sh\nexport MXL_REPAIR_URL=%s/api/mxl/repair\nwhile :; do nice -n 10 python3 /tmp/cam2_ingest.py >> /tmp/cam2-ingest.log 2>&1; echo RESTART >> /tmp/cam2-ingest.log; sleep 2; done\n" "$0" > /tmp/run-cam2.sh && chmod +x /tmp/run-cam2.sh' "${MXL_BACKEND_URL:-https://prodbots.com}" +sudo docker exec hls2mxl sh -c 'printf "#!/bin/sh\nexport MXL_REPAIR_URL=%s/api/mxl/repair\nwhile :; do nice -n 10 python3 /tmp/cam2_ingest.py >> /tmp/cam2-ingest.log 2>&1; echo RESTART >> /tmp/cam2-ingest.log; sleep 2; done\n" "$0" > /tmp/run-cam2.sh && chmod +x /tmp/run-cam2.sh' "$BACKEND_URL" sudo docker exec -d hls2mxl /tmp/run-cam2.sh sleep 6 @@ -227,7 +227,7 @@ pgrep -f "http.server 8086" >/dev/null || \ # Layout compositor (2-up / PiP, selector slot 6, added 2026-09-10): always-on, # all six sources behind two internal selectors -> compositor; layout changes # are live pad-property flips (no flow recreation -> no slot-6 wedges). Polls -# prodbots.com/api/mxl/layout-state for desired state (browser-UA header — +# $MXL_BACKEND_URL/api/mxl/layout-state for desired state (browser-UA header — # the CF zone 403s python-urllib). sudo docker cp /srv/mxl-tools/layout_pgm.py hls2mxl:/tmp/layout_pgm.py sudo docker exec hls2mxl sh -c 'pkill -9 -f run-layout.sh; pkill -9 -f layout_pgm.py; true' @@ -306,7 +306,7 @@ $SSH 'for u in ca111e00:Cam1 ca222e00:Cam2 1a900700:Layout; do uuid=${u%%:*}; nm step "Fresh layout output lock" $SSH 'sudo docker exec hls2mxl sh -c "pkill -9 -f \"[r]un-layout\"; pkill -9 -f \"[l]ayout_pgm.py\"; sleep 2"; sudo docker exec -d hls2mxl /tmp/run-layout.sh; echo " ✓ layout respawned"' -# ── 4. Feed tunnel (named: mxl-feed.cochran.cloud, systemd on the VM) ──────── +# ── 4. Feed tunnel (named tunnel serving $MXL_FEED_URL, systemd on the VM) ──── # Tunnel UUID e5ec74db-8e99-4232-b5a4-4e240f88572b; config /etc/cloudflared/mxl-feed.yml. # systemd auto-starts it on boot — this just makes sure and verifies. step "Ensuring mxl-feed named tunnel on VM" diff --git a/tests/js/mxl-auth.test.js b/tests/js/mxl-auth.test.js index a322972..4dd8f38 100644 --- a/tests/js/mxl-auth.test.js +++ b/tests/js/mxl-auth.test.js @@ -99,21 +99,39 @@ test('rate limiter: max=0 disables; env parsing falls back to defaults', () => { // ── R6a: real-client keying behind a proxy (CF-Connecting-IP / X-Forwarded-For) ── const { clientKeyFactory } = require(path.join(__dirname, '..', '..', 'backend', 'mxl-auth.js')); -test('R6a: clientKey prefers CF-Connecting-IP, then XFF, then req.ip', () => { - const key = clientKeyFactory({}); +test('R6a: clientKey (proxy trust ON) prefers CF-Connecting-IP, then XFF, then req.ip', () => { + const key = clientKeyFactory({ MXL_TRUST_PROXY: '1' }); assert.strictEqual(key({ ip: '10.0.0.1', headers: { 'cf-connecting-ip': '203.0.113.9' } }), '203.0.113.9'); assert.strictEqual(key({ ip: '10.0.0.1', headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.1' } }), '198.51.100.7'); assert.strictEqual(key({ ip: '10.0.0.1', headers: {} }), '10.0.0.1'); }); -test('R6a: MXL_TRUST_PROXY_HEADERS=0 ignores the headers (direct-exposure case)', () => { - const key = clientKeyFactory({ MXL_TRUST_PROXY_HEADERS: '0' }); +test('proxy headers are IGNORED by default (direct exposure: XFF is client-controlled)', () => { + const key = clientKeyFactory({}); assert.strictEqual(key({ ip: '10.0.0.1', headers: { 'cf-connecting-ip': '203.0.113.9' } }), '10.0.0.1'); + assert.strictEqual(key({ ip: '10.0.0.1', headers: { 'x-forwarded-for': '198.51.100.7' } }), '10.0.0.1'); +}); + +test('MXL_TRUST_PROXY_HEADERS=0 / unset keeps headers ignored; =1 is an alias for MXL_TRUST_PROXY=1', () => { + const req = { ip: '10.0.0.1', headers: { 'cf-connecting-ip': '203.0.113.9' } }; + assert.strictEqual(clientKeyFactory({ MXL_TRUST_PROXY_HEADERS: '0' })(req), '10.0.0.1'); + assert.strictEqual(clientKeyFactory({ MXL_TRUST_PROXY: '0' })(req), '10.0.0.1'); + assert.strictEqual(clientKeyFactory({ MXL_TRUST_PROXY_HEADERS: '1' })(req), '203.0.113.9'); + assert.strictEqual(clientKeyFactory({ MXL_TRUST_PROXY: 'true' })(req), '203.0.113.9'); +}); + +test('default (no proxy trust): rotating X-Forwarded-For cannot dodge the failed-auth lockout', () => { + const { middleware } = createAuth({ MXL_CONTROL_TOKEN: TOK, MXL_AUTH_FAIL_MAX: '3' }, quiet()); + let last; + for (let i = 0; i < 5; i++) { + last = run(middleware, { ip: '192.0.2.10', headers: { 'x-forwarded-for': `198.51.100.${i}`, 'x-mxl-token': 'wrong' } }); + } + assert.strictEqual(last.res.code, 429); }); test('R6a: the /repair limiter buckets per REAL client, not the shared tunnel IP', () => { // two clients behind the same tunnel (same req.ip) but different CF-Connecting-IP - const limit = rateLimiterFromEnv({ MXL_REPAIR_RATE_MAX: '2' }); + const limit = rateLimiterFromEnv({ MXL_REPAIR_RATE_MAX: '2', MXL_TRUST_PROXY: '1' }); const mk = (cf) => ({ ip: '172.17.0.1', headers: { 'cf-connecting-ip': cf } }); // client A: 2 ok then 429 assert.ok(run(limit, mk('1.1.1.1')).nexted); @@ -152,8 +170,8 @@ test('R6b: MXL_AUTH_FAIL_MAX=0 disables the throttle (always 401, never 429)', ( for (let i = 0; i < 30; i++) assert.strictEqual(run(middleware, bad).res.code, 401); }); -test('R6b: failed-auth throttle is per-client (CF-IP), so one attacker can\'t lock out others', () => { - const { middleware } = createAuth({ MXL_CONTROL_TOKEN: TOK, MXL_AUTH_FAIL_MAX: '2' }, quiet()); +test('R6b: failed-auth throttle is per-client (CF-IP, proxy trust ON), so one attacker can\'t lock out others', () => { + const { middleware } = createAuth({ MXL_CONTROL_TOKEN: TOK, MXL_AUTH_FAIL_MAX: '2', MXL_TRUST_PROXY: '1' }, quiet()); const attacker = { ip: '172.17.0.1', headers: { 'cf-connecting-ip': '6.6.6.6', 'x-mxl-token': 'wrong' } }; run(middleware, attacker); run(middleware, attacker); assert.strictEqual(run(middleware, attacker).res.code, 429); // attacker locked diff --git a/tests/test_env_config.py b/tests/test_env_config.py index 8cf942c..8163b54 100644 --- a/tests/test_env_config.py +++ b/tests/test_env_config.py @@ -1,5 +1,7 @@ -"""Env-var configuration: defaults == the historical production values (except the four -required bring-up-mxl.sh site vars, which have no default), overrides work, and every variable is documented in docs/CONFIG.md.""" +"""Env-var configuration: defaults are portable (backend/feed URLs default to the local +control plane / local mediamtx, never a specific facility; the four site vars in +bring-up-mxl.sh are required with no default), overrides work, and every variable is +documented in docs/CONFIG.md.""" import json import os import re @@ -73,8 +75,8 @@ def test_bring_up_required_vars_and_overrides(): r = _run_bring_up(**BRING_UP_REQUIRED) assert r.returncode == 0, r.stderr d = r.stdout.strip().split("|") - assert d[:8] == ["203.0.113.9", "guy", "203.0.113.50", "192.168.8.177", "https://prodbots.com", - "https://mxl-feed.cochran.cloud", "rg-test", "vm-test"] + assert d[:8] == ["203.0.113.9", "guy", "203.0.113.50", "192.168.8.177", "http://127.0.0.1:3100", + "http://127.0.0.1:8889", "rg-test", "vm-test"] assert d[8] == "ssh -i /h/.ssh/test-key -o BatchMode=yes -o ConnectTimeout=8 guy@203.0.113.9" o = _run_bring_up(**{**BRING_UP_REQUIRED, "MXL_VM_IP": "198.51.100.7", "MXL_VM_SSH_USER": "ops", "MXL_BACKEND_URL": "https://example.test/"}) @@ -110,22 +112,22 @@ def test_bring_up_required_vars_have_no_defaults(): "f'http://{TAMS_HOST}:9000'", "os.environ.get('TAMS_S3_USER', 'tams')"]), ("tools/backfill-mini.py", ["os.environ.get('TAMS_HOST','203.0.113.140')", "f'http://{_host}:9000'", "os.environ.get('TAMS_S3_USER','tams')"]), - ("tools/audio_pgm.py", ["os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com')"]), - ("tools/layout_pgm.py", ["os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com')"]), - ("tools/mxl_multiview.py", ["os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com')"]), + ("tools/audio_pgm.py", ["os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100')"]), + ("tools/layout_pgm.py", ["os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100')"]), + ("tools/mxl_multiview.py", ["os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100')"]), ("tools/mv_encode.py", ["os.environ.get('MXL_VM1_IP', '10.0.0.4')"]), ]) def test_tool_defaults_preserved(rel, needles): src = (ROOT / rel).read_text() for n in needles: assert n in src, f"{rel}: expected {n!r}" - assert "'https://prodbots.com/api" not in src, f"{rel}: backend URL literal crept back in" + assert "prodbots.com" not in src and "cochran.cloud" not in src, f"{rel}: facility host crept back in" def test_layout_pgm_urls_resolve_to_same_endpoints(): """BACKEND_URL + '/api/...' must reproduce the exact historical URLs.""" src = (ROOT / "tools" / "layout_pgm.py").read_text() - base = "https://prodbots.com" + base = "http://127.0.0.1:3100" paths = re.findall(r"BACKEND_URL \+ '(/api/mxl/[a-z-]+)'", src) assert {"/api/mxl/layout-state", "/api/mxl/input", "/api/mxl/fade-done", "/api/mxl/status", "/api/mxl/repair"} <= set(paths) @@ -140,7 +142,7 @@ def test_layout_pgm_urls_resolve_to_same_endpoints(): "MXL_MV_SRT_URL", "MXL_CONTROL_TOKEN", "MXL_CONTROL_REQUIRE_TOKEN", "MXL_REPAIR_RATE_MAX", "MXL_REPAIR_RATE_WINDOW_S", "MXL_GUEST1_SRT_PASSPHRASE", "MXL_GUEST2_SRT_PASSPHRASE", "MXL_GUEST_SRT_PASSPHRASE", "MXL_GRAPHICS_BIND", - "MXL_SELFHEAL", "MXL_SELFHEAL_INTERVAL", + "MXL_SELFHEAL", "MXL_SELFHEAL_INTERVAL", "MXL_TRUST_PROXY", "MXL_TRUST_PROXY_HEADERS", ] diff --git a/tests/test_no_hardcoded_hosts.py b/tests/test_no_hardcoded_hosts.py index c054f51..73bd5e8 100644 --- a/tests/test_no_hardcoded_hosts.py +++ b/tests/test_no_hardcoded_hosts.py @@ -10,15 +10,21 @@ The allowlist is a ratchet: an entry that no longer occurs in its file ALSO fails, so the list only ever shrinks as hardcodes get removed. """ +import ipaddress import re import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -# What we scan: code + config, not prose docs, static marketing pages, lockfiles, tests. -SCAN_DIRS = ("backend/", "scripts/", "tools/", "config/", "docker/", "companion-module-mxl-switcher/main.js") -SKIP_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".mp4", ".md", ".lock", ".companionconfig") +# What we scan for site hostnames + IP literals: code, config, and the served web pages. +# Not prose docs, static marketing pages, lockfiles, tests, or vendored third-party JS. +# (Prose docs and the web pages are ALSO covered by the public-IPv4 sweep further down.) +SCAN_DIRS = ("backend/", "scripts/", "tools/", "config/", "docker/", "web/", + "companion-module-mxl-switcher/main.js") +SKIP_PREFIXES = ("web/vendor/",) +SKIP_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".mp4", ".md", ".lock", ".companionconfig", + ".woff", ".woff2", ".ico", ".svg") SKIP_NAMES = {"package-lock.json"} IPV4 = re.compile(r"(? literals permitted there (default values behind env vars, docs-in-comments, ...) ALLOW = { "config/facility.json": {"10.0.0.5"}, # manifest defaults; MXL_VM_IP etc. override - "scripts/bring-up-mxl.sh": {"203.0.113.50", "192.168.8.177", "prodbots.com", "cochran.cloud"}, # env defaults (MXL_*); site IP is an RFC 5737 placeholder + "scripts/bring-up-mxl.sh": {"203.0.113.50", "192.168.8.177"}, # env defaults (MXL_*); site IP is an RFC 5737 placeholder "scripts/mxl-doctor": {"10.0.0.4", "prodbots.com"}, # comment only "tools/adapters.py": {"10.0.0.5"}, # default rtsp_host arg (pinned by launch-parity test) - "tools/audio_pgm.py": {"prodbots.com"}, # MXL_BACKEND_URL default - "tools/layout_pgm.py": {"prodbots.com"}, # MXL_BACKEND_URL default - "tools/mxl_multiview.py": {"prodbots.com"}, # MXL_BACKEND_URL default (+ docstring) - "tools/nmos_node.py": {"prodbots.com"}, # MXL_BACKEND_URL default (+ usage) + "tools/nmos_node.py": {"prodbots.com"}, # --facility default (+ usage); follow-up once the IS-05 work lands "tools/contribution_core.py": {"prodbots.com"}, # comment example for MXL_REPAIR_URL - "tools/mv_encode.py": {"10.0.0.4", "cochran.cloud"}, # MXL_VM1_IP default (+ docstring) - "tools/guest-leg-doctor.sh": {"10.0.0.4", "10.0.0.5", "prodbots.com"}, # MXL_VM1_IP/VM2_IP/BACKEND defaults - "tools/selector-doctor.sh": {"prodbots.com"}, # MXL_BACKEND_URL default + "tools/mv_encode.py": {"10.0.0.4"}, # MXL_VM1_IP default + "tools/guest-leg-doctor.sh": {"10.0.0.4", "10.0.0.5"}, # MXL_VM1_IP/VM2_IP defaults "scripts/quickstart.sh": {"mxlswitcher.com"}, # comment only "tools/start-jonas-leg.sh": {"10.0.0.4"}, # MXL_VM1_IP default "tools/tams_shipper.py": {"203.0.113.140"}, # TAMS_HOST default (RFC 5737 placeholder) "tools/backfill-mini.py": {"203.0.113.140"}, # TAMS_HOST default (RFC 5737 placeholder) - "companion-module-mxl-switcher/main.js": {"prodbots.com"}, # user-editable config field default + "web/mxl.html": {"203.0.113.32"}, # guest SRT host shown to visitors (RFC 5737 placeholder) + "web/lower-third.html": {"prodbots.com"}, # keyer graphic polls the backend cross-origin; follow-up + "web/mxl-archive.html": {"mxlswitcher.com"}, # links to the public project/docs site + "web/decks/deck-live.html": {"mxlswitcher.com"}, # links to the public project/docs site + "web/decks/deck-recap.html": {"mxlswitcher.com"}, # links to the public project/docs site } def _tracked(): out = subprocess.run(["git", "-C", str(ROOT), "ls-files"], capture_output=True, text=True, check=True).stdout for rel in out.splitlines(): - if rel in SKIP_NAMES or rel.endswith(SKIP_SUFFIXES): + if rel in SKIP_NAMES or rel.endswith(SKIP_SUFFIXES) or rel.startswith(SKIP_PREFIXES): continue if not rel.startswith(SCAN_DIRS): continue @@ -96,3 +102,74 @@ def test_scanner_catches_a_new_literal(tmp_path): assert not IPV4.search("version 1.5.15") assert not IPV4.search("v1.2.3.4.5") assert HOSTS.search("https://prodbots.com/api") + + +# ── Public-IPv4 sweep: EVERY tracked text file (docs, README, web pages, code) ───────── +# The per-file allowlist above guards code/config. This sweep is broader and simpler: no +# internet-routable IPv4 literal may appear anywhere in the tracked tree. Allowed ranges: +# private (RFC 1918), loopback, link-local, unspecified, shared CGNAT, and the RFC 5737 +# documentation ranges used as placeholders (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24). +# tests/ is excluded (it uses fake public client IPs on purpose) and so is vendored JS. +SAFE_NETS = [ipaddress.ip_network(n) for n in ( + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", # RFC 1918 private + "127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8", # loopback, link-local, unspecified + "100.64.0.0/10", # shared address space (CGNAT) + "192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", # RFC 5737 documentation placeholders + "255.255.255.255/32", +)] +# Public literals that are genuinely meant to be here (well-known public resolvers etc.). +# Keep this empty unless there is a real reason; facility addresses never belong here. +PUBLIC_IPV4_ALLOW = set() +SWEEP_SKIP_PREFIXES = ("tests/", "web/vendor/", ".git/") +BINARY_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".mp4", ".webm", ".ico", ".woff", ".woff2", + ".ttf", ".otf", ".pdf", ".zip", ".gz") + + +def _is_safe_ipv4(lit): + ip = ipaddress.ip_address(lit) + return lit in PUBLIC_IPV4_ALLOW or any(ip in n for n in SAFE_NETS) + + +def _public_ipv4_hits(): + out = subprocess.run(["git", "-C", str(ROOT), "ls-files"], capture_output=True, text=True, check=True).stdout + hits = {} + for rel in out.splitlines(): + if rel.startswith(SWEEP_SKIP_PREFIXES) or rel.lower().endswith(BINARY_SUFFIXES): + continue + try: + text = (ROOT / rel).read_text(errors="ignore") + except OSError: + continue + bad = {m.group(0) for m in IPV4.finditer(text) if not _is_safe_ipv4(m.group(0))} + if bad: + hits[rel] = sorted(bad) + return hits + + +def test_no_public_ipv4_anywhere_in_tracked_text(): + hits = _public_ipv4_hits() + assert not hits, ( + "Internet-routable IPv4 literal(s) in tracked files — replace with an RFC 5737 placeholder " + "(e.g. 203.0.113.10) or an env var:\n " + "\n ".join(f"{k}: {v}" for k, v in sorted(hits.items()))) + + +def test_ipv4_sweep_self_test(): + """The sweep must flag a routable address and pass the placeholder/private ranges.""" + assert not _is_safe_ipv4("8.8.4.4") + assert not _is_safe_ipv4("20.1.2.3") + for ok in ("203.0.113.10", "198.51.100.7", "192.0.2.1", "10.0.0.4", "172.17.0.1", + "192.168.8.177", "127.0.0.1", "0.0.0.0", "169.254.1.1"): + assert _is_safe_ipv4(ok), ok + + +# ── No static QR codes in the served web tree ─────────────────────────────────────────── +# A QR image encodes its address in pixels, so the text scans above can't see inside it. +# Static Larix QR PNGs once baked a real facility address into web/. The served pages now +# fetch a per-box QR from GET /api/mxl/ingest/qr/guestN.png at runtime, so no QR image +# should ever be committed under web/ again. +def test_no_static_qr_images_in_web(): + out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "web/"], capture_output=True, text=True, check=True).stdout + qr = [rel for rel in out.splitlines() + if rel.lower().endswith(BINARY_SUFFIXES + (".svg",)) and re.search(r"(^|[/_.-])qr", rel.lower())] + assert not qr, ("Static QR image(s) committed under web/ — serve them from " + "/api/mxl/ingest/qr/.png instead: " + ", ".join(qr)) diff --git a/tools/audio_pgm.py b/tools/audio_pgm.py index 9482ee3..c5f397f 100644 --- a/tools/audio_pgm.py +++ b/tools/audio_pgm.py @@ -27,7 +27,7 @@ gi.require_version('Gst', '1.0') from gi.repository import Gst, GLib -BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com').rstrip('/') # facility backend (docs/CONFIG.md) +BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100').rstrip('/') # facility backend (docs/CONFIG.md) STATE_URL = BACKEND_URL + '/api/mxl/audio-state' CAPS = 'audio/x-raw,format=F32LE,layout=interleaved,rate=48000,channels=2,channel-mask=(bitmask)0x3' MARGIN_NS = 66_000_000 diff --git a/tools/guest-leg-doctor.sh b/tools/guest-leg-doctor.sh index dbdf341..1099319 100644 --- a/tools/guest-leg-doctor.sh +++ b/tools/guest-leg-doctor.sh @@ -11,7 +11,7 @@ VM1_IP=${MXL_VM1_IP:-10.0.0.4} # fabric target host VM2_IP=${MXL_VM2_IP:-10.0.0.5} # this box's fabric address VM1_USER=${MXL_VM1_SSH_USER:-guy} -BACKEND_URL=${MXL_BACKEND_URL:-https://prodbots.com}; BACKEND_URL=${BACKEND_URL%/} +BACKEND_URL=${MXL_BACKEND_URL:-http://127.0.0.1:3100}; BACKEND_URL=${BACKEND_URL%/} TOKEN=$(grep -oP '^EASY_MXL_TOKEN=\K.*' /etc/default/easy-mxl) SSH_VM1="ssh -i /home/guy/.ssh/id_ed25519 -o BatchMode=yes -o ConnectTimeout=6 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/home/guy/.ssh/known_hosts $VM1_USER@$VM1_IP" declare -A cooldown diff --git a/tools/layout_pgm.py b/tools/layout_pgm.py index 7bee51d..afb8214 100644 --- a/tools/layout_pgm.py +++ b/tools/layout_pgm.py @@ -34,7 +34,7 @@ gi.require_version('Gst', '1.0') from gi.repository import Gst, GLib -BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com').rstrip('/') # facility backend (docs/CONFIG.md) +BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100').rstrip('/') # facility backend (docs/CONFIG.md) CMD_URL = BACKEND_URL + '/api/mxl/layout-state' INPUT_URL = BACKEND_URL + '/api/mxl/input' # fade choreography cuts DONE_URL = BACKEND_URL + '/api/mxl/fade-done' diff --git a/tools/mv_encode.py b/tools/mv_encode.py index 13d7d9c..e6c3a2d 100644 --- a/tools/mv_encode.py +++ b/tools/mv_encode.py @@ -4,7 +4,7 @@ Reads the "Multiview PGM" flow (written by mxl_multiview.py in the hls2mxl container) out of the MXL domain and publishes it to VM1 mediamtx over SRT as `publish:multiview` — the same contribution path guests use — so the wall -is viewable at mxl-feed.cochran.cloud/multiview/ (WebRTC) and the popout's +is viewable at /multiview/ (WebRTC) and the popout's /mxlfeed/multiview/ proxy. Runs in the mxl2webrtc container (the only one with x264enc). One encoder for 8 sources — that's the point of compositing in the domain first. diff --git a/tools/mxl_multiview.py b/tools/mxl_multiview.py index eda3e57..2ba7600 100644 --- a/tools/mxl_multiview.py +++ b/tools/mxl_multiview.py @@ -6,7 +6,7 @@ CPU into a 1920x1080 v210 canvas at 15fps, and written back to the domain as a new "Multiview PGM" flow (mxlsink). No decode of any contribution stream, no GPU. mv_encode.py (mxl2webrtc container) encodes this ONE flow for -browsers — the popout at prodbots.com/mxl-multiview.html. +browsers — the popout at /mxl-multiview.html. Grid (row-major, 640x360 tiles; bottom-right stays black): cam cam2 playout @@ -33,7 +33,7 @@ gi.require_version('Gst', '1.0') from gi.repository import Gst, GLib -BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'https://prodbots.com').rstrip('/') # facility backend (docs/CONFIG.md) +BACKEND_URL = os.environ.get('MXL_BACKEND_URL', 'http://127.0.0.1:3100').rstrip('/') # facility backend (docs/CONFIG.md) DST = 'ab900700-aaaa-4bbb-8ccc-000000000001' # Multiview PGM FPS = 15 # PRODUCTION SETTING — do not bump casually. 30fps was tried 9/12: # wall alone hit ~2.7 cores, box load reached 14/32 and the PROGRAM diff --git a/tools/selector-doctor.sh b/tools/selector-doctor.sh index 06fa001..84dca1f 100755 --- a/tools/selector-doctor.sh +++ b/tools/selector-doctor.sh @@ -12,7 +12,7 @@ # Runs as a systemd service on VM1 (host bash). Reads nothing; pure poller. set -u API=http://127.0.0.1:9604/pipeline/status -BACKEND="${MXL_BACKEND_URL:-https://prodbots.com}"; BACKEND="${BACKEND%/}/api/mxl/repair" # UA header dodges CF bot rule (MXL_BACKEND_URL: docs/CONFIG.md) +BACKEND="${MXL_BACKEND_URL:-http://127.0.0.1:3100}"; BACKEND="${BACKEND%/}/api/mxl/repair" # UA header dodges CF bot rule (MXL_BACKEND_URL: docs/CONFIG.md) UA='mxl-selector-doctor/1.0' FAILS=0 FAIL_LIMIT=3 # ~3 x (timeout+interval) ≈ 20s wedged before acting diff --git a/web/commentary.html b/web/commentary.html index 0b6d842..b297bf2 100644 --- a/web/commentary.html +++ b/web/commentary.html @@ -58,7 +58,10 @@

🎤 Your microphone

(function(){ // duo booth: ?slot=2 = second commentator (own path, own MXL flow, COM2 fader) var SLOT=new URLSearchParams(location.search).get('slot')==='2'?2:1; - var WHIP='https://mxl-feed.cochran.cloud/webrtc2mxl'+(SLOT===2?'2':'')+'/whip'; + // WHIP ingest via the same-origin /mxlfeed/ proxy (the multiview return above uses the + // same path). Deliberately NOT overridable from the URL: a crafted link must not be able + // to send a commentator's microphone to another host. + var WHIP='/mxlfeed/webrtc2mxl'+(SLOT===2?'2':'')+'/whip'; var MIXKEY=SLOT===2?'voice2':'voice'; if(SLOT===2){document.title='MXL Switcher — Commentary Booth 2'; document.querySelector('header .t').innerHTML='MXL Switcher · Commentary Booth · SEAT 2';} diff --git a/web/mxl.html b/web/mxl.html index 3617ea2..f07d9cf 100644 --- a/web/mxl.html +++ b/web/mxl.html @@ -129,7 +129,7 @@
● VM —LIVE · camera in U.S. studio
REC 📡 OUTPUT - 🔬 DOMAIN + 🔬 DOMAIN