Files
mediamtx/internal/servers/webrtc/session.go
T

766 lines
18 KiB
Go

package webrtc
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/google/uuid"
"github.com/pion/ice/v4"
"github.com/pion/sdp/v3"
pwebrtc "github.com/pion/webrtc/v4"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/hooks"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
"github.com/bluenviron/mediamtx/internal/protocols/webrtc"
"github.com/bluenviron/mediamtx/internal/protocols/whip"
"github.com/bluenviron/mediamtx/internal/stream"
)
func whipOffer(body []byte) *pwebrtc.SessionDescription {
return &pwebrtc.SessionDescription{
Type: pwebrtc.SDPTypeOffer,
SDP: string(body),
}
}
func parseOfferUfrag(offer []byte) string {
var desc sdp.SessionDescription
if err := desc.Unmarshal(offer); err != nil {
return ""
}
// per-media credentials (priority matches sdpFragmentToCredentials)
for _, media := range desc.MediaDescriptions {
if ufrag, ok := media.Attribute("ice-ufrag"); ok && ufrag != "" {
return ufrag
}
}
// session-level credentials
for _, attr := range desc.Attributes {
if attr.Key == "ice-ufrag" && attr.Value != "" {
return attr.Value
}
}
return ""
}
func replaceICECredentials(offerSDP []byte, ufrag, pwd string) []byte {
s := string(offerSDP)
sep := "\r\n"
if !strings.Contains(s, "\r\n") {
sep = "\n"
}
lines := strings.Split(s, sep)
for i, line := range lines {
if strings.HasPrefix(line, "a=ice-ufrag:") {
lines[i] = "a=ice-ufrag:" + ufrag
} else if strings.HasPrefix(line, "a=ice-pwd:") {
lines[i] = "a=ice-pwd:" + pwd
}
}
return []byte(strings.Join(lines, sep))
}
func sdpFragmentToCredentials(frag *whip.SDPFragment) (string, string, error) {
// media credentials
for _, media := range frag.Medias {
ufrag, _ := media.Attribute("ice-ufrag")
pwd, _ := media.Attribute("ice-pwd")
if ufrag != "" && pwd != "" {
return ufrag, pwd, nil
}
}
// session-wide credentials
var ufrag, pwd string
for _, attr := range frag.Attributes {
switch attr.Key {
case "ice-ufrag":
ufrag = attr.Value
case "ice-pwd":
pwd = attr.Value
}
}
if ufrag != "" && pwd != "" {
return ufrag, pwd, nil
}
return "", "", fmt.Errorf("ICE credentials not found")
}
func sdpFragmentToCandidates(frag *whip.SDPFragment) ([]*pwebrtc.ICECandidateInit, error) {
var candidates []*pwebrtc.ICECandidateInit
for _, media := range frag.Medias {
mid, ok := media.Attribute("mid")
if !ok {
return nil, fmt.Errorf("mid attribute is missing")
}
tmp, err := strconv.ParseUint(mid, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid mid attribute")
}
midNum := uint16(tmp)
for _, attr := range media.Attributes {
if attr.Key == "candidate" {
candidates = append(candidates, &pwebrtc.ICECandidateInit{
Candidate: attr.Value,
SDPMid: &mid,
SDPMLineIndex: &midNum,
})
}
}
}
return candidates, nil
}
func mediaHasCredentialsOrCandidates(media *sdp.MediaDescription) bool {
hasUfrag := false
hasPwd := false
for _, attr := range media.Attributes {
if attr.Value != "" {
switch attr.Key {
case "ice-ufrag":
hasUfrag = true
case "ice-pwd":
hasPwd = true
case "candidate":
return true
}
}
}
return (hasUfrag && hasPwd)
}
func fullAnswerToSDPFragment(answerSDP string) (*whip.SDPFragment, error) {
var psdp sdp.SessionDescription
err := psdp.Unmarshal([]byte(answerSDP))
if err != nil {
return nil, err
}
frag := &whip.SDPFragment{
Attributes: []sdp.Attribute{
{Key: "ice-options", Value: "trickle ice2"},
},
}
filled := false
for _, attr := range psdp.Attributes {
switch attr.Key {
case "ice-ufrag", "ice-pwd":
frag.Attributes = append(frag.Attributes, sdp.Attribute{Key: attr.Key, Value: attr.Value})
filled = true
}
}
for _, media := range psdp.MediaDescriptions {
if mediaHasCredentialsOrCandidates(media) {
filled = true
mid, ok := media.Attribute("mid")
if !ok {
return nil, fmt.Errorf("mid attribute is missing")
}
mediaFrag := &sdp.MediaDescription{
MediaName: media.MediaName,
Attributes: []sdp.Attribute{
{Key: "mid", Value: mid},
},
}
ufrag, _ := media.Attribute("ice-ufrag")
pwd, _ := media.Attribute("ice-pwd")
if ufrag != "" && pwd != "" {
mediaFrag.Attributes = append(mediaFrag.Attributes, sdp.Attribute{Key: "ice-ufrag", Value: ufrag})
mediaFrag.Attributes = append(mediaFrag.Attributes, sdp.Attribute{Key: "ice-pwd", Value: pwd})
}
for _, attr := range media.Attributes {
if attr.Key == "candidate" {
mediaFrag.Attributes = append(mediaFrag.Attributes, attr)
}
}
mediaFrag.Attributes = append(mediaFrag.Attributes, sdp.Attribute{Key: "end-of-candidates"})
frag.Medias = append(frag.Medias, mediaFrag)
}
}
if !filled {
return nil, fmt.Errorf("no credentials or candidates found in the answer")
}
return frag, nil
}
type sessionParent interface {
closeSession(sx *session)
generateICEServers(clientConfig bool) ([]pwebrtc.ICEServer, error)
logger.Writer
}
type session struct {
udpReadBufferSize uint
parentCtx context.Context
ipsFromInterfaces bool
ipsFromInterfacesList []string
additionalHosts []string
iceUDPMux ice.UDPMux
iceTCPMux *webrtc.TCPMuxWrapper
stunGatherTimeout conf.Duration
handshakeTimeout conf.Duration
trackGatherTimeout conf.Duration
req webRTCNewSessionReq
wg *sync.WaitGroup
externalCmdPool *externalcmd.Pool
pathManager serverPathManager
parent sessionParent
ctx context.Context
ctxCancel func()
created time.Time
uuid uuid.UUID
secret uuid.UUID
mutex sync.RWMutex
reader *stream.Reader
pc *webrtc.PeerConnection
user string
chNew chan webRTCNewSessionReq
chAddCandidates chan webRTCAddSessionCandidatesReq
}
func (s *session) initialize() {
ctx, ctxCancel := context.WithCancel(s.parentCtx)
s.ctx = ctx
s.ctxCancel = ctxCancel
s.created = time.Now()
s.uuid = uuid.New()
s.secret = uuid.New()
s.chNew = make(chan webRTCNewSessionReq)
s.chAddCandidates = make(chan webRTCAddSessionCandidatesReq)
s.Log(logger.Info, "created by %s", s.req.remoteAddr)
s.wg.Add(1)
go s.run()
}
// Log implements logger.Writer.
func (s *session) Log(level logger.Level, format string, args ...any) {
id := hex.EncodeToString(s.uuid[:4])
s.parent.Log(level, "[session %v] "+format, append([]any{id}, args...)...)
}
func (s *session) Close() {
s.ctxCancel()
}
func (s *session) run() {
defer s.wg.Done()
err := s.runInner()
s.ctxCancel()
s.parent.closeSession(s)
s.Log(logger.Info, "closed: %v", err)
}
func (s *session) runInner() error {
select {
case <-s.chNew:
case <-s.ctx.Done():
return fmt.Errorf("terminated")
}
errStatusCode, err := s.runInner2()
if errStatusCode != 0 {
s.req.res <- webRTCNewSessionRes{
errStatusCode: errStatusCode,
err: err,
}
}
return err
}
func (s *session) runInner2() (int, error) {
if s.req.publish {
return s.runPublish()
}
return s.runRead()
}
func (s *session) runPublish() (int, error) {
ip, _, _ := net.SplitHostPort(s.req.remoteAddr)
res1, err := s.pathManager.FindPathConf(defs.PathFindPathConfReq{
AccessRequest: defs.PathAccessRequest{
Name: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
Publish: true,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.req.httpRequest),
IP: net.ParseIP(ip),
},
})
if err != nil {
return http.StatusBadRequest, err
}
s.mutex.Lock()
s.user = res1.User
s.mutex.Unlock()
iceServers, err := s.parent.generateICEServers(false)
if err != nil {
return http.StatusInternalServerError, err
}
pc := &webrtc.PeerConnection{
UDPReadBufferSize: s.udpReadBufferSize,
ICEUDPMux: s.iceUDPMux,
ICETCPMux: s.iceTCPMux,
ICEServers: iceServers,
IPsFromInterfaces: s.ipsFromInterfaces,
IPsFromInterfacesList: s.ipsFromInterfacesList,
AdditionalHosts: s.additionalHosts,
STUNGatherTimeout: time.Duration(s.stunGatherTimeout),
Publish: false,
Log: s,
}
err = pc.Start()
if err != nil {
return http.StatusBadRequest, err
}
terminatorDone := make(chan struct{})
defer func() { <-terminatorDone }()
terminatorRun := make(chan struct{})
defer close(terminatorRun)
go func() {
defer close(terminatorDone)
select {
case <-s.ctx.Done():
case <-terminatorRun:
}
pc.Close()
}()
offer := whipOffer(s.req.offer)
var sdp sdp.SessionDescription
err = sdp.Unmarshal([]byte(offer.SDP))
if err != nil {
return http.StatusBadRequest, err
}
err = webrtc.TracksAreValid(sdp.MediaDescriptions)
if err != nil {
// RFC draft-ietf-wish-whip
// if the number of audio and or video
// tracks or number streams is not supported by the WHIP Endpoint, it
// MUST reject the HTTP POST request with a "406 Not Acceptable" error
// response.
return http.StatusNotAcceptable, err
}
answer, err := pc.CreateFullAnswer(offer, false)
if err != nil {
return http.StatusBadRequest, err
}
s.writeAnswer(answer)
go s.readRemoteCandidates(pc)
err = pc.WaitUntilConnected(time.Duration(s.handshakeTimeout))
if err != nil {
return 0, err
}
s.mutex.Lock()
s.pc = pc
s.mutex.Unlock()
err = pc.GatherIncomingTracks(time.Duration(s.trackGatherTimeout))
if err != nil {
return 0, err
}
var subStream *stream.SubStream
medias, err := webrtc.ToStream(pc, res1.Conf, &subStream, s)
if err != nil {
return 0, err
}
res2, err := s.pathManager.AddPublisher(defs.PathAddPublisherReq{
Author: s,
Desc: &description.Session{Medias: medias},
UseRTPPackets: true,
ReplaceNTP: !res1.Conf.UseAbsoluteTimestamp,
ConfToCompare: res1.Conf,
AccessRequest: defs.PathAccessRequest{
Name: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
Publish: true,
SkipAuth: true,
},
})
if err != nil {
return 0, err
}
defer res2.Path.RemovePublisher(defs.PathRemovePublisherReq{Author: s})
subStream = res2.SubStream
pc.StartReading()
select {
case <-pc.Failed():
return 0, fmt.Errorf("peer connection closed")
case <-s.ctx.Done():
return 0, fmt.Errorf("terminated")
}
}
func (s *session) runRead() (int, error) {
ip, _, _ := net.SplitHostPort(s.req.remoteAddr)
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
Author: s,
AccessRequest: defs.PathAccessRequest{
Name: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.req.httpRequest),
IP: net.ParseIP(ip),
},
})
if err != nil {
var terr2 *defs.PathNoStreamAvailableError
if errors.As(err, &terr2) {
return http.StatusNotFound, err
}
return http.StatusBadRequest, err
}
defer res.Path.RemoveReader(defs.PathRemoveReaderReq{Author: s})
s.mutex.Lock()
s.user = res.User
s.mutex.Unlock()
iceServers, err := s.parent.generateICEServers(false)
if err != nil {
return http.StatusInternalServerError, err
}
pc := &webrtc.PeerConnection{
UDPReadBufferSize: s.udpReadBufferSize,
ICEUDPMux: s.iceUDPMux,
ICETCPMux: s.iceTCPMux,
ICEServers: iceServers,
IPsFromInterfaces: s.ipsFromInterfaces,
IPsFromInterfacesList: s.ipsFromInterfacesList,
AdditionalHosts: s.additionalHosts,
STUNGatherTimeout: time.Duration(s.stunGatherTimeout),
Publish: true,
Log: s,
}
r := &stream.Reader{Parent: s}
err = webrtc.FromStream(res.Stream.Desc, r, pc)
if err != nil {
return http.StatusBadRequest, err
}
err = pc.Start()
if err != nil {
return http.StatusBadRequest, err
}
terminatorDone := make(chan struct{})
defer func() { <-terminatorDone }()
terminatorRun := make(chan struct{})
defer close(terminatorRun)
go func() {
defer close(terminatorDone)
select {
case <-s.ctx.Done():
case <-terminatorRun:
}
pc.Close()
}()
offer := whipOffer(s.req.offer)
answer, err := pc.CreateFullAnswer(offer, false)
if err != nil {
return http.StatusBadRequest, err
}
s.writeAnswer(answer)
go s.readRemoteCandidates(pc)
err = pc.WaitUntilConnected(time.Duration(s.handshakeTimeout))
if err != nil {
return 0, err
}
s.mutex.Lock()
s.pc = pc
s.mutex.Unlock()
s.Log(logger.Info, "is reading from path '%s', %s",
res.Path.Name(), defs.FormatsInfo(r.Formats()))
onUnreadHook := hooks.OnRead(hooks.OnReadParams{
Logger: s,
ExternalCmdPool: s.externalCmdPool,
Conf: res.Path.SafeConf(),
ExternalCmdEnv: res.Path.ExternalCmdEnv(),
Reader: *s.APIReaderDescribe(),
Query: s.req.httpRequest.URL.RawQuery,
})
defer onUnreadHook()
res.Stream.AddReader(r)
defer res.Stream.RemoveReader(r)
s.mutex.Lock()
s.reader = r
s.mutex.Unlock()
select {
case <-pc.Failed():
return 0, fmt.Errorf("peer connection closed")
case err = <-r.Error():
return 0, err
case <-s.ctx.Done():
return 0, fmt.Errorf("terminated")
}
}
func (s *session) writeAnswer(answer *pwebrtc.SessionDescription) {
s.req.res <- webRTCNewSessionRes{
sx: s,
answer: []byte(answer.SDP),
}
}
func (s *session) readRemoteCandidates(pc *webrtc.PeerConnection) {
remoteUfrag := parseOfferUfrag(s.req.offer)
for {
select {
case req := <-s.chAddCandidates:
// do not check for errors since credentials are optional
ufrag, pwd, _ := sdpFragmentToCredentials(req.fragment)
candidates, err := sdpFragmentToCandidates(req.fragment)
if err != nil {
req.res <- webRTCAddSessionCandidatesRes{err: err}
continue
}
// ICE restart: client sent new credentials
var answer *pwebrtc.SessionDescription
if ufrag != "" && ufrag != remoteUfrag {
sdp := replaceICECredentials(s.req.offer, ufrag, pwd)
answer, err = pc.CreateFullAnswer(whipOffer(sdp), true)
if err != nil {
req.res <- webRTCAddSessionCandidatesRes{err: err}
continue
}
}
var addErr error
for _, candidate := range candidates {
addErr = pc.AddRemoteCandidate(candidate)
if addErr != nil {
break
}
}
if addErr != nil {
req.res <- webRTCAddSessionCandidatesRes{err: addErr}
continue
}
if ufrag != "" && ufrag != remoteUfrag {
var frag *whip.SDPFragment
frag, err = fullAnswerToSDPFragment(answer.SDP)
if err != nil {
req.res <- webRTCAddSessionCandidatesRes{err: err}
continue
}
remoteUfrag = ufrag
req.res <- webRTCAddSessionCandidatesRes{answer: frag}
} else {
req.res <- webRTCAddSessionCandidatesRes{}
}
case <-s.ctx.Done():
return
}
}
}
// new is called by webRTCHTTPServer through Server.
func (s *session) new(req webRTCNewSessionReq) webRTCNewSessionRes {
select {
case s.chNew <- req:
return <-req.res
case <-s.ctx.Done():
return webRTCNewSessionRes{err: fmt.Errorf("terminated"), errStatusCode: http.StatusInternalServerError}
}
}
// addCandidates is called by webRTCHTTPServer through Server.
func (s *session) addCandidates(
req webRTCAddSessionCandidatesReq,
) webRTCAddSessionCandidatesRes {
select {
case s.chAddCandidates <- req:
return <-req.res
case <-s.ctx.Done():
return webRTCAddSessionCandidatesRes{err: fmt.Errorf("terminated")}
}
}
// APIReaderDescribe implements reader.
func (s *session) APIReaderDescribe() *defs.APIPathReader {
return &defs.APIPathReader{
Type: defs.APIPathReaderTypeWebRTCSession,
ID: s.uuid.String(),
}
}
// APISourceDescribe implements source.
func (s *session) APISourceDescribe() *defs.APIPathSource {
return &defs.APIPathSource{
Type: defs.APIPathSourceTypeWebRTCSession,
ID: s.uuid.String(),
}
}
func (s *session) apiItem() *defs.APIWebRTCSession {
s.mutex.RLock()
defer s.mutex.RUnlock()
peerConnectionEstablished := false
localCandidate := ""
remoteCandidate := ""
bytesReceived := uint64(0)
bytesSent := uint64(0)
rtpPacketsReceived := uint64(0)
rtpPacketsSent := uint64(0)
rtpPacketsLost := uint64(0)
rtpPacketsJitter := float64(0)
rtcpPacketsReceived := uint64(0)
rtcpPacketsSent := uint64(0)
outboundFramesDiscarded := uint64(0)
if s.pc != nil {
peerConnectionEstablished = true
localCandidate = s.pc.LocalCandidate()
remoteCandidate = s.pc.RemoteCandidate()
stats := s.pc.Stats()
bytesReceived = stats.BytesReceived
bytesSent = stats.BytesSent
rtpPacketsReceived = stats.RTPPacketsReceived
rtpPacketsSent = stats.RTPPacketsSent
rtpPacketsLost = stats.RTPPacketsLost
rtpPacketsJitter = stats.RTPPacketsJitter
rtcpPacketsReceived = stats.RTCPPacketsReceived
rtcpPacketsSent = stats.RTCPPacketsSent
}
if s.reader != nil {
outboundFramesDiscarded = s.reader.OutboundFramesDiscarded()
}
return &defs.APIWebRTCSession{
ID: s.uuid,
Created: s.created,
RemoteAddr: s.req.remoteAddr,
PeerConnectionEstablished: peerConnectionEstablished,
LocalCandidate: localCandidate,
RemoteCandidate: remoteCandidate,
State: func() defs.APIWebRTCSessionState {
if s.req.publish {
return defs.APIWebRTCSessionStatePublish
}
return defs.APIWebRTCSessionStateRead
}(),
Path: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
User: s.user,
InboundBytes: bytesReceived,
InboundRTPPackets: rtpPacketsReceived,
InboundRTPPacketsLost: rtpPacketsLost,
InboundRTPPacketsJitter: rtpPacketsJitter,
InboundRTCPPackets: rtcpPacketsReceived,
OutboundBytes: bytesSent,
OutboundRTPPackets: rtpPacketsSent,
OutboundRTCPPackets: rtcpPacketsSent,
OutboundFramesDiscarded: outboundFramesDiscarded,
BytesReceived: bytesReceived,
BytesSent: bytesSent,
RTPPacketsReceived: rtpPacketsReceived,
RTPPacketsSent: rtpPacketsSent,
RTPPacketsLost: rtpPacketsLost,
RTPPacketsJitter: rtpPacketsJitter,
RTCPPacketsReceived: rtcpPacketsReceived,
RTCPPacketsSent: rtcpPacketsSent,
}
}