hls: add hlsCDNSecret (#5716)
this allows to serve HLS streams behind a CDN in a simplified way, compatible with the new HLS session system.
This commit is contained in:
@@ -513,6 +513,8 @@ components:
|
||||
type: string
|
||||
hlsMuxerCloseAfter:
|
||||
type: string
|
||||
hlsCDNSecret:
|
||||
type: string
|
||||
|
||||
# WebRTC server
|
||||
webrtc:
|
||||
@@ -1211,6 +1213,8 @@ components:
|
||||
type: string
|
||||
user:
|
||||
type: string
|
||||
isCDN:
|
||||
type: boolean
|
||||
outboundBytes:
|
||||
type: integer
|
||||
format: uint64
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scaling
|
||||
# Scalability
|
||||
|
||||
When handling large amounts of readers or publishers, streaming performance might get degraded due to bottlenecks in the underlying hardware infrastructure. In case of streaming without re-encoding (which is what MediaMTX does), these bottlenecks are almost always related to the limited bandwidth between server and readers. This issue can be strongly mitigated by implementing horizontal scalability, which means deploying multiple coordinated server instances, and evenly distributing load on them.
|
||||
|
||||
@@ -163,6 +163,8 @@ The load balancer has to behave differently depending on the protocol(s) readers
|
||||
|
||||
You can now use the IP address or DNS of the load balancer machines to read streams with any protocol.
|
||||
|
||||
It is also possible to entirely skip the load balancer setup by creating a domain name associated with all read replica IPs, and using that to read streams, although it is up to the DNS provider to randomize the IP order and it is up to clients to pick a random one.
|
||||
|
||||
### AWS implementation
|
||||
|
||||
1. Create a _Security group_ called `mediamtx-load-balancer`, that will be used by the load balancers. In _Inbound rules_, add:
|
||||
@@ -170,10 +172,12 @@ You can now use the IP address or DNS of the load balancer machines to read stre
|
||||
- a rule with type _All UDP_, source `0.0.0.0/0` (anywhere).
|
||||
|
||||
2. Create a _Security group_ called `mediamtx-read-replicas`, that will be used by the read replicas. In _Inbound rules_, add:
|
||||
- a rule of type _SSH_. In the _source_ field, insert the IP range of administrators.
|
||||
- a rule with type _All TCP_, source _Custom_, pick the `mediamtx-load-balancer` security group.
|
||||
- a rule with type _All UDP_, source _Custom_, pick the `mediamtx-load-balancer` security group.
|
||||
|
||||
3. Create a _Security group_ called `mediamtx-origin`, that will be used by the origin. In _Inbound rules_, add:
|
||||
- a rule of type _SSH_. In the _source_ field, insert the IP range of administrators.
|
||||
- a rule with type _Custom TCP_, port `8554`, source _Custom_, pick the `mediamtx-read-replicas` security group.
|
||||
- a rule with type _All UDP_, source _Custom_, pick the `mediamtx-read-replicas` security group.
|
||||
- a rule of type _Custom TCP_, port `8554`. In the _source_ field, insert the IP range of publishers.
|
||||
@@ -256,90 +260,69 @@ This process involved all available protocols, but it can be greatly simplified
|
||||
|
||||
## CDN
|
||||
|
||||
The read replicas technique provides the lowest latency, is compatible with all protocols and can be implemented on any on-premises or cloud environment, but it comes with some limitations regarding performance and costs:
|
||||
The read replicas technique provides the lowest latency, is compatible with all protocols and can be implemented on any on-premises or cloud environment, but it comes with some limitations regarding performance and cost:
|
||||
|
||||
- Sudden load spikes can be handled by adjusting read replica count, but this adjustement is not immediate and depends on either an autoscaling policy or a manual action, leading to a potential temporary performance degradation.
|
||||
- Increasing the read replica count can lead to saturation of the bandwidth between read replicas and the origin, creating a new bottleneck.
|
||||
- Each read replica requires a dedicated and potentially expensive machine.
|
||||
|
||||
An alternative way to serve streams consists in using the MediaMTX HLS muxer to generate directories containing HLS playlists and segments, and then serving these directories with a CDN. This method is less versatile than read replicas (only the HLS protocol is available), introduces significant latency (since the Low-Latency HLS variant cannot be used with CDNs) but overcomes scalability and costs limitations.
|
||||
An alternative way to serve streams consists in putting a CDN in front of the MediaMTX HLS server, in charge of storing cacheable files and serving requests, freeing the server from the load of most user requests. This method overcomes scalability and cost limitations, but has some drawbacks:
|
||||
|
||||
### AWS implementation
|
||||
- Only the HLS protocol is available.
|
||||
- Low-Latency HLS playlists cannot be cached and are always requested from the server, therefore it is often necessary to disable the Low-Latency HLS variant, introducing significant latency.
|
||||
- Standard MediaMTX authentication mechanisms are not available. Streams are always accessible by anyone, unless the CDN enforces its own authentication system.
|
||||
|
||||
1. In _Amazon S3_, create a new _General purpose bucket_.
|
||||
In order to allow MediaMTX to recognize CDN requests and serve cacheable files, the CDN must insert into every request an `Authorization: Bearer` header with a secret, that must match the one defined in the `hlsCDNSecret` configuration parameter in MediaMTX.
|
||||
|
||||
2. Create a _CloudFront_ distribution that points to the S3 bucket.
|
||||
### Generic implementation
|
||||
|
||||
Enter into the distribution page, tab _Behaviors_, edit the default behavior, in the _Response headers policy_ field set `CORS-With-Preflight`. This allows to access streams from external websites.
|
||||
|
||||
Create another behavior, in _Path pattern_ insert `*.m3u8`, in _Cache policy_ insert `CachingDisabled`, in _Response header policy_ set `CORS-With-Preflight`.
|
||||
|
||||
3. Create a _Security group_ called `mediamtx-origin`, that will be used by the EC2 instance that will host MediaMTX. In _Inbound rules_, add:
|
||||
- a rule of type _Custom TCP_, port `8554`. In the _source_ field, insert the IP range of publishers.
|
||||
- a rule of type _All UDP_. In the _Source_ field, insert the IP range of publishers.
|
||||
|
||||
4. Create an _EC2 instance_. Assign the `mediamtx-origin` _Security group_ to the instance. In the _Advanced Details_ section, click on _Create new IAM profile_. In the _Additional policy_ section, paste this policy, that allows the instance to access the S3 bucket;
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "VisualEditor0",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
"Resource": "arn:aws:s3:::MYBUCKETNAME"
|
||||
},
|
||||
{
|
||||
"Sid": "VisualEditor1",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
|
||||
"Resource": "arn:aws:s3:::MYBUCKETNAME/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace `MYBUCKETNAME` with the bucket name.
|
||||
|
||||
5. Log into the EC2 instance. Create a script (`upload.sh`) that uploads new segments and playlists to the S3 bucket:
|
||||
|
||||
```sh
|
||||
#!/bin/bash
|
||||
WATCH_DIR="/home/ec2-user/hls"
|
||||
S3_BUCKET="s3://MYBUCKETNAME"
|
||||
inotifywait -m -r -e close_write --format '%w%f' "$WATCH_DIR" | while read FILE
|
||||
do
|
||||
RELATIVE_PATH="${FILE#$WATCH_DIR/}"
|
||||
aws s3 cp "$FILE" "$S3_BUCKET/$RELATIVE_PATH"
|
||||
if [[ "$FILE" == *.m3u8 ]]; then
|
||||
aws s3 sync "$WATCH_DIR" "$S3_BUCKET" --delete
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Replace `MYBUCKETNAME` with the bucket name.
|
||||
|
||||
Launch the script in background:
|
||||
|
||||
```sh
|
||||
dnf update -y
|
||||
sudo dnf install -y inotify-tools
|
||||
mkdir -p /home/ec2-user/hls
|
||||
chmod +x upload.sh
|
||||
./upload.sh &
|
||||
```
|
||||
|
||||
6. Create a MediaMTX configuration (`mediamtx.yml`) with this content:
|
||||
1. On the machine meant to host MediaMTX, create this MediaMTX configuration (`mediamtx.yml`):
|
||||
|
||||
```yml
|
||||
hlsAlwaysRemux: true
|
||||
hlsCDNSecret: XXXXXXXXXX
|
||||
hlsVariant: fmp4
|
||||
hlsDirectory: ./hls
|
||||
paths:
|
||||
all:
|
||||
```
|
||||
|
||||
Replace `hlsCDNSecret` with some secret key. Using the `fmp4` HLS variant is strongly encouraged to prevent playlist requests from always reaching the server.
|
||||
|
||||
Then launch MediaMTX:
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name mediamtx \
|
||||
--restart always \
|
||||
--network host \
|
||||
bluenviron/mediamtx:1
|
||||
```
|
||||
|
||||
2. Configure the CDN to use the MediaMTX machine as origin, and to inject `hlsCDNSecret` in the `Authorization: Bearer` header.
|
||||
|
||||
### AWS implementation
|
||||
|
||||
1. Create a _Security group_ called `mediamtx-load-balancer`, that will be used by the load balancer in front of the origin. In _Inbound rules_, add:
|
||||
- a rule with type _All TCP_, source `0.0.0.0/0` (anywhere).
|
||||
|
||||
2. Create a _Security group_ called `mediamtx-origin`, that will be used by the EC2 instance that will host MediaMTX. In _Inbound rules_, add:
|
||||
- a rule of type _SSH_. In the _source_ field, insert the IP range of administrators.
|
||||
- a rule of type _Custom TCP_, port `8554`. In the _source_ field, insert the IP range of publishers.
|
||||
- a rule of type _All UDP_. In the _Source_ field, insert the IP range of publishers.
|
||||
- a rule with type _All TCP_, source _Custom_, pick the `mediamtx-load-balancer` security group.
|
||||
|
||||
3. Create an _EC2 instance_. Assign the `mediamtx-origin` _Security group_ to the instance.
|
||||
|
||||
4. Log into the EC2 instance. create this MediaMTX configuration (`mediamtx.yml`):
|
||||
|
||||
```yml
|
||||
hlsCDNSecret: XXXXXXXXXX
|
||||
hlsVariant: fmp4
|
||||
paths:
|
||||
all:
|
||||
```
|
||||
|
||||
Replace `hlsCDNSecret` with some secret key. Using the `fmp4` HLS variant is strongly encouraged to prevent playlist requests from always reaching the server.
|
||||
|
||||
Then launch MediaMTX:
|
||||
|
||||
```sh
|
||||
@@ -354,16 +337,25 @@ An alternative way to serve streams consists in using the MediaMTX HLS muxer to
|
||||
--name mediamtx \
|
||||
--network host \
|
||||
-v $PWD/mediamtx.yml:/mediamtx.yml \
|
||||
-v $PWD/hls:/hls \
|
||||
bluenviron/mediamtx:1
|
||||
```
|
||||
|
||||
You can now use the URL of the _CloudFront_ distribution to read HLS streams.
|
||||
5. Create a _Target group_. In _Target Type_ leave _Instance_, in _Protocol_ leave _HTTP_, in _Port_ insert `8888`. Open _Advanced health check settings_, in _Success codes_ insert `404`. Associate the _Target group_ with the EC2 instance.
|
||||
|
||||
Be aware that the distribution does not come with a web player, so you have to upload one to the S3 bucket or use an external player like the one [in this page](https://hlsjs.video-dev.org/demo/). Use this URL to read streams:
|
||||
6. Create a _Load Balancer_, type _Application Load Balancer_. Assign the `mediamtx-load-balancer` _Security group_ to the load balancer. In _Listeners_, set the HTTP port to `8888` and in _Target group_ select the target group that was created previously.
|
||||
|
||||
7. Create a _CloudFront_ distribution. Point it to the load balancer. In _HTTP port_, insert `8888`.
|
||||
|
||||
In the distribution page, edit the origin. Under _Add custom header_, click on _Add header_ and fill:
|
||||
- Header name: `Authorization`
|
||||
- Value: `Bearer XXXXX` (replace XXXX with the `hlsCDNSecret` value)
|
||||
|
||||
In the distribution page, edit the default behavior. In _Cache policy_, pick `UseOriginCacheControlHeaders`.
|
||||
|
||||
You can now use the URL of the _CloudFront_ distribution to read HLS streams, for instance:
|
||||
|
||||
```
|
||||
https://xxxxxx.cloudfront.net/stream/index.m3u8
|
||||
https://xxxxxx.cloudfront.net/stream/
|
||||
```
|
||||
|
||||
Replace `xxxxxx.cloudfront.net` with the distribution domain, and `stream` with the stream path.
|
||||
@@ -4,7 +4,7 @@ WebRTC is a protocol that can be used for publishing and reading streams. Regard
|
||||
|
||||
## Codec support in browsers
|
||||
|
||||
WebRTC can be used to publish and read streams encoded with a wide variety of video and audio codecs, that are listed in [Publish a stream](../2-features/03-publish.md) and [Read a stream](../2-features/04-read.md), but not every browser can publish and read streams with every codec due to internal limitations that cannot be overcome by this or any other server.
|
||||
WebRTC can be used to publish and read streams encoded with a wide variety of video and audio codecs, but not every browser can publish and read streams with every codec due to internal limitations that cannot be overcome by this or any other server.
|
||||
|
||||
You can check what codecs your browser supports by [using this tool](https://jsfiddle.net/v24s8q1f/).
|
||||
|
||||
|
||||
@@ -28,4 +28,4 @@ HLS content can be generated in several variants:
|
||||
|
||||
All HLS pameters are listed in the [configuration file](../5-references/1-configuration-file.md).
|
||||
|
||||
HLS can also be used to [scale the server](../2-features/20-scaling.md) through a CDN.
|
||||
HLS can also be used to [scale the server](../2-features/20-scalability.md) through a CDN.
|
||||
|
||||
@@ -68,3 +68,4 @@ publish/overview: features/publish
|
||||
read/overview: features/read
|
||||
|
||||
features/embed-streams-in-a-website: read/web-browsers
|
||||
features/scaling: features/scalability
|
||||
|
||||
@@ -10,7 +10,7 @@ require (
|
||||
github.com/abema/go-mp4 v1.5.0
|
||||
github.com/alecthomas/kong v1.15.0
|
||||
github.com/asticode/go-astits v1.15.0
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.0
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.1-0.20260430110435-7edc280662f7
|
||||
github.com/bluenviron/gortmplib v0.3.1
|
||||
github.com/bluenviron/gortsplib/v5 v5.5.2
|
||||
github.com/bluenviron/mediacommon/v2 v2.8.3
|
||||
|
||||
@@ -33,8 +33,8 @@ github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2
|
||||
github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c h1:8XZeJrs4+ZYhJeJ2aZxADI2tGADS15AzIF8MQ8XAhT4=
|
||||
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c/go.mod h1:x1vxHcL/9AVzuk5HOloOEPrtJY0MaalYr78afXZ+pWI=
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.0 h1:Wb4UvN+DzN0ohNF+3E8Gl9ma1gbJ1qL16lmamPIwwOQ=
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.0/go.mod h1:kA0hTg96hmTZjeZ/Vxwpu/Njy0emAoidEoDGQ/KlMH0=
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.1-0.20260430110435-7edc280662f7 h1:ZPRP+WysOAZrbvYGhESmaWZ54a+iTixaFx+31ZAaxE8=
|
||||
github.com/bluenviron/gohlslib/v2 v2.3.1-0.20260430110435-7edc280662f7/go.mod h1:kA0hTg96hmTZjeZ/Vxwpu/Njy0emAoidEoDGQ/KlMH0=
|
||||
github.com/bluenviron/gortmplib v0.3.1 h1:gB0+CSNu7/UnOW5ajA7gyttvzZcpfKBfKtZDvvsOHKk=
|
||||
github.com/bluenviron/gortmplib v0.3.1/go.mod h1:15031Vx53/kjKdbhmLdfggv3thOv6fyRVZafAZfZh6c=
|
||||
github.com/bluenviron/gortsplib/v5 v5.5.2 h1:EECKxin9jhNAHbii/V+cZgdKGdQQHELEC5c+t50x/Nc=
|
||||
|
||||
@@ -365,6 +365,7 @@ type Conf struct {
|
||||
HLSSegmentMaxSize StringSize `json:"hlsSegmentMaxSize"`
|
||||
HLSDirectory string `json:"hlsDirectory"`
|
||||
HLSMuxerCloseAfter Duration `json:"hlsMuxerCloseAfter"`
|
||||
HLSCDNSecret string `json:"hlsCDNSecret"`
|
||||
|
||||
// WebRTC server
|
||||
WebRTC bool `json:"webrtc"`
|
||||
@@ -938,6 +939,12 @@ func (conf *Conf) Validate(l logger.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
if conf.HLSCDNSecret != "" {
|
||||
if !rePlainCredential.MatchString(conf.HLSCDNSecret) {
|
||||
return fmt.Errorf("'hlsCDNSecret' contains unsupported characters. Supported are: %s", plainCredentialSupportedChars)
|
||||
}
|
||||
}
|
||||
|
||||
// WebRTC (deprecated params)
|
||||
|
||||
if conf.WebRTCDisable != nil {
|
||||
|
||||
@@ -800,6 +800,7 @@ func TestAPIProtocolListGet(t *testing.T) {
|
||||
"path": "mypath",
|
||||
"query": "",
|
||||
"user": "",
|
||||
"isCDN": false,
|
||||
"outboundBytes": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["outboundBytes"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -606,6 +606,7 @@ func (p *Core) createResources(initial bool) error {
|
||||
PartDuration: p.conf.HLSPartDuration,
|
||||
SegmentMaxSize: p.conf.HLSSegmentMaxSize,
|
||||
Directory: p.conf.HLSDirectory,
|
||||
CDNSecret: p.conf.HLSCDNSecret,
|
||||
ReadTimeout: p.conf.ReadTimeout,
|
||||
WriteTimeout: p.conf.WriteTimeout,
|
||||
MuxerCloseAfter: p.conf.HLSMuxerCloseAfter,
|
||||
@@ -913,6 +914,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
|
||||
newConf.ReadTimeout != p.conf.ReadTimeout ||
|
||||
newConf.WriteTimeout != p.conf.WriteTimeout ||
|
||||
newConf.HLSMuxerCloseAfter != p.conf.HLSMuxerCloseAfter ||
|
||||
newConf.HLSCDNSecret != p.conf.HLSCDNSecret ||
|
||||
newConf.DumpPackets != p.conf.DumpPackets ||
|
||||
closePathManager ||
|
||||
closeMetrics ||
|
||||
|
||||
@@ -30,6 +30,7 @@ type APIHLSSession struct {
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
User string `json:"user"`
|
||||
IsCDN bool `json:"isCDN"`
|
||||
OutboundBytes uint64 `json:"outboundBytes"`
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ type httpServer struct {
|
||||
trustedProxies conf.IPNetworks
|
||||
readTimeout conf.Duration
|
||||
writeTimeout conf.Duration
|
||||
cdnSecret string
|
||||
pathManager serverPathManager
|
||||
parent *Server
|
||||
|
||||
@@ -201,6 +202,8 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
contentTyp = index
|
||||
}
|
||||
|
||||
isCDN := (s.cdnSecret != "" && ctx.Request.Header.Get("Authorization") == "Bearer "+s.cdnSecret)
|
||||
|
||||
switch contentTyp {
|
||||
case index:
|
||||
_, err := s.pathManager.FindPathConf(defs.PathFindPathConfReq{
|
||||
@@ -241,6 +244,58 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
ctx.Writer.Write(hlsIndex)
|
||||
|
||||
case multivariantPlaylist:
|
||||
if isCDN {
|
||||
if existingMuxer, err := s.parent.getMuxer(serverGetMuxerReq{path: dir, create: false}); err == nil {
|
||||
if sx := existingMuxer.getCDNSession(); sx != nil {
|
||||
sx.lastRequestTime.Store(time.Now().UnixNano())
|
||||
|
||||
ctx.Writer = &responseWriterCounter{
|
||||
ResponseWriter: ctx.Writer,
|
||||
bytesSent: &sx.bytesSent,
|
||||
}
|
||||
ctx.Request.URL.Path = fname
|
||||
|
||||
err = existingMuxer.handleRequest(ctx, isCDN)
|
||||
if err != nil {
|
||||
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sx := &session{
|
||||
isCDN: true,
|
||||
remoteAddr: httpp.RemoteAddr(ctx),
|
||||
pathName: dir,
|
||||
externalCmdPool: s.parent.ExternalCmdPool,
|
||||
pathManager: s.pathManager,
|
||||
server: s.parent,
|
||||
}
|
||||
err := sx.initialize(ctx)
|
||||
if err != nil {
|
||||
var terr2 *defs.PathNoStreamAvailableError
|
||||
if errors.As(err, &terr2) {
|
||||
s.writeErrorNoLog(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Writer = &responseWriterCounter{
|
||||
ResponseWriter: ctx.Writer,
|
||||
bytesSent: &sx.bytesSent,
|
||||
}
|
||||
ctx.Request.URL.Path = fname
|
||||
|
||||
err = sx.muxer.handleRequest(ctx, isCDN)
|
||||
if err != nil {
|
||||
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Request.URL.Query().Get("cookieCheck") != "1" {
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: "cookieCheck",
|
||||
@@ -337,7 +392,7 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
|
||||
ctx.Request.URL.Path = fname
|
||||
|
||||
err = sx.muxer.handleRequest(ctx)
|
||||
err = sx.muxer.handleRequest(ctx, isCDN)
|
||||
if err != nil {
|
||||
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -356,7 +411,12 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sx := muxer.findSession(ctx)
|
||||
var sx *session
|
||||
if isCDN {
|
||||
sx = muxer.getCDNSession()
|
||||
} else {
|
||||
sx = muxer.findSession(ctx)
|
||||
}
|
||||
if sx == nil {
|
||||
// wait some seconds to delay brute force attacks
|
||||
<-time.After(auth.PauseAfterError)
|
||||
@@ -365,6 +425,10 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if isCDN {
|
||||
sx.lastRequestTime.Store(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
ctx.Writer = &responseWriterCounter{
|
||||
ResponseWriter: ctx.Writer,
|
||||
bytesSent: &sx.bytesSent,
|
||||
@@ -372,7 +436,7 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
|
||||
|
||||
ctx.Request.URL.Path = fname
|
||||
|
||||
err = muxer.handleRequest(ctx)
|
||||
err = muxer.handleRequest(ctx, isCDN)
|
||||
if err != nil {
|
||||
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
||||
@@ -60,6 +60,7 @@ type muxer struct {
|
||||
instance *muxerInstance
|
||||
cumulatedOutboundFramesDiscarded uint64
|
||||
sessionsBySecret map[uuid.UUID]*session
|
||||
cdnSession *session
|
||||
|
||||
chCloseInstance chan muxerCloseInstanceReq
|
||||
}
|
||||
@@ -121,6 +122,11 @@ func (m *muxer) run() {
|
||||
sx.close2(fmt.Errorf("muxer destroyed"))
|
||||
}
|
||||
|
||||
if m.cdnSession != nil {
|
||||
m.cdnSession.close2(fmt.Errorf("muxer destroyed"))
|
||||
m.cdnSession = nil
|
||||
}
|
||||
|
||||
m.mutex.Unlock()
|
||||
|
||||
m.Log(logger.Info, "destroyed: %v", err)
|
||||
@@ -205,6 +211,10 @@ func (m *muxer) runInner() error {
|
||||
sx.close2(fmt.Errorf("muxer instance crashed"))
|
||||
}
|
||||
m.sessionsBySecret = make(map[uuid.UUID]*session)
|
||||
if m.cdnSession != nil {
|
||||
m.cdnSession.close2(fmt.Errorf("muxer instance crashed"))
|
||||
m.cdnSession = nil
|
||||
}
|
||||
m.mutex.Unlock()
|
||||
|
||||
m.Log(logger.Error, "muxer instance crashed: %v", req.err)
|
||||
@@ -236,6 +246,13 @@ func (m *muxer) runInner() error {
|
||||
sx.close2(fmt.Errorf("inactive"))
|
||||
}
|
||||
}
|
||||
if m.cdnSession != nil {
|
||||
lastRequest := time.Unix(0, m.cdnSession.lastRequestTime.Load())
|
||||
if now.Sub(lastRequest) >= sessionCloseAfter {
|
||||
m.cdnSession.close2(fmt.Errorf("inactive"))
|
||||
m.cdnSession = nil
|
||||
}
|
||||
}
|
||||
m.mutex.Unlock()
|
||||
|
||||
case <-activityCheckTimer.C:
|
||||
@@ -302,10 +319,23 @@ func (m *muxer) addSession(sx *session) ([]format.Format, error) {
|
||||
return nil, fmt.Errorf("muxer instance not available")
|
||||
}
|
||||
|
||||
if sx.isCDN {
|
||||
if m.cdnSession != nil {
|
||||
m.cdnSession.close2(fmt.Errorf("replaced by new CDN session"))
|
||||
}
|
||||
m.cdnSession = sx
|
||||
} else {
|
||||
m.sessionsBySecret[sx.secret] = sx
|
||||
}
|
||||
return m.instance.reader.Formats(), nil
|
||||
}
|
||||
|
||||
func (m *muxer) getCDNSession() *session {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
return m.cdnSession
|
||||
}
|
||||
|
||||
func (m *muxer) findSession(ctx *gin.Context) *session {
|
||||
var rawSecret string
|
||||
if cookie, err := ctx.Request.Cookie(sessionCookieName); err == nil {
|
||||
@@ -337,7 +367,7 @@ func (m *muxer) findSession(ctx *gin.Context) *session {
|
||||
return sx
|
||||
}
|
||||
|
||||
func (m *muxer) handleRequest(ctx *gin.Context) error {
|
||||
func (m *muxer) handleRequest(ctx *gin.Context, isCDN bool) error {
|
||||
m.lastRequestTime.Store(time.Now().UnixNano())
|
||||
|
||||
m.mutex.RLock()
|
||||
@@ -348,7 +378,7 @@ func (m *muxer) handleRequest(ctx *gin.Context) error {
|
||||
return fmt.Errorf("muxer instance not available")
|
||||
}
|
||||
|
||||
instance.handleRequest(ctx)
|
||||
instance.handleRequest(ctx, isCDN)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -383,6 +413,10 @@ func (m *muxer) apiSessionsList() []defs.APIHLSSession {
|
||||
sessions = append(sessions, *sx.apiItem())
|
||||
}
|
||||
|
||||
if m.cdnSession != nil {
|
||||
sessions = append(sessions, *m.cdnSession.apiItem())
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
@@ -397,12 +431,16 @@ func (m *muxer) findSessionByUUID(uuid uuid.UUID) *session {
|
||||
|
||||
func (m *muxer) apiSessionsGet(uuid uuid.UUID) (*defs.APIHLSSession, bool) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
if m.cdnSession != nil && m.cdnSession.uuid == uuid {
|
||||
return m.cdnSession.apiItem(), true
|
||||
}
|
||||
|
||||
sx := m.findSessionByUUID(uuid)
|
||||
if sx == nil {
|
||||
m.mutex.RUnlock()
|
||||
return nil, false
|
||||
}
|
||||
m.mutex.RUnlock()
|
||||
|
||||
return sx.apiItem(), true
|
||||
}
|
||||
@@ -411,6 +449,12 @@ func (m *muxer) apiSessionsKick(uuid uuid.UUID) bool {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
if m.cdnSession != nil && m.cdnSession.uuid == uuid {
|
||||
m.cdnSession.close2(fmt.Errorf("kicked"))
|
||||
m.cdnSession = nil
|
||||
return true
|
||||
}
|
||||
|
||||
sx := m.findSessionByUUID(uuid)
|
||||
if sx == nil {
|
||||
return false
|
||||
|
||||
@@ -140,10 +140,12 @@ func (mi *muxerInstance) runInner() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (mi *muxerInstance) handleRequest(ctx *gin.Context) {
|
||||
func (mi *muxerInstance) handleRequest(ctx *gin.Context, isCDN bool) {
|
||||
w := ctx.Writer
|
||||
|
||||
if !isCDN {
|
||||
w = &responseWriterNoCache{ResponseWriter: w}
|
||||
}
|
||||
|
||||
w = &responseWriterCounter{
|
||||
ResponseWriter: w,
|
||||
|
||||
@@ -117,6 +117,7 @@ type Server struct {
|
||||
PartDuration conf.Duration
|
||||
SegmentMaxSize conf.StringSize
|
||||
Directory string
|
||||
CDNSecret string
|
||||
ReadTimeout conf.Duration
|
||||
WriteTimeout conf.Duration
|
||||
MuxerCloseAfter conf.Duration
|
||||
@@ -170,6 +171,7 @@ func (s *Server) Initialize() error {
|
||||
trustedProxies: s.TrustedProxies,
|
||||
readTimeout: s.ReadTimeout,
|
||||
writeTimeout: s.WriteTimeout,
|
||||
cdnSecret: s.CDNSecret,
|
||||
pathManager: s.PathManager,
|
||||
parent: s,
|
||||
}
|
||||
|
||||
@@ -292,12 +292,27 @@ func TestServerNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type cdnRoundTripper struct {
|
||||
secret string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *cdnRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("Authorization", "Bearer "+t.secret)
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestServerRead(t *testing.T) {
|
||||
for _, cdn := range []string{
|
||||
"no cdn",
|
||||
"cdn",
|
||||
} {
|
||||
for _, ca := range []string{
|
||||
"always remux off",
|
||||
"always remux on",
|
||||
} {
|
||||
t.Run(ca, func(t *testing.T) {
|
||||
t.Run(cdn+"/"+ca, func(t *testing.T) {
|
||||
desc := &description.Session{Medias: []*description.Media{
|
||||
test.MediaH264,
|
||||
test.MediaMPEG4Audio,
|
||||
@@ -321,26 +336,25 @@ func TestServerRead(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
pm := &dummyPathManager{
|
||||
findPathConfImpl: func(req defs.PathFindPathConfReq) (*defs.PathFindPathConfRes, error) {
|
||||
require.Equal(t, "teststream", req.AccessRequest.Name)
|
||||
require.Equal(t, "param=value", req.AccessRequest.Query)
|
||||
require.Equal(t, "myuser", req.AccessRequest.Credentials.User)
|
||||
require.Equal(t, "mypass", req.AccessRequest.Credentials.Pass)
|
||||
return &defs.PathFindPathConfRes{Conf: &conf.Path{}, User: req.AccessRequest.Credentials.User}, nil
|
||||
},
|
||||
addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
|
||||
require.Equal(t, "teststream", req.AccessRequest.Name)
|
||||
|
||||
switch req.Author.(type) {
|
||||
case (*muxer):
|
||||
if ca == "always remux off" {
|
||||
require.Equal(t, "param=value", req.AccessRequest.Query)
|
||||
} else {
|
||||
case *muxer:
|
||||
if cdn != "no cdn" || ca == "always remux on" {
|
||||
require.Equal(t, "", req.AccessRequest.Query)
|
||||
} else {
|
||||
require.Equal(t, "param=value", req.AccessRequest.Query)
|
||||
}
|
||||
|
||||
case *session:
|
||||
if cdn != "no cdn" {
|
||||
require.True(t, req.AccessRequest.SkipAuth)
|
||||
require.Nil(t, req.AccessRequest.Credentials)
|
||||
require.Equal(t, "", req.AccessRequest.Query)
|
||||
} else {
|
||||
require.Equal(t, "param=value", req.AccessRequest.Query)
|
||||
}
|
||||
|
||||
default:
|
||||
t.Errorf("should not happen")
|
||||
@@ -350,17 +364,16 @@ func TestServerRead(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
switch ca {
|
||||
case "always remux off":
|
||||
s := &Server{
|
||||
Address: "127.0.0.1:8888",
|
||||
AlwaysRemux: false,
|
||||
AlwaysRemux: ca == "always remux on",
|
||||
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
|
||||
SegmentCount: 7,
|
||||
SegmentDuration: conf.Duration(1 * time.Second),
|
||||
PartDuration: conf.Duration(200 * time.Millisecond),
|
||||
SegmentMaxSize: 50 * 1024 * 1024,
|
||||
TrustedProxies: conf.IPNetworks{},
|
||||
CDNSecret: "myCDNSecret",
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
PathManager: pm,
|
||||
@@ -370,15 +383,12 @@ func TestServerRead(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
c := &gohlslib.Client{
|
||||
URI: "http://myuser:mypass@127.0.0.1:8888/teststream/index.m3u8?param=value",
|
||||
StartDistance: 1,
|
||||
}
|
||||
|
||||
recv1 := make(chan struct{})
|
||||
recv2 := make(chan struct{})
|
||||
|
||||
c.OnTracks = func(tracks []*gohlslib.Track) error { //nolint:dupl
|
||||
newClient := func() *gohlslib.Client {
|
||||
c := &gohlslib.Client{StartDistance: 1}
|
||||
c.OnTracks = func(tracks []*gohlslib.Track) error {
|
||||
require.Equal(t, []*gohlslib.Track{
|
||||
{
|
||||
Codec: &codecs.H264{},
|
||||
@@ -416,13 +426,21 @@ func TestServerRead(t *testing.T) {
|
||||
|
||||
return nil
|
||||
}
|
||||
if cdn != "no cdn" {
|
||||
c.URI = "http://127.0.0.1:8888/teststream/index.m3u8"
|
||||
c.HTTPClient = &http.Client{
|
||||
Transport: &cdnRoundTripper{
|
||||
secret: "myCDNSecret",
|
||||
base: &http.Transport{},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
c.URI = "http://myuser:mypass@127.0.0.1:8888/teststream/index.m3u8?param=value"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
err = c.Start()
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
strm.WaitForReaders()
|
||||
|
||||
writeData := func() {
|
||||
for i := range 2 {
|
||||
subStream.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{
|
||||
NTP: time.Time{},
|
||||
@@ -437,105 +455,33 @@ func TestServerRead(t *testing.T) {
|
||||
Payload: unit.PayloadMPEG4Audio{{1, 2}},
|
||||
})
|
||||
}
|
||||
|
||||
<-recv1
|
||||
<-recv2
|
||||
|
||||
case "always remux on":
|
||||
s := &Server{
|
||||
Address: "127.0.0.1:8888",
|
||||
AlwaysRemux: true,
|
||||
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
|
||||
SegmentCount: 7,
|
||||
SegmentDuration: conf.Duration(1 * time.Second),
|
||||
PartDuration: conf.Duration(200 * time.Millisecond),
|
||||
SegmentMaxSize: 50 * 1024 * 1024,
|
||||
TrustedProxies: conf.IPNetworks{},
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
PathManager: pm,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
err = s.Initialize()
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
if ca == "always remux off" {
|
||||
c := newClient()
|
||||
err = c.Start()
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
strm.WaitForReaders()
|
||||
writeData()
|
||||
} else {
|
||||
s.PathReady(&dummyPath{})
|
||||
|
||||
strm.WaitForReaders()
|
||||
writeData()
|
||||
|
||||
for i := range 2 {
|
||||
subStream.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{
|
||||
NTP: time.Time{},
|
||||
PTS: int64(i) * 90000,
|
||||
Payload: unit.PayloadH264{
|
||||
{5, 1}, // IDR
|
||||
},
|
||||
})
|
||||
subStream.WriteUnit(test.MediaMPEG4Audio, test.FormatMPEG4Audio, &unit.Unit{
|
||||
NTP: time.Time{},
|
||||
PTS: int64(i) * 44100,
|
||||
Payload: unit.PayloadMPEG4Audio{{1, 2}},
|
||||
})
|
||||
}
|
||||
|
||||
c := &gohlslib.Client{
|
||||
URI: "http://myuser:mypass@127.0.0.1:8888/teststream/index.m3u8?param=value",
|
||||
StartDistance: 1,
|
||||
}
|
||||
|
||||
recv1 := make(chan struct{})
|
||||
recv2 := make(chan struct{})
|
||||
|
||||
c.OnTracks = func(tracks []*gohlslib.Track) error { //nolint:dupl
|
||||
require.Equal(t, []*gohlslib.Track{
|
||||
{
|
||||
Codec: &codecs.H264{},
|
||||
ClockRate: 90000,
|
||||
},
|
||||
{
|
||||
Codec: &codecs.MPEG4Audio{
|
||||
Config: mpeg4audio.AudioSpecificConfig{
|
||||
Type: 2,
|
||||
ChannelCount: 2,
|
||||
ChannelConfig: 2,
|
||||
SampleRate: 44100,
|
||||
},
|
||||
},
|
||||
ClockRate: 90000,
|
||||
},
|
||||
}, tracks)
|
||||
|
||||
c.OnDataH26x(tracks[0], func(pts, dts int64, au [][]byte) {
|
||||
require.Equal(t, int64(0), pts)
|
||||
require.Equal(t, int64(0), dts)
|
||||
require.Equal(t, [][]byte{
|
||||
test.FormatH264.SPS,
|
||||
test.FormatH264.PPS,
|
||||
{5, 1},
|
||||
}, au)
|
||||
close(recv1)
|
||||
})
|
||||
|
||||
c.OnDataMPEG4Audio(tracks[1], func(pts int64, aus [][]byte) {
|
||||
require.Equal(t, int64(0), pts)
|
||||
require.Equal(t, [][]byte{{1, 2}}, aus)
|
||||
close(recv2)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c := newClient()
|
||||
err = c.Start()
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
}
|
||||
|
||||
<-recv1
|
||||
<-recv2
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
@@ -772,10 +718,6 @@ func TestServerNoSupportedCodecs(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
pm := &dummyPathManager{
|
||||
findPathConfImpl: func(req defs.PathFindPathConfReq) (*defs.PathFindPathConfRes, error) {
|
||||
require.Equal(t, "teststream", req.AccessRequest.Name)
|
||||
return &defs.PathFindPathConfRes{Conf: &conf.Path{}, User: req.AccessRequest.Credentials.User}, nil
|
||||
},
|
||||
addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
|
||||
require.Equal(t, "teststream", req.AccessRequest.Name)
|
||||
return &defs.PathAddReaderRes{Path: &dummyPath{}, Stream: strm}, nil
|
||||
|
||||
@@ -27,6 +27,7 @@ type sessionServer interface {
|
||||
type session struct {
|
||||
remoteAddr string
|
||||
pathName string
|
||||
isCDN bool
|
||||
externalCmdPool *externalcmd.Pool
|
||||
pathManager serverPathManager
|
||||
server sessionServer
|
||||
@@ -54,17 +55,23 @@ func (s *session) initialize(ctx *gin.Context) error {
|
||||
s.query = ctx.Request.URL.RawQuery
|
||||
s.lastRequestTime.Store(time.Now().UnixNano())
|
||||
|
||||
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
|
||||
Author: s,
|
||||
AccessRequest: defs.PathAccessRequest{
|
||||
accessReq := defs.PathAccessRequest{
|
||||
Name: s.pathName,
|
||||
Query: s.query,
|
||||
Publish: false,
|
||||
Proto: auth.ProtocolHLS,
|
||||
ID: &s.uuid,
|
||||
Credentials: httpp.Credentials(ctx.Request),
|
||||
IP: net.ParseIP(ctx.ClientIP()),
|
||||
},
|
||||
}
|
||||
if s.isCDN {
|
||||
accessReq.SkipAuth = true
|
||||
} else {
|
||||
accessReq.Credentials = httpp.Credentials(ctx.Request)
|
||||
}
|
||||
|
||||
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
|
||||
Author: s,
|
||||
AccessRequest: accessReq,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +118,11 @@ func (s *session) initialize(ctx *gin.Context) error {
|
||||
|
||||
res.Stream.AddReader(s.reader)
|
||||
|
||||
if s.isCDN {
|
||||
s.Log(logger.Info, "created by %s (CDN), reading from muxer '%s'", s.remoteAddr, s.pathName)
|
||||
} else {
|
||||
s.Log(logger.Info, "created by %s, reading from muxer '%s'", s.remoteAddr, s.pathName)
|
||||
}
|
||||
|
||||
s.onUnreadHook = hooks.OnRead(hooks.OnReadParams{
|
||||
Logger: s,
|
||||
@@ -156,6 +167,7 @@ func (s *session) apiItem() *defs.APIHLSSession {
|
||||
Path: s.pathName,
|
||||
Query: s.query,
|
||||
User: s.user,
|
||||
IsCDN: s.isCDN,
|
||||
OutboundBytes: outboundBytes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +371,10 @@ hlsDirectory: ''
|
||||
# The muxer will be closed when there are no
|
||||
# reader requests and this amount of time has passed.
|
||||
hlsMuxerCloseAfter: 60s
|
||||
# Secret to identify requests coming from a CDN.
|
||||
# The CDN must insert this secret in every request in the
|
||||
# 'Authorization: Bearer' header.
|
||||
hlsCDNSecret: ''
|
||||
|
||||
###############################################
|
||||
# Global settings -> WebRTC server
|
||||
|
||||
Reference in New Issue
Block a user