summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--etc/nginx/sites-available/yummers.dev6
-rwxr-xr-xopt/obsproxy/server.py40
3 files changed, 47 insertions, 1 deletions
diff --git a/README.md b/README.md
index ef49f2d..01f9f89 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ function get_live {
2. Install runtime dependencies: `pip install -r opt/obsproxy/requirements.txt`.
3. Start the Python service (see `etc/systemd/system/obsproxy.service` for a sample unit). The bundled entrypoint now runs under [Waitress](https://docs.pylonsproject.org/projects/waitress/en/stable/) so you get a production-grade WSGI server out of the box.
-4. When the service starts it prints a session-specific playlist URL like `https://<your-domain>/hls/<session-hex>/stream.m3u8`; share that exact URL with your VRChat video player. Multiple viewers can consume the feed concurrently.
+4. When the service starts it prints a session-specific playlist URL like `https://<your-domain>/hls/<session-hex>/stream.m3u8`; share that exact URL with your VRChat video player. The manifest now advertises AES-128 encryption with a companion key URL in the same session-scoped directory, so the player must support standard HLS key retrieval. Multiple viewers can consume the feed concurrently.
RTMPS termination happens at nginx (default port `1935`) via the `ngx_stream_module`, which proxies plain RTMP to the local nginx-rtmp listener on `127.0.0.1:1936`. Update the `INGEST_RTMP_*` settings if you run the backend elsewhere, and make sure the stream module is installed/enabled (on Debian/Ubuntu install `libnginx-mod-stream`).
diff --git a/etc/nginx/sites-available/yummers.dev b/etc/nginx/sites-available/yummers.dev
index c1162a6..5eb4fef 100644
--- a/etc/nginx/sites-available/yummers.dev
+++ b/etc/nginx/sites-available/yummers.dev
@@ -51,6 +51,12 @@ server {
add_header Access-Control-Allow-Origin "*" always;
}
+ # Key files must never be cached client-side
+ location ~ \.key$ {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header Access-Control-Allow-Origin "*" always;
+ }
+
# Segment files (.ts) can be cached - they're immutable
location ~ \.ts$ {
add_header Cache-Control "public, max-age=30" always;
diff --git a/opt/obsproxy/server.py b/opt/obsproxy/server.py
index df6edb3..e4a2750 100755
--- a/opt/obsproxy/server.py
+++ b/opt/obsproxy/server.py
@@ -7,6 +7,7 @@ import threading
import shutil
import time
from pathlib import Path
+from typing import Optional
from flask import Flask, request
import logging
import atexit
@@ -34,6 +35,9 @@ SERVER_DOMAIN = os.environ.get('SERVER_DOMAIN', 'yummers.b-cdn.net')
STREAM_HEX = secrets.token_hex(16)
STREAM_PATH = BASE_DIR / 'live' / STREAM_HEX
HLS_ROUTE_PREFIX = f"/hls/{STREAM_HEX}"
+SESSION_KEY_NAME = 'session.key'
+SESSION_KEYINFO_NAME = 'session.keyinfo'
+SESSION_KEY_URI: Optional[str] = None
# Media output settings tuned for VRChat playback
AUDIO_BITRATE = '256k'
AUDIO_CHANNELS = 2
@@ -65,10 +69,38 @@ if not INGEST_PSK:
BASE_DIR.mkdir(parents=True, exist_ok=True)
STREAM_PATH.mkdir(parents=True, exist_ok=True)
+
+def _session_key_path() -> Path:
+ return STREAM_PATH / SESSION_KEY_NAME
+
+
+def _session_keyinfo_path() -> Path:
+ return STREAM_PATH / SESSION_KEYINFO_NAME
+
+
+def _write_key_material() -> None:
+ """Generate and persist AES-128 key + keyinfo for the current session."""
+ global SESSION_KEY_URI
+
+ key_bytes = secrets.token_bytes(16)
+ iv_bytes = secrets.token_bytes(16)
+ key_path = _session_key_path()
+ key_path.write_bytes(key_bytes)
+
+ key_uri = f"https://{SERVER_DOMAIN}{HLS_ROUTE_PREFIX}/{SESSION_KEY_NAME}"
+ keyinfo_path = _session_keyinfo_path()
+ iv_hex = format(int.from_bytes(iv_bytes, 'big'), '032x')
+ keyinfo_path.write_text(
+ f"{key_uri}\n{key_path}\n{iv_hex}\n",
+ encoding="utf-8",
+ )
+ SESSION_KEY_URI = key_uri
+
def reset_stream_path():
"""Ensure the live stream directory is empty and ready."""
shutil.rmtree(STREAM_PATH, ignore_errors=True)
STREAM_PATH.mkdir(parents=True, exist_ok=True)
+ _write_key_material()
def _safe_reset_stream_path(context: str) -> bool:
@@ -160,6 +192,11 @@ def _terminate_ffmpeg_process(process: subprocess.Popen[str], *, timeout: float
def _build_ffmpeg_command() -> list[str]:
"""Construct the ffmpeg command line we execute for each attempt."""
+ keyinfo_path = _session_keyinfo_path()
+ if not keyinfo_path.exists():
+ _write_key_material()
+
+ keyinfo_path = _session_keyinfo_path()
return [
'ffmpeg',
'-nostdin',
@@ -180,6 +217,7 @@ def _build_ffmpeg_command() -> list[str]:
'-hls_list_size', str(HLS_PLAYLIST_SIZE),
'-hls_flags', 'delete_segments+independent_segments',
'-hls_delete_threshold', str(HLS_DELETE_THRESHOLD),
+ '-hls_key_info_file', str(keyinfo_path),
'-hls_segment_filename', str(STREAM_PATH / 'segment-%05d.ts'),
str(STREAM_PATH / 'stream.m3u8'),
]
@@ -381,6 +419,8 @@ def print_instructions():
print("\n[URLS]")
print(f" OBS ingest: {obs_url}")
print(f" HLS: {hls_url}")
+ if SESSION_KEY_URI:
+ print(f" HLS key: {SESSION_KEY_URI}")
print("\n[STATUS]")
print(f" Stream is {'ACTIVE' if ffmpeg_process else 'INACTIVE'}")