From 8b8385afaf01e351687e242fbffd2af83eb8823f Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Fri, 20 Dec 2024 17:47:01 -0800 Subject: [PATCH 01/10] Add a bunch of config info to README --- README.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a83cce4..d9de0c1 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Archlinux packages for the above should get you rolling immediately. Otherwise s This is a thousand mile up view to get you running quickly. You should review the rest of the README (including the security considerations below) before actually putting anything here to use. -1. Install and configure Ovenmediaengine. The following components are required: +1. Install and configure Ovenmediaengine. Check the `example/` dir for a Server.xml to start with. The following components are required: 1. WebRTC publishing 2. The API enabled with a user/password set 3. Some number of applications 4. Applications configured with a producer webhook of `http://localhost:8080/admission` 2. Extract or clone this repository somewhere -3. Configure your HTTP daemon/proxy/etc to proxy HTTPS to `http://localhost:8080` +3. Configure your HTTP daemon/proxy/etc to proxy HTTPS to `http://localhost:8080`; check the Security section below for further guidance 4. Set up environment variables to your liking. The OvenMediaEngine API key and password are mandatory; see Configuration below 5. Start the management engine with either `python3 main.py` or a systemd unit as noted in `examples/` @@ -36,7 +36,18 @@ By default this provides a few things: - `https:///` will provide a "Discord like" interface to every stream live in the current app - `https:////` will display only that stream -- `https:////` will, if configured, display a management interface to allow basic stream management +- `https:///` will, if configured, display a management interface to allow basic stream management + +Any stream is valid, but you must have a proper application configured in OvenMediaEngine to both receive streams and present them. As configured in the examples, the video server will push source videos back out, without re-encoding. This means it's extremely light, but your video pushing software must be configured a certain way. OvenMediaEngine recommends... + +- 0 bframes (or your video will slideshow) +- 1s keyframe interval +- zerolatency profile + +You can use either RTMP or WHIP to push video from OBS Studio, or any other streaming software. Ingest URLs should be... + +- `https:////?direction=whip` for WHIP +- `rtmp://:1935//` for RTMP # Configuration @@ -45,6 +56,10 @@ All configuration is done with environment variables. If using systemd you can c Check out the config files in the `examples/` dir to see available configuration arguments. +The one configuration that is mandatory is the population of the `OVENMONITOR_API_USER` and `OVENMONITOR_API_PASSWORD` variables. These must match an API user configured in OvenMediaEngine's `Server.xml`. + +The `OVENMONITOR_WEBHOOK_*` variables are optional and setting them all enables OvenEmprex's Discord webhook functionality, which will inform the given Discord Webhook (or possibly any webhook if the format matches) when someone is live on the server or not. + # Customization @@ -57,7 +72,7 @@ There's only a couple supported methods of customization at this time: # Security -For the moment, security is the responsibility of the HTTP proxy. The CherryPy app does not do any kind of authentication (and you want to do authentication). You *should not* simply proxy all HTTPS traffic to the app. You should add basic authentication for your `/management*` endpoints, and also add authentication to the endpoint named after your OvenMediaEngine apps if you want to secure them. You also need `/assets*` proxied without auth to the app. +For the moment, security is the responsibility of the HTTP proxy. The CherryPy app does not do any kind of authentication (and you want to do authentication). You *should not* simply proxy all HTTPS traffic to the app and call it a day. You should add basic authentication for your `/management*` endpoints, and also add authentication to the endpoint named after your OvenMediaEngine applications if you want to secure them. You also need `/assets*` proxied without auth to the CherryPy app. Even still, someone who knows an exact stream key can currently get the Websocket for your WebRTC sessions and the RTMP URL to push. This is an inherited weakness from OvenMediaEngine and would be a 2.0 goal to add viewer authentication and passphrases to the Admission Webhook. @@ -72,4 +87,6 @@ In addition, OvenMediaEngine has been known to have a recurring bug where its AP - HTTP proxy also listening on :3334 and proxying all HTTP traffic to localhost:3333 - HTTP proxy applying basic auth of some form to `/management*` -tl;dr: This is no more or less secure than an RTMP server sitting on the open internet if you firewall stuff. +tl;dr: This is no more or less secure than an RTMP server sitting on the open internet if you firewall stuff but OvenMediaEngine has some quirks to be aware of and CherryPy assumes security is being performed by the proxy. + +This all fits *my* use-case but is a 2.0 item to fix. From 70eb03568fb3d3715ae2ee2c4d30c9b4465ad9cb Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Fri, 20 Dec 2024 17:52:50 -0800 Subject: [PATCH 02/10] Add Mako requirement, oops --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d9de0c1..7d9bfc7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This project tries to be pretty lean. Requirements should be roughly... - Python 3.8 or greater - python-cherrypy - python-requests +- python-mako Archlinux packages for the above should get you rolling immediately. Otherwise setting up a virtualenv is recommended. From b727e87eaf12cee755d5d6f22ca1d50715fd572f Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Fri, 20 Dec 2024 18:18:47 -0800 Subject: [PATCH 03/10] Add screenshot to README --- README.md | 5 +++++ example/sample.png | Bin 0 -> 41996 bytes 2 files changed, 5 insertions(+) create mode 100644 example/sample.png diff --git a/README.md b/README.md index 7d9bfc7..56e0d64 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ OvenMediaEngine management middleware +# Screenshot + +![A sample two-stream view of OvenEmprex's default interface](example/sample.png 'Sample screenshot') + + # Requirements This project tries to be pretty lean. Requirements should be roughly... diff --git a/example/sample.png b/example/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..ba12a34d4a6c7cd85a89db6b37cfbaf12b0c1bff GIT binary patch literal 41996 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 literal 0 HcmV?d00001 From 97e20ae0d00177be46547f7e8fa1183ec0d0adbe Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Fri, 20 Dec 2024 20:37:03 -0800 Subject: [PATCH 04/10] Add status endpoint to recommended proxy bypass --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56e0d64..c0733f9 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ There's only a couple supported methods of customization at this time: # Security -For the moment, security is the responsibility of the HTTP proxy. The CherryPy app does not do any kind of authentication (and you want to do authentication). You *should not* simply proxy all HTTPS traffic to the app and call it a day. You should add basic authentication for your `/management*` endpoints, and also add authentication to the endpoint named after your OvenMediaEngine applications if you want to secure them. You also need `/assets*` proxied without auth to the CherryPy app. +For the moment, security is the responsibility of the HTTP proxy. The CherryPy app does not do any kind of authentication (and you want to do authentication). You *should not* simply proxy all HTTPS traffic to the app and call it a day. You should add basic authentication for your `/management*` endpoints, and also add authentication to the endpoint named after your OvenMediaEngine applications if you want to secure them. You also need `/status*` and `/assets*` proxied without auth to the CherryPy app. Even still, someone who knows an exact stream key can currently get the Websocket for your WebRTC sessions and the RTMP URL to push. This is an inherited weakness from OvenMediaEngine and would be a 2.0 goal to add viewer authentication and passphrases to the Admission Webhook. From a490fc56621114ee8861e91e90a6b1c4c42c00f0 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Wed, 25 Dec 2024 19:48:13 -0800 Subject: [PATCH 05/10] Rework admission webhook to not need sleep There's a one-second sleep in the old hook, because the Oven API did not always record the stream change quickly. Sometimes even that was not enough. This attempts to work around that by managing the stream list directly. --- admission.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/admission.py b/admission.py index d493145..532e531 100644 --- a/admission.py +++ b/admission.py @@ -63,14 +63,31 @@ def check_authorized(host, app, stream, source) -> bool: @cherrypy.tools.register("on_end_request") def handle_notify() -> None: + """ + Inspect and react to live streamer state after an admission webhook has fired. + + After the new stream has been authorized and the connection closed, this hook fires. + It expects two variables inserted into the cherrypy.request namespace named "update_stream" + and "update_opening" to let it process who has changed and in which direction. After + altering our stream list appropriately it checks if a notification webhook needs to fire. + """ # If we don't have API creds we can't do this, abort if not (config.API_USER and config.API_PASS): return - # Get stream list from API - # Unfortunately Oven doesn't reflect the new stream fast enough so we have to wait :( - time.sleep(1) - stream_list = ovenapi.OvenAPI(config.API_USER, config.API_PASS).get_stream_list() + # If we didn't fill out a state change, we don't need to do anything + if not hasattr(cherrypy.request, "update_opening") or not hasattr(cherrypy.request, "update_stream"): + return + + # Copy over our prior stream list so we have a clean one to alter + stream_list = config.LAST_STREAM_LIST.copy() + + # Remove or add the changed stream, as appropriate + if cherrypy.request.update_opening and cherrypy.request.update_stream not in stream_list: + stream_list.append(cherrypy.request.update_stream) + + if not cherrypy.request.update_opening and cherrypy.request.update_stream in stream_list: + stream_list.remove(cherrypy.request.update_stream) # If we haven't gone empty->active or active->empty we need to do nothing if bool(stream_list) != bool(config.LAST_STREAM_LIST): @@ -82,6 +99,7 @@ def handle_notify() -> None: webhook_online(stream_list[0]) if stream_list else webhook_offline() # Save our stream list into a durable value + cherrypy.log(str(stream_list)) config.LAST_STREAM_LIST = stream_list.copy() @@ -108,8 +126,12 @@ class Admission: _, _, host, app, path = input_json["request"]["url"].split("/")[:5] stream = path.split("?")[0] + # Populate variables for our on_end_request tool into request object + cherrypy.request.update_stream = ("default", app, stream) + # If we are closing, return a fast 200 if input_json["request"]["status"] == "closing": + cherrypy.request.update_opening = False return {} # Get client IP for ACL checking @@ -121,4 +143,5 @@ class Admission: return {"allowed": False} # Compile and dispatch our response + cherrypy.request.update_opening = True return {"allowed": True} From e5c73cd5341cda3ce9c1d181587019d48b9fb985 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Wed, 25 Dec 2024 19:51:37 -0800 Subject: [PATCH 06/10] Remove API readiness check from admission hook We don't need API access anymore in the admission webhook. --- admission.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/admission.py b/admission.py index 532e531..10ddbe2 100644 --- a/admission.py +++ b/admission.py @@ -71,10 +71,6 @@ def handle_notify() -> None: and "update_opening" to let it process who has changed and in which direction. After altering our stream list appropriately it checks if a notification webhook needs to fire. """ - # If we don't have API creds we can't do this, abort - if not (config.API_USER and config.API_PASS): - return - # If we didn't fill out a state change, we don't need to do anything if not hasattr(cherrypy.request, "update_opening") or not hasattr(cherrypy.request, "update_stream"): return From d1767bc1b4735a03e7c648383ec7757b923b70c0 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Sat, 28 Dec 2024 19:15:47 -0800 Subject: [PATCH 07/10] Record changes to stream list sooner We used to wait until all work in the admission webhook was done to save out the new list of streams as edited by the webhook. This produces two issues: 1. It was possible to encounter a race condition if a second webhook fired while the first was still processing the callout to Discord 2. If the webhook throttle engaged, `handle_notify()` would return before saving the list out at all Now we inspect the list, decide what we're going to do, save the new list out, and then call the Discord webhook as appropriate. --- admission.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/admission.py b/admission.py index 10ddbe2..c91411c 100644 --- a/admission.py +++ b/admission.py @@ -85,8 +85,15 @@ def handle_notify() -> None: if not cherrypy.request.update_opening and cherrypy.request.update_stream in stream_list: stream_list.remove(cherrypy.request.update_stream) - # If we haven't gone empty->active or active->empty we need to do nothing - if bool(stream_list) != bool(config.LAST_STREAM_LIST): + # Figure out if we changed state between "someone online" and "no one online" + changed = bool(stream_list) != bool(config.LAST_STREAM_LIST) + + # Save our stream list out before dispatching any webhooks + # We do this before any network callouts to try to prevent race conditions + # FIXME: The right way to handle this is threadsafe locking + config.LAST_STREAM_LIST = stream_list.copy() + + if changed: if not check_webhook_throttle(): cherrypy.log("Webhook throttle limit hit, ignoring") return @@ -94,10 +101,6 @@ def handle_notify() -> None: # Dispatch the appropriate webhook webhook_online(stream_list[0]) if stream_list else webhook_offline() - # Save our stream list into a durable value - cherrypy.log(str(stream_list)) - config.LAST_STREAM_LIST = stream_list.copy() - class Admission: # /admission to control/trigger sessions From bc79be8a96fbfc7def16cb78fa14fab92daca8b6 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Mon, 30 Dec 2024 03:17:11 -0800 Subject: [PATCH 08/10] Add CSRF protection to destructive endpoints For management endpoints that change server state (restart, ban, etc), add a referer header check to safeguard against both CSRF and accidental browser history completion. Closes #1 --- management.py | 58 ++++++++++++++++++++++++++++++++++++++++++++------- viewer.py | 3 ++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/management.py b/management.py index c7d637b..493ec40 100644 --- a/management.py +++ b/management.py @@ -1,5 +1,6 @@ import subprocess from pathlib import Path +from urllib.parse import urlparse import cherrypy from mako.template import Template @@ -8,37 +9,65 @@ import config import ovenapi -@cherrypy.tools.register("on_end_request") -def restart_server() -> None: - subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"]) - - class Management: def __init__(self): self.page_template = Path("template/management.mako").read_text(encoding="utf-8") self.redirect_template = Path("template/message.mako").read_text(encoding="utf-8") self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS) + @staticmethod + def __verify_same_domain() -> bool: + """ + Verify that the requested domain and referer domain match. + + This is mainly intended to be used as a guard for "destructive" endpoints such as + /management/restart. This safeguards against cross-site requests as well as accidental + history completions such as intents to access /management but one's browser helpfully + populates /management/restart. + """ + referer = cherrypy.request.headers.get("Referer", "").lower() + + # For comparing request and referer domains we drop the port + referer_domain = urlparse(referer).netloc.split(":")[0] + request_domain = urlparse(cherrypy.request.base).netloc.split(":")[0] + + return referer_domain == request_domain + + @staticmethod + def __restart_server() -> None: + subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"]) + def __message_and_redirect(self, message: str) -> bytes | str: return Template(self.redirect_template).render(message=message) @cherrypy.expose - @cherrypy.tools.restart_server() def restart(self) -> bytes | str: + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" + # Blank our stream list because we're about to DC everyone config.LAST_STREAM_LIST = [] + self.__restart_server() + # Compile and dispatch our response - return self.__message_and_redirect("Restart command dispatched") + return self.__message_and_redirect("Server restarted") @cherrypy.expose def disconnect(self, target): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" vhost, app, stream = target.split(":") self.api.disconnect_key(vhost, app, stream) return self.__message_and_redirect(f"Disconnected {target}") @cherrypy.expose def ban(self, target): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" vhost, app, stream = target.split(":") ip = self.api.get_stream_ip(vhost, app, stream) if ip: @@ -49,6 +78,9 @@ class Management: @cherrypy.expose def unban(self, target): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" if target in config.BLOCKED_IPS: config.BLOCKED_IPS.remove(target) return self.__message_and_redirect(f"Unbanned {target}") @@ -56,12 +88,18 @@ class Management: @cherrypy.expose def disable(self, target): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" config.DISABLED_KEYS.append(target) self.disconnect(target) return self.__message_and_redirect(f"Disabled key {target}") @cherrypy.expose def enable(self, target): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" if target in config.DISABLED_KEYS: config.DISABLED_KEYS.remove(target) return self.__message_and_redirect(f"Re-enabled {target}") @@ -69,12 +107,18 @@ class Management: @cherrypy.expose def stop(self): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" config.DISABLED = True self.api.disconnect_all() return self.__message_and_redirect("Server disabled") @cherrypy.expose def start(self): + if not self.__verify_same_domain(): + cherrypy.response.status = 403 + return "Cross-site request detected. Please go back to /management and try again" config.DISABLED = False return self.__message_and_redirect("Server re-enabled") diff --git a/viewer.py b/viewer.py index 10e8fe9..a38d8d3 100644 --- a/viewer.py +++ b/viewer.py @@ -1,4 +1,5 @@ from pathlib import Path +from urllib.parse import urlparse import cherrypy from mako.template import Template @@ -39,7 +40,7 @@ class Viewer: return "App not found" # Get domain for templates - domain = cherrypy.request.base.split("/")[-1].split(":")[0] + domain = urlparse(cherrypy.request.base).netloc # Any subpath is presumed to be a single player interface for app/stream if "page" in params: From 25d260dccd1b8c049db4c4af8d07f588c36b31dd Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Thu, 2 Jan 2025 08:31:56 -0800 Subject: [PATCH 09/10] Remember volume settings between streams This is same-session only. We still don't store any session data or cookies. We dump mute and volume settings to an object when a webcall stream is reaped, then restore them if the stream comes back. This should address hardship in a lossy connection or someone "quickly restarting OBS" needing to re-unmute the player. Closes #5 --- assets/player.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/assets/player.js b/assets/player.js index 8c1620f..60c2bf9 100644 --- a/assets/player.js +++ b/assets/player.js @@ -7,6 +7,7 @@ xhr.onreadystatechange = function() { } disabledPlayers = []; +playerVolumeSettings = {}; // Auto-resize frames in a webcall interface function webcallFrameResize() { @@ -146,8 +147,12 @@ function closePlayer(containerId) { } function destroyPlayerById(containerId) { - // Tear down player player = OvenPlayer.getPlayerByContainerId(containerId); + + // Get our volume settings to save for re-use if this player comes back + playerVolumeSettings[containerId] = [player.getMute(), player.getVolume()]; + + // Tear down player player.remove(); // Delete frame @@ -171,7 +176,16 @@ function processStreamList(streams) { // Create any player in the list that doesn't have one streams.forEach((i, index) => { if (OvenPlayer.getPlayerByContainerId(i) == null) { - createPlayer(i, true, 100); + // Check if we have volume settings for this player + var muted = true; + var volume = 100; + if (i in playerVolumeSettings) { + muted = playerVolumeSettings[i][0]; + volume = playerVolumeSettings[i][1]; + } + + // Create the player with noted settings or defaults + createPlayer(i, muted, volume); } }) From fab8d6bb239481125fc19ade2accb06bb6f44619 Mon Sep 17 00:00:00 2001 From: Trysdyn Black Date: Tue, 7 Jan 2025 09:39:09 -0800 Subject: [PATCH 10/10] Remove ovenapi import from admissions --- admission.py | 1 - 1 file changed, 1 deletion(-) diff --git a/admission.py b/admission.py index c91411c..0658a85 100644 --- a/admission.py +++ b/admission.py @@ -5,7 +5,6 @@ import cherrypy import requests import config -import ovenapi def check_webhook_throttle() -> bool: