From 35e6611ab87cd2fb1b27c68a50e6e72c3c4b7833 Mon Sep 17 00:00:00 2001 From: Guy Cochran Date: Wed, 7 Oct 2026 19:34:53 -0700 Subject: [PATCH] security(srt-listen): enforce MXL_GUEST_SRT_PASSPHRASE on the public guest listener In srt-listen mode (the quickstart default) mediamtx's SRT server is off, so the existing MXL_GUEST*_SRT_PASSPHRASE settings were ignored and the public guest ports accepted any caller. - tools/guest_av_listen.sh: the A/V fan-out (owns the public port) passes passphrase= to its srtsrc listener when MXL_GUEST_SRT_PASSPHRASE is set; validates first and exits 2 on a bad value; loud warning when unset. Loopback legs stay unencrypted. - tools/adapters.py / guest_ingest.py: SrtListenerGuestAdapter takes an optional passphrase (launch string byte-identical when unset); guest_ingest reads MXL_GUEST_SRT_PASSPHRASE in srt-listen mode and fails closed on invalid values. - scripts/quickstart.sh: validates per-slot/shared passphrases in listen mode, hands them to each fan-out via env inheritance (not docker argv), reports which slots require one and warns loudly about open slots. - Passphrase charset limited to URL-unreserved A-Za-z0-9._~- (10-79 chars) so it survives both gst-launch argv and a caller's srt:// URL unescaped. - docs/TESTERS.md: how to lock guest slots. tests/test_srt_listen_passphrase.py. No behaviour change unless a passphrase variable is set. Signed-off-by: Guy Cochran --- docs/TESTERS.md | 15 ++ scripts/quickstart.sh | 57 +++++-- tests/test_srt_listen_passphrase.py | 228 ++++++++++++++++++++++++++++ tools/adapters.py | 26 +++- tools/guest_av_listen.sh | 28 +++- tools/guest_ingest.py | 18 ++- 6 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 tests/test_srt_listen_passphrase.py diff --git a/docs/TESTERS.md b/docs/TESTERS.md index d5a6711..3e2b7b6 100644 --- a/docs/TESTERS.md +++ b/docs/TESTERS.md @@ -76,6 +76,21 @@ The quickstart gives you a pattern + clip. To add your own feed: No hand-built QR, no stale IP — the running box generates everything for the machine it's actually on. +**Lock the guest slots (recommended on any public VM).** Without a passphrase the +guest SRT ports are open: anyone who can reach them can publish into your program +(the quickstart prints a loud warning). Set a passphrase before running: + +```bash +sudo MXL_GUEST_SRT_PASSPHRASE="$(openssl rand -hex 16)" scripts/quickstart.sh +# or one per slot: MXL_GUEST1_SRT_PASSPHRASE=... MXL_GUEST2_SRT_PASSPHRASE=... +# (the quickstart prints which slots require it, never the value — note it down) +``` + +10–79 characters of `A-Z a-z 0-9 . _ ~ -` (so it pastes into an SRT URL +unescaped; `openssl rand -hex 16` is a good choice). Callers without it, or with the wrong one, are rejected at the SRT +handshake. Enter it in Larix's **Passphrase** field, or add `&passphrase=...` to +the SRT URL in OBS/ffmpeg. The QR code does not carry the passphrase — type it in. + ## 6. Tear down ```bash diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh index 378536f..2de668b 100755 --- a/scripts/quickstart.sh +++ b/scripts/quickstart.sh @@ -28,6 +28,8 @@ # streamid) | rtsp (legacy). srt-listen is HW-proven and avoids the mediamtx # read-back restart-loop on jittery sources; it carries A/V (a per-guest fan-out # splits the one SRT stream into a video + an audio flow; MXL_GUEST_AUDIO=0 skips audio). +# Set MXL_GUEST_SRT_PASSPHRASE (or MXL_GUEST1_/MXL_GUEST2_SRT_PASSPHRASE) to require an +# SRT passphrase in EVERY transport; without one the guest listeners are OPEN (warned). # # Idempotent: safe to re-run. Teardown: sudo scripts/quickstart.sh --down set -euo pipefail @@ -272,17 +274,38 @@ render_mediamtx_guest_conf(){ # prints YAML on stdout; empty output = no auth co [ -n "$out" ] && printf 'paths:\n%s all_others:\n' "$out" return 0 } +_srt_pass_listen_ok(){ # srt-listen: gst-launch argv word + caller URL → URL-unreserved chars only + _srt_pass_ok "$1" || return 1 + case "$1" in *[!A-Za-z0-9._~-]*) return 1;; esac + return 0 +} +srt_listen_pass_check(){ # validate + report; sets SRT_LISTEN_REQUIRED / SRT_LISTEN_OPEN + local i p + SRT_LISTEN_REQUIRED=""; SRT_LISTEN_OPEN="" + for i in 1 2; do + p=$(_srt_pass_for "$i") + if [ -z "$p" ]; then SRT_LISTEN_OPEN+="guest$i "; continue; fi + _srt_pass_listen_ok "$p" || { echo "✗ guest $i SRT passphrase (srt-listen) must be 10-79 chars of A-Z a-z 0-9 . _ ~ - (e.g. openssl rand -hex 16)" >&2; return 1; } + SRT_LISTEN_REQUIRED+="guest$i " + done + if [ -n "$SRT_LISTEN_REQUIRED" ]; then + echo " ✓ SRT passphrase REQUIRED (srt-listen, AES) for: $SRT_LISTEN_REQUIRED" + fi + if [ -n "$SRT_LISTEN_OPEN" ]; then + echo " ⚠⚠ WARNING: guest SRT listener(s) OPEN for: ${SRT_LISTEN_OPEN}— anyone who reaches the UDP port can publish into your program." + echo " Set MXL_GUEST_SRT_PASSPHRASE (or MXL_GUEST1_/MXL_GUEST2_SRT_PASSPHRASE) to require one, and restrict the port in your cloud firewall." + fi + return 0 +} # --- END guest-srt-conf --- MTX_CONF_ARGS=() +SRT_LISTEN_REQUIRED=""; SRT_LISTEN_OPEN="" GUEST_CONF=$(render_mediamtx_guest_conf) || exit 1 if [ "$GUEST_TRANSPORT" = srt-listen ]; then - # mediamtx's SRT server is disabled in listen mode, so its publish passphrase can't - # apply — the contributor connects to the ingest's srtsrc listener, not mediamtx. - if [ -n "$GUEST_CONF" ]; then - echo " ⚠ MXL_GUEST_*_SRT_PASSPHRASE is ignored in srt-listen mode (mediamtx SRT is off); gate the listener with the SRT passphrase on the publisher side, or restrict the port in your firewall" - else - echo " ⚠ guest SRT slots are OPEN (srt-listen: any caller reaching the port). Restrict the UDP port in your cloud firewall to limit who can publish" - fi + # mediamtx's SRT server is disabled in listen mode, so the passphrase is enforced by + # each guest's PUBLIC listener instead (the A/V fan-out, tools/guest_av_listen.sh): + # unencrypted / wrong-key callers are rejected at the SRT handshake. + srt_listen_pass_check || exit 1 elif [ -n "$GUEST_CONF" ]; then umask 077; printf '%s\n' "$GUEST_CONF" > "$BASE/mediamtx.guest-auth.yml"; umask 022 MTX_CONF_ARGS=(-v "$BASE/mediamtx.guest-auth.yml":/mediamtx.yml:ro) @@ -420,9 +443,14 @@ if [ -n "$GUEST_IMAGE" ]; then } # A/V fan-out (srt-listen only): one public SRT listener splits the contributor's TS to # the local video + audio leg listeners. See tools/guest_av_listen.sh. - run_guest_fanout(){ # name public-port video-leg-port audio-leg-port + # The public listener enforces the slot's SRT passphrase (if set). It is handed over by + # env INHERITANCE (`-e NAME`, no value) so it never appears in the docker command line; + # empty = open listener. The 127.0.0.1 legs stay unencrypted (loopback only). + run_guest_fanout(){ # name public-port video-leg-port audio-leg-port slot(1|2) docker rm -f "$1-fanout" >/dev/null 2>&1 || true - docker run -d --name "$1-fanout" --network host --entrypoint sh \ + local pass; pass=$(_srt_pass_for "$5") + MXL_GUEST_SRT_PASSPHRASE="$pass" docker run -d --name "$1-fanout" --network host \ + -e MXL_GUEST_SRT_PASSPHRASE --entrypoint sh \ "$GUEST_IMAGE" -c "exec sh guest_av_listen.sh \"\$0\" \"\$1\" \"\$2\" 300" \ "$2" "$3" "$4" >/dev/null } @@ -440,8 +468,8 @@ if [ -n "$GUEST_IMAGE" ]; then run_guest_audio guest1 guest1 "$GUEST1_AUDIO_FLOW" "Guest 1 Audio" "$_g1a" 127.0.0.1 run_guest_audio guest2 guest2 "$GUEST2_AUDIO_FLOW" "Guest 2 Audio" "$_g2a" 127.0.0.1 fi - run_guest_fanout guest1 "$_g1pub" "$_g1v" "$_g1a" - run_guest_fanout guest2 "$_g2pub" "$_g2v" "$_g2a" + run_guest_fanout guest1 "$_g1pub" "$_g1v" "$_g1a" 1 + run_guest_fanout guest2 "$_g2pub" "$_g2v" "$_g2a" 2 else # srt-direct/rtsp: all guests multiplex on mediamtx:8890 (the port args are ignored). run_guest guest1 guest1 "$GUEST1_FLOW" "Guest 1" @@ -564,6 +592,13 @@ fi sleep 4 GUEST_PASS_NOTE="" [ -n "$GUEST_CONF" ] && [ "$GUEST_TRANSPORT" != srt-listen ] && GUEST_PASS_NOTE=" Passphrase: the SRT passphrase you configured for that slot (encryption AES, required)" +if [ "$GUEST_TRANSPORT" = srt-listen ]; then + [ -n "$SRT_LISTEN_REQUIRED" ] && GUEST_PASS_NOTE=" Passphrase: the SRT passphrase you configured (required for: ${SRT_LISTEN_REQUIRED% }; Larix: Passphrase field · ffmpeg/OBS: &passphrase=...)" + if [ -n "$SRT_LISTEN_OPEN" ]; then + GUEST_PASS_NOTE="${GUEST_PASS_NOTE:+$GUEST_PASS_NOTE +} ⚠ OPEN (no passphrase): ${SRT_LISTEN_OPEN% } — anyone reaching the port can publish. Set MXL_GUEST_SRT_PASSPHRASE." + fi +fi # Transport-aware "put your face on air" help: srt-listen has per-guest ports + no # streamid; srt-direct/rtsp share 8890 with a publish:guestN streamid. if [ "$GUEST_TRANSPORT" = srt-listen ]; then diff --git a/tests/test_srt_listen_passphrase.py b/tests/test_srt_listen_passphrase.py new file mode 100644 index 0000000..94afce3 --- /dev/null +++ b/tests/test_srt_listen_passphrase.py @@ -0,0 +1,228 @@ +"""srt-listen SRT passphrase: the public guest listener can require encryption. + +Covers the three places the passphrase flows through in srt-listen mode: + * SrtListenerGuestAdapter / guest_ingest.py (single-port listener) + * tools/guest_av_listen.sh (the A/V fan-out that owns the public port) + * scripts/quickstart.sh (validation, warnings, hand-off to the fan-out) +Unset = previous behaviour (open listener, byte-identical launch string) + a loud warning. +Hardware-free: gi is stubbed (conftest.py) and gst-launch-1.0 is a PATH stub. +""" +import os +import re +import runpy +import shutil +import subprocess +from pathlib import Path + +import pytest + +import contribution_core as cc +from adapters import SrtListenerGuestAdapter, srt_listen_passphrase + +ROOT = Path(__file__).resolve().parent.parent +QS = (ROOT / "scripts" / "quickstart.sh").read_text() +FANOUT = ROOT / "tools" / "guest_av_listen.sh" +BASH = shutil.which("bash") +SH = shutil.which("dash") or shutil.which("sh") + +GOOD = "Ab-c.d_e~f-0123456789" # every allowed punctuation char +BAD = ["short", "x" * 80, "has space-123456", "has'quote-123456", 'has"quote-12345', + "back\\slash-12345", "bang!bang-123456", "semi;colon-123456", "dollar$sign-12345", + "plus+sign-123456", "pct%41-123456789", "amp&mode=caller-1", "slash/colon:at@-1"] + + +def _fid(n=1): + return f"9e111e00-aaaa-4bbb-8ccc-00000000000{n}" + + +# ── adapter ────────────────────────────────────────────────────────────────── +def test_adapter_default_has_no_passphrase(): + a = SrtListenerGuestAdapter(path="guest1", flow_id=_fid(), label="Guest 1") + assert "passphrase" not in a.source_fragment() + + +def test_adapter_emits_passphrase_on_the_srtsrc_only(): + a = SrtListenerGuestAdapter(path="guest1", flow_id=_fid(), label="Guest 1", + listen_port=8890, passphrase=GOOD) + frag = a.source_fragment() + assert frag.startswith( + f'srtsrc uri="srt://0.0.0.0:8890?mode=listener&latency=300" passphrase="{GOOD}" ! tsdemux') + assert frag.count("passphrase") == 1 + assert GOOD not in a.description # secret never lands in flow metadata + + +def test_core_launch_with_passphrase_keeps_back_half_identical(): + plain = cc.ContributionCore(SrtListenerGuestAdapter( + path="guest1", flow_id=_fid(), label="Guest 1", listen_port=8990), repair_url="") + locked = cc.ContributionCore(SrtListenerGuestAdapter( + path="guest1", flow_id=_fid(), label="Guest 1", listen_port=8990, passphrase=GOOD), + repair_url="") + assert locked.pipe._launch == plain.pipe._launch.replace( + 'latency=300" ', f'latency=300" passphrase="{GOOD}" ', 1) + assert "mxlsink" in locked.pipe._launch and GOOD not in locked.pipe._launch.split("mxlsink", 1)[1] + + +@pytest.mark.parametrize("bad", BAD) +def test_adapter_rejects_invalid_passphrase(bad): + with pytest.raises(ValueError): + srt_listen_passphrase(bad) + with pytest.raises(ValueError): + SrtListenerGuestAdapter(path="guest1", flow_id=_fid(), label="Guest 1", passphrase=bad) + + +def test_empty_passphrase_means_open(): + assert srt_listen_passphrase("") == "" and srt_listen_passphrase(None) == "" + + +# ── guest_ingest.py (srt-listen entrypoint) ────────────────────────────────── +class _FakeCore: + last = None + + def __init__(self, adapter, **_kw): + _FakeCore.last = adapter + + def run(self): + pass + + +def _run_ingest(monkeypatch, passphrase): + monkeypatch.setattr(cc, "ContributionCore", _FakeCore) + monkeypatch.setenv("MXL_GUEST_TRANSPORT", "srt-listen") + monkeypatch.setenv("MXL_GUEST_LISTEN_PORT", "8890") + monkeypatch.setenv("MXL_GUEST_LISTEN_HOST", "0.0.0.0") + if passphrase is None: + monkeypatch.delenv("MXL_GUEST_SRT_PASSPHRASE", raising=False) + else: + monkeypatch.setenv("MXL_GUEST_SRT_PASSPHRASE", passphrase) + monkeypatch.setattr("sys.argv", ["guest_ingest.py", "guest1", _fid(), "Guest 1", "1000"]) + _FakeCore.last = None + runpy.run_path(str(ROOT / "tools" / "guest_ingest.py"), run_name="__main__") + return _FakeCore.last + + +def test_guest_ingest_applies_passphrase(monkeypatch, capsys): + a = _run_ingest(monkeypatch, GOOD) + assert f'passphrase="{GOOD}"' in a.source_fragment() + err = capsys.readouterr().err + assert "REQUIRED" in err and GOOD not in err + + +def test_guest_ingest_unset_is_open_and_warns(monkeypatch, capsys): + a = _run_ingest(monkeypatch, None) + assert "passphrase" not in a.source_fragment() + assert "WARNING" in capsys.readouterr().err + + +def test_guest_ingest_invalid_passphrase_fails_closed(monkeypatch): + with pytest.raises(SystemExit) as e: + _run_ingest(monkeypatch, "has space-123456") + assert "MXL_GUEST_SRT_PASSPHRASE invalid" in str(e.value) + assert _FakeCore.last is None # never started an open listener + + +# ── tools/guest_av_listen.sh (A/V fan-out, owns the public port) ───────────── +def _run_fanout(tmp_path, passphrase): + """Run the fan-out with a stub gst-launch-1.0 that records its argv (one word per + line) and then kills the supervising loop.""" + bindir = tmp_path / "bin"; bindir.mkdir() + argv_log = tmp_path / "argv" + stub = bindir / "gst-launch-1.0" + stub.write_text('#!/bin/sh\nfor a in "$@"; do printf "%s\\n" "$a"; done > "$ARGV_LOG"\n' + 'kill -TERM "$PPID"\n') + stub.chmod(0o755) + env = {"PATH": f"{bindir}:/usr/bin:/bin", "ARGV_LOG": str(argv_log)} + if passphrase is not None: + env["MXL_GUEST_SRT_PASSPHRASE"] = passphrase + r = subprocess.run([SH, str(FANOUT), "8890", "8990", "9090", "300"], env=env, + capture_output=True, text=True, timeout=20) + words = argv_log.read_text().splitlines() if argv_log.exists() else None + return r, words + + +@pytest.mark.skipif(SH is None, reason="sh not available") +def test_fanout_passes_passphrase_as_its_own_argv_word(tmp_path): + r, words = _run_fanout(tmp_path, GOOD) + assert words is not None, r.stderr + i = words.index("srtsrc") + assert words[i + 1] == "uri=srt://0.0.0.0:8890?mode=listener&latency=300" + assert words[i + 2] == f"passphrase={GOOD}" + assert sum(w.startswith("passphrase=") for w in words) == 1 # loopback legs stay plain + assert "REQUIRED" in r.stderr and GOOD not in r.stderr + + +@pytest.mark.skipif(SH is None, reason="sh not available") +def test_fanout_unset_is_unchanged_and_warns(tmp_path): + r, words = _run_fanout(tmp_path, None) + assert words is not None, r.stderr + assert not any(w.startswith("passphrase") for w in words) + assert "WARNING" in r.stderr and "OPEN" in r.stderr + + +@pytest.mark.skipif(SH is None, reason="sh not available") +@pytest.mark.parametrize("bad", BAD) +def test_fanout_invalid_passphrase_exits_before_listening(tmp_path, bad): + r, words = _run_fanout(tmp_path, bad) + assert r.returncode == 2 and words is None # gst-launch never ran + assert "MXL_GUEST_SRT_PASSPHRASE" in r.stderr + + +# ── scripts/quickstart.sh ──────────────────────────────────────────────────── +def _block(): + m = re.search(r"# --- BEGIN guest-srt-conf.*?\n(.*?)\n# --- END guest-srt-conf ---", QS, re.S) + assert m + return m.group(1) + + +def _listen_check(env): + e = {k: v for k, v in os.environ.items() if not k.startswith("MXL_")} + e.update(env) + script = _block() + '\nsrt_listen_pass_check; rc=$?\necho "REQ=[$SRT_LISTEN_REQUIRED] OPEN=[$SRT_LISTEN_OPEN]"\nexit $rc' + return subprocess.run([BASH, "-c", script], capture_output=True, text=True, env=e) + + +@pytest.mark.skipif(BASH is None, reason="bash not available") +def test_quickstart_listen_unset_warns_loudly(): + r = _listen_check({}) + assert r.returncode == 0 + assert "WARNING" in r.stdout and "OPEN" in r.stdout + assert "REQ=[] OPEN=[guest1 guest2 ]" in r.stdout + + +@pytest.mark.skipif(BASH is None, reason="bash not available") +def test_quickstart_listen_shared_and_per_guest(): + r = _listen_check({"MXL_GUEST_SRT_PASSPHRASE": GOOD}) + assert r.returncode == 0 and "REQ=[guest1 guest2 ] OPEN=[]" in r.stdout + assert "WARNING" not in r.stdout and GOOD not in r.stdout + r = _listen_check({"MXL_GUEST1_SRT_PASSPHRASE": GOOD}) + assert r.returncode == 0 and "REQ=[guest1 ] OPEN=[guest2 ]" in r.stdout + assert "WARNING" in r.stdout # a partially-open setup still warns + + +@pytest.mark.skipif(BASH is None, reason="bash not available") +@pytest.mark.parametrize("bad", BAD) +def test_quickstart_listen_rejects_invalid(bad): + r = _listen_check({"MXL_GUEST2_SRT_PASSPHRASE": bad}) + assert r.returncode == 1 and "srt-listen" in r.stderr + + +def test_quickstart_listen_branch_enforces_check(): + assert "srt_listen_pass_check || exit 1" in QS + assert "is ignored in srt-listen mode" not in QS # the old "we drop it" message is gone + + +def test_quickstart_fanout_gets_passphrase_via_env_inheritance_only(): + m = re.search(r"run_guest_fanout\(\)\{.*?\n \}\n", QS, re.S) + assert m, "run_guest_fanout not found" + body = m.group(0) + assert 'pass=$(_srt_pass_for "$5")' in body + assert 'MXL_GUEST_SRT_PASSPHRASE="$pass" docker run' in body + assert "-e MXL_GUEST_SRT_PASSPHRASE " in body # name only: value not in argv + assert '-e "MXL_GUEST_SRT_PASSPHRASE=' not in body + assert 'run_guest_fanout guest1 "$_g1pub" "$_g1v" "$_g1a" 1' in QS + assert 'run_guest_fanout guest2 "$_g2pub" "$_g2v" "$_g2a" 2' in QS + + +def test_quickstart_legs_never_get_the_passphrase(): + for fn in ("run_guest", "run_guest_audio"): + m = re.search(fn + r"\(\)\{.*?\n \}\n", QS, re.S) + assert m and "SRT_PASSPHRASE" not in m.group(0) diff --git a/tools/adapters.py b/tools/adapters.py index 28e80b7..e5b65e9 100644 --- a/tools/adapters.py +++ b/tools/adapters.py @@ -7,6 +7,8 @@ See docs/CONTRIBUTION-SEAM.md. """ +import re + from contribution_core import SourceAdapter @@ -100,6 +102,23 @@ def source_fragment(self) -> str: f'! queue max-size-buffers=8 ') +# SRT passphrase for a PUBLIC srt-listen listener (MXL_GUEST_SRT_PASSPHRASE). libsrt wants +# 10-79 chars; we also restrict it to URL-unreserved characters so it survives a GStreamer +# launch string / gst-launch argv AND a caller's `srt://...&passphrase=` URL unescaped +# (ffmpeg URL-decodes '%', '+' etc., which silently yields a different key). +# With a passphrase set, srtsrc rejects unencrypted and wrong-key callers at handshake. +SRT_PASSPHRASE_RE = re.compile(r'^[A-Za-z0-9._~-]{10,79}$') + + +def srt_listen_passphrase(value): + """Validate an srt-listen passphrase. '' / None -> '' (listener stays open).""" + value = (value or '').strip() + if value and not SRT_PASSPHRASE_RE.match(value): + raise ValueError('SRT passphrase must be 10-79 chars of A-Z a-z 0-9 . _ ~ - ' + '(e.g. `openssl rand -hex 16`)') + return value + + class SrtListenerGuestAdapter(SourceAdapter): """Guest VIDEO via SRT-DIRECT-LISTEN: the ingest IS the SRT listener — the contributor's SRT caller lands straight on srtsrc, no mediamtx in the contribution @@ -116,11 +135,13 @@ class SrtListenerGuestAdapter(SourceAdapter): One listener owns one UDP port, so each guest slot binds its own SRT port (8890 + slot offset). The contributor's deep-link/QR points straight here.""" def __init__(self, path: str, flow_id: str, label: str, latency_ms: int = 300, - listen_port: int = 8890, listen_host: str = '0.0.0.0'): + listen_port: int = 8890, listen_host: str = '0.0.0.0', passphrase: str = ''): self.path = path self.flow_id = flow_id self.label = label self.latency_ms = latency_ms + # Optional SRT encryption on the listener (validated; never put in the description). + self._passphrase = srt_listen_passphrase(passphrase) # Default binds all interfaces so the public caller reaches us (one port per # guest). The A/V fan-out (guest_av_listen) binds 127.0.0.1 instead: there the # public listener is the fan-out, and this leg only reads the local split. @@ -144,7 +165,8 @@ def source_fragment(self) -> str: # oldest decoded frame instead of back-pressuring the network leg, so srtsrc keeps # draining the socket and never trips -5. Sized by time (400ms) so it tolerates a # conform hiccup without unbounded latency. (Diagnosed on HW Oct 6 2026.) - return (f'srtsrc uri="{self._uri}" ! tsdemux name=d d. ' + pp = f' passphrase="{self._passphrase}"' if self._passphrase else '' + return (f'srtsrc uri="{self._uri}"{pp} ! tsdemux name=d d. ' f'! h264parse ! avdec_h264 max-threads=4 thread-type=frame ' f'! queue leaky=downstream max-size-time=400000000 max-size-buffers=0 ' f'max-size-bytes=0 ') diff --git a/tools/guest_av_listen.sh b/tools/guest_av_listen.sh index c4e7c5c..290ccb8 100755 --- a/tools/guest_av_listen.sh +++ b/tools/guest_av_listen.sh @@ -22,6 +22,12 @@ # can't publish the same stream to two sockets. The split must happen server-side. # # Usage: guest_av_listen.sh [latency_ms] +# +# Encryption: if MXL_GUEST_SRT_PASSPHRASE is set (10-79 chars of A-Za-z0-9._~-), +# the PUBLIC listener requires it — unencrypted / wrong-key callers are rejected at the +# SRT handshake. Unset = open listener (previous behaviour), with a loud warning. The two +# 127.0.0.1 legs stay unencrypted (loopback only). Pass the variable via the environment +# (e.g. `docker run -e MXL_GUEST_SRT_PASSPHRASE`) rather than on a command line. # POSIX sh-safe (the container entrypoint may be dash): no `pipefail`, no bashisms. set -eu @@ -29,14 +35,34 @@ PUBLIC_PORT="${1:?public SRT port}" VIDEO_PORT="${2:?local video leg port}" AUDIO_PORT="${3:?local audio leg port}" LATENCY_MS="${4:-300}" +PASS="${MXL_GUEST_SRT_PASSPHRASE:-}" + +# Validate before binding anything: a bad value must fail loudly, never fall back to open. +if [ -n "$PASS" ]; then + case "$PASS" in + *[!A-Za-z0-9._~-]*) + echo "guest_av_listen: MXL_GUEST_SRT_PASSPHRASE has unsupported characters (allowed: A-Z a-z 0-9 . _ ~ -)" >&2 + exit 2 ;; + esac + if [ "${#PASS}" -lt 10 ] || [ "${#PASS}" -gt 79 ]; then + echo "guest_av_listen: MXL_GUEST_SRT_PASSPHRASE must be 10-79 characters" >&2 + exit 2 + fi + echo "guest_av_listen: SRT passphrase REQUIRED on :${PUBLIC_PORT}" >&2 +else + echo "guest_av_listen: WARNING :${PUBLIC_PORT} is an OPEN SRT listener (MXL_GUEST_SRT_PASSPHRASE unset) — anyone who reaches the port can publish" >&2 +fi # Supervised loop: if the contributor disconnects (EOS) or a leg restarts, gst-launch # exits and we rebuild. The leg listeners (video/audio cores) are supervised separately; # our srtsink callers reconnect to them. wait-for-connection=false so the pipeline comes # up and listens even before a contributor or a leg is present. +# The passphrase (if any) rides as its own argv word ("$@"), never spliced into the URI. +if [ -n "$PASS" ]; then set -- passphrase="$PASS"; else set --; fi + while :; do gst-launch-1.0 -e \ - srtsrc uri="srt://0.0.0.0:${PUBLIC_PORT}?mode=listener&latency=${LATENCY_MS}" \ + srtsrc uri="srt://0.0.0.0:${PUBLIC_PORT}?mode=listener&latency=${LATENCY_MS}" "$@" \ ! tsparse set-timestamps=true ! tee name=t \ t. ! queue ! srtsink uri="srt://127.0.0.1:${VIDEO_PORT}?mode=caller&latency=${LATENCY_MS}" \ wait-for-connection=false \ diff --git a/tools/guest_ingest.py b/tools/guest_ingest.py index 400585d..d10842f 100644 --- a/tools/guest_ingest.py +++ b/tools/guest_ingest.py @@ -54,10 +54,24 @@ def _guest1_default(): adapter = SrtGuestAdapter(path=PATH, flow_id=DST, label=LABEL, latency_ms=JITTER_MS, rtsp_host=HOST) elif TRANSPORT == 'srt-listen': - from adapters import SrtListenerGuestAdapter # direct: srtsrc mode=listener + from adapters import SrtListenerGuestAdapter, srt_listen_passphrase # srtsrc mode=listener + # Optional SRT encryption for a PUBLIC listener: MXL_GUEST_SRT_PASSPHRASE (the same + # variable srt-direct uses). Unset = open listener (previous behaviour). Behind the + # A/V fan-out (127.0.0.1 leg) the fan-out enforces it on the public port instead. + try: + PASSPHRASE = srt_listen_passphrase(os.environ.get('MXL_GUEST_SRT_PASSPHRASE', '')) + except ValueError as e: + sys.exit(f'guest_ingest: MXL_GUEST_SRT_PASSPHRASE invalid: {e}') + if PASSPHRASE: + print(f'guest_ingest: SRT passphrase REQUIRED on {LISTEN_HOST}:{LISTEN_PORT}', + file=sys.stderr, flush=True) + elif LISTEN_HOST not in ('127.0.0.1', 'localhost', '::1'): + print(f'guest_ingest: WARNING srt-listen on {LISTEN_HOST}:{LISTEN_PORT} is OPEN ' + '(no MXL_GUEST_SRT_PASSPHRASE) - anyone who reaches the port can publish', + file=sys.stderr, flush=True) adapter = SrtListenerGuestAdapter(path=PATH, flow_id=DST, label=LABEL, latency_ms=JITTER_MS, listen_port=LISTEN_PORT, - listen_host=LISTEN_HOST) + listen_host=LISTEN_HOST, passphrase=PASSPHRASE) else: from adapters import SrtGuestVideoAdapter # default: srtsrc ! tsdemux (via mediamtx) adapter = SrtGuestVideoAdapter(path=PATH, flow_id=DST, label=LABEL,