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
# 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://<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>/<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
- `https://<domain>/<appname>/<management>` 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.

View file

@ -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}

View file

@ -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);
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -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")

View file

@ -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: