Add user agent field to RTMP, RTSP, WebRTC, and HLS (#5753)

Surface user agent in the auth HTTP webhook payload as "userAgent"
and in the API structs for RTMP, RTSP, WebRTC and HLS connections.

---------

Co-authored-by: aler9 <46489434+aler9@users.noreply.github.com>
This commit is contained in:
Alex McKenzie
2026-06-02 20:29:07 +02:00
committed by GitHub
co-authored by aler9
parent 0d7cbd6df8
commit 5decbb4a3d
20 changed files with 130 additions and 76 deletions
+8
View File
@@ -1213,6 +1213,8 @@ components:
type: string
user:
type: string
userAgent:
type: string
isCDN:
type: boolean
outboundBytes:
@@ -1281,6 +1283,8 @@ components:
type: string
user:
type: string
userAgent:
type: string
inboundBytes:
type: integer
format: uint64
@@ -1376,6 +1380,8 @@ components:
type: string
user:
type: string
userAgent:
type: string
transport:
type: string
nullable: true
@@ -1774,6 +1780,8 @@ components:
type: string
user:
type: string
userAgent:
type: string
inboundBytes:
type: integer
format: uint64
+2 -1
View File
@@ -93,7 +93,8 @@ Each time a user needs to be authenticated, the specified URL will be requested
"path": "path",
"protocol": "rtsp|rtmp|hls|webrtc|srt",
"id": "id",
"query": "query"
"query": "query",
"userAgent": "userAgent"
}
```
+20 -18
View File
@@ -197,25 +197,27 @@ func (m *Manager) authenticateHTTP(req *Request, token string) (string, error) {
}
enc, _ := json.Marshal(struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Token string `json:"token"`
Action string `json:"action"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID *uuid.UUID `json:"id"`
Query string `json:"query"`
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Token string `json:"token"`
Action string `json:"action"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID *uuid.UUID `json:"id"`
Query string `json:"query"`
UserAgent string `json:"userAgent"`
}{
IP: req.IP.String(),
User: req.Credentials.User,
Password: req.Credentials.Pass,
Token: token,
Action: string(req.Action),
Path: req.Path,
Protocol: string(req.Protocol),
ID: req.ID,
Query: req.Query,
IP: req.IP.String(),
User: req.Credentials.User,
Password: req.Credentials.Pass,
Token: token,
Action: string(req.Action),
Path: req.Path,
Protocol: string(req.Protocol),
ID: req.ID,
Query: req.Query,
UserAgent: req.UserAgent,
})
tr := &http.Transport{
+20 -16
View File
@@ -274,14 +274,15 @@ func TestAuthHTTP(t *testing.T) {
require.Equal(t, "/auth", r.URL.Path)
var in struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
UserAgent string `json:"userAgent"`
}
err := json.NewDecoder(r.Body).Decode(&in)
require.NoError(t, err)
@@ -293,6 +294,7 @@ func TestAuthHTTP(t *testing.T) {
in.Protocol != "rtsp" ||
(firstReceived && in.ID == "") ||
in.Action != "publish" ||
in.UserAgent != "testagent" ||
(in.Query != "user=testreader&pass=testpass&param=value" &&
in.Query != "user=testpublisher&pass=testpass&param=value" &&
in.Query != "param=value") {
@@ -319,10 +321,11 @@ func TestAuthHTTP(t *testing.T) {
if outcome == "ok" {
req = &Request{
Action: conf.AuthActionPublish,
Path: "teststream",
Query: "param=value",
Protocol: ProtocolRTSP,
Action: conf.AuthActionPublish,
Path: "teststream",
Query: "param=value",
Protocol: ProtocolRTSP,
UserAgent: "testagent",
Credentials: &Credentials{
User: "testpublisher",
Pass: "testpass",
@@ -331,10 +334,11 @@ func TestAuthHTTP(t *testing.T) {
}
} else {
req = &Request{
Action: conf.AuthActionPublish,
Path: "teststream",
Query: "param=value",
Protocol: ProtocolRTSP,
Action: conf.AuthActionPublish,
Path: "teststream",
Query: "param=value",
Protocol: ProtocolRTSP,
UserAgent: "testagent",
Credentials: &Credentials{
User: "invalid",
Pass: "testpass",
+1
View File
@@ -26,6 +26,7 @@ type Request struct {
Query string
Protocol Protocol // only for ActionPublish, ActionRead
ID *uuid.UUID // only for ActionPublish, ActionRead
UserAgent string
Credentials *Credentials
IP net.IP
CustomVerifyFunc func(expectedUser string, expectedPass string) bool
+6
View File
@@ -679,6 +679,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"rtcpPacketsSent": float64(0),
"rtcpPacketsInError": float64(0),
"conns": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["conns"],
"userAgent": "gortsplib",
},
},
}, out1)
@@ -740,6 +741,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"rtcpPacketsSent": float64(0),
"rtcpPacketsInError": float64(0),
"conns": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["conns"],
"userAgent": "gortsplib",
},
},
}, out1)
@@ -762,6 +764,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"user": "",
"remoteAddr": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["remoteAddr"],
"state": "publish",
"userAgent": "LNX 9,0,124,2",
},
},
}, out1)
@@ -784,6 +787,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"user": "",
"remoteAddr": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["remoteAddr"],
"state": "publish",
"userAgent": "LNX 9,0,124,2",
},
},
}, out1)
@@ -802,6 +806,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"user": "",
"isCDN": false,
"outboundBytes": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["outboundBytes"],
"userAgent": "Go-http-client/1.1",
},
},
}, out1)
@@ -855,6 +860,7 @@ func TestAPIProtocolListGet(t *testing.T) {
"rtpPacketsLost": float64(0),
"rtpPacketsReceived": float64(0),
"rtpPacketsSent": float64(1),
"userAgent": "Go-http-client/1.1",
},
},
}, out1)
+1
View File
@@ -30,6 +30,7 @@ type APIHLSSession struct {
Path string `json:"path"`
Query string `json:"query"`
User string `json:"user"`
UserAgent string `json:"userAgent"`
IsCDN bool `json:"isCDN"`
OutboundBytes uint64 `json:"outboundBytes"`
}
+1
View File
@@ -32,6 +32,7 @@ type APIRTMPConn struct {
Path string `json:"path"`
Query string `json:"query"`
User string `json:"user"`
UserAgent string `json:"userAgent"`
InboundBytes uint64 `json:"inboundBytes"`
OutboundBytes uint64 `json:"outboundBytes"`
OutboundFramesDiscarded uint64 `json:"outboundFramesDiscarded"`
+1
View File
@@ -54,6 +54,7 @@ type APIRTSPSession struct {
Path string `json:"path"`
Query string `json:"query"`
User string `json:"user"`
UserAgent string `json:"userAgent"`
Transport *string `json:"transport"`
Profile *string `json:"profile"`
Conns []uuid.UUID `json:"conns"`
+1
View File
@@ -34,6 +34,7 @@ type APIWebRTCSession struct {
Path string `json:"path"`
Query string `json:"query"`
User string `json:"user"`
UserAgent string `json:"userAgent"`
InboundBytes uint64 `json:"inboundBytes"`
InboundRTPPackets uint64 `json:"inboundRTPPackets"`
InboundRTPPacketsLost uint64 `json:"inboundRTPPacketsLost"`
+6 -4
View File
@@ -10,10 +10,11 @@ import (
// PathAccessRequest is a path access request.
type PathAccessRequest struct {
Name string
Query string
Publish bool
SkipAuth bool
Name string
Query string
Publish bool
SkipAuth bool
UserAgent string
// only if skipAuth = false
Proto auth.Protocol
@@ -36,6 +37,7 @@ func (r *PathAccessRequest) ToAuthRequest() *auth.Request {
Query: r.Query,
Protocol: r.Proto,
ID: r.ID,
UserAgent: r.UserAgent,
Credentials: r.Credentials,
IP: r.IP,
CustomVerifyFunc: r.CustomVerifyFunc,
+10 -6
View File
@@ -38,6 +38,7 @@ type session struct {
created time.Time
query string
user string
userAgent string
lastRequestTime atomic.Int64
bytesSent atomic.Uint64
path defs.Path
@@ -53,15 +54,17 @@ func (s *session) initialize(ctx *gin.Context) error {
s.ip, _, _ = net.SplitHostPort(s.remoteAddr)
s.created = time.Now()
s.query = ctx.Request.URL.RawQuery
s.userAgent = ctx.Request.UserAgent()
s.lastRequestTime.Store(time.Now().UnixNano())
accessReq := defs.PathAccessRequest{
Name: s.pathName,
Query: s.query,
Publish: false,
Proto: auth.ProtocolHLS,
ID: &s.uuid,
IP: net.ParseIP(ctx.ClientIP()),
Name: s.pathName,
Query: s.query,
Publish: false,
UserAgent: s.userAgent,
Proto: auth.ProtocolHLS,
ID: &s.uuid,
IP: net.ParseIP(ctx.ClientIP()),
}
if s.isCDN {
accessReq.SkipAuth = true
@@ -167,6 +170,7 @@ func (s *session) apiItem() *defs.APIHLSSession {
Path: s.pathName,
Query: s.query,
User: s.user,
UserAgent: s.userAgent,
IsCDN: s.isCDN,
OutboundBytes: outboundBytes,
}
+14 -9
View File
@@ -48,6 +48,7 @@ type conn struct {
pathName string
query string
user string
userAgent string
reader *stream.Reader
}
@@ -141,6 +142,7 @@ func (c *conn) runReader() error {
c.mutex.Lock()
c.rconn = conn
c.userAgent = conn.FlashVer
c.mutex.Unlock()
if !conn.Publish {
@@ -156,10 +158,11 @@ func (c *conn) runRead() error {
res, err := c.pathManager.AddReader(defs.PathAddReaderReq{
Author: c,
AccessRequest: defs.PathAccessRequest{
Name: pathName,
Query: c.rconn.URL.RawQuery,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
Name: pathName,
Query: c.rconn.URL.RawQuery,
UserAgent: c.userAgent,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
Credentials: &auth.Credentials{
User: query.Get("user"),
Pass: query.Get("pass"),
@@ -249,11 +252,12 @@ func (c *conn) runPublish() error {
UseRTPPackets: false,
ReplaceNTP: true,
AccessRequest: defs.PathAccessRequest{
Name: pathName,
Query: c.rconn.URL.RawQuery,
Publish: true,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
Name: pathName,
Query: c.rconn.URL.RawQuery,
Publish: true,
UserAgent: c.userAgent,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
Credentials: &auth.Credentials{
User: query.Get("user"),
Pass: query.Get("pass"),
@@ -344,6 +348,7 @@ func (c *conn) apiItem() *defs.APIRTMPConn {
Path: c.pathName,
Query: c.query,
User: c.user,
UserAgent: c.userAgent,
InboundBytes: bytesReceived,
OutboundBytes: bytesSent,
BytesReceived: bytesReceived,
+2
View File
@@ -196,6 +196,7 @@ func TestServerPublish(t *testing.T) {
Path: "teststream",
Query: "user=myuser&pass=mypass&param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
OutboundBytes: list.Items[0].OutboundBytes,
OutboundFramesDiscarded: list.Items[0].OutboundFramesDiscarded,
@@ -351,6 +352,7 @@ func TestServerRead(t *testing.T) {
Path: "teststream",
Query: "user=myuser&pass=mypass&param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
OutboundBytes: list.Items[0].OutboundBytes,
OutboundFramesDiscarded: list.Items[0].OutboundFramesDiscarded,
+2
View File
@@ -188,6 +188,7 @@ func TestServerPublish(t *testing.T) {
Path: "teststream",
Query: "param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
InboundRTPPackets: list.Items[0].InboundRTPPackets,
OutboundBytes: list.Items[0].OutboundBytes,
@@ -510,6 +511,7 @@ func TestServerRead(t *testing.T) {
Path: "teststream",
Query: "param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
InboundRTPPackets: list.Items[0].InboundRTPPackets,
OutboundBytes: list.Items[0].OutboundBytes,
+23 -6
View File
@@ -79,6 +79,7 @@ type session struct {
outboundRTPPacketsDiscarded *counterdumper.Dumper
mutex sync.RWMutex
user string
userAgent string
mpegtsDemuxer *mpegtsDemuxer
}
@@ -194,11 +195,17 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx)
}
}
var userAgent string
if ua, ok := ctx.Request.Header["User-Agent"]; ok && len(ua) > 0 {
userAgent = ua[0]
}
res, err := s.pathManager.FindPathConf(defs.PathFindPathConfReq{
AccessRequest: defs.PathAccessRequest{
Name: ctx.Path,
Query: ctx.Query,
Publish: true,
UserAgent: userAgent,
Proto: auth.ProtocolRTSP,
ID: &c.uuid,
Credentials: rtsp.Credentials(ctx.Request),
@@ -221,6 +228,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx)
s.mutex.Lock()
s.user = res.User
s.userAgent = userAgent
s.mutex.Unlock()
return &base.Response{
@@ -266,6 +274,11 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
}
}
var userAgent string
if ua, ok := ctx.Request.Header["User-Agent"]; ok && len(ua) > 0 {
userAgent = ua[0]
}
switch s.rsession.State() {
case gortsplib.ServerSessionStateInitial: // play
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
@@ -273,6 +286,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
AccessRequest: defs.PathAccessRequest{
Name: ctx.Path,
Query: ctx.Query,
UserAgent: userAgent,
Proto: auth.ProtocolRTSP,
ID: &c.uuid,
Credentials: rtsp.Credentials(ctx.Request),
@@ -304,6 +318,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
s.mutex.Lock()
s.user = res.User
s.userAgent = userAgent
s.mutex.Unlock()
return &base.Response{
@@ -385,10 +400,11 @@ func (s *session) onRecord(_ *gortsplib.ServerHandlerOnRecordCtx) (*base.Respons
ReplaceNTP: !s.pathConf.UseAbsoluteTimestamp,
ConfToCompare: s.pathConf,
AccessRequest: defs.PathAccessRequest{
Name: s.rsession.Path()[1:],
Query: s.rsession.Query(),
Publish: true,
SkipAuth: true,
Name: s.rsession.Path()[1:],
Query: s.rsession.Query(),
Publish: true,
SkipAuth: true,
UserAgent: s.userAgent,
},
})
if err != nil {
@@ -507,8 +523,9 @@ func (s *session) apiItem() *defs.APIRTSPSession {
}
return ""
}(),
Query: s.rsession.Query(),
User: s.user,
Query: s.rsession.Query(),
User: s.user,
UserAgent: s.userAgent,
Transport: func() *string {
transport := s.rsession.Transport()
if transport == nil {
+1
View File
@@ -142,6 +142,7 @@ func (s *httpServer) checkAuthOutsideSession(ctx *gin.Context, pathName string,
Name: pathName,
Query: ctx.Request.URL.RawQuery,
Publish: publish,
UserAgent: ctx.Request.Header.Get("User-Agent"),
Proto: auth.ProtocolWebRTC,
Credentials: httpp.Credentials(ctx.Request),
IP: net.ParseIP(ctx.ClientIP()),
+2
View File
@@ -469,6 +469,7 @@ func TestServerPublish(t *testing.T) {
Path: "teststream",
Query: "param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
InboundRTPPackets: list.Items[0].InboundRTPPackets,
InboundRTPPacketsLost: list.Items[0].InboundRTPPacketsLost,
@@ -763,6 +764,7 @@ func TestServerRead(t *testing.T) {
Path: "teststream",
Query: "param=value",
User: "myuser",
UserAgent: list.Items[0].UserAgent,
InboundBytes: list.Items[0].InboundBytes,
InboundRTPPackets: list.Items[0].InboundRTPPackets,
InboundRTPPacketsLost: list.Items[0].InboundRTPPacketsLost,
+8 -4
View File
@@ -341,6 +341,7 @@ func (s *session) runPublish(req *initialRequestReq) (int, error) {
Name: s.pathName,
Query: s.httpRequest.URL.RawQuery,
Publish: true,
UserAgent: s.httpRequest.Header.Get("User-Agent"),
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.httpRequest),
@@ -449,10 +450,11 @@ func (s *session) runPublish(req *initialRequestReq) (int, error) {
ReplaceNTP: !res1.Conf.UseAbsoluteTimestamp,
ConfToCompare: res1.Conf,
AccessRequest: defs.PathAccessRequest{
Name: s.pathName,
Query: s.httpRequest.URL.RawQuery,
Publish: true,
SkipAuth: true,
Name: s.pathName,
Query: s.httpRequest.URL.RawQuery,
Publish: true,
SkipAuth: true,
UserAgent: s.httpRequest.Header.Get("User-Agent"),
},
})
if err != nil {
@@ -482,6 +484,7 @@ func (s *session) runRead(req *initialRequestReq) (int, error) {
AccessRequest: defs.PathAccessRequest{
Name: s.pathName,
Query: s.httpRequest.URL.RawQuery,
UserAgent: s.httpRequest.Header.Get("User-Agent"),
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.httpRequest),
@@ -754,6 +757,7 @@ func (s *session) apiItem() *defs.APIWebRTCSession {
Path: s.pathName,
Query: s.httpRequest.URL.RawQuery,
User: s.user,
UserAgent: s.httpRequest.Header.Get("User-Agent"),
InboundBytes: bytesReceived,
InboundRTPPackets: rtpPacketsReceived,
InboundRTPPacketsLost: rtpPacketsLost,
+1 -12
View File
@@ -94,18 +94,7 @@ authInternalUsers:
# HTTP-based authentication.
# URL called to perform authentication. Every time a user wants
# to authenticate, the server calls this URL with the POST method
# and a body containing:
# {
# "user": "user",
# "password": "password",
# "token": "token",
# "ip": "ip",
# "action": "publish|read|playback|api|metrics|pprof",
# "path": "path",
# "protocol": "rtsp|rtmp|hls|webrtc|srt",
# "id": "id",
# "query": "query"
# }
# and a payload described in the documentation.
# If the response code is 20x, authentication is accepted, otherwise
# it is discarded.
authHTTPAddress: