openvibers/openvibe.media Commits

Media may pull recordings from OpenRestream's loopback play ports (MEDIA_RTMP_PULL_ALLOW ranges) (#159)

OP OpenVibers committed 21 days ago · 19814aeec4 · 4 files, +31 −9

Changes

ddiff --git a/README.md b/README.md
iindex 668a9b3..078c112 100644
---- a/README.md
++++ b/README.md
@@@ -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 |
ddiff --git a/server/config.js b/server/config.js
iindex 6f60184..7df7bae 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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),
},
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex 96e732c..68a1161 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -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)}` };
}
ddiff --git a/test/rtmp-allowlist.test.js b/test/rtmp-allowlist.test.js
iindex 6ce9af8..5b067d3 100644
---- a/test/rtmp-allowlist.test.js
++++ b/test/rtmp-allowlist.test.js
@@@ -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',