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:
Alessandro Ros
2026-04-30 15:52:11 +02:00
committed by GitHub
parent 585b17375e
commit ddb5f7212f
18 changed files with 404 additions and 326 deletions
+4
View File
@@ -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/).
+1 -1
View File
@@ -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.
+1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+7
View File
@@ -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 {
+1
View File
@@ -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"],
},
},
+2
View File
@@ -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 ||
+1
View File
@@ -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 -3
View File
@@ -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
+48 -4
View File
@@ -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
+3 -1
View File
@@ -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,
+2
View File
@@ -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,
}
+58 -116
View File
@@ -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
+17 -5
View File
@@ -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,
}
}
+4
View File
@@ -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