openvibers/openrestream Commits
Serve WHIP ingest on ingest.openre.stream and document its cutover (#12)
Changes
@@@ -15,6 +15,8 @@## RTMP does NOT go through nginx or Cloudflare: ingest.openre.stream is a DNS-only (grey cloud)## A record to this host, and the rtmp-ingest workers listen on TCP 1936 themselves (README## "Port plan"). Open 1936/tcp in the host firewall and at the provider edge.++##++## ingest.openre.stream also has an HTTPS server here (WHIP + WebRTC signaling, last section).## ═══════════════════════════════════════════════════════════════limit_req_zone $binary_remote_addr zone=openre_api:10m rate=20r/s;@@@ -100,40 +102,59 @@ server {}## ═══════════════════════════════════════════════════════════════--## REFERENCE (not installed): WHIP + WebRTC signaling over HTTPS at ingest.openre.stream.--## Opus installs this (or an equivalent) at the WebRTC cutover. It terminates TLS for--## `ingest.openre.stream` and proxies to the webrtc worker's public port (OPENRE_WEBRTC_PORT,--## default 9936): WHIP POST/PATCH/DELETE under /whip/* and the WS signalling endpoints /b/* and--## /w/*. The worker builds `Location: https://<host>/whip/session/<id>` from the request Host, so--## set proxy_set_header Host. The certificate must cover ingest.openre.stream. The RTC media itself--## does NOT go through nginx: it is UDP 10200-10300 straight to this host, so MEDIASOUP_ANNOUNCED_IP--## must be the public address and 10200-10300/udp must be open at the provider edge (grey-cloud DNS;--## Cloudflare's proxy cannot carry raw UDP WebRTC).--##--## server {--## listen 443 ssl;--## listen [::]:443 ssl;--## http2 on;--## server_name ingest.openre.stream;--## ssl_certificate /etc/letsencrypt/live/ingest.openre.stream/fullchain.pem;--## ssl_certificate_key /etc/letsencrypt/live/ingest.openre.stream/privkey.pem;--## ssl_protocols TLSv1.2 TLSv1.3;++## >>> ingest.openre.stream — WHIP + WebRTC signaling over HTTPS (docs/cutover.md "WHIP / WebRTC++## ingest slot"). Until the openre.stream UI is launched (cutover B0 (a)), only this section is++## installed, as /etc/nginx/sites-available/ingest.openre.stream.conf:++## sed -n '/^## >>> ingest.openre.stream/,/^## <<< ingest.openre.stream/p' openre.stream.conf##--## client_max_body_size 256k;--##--## location / {--## proxy_pass http://127.0.0.1:9936;--## proxy_http_version 1.1;--## proxy_set_header Host $host;--## proxy_set_header X-Real-IP $remote_addr;--## proxy_set_header X-Forwarded-For $remote_addr;--## proxy_set_header X-Forwarded-Proto $scheme;--## # WebSocket upgrade for /b/<key> and /w/<session id>--## proxy_set_header Upgrade $http_upgrade;--## proxy_set_header Connection $connection_upgrade;--## proxy_read_timeout 12h;--## send_timeout 12h;--## }--## }--## (Add `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` in http{} once.)++## It terminates TLS for ingest.openre.stream and proxies to the webrtc worker's public port++## (OPENRE_WEBRTC_PORT=9936, unit openre-webrtc@<sha>): WHIP POST/PATCH/DELETE under /whip/* and the++## WebSocket signalling endpoints /b/<key> and /w/<session id>. The worker builds++## `Location: https://<host>/whip/session/<id>` from the request Host, so keep `Host $host`.++## Certificate: the *.openre.stream wildcard at /etc/letsencrypt/live/openre.stream/ covers++## ingest.openre.stream; check it is there (certbot certificates) before enabling, nginx -t fails++## without it. The RTC media does NOT go through nginx or Cloudflare: it is UDP 10200-10300++## straight to this host (grey-cloud DNS), so MEDIASOUP_ANNOUNCED_IP must be the public address and++## 10200-10300/udp must be open at the provider edge.++## Ingest keys are in the path (/whip/<key>, /b/<key>) and are never logged, so there is no access++## log and the error log keeps only crit (an upstream error line carries the request line).## ═══════════════════════════════════════════════════════════════++++# http{} scope (sites-enabled is included there). Defined once in this file; an identical map in++# another site's file is harmless (nginx lets a later map redefine the variable).++map $http_upgrade $connection_upgrade {++ default upgrade;++ '' close;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name ingest.openre.stream;++++ ssl_certificate /etc/letsencrypt/live/openre.stream/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openre.stream/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log off;++ error_log /var/log/nginx/ingest.openre.stream.error.log crit;++++ client_max_body_size 256k;++++ location / {++ proxy_pass http://127.0.0.1:9936;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ # WebSocket upgrade for /b/<key> and /w/<session id>++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connection_upgrade;++ proxy_buffering off;++ proxy_read_timeout 12h;++ send_timeout 12h;++ }++}++## <<< ingest.openre.stream@@@ -248,7 +248,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).- (a) Launch the openre.stream UI: install `deploy/nginx/openre.stream.conf` from the release to`/etc/nginx/sites-available/openre.stream.conf`, run `nginx -t`, reload, and remove the entryfrom OpenVibe.Sites. The certificate and the OAuth client `openre` with redirect-- `https://openre.stream/auth/callback` must exist. This touches the README launch rule.++ `https://openre.stream/auth/callback` must exist. This touches the README launch rule. The++ full file carries the `ingest.openre.stream` section too, so remove++ `/etc/nginx/sites-enabled/ingest.openre.stream.conf` (if the WHIP slot below installed it) in++ the same change, before `nginx -t`.- (b) Tell broadcasters that destinations are frozen at the cutover until (a).- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRe key that@@@ -390,9 +393,10 @@ done **once** (the worker generation and its ports), then per slot like every prhost's public IP>` (required in production — the worker refuses to start without it),`OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT/MAX_PORT=10200/10300`.Each worker generation picks its own loopback egress port from `OPENRE_WEBRTC_INTERNAL_PORT_MIN..MAX`.--- **nginx (Opus).** Install the reference `ingest.openre.stream` server block in-- `deploy/nginx/openre.stream.conf` to terminate TLS in front of the worker's 9936, with the-- `Upgrade`/`Connection` map for `/b/*` and `/w/*`. Cert must cover `ingest.openre.stream`.++- **nginx.** Install the `ingest.openre.stream` server block from++ `deploy/nginx/openre.stream.conf` (TLS in front of the worker's 9936, with the++ `Upgrade`/`Connection` map for `/b/*` and `/w/*`): the ordered steps, smoke test and rollback are++ in "WHIP / WebRTC ingest slot" below.- **Deploy the worker.** `deploy.sh workers <sha>` now also starts `openre-webrtc@<sha>`; oldergenerations drain and exit by themselves.- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant@@@ -429,6 +433,61 @@ done **once** (the worker generation and its ports), then per slot like every prbroadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the wholeprotocol rolls back.++## WHIP / WebRTC ingest slot (ingest.openre.stream)++++Puts `https://ingest.openre.stream/whip/<key>` and `wss://ingest.openre.stream/<b|w>/…` in front of++`openre-webrtc@<sha>` (the W-A "once" steps, in order). Signaling goes through nginx on 443; the RTC++media never does: it is UDP 10200–10300 straight to the host, never through nginx or Cloudflare. Do++the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCED_IP`.++++1. **Cert (AGENT).** `sudo certbot certificates` lists `/etc/letsencrypt/live/openre.stream/` with++ `*.openre.stream` (read on the host 2026-10-03, expires 2026-12-17); that covers++ `ingest.openre.stream` and is the path the server block uses. If it is gone, issue a certificate++ that covers `ingest.openre.stream` first and point the block's two `ssl_certificate*` lines at it:++ `nginx -t` fails without the files.++2. **Env (AGENT).** In `/etc/openvibe/openre.env` (names only here; never paste values into a++ ticket): `MEDIASOUP_ANNOUNCED_IP` (this host's public address), `OPENRE_WEBRTC_PORT=9936`,++ `OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT`/`MAX_PORT`++ (10200/10300). Keep a copy first: `sudo cp -p /etc/openvibe/openre.env /etc/openvibe/openre.env.pre-webrtc`.++3. **Edge (OWNER).** `ingest.openre.stream` stays the DNS-only (grey cloud) record RTMP uses. Open++ 443/tcp (already open for the sites) and 10200–10300/udp at the provider edge.++4. **Vhost (AGENT).** Until the openre.stream UI is launched (B0 (a)), install only the ingest++ section of the release's file, then test and reload:++ ```++ R=/opt/openre.stream/releases/<sha>++ sed -n '/^## >>> ingest.openre.stream/,/^## <<< ingest.openre.stream/p' $R/deploy/nginx/openre.stream.conf \++ | sudo tee /etc/nginx/sites-available/ingest.openre.stream.conf >/dev/null++ sudo ln -s /etc/nginx/sites-available/ingest.openre.stream.conf /etc/nginx/sites-enabled/++ sudo nginx -t && sudo systemctl reload nginx++ ```++5. **Worker (AGENT).** `sudo $R/deploy/scripts/deploy.sh workers <sha>` starts++ `openre-webrtc@<sha>.service` with the other workers; older generations drain and exit by++ themselves. Check `systemctl status 'openre-webrtc@*'` and++ `sudo journalctl -u 'openre-webrtc@<sha>.service' -n 50` (it logs the refusal if++ `MEDIASOUP_ANNOUNCED_IP` is missing).++6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRe never issued must be++ refused by the worker, not by nginx:++ ```++ curl -sS -o /dev/null -D - -X POST -H 'content-type: application/sdp' --data-binary $'v=0\r\n' \++ https://ingest.openre.stream/whip/ork_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA++ ```++ PASS: TLS verifies, `409` with `x-whip-error: unknown_key`. A `502`/`504` means the worker is not++ listening on 9936; `x-whip-error: generation not taking sessions` means it is not ready yet; a++ `404` means the vhost did not load.++7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRe issued++ (`POST /api/v1/streams`, rotated with `POST /api/v1/streams/:id/keys/rotate`): Bot mints its own++ keys today and OpenRe refuses them exactly like the smoke key. That is Bot job R5++ (`bot-t15-r5-openvibe-bot-a`); until it ships, steps 1–6 work and a robot's camera is refused.++++**Rollback.** Nothing here touches RTMP, JSMPEG or the API.++- vhost: `sudo rm /etc/nginx/sites-enabled/ingest.openre.stream.conf && sudo nginx -t && sudo systemctl reload nginx`.++- worker: `sudo systemctl disable --now 'openre-webrtc@<sha>.service'`, only while no WebRTC session++ is open. Every `deploy.sh workers` enables it again; leave the env in place so that generation++ starts cleanly (without `MEDIASOUP_ANNOUNCED_IP` it fails on every start), and disable it again.++- env (only with the protocol abandoned): `sudo cp -p /etc/openvibe/openre.env.pre-webrtc++ /etc/openvibe/openre.env`, then a new worker generation; never `systemctl restart` a worker instance.++- edge: close 10200–10300/udp.++- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` intonnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * deploy/nginx/openre.stream.conf must carry a live (not ##-commented) server block for++ * ingest.openre.stream: the WHIP ingest a robot's camera publishes to (OpenVibe.Bot++ * whip_url = https://ingest.openre.stream/whip/<key>) is TLS + a proxy to the webrtc worker on 9936.++ * - it proxies to 127.0.0.1:9936 and passes Host (the worker builds the WHIP Location from it);++ * - it upgrades WebSockets (/b/<key>, /w/<id>) through a $connection_upgrade map defined once;++ * - the section docs/cutover.md extracts with sed (the ## >>> / ## <<< markers) is self-contained;++ * - ingest keys are in the path, so the vhost keeps no access log.++ * File parsing only; no nginx binary.++ */++const { test } = require('node:test');++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openre.stream.conf'), 'utf8');++// What nginx reads: drop comments (## banners and # notes) and blank lines.++const live = (text) => text.split('\n').map((l) => l.replace(/#.*$/, '').trimEnd()).filter(Boolean).join('\n');++++/** The top-level server { … } blocks of a config, by brace depth. */++function serverBlocks(text) {++ const blocks = [];++ const re = /^server\s*\{/gm;++ let m;++ while ((m = re.exec(text))) {++ let depth = 0;++ for (let i = m.index; i < text.length; i++) {++ if (text[i] === '{') depth++;++ else if (text[i] === '}' && --depth === 0) { blocks.push(text.slice(m.index, i + 1)); re.lastIndex = i + 1; break; }++ }++ }++ return blocks;++}++++const ingestOf = (text) => serverBlocks(live(text)).filter((b) => /^\s*server_name\s+ingest\.openre\.stream\s*;/m.test(b));++const has = (block, directive) => new RegExp(`^\\s*${directive.replace(/[$.*()[\]]/g, '\\$&').replace(/ /g, '\\s+')}\\s*;`, 'm').test(block);++++test('the ingest.openre.stream server block is live, TLS on 443, proxied to the webrtc worker on 9936', () => {++ const blocks = ingestOf(conf);++ assert.strictEqual(blocks.length, 1, 'exactly one uncommented ingest.openre.stream server block');++ const [b] = blocks;++ assert.ok(has(b, 'listen 443 ssl'), 'listens on 443 with TLS');++ assert.ok(/^\s*ssl_certificate\s+\/etc\/letsencrypt\/live\/[^/]+\/fullchain\.pem\s*;/m.test(b), 'has a certificate');++ assert.ok(/^\s*ssl_certificate_key\s+\/etc\/letsencrypt\/live\/[^/]+\/privkey\.pem\s*;/m.test(b), 'has a key');++ assert.ok(has(b, 'proxy_pass http://127.0.0.1:9936'), 'proxies to OPENRE_WEBRTC_PORT on loopback');++ assert.ok(has(b, 'proxy_set_header Host $host'), 'passes Host: the WHIP Location is built from it');++ assert.ok(has(b, 'access_log off'), 'no access log: the ingest key is in the request path');++});++++test('WebSocket upgrade headers for /b/* and /w/*, with $connection_upgrade mapped exactly once', () => {++ const [b] = ingestOf(conf);++ assert.ok(has(b, 'proxy_http_version 1.1'));++ assert.ok(has(b, 'proxy_set_header Upgrade $http_upgrade'));++ assert.ok(has(b, 'proxy_set_header Connection $connection_upgrade'));++ const maps = live(conf).match(/^map\s+\$http_upgrade\s+\$connection_upgrade\s*\{/gm) || [];++ assert.strictEqual(maps.length, 1, '$connection_upgrade is defined once, at the top (http{}) level');++ assert.ok(/^map\s+\$http_upgrade\s+\$connection_upgrade\s*\{\s*default\s+upgrade;\s*''\s+close;\s*\}/m.test(live(conf)));++});++++test('the marked section installs on its own: map + ingest server block, nothing else', () => {++ const start = conf.search(/^## >>> ingest\.openre\.stream/m);++ const endMatch = /^## <<< ingest\.openre\.stream.*$/m.exec(conf);++ assert.ok(start >= 0 && endMatch && endMatch.index > start, 'both markers, in order');++ const section = conf.slice(start, endMatch.index + endMatch[0].length);++ assert.strictEqual(ingestOf(section).length, 1);++ assert.strictEqual(serverBlocks(live(section)).length, 1, 'only the ingest server block');++ assert.match(live(section), /^map\s+\$http_upgrade\s+\$connection_upgrade/m, 'the map travels with the block');++ assert.doesNotMatch(live(section), /limit_(req|conn)_zone/, 'the openre.stream zones stay out of it');++});
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
## RTMP does NOT go through nginx or Cloudflare: ingest.openre.stream is a DNS-only (grey cloud)
## RTMP does NOT go through nginx or Cloudflare: ingest.openre.stream is a DNS-only (grey cloud)
## A record to this host, and the rtmp-ingest workers listen on TCP 1936 themselves (README
## A record to this host, and the rtmp-ingest workers listen on TCP 1936 themselves (README
## "Port plan"). Open 1936/tcp in the host firewall and at the provider edge.
## "Port plan"). Open 1936/tcp in the host firewall and at the provider edge.
+##
+## ingest.openre.stream also has an HTTPS server here (WHIP + WebRTC signaling, last section).
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=openre_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=openre_api:10m rate=20r/s;
@@ -100,40 +102,59 @@ server {
@@ -100,40 +102,59 @@ server {
}
}
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
-## REFERENCE (not installed): WHIP + WebRTC signaling over HTTPS at ingest.openre.stream.
-## Opus installs this (or an equivalent) at the WebRTC cutover. It terminates TLS for
-## `ingest.openre.stream` and proxies to the webrtc worker's public port (OPENRE_WEBRTC_PORT,
-## default 9936): WHIP POST/PATCH/DELETE under /whip/* and the WS signalling endpoints /b/* and
-## /w/*. The worker builds `Location: https://<host>/whip/session/<id>` from the request Host, so
-## set proxy_set_header Host. The certificate must cover ingest.openre.stream. The RTC media itself
-## does NOT go through nginx: it is UDP 10200-10300 straight to this host, so MEDIASOUP_ANNOUNCED_IP
-## must be the public address and 10200-10300/udp must be open at the provider edge (grey-cloud DNS;
-## Cloudflare's proxy cannot carry raw UDP WebRTC).
-##
-## server {
-## listen 443 ssl;
-## listen [::]:443 ssl;
-## http2 on;
-## server_name ingest.openre.stream;
-## ssl_certificate /etc/letsencrypt/live/ingest.openre.stream/fullchain.pem;
-## ssl_certificate_key /etc/letsencrypt/live/ingest.openre.stream/privkey.pem;
-## ssl_protocols TLSv1.2 TLSv1.3;
+## >>> ingest.openre.stream — WHIP + WebRTC signaling over HTTPS (docs/cutover.md "WHIP / WebRTC
+## ingest slot"). Until the openre.stream UI is launched (cutover B0 (a)), only this section is
+## installed, as /etc/nginx/sites-available/ingest.openre.stream.conf:
+## sed -n '/^## >>> ingest.openre.stream/,/^## <<< ingest.openre.stream/p' openre.stream.conf
##
##
-## client_max_body_size 256k;
-##
-## location / {
-## proxy_pass http://127.0.0.1:9936;
-## proxy_http_version 1.1;
-## proxy_set_header Host $host;
-## proxy_set_header X-Real-IP $remote_addr;
-## proxy_set_header X-Forwarded-For $remote_addr;
-## proxy_set_header X-Forwarded-Proto $scheme;
-## # WebSocket upgrade for /b/<key> and /w/<session id>
-## proxy_set_header Upgrade $http_upgrade;
-## proxy_set_header Connection $connection_upgrade;
-## proxy_read_timeout 12h;
-## send_timeout 12h;
-## }
-## }
-## (Add `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` in http{} once.)
+## It terminates TLS for ingest.openre.stream and proxies to the webrtc worker's public port
+## (OPENRE_WEBRTC_PORT=9936, unit openre-webrtc@<sha>): WHIP POST/PATCH/DELETE under /whip/* and the
+## WebSocket signalling endpoints /b/<key> and /w/<session id>. The worker builds
+## `Location: https://<host>/whip/session/<id>` from the request Host, so keep `Host $host`.
+## Certificate: the *.openre.stream wildcard at /etc/letsencrypt/live/openre.stream/ covers
+## ingest.openre.stream; check it is there (certbot certificates) before enabling, nginx -t fails
+## without it. The RTC media does NOT go through nginx or Cloudflare: it is UDP 10200-10300
+## straight to this host (grey-cloud DNS), so MEDIASOUP_ANNOUNCED_IP must be the public address and
+## 10200-10300/udp must be open at the provider edge.
+## Ingest keys are in the path (/whip/<key>, /b/<key>) and are never logged, so there is no access
+## log and the error log keeps only crit (an upstream error line carries the request line).
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
+
+# http{} scope (sites-enabled is included there). Defined once in this file; an identical map in
+# another site's file is harmless (nginx lets a later map redefine the variable).
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name ingest.openre.stream;
+
+ ssl_certificate /etc/letsencrypt/live/openre.stream/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openre.stream/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log off;
+ error_log /var/log/nginx/ingest.openre.stream.error.log crit;
+
+ client_max_body_size 256k;
+
+ location / {
+ proxy_pass http://127.0.0.1:9936;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # WebSocket upgrade for /b/<key> and /w/<session id>
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_buffering off;
+ proxy_read_timeout 12h;
+ send_timeout 12h;
+ }
+}
+## <<< ingest.openre.stream
@@ -248,7 +248,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
@@ -248,7 +248,10 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
- (a) Launch the openre.stream UI: install `deploy/nginx/openre.stream.conf` from the release to
- (a) Launch the openre.stream UI: install `deploy/nginx/openre.stream.conf` from the release to
`/etc/nginx/sites-available/openre.stream.conf`, run `nginx -t`, reload, and remove the entry
`/etc/nginx/sites-available/openre.stream.conf`, run `nginx -t`, reload, and remove the entry
from OpenVibe.Sites. The certificate and the OAuth client `openre` with redirect
from OpenVibe.Sites. The certificate and the OAuth client `openre` with redirect
- `https://openre.stream/auth/callback` must exist. This touches the README launch rule.
+ `https://openre.stream/auth/callback` must exist. This touches the README launch rule. The
+ full file carries the `ingest.openre.stream` section too, so remove
+ `/etc/nginx/sites-enabled/ingest.openre.stream.conf` (if the WHIP slot below installed it) in
+ the same change, before `nginx -t`.
- (b) Tell broadcasters that destinations are frozen at the cutover until (a).
- (b) Tell broadcasters that destinations are frozen at the cutover until (a).
- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.
- **Hazard H4, the leaked keys.** 97 of 97 Live stream keys are identical to the pre-leak backup.
Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRe key that
Each slot's cutover rotates that slot's Live key (at the switch) and issues a new OpenRe key that
@@ -390,9 +393,10 @@ done **once** (the worker generation and its ports), then per slot like every pr
@@ -390,9 +393,10 @@ done **once** (the worker generation and its ports), then per slot like every pr
host's public IP>` (required in production — the worker refuses to start without it),
host's public IP>` (required in production — the worker refuses to start without it),
`OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT/MAX_PORT=10200/10300`.
`OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT/MAX_PORT=10200/10300`.
Each worker generation picks its own loopback egress port from `OPENRE_WEBRTC_INTERNAL_PORT_MIN..MAX`.
Each worker generation picks its own loopback egress port from `OPENRE_WEBRTC_INTERNAL_PORT_MIN..MAX`.
-- **nginx (Opus).** Install the reference `ingest.openre.stream` server block in
- `deploy/nginx/openre.stream.conf` to terminate TLS in front of the worker's 9936, with the
- `Upgrade`/`Connection` map for `/b/*` and `/w/*`. Cert must cover `ingest.openre.stream`.
+- **nginx.** Install the `ingest.openre.stream` server block from
+ `deploy/nginx/openre.stream.conf` (TLS in front of the worker's 9936, with the
+ `Upgrade`/`Connection` map for `/b/*` and `/w/*`): the ordered steps, smoke test and rollback are
+ in "WHIP / WebRTC ingest slot" below.
- **Deploy the worker.** `deploy.sh workers <sha>` now also starts `openre-webrtc@<sha>`; older
- **Deploy the worker.** `deploy.sh workers <sha>` now also starts `openre-webrtc@<sha>`; older
generations drain and exit by themselves.
generations drain and exit by themselves.
- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant
- **Media grants.** Thumbnails upload as Media objects under namespace `live` (Media grant
@@ -429,6 +433,61 @@ done **once** (the worker generation and its ports), then per slot like every pr
@@ -429,6 +433,61 @@ done **once** (the worker generation and its ports), then per slot like every pr
broadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the whole
broadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the whole
protocol rolls back.
protocol rolls back.
+## WHIP / WebRTC ingest slot (ingest.openre.stream)
+
+Puts `https://ingest.openre.stream/whip/<key>` and `wss://ingest.openre.stream/<b|w>/…` in front of
+`openre-webrtc@<sha>` (the W-A "once" steps, in order). Signaling goes through nginx on 443; the RTC
+media never does: it is UDP 10200–10300 straight to the host, never through nginx or Cloudflare. Do
+the steps in this order: the worker refuses to start without `MEDIASOUP_ANNOUNCED_IP`.
+
+1. **Cert (AGENT).** `sudo certbot certificates` lists `/etc/letsencrypt/live/openre.stream/` with
+ `*.openre.stream` (read on the host 2026-10-03, expires 2026-12-17); that covers
+ `ingest.openre.stream` and is the path the server block uses. If it is gone, issue a certificate
+ that covers `ingest.openre.stream` first and point the block's two `ssl_certificate*` lines at it:
+ `nginx -t` fails without the files.
+2. **Env (AGENT).** In `/etc/openvibe/openre.env` (names only here; never paste values into a
+ ticket): `MEDIASOUP_ANNOUNCED_IP` (this host's public address), `OPENRE_WEBRTC_PORT=9936`,
+ `OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream`, `OPENRE_MEDIASOUP_MIN_PORT`/`MAX_PORT`
+ (10200/10300). Keep a copy first: `sudo cp -p /etc/openvibe/openre.env /etc/openvibe/openre.env.pre-webrtc`.
+3. **Edge (OWNER).** `ingest.openre.stream` stays the DNS-only (grey cloud) record RTMP uses. Open
+ 443/tcp (already open for the sites) and 10200–10300/udp at the provider edge.
+4. **Vhost (AGENT).** Until the openre.stream UI is launched (B0 (a)), install only the ingest
+ section of the release's file, then test and reload:
+ ```
+ R=/opt/openre.stream/releases/<sha>
+ sed -n '/^## >>> ingest.openre.stream/,/^## <<< ingest.openre.stream/p' $R/deploy/nginx/openre.stream.conf \
+ | sudo tee /etc/nginx/sites-available/ingest.openre.stream.conf >/dev/null
+ sudo ln -s /etc/nginx/sites-available/ingest.openre.stream.conf /etc/nginx/sites-enabled/
+ sudo nginx -t && sudo systemctl reload nginx
+ ```
+5. **Worker (AGENT).** `sudo $R/deploy/scripts/deploy.sh workers <sha>` starts
+ `openre-webrtc@<sha>.service` with the other workers; older generations drain and exit by
+ themselves. Check `systemctl status 'openre-webrtc@*'` and
+ `sudo journalctl -u 'openre-webrtc@<sha>.service' -n 50` (it logs the refusal if
+ `MEDIASOUP_ANNOUNCED_IP` is missing).
+6. **Smoke (AGENT, from off the host too).** A WHIP POST with a key OpenRe never issued must be
+ refused by the worker, not by nginx:
+ ```
+ curl -sS -o /dev/null -D - -X POST -H 'content-type: application/sdp' --data-binary $'v=0\r\n' \
+ https://ingest.openre.stream/whip/ork_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+ ```
+ PASS: TLS verifies, `409` with `x-whip-error: unknown_key`. A `502`/`504` means the worker is not
+ listening on 9936; `x-whip-error: generation not taking sessions` means it is not ready yet; a
+ `404` means the vhost did not load.
+7. **Prerequisite for robot video.** OpenVibe.Bot must publish with keys OpenRe issued
+ (`POST /api/v1/streams`, rotated with `POST /api/v1/streams/:id/keys/rotate`): Bot mints its own
+ keys today and OpenRe refuses them exactly like the smoke key. That is Bot job R5
+ (`bot-t15-r5-openvibe-bot-a`); until it ships, steps 1–6 work and a robot's camera is refused.
+
+**Rollback.** Nothing here touches RTMP, JSMPEG or the API.
+- vhost: `sudo rm /etc/nginx/sites-enabled/ingest.openre.stream.conf && sudo nginx -t && sudo systemctl reload nginx`.
+- worker: `sudo systemctl disable --now 'openre-webrtc@<sha>.service'`, only while no WebRTC session
+ is open. Every `deploy.sh workers` enables it again; leave the env in place so that generation
+ starts cleanly (without `MEDIASOUP_ANNOUNCED_IP` it fails on every start), and disable it again.
+- env (only with the protocol abandoned): `sudo cp -p /etc/openvibe/openre.env.pre-webrtc
+ /etc/openvibe/openre.env`, then a new worker generation; never `systemctl restart` a worker instance.
+- edge: close 10200–10300/udp.
+
- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` into
- **ovhost inventory.** Copy the `openre` entry of OpenVibe.Host's `host.example.json` into
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * deploy/nginx/openre.stream.conf must carry a live (not ##-commented) server block for
+ * ingest.openre.stream: the WHIP ingest a robot's camera publishes to (OpenVibe.Bot
+ * whip_url = https://ingest.openre.stream/whip/<key>) is TLS + a proxy to the webrtc worker on 9936.
+ * - it proxies to 127.0.0.1:9936 and passes Host (the worker builds the WHIP Location from it);
+ * - it upgrades WebSockets (/b/<key>, /w/<id>) through a $connection_upgrade map defined once;
+ * - the section docs/cutover.md extracts with sed (the ## >>> / ## <<< markers) is self-contained;
+ * - ingest keys are in the path, so the vhost keeps no access log.
+ * File parsing only; no nginx binary.
+ */
+const { test } = require('node:test');
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openre.stream.conf'), 'utf8');
+// What nginx reads: drop comments (## banners and # notes) and blank lines.
+const live = (text) => text.split('\n').map((l) => l.replace(/#.*$/, '').trimEnd()).filter(Boolean).join('\n');
+
+/** The top-level server { … } blocks of a config, by brace depth. */
+function serverBlocks(text) {
+ const blocks = [];
+ const re = /^server\s*\{/gm;
+ let m;
+ while ((m = re.exec(text))) {
+ let depth = 0;
+ for (let i = m.index; i < text.length; i++) {
+ if (text[i] === '{') depth++;
+ else if (text[i] === '}' && --depth === 0) { blocks.push(text.slice(m.index, i + 1)); re.lastIndex = i + 1; break; }
+ }
+ }
+ return blocks;
+}
+
+const ingestOf = (text) => serverBlocks(live(text)).filter((b) => /^\s*server_name\s+ingest\.openre\.stream\s*;/m.test(b));
+const has = (block, directive) => new RegExp(`^\\s*${directive.replace(/[$.*()[\]]/g, '\\$&').replace(/ /g, '\\s+')}\\s*;`, 'm').test(block);
+
+test('the ingest.openre.stream server block is live, TLS on 443, proxied to the webrtc worker on 9936', () => {
+ const blocks = ingestOf(conf);
+ assert.strictEqual(blocks.length, 1, 'exactly one uncommented ingest.openre.stream server block');
+ const [b] = blocks;
+ assert.ok(has(b, 'listen 443 ssl'), 'listens on 443 with TLS');
+ assert.ok(/^\s*ssl_certificate\s+\/etc\/letsencrypt\/live\/[^/]+\/fullchain\.pem\s*;/m.test(b), 'has a certificate');
+ assert.ok(/^\s*ssl_certificate_key\s+\/etc\/letsencrypt\/live\/[^/]+\/privkey\.pem\s*;/m.test(b), 'has a key');
+ assert.ok(has(b, 'proxy_pass http://127.0.0.1:9936'), 'proxies to OPENRE_WEBRTC_PORT on loopback');
+ assert.ok(has(b, 'proxy_set_header Host $host'), 'passes Host: the WHIP Location is built from it');
+ assert.ok(has(b, 'access_log off'), 'no access log: the ingest key is in the request path');
+});
+
+test('WebSocket upgrade headers for /b/* and /w/*, with $connection_upgrade mapped exactly once', () => {
+ const [b] = ingestOf(conf);
+ assert.ok(has(b, 'proxy_http_version 1.1'));
+ assert.ok(has(b, 'proxy_set_header Upgrade $http_upgrade'));
+ assert.ok(has(b, 'proxy_set_header Connection $connection_upgrade'));
+ const maps = live(conf).match(/^map\s+\$http_upgrade\s+\$connection_upgrade\s*\{/gm) || [];
+ assert.strictEqual(maps.length, 1, '$connection_upgrade is defined once, at the top (http{}) level');
+ assert.ok(/^map\s+\$http_upgrade\s+\$connection_upgrade\s*\{\s*default\s+upgrade;\s*''\s+close;\s*\}/m.test(live(conf)));
+});
+
+test('the marked section installs on its own: map + ingest server block, nothing else', () => {
+ const start = conf.search(/^## >>> ingest\.openre\.stream/m);
+ const endMatch = /^## <<< ingest\.openre\.stream.*$/m.exec(conf);
+ assert.ok(start >= 0 && endMatch && endMatch.index > start, 'both markers, in order');
+ const section = conf.slice(start, endMatch.index + endMatch[0].length);
+ assert.strictEqual(ingestOf(section).length, 1);
+ assert.strictEqual(serverBlocks(live(section)).length, 1, 'only the ingest server block');
+ assert.match(live(section), /^map\s+\$http_upgrade\s+\$connection_upgrade/m, 'the map travels with the block');
+ assert.doesNotMatch(live(section), /limit_(req|conn)_zone/, 'the openre.stream zones stay out of it');
+});