moq: support pulling streams from other servers (#6111)
This commit is contained in:
@@ -141,6 +141,7 @@ components:
|
||||
- rtpSource
|
||||
- webRTCSession
|
||||
- webRTCSource
|
||||
- moqSource
|
||||
- moqSession
|
||||
|
||||
ForwardDestProtocol:
|
||||
@@ -947,6 +948,9 @@ components:
|
||||
maxReaders:
|
||||
type: integer
|
||||
format: int64
|
||||
moqTransport:
|
||||
type: string
|
||||
description: MoQ source
|
||||
mpegtsUDPReadBufferSize:
|
||||
type: integer
|
||||
format: uint64
|
||||
|
||||
@@ -3,29 +3,30 @@
|
||||
Live streams can be published to the server with the following protocols:
|
||||
|
||||
- [Media-over-QUIC clients](../3-publish/01-moq-clients.md)
|
||||
- [SRT clients](../3-publish/02-srt-clients.md)
|
||||
- [SRT cameras and servers](../3-publish/03-srt-cameras-and-servers.md)
|
||||
- [WebRTC clients](../3-publish/04-webrtc-clients.md)
|
||||
- [WebRTC servers](../3-publish/05-webrtc-servers.md)
|
||||
- [RTSP clients](../3-publish/06-rtsp-clients.md)
|
||||
- [RTSP cameras and servers](../3-publish/07-rtsp-cameras-and-servers.md)
|
||||
- [RTMP clients](../3-publish/08-rtmp-clients.md)
|
||||
- [RTMP cameras and servers](../3-publish/09-rtmp-cameras-and-servers.md)
|
||||
- [HLS cameras and servers](../3-publish/10-hls-cameras-and-servers.md)
|
||||
- [MPEG-TS](../3-publish/11-mpeg-ts.md)
|
||||
- [RTP](../3-publish/12-rtp.md)
|
||||
- [Media-over-QUIC servers](../3-publish/02-moq-servers.md)
|
||||
- [SRT clients](../3-publish/03-srt-clients.md)
|
||||
- [SRT cameras and servers](../3-publish/04-srt-cameras-and-servers.md)
|
||||
- [WebRTC clients](../3-publish/05-webrtc-clients.md)
|
||||
- [WebRTC servers](../3-publish/06-webrtc-servers.md)
|
||||
- [RTSP clients](../3-publish/07-rtsp-clients.md)
|
||||
- [RTSP cameras and servers](../3-publish/08-rtsp-cameras-and-servers.md)
|
||||
- [RTMP clients](../3-publish/09-rtmp-clients.md)
|
||||
- [RTMP cameras and servers](../3-publish/10-rtmp-cameras-and-servers.md)
|
||||
- [HLS cameras and servers](../3-publish/11-hls-cameras-and-servers.md)
|
||||
- [MPEG-TS](../3-publish/12-mpeg-ts.md)
|
||||
- [RTP](../3-publish/13-rtp.md)
|
||||
|
||||
We provide instructions for publishing with the following devices:
|
||||
|
||||
- [Raspberry Pi Cameras](../3-publish/13-raspberry-pi-cameras.md)
|
||||
- [Generic webcams](../3-publish/14-generic-webcams.md)
|
||||
- [Raspberry Pi Cameras](../3-publish/14-raspberry-pi-cameras.md)
|
||||
- [Generic webcams](../3-publish/15-generic-webcams.md)
|
||||
|
||||
We provide instructions for publishing with the following software:
|
||||
|
||||
- [Web browsers](../3-publish/15-web-browsers.md)
|
||||
- [FFmpeg](../3-publish/16-ffmpeg.md)
|
||||
- [GStreamer](../3-publish/17-gstreamer.md)
|
||||
- [OBS Studio](../3-publish/18-obs-studio.md)
|
||||
- [Python and OpenCV](../3-publish/19-python-opencv.md)
|
||||
- [Golang](../3-publish/20-golang.md)
|
||||
- [Unity](../3-publish/21-unity.md)
|
||||
- [Web browsers](../3-publish/16-web-browsers.md)
|
||||
- [FFmpeg](../3-publish/17-ffmpeg.md)
|
||||
- [GStreamer](../3-publish/18-gstreamer.md)
|
||||
- [OBS Studio](../3-publish/19-obs-studio.md)
|
||||
- [Python and OpenCV](../3-publish/20-python-opencv.md)
|
||||
- [Golang](../3-publish/21-golang.md)
|
||||
- [Unity](../3-publish/22-unity.md)
|
||||
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
whipBearerToken: ""
|
||||
```
|
||||
|
||||
If the remote server is a _MediaMTX_ instance, remember to add a `/whip` suffix after the stream name, since in _MediaMTX_ [it's part of the WHIP URL](../3-publish/04-webrtc-clients.md).
|
||||
If the remote server is a _MediaMTX_ instance, remember to add a `/whip` suffix after the stream name, since in _MediaMTX_ [it's part of the WHIP URL](../3-publish/05-webrtc-clients.md).
|
||||
|
||||
## RTSP
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SRT-specific features
|
||||
|
||||
SRT is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with SRT clients](../3-publish/02-srt-clients.md) and [Read with SRT clients](../4-read/02-srt.md). Features in this page are shared among both tasks.
|
||||
SRT is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with SRT clients](../3-publish/03-srt-clients.md) and [Read with SRT clients](../4-read/02-srt.md). Features in this page are shared among both tasks.
|
||||
|
||||
## Standard stream ID syntax
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WebRTC-specific features
|
||||
|
||||
WebRTC is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with WebRTC clients](../3-publish/04-webrtc-clients.md) and [Read with WebRTC clients](../4-read/03-webrtc.md). Features in this page are shared among both tasks.
|
||||
WebRTC is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with WebRTC clients](../3-publish/05-webrtc-clients.md) and [Read with WebRTC clients](../4-read/03-webrtc.md). Features in this page are shared among both tasks.
|
||||
|
||||
## Codec support in browsers
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RTSP-specific features
|
||||
|
||||
RTSP is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with RTSP clients](../3-publish/06-rtsp-clients.md) and [Read with RTSP clients](../4-read/04-rtsp.md). Features in this page are shared among both tasks.
|
||||
RTSP is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with RTSP clients](../3-publish/07-rtsp-clients.md) and [Read with RTSP clients](../4-read/04-rtsp.md). Features in this page are shared among both tasks.
|
||||
|
||||
## Transport protocols
|
||||
|
||||
@@ -107,4 +107,4 @@ There are also the `rtsps+http`, `rtsp+ws`, `rtsps+ws` schemes to handle any var
|
||||
|
||||
## MPEG-TS inside RTSP
|
||||
|
||||
read [MPEG-TS inside RTSP](../3-publish/06-rtsp-clients.md#mpeg-ts-inside-rtsp) in the "Publish with RTSP clients" page.
|
||||
read [MPEG-TS inside RTSP](../3-publish/07-rtsp-clients.md#mpeg-ts-inside-rtsp) in the "Publish with RTSP clients" page.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RTMP-specific features
|
||||
|
||||
RTMP is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with RTMP clients](../3-publish/08-rtmp-clients.md) and [Read with RTMP clients](../4-read/05-rtmp.md). Features in this page are shared among both tasks.
|
||||
RTMP is a protocol that can be used for publishing and reading streams. Regarding specific tasks, check out [Publish with RTMP clients](../3-publish/09-rtmp-clients.md) and [Read with RTMP clients](../4-read/05-rtmp.md). Features in this page are shared among both tasks.
|
||||
|
||||
## Encryption
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Media-over-QUIC servers
|
||||
|
||||
| | supported codecs |
|
||||
| --------- | ------------------------- |
|
||||
| **video** | AV1, VP9, VP8, H265, H264 |
|
||||
| **audio** | Opus, MPEG-4 Audio (AAC) |
|
||||
|
||||
In order to ingest a Media-over-QUIC stream from a remote server, add the corresponding URL into the `source` parameter of a path:
|
||||
|
||||
```yml
|
||||
paths:
|
||||
proxied:
|
||||
source: moqt://user:pass@host:port/path
|
||||
# Transport protocol used to pull the stream. available values are "quic", "webtransport".
|
||||
moqTransport: quic
|
||||
```
|
||||
@@ -18,4 +18,4 @@ If you need to use the standard stream ID syntax instead of the custom one in us
|
||||
|
||||
If you want to publish a stream by using a client in listening mode (i.e. with `mode=listener` appended to the URL), read the next section.
|
||||
|
||||
Some clients that can publish with SRT are [FFmpeg](16-ffmpeg.md), [GStreamer](17-gstreamer.md), [OBS Studio](18-obs-studio.md).
|
||||
Some clients that can publish with SRT are [FFmpeg](17-ffmpeg.md), [GStreamer](18-gstreamer.md), [OBS Studio](19-obs-studio.md).
|
||||
@@ -23,4 +23,4 @@ Be aware that not all browsers can publish tracks with any codec, check [Codec s
|
||||
|
||||
Depending on the network it might be difficult to establish a connection between server and clients, read [Solving WebRTC connectivity issues](../2-features/25-webrtc-specific-features.md#solving-webrtc-connectivity-issues).
|
||||
|
||||
Some clients that can publish with WebRTC and WHIP are [FFmpeg](16-ffmpeg.md), [GStreamer](17-gstreamer.md), [OBS Studio](18-obs-studio.md), [Unity](21-unity.md) and [Web browsers](15-web-browsers.md).
|
||||
Some clients that can publish with WebRTC and WHIP are [FFmpeg](17-ffmpeg.md), [GStreamer](18-gstreamer.md), [OBS Studio](19-obs-studio.md), [Unity](22-unity.md) and [Web browsers](16-web-browsers.md).
|
||||
@@ -14,7 +14,7 @@ rtsp://localhost:8554/mystream
|
||||
|
||||
The resulting stream will be available on path `/mystream`.
|
||||
|
||||
Some clients that can publish with RTSP are [FFmpeg](16-ffmpeg.md), [GStreamer](17-gstreamer.md), [OBS Studio](18-obs-studio.md), [Python and OpenCV](19-python-opencv.md).
|
||||
Some clients that can publish with RTSP are [FFmpeg](17-ffmpeg.md), [GStreamer](18-gstreamer.md), [OBS Studio](19-obs-studio.md), [Python and OpenCV](20-python-opencv.md).
|
||||
|
||||
Advanced RTSP features and settings are described in [RTSP-specific features](../2-features/26-rtsp-specific-features.md).
|
||||
|
||||
@@ -13,4 +13,4 @@ rtmp://localhost/mystream
|
||||
|
||||
The resulting stream will be available on path `/mystream`.
|
||||
|
||||
Some clients that can publish with RTMP are [FFmpeg](16-ffmpeg.md), [GStreamer](17-gstreamer.md) and [OBS Studio](18-obs-studio.md).
|
||||
Some clients that can publish with RTMP are [FFmpeg](17-ffmpeg.md), [GStreamer](18-gstreamer.md) and [OBS Studio](19-obs-studio.md).
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
source: udp+mpegts://0.0.0.0:1234?source=192.168.3.5
|
||||
```
|
||||
|
||||
Some clients that can publish with UDP and MPEG-TS are [FFmpeg](16-ffmpeg.md) and [GStreamer](17-gstreamer.md).
|
||||
Some clients that can publish with UDP and MPEG-TS are [FFmpeg](17-ffmpeg.md) and [GStreamer](18-gstreamer.md).
|
||||
|
||||
Unix sockets are more efficient than UDP packets and can be used as transport by specifying the `unix+mpegts` scheme:
|
||||
|
||||
@@ -58,4 +58,4 @@ paths:
|
||||
a=rtpmap:96 H264/90000
|
||||
```
|
||||
|
||||
Some clients that can publish with UDP and MPEG-TS are [FFmpeg](16-ffmpeg.md) and [GStreamer](17-gstreamer.md).
|
||||
Some clients that can publish with UDP and MPEG-TS are [FFmpeg](17-ffmpeg.md) and [GStreamer](18-gstreamer.md).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web browsers
|
||||
|
||||
Web browsers can publish a stream to the server by acting as a [Media-over-QUIC client](01-moq-clients.md) or a [WebRTC client](04-webrtc-clients.md).
|
||||
Web browsers can publish a stream to the server by acting as a [Media-over-QUIC client](01-moq-clients.md) or a [WebRTC client](05-webrtc-clients.md).
|
||||
|
||||
## Media-over-QUIC
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FFmpeg
|
||||
|
||||
FFmpeg can publish a stream to the server by acting as a [RTSP client](06-rtsp-clients.md), [RTMP client](08-rtmp-clients.md), [SRT client](02-srt-clients.md), [WebRTC client](04-webrtc-clients.md) or by sending [MPEG-TS packets](11-mpeg-ts.md) or [RTP packets](12-rtp.md). The recommended way is acting as a RTSP client.
|
||||
FFmpeg can publish a stream to the server by acting as a [RTSP client](07-rtsp-clients.md), [RTMP client](09-rtmp-clients.md), [SRT client](03-srt-clients.md), [WebRTC client](05-webrtc-clients.md) or by sending [MPEG-TS packets](12-mpeg-ts.md) or [RTP packets](13-rtp.md). The recommended way is acting as a RTSP client.
|
||||
|
||||
## FFmpeg as a RTSP client
|
||||
|
||||
@@ -52,7 +52,7 @@ ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=30 \
|
||||
|
||||
## FFmpeg and RTP over UDP
|
||||
|
||||
In _MediaMTX_ configuration, add a path with `source: udp+rtp://238.0.0.1:1234` and a valid `rtpSDP` (read [RTP](12-rtp.md)). Then:
|
||||
In _MediaMTX_ configuration, add a path with `source: udp+rtp://238.0.0.1:1234` and a valid `rtpSDP` (read [RTP](13-rtp.md)). Then:
|
||||
|
||||
```sh
|
||||
ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=30 \
|
||||
@@ -1,6 +1,6 @@
|
||||
# GStreamer
|
||||
|
||||
GStreamer can publish a stream to the server by acting as a [RTSP client](06-rtsp-clients.md), [RTMP client](08-rtmp-clients.md), [SRT client](02-srt-clients.md), [WebRTC client](04-webrtc-clients.md) or by sending [MPEG-TS packets](11-mpeg-ts.md) or [RTP packets](12-rtp.md). The recommended way is acting as a RTSP client.
|
||||
GStreamer can publish a stream to the server by acting as a [RTSP client](07-rtsp-clients.md), [RTMP client](09-rtmp-clients.md), [SRT client](03-srt-clients.md), [WebRTC client](05-webrtc-clients.md) or by sending [MPEG-TS packets](12-mpeg-ts.md) or [RTP packets](13-rtp.md). The recommended way is acting as a RTSP client.
|
||||
|
||||
## GStreamer as a RTSP client
|
||||
|
||||
@@ -58,7 +58,7 @@ audiotestsrc ! audioconvert ! avenc_aac ! mux.
|
||||
|
||||
## GStreamer and RTP over UDP
|
||||
|
||||
In _MediaMTX_ configuration, add a path with `source: udp+rtp://238.0.0.1:1234` and a valid `rtpSDP` (read [RTP](12-rtp.md)). Then:
|
||||
In _MediaMTX_ configuration, add a path with `source: udp+rtp://238.0.0.1:1234` and a valid `rtpSDP` (read [RTP](13-rtp.md)). Then:
|
||||
|
||||
```sh
|
||||
gst-launch-1.0 -v \
|
||||
@@ -1,6 +1,6 @@
|
||||
# OBS Studio
|
||||
|
||||
OBS Studio can publish streams to the server by acting as a [RTMP client](08-rtmp-clients.md) or a [WebRTC client](04-webrtc-clients.md) protocols. The recommended way is acting as a RTMP client.
|
||||
OBS Studio can publish streams to the server by acting as a [RTMP client](09-rtmp-clients.md) or a [WebRTC client](05-webrtc-clients.md) protocols. The recommended way is acting as a RTMP client.
|
||||
|
||||
## OBS Studio as a RTMP client
|
||||
|
||||
@@ -177,7 +177,7 @@ OBS Studio requires _MediaMTX_ to use a TLS certificate signed by a public certi
|
||||
|
||||
### Standard
|
||||
|
||||
Recent versions of OBS Studio can also publish streams to the server with the [WebRTC / WHIP protocol](04-webrtc-clients.md) Use the following parameters:
|
||||
Recent versions of OBS Studio can also publish streams to the server with the [WebRTC / WHIP protocol](05-webrtc-clients.md) Use the following parameters:
|
||||
|
||||
- Service: `WHIP`
|
||||
- Server: `http://localhost:8889/mystream/whip`
|
||||
@@ -1,6 +1,6 @@
|
||||
# Python and OpenCV
|
||||
|
||||
Python-based software can publish streams to the server with the OpenCV library and its GStreamer plugin, acting as a [RTSP client](06-rtsp-clients.md). OpenCV must be compiled with support for GStreamer, by following this procedure:
|
||||
Python-based software can publish streams to the server with the OpenCV library and its GStreamer plugin, acting as a [RTSP client](07-rtsp-clients.md). OpenCV must be compiled with support for GStreamer, by following this procedure:
|
||||
|
||||
```sh
|
||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-ugly gstreamer1.0-rtsp python3-dev python3-numpy
|
||||
@@ -1,6 +1,6 @@
|
||||
# Unity
|
||||
|
||||
Software written with the Unity Engine can publish a stream to the server by acting as a [WebRTC client](04-webrtc-clients.md).
|
||||
Software written with the Unity Engine can publish a stream to the server by acting as a [WebRTC client](05-webrtc-clients.md).
|
||||
|
||||
Create a new Unity project or open an existing one.
|
||||
|
||||
@@ -56,6 +56,7 @@ func TestConfFromFile(t *testing.T) {
|
||||
RecordSegmentDuration: 3600000000000,
|
||||
RecordDeleteAfter: 86400000000000,
|
||||
RTSPUDPSourcePortRange: []uint{32768, 60999},
|
||||
MoQTransport: MoQTransportQUIC,
|
||||
WHEPSTUNGatherTimeout: 5 * Duration(time.Second),
|
||||
WHEPHandshakeTimeout: 10 * Duration(time.Second),
|
||||
WHEPTrackGatherTimeout: 2 * Duration(time.Second),
|
||||
@@ -132,6 +133,10 @@ func TestConfFromFile(t *testing.T) {
|
||||
name: "wheps with placeholders",
|
||||
source: "wheps://$G1:$G2/$G3",
|
||||
},
|
||||
{
|
||||
name: "moqt with placeholders",
|
||||
source: "moqt://$G1:$G2/$G3",
|
||||
},
|
||||
{
|
||||
name: "udp with placeholders",
|
||||
source: "udp://$G1:$G2",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf/jsonwrapper"
|
||||
)
|
||||
|
||||
// MoQTransport is the moqTransport parameter.
|
||||
type MoQTransport string
|
||||
|
||||
// supported values.
|
||||
const (
|
||||
MoQTransportQUIC MoQTransport = "quic"
|
||||
MoQTransportWebTransport MoQTransport = "webtransport"
|
||||
)
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *MoQTransport) UnmarshalJSON(b []byte) error {
|
||||
type alias MoQTransport
|
||||
if err := jsonwrapper.Unmarshal(b, (*alias)(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *d {
|
||||
case MoQTransportQUIC, MoQTransportWebTransport:
|
||||
|
||||
default:
|
||||
return fmt.Errorf("invalid MoQ transport '%s'", *d)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalEnv implements env.Unmarshaler.
|
||||
func (d *MoQTransport) UnmarshalEnv(_ string, v string) error {
|
||||
return d.UnmarshalJSON([]byte(`"` + v + `"`))
|
||||
}
|
||||
@@ -272,6 +272,9 @@ type Path struct {
|
||||
RTPSDP string `json:"rtpSDP"`
|
||||
RTPUDPReadBufferSize *uint `json:"rtpUDPReadBufferSize,omitempty" deprecated:"true"`
|
||||
|
||||
// MoQ source
|
||||
MoQTransport MoQTransport `json:"moqTransport"`
|
||||
|
||||
// WHEP source
|
||||
WHEPBearerToken string `json:"whepBearerToken"`
|
||||
WHEPSTUNGatherTimeout Duration `json:"whepSTUNGatherTimeout"`
|
||||
@@ -381,6 +384,9 @@ func (pconf *Path) setDefaults() {
|
||||
// RTSP source
|
||||
pconf.RTSPUDPSourcePortRange = []uint{32768, 60999}
|
||||
|
||||
// MoQ source
|
||||
pconf.MoQTransport = MoQTransportQUIC
|
||||
|
||||
// WHEP source
|
||||
pconf.WHEPSTUNGatherTimeout = Duration(5 * time.Second)
|
||||
pconf.WHEPHandshakeTimeout = Duration(10 * time.Second)
|
||||
@@ -567,6 +573,12 @@ func (pconf *Path) validate(
|
||||
return err
|
||||
}
|
||||
|
||||
case strings.HasPrefix(pconf.Source, "moqt://"):
|
||||
_, err := validateURL(pconf.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case strings.HasPrefix(pconf.Source, "whep://") ||
|
||||
strings.HasPrefix(pconf.Source, "wheps://"):
|
||||
_, err := validateURL(pconf.Source)
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
APIPathSourceTypeRTPSource APIPathSourceType = "rtpSource"
|
||||
APIPathSourceTypeWebRTCSession APIPathSourceType = "webRTCSession"
|
||||
APIPathSourceTypeWebRTCSource APIPathSourceType = "webRTCSource"
|
||||
APIPathSourceTypeMoQSource APIPathSourceType = "moqSource"
|
||||
APIPathSourceTypeMoQSession APIPathSourceType = "moqSession"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
sshls "github.com/bluenviron/mediamtx/internal/staticsources/hls"
|
||||
ssmoq "github.com/bluenviron/mediamtx/internal/staticsources/moq"
|
||||
ssmpegts "github.com/bluenviron/mediamtx/internal/staticsources/mpegts"
|
||||
ssrpicamera "github.com/bluenviron/mediamtx/internal/staticsources/rpicamera"
|
||||
ssrtmp "github.com/bluenviron/mediamtx/internal/staticsources/rtmp"
|
||||
@@ -142,6 +143,12 @@ func (s *Handler) Initialize() {
|
||||
Parent: s,
|
||||
}
|
||||
|
||||
case strings.HasPrefix(s.Conf.Source, "moqt://"):
|
||||
s.instance = &ssmoq.Source{
|
||||
ReadTimeout: s.ReadTimeout,
|
||||
Parent: s,
|
||||
}
|
||||
|
||||
case strings.HasPrefix(s.Conf.Source, "whep://") ||
|
||||
strings.HasPrefix(s.Conf.Source, "wheps://"):
|
||||
s.instance = &sswebrtc.Source{
|
||||
|
||||
@@ -91,6 +91,13 @@ func TestResolveSource(t *testing.T) {
|
||||
query: "",
|
||||
expected: "whep://example.com:443/mystream",
|
||||
},
|
||||
{
|
||||
name: "moq protocol",
|
||||
source: "moqt://$G1:$G2/$G3",
|
||||
matches: []string{"srv_example.com_443_mystream", "example.com", "443", "mystream"},
|
||||
query: "",
|
||||
expected: "moqt://example.com:443/mystream",
|
||||
},
|
||||
{
|
||||
name: "mpeg ts udp",
|
||||
source: "udp://$G1:$G2",
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
// Package moq contains the MoQ static source.
|
||||
package moq
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/webtransport-go"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
protomoq "github.com/bluenviron/mediamtx/internal/protocols/moq"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/catalog"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/controlmessage"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/parameter"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/reorderer"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/subgroup"
|
||||
ptls "github.com/bluenviron/mediamtx/internal/protocols/tls"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
)
|
||||
|
||||
const maxReorderedSubGroups = 50
|
||||
|
||||
type parent interface {
|
||||
logger.Writer
|
||||
SetReady(req defs.PathSourceStaticSetReadyReq) defs.PathSourceStaticSetReadyRes
|
||||
SetNotReady(req defs.PathSourceStaticSetNotReadyReq)
|
||||
}
|
||||
|
||||
type conn interface {
|
||||
OpenUniStreamSync(ctx context.Context) (io.WriteCloser, error)
|
||||
AcceptUniStream(ctx context.Context) (io.Reader, error)
|
||||
OpenStreamSync(ctx context.Context) (io.ReadWriteCloser, error)
|
||||
CloseWithError(code uint64, msg string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type connQUIC struct {
|
||||
conn *quic.Conn
|
||||
}
|
||||
|
||||
func (c *connQUIC) OpenUniStreamSync(ctx context.Context) (io.WriteCloser, error) {
|
||||
return c.conn.OpenUniStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *connQUIC) AcceptUniStream(ctx context.Context) (io.Reader, error) {
|
||||
return c.conn.AcceptUniStream(ctx)
|
||||
}
|
||||
|
||||
func (c *connQUIC) OpenStreamSync(ctx context.Context) (io.ReadWriteCloser, error) {
|
||||
return c.conn.OpenStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *connQUIC) CloseWithError(code uint64, msg string) error {
|
||||
return c.conn.CloseWithError(quic.ApplicationErrorCode(code), msg)
|
||||
}
|
||||
|
||||
func (c *connQUIC) Close() error {
|
||||
return c.conn.CloseWithError(0, "")
|
||||
}
|
||||
|
||||
type connWebTransport struct {
|
||||
session *webtransport.Session
|
||||
transport *webtransport.Transport
|
||||
responseBody io.Closer
|
||||
}
|
||||
|
||||
func (c *connWebTransport) OpenUniStreamSync(ctx context.Context) (io.WriteCloser, error) {
|
||||
return c.session.OpenUniStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *connWebTransport) AcceptUniStream(ctx context.Context) (io.Reader, error) {
|
||||
return c.session.AcceptUniStream(ctx)
|
||||
}
|
||||
|
||||
func (c *connWebTransport) OpenStreamSync(ctx context.Context) (io.ReadWriteCloser, error) {
|
||||
return c.session.OpenStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *connWebTransport) CloseWithError(code uint64, msg string) error {
|
||||
return c.session.CloseWithError(webtransport.SessionErrorCode(code), msg)
|
||||
}
|
||||
|
||||
func (c *connWebTransport) Close() error {
|
||||
c.session.CloseWithError(0, "") //nolint:errcheck
|
||||
if c.responseBody != nil {
|
||||
c.responseBody.Close() //nolint:errcheck
|
||||
}
|
||||
if c.transport != nil {
|
||||
return c.transport.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type inboundTrack struct {
|
||||
onSubGroup func(sg *subgroup.SubGroup) error
|
||||
parent logger.Writer
|
||||
|
||||
reorderer *reorderer.Reorderer
|
||||
}
|
||||
|
||||
func (t *inboundTrack) initialize() {
|
||||
t.reorderer = &reorderer.Reorderer{
|
||||
MaxReordered: maxReorderedSubGroups,
|
||||
Parent: t.parent,
|
||||
}
|
||||
t.reorderer.Initialize()
|
||||
}
|
||||
|
||||
func (t *inboundTrack) push(sg *subgroup.SubGroup) error {
|
||||
sgs, err := t.reorderer.Push(sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sg := range sgs {
|
||||
err = t.onSubGroup(sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeAuthorization(user *url.Userinfo) parameter.Parameters {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
username := user.Username()
|
||||
password, ok := user.Password()
|
||||
if username == "" || !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
credentials := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
|
||||
return parameter.Parameters{
|
||||
¶meter.AuthorizationToken{
|
||||
AliasType: parameter.AuthorizationTokenAliasTypeUseValue,
|
||||
TokenType: 1,
|
||||
TokenValue: []byte("Basic " + credentials),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseVersionFromWTHeader(v string) defs.APIMoQVersion {
|
||||
v = strings.Trim(v, "\"")
|
||||
|
||||
switch defs.APIMoQVersion(v) {
|
||||
case defs.APIMoQVersionDraft19,
|
||||
defs.APIMoQVersionDraft18,
|
||||
defs.APIMoQVersionDraft17,
|
||||
defs.APIMoQVersionDraft16:
|
||||
return defs.APIMoQVersion(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func performSetup(ctx context.Context, c conn, version defs.APIMoQVersion, path string) error {
|
||||
if version == defs.APIMoQVersionDraft16 {
|
||||
setupBidi, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer setupBidi.Close() //nolint:errcheck
|
||||
|
||||
_, err = setupBidi.Write(controlmessage.ClientSetup(controlmessage.Setup{Path: path}).Marshal())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := controlmessage.Read(setupBidi)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := msg.(*controlmessage.ServerSetup); !ok {
|
||||
return fmt.Errorf("unexpected setup response: %T", msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
setupStream, err := c.AcceptUniStream(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := controlmessage.Read(setupStream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := msg.(*controlmessage.Setup); !ok {
|
||||
return fmt.Errorf("unexpected setup response: %T", msg)
|
||||
}
|
||||
|
||||
clientSetup, err := c.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clientSetup.Close() //nolint:errcheck
|
||||
|
||||
payload := controlmessage.Setup{}
|
||||
if path != "" {
|
||||
payload.Path = path
|
||||
}
|
||||
|
||||
_, err = clientSetup.Write(payload.Marshal())
|
||||
return err
|
||||
}
|
||||
|
||||
func subscribe(
|
||||
ctx context.Context,
|
||||
c conn,
|
||||
requestID uint64,
|
||||
trackName string,
|
||||
params parameter.Parameters,
|
||||
) (uint64, io.ReadWriteCloser, error) {
|
||||
bidi, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
_, err = bidi.Write(controlmessage.Subscribe{
|
||||
RequestID: requestID,
|
||||
TrackName: trackName,
|
||||
Parameters: params,
|
||||
}.Marshal())
|
||||
if err != nil {
|
||||
bidi.Close() //nolint:errcheck
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
msg, err := controlmessage.Read(bidi)
|
||||
if err != nil {
|
||||
bidi.Close() //nolint:errcheck
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *controlmessage.SubscribeOk:
|
||||
return msg.TrackAlias, bidi, nil
|
||||
|
||||
case *controlmessage.RequestError:
|
||||
bidi.Close() //nolint:errcheck
|
||||
return 0, nil, fmt.Errorf("subscribe failed: %s", msg.Reason)
|
||||
|
||||
default:
|
||||
bidi.Close() //nolint:errcheck
|
||||
return 0, nil, fmt.Errorf("unexpected subscribe response: %T", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func readSubGroup(r io.Reader) (*subgroup.SubGroup, error) {
|
||||
br := bufio.NewReader(r)
|
||||
firstByte, err := br.Peek(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (firstByte[0] & 0x90) != 0x10 {
|
||||
return nil, fmt.Errorf("unexpected unidirectional stream")
|
||||
}
|
||||
|
||||
var sg subgroup.SubGroup
|
||||
err = sg.Read(br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &sg, nil
|
||||
}
|
||||
|
||||
func dialQUIC(ctx context.Context, u *url.URL, tlsConfig *tls.Config) (conn, defs.APIMoQVersion, error) {
|
||||
host := u.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":443"
|
||||
}
|
||||
|
||||
cfg := &tls.Config{}
|
||||
if tlsConfig != nil {
|
||||
cfg = tlsConfig.Clone()
|
||||
}
|
||||
cfg.NextProtos = []string{
|
||||
string(defs.APIMoQVersionDraft19),
|
||||
string(defs.APIMoQVersionDraft18),
|
||||
string(defs.APIMoQVersionDraft17),
|
||||
string(defs.APIMoQVersionDraft16),
|
||||
}
|
||||
if cfg.ServerName == "" {
|
||||
hostname := u.Hostname()
|
||||
if hostname != "" && tlsConfig == nil {
|
||||
cfg.ServerName = hostname
|
||||
}
|
||||
}
|
||||
|
||||
qconn, err := quic.DialAddr(ctx, host, cfg, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
version := defs.APIMoQVersion(qconn.ConnectionState().TLS.NegotiatedProtocol)
|
||||
if version == "" {
|
||||
qconn.CloseWithError(0, "") //nolint:errcheck
|
||||
return nil, "", fmt.Errorf("missing negotiated MoQ version")
|
||||
}
|
||||
|
||||
return &connQUIC{conn: qconn}, version, nil
|
||||
}
|
||||
|
||||
func dialWebTransport(ctx context.Context, u *url.URL, tlsConfig *tls.Config) (conn, defs.APIMoQVersion, error) {
|
||||
httpsURL := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawQuery: u.RawQuery,
|
||||
}
|
||||
|
||||
cfg := &tls.Config{}
|
||||
if tlsConfig != nil {
|
||||
cfg = tlsConfig.Clone()
|
||||
}
|
||||
if cfg.ServerName == "" {
|
||||
hostname := u.Hostname()
|
||||
if hostname != "" && tlsConfig == nil {
|
||||
cfg.ServerName = hostname
|
||||
}
|
||||
}
|
||||
|
||||
transport := &webtransport.Transport{
|
||||
TLSClientConfig: cfg,
|
||||
QUICConfig: &quic.Config{
|
||||
EnableDatagrams: true,
|
||||
EnableStreamResetPartialDelivery: true,
|
||||
},
|
||||
ApplicationProtocols: []string{
|
||||
string(defs.APIMoQVersionDraft19),
|
||||
string(defs.APIMoQVersionDraft18),
|
||||
string(defs.APIMoQVersionDraft17),
|
||||
string(defs.APIMoQVersionDraft16),
|
||||
},
|
||||
}
|
||||
|
||||
res, session, err := transport.Dial(ctx, httpsURL.String(), nil)
|
||||
if err != nil {
|
||||
transport.Close() //nolint:errcheck
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
version := parseVersionFromWTHeader(res.Header.Get("WT-Protocol"))
|
||||
if version == "" {
|
||||
res.Body.Close() //nolint:errcheck
|
||||
session.CloseWithError(0, "") //nolint:errcheck
|
||||
transport.Close() //nolint:errcheck
|
||||
return nil, "", fmt.Errorf("missing negotiated MoQ version")
|
||||
}
|
||||
|
||||
return &connWebTransport{session: session, transport: transport, responseBody: res.Body}, version, nil
|
||||
}
|
||||
|
||||
// Source is a MoQ static source.
|
||||
type Source struct {
|
||||
ReadTimeout conf.Duration
|
||||
Parent parent
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (s *Source) Log(level logger.Level, format string, args ...any) {
|
||||
s.Parent.Log(level, "[MoQ source] "+format, args...)
|
||||
}
|
||||
|
||||
// Run implements StaticSource.
|
||||
func (s *Source) Run(params defs.StaticSourceRunParams) error {
|
||||
u, err := url.Parse(params.ResolvedSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tlsConfig := ptls.MakeConfig(params.Conf.SourceFingerprint)
|
||||
|
||||
var c conn
|
||||
var version defs.APIMoQVersion
|
||||
|
||||
switch params.Conf.MoQTransport {
|
||||
case conf.MoQTransportWebTransport:
|
||||
c, version, err = dialWebTransport(params.Context, u, tlsConfig)
|
||||
default:
|
||||
c, version, err = dialQUIC(params.Context, u, tlsConfig)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close() //nolint:errcheck
|
||||
|
||||
setupPath := ""
|
||||
if params.Conf.MoQTransport == conf.MoQTransportQUIC {
|
||||
setupPath = u.RequestURI()
|
||||
if setupPath == "" {
|
||||
setupPath = "/"
|
||||
}
|
||||
}
|
||||
|
||||
err = performSetup(params.Context, c, version, setupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authParams := encodeAuthorization(u.User)
|
||||
|
||||
catalogAlias, catalogBidi, err := subscribe(params.Context, c, 1, ".catalog", authParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go io.Copy(io.Discard, catalogBidi) //nolint:errcheck
|
||||
|
||||
catalogStream, err := c.AcceptUniStream(params.Context)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sg, err := readSubGroup(catalogStream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sg.Header.TrackAlias != catalogAlias {
|
||||
return fmt.Errorf("unexpected catalog track alias: expected %d, got %d", catalogAlias, sg.Header.TrackAlias)
|
||||
}
|
||||
if len(sg.Objects) == 0 {
|
||||
return fmt.Errorf("received empty catalog")
|
||||
}
|
||||
|
||||
var cat catalog.Catalog
|
||||
err = json.Unmarshal(sg.Objects[0].Payload, &cat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse catalog JSON: %w", err)
|
||||
}
|
||||
|
||||
var subStream *stream.SubStream
|
||||
medias, writeFuncs, err := protomoq.ToStream(&cat, &subStream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res := s.Parent.SetReady(defs.PathSourceStaticSetReadyReq{
|
||||
Desc: &description.Session{Medias: medias},
|
||||
UseRTPPackets: false,
|
||||
ReplaceNTP: !params.Conf.UseAbsoluteTimestamp,
|
||||
})
|
||||
if res.Err != nil {
|
||||
return res.Err
|
||||
}
|
||||
defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{})
|
||||
|
||||
subStream = res.SubStream
|
||||
|
||||
tracks := make(map[uint64]*inboundTrack)
|
||||
for i, track := range cat.Tracks {
|
||||
writer := writeFuncs[uint64(i+1)]
|
||||
if writer == nil {
|
||||
return fmt.Errorf("missing writer for track %s", track.Name)
|
||||
}
|
||||
|
||||
alias, bidi, err2 := subscribe(params.Context, c, uint64(i+2), track.Name, authParams)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
go io.Copy(io.Discard, bidi) //nolint:errcheck
|
||||
|
||||
tr := &inboundTrack{onSubGroup: writer, parent: s}
|
||||
tr.initialize()
|
||||
tracks[alias] = tr
|
||||
}
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
uni, err2 := c.AcceptUniStream(params.Context)
|
||||
if err2 != nil {
|
||||
if params.Context.Err() != nil {
|
||||
readErr <- nil
|
||||
return
|
||||
}
|
||||
readErr <- err2
|
||||
return
|
||||
}
|
||||
|
||||
sg2, err2 := readSubGroup(uni)
|
||||
if err2 != nil {
|
||||
readErr <- err2
|
||||
return
|
||||
}
|
||||
|
||||
track := tracks[sg2.Header.TrackAlias]
|
||||
if track == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err2 = track.push(sg2)
|
||||
if err2 != nil {
|
||||
readErr <- err2
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err = <-readErr:
|
||||
return err
|
||||
|
||||
case <-params.ReloadConf:
|
||||
|
||||
case <-params.Context.Done():
|
||||
c.CloseWithError(0, "") //nolint:errcheck
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// APISourceDescribe implements StaticSource.
|
||||
func (*Source) APISourceDescribe() *defs.APIPathSource {
|
||||
return &defs.APIPathSource{
|
||||
Type: defs.APIPathSourceTypeMoQSource,
|
||||
ID: "",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package moq_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mediacommonh264 "github.com/bluenviron/mediacommon/v2/pkg/codecs/h264"
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/webtransport-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/httpp3"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/catalog"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/controlmessage"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/parameter"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/property"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/moq/subgroup"
|
||||
ssmoq "github.com/bluenviron/mediamtx/internal/staticsources/moq"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
"github.com/bluenviron/mediamtx/internal/unit"
|
||||
)
|
||||
|
||||
const testVersion = defs.APIMoQVersionDraft19
|
||||
|
||||
type testServerConn interface {
|
||||
OpenUniStreamSync(context.Context) (io.WriteCloser, error)
|
||||
AcceptUniStream(context.Context) (io.Reader, error)
|
||||
AcceptStream(context.Context) (io.ReadWriteCloser, error)
|
||||
CloseWithError(uint64, string) error
|
||||
}
|
||||
|
||||
type testQUICConn struct {
|
||||
conn *quic.Conn
|
||||
}
|
||||
|
||||
func (c *testQUICConn) OpenUniStreamSync(ctx context.Context) (io.WriteCloser, error) {
|
||||
return c.conn.OpenUniStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *testQUICConn) AcceptUniStream(ctx context.Context) (io.Reader, error) {
|
||||
return c.conn.AcceptUniStream(ctx)
|
||||
}
|
||||
|
||||
func (c *testQUICConn) AcceptStream(ctx context.Context) (io.ReadWriteCloser, error) {
|
||||
return c.conn.AcceptStream(ctx)
|
||||
}
|
||||
|
||||
func (c *testQUICConn) CloseWithError(code uint64, msg string) error {
|
||||
return c.conn.CloseWithError(quic.ApplicationErrorCode(code), msg)
|
||||
}
|
||||
|
||||
type testWTConn struct {
|
||||
session *webtransport.Session
|
||||
}
|
||||
|
||||
func (c *testWTConn) OpenUniStreamSync(ctx context.Context) (io.WriteCloser, error) {
|
||||
return c.session.OpenUniStreamSync(ctx)
|
||||
}
|
||||
|
||||
func (c *testWTConn) AcceptUniStream(ctx context.Context) (io.Reader, error) {
|
||||
return c.session.AcceptUniStream(ctx)
|
||||
}
|
||||
|
||||
func (c *testWTConn) AcceptStream(ctx context.Context) (io.ReadWriteCloser, error) {
|
||||
return c.session.AcceptStream(ctx)
|
||||
}
|
||||
|
||||
func (c *testWTConn) CloseWithError(code uint64, msg string) error {
|
||||
return c.session.CloseWithError(webtransport.SessionErrorCode(code), msg)
|
||||
}
|
||||
|
||||
type testMoqServer struct {
|
||||
address string
|
||||
expectedRequestURI string
|
||||
expectedAuth string
|
||||
fingerprint string
|
||||
transport conf.MoQTransport
|
||||
|
||||
ctx context.Context
|
||||
ctxCancel func()
|
||||
closeFunc func()
|
||||
err chan error
|
||||
}
|
||||
|
||||
func newTestMoqServer(
|
||||
t *testing.T,
|
||||
transport conf.MoQTransport,
|
||||
expectedRequestURI string,
|
||||
expectedAuth string,
|
||||
) *testMoqServer {
|
||||
t.Helper()
|
||||
|
||||
ctx, ctxCancel := context.WithCancel(context.Background())
|
||||
ts := &testMoqServer{
|
||||
address: "",
|
||||
expectedRequestURI: expectedRequestURI,
|
||||
expectedAuth: expectedAuth,
|
||||
fingerprint: "",
|
||||
transport: transport,
|
||||
ctx: ctx,
|
||||
ctxCancel: ctxCancel,
|
||||
err: make(chan error, 1),
|
||||
}
|
||||
|
||||
switch transport {
|
||||
case conf.MoQTransportWebTransport:
|
||||
ts.initializeWebTransport(t)
|
||||
|
||||
default:
|
||||
ts.initializeQUIC(t)
|
||||
}
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func (s *testMoqServer) initializeQUIC(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
cert, err := tls.X509KeyPair(test.TLSCertPub, test.TLSCertKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
ln, err := quic.ListenAddr("127.0.0.1:0", &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
NextProtos: []string{string(testVersion)},
|
||||
}, &quic.Config{EnableDatagrams: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
s.address = ln.Addr().String()
|
||||
s.fingerprint = fingerprintFromRaw(t, cert.Certificate[0])
|
||||
s.closeFunc = func() {
|
||||
ln.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
acceptedConn, acceptErr := ln.Accept(s.ctx)
|
||||
if s.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if acceptErr != nil {
|
||||
s.fail(acceptErr)
|
||||
return
|
||||
}
|
||||
|
||||
go s.runSession(&testQUICConn{conn: acceptedConn}, false)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *testMoqServer) initializeWebTransport(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
addr := freeUDPAddress(t)
|
||||
|
||||
h3s := &httpp3.Server{
|
||||
Address: addr,
|
||||
EnableWebTransport: true,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
h3s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.RequestURI() != s.expectedRequestURI {
|
||||
s.fail(fmt.Errorf("unexpected request URI: expected %s, got %s", s.expectedRequestURI, r.URL.RequestURI()))
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
offered := r.Header.Get("WT-Available-Protocols")
|
||||
if !strings.Contains(offered, string(testVersion)) {
|
||||
s.fail(fmt.Errorf("unexpected WT-Available-Protocols header: %s", offered))
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("WT-Protocol", `"`+string(testVersion)+`"`)
|
||||
|
||||
session, err := h3s.Upgrade(w, r)
|
||||
if err != nil {
|
||||
if s.ctx.Err() == nil {
|
||||
s.fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go s.runSession(&testWTConn{session: session}, true)
|
||||
})
|
||||
|
||||
err := h3s.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
s.address = addr
|
||||
s.fingerprint = fingerprintFromRaw(t, h3s.Certificate().Certificate[0])
|
||||
s.closeFunc = h3s.Close
|
||||
}
|
||||
|
||||
func (s *testMoqServer) runSession(c testServerConn, webTransport bool) {
|
||||
defer c.CloseWithError(0, "") //nolint:errcheck
|
||||
|
||||
err := s.performSetup(c, webTransport)
|
||||
if err != nil {
|
||||
s.fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
bidi, acceptErr := c.AcceptStream(s.ctx)
|
||||
if s.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if acceptErr != nil {
|
||||
if strings.Contains(acceptErr.Error(), "Application error 0x0") ||
|
||||
strings.Contains(acceptErr.Error(), "context canceled") {
|
||||
return
|
||||
}
|
||||
s.fail(acceptErr)
|
||||
return
|
||||
}
|
||||
|
||||
go s.handleSubscribe(c, bidi)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testMoqServer) performSetup(c testServerConn, webTransport bool) error {
|
||||
setupWriter, err := c.OpenUniStreamSync(s.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = setupWriter.Write(controlmessage.Setup{}.Marshal())
|
||||
setupWriter.Close() //nolint:errcheck
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setupReader, err := c.AcceptUniStream(s.ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Application error 0x0") ||
|
||||
strings.Contains(err.Error(), "context canceled") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := controlmessage.Read(setupReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setup, ok := msg.(*controlmessage.Setup)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected setup message: %T", msg)
|
||||
}
|
||||
|
||||
if webTransport {
|
||||
if setup.Path != "" {
|
||||
return fmt.Errorf("unexpected WebTransport setup path: %s", setup.Path)
|
||||
}
|
||||
} else if setup.Path != s.expectedRequestURI {
|
||||
return fmt.Errorf("unexpected QUIC setup path: expected %s, got %s", s.expectedRequestURI, setup.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testMoqServer) handleSubscribe(c testServerConn, bidi io.ReadWriteCloser) {
|
||||
msg, err := controlmessage.Read(bidi)
|
||||
if err != nil {
|
||||
if s.ctx.Err() == nil {
|
||||
s.fail(err)
|
||||
}
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := msg.(*controlmessage.Subscribe)
|
||||
if !ok {
|
||||
s.fail(fmt.Errorf("unexpected control message: %T", msg))
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
authErr := s.checkAuthorization(sub.Parameters)
|
||||
if authErr != nil {
|
||||
s.fail(authErr)
|
||||
_, _ = bidi.Write(controlmessage.RequestError{
|
||||
Code: controlmessage.RequestErrorCodeUnauthorized,
|
||||
Reason: authErr.Error(),
|
||||
}.Marshal())
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
switch sub.TrackName {
|
||||
case ".catalog":
|
||||
payload, err = catalogPayload()
|
||||
|
||||
case "0":
|
||||
payload, err = mediaPayload()
|
||||
|
||||
default:
|
||||
_, _ = bidi.Write(controlmessage.RequestError{
|
||||
Code: controlmessage.RequestErrorCodeDoesNotExist,
|
||||
Reason: "unknown track",
|
||||
}.Marshal())
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(err)
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
_, err = bidi.Write(controlmessage.SubscribeOk{TrackAlias: sub.RequestID}.Marshal())
|
||||
if err != nil {
|
||||
s.fail(err)
|
||||
bidi.Close() //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
go io.Copy(io.Discard, bidi) //nolint:errcheck
|
||||
|
||||
err = s.writeSubGroup(c, sub.TrackName, sub.RequestID, payload)
|
||||
if err != nil {
|
||||
s.fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testMoqServer) checkAuthorization(params parameter.Parameters) error {
|
||||
if s.expectedAuth == "" {
|
||||
if len(params) != 0 {
|
||||
return fmt.Errorf("unexpected authorization parameter")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(params) != 1 {
|
||||
return fmt.Errorf("expected exactly one authorization parameter")
|
||||
}
|
||||
|
||||
tok, ok := params[0].(*parameter.AuthorizationToken)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected authorization parameter type: %T", params[0])
|
||||
}
|
||||
if tok.AliasType != parameter.AuthorizationTokenAliasTypeUseValue {
|
||||
return fmt.Errorf("unexpected authorization alias type: %d", tok.AliasType)
|
||||
}
|
||||
if tok.TokenType != 1 {
|
||||
return fmt.Errorf("unexpected authorization token type: %d", tok.TokenType)
|
||||
}
|
||||
if string(tok.TokenValue) != s.expectedAuth {
|
||||
return fmt.Errorf("unexpected authorization token: expected %q, got %q", s.expectedAuth, string(tok.TokenValue))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testMoqServer) writeSubGroup(c testServerConn, trackName string, trackAlias uint64, payload []byte) error {
|
||||
uni, err := c.OpenUniStreamSync(s.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer uni.Close() //nolint:errcheck
|
||||
|
||||
sg := &subgroup.SubGroup{
|
||||
Header: subgroup.Header{
|
||||
FirstObject: true,
|
||||
TrackAlias: trackAlias,
|
||||
GroupID: 0,
|
||||
},
|
||||
Objects: []subgroup.Object{{
|
||||
Payload: payload,
|
||||
}},
|
||||
}
|
||||
|
||||
if trackName != ".catalog" {
|
||||
ts := property.Timestamp(0)
|
||||
sg.Header.Properties = true
|
||||
sg.Objects[0].Properties = property.Properties{&ts}
|
||||
}
|
||||
|
||||
_, err = uni.Write(sg.Marshal())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *testMoqServer) fail(err error) {
|
||||
select {
|
||||
case s.err <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testMoqServer) Close() {
|
||||
s.ctxCancel()
|
||||
if s.closeFunc != nil {
|
||||
s.closeFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testMoqServer) Check(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case err := <-s.err:
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func basicAuthToken(user string, pass string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||
}
|
||||
|
||||
func catalogPayload() ([]byte, error) {
|
||||
return json.Marshal(catalog.Catalog{
|
||||
Version: 1,
|
||||
Tracks: []catalog.Track{{
|
||||
Name: "0",
|
||||
Packaging: "loc",
|
||||
IsLive: true,
|
||||
Codec: "avc3.640028",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func mediaPayload() ([]byte, error) {
|
||||
return mediacommonh264.AVCC{test.FormatH264.SPS, test.FormatH264.PPS, {5, 1}}.Marshal()
|
||||
}
|
||||
|
||||
func fingerprintFromRaw(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func freeUDPAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := pc.LocalAddr().String()
|
||||
pc.Close() //nolint:errcheck
|
||||
return addr
|
||||
}
|
||||
|
||||
type fingerprintErrorParent struct{}
|
||||
|
||||
func (*fingerprintErrorParent) Log(_ logger.Level, _ string, _ ...any) {}
|
||||
|
||||
func (*fingerprintErrorParent) SetReady(_ defs.PathSourceStaticSetReadyReq) defs.PathSourceStaticSetReadyRes {
|
||||
panic("should not happen")
|
||||
}
|
||||
|
||||
func (*fingerprintErrorParent) SetNotReady(_ defs.PathSourceStaticSetNotReadyReq) {}
|
||||
|
||||
func TestSource(t *testing.T) {
|
||||
for _, ca := range []struct {
|
||||
name string
|
||||
transport conf.MoQTransport
|
||||
withAuth bool
|
||||
withQuery bool
|
||||
}{
|
||||
{
|
||||
name: "quic",
|
||||
transport: conf.MoQTransportQUIC,
|
||||
},
|
||||
{
|
||||
name: "quic_auth_query",
|
||||
transport: conf.MoQTransportQUIC,
|
||||
withAuth: true,
|
||||
withQuery: true,
|
||||
},
|
||||
{
|
||||
name: "webtransport",
|
||||
transport: conf.MoQTransportWebTransport,
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
expectedRequestURI := "/teststream"
|
||||
if ca.withQuery {
|
||||
expectedRequestURI += "?key=value"
|
||||
}
|
||||
|
||||
expectedAuth := ""
|
||||
if ca.withAuth {
|
||||
expectedAuth = basicAuthToken("myuser", "mypass")
|
||||
}
|
||||
|
||||
server := newTestMoqServer(t, ca.transport, expectedRequestURI, expectedAuth)
|
||||
defer func() {
|
||||
server.Close()
|
||||
server.Check(t)
|
||||
}()
|
||||
|
||||
p := &test.StaticSourceParent{}
|
||||
p.Initialize()
|
||||
|
||||
so := &ssmoq.Source{
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
Parent: p,
|
||||
}
|
||||
|
||||
resolved := "moqt://" + server.address + "/teststream"
|
||||
if ca.withAuth {
|
||||
resolved = "moqt://myuser:mypass@" + server.address + "/teststream"
|
||||
}
|
||||
if ca.withQuery {
|
||||
resolved += "?key=value"
|
||||
}
|
||||
|
||||
sourceErr := make(chan error, 1)
|
||||
go func() {
|
||||
sourceErr <- so.Run(defs.StaticSourceRunParams{
|
||||
Context: ctx,
|
||||
ResolvedSource: resolved,
|
||||
Conf: &conf.Path{
|
||||
MoQTransport: ca.transport,
|
||||
SourceFingerprint: server.fingerprint,
|
||||
},
|
||||
ReloadConf: make(chan *conf.Path),
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case u := <-p.Unit:
|
||||
require.Equal(t, unit.PayloadH264{test.FormatH264.SPS, test.FormatH264.PPS, {5, 1}}, u.Payload)
|
||||
|
||||
case err := <-sourceErr:
|
||||
require.NoError(t, err)
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for unit")
|
||||
}
|
||||
|
||||
cancel()
|
||||
require.NoError(t, <-sourceErr)
|
||||
p.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceWebTransportFingerprintError(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
server := newTestMoqServer(t, conf.MoQTransportWebTransport, "/teststream", "")
|
||||
defer func() {
|
||||
server.Close()
|
||||
server.Check(t)
|
||||
}()
|
||||
|
||||
so := &ssmoq.Source{Parent: &fingerprintErrorParent{}}
|
||||
|
||||
err := so.Run(defs.StaticSourceRunParams{
|
||||
Context: ctx,
|
||||
ResolvedSource: "moqt://" + server.address + "/teststream",
|
||||
Conf: &conf.Path{
|
||||
MoQTransport: conf.MoQTransportWebTransport,
|
||||
SourceFingerprint: strings.Repeat("0", 64),
|
||||
},
|
||||
ReloadConf: make(chan *conf.Path),
|
||||
})
|
||||
require.ErrorContains(t, err, "source fingerprint does not match")
|
||||
}
|
||||
|
||||
func TestSourceFingerprintError(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
server := newTestMoqServer(t, conf.MoQTransportQUIC, "/teststream", "")
|
||||
defer func() {
|
||||
server.Close()
|
||||
server.Check(t)
|
||||
}()
|
||||
|
||||
so := &ssmoq.Source{Parent: &fingerprintErrorParent{}}
|
||||
|
||||
err := so.Run(defs.StaticSourceRunParams{
|
||||
Context: ctx,
|
||||
ResolvedSource: "moqt://" + server.address + "/teststream",
|
||||
Conf: &conf.Path{
|
||||
MoQTransport: conf.MoQTransportQUIC,
|
||||
SourceFingerprint: strings.Repeat("0", 64),
|
||||
},
|
||||
ReloadConf: make(chan *conf.Path),
|
||||
})
|
||||
require.ErrorContains(t, err, "source fingerprint does not match")
|
||||
}
|
||||
@@ -486,6 +486,7 @@ pathDefaults:
|
||||
# * unix+mpegts://socketPath -> the stream is pulled from MPEG-TS over a Unix socket
|
||||
# * udp+rtp://ip:port -> the stream is pulled from RTP over UDP, by listening on the specified address
|
||||
# * srt://host:port?streamid=streamid -> the stream is pulled from another SRT server / camera
|
||||
# * moqt://user:pass@host:port/path -> the stream is pulled from another MoQ server
|
||||
# * whep://host:port/path -> the stream is pulled from another WebRTC server / camera with HTTP+WHEP
|
||||
# * wheps://host:port/path -> the stream is pulled from another WebRTC server / camera with HTTPS+WHEP
|
||||
# * redirect -> the stream is provided by another path or server
|
||||
@@ -630,6 +631,12 @@ pathDefaults:
|
||||
# session description protocol (SDP) of the RTP stream.
|
||||
rtpSDP:
|
||||
|
||||
###############################################
|
||||
# Default path settings -> MoQ source (when source is a MoQ URL)
|
||||
|
||||
# Transport protocol used to pull the stream. available values are "quic", "webtransport".
|
||||
moqTransport: quic
|
||||
|
||||
###############################################
|
||||
# Default path settings -> WebRTC / WHEP source (when source is WHEP)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user