openvibers/openvibe.media Commits
Media may pull recordings from OpenRestream's loopback play ports (MEDIA_RTMP_PULL_ALLOW ranges) (#159)
Changes
@@@ -239,7 +239,7 @@ and in `scripts/smoke-test.sh`.| method | path | notes ||---|---|---|| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |--| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4`. Only allow-listed RTMP servers (`MEDIA_RTMP_PULL_ALLOW`, `host:port` list, default `127.0.0.1:1935,localhost:1935,[::1]:1935`: Live's RTMP server on this host) and a plain `rtmp[s]://host[:port]/path` shape (no user info, whitespace or backslash); anything else is **400** and starts nothing |++| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4`. Only allow-listed RTMP servers (`MEDIA_RTMP_PULL_ALLOW`, a list of `host:port` or `host:low-high`, default `127.0.0.1:1935,localhost:1935,[::1]:1935,127.0.0.1:19360-19399`: Live's RTMP server and OpenRestream's loopback play ports on this host) and a plain `rtmp[s]://host[:port]/path` shape (no user info, whitespace or backslash); anything else is **400** and starts nothing || POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) || POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording || POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |@@@ -150,10 +150,12 @@ const config = {intervalMin: intEnv('MEDIA_OWNER_SUBJECT_INTERVAL_MIN', 10),},-- // Where POST /vods/:id/ingest/rtmp may make ffmpeg connect (an SSRF guard): host:port entries,-- // by default Live's RTMP server on this host. A URL without a port means 1935 (rtmp) / 443 (rtmps).++ // Where POST /vods/:id/ingest/rtmp may make ffmpeg connect (an SSRF guard): host:port entries, or++ // host:low-high for a range of ports. By default Live's RTMP server on this host, and the loopback++ // play ports OpenRestream's RTMP workers take (OPENRE_RTMP_INTERNAL_PORT_MIN..MAX, 19360-19399):++ // its recordings are pulled from there. A URL without a port means 1935 (rtmp) / 443 (rtmps).rtmpPull: {-- allow: String(process.env.MEDIA_RTMP_PULL_ALLOW || '127.0.0.1:1935,localhost:1935,[::1]:1935')++ allow: String(process.env.MEDIA_RTMP_PULL_ALLOW || '127.0.0.1:1935,localhost:1935,[::1]:1935,127.0.0.1:19360-19399').split(',').map(s => s.trim().toLowerCase()).filter(Boolean),},@@@ -5,7 +5,7 @@* own their stream state and point ingest at a VOD they created):** RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL (allow-listed RTMP servers-- * only: MEDIA_RTMP_PULL_ALLOW, default Live's on this host), lossless++ * only: MEDIA_RTMP_PULL_ALLOW, default Live's and OpenRestream's on this host), lossless* stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero* CPU, live-seekable, kill-tolerant, and IS its own lossless master).* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair@@@ -39,8 +39,18 @@ const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;* Is this an RTMP URL ffmpeg may pull? Only rtmp:// or rtmps:// with a plain host (a name, IPv4 or* [IPv6]), an optional port and a plain path/query: no user info, no backslash, no whitespace or* control characters, nothing ffmpeg and a URL parser could read differently. The host:port (default-- * 1935 / 443) must be in `allow` (config.rtmpPull.allow). Returns { ok, host, port, url } or { ok: false, error }.++ * 1935 / 443) must be in `allow` (config.rtmpPull.allow): an exact host:port, or host:low-high for a range of ports.++ * Returns { ok, host, port, url } or { ok: false, error }.*/++function allows(entries, host, port) {++ for (const raw of entries || []) {++ const a = String(raw).toLowerCase();++ const m = /^(.+):(\d{1,5})-(\d{1,5})$/.exec(a);++ if (m ? m[1] === host && port >= Number(m[2]) && port <= Number(m[3]) : a === `${host}:${port}`) return true;++ }++ return false;++}++function checkRtmpUrl(url, allow = config.rtmpPull.allow) {const u = String(url == null ? '' : url);if (!u || u.length > 2048) return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };@@@ -49,8 +59,7 @@ function checkRtmpUrl(url, allow = config.rtmpPull.allow) {const host = m[2].toLowerCase();const port = m[3] ? Number(m[3]) : (m[1].toLowerCase() === 'rtmps' ? 443 : 1935);if (!(port > 0 && port < 65536)) return { ok: false, error: 'rtmp_url has an invalid port' };-- const allowed = (allow || []).map(a => String(a).toLowerCase());-- if (!allowed.includes(`${host}:${port}`)) return { ok: false, error: `rtmp_url host ${host}:${port} is not an allowed RTMP ingest (MEDIA_RTMP_PULL_ALLOW)` };++ if (!allows(allow, host, port)) return { ok: false, error: `rtmp_url host ${host}:${port} is not an allowed RTMP ingest (MEDIA_RTMP_PULL_ALLOW)` };// ffmpeg matches protocol names case-sensitively: hand it the scheme in lower case.return { ok: true, host, port, url: `${m[1].toLowerCase()}${u.slice(m[1].length)}` };}@@@ -23,7 +23,18 @@ const express = require('express');const recorder = require('../server/vod/recorder');const { checkRtmpUrl } = recorder;-- assert.deepStrictEqual(config.rtmpPull.allow, ['127.0.0.1:1935', 'localhost:1935', '[::1]:1935'], 'default: Live\'s RTMP server on this host');++ assert.deepStrictEqual(config.rtmpPull.allow, ['127.0.0.1:1935', 'localhost:1935', '[::1]:1935', '127.0.0.1:19360-19399'], 'default: Live\'s RTMP server and OpenRestream\'s loopback play ports on this host');++++ // OpenRestream's RTMP workers serve each session on a loopback play port from 19360-19399 (a record-only stream's++ // recording is pulled from there, 2026-10-23): the whole range, on 127.0.0.1 only, and nothing past it.++ for (const port of [19360, 19377, 19399]) assert.ok(checkRtmpUrl(`rtmp://127.0.0.1:${port}/live/ses_01M5K5W32ZN3NR76M1DQK8F76Q`).ok, `OpenRestream play port ${port}`);++ for (const url of ['rtmp://127.0.0.1:19359/live/x', 'rtmp://127.0.0.1:19400/live/x', 'rtmp://localhost:19360/live/x', 'rtmp://[::1]:19360/live/x']) {++ assert.ok(!checkRtmpUrl(url).ok, `outside the range: ${url}`);++ }++ // A range in MEDIA_RTMP_PULL_ALLOW is host:low-high; a malformed entry matches nothing.++ assert.ok(checkRtmpUrl('rtmp://rtmp.example:2001/live/k', ['rtmp.example:2000-2010']).ok);++ assert.ok(!checkRtmpUrl('rtmp://rtmp.example:2011/live/k', ['rtmp.example:2000-2010']).ok);++ assert.ok(!checkRtmpUrl('rtmp://rtmp.example:2001/live/k', ['rtmp.example:2000-']).ok);// ── 1. The allow-list and the URL shape ──for (const ok of ['rtmp://127.0.0.1:1935/live/abc123', 'rtmp://localhost:1935/live/Key_9-x', 'rtmp://127.0.0.1/live/k', 'rtmp://[::1]:1935/live/k',
@@ -239,7 +239,7 @@ and in `scripts/smoke-test.sh`.
@@ -239,7 +239,7 @@ and in `scripts/smoke-test.sh`.
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
-| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4`. Only allow-listed RTMP servers (`MEDIA_RTMP_PULL_ALLOW`, `host:port` list, default `127.0.0.1:1935,localhost:1935,[::1]:1935`: Live's RTMP server on this host) and a plain `rtmp[s]://host[:port]/path` shape (no user info, whitespace or backslash); anything else is **400** and starts nothing |
+| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4`. Only allow-listed RTMP servers (`MEDIA_RTMP_PULL_ALLOW`, a list of `host:port` or `host:low-high`, default `127.0.0.1:1935,localhost:1935,[::1]:1935,127.0.0.1:19360-19399`: Live's RTMP server and OpenRestream's loopback play ports on this host) and a plain `rtmp[s]://host[:port]/path` shape (no user info, whitespace or backslash); anything else is **400** and starts nothing |
| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
@@ -150,10 +150,12 @@ const config = {
@@ -150,10 +150,12 @@ const config = {
intervalMin: intEnv('MEDIA_OWNER_SUBJECT_INTERVAL_MIN', 10),
intervalMin: intEnv('MEDIA_OWNER_SUBJECT_INTERVAL_MIN', 10),
},
},
- // Where POST /vods/:id/ingest/rtmp may make ffmpeg connect (an SSRF guard): host:port entries,
- // by default Live's RTMP server on this host. A URL without a port means 1935 (rtmp) / 443 (rtmps).
+ // Where POST /vods/:id/ingest/rtmp may make ffmpeg connect (an SSRF guard): host:port entries, or
+ // host:low-high for a range of ports. By default Live's RTMP server on this host, and the loopback
+ // play ports OpenRestream's RTMP workers take (OPENRE_RTMP_INTERNAL_PORT_MIN..MAX, 19360-19399):
+ // its recordings are pulled from there. A URL without a port means 1935 (rtmp) / 443 (rtmps).
rtmpPull: {
rtmpPull: {
- allow: String(process.env.MEDIA_RTMP_PULL_ALLOW || '127.0.0.1:1935,localhost:1935,[::1]:1935')
+ allow: String(process.env.MEDIA_RTMP_PULL_ALLOW || '127.0.0.1:1935,localhost:1935,[::1]:1935,127.0.0.1:19360-19399')
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean),
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean),
},
},
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* own their stream state and point ingest at a VOD they created):
* own their stream state and point ingest at a VOD they created):
*
*
* RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL (allow-listed RTMP servers
* RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL (allow-listed RTMP servers
- * only: MEDIA_RTMP_PULL_ALLOW, default Live's on this host), lossless
+ * only: MEDIA_RTMP_PULL_ALLOW, default Live's and OpenRestream's on this host), lossless
* stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
* stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
* CPU, live-seekable, kill-tolerant, and IS its own lossless master).
* CPU, live-seekable, kill-tolerant, and IS its own lossless master).
* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
@@ -39,8 +39,18 @@ const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;
@@ -39,8 +39,18 @@ const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;
* Is this an RTMP URL ffmpeg may pull? Only rtmp:// or rtmps:// with a plain host (a name, IPv4 or
* Is this an RTMP URL ffmpeg may pull? Only rtmp:// or rtmps:// with a plain host (a name, IPv4 or
* [IPv6]), an optional port and a plain path/query: no user info, no backslash, no whitespace or
* [IPv6]), an optional port and a plain path/query: no user info, no backslash, no whitespace or
* control characters, nothing ffmpeg and a URL parser could read differently. The host:port (default
* control characters, nothing ffmpeg and a URL parser could read differently. The host:port (default
- * 1935 / 443) must be in `allow` (config.rtmpPull.allow). Returns { ok, host, port, url } or { ok: false, error }.
+ * 1935 / 443) must be in `allow` (config.rtmpPull.allow): an exact host:port, or host:low-high for a range of ports.
+ * Returns { ok, host, port, url } or { ok: false, error }.
*/
*/
+function allows(entries, host, port) {
+ for (const raw of entries || []) {
+ const a = String(raw).toLowerCase();
+ const m = /^(.+):(\d{1,5})-(\d{1,5})$/.exec(a);
+ if (m ? m[1] === host && port >= Number(m[2]) && port <= Number(m[3]) : a === `${host}:${port}`) return true;
+ }
+ return false;
+}
+
function checkRtmpUrl(url, allow = config.rtmpPull.allow) {
function checkRtmpUrl(url, allow = config.rtmpPull.allow) {
const u = String(url == null ? '' : url);
const u = String(url == null ? '' : url);
if (!u || u.length > 2048) return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
if (!u || u.length > 2048) return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
@@ -49,8 +59,7 @@ function checkRtmpUrl(url, allow = config.rtmpPull.allow) {
@@ -49,8 +59,7 @@ function checkRtmpUrl(url, allow = config.rtmpPull.allow) {
const host = m[2].toLowerCase();
const host = m[2].toLowerCase();
const port = m[3] ? Number(m[3]) : (m[1].toLowerCase() === 'rtmps' ? 443 : 1935);
const port = m[3] ? Number(m[3]) : (m[1].toLowerCase() === 'rtmps' ? 443 : 1935);
if (!(port > 0 && port < 65536)) return { ok: false, error: 'rtmp_url has an invalid port' };
if (!(port > 0 && port < 65536)) return { ok: false, error: 'rtmp_url has an invalid port' };
- const allowed = (allow || []).map(a => String(a).toLowerCase());
- if (!allowed.includes(`${host}:${port}`)) return { ok: false, error: `rtmp_url host ${host}:${port} is not an allowed RTMP ingest (MEDIA_RTMP_PULL_ALLOW)` };
+ if (!allows(allow, host, port)) return { ok: false, error: `rtmp_url host ${host}:${port} is not an allowed RTMP ingest (MEDIA_RTMP_PULL_ALLOW)` };
// ffmpeg matches protocol names case-sensitively: hand it the scheme in lower case.
// ffmpeg matches protocol names case-sensitively: hand it the scheme in lower case.
return { ok: true, host, port, url: `${m[1].toLowerCase()}${u.slice(m[1].length)}` };
return { ok: true, host, port, url: `${m[1].toLowerCase()}${u.slice(m[1].length)}` };
}
}
@@ -23,7 +23,18 @@ const express = require('express');
@@ -23,7 +23,18 @@ const express = require('express');
const recorder = require('../server/vod/recorder');
const recorder = require('../server/vod/recorder');
const { checkRtmpUrl } = recorder;
const { checkRtmpUrl } = recorder;
- assert.deepStrictEqual(config.rtmpPull.allow, ['127.0.0.1:1935', 'localhost:1935', '[::1]:1935'], 'default: Live\'s RTMP server on this host');
+ assert.deepStrictEqual(config.rtmpPull.allow, ['127.0.0.1:1935', 'localhost:1935', '[::1]:1935', '127.0.0.1:19360-19399'], 'default: Live\'s RTMP server and OpenRestream\'s loopback play ports on this host');
+
+ // OpenRestream's RTMP workers serve each session on a loopback play port from 19360-19399 (a record-only stream's
+ // recording is pulled from there, 2026-10-23): the whole range, on 127.0.0.1 only, and nothing past it.
+ for (const port of [19360, 19377, 19399]) assert.ok(checkRtmpUrl(`rtmp://127.0.0.1:${port}/live/ses_01M5K5W32ZN3NR76M1DQK8F76Q`).ok, `OpenRestream play port ${port}`);
+ for (const url of ['rtmp://127.0.0.1:19359/live/x', 'rtmp://127.0.0.1:19400/live/x', 'rtmp://localhost:19360/live/x', 'rtmp://[::1]:19360/live/x']) {
+ assert.ok(!checkRtmpUrl(url).ok, `outside the range: ${url}`);
+ }
+ // A range in MEDIA_RTMP_PULL_ALLOW is host:low-high; a malformed entry matches nothing.
+ assert.ok(checkRtmpUrl('rtmp://rtmp.example:2001/live/k', ['rtmp.example:2000-2010']).ok);
+ assert.ok(!checkRtmpUrl('rtmp://rtmp.example:2011/live/k', ['rtmp.example:2000-2010']).ok);
+ assert.ok(!checkRtmpUrl('rtmp://rtmp.example:2001/live/k', ['rtmp.example:2000-']).ok);
// ── 1. The allow-list and the URL shape ──
// ── 1. The allow-list and the URL shape ──
for (const ok of ['rtmp://127.0.0.1:1935/live/abc123', 'rtmp://localhost:1935/live/Key_9-x', 'rtmp://127.0.0.1/live/k', 'rtmp://[::1]:1935/live/k',
for (const ok of ['rtmp://127.0.0.1:1935/live/abc123', 'rtmp://localhost:1935/live/Key_9-x', 'rtmp://127.0.0.1/live/k', 'rtmp://[::1]:1935/live/k',