diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | etc/nginx/sites-available/yummers.dev | 6 | ||||
| -rwxr-xr-x | opt/obsproxy/server.py | 40 |
3 files changed, 47 insertions, 1 deletions
@@ -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'}") |
