36 changed files with 1841 additions and 1164 deletions
@ -1,279 +0,0 @@ |
|||||||
package core |
|
||||||
|
|
||||||
import ( |
|
||||||
"strconv" |
|
||||||
"sync" |
|
||||||
|
|
||||||
"github.com/pion/ice/v2" |
|
||||||
"github.com/pion/interceptor" |
|
||||||
"github.com/pion/webrtc/v3" |
|
||||||
|
|
||||||
"github.com/bluenviron/mediamtx/internal/logger" |
|
||||||
) |
|
||||||
|
|
||||||
var videoCodecs = []webrtc.RTPCodecParameters{ |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeAV1, |
|
||||||
ClockRate: 90000, |
|
||||||
}, |
|
||||||
PayloadType: 96, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeVP9, |
|
||||||
ClockRate: 90000, |
|
||||||
SDPFmtpLine: "profile-id=0", |
|
||||||
}, |
|
||||||
PayloadType: 97, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeVP9, |
|
||||||
ClockRate: 90000, |
|
||||||
SDPFmtpLine: "profile-id=1", |
|
||||||
}, |
|
||||||
PayloadType: 98, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeVP8, |
|
||||||
ClockRate: 90000, |
|
||||||
}, |
|
||||||
PayloadType: 99, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeH264, |
|
||||||
ClockRate: 90000, |
|
||||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", |
|
||||||
}, |
|
||||||
PayloadType: 100, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeH264, |
|
||||||
ClockRate: 90000, |
|
||||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", |
|
||||||
}, |
|
||||||
PayloadType: 101, |
|
||||||
}, |
|
||||||
} |
|
||||||
|
|
||||||
var audioCodecs = []webrtc.RTPCodecParameters{ |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeOpus, |
|
||||||
ClockRate: 48000, |
|
||||||
Channels: 2, |
|
||||||
SDPFmtpLine: "minptime=10;useinbandfec=1", |
|
||||||
}, |
|
||||||
PayloadType: 111, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypeG722, |
|
||||||
ClockRate: 8000, |
|
||||||
}, |
|
||||||
PayloadType: 9, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypePCMU, |
|
||||||
ClockRate: 8000, |
|
||||||
}, |
|
||||||
PayloadType: 0, |
|
||||||
}, |
|
||||||
{ |
|
||||||
RTPCodecCapability: webrtc.RTPCodecCapability{ |
|
||||||
MimeType: webrtc.MimeTypePCMA, |
|
||||||
ClockRate: 8000, |
|
||||||
}, |
|
||||||
PayloadType: 8, |
|
||||||
}, |
|
||||||
} |
|
||||||
|
|
||||||
type peerConnection struct { |
|
||||||
*webrtc.PeerConnection |
|
||||||
stateChangeMutex sync.Mutex |
|
||||||
localCandidateRecv chan *webrtc.ICECandidateInit |
|
||||||
connected chan struct{} |
|
||||||
disconnected chan struct{} |
|
||||||
closed chan struct{} |
|
||||||
gatheringDone chan struct{} |
|
||||||
} |
|
||||||
|
|
||||||
func newPeerConnection( |
|
||||||
iceServers []webrtc.ICEServer, |
|
||||||
iceHostNAT1To1IPs []string, |
|
||||||
iceUDPMux ice.UDPMux, |
|
||||||
iceTCPMux ice.TCPMux, |
|
||||||
log logger.Writer, |
|
||||||
) (*peerConnection, error) { |
|
||||||
configuration := webrtc.Configuration{ICEServers: iceServers} |
|
||||||
settingsEngine := webrtc.SettingEngine{} |
|
||||||
|
|
||||||
if len(iceHostNAT1To1IPs) != 0 { |
|
||||||
settingsEngine.SetNAT1To1IPs(iceHostNAT1To1IPs, webrtc.ICECandidateTypeHost) |
|
||||||
} |
|
||||||
|
|
||||||
if iceUDPMux != nil { |
|
||||||
settingsEngine.SetICEUDPMux(iceUDPMux) |
|
||||||
} |
|
||||||
|
|
||||||
if iceTCPMux != nil { |
|
||||||
settingsEngine.SetICETCPMux(iceTCPMux) |
|
||||||
settingsEngine.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeTCP4}) |
|
||||||
} |
|
||||||
|
|
||||||
mediaEngine := &webrtc.MediaEngine{} |
|
||||||
|
|
||||||
for _, codec := range videoCodecs { |
|
||||||
err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
for _, codec := range audioCodecs { |
|
||||||
err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeAudio) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interceptorRegistry := &interceptor.Registry{} |
|
||||||
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
api := webrtc.NewAPI( |
|
||||||
webrtc.WithSettingEngine(settingsEngine), |
|
||||||
webrtc.WithMediaEngine(mediaEngine), |
|
||||||
webrtc.WithInterceptorRegistry(interceptorRegistry)) |
|
||||||
|
|
||||||
pc, err := api.NewPeerConnection(configuration) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
co := &peerConnection{ |
|
||||||
PeerConnection: pc, |
|
||||||
localCandidateRecv: make(chan *webrtc.ICECandidateInit), |
|
||||||
connected: make(chan struct{}), |
|
||||||
disconnected: make(chan struct{}), |
|
||||||
closed: make(chan struct{}), |
|
||||||
gatheringDone: make(chan struct{}), |
|
||||||
} |
|
||||||
|
|
||||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { |
|
||||||
co.stateChangeMutex.Lock() |
|
||||||
defer co.stateChangeMutex.Unlock() |
|
||||||
|
|
||||||
select { |
|
||||||
case <-co.closed: |
|
||||||
return |
|
||||||
default: |
|
||||||
} |
|
||||||
|
|
||||||
log.Log(logger.Debug, "peer connection state: "+state.String()) |
|
||||||
|
|
||||||
switch state { |
|
||||||
case webrtc.PeerConnectionStateConnected: |
|
||||||
log.Log(logger.Info, "peer connection established, local candidate: %v, remote candidate: %v", |
|
||||||
co.localCandidate(), co.remoteCandidate()) |
|
||||||
|
|
||||||
close(co.connected) |
|
||||||
|
|
||||||
case webrtc.PeerConnectionStateDisconnected: |
|
||||||
close(co.disconnected) |
|
||||||
|
|
||||||
case webrtc.PeerConnectionStateClosed: |
|
||||||
close(co.closed) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
pc.OnICECandidate(func(i *webrtc.ICECandidate) { |
|
||||||
if i != nil { |
|
||||||
v := i.ToJSON() |
|
||||||
select { |
|
||||||
case co.localCandidateRecv <- &v: |
|
||||||
case <-co.connected: |
|
||||||
case <-co.closed: |
|
||||||
} |
|
||||||
} else { |
|
||||||
close(co.gatheringDone) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
return co, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (co *peerConnection) close() { |
|
||||||
co.PeerConnection.Close() |
|
||||||
<-co.closed |
|
||||||
} |
|
||||||
|
|
||||||
func (co *peerConnection) localCandidate() string { |
|
||||||
var cid string |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.ICECandidatePairStats); ok && tstats.Nominated { |
|
||||||
cid = tstats.LocalCandidateID |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if cid != "" { |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.ICECandidateStats); ok && tstats.ID == cid { |
|
||||||
return tstats.CandidateType.String() + "/" + tstats.Protocol + "/" + |
|
||||||
tstats.IP + "/" + strconv.FormatInt(int64(tstats.Port), 10) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return "" |
|
||||||
} |
|
||||||
|
|
||||||
func (co *peerConnection) remoteCandidate() string { |
|
||||||
var cid string |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.ICECandidatePairStats); ok && tstats.Nominated { |
|
||||||
cid = tstats.RemoteCandidateID |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if cid != "" { |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.ICECandidateStats); ok && tstats.ID == cid { |
|
||||||
return tstats.CandidateType.String() + "/" + tstats.Protocol + "/" + |
|
||||||
tstats.IP + "/" + strconv.FormatInt(int64(tstats.Port), 10) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return "" |
|
||||||
} |
|
||||||
|
|
||||||
func (co *peerConnection) bytesReceived() uint64 { |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.TransportStats); ok { |
|
||||||
if tstats.ID == "iceTransport" { |
|
||||||
return tstats.BytesReceived |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return 0 |
|
||||||
} |
|
||||||
|
|
||||||
func (co *peerConnection) bytesSent() uint64 { |
|
||||||
for _, stats := range co.GetStats() { |
|
||||||
if tstats, ok := stats.(webrtc.TransportStats); ok { |
|
||||||
if tstats.ID == "iceTransport" { |
|
||||||
return tstats.BytesSent |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return 0 |
|
||||||
} |
|
@ -0,0 +1,175 @@ |
|||||||
|
package core |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"net/url" |
||||||
|
"strings" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/pion/sdp/v3" |
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
|
||||||
|
"github.com/bluenviron/mediamtx/internal/conf" |
||||||
|
"github.com/bluenviron/mediamtx/internal/logger" |
||||||
|
"github.com/bluenviron/mediamtx/internal/webrtcpc" |
||||||
|
"github.com/bluenviron/mediamtx/internal/whip" |
||||||
|
) |
||||||
|
|
||||||
|
type webRTCSourceParent interface { |
||||||
|
logger.Writer |
||||||
|
setReady(req pathSourceStaticSetReadyReq) pathSourceStaticSetReadyRes |
||||||
|
setNotReady(req pathSourceStaticSetNotReadyReq) |
||||||
|
} |
||||||
|
|
||||||
|
type webRTCSource struct { |
||||||
|
readTimeout conf.StringDuration |
||||||
|
|
||||||
|
parent webRTCSourceParent |
||||||
|
} |
||||||
|
|
||||||
|
func newWebRTCSource( |
||||||
|
readTimeout conf.StringDuration, |
||||||
|
parent webRTCSourceParent, |
||||||
|
) *webRTCSource { |
||||||
|
s := &webRTCSource{ |
||||||
|
readTimeout: readTimeout, |
||||||
|
parent: parent, |
||||||
|
} |
||||||
|
|
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
func (s *webRTCSource) Log(level logger.Level, format string, args ...interface{}) { |
||||||
|
s.parent.Log(level, "[WebRTC source] "+format, args...) |
||||||
|
} |
||||||
|
|
||||||
|
// run implements sourceStaticImpl.
|
||||||
|
func (s *webRTCSource) run(ctx context.Context, cnf *conf.PathConf, _ chan *conf.PathConf) error { |
||||||
|
s.Log(logger.Debug, "connecting") |
||||||
|
|
||||||
|
u, err := url.Parse(cnf.Source) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
u.Scheme = strings.ReplaceAll(u.Scheme, "whep", "http") |
||||||
|
|
||||||
|
c := &http.Client{ |
||||||
|
Timeout: time.Duration(s.readTimeout), |
||||||
|
} |
||||||
|
|
||||||
|
iceServers, err := whip.GetICEServers(ctx, c, u.String()) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
api, err := webrtcNewAPI(nil, nil, nil) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
pc, err := webrtcpc.New(iceServers, api, s) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
defer pc.Close() |
||||||
|
|
||||||
|
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
err = pc.SetLocalDescription(offer) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
err = pc.WaitGatheringDone(ctx) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
res, err := whip.PostOffer(ctx, c, u.String(), pc.LocalDescription()) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
var sdp sdp.SessionDescription |
||||||
|
err = sdp.Unmarshal([]byte(res.Answer.SDP)) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// check that there are at most two tracks
|
||||||
|
_, err = webrtcTrackCount(sdp.MediaDescriptions) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
trackRecv := make(chan trackRecvPair) |
||||||
|
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { |
||||||
|
select { |
||||||
|
case trackRecv <- trackRecvPair{track, receiver}: |
||||||
|
case <-ctx.Done(): |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
err = pc.SetRemoteDescription(*res.Answer) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
err = webrtcWaitUntilConnected(ctx, pc) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
tracks, err := webrtcGatherIncomingTracks(ctx, pc, trackRecv, 0) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
medias := webrtcMediasOfIncomingTracks(tracks) |
||||||
|
|
||||||
|
rres := s.parent.setReady(pathSourceStaticSetReadyReq{ |
||||||
|
medias: medias, |
||||||
|
generateRTPPackets: true, |
||||||
|
}) |
||||||
|
if rres.err != nil { |
||||||
|
return rres.err |
||||||
|
} |
||||||
|
|
||||||
|
defer s.parent.setNotReady(pathSourceStaticSetNotReadyReq{}) |
||||||
|
|
||||||
|
for _, track := range tracks { |
||||||
|
track.start(rres.stream) |
||||||
|
} |
||||||
|
|
||||||
|
select { |
||||||
|
case <-pc.Disconnected(): |
||||||
|
return fmt.Errorf("peer connection closed") |
||||||
|
|
||||||
|
case <-ctx.Done(): |
||||||
|
return fmt.Errorf("terminated") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// apiSourceDescribe implements sourceStaticImpl.
|
||||||
|
func (*webRTCSource) apiSourceDescribe() pathAPISourceOrReader { |
||||||
|
return pathAPISourceOrReader{ |
||||||
|
Type: "webRTCSource", |
||||||
|
ID: "", |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,188 @@ |
|||||||
|
package core |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"io" |
||||||
|
"net" |
||||||
|
"net/http" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/bluenviron/gortsplib/v3" |
||||||
|
"github.com/bluenviron/gortsplib/v3/pkg/formats" |
||||||
|
"github.com/bluenviron/gortsplib/v3/pkg/url" |
||||||
|
"github.com/pion/rtp" |
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
|
||||||
|
"github.com/bluenviron/mediamtx/internal/webrtcpc" |
||||||
|
) |
||||||
|
|
||||||
|
func TestWebRTCSource(t *testing.T) { |
||||||
|
state := 0 |
||||||
|
|
||||||
|
api, err := webrtcNewAPI(nil, nil, nil) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
pc, err := webrtcpc.New(nil, api, nilLogger{}) |
||||||
|
require.NoError(t, err) |
||||||
|
defer pc.Close() |
||||||
|
|
||||||
|
outgoingTrack1, err := webrtc.NewTrackLocalStaticRTP( |
||||||
|
webrtc.RTPCodecCapability{ |
||||||
|
MimeType: webrtc.MimeTypeVP8, |
||||||
|
ClockRate: 90000, |
||||||
|
}, |
||||||
|
"vp8", |
||||||
|
webrtcStreamID, |
||||||
|
) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
_, err = pc.AddTrack(outgoingTrack1) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
outgoingTrack2, err := webrtc.NewTrackLocalStaticRTP( |
||||||
|
webrtc.RTPCodecCapability{ |
||||||
|
MimeType: webrtc.MimeTypeOpus, |
||||||
|
ClockRate: 48000, |
||||||
|
Channels: 2, |
||||||
|
}, |
||||||
|
"opus", |
||||||
|
webrtcStreamID, |
||||||
|
) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
_, err = pc.AddTrack(outgoingTrack2) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
httpServ := &http.Server{ |
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||||
|
switch state { |
||||||
|
case 0: |
||||||
|
require.Equal(t, http.MethodOptions, r.Method) |
||||||
|
require.Equal(t, "/my/resource", r.URL.Path) |
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH") |
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match") |
||||||
|
w.WriteHeader(http.StatusNoContent) |
||||||
|
|
||||||
|
case 1: |
||||||
|
require.Equal(t, http.MethodPost, r.Method) |
||||||
|
require.Equal(t, "/my/resource", r.URL.Path) |
||||||
|
require.Equal(t, "application/sdp", r.Header.Get("Content-Type")) |
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body) |
||||||
|
require.NoError(t, err) |
||||||
|
offer := whipOffer(body) |
||||||
|
|
||||||
|
err = pc.SetRemoteDescription(*offer) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
answer, err := pc.CreateAnswer(nil) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
err = pc.SetLocalDescription(answer) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
err = pc.WaitGatheringDone(context.Background()) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/sdp") |
||||||
|
w.Header().Set("Accept-Patch", "application/trickle-ice-sdpfrag") |
||||||
|
w.Header().Set("E-Tag", "test_etag") |
||||||
|
w.Header().Set("Location", "/my/resource/sessionid") |
||||||
|
w.WriteHeader(http.StatusCreated) |
||||||
|
w.Write([]byte(pc.LocalDescription().SDP)) |
||||||
|
|
||||||
|
go func() { |
||||||
|
<-pc.Connected() |
||||||
|
|
||||||
|
err = outgoingTrack1.WriteRTP(&rtp.Packet{ |
||||||
|
Header: rtp.Header{ |
||||||
|
Version: 2, |
||||||
|
Marker: true, |
||||||
|
PayloadType: 96, |
||||||
|
SequenceNumber: 123, |
||||||
|
Timestamp: 45343, |
||||||
|
SSRC: 563423, |
||||||
|
}, |
||||||
|
Payload: []byte{1}, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
err = outgoingTrack2.WriteRTP(&rtp.Packet{ |
||||||
|
Header: rtp.Header{ |
||||||
|
Version: 2, |
||||||
|
Marker: true, |
||||||
|
PayloadType: 97, |
||||||
|
SequenceNumber: 1123, |
||||||
|
Timestamp: 45343, |
||||||
|
SSRC: 563423, |
||||||
|
}, |
||||||
|
Payload: []byte{2}, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
}() |
||||||
|
|
||||||
|
default: |
||||||
|
t.Errorf("should not happen since there should not be additional candidates") |
||||||
|
} |
||||||
|
state++ |
||||||
|
}), |
||||||
|
} |
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "localhost:5555") |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
go httpServ.Serve(ln) |
||||||
|
defer httpServ.Shutdown(context.Background()) |
||||||
|
|
||||||
|
p, ok := newInstance("paths:\n" + |
||||||
|
" proxied:\n" + |
||||||
|
" source: whep://localhost:5555/my/resource\n" + |
||||||
|
" sourceOnDemand: yes\n") |
||||||
|
require.Equal(t, true, ok) |
||||||
|
defer p.Close() |
||||||
|
|
||||||
|
c := gortsplib.Client{} |
||||||
|
|
||||||
|
u, err := url.Parse("rtsp://127.0.0.1:8554/proxied") |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
err = c.Start(u.Scheme, u.Host) |
||||||
|
require.NoError(t, err) |
||||||
|
defer c.Close() |
||||||
|
|
||||||
|
medias, baseURL, _, err := c.Describe(u) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
var forma *formats.VP8 |
||||||
|
medi := medias.FindFormat(&forma) |
||||||
|
|
||||||
|
_, err = c.Setup(medi, baseURL, 0, 0) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
received := make(chan struct{}) |
||||||
|
|
||||||
|
c.OnPacketRTP(medi, forma, func(pkt *rtp.Packet) { |
||||||
|
require.Equal(t, []byte{3}, pkt.Payload) |
||||||
|
close(received) |
||||||
|
}) |
||||||
|
|
||||||
|
_, err = c.Play(nil) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
err = outgoingTrack1.WriteRTP(&rtp.Packet{ |
||||||
|
Header: rtp.Header{ |
||||||
|
Version: 2, |
||||||
|
Marker: true, |
||||||
|
PayloadType: 96, |
||||||
|
SequenceNumber: 124, |
||||||
|
Timestamp: 45343, |
||||||
|
SSRC: 563423, |
||||||
|
}, |
||||||
|
Payload: []byte{3}, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
|
||||||
|
<-received |
||||||
|
} |
@ -0,0 +1,93 @@ |
|||||||
|
//go:build enable_highlevel_tests
|
||||||
|
// +build enable_highlevel_tests
|
||||||
|
|
||||||
|
package highleveltests |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
) |
||||||
|
|
||||||
|
func TestHLSServerRead(t *testing.T) { |
||||||
|
p, ok := newInstance("paths:\n" + |
||||||
|
" all:\n") |
||||||
|
require.Equal(t, true, ok) |
||||||
|
defer p.Close() |
||||||
|
|
||||||
|
cnt1, err := newContainer("ffmpeg", "source", []string{ |
||||||
|
"-re", |
||||||
|
"-stream_loop", "-1", |
||||||
|
"-i", "emptyvideo.mkv", |
||||||
|
"-c", "copy", |
||||||
|
"-f", "rtsp", |
||||||
|
"rtsp://127.0.0.1:8554/test/stream", |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
defer cnt1.close() |
||||||
|
|
||||||
|
time.Sleep(1 * time.Second) |
||||||
|
|
||||||
|
cnt2, err := newContainer("ffmpeg", "dest", []string{ |
||||||
|
"-i", "http://127.0.0.1:8888/test/stream/index.m3u8", |
||||||
|
"-vframes", "1", |
||||||
|
"-f", "image2", |
||||||
|
"-y", "/dev/null", |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
defer cnt2.close() |
||||||
|
require.Equal(t, 0, cnt2.wait()) |
||||||
|
} |
||||||
|
|
||||||
|
func TestHLSServerAuth(t *testing.T) { |
||||||
|
for _, result := range []string{ |
||||||
|
"success", |
||||||
|
"fail", |
||||||
|
} { |
||||||
|
t.Run(result, func(t *testing.T) { |
||||||
|
conf := "paths:\n" + |
||||||
|
" all:\n" + |
||||||
|
" readUser: testreader\n" + |
||||||
|
" readPass: testpass\n" + |
||||||
|
" readIPs: [127.0.0.0/16]\n" |
||||||
|
|
||||||
|
p, ok := newInstance(conf) |
||||||
|
require.Equal(t, true, ok) |
||||||
|
defer p.Close() |
||||||
|
|
||||||
|
cnt1, err := newContainer("ffmpeg", "source", []string{ |
||||||
|
"-re", |
||||||
|
"-stream_loop", "-1", |
||||||
|
"-i", "emptyvideo.mkv", |
||||||
|
"-c", "copy", |
||||||
|
"-f", "rtsp", |
||||||
|
"rtsp://testpublisher:testpass@127.0.0.1:8554/teststream?param=value", |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
defer cnt1.close() |
||||||
|
|
||||||
|
time.Sleep(1 * time.Second) |
||||||
|
|
||||||
|
var usr string |
||||||
|
if result == "success" { |
||||||
|
usr = "testreader" |
||||||
|
} else { |
||||||
|
usr = "testreader2" |
||||||
|
} |
||||||
|
|
||||||
|
hc := &http.Client{Transport: &http.Transport{}} |
||||||
|
|
||||||
|
res, err := hc.Get("http://" + usr + ":testpass@127.0.0.1:8888/teststream/index.m3u8?param=value") |
||||||
|
require.NoError(t, err) |
||||||
|
defer res.Body.Close() |
||||||
|
|
||||||
|
if result == "success" { |
||||||
|
require.Equal(t, http.StatusOK, res.StatusCode) |
||||||
|
} else { |
||||||
|
require.Equal(t, http.StatusUnauthorized, res.StatusCode) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -1,182 +0,0 @@ |
|||||||
//go:build enable_highlevel_tests
|
|
||||||
// +build enable_highlevel_tests
|
|
||||||
|
|
||||||
package highleveltests |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"encoding/json" |
|
||||||
"net" |
|
||||||
"net/http" |
|
||||||
"testing" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/gin-gonic/gin" |
|
||||||
"github.com/stretchr/testify/require" |
|
||||||
) |
|
||||||
|
|
||||||
type testHTTPAuthenticator struct { |
|
||||||
action string |
|
||||||
|
|
||||||
s *http.Server |
|
||||||
} |
|
||||||
|
|
||||||
func newTestHTTPAuthenticator(t *testing.T, action string) *testHTTPAuthenticator { |
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:9120") |
|
||||||
require.NoError(t, err) |
|
||||||
|
|
||||||
ts := &testHTTPAuthenticator{ |
|
||||||
action: action, |
|
||||||
} |
|
||||||
|
|
||||||
router := gin.New() |
|
||||||
router.POST("/auth", ts.onAuth) |
|
||||||
|
|
||||||
ts.s = &http.Server{Handler: router} |
|
||||||
go ts.s.Serve(ln) |
|
||||||
|
|
||||||
return ts |
|
||||||
} |
|
||||||
|
|
||||||
func (ts *testHTTPAuthenticator) close() { |
|
||||||
ts.s.Shutdown(context.Background()) |
|
||||||
} |
|
||||||
|
|
||||||
func (ts *testHTTPAuthenticator) onAuth(ctx *gin.Context) { |
|
||||||
var in struct { |
|
||||||
IP string `json:"ip"` |
|
||||||
User string `json:"user"` |
|
||||||
Password string `json:"password"` |
|
||||||
Path string `json:"path"` |
|
||||||
Action string `json:"action"` |
|
||||||
Query string `json:"query"` |
|
||||||
} |
|
||||||
err := json.NewDecoder(ctx.Request.Body).Decode(&in) |
|
||||||
if err != nil { |
|
||||||
ctx.AbortWithStatus(http.StatusBadRequest) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
var user string |
|
||||||
if ts.action == "publish" { |
|
||||||
user = "testpublisher" |
|
||||||
} else { |
|
||||||
user = "testreader" |
|
||||||
} |
|
||||||
|
|
||||||
if in.IP != "127.0.0.1" || |
|
||||||
in.User != user || |
|
||||||
in.Password != "testpass" || |
|
||||||
in.Path != "teststream" || |
|
||||||
in.Action != ts.action || |
|
||||||
(in.Query != "user=testreader&pass=testpass¶m=value" && |
|
||||||
in.Query != "user=testpublisher&pass=testpass¶m=value" && |
|
||||||
in.Query != "param=value") { |
|
||||||
ctx.AbortWithStatus(http.StatusBadRequest) |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func TestHLSServerRead(t *testing.T) { |
|
||||||
p, ok := newInstance("paths:\n" + |
|
||||||
" all:\n") |
|
||||||
require.Equal(t, true, ok) |
|
||||||
defer p.Close() |
|
||||||
|
|
||||||
cnt1, err := newContainer("ffmpeg", "source", []string{ |
|
||||||
"-re", |
|
||||||
"-stream_loop", "-1", |
|
||||||
"-i", "emptyvideo.mkv", |
|
||||||
"-c", "copy", |
|
||||||
"-f", "rtsp", |
|
||||||
"rtsp://127.0.0.1:8554/test/stream", |
|
||||||
}) |
|
||||||
require.NoError(t, err) |
|
||||||
defer cnt1.close() |
|
||||||
|
|
||||||
time.Sleep(1 * time.Second) |
|
||||||
|
|
||||||
cnt2, err := newContainer("ffmpeg", "dest", []string{ |
|
||||||
"-i", "http://127.0.0.1:8888/test/stream/index.m3u8", |
|
||||||
"-vframes", "1", |
|
||||||
"-f", "image2", |
|
||||||
"-y", "/dev/null", |
|
||||||
}) |
|
||||||
require.NoError(t, err) |
|
||||||
defer cnt2.close() |
|
||||||
require.Equal(t, 0, cnt2.wait()) |
|
||||||
} |
|
||||||
|
|
||||||
func TestHLSServerAuth(t *testing.T) { |
|
||||||
for _, mode := range []string{ |
|
||||||
"internal", |
|
||||||
"external", |
|
||||||
} { |
|
||||||
for _, result := range []string{ |
|
||||||
"success", |
|
||||||
"fail", |
|
||||||
} { |
|
||||||
t.Run(mode+"_"+result, func(t *testing.T) { |
|
||||||
var conf string |
|
||||||
if mode == "internal" { |
|
||||||
conf = "paths:\n" + |
|
||||||
" all:\n" + |
|
||||||
" readUser: testreader\n" + |
|
||||||
" readPass: testpass\n" + |
|
||||||
" readIPs: [127.0.0.0/16]\n" |
|
||||||
} else { |
|
||||||
conf = "externalAuthenticationURL: http://127.0.0.1:9120/auth\n" + |
|
||||||
"paths:\n" + |
|
||||||
" all:\n" |
|
||||||
} |
|
||||||
|
|
||||||
p, ok := newInstance(conf) |
|
||||||
require.Equal(t, true, ok) |
|
||||||
defer p.Close() |
|
||||||
|
|
||||||
var a *testHTTPAuthenticator |
|
||||||
if mode == "external" { |
|
||||||
a = newTestHTTPAuthenticator(t, "publish") |
|
||||||
} |
|
||||||
|
|
||||||
cnt1, err := newContainer("ffmpeg", "source", []string{ |
|
||||||
"-re", |
|
||||||
"-stream_loop", "-1", |
|
||||||
"-i", "emptyvideo.mkv", |
|
||||||
"-c", "copy", |
|
||||||
"-f", "rtsp", |
|
||||||
"rtsp://testpublisher:testpass@127.0.0.1:8554/teststream?param=value", |
|
||||||
}) |
|
||||||
require.NoError(t, err) |
|
||||||
defer cnt1.close() |
|
||||||
|
|
||||||
time.Sleep(1 * time.Second) |
|
||||||
|
|
||||||
if mode == "external" { |
|
||||||
a.close() |
|
||||||
a = newTestHTTPAuthenticator(t, "read") |
|
||||||
defer a.close() |
|
||||||
} |
|
||||||
|
|
||||||
var usr string |
|
||||||
if result == "success" { |
|
||||||
usr = "testreader" |
|
||||||
} else { |
|
||||||
usr = "testreader2" |
|
||||||
} |
|
||||||
|
|
||||||
hc := &http.Client{Transport: &http.Transport{}} |
|
||||||
|
|
||||||
res, err := hc.Get("http://" + usr + ":testpass@127.0.0.1:8888/teststream/index.m3u8?param=value") |
|
||||||
require.NoError(t, err) |
|
||||||
defer res.Body.Close() |
|
||||||
|
|
||||||
if result == "success" { |
|
||||||
require.Equal(t, http.StatusOK, res.StatusCode) |
|
||||||
} else { |
|
||||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,196 @@ |
|||||||
|
// Package webrtcpc contains a WebRTC peer connection wrapper.
|
||||||
|
package webrtcpc |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"strconv" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
|
||||||
|
"github.com/bluenviron/mediamtx/internal/logger" |
||||||
|
) |
||||||
|
|
||||||
|
// PeerConnection is a wrapper around webrtc.PeerConnection.
|
||||||
|
type PeerConnection struct { |
||||||
|
*webrtc.PeerConnection |
||||||
|
stateChangeMutex sync.Mutex |
||||||
|
newLocalCandidate chan *webrtc.ICECandidateInit |
||||||
|
connected chan struct{} |
||||||
|
disconnected chan struct{} |
||||||
|
closed chan struct{} |
||||||
|
gatheringDone chan struct{} |
||||||
|
} |
||||||
|
|
||||||
|
// New allocates a PeerConnection.
|
||||||
|
func New( |
||||||
|
iceServers []webrtc.ICEServer, |
||||||
|
api *webrtc.API, |
||||||
|
log logger.Writer, |
||||||
|
) (*PeerConnection, error) { |
||||||
|
configuration := webrtc.Configuration{ICEServers: iceServers} |
||||||
|
|
||||||
|
pc, err := api.NewPeerConnection(configuration) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
co := &PeerConnection{ |
||||||
|
PeerConnection: pc, |
||||||
|
newLocalCandidate: make(chan *webrtc.ICECandidateInit), |
||||||
|
connected: make(chan struct{}), |
||||||
|
disconnected: make(chan struct{}), |
||||||
|
closed: make(chan struct{}), |
||||||
|
gatheringDone: make(chan struct{}), |
||||||
|
} |
||||||
|
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { |
||||||
|
co.stateChangeMutex.Lock() |
||||||
|
defer co.stateChangeMutex.Unlock() |
||||||
|
|
||||||
|
select { |
||||||
|
case <-co.closed: |
||||||
|
return |
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
log.Log(logger.Debug, "peer connection state: "+state.String()) |
||||||
|
|
||||||
|
switch state { |
||||||
|
case webrtc.PeerConnectionStateConnected: |
||||||
|
log.Log(logger.Info, "peer connection established, local candidate: %v, remote candidate: %v", |
||||||
|
co.LocalCandidate(), co.RemoteCandidate()) |
||||||
|
|
||||||
|
close(co.connected) |
||||||
|
|
||||||
|
case webrtc.PeerConnectionStateDisconnected: |
||||||
|
close(co.disconnected) |
||||||
|
|
||||||
|
case webrtc.PeerConnectionStateClosed: |
||||||
|
close(co.closed) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
pc.OnICECandidate(func(i *webrtc.ICECandidate) { |
||||||
|
if i != nil { |
||||||
|
v := i.ToJSON() |
||||||
|
select { |
||||||
|
case co.newLocalCandidate <- &v: |
||||||
|
case <-co.connected: |
||||||
|
case <-co.closed: |
||||||
|
} |
||||||
|
} else { |
||||||
|
close(co.gatheringDone) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
return co, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Close closes the connection.
|
||||||
|
func (co *PeerConnection) Close() { |
||||||
|
co.PeerConnection.Close() |
||||||
|
<-co.closed |
||||||
|
} |
||||||
|
|
||||||
|
// Connected returns when connected.
|
||||||
|
func (co *PeerConnection) Connected() <-chan struct{} { |
||||||
|
return co.connected |
||||||
|
} |
||||||
|
|
||||||
|
// Disconnected returns when disconnected.
|
||||||
|
func (co *PeerConnection) Disconnected() <-chan struct{} { |
||||||
|
return co.disconnected |
||||||
|
} |
||||||
|
|
||||||
|
// NewLocalCandidate returns when there's a new local candidate.
|
||||||
|
func (co *PeerConnection) NewLocalCandidate() <-chan *webrtc.ICECandidateInit { |
||||||
|
return co.newLocalCandidate |
||||||
|
} |
||||||
|
|
||||||
|
// GatheringDone returns when candidate gathering is complete.
|
||||||
|
func (co *PeerConnection) GatheringDone() <-chan struct{} { |
||||||
|
return co.gatheringDone |
||||||
|
} |
||||||
|
|
||||||
|
// WaitGatheringDone waits until candidate gathering is complete.
|
||||||
|
func (co *PeerConnection) WaitGatheringDone(ctx context.Context) error { |
||||||
|
for { |
||||||
|
select { |
||||||
|
case <-co.NewLocalCandidate(): |
||||||
|
case <-co.GatheringDone(): |
||||||
|
return nil |
||||||
|
case <-ctx.Done(): |
||||||
|
return fmt.Errorf("terminated") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// LocalCandidate returns the local candidate.
|
||||||
|
func (co *PeerConnection) LocalCandidate() string { |
||||||
|
var cid string |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.ICECandidatePairStats); ok && tstats.Nominated { |
||||||
|
cid = tstats.LocalCandidateID |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if cid != "" { |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.ICECandidateStats); ok && tstats.ID == cid { |
||||||
|
return tstats.CandidateType.String() + "/" + tstats.Protocol + "/" + |
||||||
|
tstats.IP + "/" + strconv.FormatInt(int64(tstats.Port), 10) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return "" |
||||||
|
} |
||||||
|
|
||||||
|
// RemoteCandidate returns the remote candidate.
|
||||||
|
func (co *PeerConnection) RemoteCandidate() string { |
||||||
|
var cid string |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.ICECandidatePairStats); ok && tstats.Nominated { |
||||||
|
cid = tstats.RemoteCandidateID |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if cid != "" { |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.ICECandidateStats); ok && tstats.ID == cid { |
||||||
|
return tstats.CandidateType.String() + "/" + tstats.Protocol + "/" + |
||||||
|
tstats.IP + "/" + strconv.FormatInt(int64(tstats.Port), 10) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return "" |
||||||
|
} |
||||||
|
|
||||||
|
// BytesReceived returns received bytes.
|
||||||
|
func (co *PeerConnection) BytesReceived() uint64 { |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.TransportStats); ok { |
||||||
|
if tstats.ID == "iceTransport" { |
||||||
|
return tstats.BytesReceived |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return 0 |
||||||
|
} |
||||||
|
|
||||||
|
// BytesSent returns sent bytes.
|
||||||
|
func (co *PeerConnection) BytesSent() uint64 { |
||||||
|
for _, stats := range co.GetStats() { |
||||||
|
if tstats, ok := stats.(webrtc.TransportStats); ok { |
||||||
|
if tstats.ID == "iceTransport" { |
||||||
|
return tstats.BytesSent |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return 0 |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
) |
||||||
|
|
||||||
|
// GetICEServers posts a WHIP/WHEP request for ICE servers.
|
||||||
|
func GetICEServers( |
||||||
|
ctx context.Context, |
||||||
|
hc *http.Client, |
||||||
|
ur string, |
||||||
|
) ([]webrtc.ICEServer, error) { |
||||||
|
req, err := http.NewRequestWithContext(ctx, "OPTIONS", ur, nil) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
res, err := hc.Do(req) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
defer res.Body.Close() |
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { |
||||||
|
return nil, fmt.Errorf("bad status code: %v", res.StatusCode) |
||||||
|
} |
||||||
|
|
||||||
|
return LinkHeaderUnmarshal(res.Header["Link"]) |
||||||
|
} |
@ -0,0 +1,83 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"strconv" |
||||||
|
|
||||||
|
"github.com/pion/sdp/v3" |
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
) |
||||||
|
|
||||||
|
// ICEFragmentUnmarshal decodes an ICE fragment.
|
||||||
|
func ICEFragmentUnmarshal(buf []byte) ([]*webrtc.ICECandidateInit, error) { |
||||||
|
buf = append([]byte("v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\n"), buf...) |
||||||
|
|
||||||
|
var sdp sdp.SessionDescription |
||||||
|
err := sdp.Unmarshal(buf) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var ret []*webrtc.ICECandidateInit |
||||||
|
|
||||||
|
for _, media := range sdp.MediaDescriptions { |
||||||
|
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" { |
||||||
|
ret = append(ret, &webrtc.ICECandidateInit{ |
||||||
|
Candidate: attr.Value, |
||||||
|
SDPMid: &mid, |
||||||
|
SDPMLineIndex: &midNum, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ret, nil |
||||||
|
} |
||||||
|
|
||||||
|
// ICEFragmentMarshal encodes an ICE fragment.
|
||||||
|
func ICEFragmentMarshal(offer string, candidates []*webrtc.ICECandidateInit) ([]byte, error) { |
||||||
|
var sdp sdp.SessionDescription |
||||||
|
err := sdp.Unmarshal([]byte(offer)) |
||||||
|
if err != nil || len(sdp.MediaDescriptions) == 0 { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
firstMedia := sdp.MediaDescriptions[0] |
||||||
|
iceUfrag, _ := firstMedia.Attribute("ice-ufrag") |
||||||
|
icePwd, _ := firstMedia.Attribute("ice-pwd") |
||||||
|
|
||||||
|
candidatesByMedia := make(map[uint16][]*webrtc.ICECandidateInit) |
||||||
|
for _, candidate := range candidates { |
||||||
|
mid := *candidate.SDPMLineIndex |
||||||
|
candidatesByMedia[mid] = append(candidatesByMedia[mid], candidate) |
||||||
|
} |
||||||
|
|
||||||
|
frag := "a=ice-ufrag:" + iceUfrag + "\r\n" + |
||||||
|
"a=ice-pwd:" + icePwd + "\r\n" |
||||||
|
|
||||||
|
for mid, media := range sdp.MediaDescriptions { |
||||||
|
cbm, ok := candidatesByMedia[uint16(mid)] |
||||||
|
if ok { |
||||||
|
frag += "m=" + media.MediaName.String() + "\r\n" + |
||||||
|
"a=mid:" + strconv.FormatUint(uint64(mid), 10) + "\r\n" |
||||||
|
|
||||||
|
for _, candidate := range cbm { |
||||||
|
frag += "a=candidate:" + candidate.Candidate + "\r\n" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return []byte(frag), nil |
||||||
|
} |
@ -0,0 +1,208 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
) |
||||||
|
|
||||||
|
func stringPtr(v string) *string { |
||||||
|
return &v |
||||||
|
} |
||||||
|
|
||||||
|
func uint16Ptr(v uint16) *uint16 { |
||||||
|
return &v |
||||||
|
} |
||||||
|
|
||||||
|
var iceFragmentCases = []struct { |
||||||
|
name string |
||||||
|
offer string |
||||||
|
candidates []*webrtc.ICECandidateInit |
||||||
|
enc string |
||||||
|
}{ |
||||||
|
{ |
||||||
|
"a", |
||||||
|
"v=0\n" + |
||||||
|
"o=- 8429658789122714282 1690995382 IN IP4 0.0.0.0\n" + |
||||||
|
"s=-\n" + |
||||||
|
"t=0 0\n" + |
||||||
|
"a=fingerprint:sha-256 EA:05:9D:04:8F:56:41:92:3E:D5:2B:55:03:" + |
||||||
|
"1B:5A:2C:3D:D8:B3:FB:1B:D9:F7:1F:DA:77:0E:B9:E0:3D:B6:FF\n" + |
||||||
|
"a=extmap-allow-mixed\n" + |
||||||
|
"a=group:BUNDLE 0\n" + |
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 123 118 45 46 116\n" + |
||||||
|
"c=IN IP4 0.0.0.0\n" + |
||||||
|
"a=setup:actpass\n" + |
||||||
|
"a=mid:0\n" + |
||||||
|
"a=ice-ufrag:tUQMzoQAVLzlvBys\n" + |
||||||
|
"a=ice-pwd:pimyGfJcjjRwvUjnmGOODSjtIxyDljQj\n" + |
||||||
|
"a=rtcp-mux\n" + |
||||||
|
"a=rtcp-rsize\n" + |
||||||
|
"a=rtpmap:96 VP8/90000\n" + |
||||||
|
"a=rtcp-fb:96 goog-remb \n" + |
||||||
|
"a=rtcp-fb:96 ccm fir\n" + |
||||||
|
"a=rtcp-fb:96 nack \n" + |
||||||
|
"a=rtcp-fb:96 nack pli\n" + |
||||||
|
"a=rtcp-fb:96 nack \n" + |
||||||
|
"a=rtcp-fb:96 nack pli\n" + |
||||||
|
"a=rtcp-fb:96 transport-cc \n" + |
||||||
|
"a=rtpmap:97 rtx/90000\n" + |
||||||
|
"a=fmtp:97 apt=96\n" + |
||||||
|
"a=rtcp-fb:97 nack \n" + |
||||||
|
"a=rtcp-fb:97 nack pli\n" + |
||||||
|
"a=rtcp-fb:97 transport-cc \n" + |
||||||
|
"a=rtpmap:98 VP9/90000\n" + |
||||||
|
"a=fmtp:98 profile-id=0\n" + |
||||||
|
"a=rtcp-fb:98 goog-remb \n" + |
||||||
|
"a=rtcp-fb:98 ccm fir\n" + |
||||||
|
"a=rtcp-fb:98 nack \n" + |
||||||
|
"a=rtcp-fb:98 nack pli\n" + |
||||||
|
"a=rtcp-fb:98 nack \n" + |
||||||
|
"a=rtcp-fb:98 nack pli\n" + |
||||||
|
"a=rtcp-fb:98 transport-cc \n" + |
||||||
|
"a=rtpmap:99 rtx/90000\n" + |
||||||
|
"a=fmtp:99 apt=98\n" + |
||||||
|
"a=rtcp-fb:99 nack \n" + |
||||||
|
"a=rtcp-fb:99 nack pli\n" + |
||||||
|
"a=rtcp-fb:99 transport-cc \n" + |
||||||
|
"a=rtpmap:100 VP9/90000\n" + |
||||||
|
"a=fmtp:100 profile-id=1\n" + |
||||||
|
"a=rtcp-fb:100 goog-remb \n" + |
||||||
|
"a=rtcp-fb:100 ccm fir\n" + |
||||||
|
"a=rtcp-fb:100 nack \n" + |
||||||
|
"a=rtcp-fb:100 nack pli\n" + |
||||||
|
"a=rtcp-fb:100 nack \n" + |
||||||
|
"a=rtcp-fb:100 nack pli\n" + |
||||||
|
"a=rtcp-fb:100 transport-cc \n" + |
||||||
|
"a=rtpmap:101 rtx/90000\n" + |
||||||
|
"a=fmtp:101 apt=100\n" + |
||||||
|
"a=rtcp-fb:101 nack \n" + |
||||||
|
"a=rtcp-fb:101 nack pli\n" + |
||||||
|
"a=rtcp-fb:101 transport-cc \n" + |
||||||
|
"a=rtpmap:102 H264/90000\n" + |
||||||
|
"a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\n" + |
||||||
|
"a=rtcp-fb:102 goog-remb \n" + |
||||||
|
"a=rtcp-fb:102 ccm fir\n" + |
||||||
|
"a=rtcp-fb:102 nack \n" + |
||||||
|
"a=rtcp-fb:102 nack pli\n" + |
||||||
|
"a=rtcp-fb:102 nack \n" + |
||||||
|
"a=rtcp-fb:102 nack pli\n" + |
||||||
|
"a=rtcp-fb:102 transport-cc \n" + |
||||||
|
"a=rtpmap:121 rtx/90000\n" + |
||||||
|
"a=fmtp:121 apt=102\n" + |
||||||
|
"a=rtcp-fb:121 nack \n" + |
||||||
|
"a=rtcp-fb:121 nack pli\n" + |
||||||
|
"a=rtcp-fb:121 transport-cc \n" + |
||||||
|
"a=rtpmap:127 H264/90000\n" + |
||||||
|
"a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\n" + |
||||||
|
"a=rtcp-fb:127 goog-remb \n" + |
||||||
|
"a=rtcp-fb:127 ccm fir\n" + |
||||||
|
"a=rtcp-fb:127 nack \n" + |
||||||
|
"a=rtcp-fb:127 nack pli\n" + |
||||||
|
"a=rtcp-fb:127 nack \n" + |
||||||
|
"a=rtcp-fb:127 nack pli\n" + |
||||||
|
"a=rtcp-fb:127 transport-cc \n" + |
||||||
|
"a=rtpmap:120 rtx/90000\n" + |
||||||
|
"a=fmtp:120 apt=127\n" + |
||||||
|
"a=rtcp-fb:120 nack \n" + |
||||||
|
"a=rtcp-fb:120 nack pli\n" + |
||||||
|
"a=rtcp-fb:120 transport-cc \n" + |
||||||
|
"a=rtpmap:125 H264/90000\n" + |
||||||
|
"a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" + |
||||||
|
"a=rtcp-fb:125 goog-remb \n" + |
||||||
|
"a=rtcp-fb:125 ccm fir\n" + |
||||||
|
"a=rtcp-fb:125 nack \n" + |
||||||
|
"a=rtcp-fb:125 nack pli\n" + |
||||||
|
"a=rtcp-fb:125 nack \n" + |
||||||
|
"a=rtcp-fb:125 nack pli\n" + |
||||||
|
"a=rtcp-fb:125 transport-cc \n" + |
||||||
|
"a=rtpmap:107 rtx/90000\n" + |
||||||
|
"a=fmtp:107 apt=125\n" + |
||||||
|
"a=rtcp-fb:107 nack \n" + |
||||||
|
"a=rtcp-fb:107 nack pli\n" + |
||||||
|
"a=rtcp-fb:107 transport-cc \n" + |
||||||
|
"a=rtpmap:108 H264/90000\n" + |
||||||
|
"a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\n" + |
||||||
|
"a=rtcp-fb:108 goog-remb \n" + |
||||||
|
"a=rtcp-fb:108 ccm fir\n" + |
||||||
|
"a=rtcp-fb:108 nack \n" + |
||||||
|
"a=rtcp-fb:108 nack pli\n" + |
||||||
|
"a=rtcp-fb:108 nack \n" + |
||||||
|
"a=rtcp-fb:108 nack pli\n" + |
||||||
|
"a=rtcp-fb:108 transport-cc \n" + |
||||||
|
"a=rtpmap:109 rtx/90000\n" + |
||||||
|
"a=fmtp:109 apt=108\n" + |
||||||
|
"a=rtcp-fb:109 nack \n" + |
||||||
|
"a=rtcp-fb:109 nack pli\n" + |
||||||
|
"a=rtcp-fb:109 transport-cc \n" + |
||||||
|
"a=rtpmap:123 H264/90000\n" + |
||||||
|
"a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032\n" + |
||||||
|
"a=rtcp-fb:123 goog-remb \n" + |
||||||
|
"a=rtcp-fb:123 ccm fir\n" + |
||||||
|
"a=rtcp-fb:123 nack \n" + |
||||||
|
"a=rtcp-fb:123 nack pli\n" + |
||||||
|
"a=rtcp-fb:123 nack \n" + |
||||||
|
"a=rtcp-fb:123 nack pli\n" + |
||||||
|
"a=rtcp-fb:123 transport-cc \n" + |
||||||
|
"a=rtpmap:118 rtx/90000\n" + |
||||||
|
"a=fmtp:118 apt=123\n" + |
||||||
|
"a=rtcp-fb:118 nack \n" + |
||||||
|
"a=rtcp-fb:118 nack pli\n" + |
||||||
|
"a=rtcp-fb:118 transport-cc \n" + |
||||||
|
"a=rtpmap:45 AV1/90000\n" + |
||||||
|
"a=rtcp-fb:45 goog-remb \n" + |
||||||
|
"a=rtcp-fb:45 ccm fir\n" + |
||||||
|
"a=rtcp-fb:45 nack \n" + |
||||||
|
"a=rtcp-fb:45 nack pli\n" + |
||||||
|
"a=rtcp-fb:45 nack \n" + |
||||||
|
"a=rtcp-fb:45 nack pli\n" + |
||||||
|
"a=rtcp-fb:45 transport-cc \n" + |
||||||
|
"a=rtpmap:46 rtx/90000\n" + |
||||||
|
"a=fmtp:46 apt=45\n" + |
||||||
|
"a=rtcp-fb:46 nack \n" + |
||||||
|
"a=rtcp-fb:46 nack pli\n" + |
||||||
|
"a=rtcp-fb:46 transport-cc \n" + |
||||||
|
"a=rtpmap:116 ulpfec/90000\n" + |
||||||
|
"a=rtcp-fb:116 nack \n" + |
||||||
|
"a=rtcp-fb:116 nack pli\n" + |
||||||
|
"a=rtcp-fb:116 transport-cc \n" + |
||||||
|
"a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" + |
||||||
|
"a=ssrc:3421396091 cname:BmFVQDtOlcBwXZCl\n" + |
||||||
|
"a=ssrc:3421396091 msid:BmFVQDtOlcBwXZCl CLgunVCazXXKLyEx\n" + |
||||||
|
"a=ssrc:3421396091 mslabel:BmFVQDtOlcBwXZCl\n" + |
||||||
|
"a=ssrc:3421396091 label:CLgunVCazXXKLyEx\n" + |
||||||
|
"a=msid:BmFVQDtOlcBwXZCl CLgunVCazXXKLyEx\n" + |
||||||
|
"a=sendrecv\n", |
||||||
|
[]*webrtc.ICECandidateInit{{ |
||||||
|
Candidate: "3628911098 1 udp 2130706431 192.168.3.218 49462 typ host", |
||||||
|
SDPMid: stringPtr("0"), |
||||||
|
SDPMLineIndex: uint16Ptr(0), |
||||||
|
}}, |
||||||
|
"a=ice-ufrag:tUQMzoQAVLzlvBys\r\n" + |
||||||
|
"a=ice-pwd:pimyGfJcjjRwvUjnmGOODSjtIxyDljQj\r\n" + |
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 123 118 45 46 116\r\n" + |
||||||
|
"a=mid:0\r\n" + |
||||||
|
"a=candidate:3628911098 1 udp 2130706431 192.168.3.218 49462 typ host\r\n", |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
func TestICEFragmentUnmarshal(t *testing.T) { |
||||||
|
for _, ca := range iceFragmentCases { |
||||||
|
t.Run(ca.name, func(t *testing.T) { |
||||||
|
candidates, err := ICEFragmentUnmarshal([]byte(ca.enc)) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Equal(t, ca.candidates, candidates) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestICEFragmentMarshal(t *testing.T) { |
||||||
|
for _, ca := range iceFragmentCases { |
||||||
|
t.Run(ca.name, func(t *testing.T) { |
||||||
|
byts, err := ICEFragmentMarshal(ca.offer, ca.candidates) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Equal(t, ca.enc, string(byts)) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,66 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"regexp" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
) |
||||||
|
|
||||||
|
func quoteCredential(v string) string { |
||||||
|
b, _ := json.Marshal(v) |
||||||
|
s := string(b) |
||||||
|
return s[1 : len(s)-1] |
||||||
|
} |
||||||
|
|
||||||
|
func unquoteCredential(v string) string { |
||||||
|
var s string |
||||||
|
json.Unmarshal([]byte("\""+v+"\""), &s) |
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
// LinkHeaderMarshal encodes a link header.
|
||||||
|
func LinkHeaderMarshal(iceServers []webrtc.ICEServer) []string { |
||||||
|
ret := make([]string, len(iceServers)) |
||||||
|
|
||||||
|
for i, server := range iceServers { |
||||||
|
link := "<" + server.URLs[0] + ">; rel=\"ice-server\"" |
||||||
|
if server.Username != "" { |
||||||
|
link += "; username=\"" + quoteCredential(server.Username) + "\"" + |
||||||
|
"; credential=\"" + quoteCredential(server.Credential.(string)) + "\"; credential-type=\"password\"" |
||||||
|
} |
||||||
|
ret[i] = link |
||||||
|
} |
||||||
|
|
||||||
|
return ret |
||||||
|
} |
||||||
|
|
||||||
|
var reLink = regexp.MustCompile(`^<(.+?)>; rel="ice-server"(; username="(.+?)"` + |
||||||
|
`; credential="(.+?)"; credential-type="password")?`) |
||||||
|
|
||||||
|
// LinkHeaderUnmarshal decodes a link header.
|
||||||
|
func LinkHeaderUnmarshal(link []string) ([]webrtc.ICEServer, error) { |
||||||
|
ret := make([]webrtc.ICEServer, len(link)) |
||||||
|
|
||||||
|
for i, li := range link { |
||||||
|
m := reLink.FindStringSubmatch(li) |
||||||
|
if m == nil { |
||||||
|
return nil, fmt.Errorf("invalid link header: '%s'", li) |
||||||
|
} |
||||||
|
|
||||||
|
s := webrtc.ICEServer{ |
||||||
|
URLs: []string{m[1]}, |
||||||
|
} |
||||||
|
|
||||||
|
if m[3] != "" { |
||||||
|
s.Username = unquoteCredential(m[3]) |
||||||
|
s.Credential = unquoteCredential(m[4]) |
||||||
|
s.CredentialType = webrtc.ICECredentialTypePassword |
||||||
|
} |
||||||
|
|
||||||
|
ret[i] = s |
||||||
|
} |
||||||
|
|
||||||
|
return ret, nil |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
) |
||||||
|
|
||||||
|
var linkHeaderCases = []struct { |
||||||
|
name string |
||||||
|
enc []string |
||||||
|
dec []webrtc.ICEServer |
||||||
|
}{ |
||||||
|
{ |
||||||
|
"a", |
||||||
|
[]string{ |
||||||
|
`<stun:stun.l.google.com:19302>; rel="ice-server"`, |
||||||
|
`<turns:turn.example.com>; rel="ice-server"; username="myuser\"a?2;B"; ` + |
||||||
|
`credential="mypwd"; credential-type="password"`, |
||||||
|
}, |
||||||
|
[]webrtc.ICEServer{ |
||||||
|
{ |
||||||
|
URLs: []string{"stun:stun.l.google.com:19302"}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
URLs: []string{"turns:turn.example.com"}, |
||||||
|
Username: "myuser\"a?2;B", |
||||||
|
Credential: "mypwd", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
func TestLinkHeaderUnmarshal(t *testing.T) { |
||||||
|
for _, ca := range linkHeaderCases { |
||||||
|
t.Run(ca.name, func(t *testing.T) { |
||||||
|
dec, err := LinkHeaderUnmarshal(ca.enc) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Equal(t, ca.dec, dec) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestLinkHeaderMarshal(t *testing.T) { |
||||||
|
for _, ca := range linkHeaderCases { |
||||||
|
t.Run(ca.name, func(t *testing.T) { |
||||||
|
enc := LinkHeaderMarshal(ca.dec) |
||||||
|
require.Equal(t, ca.enc, enc) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
// Package whip contains WebRTC / WHIP utilities.
|
||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
) |
||||||
|
|
||||||
|
// PostCandidate posts a WHIP/WHEP candidate.
|
||||||
|
func PostCandidate( |
||||||
|
ctx context.Context, |
||||||
|
hc *http.Client, |
||||||
|
ur string, |
||||||
|
offer *webrtc.SessionDescription, |
||||||
|
etag string, |
||||||
|
candidate *webrtc.ICECandidateInit, |
||||||
|
) error { |
||||||
|
frag, err := ICEFragmentMarshal(offer.SDP, []*webrtc.ICECandidateInit{candidate}) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "PATCH", ur, bytes.NewReader(frag)) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/trickle-ice-sdpfrag") |
||||||
|
req.Header.Set("If-Match", etag) |
||||||
|
|
||||||
|
res, err := hc.Do(req) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
defer res.Body.Close() |
||||||
|
|
||||||
|
if res.StatusCode != http.StatusNoContent { |
||||||
|
return fmt.Errorf("bad status code: %v", res.StatusCode) |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,76 @@ |
|||||||
|
package whip |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3" |
||||||
|
) |
||||||
|
|
||||||
|
// PostOfferResponse is the response to a post offer.
|
||||||
|
type PostOfferResponse struct { |
||||||
|
Answer *webrtc.SessionDescription |
||||||
|
Location string |
||||||
|
ETag string |
||||||
|
} |
||||||
|
|
||||||
|
// PostOffer posts a WHIP/WHEP offer.
|
||||||
|
func PostOffer( |
||||||
|
ctx context.Context, |
||||||
|
hc *http.Client, |
||||||
|
ur string, |
||||||
|
offer *webrtc.SessionDescription, |
||||||
|
) (*PostOfferResponse, error) { |
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", ur, bytes.NewReader([]byte(offer.SDP))) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/sdp") |
||||||
|
|
||||||
|
res, err := hc.Do(req) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
defer res.Body.Close() |
||||||
|
|
||||||
|
if res.StatusCode != http.StatusCreated { |
||||||
|
return nil, fmt.Errorf("bad status code: %v", res.StatusCode) |
||||||
|
} |
||||||
|
|
||||||
|
contentType := res.Header.Get("Content-Type") |
||||||
|
if contentType != "application/sdp" { |
||||||
|
return nil, fmt.Errorf("bad Content-Type: expected 'application/sdp', got '%s'", contentType) |
||||||
|
} |
||||||
|
|
||||||
|
acceptPatch := res.Header.Get("Accept-Patch") |
||||||
|
if acceptPatch != "application/trickle-ice-sdpfrag" { |
||||||
|
return nil, fmt.Errorf("wrong Accept-Patch: expected 'application/trickle-ice-sdpfrag', got '%s'", acceptPatch) |
||||||
|
} |
||||||
|
|
||||||
|
Location := res.Header.Get("Location") |
||||||
|
|
||||||
|
etag := res.Header.Get("E-Tag") |
||||||
|
if etag == "" { |
||||||
|
return nil, fmt.Errorf("E-Tag is missing") |
||||||
|
} |
||||||
|
|
||||||
|
sdp, err := io.ReadAll(res.Body) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
answer := &webrtc.SessionDescription{ |
||||||
|
Type: webrtc.SDPTypeAnswer, |
||||||
|
SDP: string(sdp), |
||||||
|
} |
||||||
|
|
||||||
|
return &PostOfferResponse{ |
||||||
|
Answer: answer, |
||||||
|
Location: Location, |
||||||
|
ETag: etag, |
||||||
|
}, nil |
||||||
|
} |
Loading…
Reference in new issue