openvibers/openrestream Commits

Serve WHIP ingest on ingest.openre.stream and document its cutover (#12)

OP OpenVibers committed 4 Oct 2026 · 4bc46be010 · 3 files, +189 −39

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenRe.Stream/pull/12

Changes

ddiff --git a/deploy/nginx/openre.stream.conf b/deploy/nginx/openre.stream.conf
iindex d96a0a7..92e0c4a 100644
---- a/deploy/nginx/openre.stream.conf
++++ b/deploy/nginx/openre.stream.conf
@@@ -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
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex 37238bd..cb3f22a 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -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 entry
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).
- **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 pr
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`.
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
generations 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 pr
broadcaster points WHIP/browser back at Live. Remove `webrtc` from `OPENRE_PROTOCOLS` if the whole
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
ddiff --git a/test/deploy-conf.test.js b/test/deploy-conf.test.js
nnew file mode 100644
iindex 0000000..db4e8eb
---- /dev/null
++++ b/test/deploy-conf.test.js
@@@ -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');
++});