36 changed files with 1841 additions and 1164 deletions
@ -1,279 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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