Compare commits
10 commits
96904eca5c
...
fab8d6bb23
Author | SHA1 | Date | |
---|---|---|---|
fab8d6bb23 | |||
25d260dccd | |||
bc79be8a96 | |||
d1767bc1b4 | |||
e5c73cd534 | |||
a490fc5662 | |||
97e20ae0d0 | |||
b727e87eaf | |||
70eb03568f | |||
8b8385afaf |
6 changed files with 130 additions and 27 deletions
33
README.md
33
README.md
|
@ -3,6 +3,11 @@
|
|||
OvenMediaEngine management middleware
|
||||
|
||||
|
||||
# Screenshot
|
||||
|
||||

|
||||
|
||||
|
||||
# Requirements
|
||||
|
||||
This project tries to be pretty lean. Requirements should be roughly...
|
||||
|
@ -11,6 +16,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.
|
||||
|
||||
|
@ -19,13 +25,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 +42,18 @@ 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>/<appname>/<management>` will, if configured, display a management interface to allow basic stream management
|
||||
- `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
|
||||
|
||||
|
||||
# Configuration
|
||||
|
@ -45,6 +62,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 +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. 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 `/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.
|
||||
|
||||
|
@ -72,4 +93,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.
|
||||
|
|
45
admission.py
45
admission.py
|
@ -5,7 +5,6 @@ import cherrypy
|
|||
import requests
|
||||
|
||||
import config
|
||||
import ovenapi
|
||||
|
||||
|
||||
def check_webhook_throttle() -> bool:
|
||||
|
@ -63,17 +62,37 @@ def check_authorized(host, app, stream, source) -> bool:
|
|||
|
||||
@cherrypy.tools.register("on_end_request")
|
||||
def handle_notify() -> None:
|
||||
# If we don't have API creds we can't do this, abort
|
||||
if not (config.API_USER and config.API_PASS):
|
||||
"""
|
||||
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"):
|
||||
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()
|
||||
# Copy over our prior stream list so we have a clean one to alter
|
||||
stream_list = config.LAST_STREAM_LIST.copy()
|
||||
|
||||
# If we haven't gone empty->active or active->empty we need to do nothing
|
||||
if bool(stream_list) != bool(config.LAST_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 not check_webhook_throttle():
|
||||
cherrypy.log("Webhook throttle limit hit, ignoring")
|
||||
return
|
||||
|
@ -81,9 +100,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
|
||||
config.LAST_STREAM_LIST = stream_list.copy()
|
||||
|
||||
|
||||
class Admission:
|
||||
# /admission to control/trigger sessions
|
||||
|
@ -108,8 +124,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 +141,5 @@ class Admission:
|
|||
return {"allowed": False}
|
||||
|
||||
# Compile and dispatch our response
|
||||
cherrypy.request.update_opening = True
|
||||
return {"allowed": True}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
|
|
BIN
example/sample.png
Normal file
BIN
example/sample.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
|
@ -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")
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue