This commit is contained in:
Hamcha 2024-06-21 18:40:58 -04:00
commit 1c27053a9d
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
11 changed files with 1200 additions and 0 deletions

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# OvenStarter
This is a template to get started using OvenMediaEngine. It has one very specific audience: Streamers proficient in server management and fucking around with HTML and code wanting to start selfhosting a bit more without quitting popular platforms like Twitch.
Most of this is my own setup which I've torn apart a little and added tons of comments everywhere I saw fit.
This README hopefully will work as a guide on how to use the provided files in this repo but feel free to hit me up at [hamcha@crunchy.rocks](mailto:hamcha@crunchy.rocks) if you need some help.
**What this is NOT**: A comprehensive, beginner-friendly, battery-powered set-up. Prepare to get your hands dirty and possibly dig through some manuals. You will most definetely want to use this as a starting point only and customize the hell out of it.
## Requirements
The ingredients you will need to get started are:
- Some server running any flavor of Docker that supports docker-compose
- TLS certificate setup using LetsEncrypt or something else
You may be able to get away without a certificate setup but you'd be playing a risky game.
## Setting up the oven
Clone this repo somewhere, look at the docker compose file.
Make sure the ports used by OME are available. If you need to change some, note them down since you'll have to change them in the config file as well.
Set the `OME_HOST_IP` to whatever IP or domain name your server is reachable at. You're gonna use that IP/domain for your RTMP ingest and output feeds.
The config folder contains the configuration files, including the most important one, `Server.xml`. I added plenty of comments to it so please look through it all and configure it to your liking.
Once everything has been set up properly, start it with `docker compose up` (and add `-d` for detached yadda yadda hopefully you know the deal)
## The SignedPolicy bit
The config file specifies two credentials to set up, a API access key (for later) and something about SignedPolicy. SignedPolicy is the main authentication method that OME uses to prevent anyone from streaming your content. It has additional benefits like being able to provide lock content or transcoding options behing paywalls as well as provide self-expiring URLs for both streaming and viewing. You can explore the whole thing for yourself but for us it's mostly an annoyance, here is how it works:
Every URL for either viewing (through WebRTC or HLS) and streaming (RTMP, SRT, WHIP) must be accompanied by a policy object that provides limits to that URL (at minimum an expiration date) and a signature that signs the whole thing as "valid" by whoever has the secret key.
In other words, you'll need to use whatever secret key you specified in the config file to create signed URLs both to put in your OBS for streaming and in whatever web player for viewing.
OME provides a very bland shell script for this but I re-implemented all of it as a webpage on https://nebula.cafe/ome.html (all running with clientside javascript)
If you don't trust using a website you can use the `ome.html` page bundled in this repo, it's the same file and its code is not minimized in any way (feel free to snoop to see how the signing process works, in fact).
The URLs you're looking to sign are most likely the following:
### Viewer endpoints (WebRTC, HLS)
These URLs are used in the web player for letting users access your stream, they look something like this:
- `wss://your.server.ip:3334/app/stream/webrtc`
- `https://your.server.ip:3334/app/stream/llhls.m3u8`
after using the tool to sign them, they would look something like this:
`wss://your.server.ip:3334/app/stream/webrtc?policy=eyJ1cmxfZXhwaXJlIjo2Mjg5MzE4ODAwMDAwfQ&signature=whGrRzv7GrNbHy8pIcG2qEPmeVo`
This is what you need to use as the URL in your web player.
### Ingest URLs (RTMP, SRT, WHIP)
To stream to OME you'll also need a signed URL. For RTMP you'll need to sign something akin to the following:
`rtmp://your.server.ip:1935/app/stream`
after using the tool, it should look like this:
`rtmp://your.server.ip:1935/app/stream?policy=eyJ1cmxfZXhwaXJlIjo2Mjg5MzE4ODAwMDAwfQ&signature=fjS6rae_bzOU6pP_SrM7aRI7eVs`
To use it in OBS, you'll need to split it in two like this:
- For server, use `rtmp://your.server.ip:1935/app`
- For stream key, use `stream?policy=eyJ1cmxfZXhwaXJlIjo2Mjg5MzE4ODAwMDAwfQ&signature=fjS6rae_bzOU6pP_SrM7aRI7eVs`
> SRT and WHIP are work in progress since they're a bit annoying to figure out, I got it once and forgot about it, whoops. I'll update this later I swear!
## Setting up the player
The player is the most scuffed part of it all currently. I provided you with my scuffy setup for you to hack at, just mess with the HTML and JS file until it looks passable to you. The biggest problem is that there is no proper offline state and the player will error out when a stream isn't playing. I'd like to make it proper but for now it is what it is.
## Re-streaming
You will have noticed the config file has nothing about restreaming to Twitch/Youtube. This is a bit of a weird thing for me as well because currently the only way to restream via the RTMP publisher (which *is* enabled via the config) is to use API calls to set ip up and start it.
> WIP! Gonna fill this up later, I forgot my Bruno workspace at home and need to finesse my current overlay setup so I can include it in here.
## Notes
I currently use a LetsEncrypt setup for certificates, which mean they change roughly every couple months. This means OME needs to reload the cert files every once in a while and I do this with a cronjob that just copies those files over and restarts the container. If you have an `acme.json` file because of managing certificates through tools like Traefik, check `update-cert.sh` on how to extract specific certificates/keys from that.
The API bit is annoying not only because it's not configurable via the config file but also because you have to redo the calls basically every stream. For some reasons the "pushes" expire in a fashion that I cannot seem to understand. I just use the overlay solution described. I hope a nicer solution comes up to address this.
## License
I guess this needs some? The bits in `player/vendor` are provided under their own permissive license, treat anything else as [WTFPL](http://www.wtfpl.net) or whatever.

14
config/Logger.xml Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<Logger version="2">
<!-- Log file location -->
<Path>/var/log/ovenmediaengine</Path>
<!-- Disable some SRT internal logs -->
<Tag name="SRT" level="critical" />
<Tag name="HttpServer" level="warn" />
<Tag name=".*\.Stat" level="warn" />
<!-- Log level: [debug, info, warn, error, critical] -->
<Tag name=".*" level="info" />
</Logger>

411
config/Server.xml Normal file
View file

@ -0,0 +1,411 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
This is a stripped down config meant for streamers looking to self-host, rebroadcast
and maybe offer transcoding for their content. OvenMediaEngine supports extra features
not covered here like VOD storage, scheduled contents and multiplexing, check the website
https://airensoft.gitbook.io/ovenmediaengine for a more comprehesive feature list.
-->
<Server version="8">
<!-- Not sure this is used anywhere, just call it whatever -->
<Name>Your stream "service" name</Name>
<!-- Single node setup -->
<Type>origin</Type>
<!-- Specify IP address to bind ("*" means all IPv4 IPs, "::" means all IPv6 IPs) -->
<!-- Uncomment the line below to enable IPv6 -->
<!-- <IP>::</IP> -->
<IP>*</IP>
<!-- Anonymize IPs in logs for compliance with privacy laws -->
<PrivacyProtection>true</PrivacyProtection>
<!--
To get the public IP address(mapped address of stun) of the local server.
This is useful when OME cannot obtain a public IP from an interface, such as AWS or docker environment.
If this is successful, you can use ${PublicIP} in your settings.
-->
<StunServer>stun.ovenmediaengine.com:13478</StunServer>
<Modules>
<!--
Currently OME only supports h2 like all browsers do. Therefore, HTTP/2 only works on TLS ports.
-->
<HTTP2>
<Enable>true</Enable>
</HTTP2>
<!--
Low Latency HLS is a Apple-specific thing, don't expect it to work elsewhere!
Other clients will treat is as HLS with its normal latency of a few seconds.
-->
<LLHLS>
<Enable>true</Enable>
</LLHLS>
<!-- P2P works only in WebRTC and is experiment feature -->
<P2P>
<!-- disabled by default -->
<Enable>false</Enable>
<MaxClientPeersPerHostPeer>2</MaxClientPeersPerHostPeer>
</P2P>
</Modules>
<!-- Fuck with this to do your performance tuning and port shuffling -->
<Bind>
<Managers>
<API>
<Port>8081</Port>
<WorkerCount>1</WorkerCount>
</API>
</Managers>
<Providers>
<RTSPC>
<WorkerCount>1</WorkerCount>
</RTSPC>
<OVT>
<WorkerCount>1</WorkerCount>
</OVT>
<RTMP>
<Port>1935</Port>
<WorkerCount>1</WorkerCount>
</RTMP>
<SRT>
<Port>9999</Port>
<WorkerCount>1</WorkerCount>
</SRT>
<WebRTC>
<Signalling>
<Port>3333</Port>
<TLSPort>3334</TLSPort>
<WorkerCount>1</WorkerCount>
</Signalling>
<IceCandidates>
<!-- Uncomment the line below to use IPv6 ICE Candidate -->
<!-- <IceCandidate>[::]:10000-10004/udp</IceCandidate> -->
<IceCandidate>*:10000/udp</IceCandidate>
<TcpRelay>*:3478</TcpRelay>
<TcpForce>false</TcpForce>
<TcpRelayWorkerCount>1</TcpRelayWorkerCount>
</IceCandidates>
</WebRTC>
</Providers>
<Publishers>
<!-- The OVT is protocol for ORIGIN-EDGE -->
<OVT>
<Port>9000</Port>
<WorkerCount>1</WorkerCount>
</OVT>
<LLHLS>
<!--
OME only supports h2, so LLHLS works over HTTP/1.1 on non-TLS ports.
LLHLS works with higher performance over HTTP/2,
so it is recommended to use a TLS port.
-->
<Port>3333</Port>
<TLSPort>3334</TLSPort>
<WorkerCount>1</WorkerCount>
</LLHLS>
<WebRTC>
<Signalling>
<Port>3333</Port>
<TLSPort>3334</TLSPort>
<WorkerCount>1</WorkerCount>
</Signalling>
<IceCandidates>
<!-- Uncomment the line below to use IPv6 ICE Candidate -->
<!-- <IceCandidate>[::]:10000-10004/udp</IceCandidate> -->
<IceCandidate>*:10001-10004/udp</IceCandidate>
<!--
If you want to stream WebRTC over TCP, specify IP:Port for TURN server.
This uses the TURN protocol, which delivers the stream from the built-in TURN server to the player's TURN client over TCP.
-->
<TcpRelay>*:3478</TcpRelay>
<!--
TcpForce is an option to force the use of TCP rather than UDP in WebRTC streaming.
(You can omit ?transport=tcp accordingly.) If <TcpRelay> is not set, playback may fail.
-->
<TcpForce>false</TcpForce>
<TcpRelayWorkerCount>1</TcpRelayWorkerCount>
</IceCandidates>
</WebRTC>
</Publishers>
</Bind>
<Managers>
<Host>
<!--
Add your TLS-covered names here, if your certificate has multiple names
(e.g. wildcard cert) add more <Name> entries like the commented one
-->
<Names>
<Name>localhost</Name>
<!-- <Name>*.localhost</Name> -->
</Names>
<!--
Bring the path to your cert and key file, if your certificate contains the whole chain (likely)
use that for the last parameter. These files are relative to the config folder
-->
<TLS>
<CertPath>cert.crt</CertPath>
<KeyPath>cert.key</KeyPath>
<ChainCertPath>cert.crt</ChainCertPath>
</TLS>
</Host>
<API>
<!-- IMPORTANT: Change this to some username:password of choice -->
<AccessToken>admin:changeme</AccessToken>
<CrossDomains>
<Url>*</Url>
</CrossDomains>
</API>
</Managers>
<VirtualHosts>
<VirtualHost>
<Name>default</Name>
<!--
Distribution is a value that can be used when grouping the same vhost distributed across multiple servers.
This value is output to the events log, so you can use it to aggregate statistics.
-->
<Distribution>localhost</Distribution>
<!--
Bring the path to your cert and key file, if your certificate contains the whole chain (likely)
use that for the last parameter. These files are relative to the config folder
-->
<Host>
<!--
Add your TLS-covered names here, if your certificate has multiple names
(e.g. wildcard cert) add more <Name> entries like the commented one
-->
<Names>
<Name>localhost</Name>
<!-- <Name>*.localhost</Name> -->
</Names>
<TLS>
<CertPath>cert.crt</CertPath>
<KeyPath>cert.key</KeyPath>
<ChainCertPath>cert.crt</ChainCertPath>
</TLS>
</Host>
<!--
This is an important bit, it prevents just about anyone from streaming on this instance.
Set the secret key to some string of choice, then use https://nebula.cafe/ome.html
to sign URLs for both streaming and playback.
-->
<SignedPolicy>
<PolicyQueryKeyName>policy</PolicyQueryKeyName>
<SignatureQueryKeyName>signature</SignatureQueryKeyName>
<!-- IMPORTANT: Change this to some alphanumeric string of choice -->
<SecretKey>CHANGEME</SecretKey>
<Enables>
<Publishers>webrtc,hls,llhls,dash,lldash</Publishers>
<Providers>rtmp,webrtc,srt</Providers>
</Enables>
</SignedPolicy>
<CrossDomains>
<Url>*</Url>
</CrossDomains>
<!--
Every application is a streaming endpoint, this example just uses one but you
could theoretically host as many streams as you want with their own settings.
-->
<Applications>
<Application>
<Name>app</Name>
<!-- Application type (live/vod) -->
<Type>live</Type>
<OutputProfiles>
<!-- Enable this configuration if you want to hardware acceleration using GPU -->
<HWAccels>
<Decoder>
<Enable>false</Enable>
<!--
Setting for Hardware Modules.
- xma :Xilinx Media Accelerator
- qsv :Intel Quick Sync Video
- nv : Nvidia Video Codec SDK
- nilogan: Netint VPU
You can use multiple modules by separating them with commas.
For example, if you want to use xma and nv, you can set it as follows.
<Modules>[ModuleName]:[DeviceId],[ModuleName]:[DeviceId],...</Modules>
<Modules>xma:0,nv:0</Modules>
-->
<!-- <Modules>nv</Modules> -->
</Decoder>
<Encoder>
<Enable>false</Enable>
<!-- <Modules>nv</Modules> -->
</Encoder>
</HWAccels>
<OutputProfile>
<Name>stream</Name>
<OutputStreamName>stream</OutputStreamName>
<!-- Transcoding options go here -->
<Encodes>
<!--
Create bypass_video/audio for exposing the ingest streams
and serve them as "Source" options. This has basically no overhead.
-->
<Video>
<Name>bypass_video</Name>
<Bypass>true</Bypass>
</Video>
<Audio>
<Name>bypass_audio</Name>
<Bypass>True</Bypass>
</Audio>
<!--
Encode the audio as AAC and OPUS. The "BypassIfMatch" will replace the
encoding with the source audio stream if settings match.e
This has pretty negligible CPU usage.
-->
<Audio>
<Name>aac</Name>
<Codec>aac</Codec>
<Bitrate>128000</Bitrate>
<Samplerate>48000</Samplerate>
<Channel>2</Channel>
<BypassIfMatch>
<Codec>eq</Codec>
</BypassIfMatch>
</Audio>
<Audio>
<Name>opus</Name>
<Codec>opus</Codec>
<Bitrate>128000</Bitrate>
<Samplerate>48000</Samplerate>
<Channel>2</Channel>
<BypassIfMatch>
<Codec>eq</Codec>
</BypassIfMatch>
</Audio>
<!--
The following enables video transcoding.
You can have as many as you want, but they will cause a lot of CPU usage
(unless using hardware acceleration)
-->
<!--
<Video>
<Name>720p</Name>
<Codec>h264</Codec>
<Bitrate>2024000</Bitrate>
<Width>1280</Width>
<Height>720</Height>
<Framerate>30</Framerate>
<KeyFrameInterval>30</KeyFrameInterval>
<BFrames>0</BFrames>
<Preset>faster</Preset>
</Video>
-->
</Encodes>
<!--
You can provide ABR with Playlist. Currently, ABR is supported in LLHLS and WebRTC.
You can play this playlist with
LLHLS : http[s]://<domain>[:port]/<app>/<stream>/<FileName>.m3u8
WebRTC : ws[s]://<domain>[:port]/<app>/<stream>/<FileName>
Note that the keywords "playlist" and "chunklist" MUST NOT be included in FileName.
-->
<!--
The WebRTC playlist, there is no reason for using different playlists for LLHLS vs WebRTC
other than being able to provide different transcodes (if that's what you want) but mostly
it's for using OPUS instead of AAC for audio (HLS is stinky)
-->
<Playlist>
<Name>webrtc</Name>
<FileName>webrtc</FileName>
<Options>
<WebRtcAutoAbr>true</WebRtcAutoAbr>
</Options>
<!-- Each rendition is a quality option made of a video+audio encode, eg. 480p with AAC audio -->
<Rendition>
<Name>Source</Name>
<Video>bypass_video</Video>
<Audio>bypass_audio</Audio>
</Rendition>
<!-- If you enabled transcoding, you can uncomment the following and fiddle with it -->
<!--
<Rendition>
<Name>720p</Name>
<Video>720p</Video>
<Audio>opus</Audio>
</Rendition>
-->
</Playlist>
<Playlist>
<Name>llhls</Name>
<FileName>llhls</FileName>
<Rendition>
<Name>Source</Name>
<Video>bypass_video</Video>
<Audio>aac</Audio> <!-- Make sure we're using a supported audio format for HLS -->
</Rendition>
<!-- If you enabled transcoding, you can uncomment the following and fiddle with it -->
<!--
<Rendition>
<Name>720p</Name>
<Video>720p</Video>
<Audio>opus</Audio>
</Rendition>
-->
</Playlist>
</OutputProfile>
</OutputProfiles>
<Providers>
<WebRTC />
<RTMP />
<SRT />
<RTSPPull />
</Providers>
<Publishers>
<AppWorkerCount>1</AppWorkerCount>
<StreamWorkerCount>8</StreamWorkerCount>
<WebRTC>
<Timeout>30000</Timeout>
<Rtx>true</Rtx>
<Ulpfec>true</Ulpfec>
<JitterBuffer>false</JitterBuffer>
</WebRTC>
<LLHLS>
<OriginMode>false</OriginMode>
<CacheControl>
<MasterPlaylistMaxAge>0</MasterPlaylistMaxAge>
<ChunklistMaxAge>0</ChunklistMaxAge>
<ChunklistWithDirectivesMaxAge>60</ChunklistWithDirectivesMaxAge>
<SegmentMaxAge>-1</SegmentMaxAge>
<PartialSegmentMaxAge>-1</PartialSegmentMaxAge>
</CacheControl>
<ChunkDuration>0.5</ChunkDuration>
<PartHoldBack>1.5</PartHoldBack>
<SegmentDuration>6</SegmentDuration>
<SegmentCount>10</SegmentCount>
<CrossDomains>
<Url>*</Url>
</CrossDomains>
</LLHLS>
<RTMPPush>
</RTMPPush>
</Publishers>
</Application>
</Applications>
</VirtualHost>
</VirtualHosts>
</Server>

20
docker-compose.yml Normal file
View file

@ -0,0 +1,20 @@
version: '3'
services:
mediaengine:
image: 'airensoft/ovenmediaengine:0.16.5'
restart: always
environment:
OME_HOST_IP: "127.0.0.1" # Replace with public IP/domain
ports:
#- 8081:8081 # HTTP API
- 8082:8082 # HTTPS API
- 1935:1935 # RTMP ingest
- 9999:9999/udp # SRT ingest
#- 9000:9000 # OVT output (needed for multi-node systems)
- 3333:3333 # WebRTC/HLS output
- 3334:3334 # Like above, but with TLS encryption
- 3478:3478 # TCP relay port (WebRTC punchthrough)
- 10000-10009:10000-10009/udp # ICE UDP ports (WebRTC punchthrough)
volumes:
- './config:/opt/ovenmediaengine/bin/origin_conf'
- './logs:/var/log/ovenmediaengine'

499
ome.html Normal file
View file

@ -0,0 +1,499 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generate signed URL</title>
<link rel=stylesheet href="https://rsms.me/inter/inter.css">
</head>
<body>
<main>
<div class="pad-x">
<h2>OvenMediaEngine utility (unofficial)</h2>
<h1>Generate signed URL</h1>
<p>
This webpage saves you from needing to run the official <code>signed_policy_url_generator.sh</code>
shell script by using web browser's built-in Crypto APIs. If you want a more in-depth breakdown on how
to generate signed URLs, check the <a
href="https://airensoft.gitbook.io/ovenmediaengine/access-control/signedpolicy">official
documentation</a> on SignedPolicy.
</p>
<p>
<b>Note:</b>
This page works completely in your browser (check the source, it's not minimized or anything!) so your
keys should never reach my server, not even in logs!
</p>
<section class="input">
<fieldset>
<legend>URL to sign</legend>
<input type="url" name="url" required
placeholder="wss://your.ome.endpoint:3334/app/stream/webrtc" />
</fieldset>
<fieldset>
<legend>Secret key</legend>
<input type="password" name="secret" required placeholder="your secret key" />
</fieldset>
<fieldset>
<legend>Policy</legend>
<div>
<input type="radio" id="policy-unlimited" name="policy-preset" value="unlimited" checked />
<label for="policy-unlimited">Unlimited (very high <code>url_expire</code>)</label>
</div>
<div>
<input type="radio" id="policy-custom" name="policy-preset" value="custom" />
<label for="policy-custom">Custom (specify below as JSON object)</label>
</div>
<textarea name="custom-policy" disabled rows="5">{
"url_expire" : 0
}</textarea>
</fieldset>
<button id="generate">Generate signed URL</button>
<div id="error" class="output-box hidden"></div>
<div id="result" class="output-box hidden"></div>
</section>
</div>
</main>
<script>
// Using a form element would be really nice but I don't wanna risk javascript shenanigans
// sending the data to my server by adding a form tag that might be faulty, so
// I'll do it the boring way and re-implement form stuff in Javascript
/** @type {HTMLInputElement} */
const urlElement = document.querySelector("input[name=url]");
/** @type {HTMLInputElement} */
const secretElement = document.querySelector("input[name=secret]");
/** @type {HTMLInputElement} */
const policyElements = [...document.querySelectorAll("input[name=policy-preset]")];
/** @type {HTMLTextAreaElement} */
const customPolicyElement = document.querySelector("textarea[name=custom-policy]");
/** @type {HTMLButtonElement} */
const buttonElement = document.querySelector("button#generate");
/** @type {HTMLDivElement} */
const errorElement = document.querySelector("div#error");
/** @type {HTMLDivElement} */
const resultElement = document.querySelector("div#result");
function showError(message) {
errorElement.innerHTML = message;
errorElement.classList.remove("hidden");
}
function hideError() {
errorElement.classList.add("hidden");
}
function showResult(message) {
resultElement.innerHTML = message;
resultElement.classList.remove("hidden");
}
function hideResult() {
resultElement.classList.add("hidden");
}
/**
* Runs a function and catches any errors it might throw,
* then shows an error message if one is thrown.
* @template T
* @param {() => T} fn - The function to run
* @param {string} message - The error message to show
* @returns {T} - The return value of the function
*/
function must(fn, message) {
try {
return fn();
} catch (e) {
showError(`<b>${message}:</b> ${e.message}`);
}
}
const policyPresets = {
"unlimited": {
"url_expire": 6289318800000 // remember to update this before 2169-4-20
},
};
async function generateURL() {
hideError();
if (!urlElement.reportValidity()) return;
if (!secretElement.reportValidity()) return;
if (!customPolicyElement.reportValidity()) return;
const url = must(() => new URL(urlElement.value), "URL is not valid");
let toSign = new URL(url.href);
// Check if URL is SRT, if so, extract the sub-URL (streamid) and use that instead
if (url.protocol === "srt:") {
const streamid = url.searchParams.get("streamid");
if (!streamid) {
showError("Missing streamid in SRT url");
return;
}
toSign = must(() => new URL(streamid), "streamid is not a valid URL");
}
// If custom policy, parse and validate it, otherwise use the chosen preset
const policyPreset = policyElements.find((el) => el.checked).value;
const policy = policyPreset === "custom"
? must(() => JSON.parse(customPolicyElement.value), "Custom policy is not valid JSON")
: policyPresets[policyPreset];
// Convert policy to Base64
const binaryPolicy = new TextEncoder().encode(JSON.stringify(policy));
const encodedPolicy = new Base64UrlSafe(false).encode(binaryPolicy);
// Append policy to URL
toSign.searchParams.append("policy", encodedPolicy);
// Get secret as bytes
const secret = new TextEncoder().encode(secretElement.value);
// Create HMAC-SHA1
const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(toSign.href));
// Append policy and signature to original URL
url.searchParams.append("policy", encodedPolicy);
debugger;
url.searchParams.append("signature", new Base64UrlSafe(false).encode(new Uint8Array(signature)));
// Copy URL to clipboard
showResult(`URL generated: <code>${url.href}</code>`);
}
// Run URL generation on button click
buttonElement.addEventListener("click", () => generateURL());
// Toggle custom policy box (and requirement) on policy preset change
policyElements.forEach((el) => {
el.addEventListener("change", () => {
const isCustom = el.value === "custom";
customPolicyElement.required = isCustom;
customPolicyElement.disabled = !isCustom;
});
});
</script>
<script>
// https://github.com/jedisct1/js-base64-ct
// Copyright (c) 2021 Frank Denis
// License: MIT, full text: https://github.com/jedisct1/js-base64-ct/blob/master/LICENSE
function eq(x, y) { return (((0 - (x ^ y)) >> 16) & 0xffff) ^ 0xffff; }
function gt(x, y) { return ((y - x) >> 8) & 0xffff; }
function lt(x, y) { return gt(y, x); }
function ge(x, y) { return gt(y, x) ^ 0xffff; }
function le(x, y) { return ge(y, x); }
function byteToCharOriginal(x) {
const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) |
(ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) |
(ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '+'.charCodeAt(0)) |
(eq(x, 63) & '/'.charCodeAt(0));
return String.fromCharCode(c);
}
function charToByteOriginal(c) {
const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) |
(ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) |
(ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '+'.charCodeAt(0)) & 62) |
(eq(c, '/'.charCodeAt(0)) & 63);
return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff));
}
function byteToCharUrlSafe(x) {
const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) |
(ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) |
(ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '-'.charCodeAt(0)) |
(eq(x, 63) & '_'.charCodeAt(0));
return String.fromCharCode(c);
}
function charToByteUrlSafe(c) {
const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) |
(ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) |
(ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '-'.charCodeAt(0)) & 62) |
(eq(c, '_'.charCodeAt(0)) & 63);
return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff));
}
function bin2Base64(bin, padding, byteToChar) {
let bin_len = bin.length;
let nibbles = Math.floor(bin_len / 3);
let remainder = bin_len - 3 * nibbles;
let b64_len = nibbles * 4;
if (remainder) {
if (padding) {
b64_len += 4;
}
else {
b64_len += 2 + (remainder >> 1);
}
}
let b64 = "";
let acc = 0, acc_len = 0, bin_pos = 0;
while (bin_pos < bin_len) {
acc = ((acc << 8) + bin[bin_pos++]) & 0xfff;
acc_len += 8;
while (acc_len >= 6) {
acc_len -= 6;
b64 += byteToChar((acc >> acc_len) & 0x3F);
}
}
if (acc_len > 0) {
b64 += byteToChar((acc << (6 - acc_len)) & 0x3F);
}
while (b64.length < b64_len) {
b64 += '=';
}
return b64;
}
function skipPadding(b64, ignore, padding_len) {
let i = 0;
while (padding_len > 0) {
let c = b64[i++];
if (c == '=') {
padding_len--;
}
else if (!ignore || ignore.indexOf(c) < 0) {
throw new Error("Invalid base64 padding");
}
}
if (i !== b64.length) {
throw new Error("Invalid base64 padding length");
}
}
function base642Bin(b64, padding, ignore, charToByte) {
let b64_len = b64.length;
let bin = new Uint8Array(Math.ceil(b64_len * 3 / 4));
let acc = 0, acc_len = 0, bin_len = 0, b64_pos = 0;
while (b64_pos < b64_len) {
const c = b64[b64_pos];
const d = charToByte(c.charCodeAt(0));
if (d == 0xffff) {
if (ignore && ignore.indexOf(c) >= 0) {
b64_pos++;
continue;
}
break;
}
acc = ((acc << 6) + d) & 0xfff;
acc_len += 6;
if (acc_len >= 8) {
acc_len -= 8;
bin[bin_len++] = (acc >> acc_len) & 0xff;
}
b64_pos++;
}
if (acc_len > 4 || (acc & ((1 << acc_len) - 1)) != 0) {
throw new Error("Non-canonical base64 encoding");
}
if (padding) {
skipPadding(b64.slice(b64_pos), ignore, acc_len / 2);
}
return new Uint8Array(bin.buffer, 0, bin_len);
}
/**
* Custom Base64 encoding/decoding, with support for arbitrary char<->maps.
*/
class Base64Codec {
/**
* Custom Base64 encoding/decoding.
*
* @param padding - whether padding is used.
* @param ignore - string of characters to ignore.
* @param charToByte - function to convert Base64 char to a byte.
* @param byteToChar - function to convert byte to a Base64 char.
*/
constructor(padding = false, ignore = null, charToByte, byteToChar) {
this._ignore = null;
this._padding = false;
this._padding = padding;
this._ignore = ignore;
this._charToByte = charToByte;
this._byteToChar = byteToChar;
}
/**
* Encode a buffer to Base64.
*
* @param data - a buffer to encode.
* @returns a Base64 encoded string.
*/
encode(data) {
return bin2Base64(data, this._padding, this._byteToChar);
}
/**
* Decode a Base64 string to a buffer.
*
* @param data - a Base64 string to decode.
* @returns a buffer.
* @throws an error if the string is not a valid Base64 string.
*/
decode(data) {
return base642Bin(data, this._padding, this._ignore, this._charToByte);
}
}
/**
* URL-safe Base64 encoding/decoding.
*/
class Base64UrlSafe extends Base64Codec {
/**
* URL-safe Base64 encoding/decoding.
*
* @param padding - whether padding is used.
* @param ignore - string of characters to ignore.
*/
constructor(padding = false, ignore = null) {
super(padding, ignore, charToByteUrlSafe, byteToCharUrlSafe);
}
}
</script>
<style>
:root {
background-color: #111;
color: #f3f3f3;
--copy-font: inter, gill sans, gill sans mt, segoe ui, sans-serif;
--monospace-font: monospace;
--link-color: #e9d;
--link-color-border: #848;
--link-color-bg: #414;
--header-color: #ffc;
--bold-text: #dcf;
font-family: var(--copy-font);
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
fieldset {
display: flex;
flex-direction: column;
border-radius: 8px;
border-color: #666;
gap: 0.5rem;
}
input,
textarea,
button {
font-size: 1.25rem;
background-color: #333;
color: #eee;
border: 0;
border-radius: 5px;
padding: 8px 10px;
}
textarea {
font-size: 1rem;
font-family: var(--monospace-font);
}
input:disabled,
textarea:disabled {
opacity: 0.3;
}
input[type="radio"],
label {
cursor: pointer;
}
button {
border: 1px solid var(--link-color-border);
background-color: var(--link-color-bg);
color: var(--link-color);
cursor: pointer;
margin: 0 1rem;
}
main {
margin: 0 auto;
width: 100%;
max-width: 960px;
.pad-x {
padding: 0 1rem;
}
}
b {
color: var(--bold-text);
}
.hidden {
display: none;
}
.output-box {
position: relative;
border-radius: 5px;
padding: 5px 8px;
margin-top: 1rem;
&::before {
text-transform: uppercase;
font-weight: bold;
font-size: 10pt;
position: absolute;
left: 0px;
top: -20px;
}
& b {
color: #fff;
}
code {
user-select: all;
}
}
#error {
background-color: #512;
border: 1px solid #b23;
color: #fdd;
&::before {
content: "Error";
color: #f46;
}
}
#result {
background-color: #154;
border: 1px solid #afe;
color: #dff;
&::before {
content: "Result";
color: #bfe9e0;
}
}
h2 {
color: var(--header-color);
}
code {
background-color: #333;
display: inline-block;
padding: 2px 4px;
font-size: 1rem;
border-radius: 4px;
}
a,
a:visited {
color: var(--link-color);
}
</style>
</body>
</html>

48
player/index.html Normal file
View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stream player sample</title>
<link href="./style.css" rel="stylesheet" type="text/css" />
<style>
body,
html {
padding: 0;
margin: 0;
font-family: Inter, Roobert, Helvetica Neue, Helvetica, Arial,
sans-serif;
background-color: #18181b;
color: white;
font-size: 10pt;
}
</style>
</head>
<body>
<main>
<section id="player">
<section class="header">
<span id="stream-title">Some stream something here</span>
</section>
<div id="player_id"></div>
<section class="bottom">
<div class="links">
<!-- Change the href to have your twitch handle -->
<a class="button twitch" href="https://www.twitch.tv/yourhandlehere"
>Watch on Twitch</a
>
</div>
</section>
</section>
<!-- Change the src to have your twitch handle and the top level domain where this page is hosted -->
<iframe
src="https://www.twitch.tv/embed/yourhandlehere/chat?darkpopout&parent=your.domain.here"
width="357px"
>
</iframe>
</main>
<script src="./vendor/ovenplayer.js"></script>
<script src="./vendor/hls.min.js"></script>
<script async defer src="./script.js"></script>
</body>
</html>

33
player/script.js Normal file
View file

@ -0,0 +1,33 @@
const player = OvenPlayer.create("player_id", {
autoStart: false,
autoFallback: true,
aspectRatio: "16:9",
mute: false,
disableSeekUI: true,
playbackRates: [],
sources: [
{
label: "WebRTC (low latency)",
type: "webrtc",
file: "wss://your.server.ip:3334/app/stream/webrtc?policy=eyJ1cmxfZXhwaXJlIjo2Mjg5MzE4ODAwMDAwfQ&signature=whGrRzv7GrNbHy8pIcG2qEPmeVo",
},
{
label: "HLS",
type: "hls",
file: "https://your.server.ip:3334/app/stream/llhls.m3u8?policy=eyJ1cmxfZXhwaXJlIjo2Mjg5MzE4ODAwMDAwfQ&signature=UqgRtDIuM6PNOHNVO640Ij731yg",
},
],
});
player.on("ready", () => {
window.player = player;
var audioCheck = player.getMediaElement().play();
// If website is not trusted by browser to autoplay with audio, autoplay muted instead
if (audioCheck !== undefined) {
audioCheck.catch((error) => {
player.setMute(true);
player.play();
});
}
});

72
player/style.css Normal file
View file

@ -0,0 +1,72 @@
main {
display: flex;
height: 100vh;
}
#player {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
#player > section {
display: none;
}
iframe {
flex: 0 357px;
border: 0;
}
.op-wrapper.ovenplayer {
/* Change this to your "offline" screen */
background-image: url(https://wallpapercave.com/wp/wp4749222.jpg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.button {
display: inline-block;
padding: 0.5em 1em;
text-decoration: none;
border-radius: 4px;
background-color: #bebed6;
color: black;
cursor: pointer;
border: 0;
}
.button.disabled {
pointer-events: none;
opacity: 0.5;
cursor: inherit;
}
.twitch {
background-color: #6441a5;
color: white;
}
.twitch:hover {
background-color: #8f75bf;
}
@media (max-aspect-ratio: 19/9) {
#player > section {
display: flex;
}
}
.header {
display: flex;
align-items: center;
padding: 1rem;
font-size: 13pt;
}
#stream-game {
color: #a4a1bc;
padding-left: 1rem;
}
.links {
flex: 1;
padding: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex-direction: row-reverse;
}
.bottom {
display: flex;
}

2
player/vendor/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
player/vendor/ovenplayer.js vendored Normal file

File diff suppressed because one or more lines are too long

5
update-cert.sh Normal file
View file

@ -0,0 +1,5 @@
#! /run/current-system/sw/bin/nix-shell
#! nix-shell -i bash -p jq -p docker
jq -r '.cf.Certificates[] | select(.domain.main=="*.nebula.cafe") | .certificate' /stacks/traefik/acme.json | base64 -d > cert.crt
jq -r '.cf.Certificates[] | select(.domain.main=="*.nebula.cafe") | .key' /stacks/traefik/acme.json | base64 -d > cert.key
docker restart ovenmediaengine-mediaengine-1