Compare commits

...

10 commits

Author SHA1 Message Date
fab8d6bb23 Remove ovenapi import from admissions 2025-01-07 09:39:09 -08:00
25d260dccd 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
2025-01-02 08:31:56 -08:00
bc79be8a96 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
2024-12-30 03:18:04 -08:00
d1767bc1b4 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.
2024-12-28 19:15:47 -08:00
e5c73cd534 Remove API readiness check from admission hook
We don't need API access anymore in the admission webhook.
2024-12-25 19:51:37 -08:00
a490fc5662 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.
2024-12-25 19:48:13 -08:00
97e20ae0d0 Add status endpoint to recommended proxy bypass 2024-12-20 20:37:03 -08:00
b727e87eaf Add screenshot to README 2024-12-20 18:18:47 -08:00
70eb03568f Add Mako requirement, oops 2024-12-20 17:52:50 -08:00
8b8385afaf Add a bunch of config info to README 2024-12-20 17:47:01 -08:00
6 changed files with 130 additions and 27 deletions

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

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

View file

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