diff --git a/README.md b/README.md index c0733f9..a83cce4 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,6 @@ 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... @@ -16,7 +11,6 @@ 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. @@ -25,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. Check the `example/` dir for a Server.xml to start with. The following components are required: +1. Install and configure Ovenmediaengine. 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`; check the Security section below for further guidance +3. Configure your HTTP daemon/proxy/etc to proxy HTTPS to `http://localhost:8080` 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/` @@ -42,18 +36,7 @@ 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 - -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 +- `https:////` will, if configured, display a management interface to allow basic stream management # Configuration @@ -62,10 +45,6 @@ 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 @@ -78,7 +57,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 `/status*` and `/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. 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. 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. @@ -93,6 +72,4 @@ 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 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. +tl;dr: This is no more or less secure than an RTMP server sitting on the open internet if you firewall stuff. diff --git a/admission.py b/admission.py index 0658a85..d493145 100644 --- a/admission.py +++ b/admission.py @@ -5,6 +5,7 @@ import cherrypy import requests import config +import ovenapi def check_webhook_throttle() -> bool: @@ -62,37 +63,17 @@ 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 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"): + # If we don't have API creds we can't do this, abort + if not (config.API_USER and config.API_PASS): return - # Copy over our prior stream list so we have a clean one to alter - stream_list = config.LAST_STREAM_LIST.copy() + # 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() - # 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) - - # 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 we haven't gone empty->active or active->empty we need to do nothing + if bool(stream_list) != bool(config.LAST_STREAM_LIST): if not check_webhook_throttle(): cherrypy.log("Webhook throttle limit hit, ignoring") return @@ -100,6 +81,9 @@ 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 + config.LAST_STREAM_LIST = stream_list.copy() + class Admission: # /admission to control/trigger sessions @@ -124,12 +108,8 @@ 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 @@ -141,5 +121,4 @@ class Admission: return {"allowed": False} # Compile and dispatch our response - cherrypy.request.update_opening = True return {"allowed": True} diff --git a/assets/player.js b/assets/player.js index 60c2bf9..8c1620f 100644 --- a/assets/player.js +++ b/assets/player.js @@ -7,7 +7,6 @@ xhr.onreadystatechange = function() { } disabledPlayers = []; -playerVolumeSettings = {}; // Auto-resize frames in a webcall interface function webcallFrameResize() { @@ -147,12 +146,8 @@ function closePlayer(containerId) { } function destroyPlayerById(containerId) { - 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 = OvenPlayer.getPlayerByContainerId(containerId); player.remove(); // Delete frame @@ -176,16 +171,7 @@ function processStreamList(streams) { // Create any player in the list that doesn't have one streams.forEach((i, index) => { if (OvenPlayer.getPlayerByContainerId(i) == null) { - // 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); + createPlayer(i, true, 100); } }) diff --git a/example/sample.png b/example/sample.png deleted file mode 100644 index ba12a34..0000000 Binary files a/example/sample.png and /dev/null differ diff --git a/management.py b/management.py index 493ec40..c7d637b 100644 --- a/management.py +++ b/management.py @@ -1,6 +1,5 @@ import subprocess from pathlib import Path -from urllib.parse import urlparse import cherrypy from mako.template import Template @@ -9,65 +8,37 @@ 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("Server restarted") + return self.__message_and_redirect("Restart command dispatched") @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: @@ -78,9 +49,6 @@ 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}") @@ -88,18 +56,12 @@ 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}") @@ -107,18 +69,12 @@ 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 a38d8d3..10e8fe9 100644 --- a/viewer.py +++ b/viewer.py @@ -1,5 +1,4 @@ from pathlib import Path -from urllib.parse import urlparse import cherrypy from mako.template import Template @@ -40,7 +39,7 @@ class Viewer: return "App not found" # Get domain for templates - domain = urlparse(cherrypy.request.base).netloc + domain = cherrypy.request.base.split("/")[-1].split(":")[0] # Any subpath is presumed to be a single player interface for app/stream if "page" in params: