Compare commits

..

No commits in common. "fab8d6bb239481125fc19ade2accb06bb6f44619" and "96904eca5c6b01822c092e0b39b1c455832ca744" have entirely different histories.

6 changed files with 27 additions and 130 deletions

View file

@ -3,11 +3,6 @@
OvenMediaEngine management middleware OvenMediaEngine management middleware
# Screenshot
![A sample two-stream view of OvenEmprex's default interface](example/sample.png 'Sample screenshot')
# Requirements # Requirements
This project tries to be pretty lean. Requirements should be roughly... 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 3.8 or greater
- python-cherrypy - python-cherrypy
- python-requests - python-requests
- python-mako
Archlinux packages for the above should get you rolling immediately. Otherwise setting up a virtualenv is recommended. 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. 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 1. WebRTC publishing
2. The API enabled with a user/password set 2. The API enabled with a user/password set
3. Some number of applications 3. Some number of applications
4. Applications configured with a producer webhook of `http://localhost:8080/admission` 4. Applications configured with a producer webhook of `http://localhost:8080/admission`
2. Extract or clone this repository somewhere 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 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/` 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://<domain>/<appname>` will provide a "Discord like" interface to every stream live in the current app - `https://<domain>/<appname>` will provide a "Discord like" interface to every stream live in the current app
- `https://<domain>/<appname>/<streamname>` will display only that stream - `https://<domain>/<appname>/<streamname>` will display only that stream
- `https://<domain>/<management>` will, if configured, display a management interface to allow basic stream management - `https://<domain>/<appname>/<management>` 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://<domain>/<appname>/<streamname>?direction=whip` for WHIP
- `rtmp://<domain>:1935/<appname>/<streamname>` for RTMP
# Configuration # 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. 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 # Customization
@ -78,7 +57,7 @@ There's only a couple supported methods of customization at this time:
# Security # 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. 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 also listening on :3334 and proxying all HTTP traffic to localhost:3333
- HTTP proxy applying basic auth of some form to `/management*` - 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. tl;dr: This is no more or less secure than an RTMP server sitting on the open internet if you firewall stuff.
This all fits *my* use-case but is a 2.0 item to fix.

View file

@ -5,6 +5,7 @@ import cherrypy
import requests import requests
import config import config
import ovenapi
def check_webhook_throttle() -> bool: def check_webhook_throttle() -> bool:
@ -62,37 +63,17 @@ def check_authorized(host, app, stream, source) -> bool:
@cherrypy.tools.register("on_end_request") @cherrypy.tools.register("on_end_request")
def handle_notify() -> None: def handle_notify() -> None:
""" # If we don't have API creds we can't do this, abort
Inspect and react to live streamer state after an admission webhook has fired. if not (config.API_USER and config.API_PASS):
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"):
return return
# Copy over our prior stream list so we have a clean one to alter # Get stream list from API
stream_list = config.LAST_STREAM_LIST.copy() # 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 we haven't gone empty->active or active->empty we need to do nothing
if cherrypy.request.update_opening and cherrypy.request.update_stream not in stream_list: if bool(stream_list) != bool(config.LAST_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 not check_webhook_throttle(): if not check_webhook_throttle():
cherrypy.log("Webhook throttle limit hit, ignoring") cherrypy.log("Webhook throttle limit hit, ignoring")
return return
@ -100,6 +81,9 @@ def handle_notify() -> None:
# Dispatch the appropriate webhook # Dispatch the appropriate webhook
webhook_online(stream_list[0]) if stream_list else webhook_offline() 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: class Admission:
# /admission to control/trigger sessions # /admission to control/trigger sessions
@ -124,12 +108,8 @@ class Admission:
_, _, host, app, path = input_json["request"]["url"].split("/")[:5] _, _, host, app, path = input_json["request"]["url"].split("/")[:5]
stream = path.split("?")[0] 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 we are closing, return a fast 200
if input_json["request"]["status"] == "closing": if input_json["request"]["status"] == "closing":
cherrypy.request.update_opening = False
return {} return {}
# Get client IP for ACL checking # Get client IP for ACL checking
@ -141,5 +121,4 @@ class Admission:
return {"allowed": False} return {"allowed": False}
# Compile and dispatch our response # Compile and dispatch our response
cherrypy.request.update_opening = True
return {"allowed": True} return {"allowed": True}

View file

@ -7,7 +7,6 @@ xhr.onreadystatechange = function() {
} }
disabledPlayers = []; disabledPlayers = [];
playerVolumeSettings = {};
// Auto-resize frames in a webcall interface // Auto-resize frames in a webcall interface
function webcallFrameResize() { function webcallFrameResize() {
@ -147,12 +146,8 @@ function closePlayer(containerId) {
} }
function destroyPlayerById(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 // Tear down player
player = OvenPlayer.getPlayerByContainerId(containerId);
player.remove(); player.remove();
// Delete frame // Delete frame
@ -176,16 +171,7 @@ function processStreamList(streams) {
// Create any player in the list that doesn't have one // Create any player in the list that doesn't have one
streams.forEach((i, index) => { streams.forEach((i, index) => {
if (OvenPlayer.getPlayerByContainerId(i) == null) { if (OvenPlayer.getPlayerByContainerId(i) == null) {
// Check if we have volume settings for this player createPlayer(i, true, 100);
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);
} }
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -1,6 +1,5 @@
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import cherrypy import cherrypy
from mako.template import Template from mako.template import Template
@ -9,65 +8,37 @@ import config
import ovenapi import ovenapi
@cherrypy.tools.register("on_end_request")
def restart_server() -> None:
subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"])
class Management: class Management:
def __init__(self): def __init__(self):
self.page_template = Path("template/management.mako").read_text(encoding="utf-8") 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.redirect_template = Path("template/message.mako").read_text(encoding="utf-8")
self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS) 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: def __message_and_redirect(self, message: str) -> bytes | str:
return Template(self.redirect_template).render(message=message) return Template(self.redirect_template).render(message=message)
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.restart_server()
def restart(self) -> bytes | str: 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 # Blank our stream list because we're about to DC everyone
config.LAST_STREAM_LIST = [] config.LAST_STREAM_LIST = []
self.__restart_server()
# Compile and dispatch our response # Compile and dispatch our response
return self.__message_and_redirect("Server restarted") return self.__message_and_redirect("Restart command dispatched")
@cherrypy.expose @cherrypy.expose
def disconnect(self, target): 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(":") vhost, app, stream = target.split(":")
self.api.disconnect_key(vhost, app, stream) self.api.disconnect_key(vhost, app, stream)
return self.__message_and_redirect(f"Disconnected {target}") return self.__message_and_redirect(f"Disconnected {target}")
@cherrypy.expose @cherrypy.expose
def ban(self, target): 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(":") vhost, app, stream = target.split(":")
ip = self.api.get_stream_ip(vhost, app, stream) ip = self.api.get_stream_ip(vhost, app, stream)
if ip: if ip:
@ -78,9 +49,6 @@ class Management:
@cherrypy.expose @cherrypy.expose
def unban(self, target): 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: if target in config.BLOCKED_IPS:
config.BLOCKED_IPS.remove(target) config.BLOCKED_IPS.remove(target)
return self.__message_and_redirect(f"Unbanned {target}") return self.__message_and_redirect(f"Unbanned {target}")
@ -88,18 +56,12 @@ class Management:
@cherrypy.expose @cherrypy.expose
def disable(self, target): 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) config.DISABLED_KEYS.append(target)
self.disconnect(target) self.disconnect(target)
return self.__message_and_redirect(f"Disabled key {target}") return self.__message_and_redirect(f"Disabled key {target}")
@cherrypy.expose @cherrypy.expose
def enable(self, target): 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: if target in config.DISABLED_KEYS:
config.DISABLED_KEYS.remove(target) config.DISABLED_KEYS.remove(target)
return self.__message_and_redirect(f"Re-enabled {target}") return self.__message_and_redirect(f"Re-enabled {target}")
@ -107,18 +69,12 @@ class Management:
@cherrypy.expose @cherrypy.expose
def stop(self): 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 config.DISABLED = True
self.api.disconnect_all() self.api.disconnect_all()
return self.__message_and_redirect("Server disabled") return self.__message_and_redirect("Server disabled")
@cherrypy.expose @cherrypy.expose
def start(self): 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 config.DISABLED = False
return self.__message_and_redirect("Server re-enabled") return self.__message_and_redirect("Server re-enabled")

View file

@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import cherrypy import cherrypy
from mako.template import Template from mako.template import Template
@ -40,7 +39,7 @@ class Viewer:
return "App not found" return "App not found"
# Get domain for templates # 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 # Any subpath is presumed to be a single player interface for app/stream
if "page" in params: if "page" in params: