feat: add WHIP server implementation with Docker support
- Introduced a new Dockerfile for building the WHIP server with Go and FFmpeg. - Implemented the WHIPHandler for managing WHIP publish sessions and generating URLs for clients. - Added pionProvider for handling WebRTC sessions using the pion/webrtc library. - Created the main WHIP server logic to handle SDP offers and manage active publishing streams. - Implemented HTTP handlers for publishing, deleting, and retrieving stream information. - Added support for SDP generation for FFmpeg consumption.
This commit is contained in:
parent
8b66753a27
commit
7a8073eedd
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,7 +6,7 @@
|
||||
/data/**
|
||||
/test/**
|
||||
.vscode
|
||||
|
||||
/vendor
|
||||
*.ts
|
||||
*.ts.tmp
|
||||
*.m3u8
|
||||
|
||||
40
Dockerfile.whip-test
Normal file
40
Dockerfile.whip-test
Normal file
@ -0,0 +1,40 @@
|
||||
ARG GOLANG_IMAGE=golang:1.22-alpine3.19
|
||||
ARG FFMPEG_IMAGE=datarhei/base:alpine-ffmpeg-latest
|
||||
|
||||
FROM --platform=$BUILDPLATFORM $GOLANG_IMAGE AS builder
|
||||
|
||||
RUN apk add git make
|
||||
|
||||
# Cache go module downloads separately from source code.
|
||||
# This layer only rebuilds when go.mod/go.sum/vendor change.
|
||||
WORKDIR /dist/core
|
||||
COPY go.mod go.sum ./
|
||||
COPY vendor/ ./vendor/
|
||||
|
||||
# Now copy source and build. This layer rebuilds on any .go file change.
|
||||
COPY . .
|
||||
RUN make release && make import && make ffmigrate
|
||||
|
||||
FROM $FFMPEG_IMAGE
|
||||
|
||||
COPY --from=builder /dist/core/core /core/bin/core
|
||||
COPY --from=builder /dist/core/import /core/bin/import
|
||||
COPY --from=builder /dist/core/ffmigrate /core/bin/ffmigrate
|
||||
COPY --from=builder /dist/core/mime.types /core/mime.types
|
||||
COPY --from=builder /dist/core/run.sh /core/bin/run.sh
|
||||
|
||||
RUN chmod +x /core/bin/run.sh && mkdir -p /core/config /core/data
|
||||
|
||||
ENV CORE_CONFIGFILE=/core/config/config.json
|
||||
ENV CORE_STORAGE_DISK_DIR=/core/data
|
||||
ENV CORE_DB_DIR=/core/config
|
||||
ENV CORE_WHIP_ENABLE=true
|
||||
ENV CORE_WHIP_ADDRESS=:8555
|
||||
ENV CORE_API_AUTH_ENABLE=false
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 8555/tcp
|
||||
|
||||
VOLUME ["/core/data", "/core/config"]
|
||||
ENTRYPOINT ["/core/bin/run.sh"]
|
||||
WORKDIR /core
|
||||
@ -40,6 +40,7 @@ import (
|
||||
"github.com/datarhei/core/v16/session"
|
||||
"github.com/datarhei/core/v16/srt"
|
||||
"github.com/datarhei/core/v16/update"
|
||||
"github.com/datarhei/core/v16/whip"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"go.uber.org/zap"
|
||||
@ -73,6 +74,7 @@ type api struct {
|
||||
s3fs map[string]fs.Filesystem
|
||||
rtmpserver rtmp.Server
|
||||
srtserver srt.Server
|
||||
whipserver whip.Server
|
||||
metrics monitor.HistoryMonitor
|
||||
prom prometheus.Metrics
|
||||
service service.Service
|
||||
@ -98,6 +100,7 @@ type api struct {
|
||||
rtmp log.Logger
|
||||
rtmps log.Logger
|
||||
srt log.Logger
|
||||
whip log.Logger
|
||||
}
|
||||
}
|
||||
|
||||
@ -348,6 +351,11 @@ func (a *api) start() error {
|
||||
return fmt.Errorf("unable to register session collector: %w", err)
|
||||
}
|
||||
|
||||
whipCollector, err := sessions.Register("whip", config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to register session collector: %w", err)
|
||||
}
|
||||
|
||||
if _, err := sessions.Register("http", config); err != nil {
|
||||
return fmt.Errorf("unable to register session collector: %w", err)
|
||||
}
|
||||
@ -373,9 +381,13 @@ func (a *api) start() error {
|
||||
srt.AddCompanion(ffmpeg)
|
||||
srt.AddCompanion(rtmp)
|
||||
|
||||
whipCollector.AddCompanion(hls)
|
||||
whipCollector.AddCompanion(ffmpeg)
|
||||
|
||||
ffmpeg.AddCompanion(hls)
|
||||
ffmpeg.AddCompanion(rtmp)
|
||||
ffmpeg.AddCompanion(srt)
|
||||
ffmpeg.AddCompanion(whipCollector)
|
||||
} else {
|
||||
sessions, _ := session.New(session.Config{})
|
||||
a.sessions = sessions
|
||||
@ -570,6 +582,23 @@ func (a *api) start() error {
|
||||
}, map[string]string{
|
||||
"latency": "20000", // 20 milliseconds, FFmpeg requires microseconds
|
||||
})
|
||||
|
||||
// WHIP: {whip} in an input address expands to the FFmpeg-readable SDP relay
|
||||
// endpoint served by the local WHIP server (http://localhost:{port}/whip/{name}/sdp).
|
||||
// Encoders and browsers push to the companion URL:
|
||||
// http://{host}:{port}/whip/{name} (plain RTP / no ICE)
|
||||
// or, with a WebRTCProvider injected, full WebRTC is negotiated.
|
||||
a.replacer.RegisterTemplateFunc("whip", func(config *restreamapp.Config, section string) string {
|
||||
_, whipPort, _ := gonet.SplitHostPort(cfg.WHIP.Address)
|
||||
if whipPort == "" {
|
||||
whipPort = "8555"
|
||||
}
|
||||
url := "http://localhost:" + whipPort + "/whip/{name}/sdp"
|
||||
if len(cfg.WHIP.Token) != 0 {
|
||||
url += "?token=" + cfg.WHIP.Token
|
||||
}
|
||||
return url
|
||||
}, nil)
|
||||
}
|
||||
|
||||
filesystems := []fs.Filesystem{
|
||||
@ -924,6 +953,23 @@ func (a *api) start() error {
|
||||
a.srtserver = srtserver
|
||||
}
|
||||
|
||||
if cfg.WHIP.Enable {
|
||||
a.log.logger.whip = a.log.logger.core.WithComponent("WHIP").WithField("address", cfg.WHIP.Address)
|
||||
|
||||
whipserver, err := whip.New(whip.Config{
|
||||
Addr: cfg.WHIP.Address,
|
||||
Token: cfg.WHIP.Token,
|
||||
Logger: a.log.logger.whip,
|
||||
Collector: a.sessions.Collector("whip"),
|
||||
WebRTCProvider: whip.NewPionProvider(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create WHIP server: %w", err)
|
||||
}
|
||||
|
||||
a.whipserver = whipserver
|
||||
}
|
||||
|
||||
logcontext := "HTTP"
|
||||
if cfg.TLS.Enable {
|
||||
logcontext = "HTTPS"
|
||||
@ -1014,6 +1060,7 @@ func (a *api) start() error {
|
||||
},
|
||||
RTMP: a.rtmpserver,
|
||||
SRT: a.srtserver,
|
||||
WHIP: a.whipserver,
|
||||
JWT: a.httpjwt,
|
||||
Config: a.config.store,
|
||||
Sessions: a.sessions,
|
||||
@ -1194,6 +1241,32 @@ func (a *api) start() error {
|
||||
}()
|
||||
}
|
||||
|
||||
if a.whipserver != nil {
|
||||
wgStart.Add(1)
|
||||
a.wgStop.Add(1)
|
||||
|
||||
go func() {
|
||||
logger := a.log.logger.whip
|
||||
|
||||
defer func() {
|
||||
logger.Info().Log("Server exited")
|
||||
a.wgStop.Done()
|
||||
}()
|
||||
|
||||
wgStart.Done()
|
||||
|
||||
logger.Info().Log("Server started")
|
||||
err := a.whipserver.ListenAndServe()
|
||||
if err != nil && err != whip.ErrServerClosed {
|
||||
err = fmt.Errorf("WHIP server: %w", err)
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
|
||||
sendError(err)
|
||||
}()
|
||||
}
|
||||
|
||||
wgStart.Add(1)
|
||||
a.wgStop.Add(1)
|
||||
|
||||
@ -1354,6 +1427,14 @@ func (a *api) stop() {
|
||||
a.srtserver = nil
|
||||
}
|
||||
|
||||
// Stop the WHIP server
|
||||
if a.whipserver != nil {
|
||||
a.log.logger.whip.Info().Log("Stopping ...")
|
||||
|
||||
a.whipserver.Close()
|
||||
a.whipserver = nil
|
||||
}
|
||||
|
||||
// Stop the RTMP server
|
||||
if a.rtmpserver != nil {
|
||||
a.log.logger.rtmp.Info().Log("Stopping ...")
|
||||
|
||||
@ -98,6 +98,7 @@ func (d *Config) Clone() *Config {
|
||||
data.Storage = d.Storage
|
||||
data.RTMP = d.RTMP
|
||||
data.SRT = d.SRT
|
||||
data.WHIP = d.WHIP
|
||||
data.FFmpeg = d.FFmpeg
|
||||
data.Playout = d.Playout
|
||||
data.Debug = d.Debug
|
||||
@ -227,6 +228,11 @@ func (d *Config) init() {
|
||||
d.vars.Register(value.NewBool(&d.SRT.Log.Enable, false), "srt.log.enable", "CORE_SRT_LOG_ENABLE", nil, "Enable SRT server logging", false, false)
|
||||
d.vars.Register(value.NewStringList(&d.SRT.Log.Topics, []string{}, ","), "srt.log.topics", "CORE_SRT_LOG_TOPICS", nil, "List of topics to log", false, false)
|
||||
|
||||
// WHIP
|
||||
d.vars.Register(value.NewBool(&d.WHIP.Enable, false), "whip.enable", "CORE_WHIP_ENABLE", nil, "Enable WHIP (WebRTC HTTP Ingestion Protocol) server", false, false)
|
||||
d.vars.Register(value.NewAddress(&d.WHIP.Address, ":8555"), "whip.address", "CORE_WHIP_ADDRESS", nil, "WHIP server HTTP listen address", false, false)
|
||||
d.vars.Register(value.NewString(&d.WHIP.Token, ""), "whip.token", "CORE_WHIP_TOKEN", nil, "WHIP bearer token for publishing clients", false, true)
|
||||
|
||||
// FFmpeg
|
||||
d.vars.Register(value.NewExec(&d.FFmpeg.Binary, "ffmpeg", d.fs), "ffmpeg.binary", "CORE_FFMPEG_BINARY", nil, "Path to ffmpeg binary", true, false)
|
||||
d.vars.Register(value.NewInt64(&d.FFmpeg.MaxProcesses, 0), "ffmpeg.max_processes", "CORE_FFMPEG_MAXPROCESSES", nil, "Max. allowed simultaneously running ffmpeg instances, 0 for unlimited", false, false)
|
||||
|
||||
@ -113,6 +113,20 @@ type Data struct {
|
||||
Topics []string `json:"topics"`
|
||||
} `json:"log"`
|
||||
} `json:"srt"`
|
||||
// WHIP is the configuration for the WHIP (WebRTC HTTP Ingestion Protocol) server.
|
||||
// The server accepts WebRTC/RTP streams from encoders and browsers, making them
|
||||
// available to FFmpeg via an SDP relay endpoint.
|
||||
//
|
||||
// FFmpeg input address template: http://localhost:{Address}/whip/{name}/sdp
|
||||
// (Use the {whip} replacer in process configs.)
|
||||
WHIP struct {
|
||||
// Enable activates the WHIP HTTP ingestion server.
|
||||
Enable bool `json:"enable"`
|
||||
// Address is the HTTP listen address for the WHIP endpoint, e.g. ":8555".
|
||||
Address string `json:"address"`
|
||||
// Token is an optional bearer token required from WHIP publishing clients.
|
||||
Token string `json:"token"`
|
||||
} `json:"whip"`
|
||||
FFmpeg struct {
|
||||
Binary string `json:"binary"`
|
||||
MaxProcesses int64 `json:"max_processes" format:"int64"`
|
||||
|
||||
@ -1896,6 +1896,102 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/whip": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "List all currently active WHIP (WebRTC HTTP Ingestion Protocol) streams.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"v16.?.?"
|
||||
],
|
||||
"summary": "List all publishing WHIP streams",
|
||||
"operationId": "whip-3-list-channels",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/api.WHIPChannel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/whip/url": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the base publish URL and configuration so the UI can display it in a WHIP Server panel.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"v16.?.?"
|
||||
],
|
||||
"summary": "Get WHIP server base URL",
|
||||
"operationId": "whip-3-get-server-url",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WHIPServerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/whip/{name}/url": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the URL to configure in OBS and the internal SDP relay URL for FFmpeg.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"v16.?.?"
|
||||
],
|
||||
"summary": "Get WHIP publish URL for a stream key",
|
||||
"operationId": "whip-3-get-url",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Stream key",
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WHIPURLs"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/widget/process/{id}": {
|
||||
"get": {
|
||||
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
||||
@ -3439,6 +3535,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.WHIPChannel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Stream key used in the WHIP URL path.",
|
||||
"type": "string"
|
||||
},
|
||||
"published_at": {
|
||||
"description": "RFC 3339 timestamp when the publisher connected.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.WHIPURLs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"publish_url": {
|
||||
"description": "URL to enter in OBS Settings Stream Server.",
|
||||
"type": "string"
|
||||
},
|
||||
"sdp_url": {
|
||||
"description": "Internal FFmpeg-readable relay SDP URL.",
|
||||
"type": "string"
|
||||
},
|
||||
"stream_key": {
|
||||
"description": "Stream key component used in both URLs.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.WHIPServerInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_publish_url": {
|
||||
"description": "Base URL for OBS. Append the stream key.",
|
||||
"type": "string"
|
||||
},
|
||||
"base_sdp_url": {
|
||||
"description": "Base internal SDP relay URL.",
|
||||
"type": "string"
|
||||
},
|
||||
"example_obs_url": {
|
||||
"description": "Example URL with placeholder stream key.",
|
||||
"type": "string"
|
||||
},
|
||||
"has_token": {
|
||||
"description": "Whether a bearer token is required.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"input_address_template": {
|
||||
"description": "Process input address template for WHIP.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.SRTChannels": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@ -976,6 +976,46 @@ definitions:
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
api.WHIPChannel:
|
||||
properties:
|
||||
name:
|
||||
description: Stream key used in the WHIP URL path.
|
||||
type: string
|
||||
published_at:
|
||||
description: RFC 3339 timestamp when the publisher connected.
|
||||
format: date-time
|
||||
type: string
|
||||
type: object
|
||||
api.WHIPURLs:
|
||||
properties:
|
||||
publish_url:
|
||||
description: URL to enter in OBS Settings Stream Server.
|
||||
type: string
|
||||
sdp_url:
|
||||
description: Internal FFmpeg-readable relay SDP URL.
|
||||
type: string
|
||||
stream_key:
|
||||
description: Stream key component used in both URLs.
|
||||
type: string
|
||||
type: object
|
||||
api.WHIPServerInfo:
|
||||
properties:
|
||||
base_publish_url:
|
||||
description: Base URL for OBS. Append the stream key.
|
||||
type: string
|
||||
base_sdp_url:
|
||||
description: Base internal SDP relay URL.
|
||||
type: string
|
||||
example_obs_url:
|
||||
description: Example URL with placeholder stream key.
|
||||
type: string
|
||||
has_token:
|
||||
description: Whether a bearer token is required.
|
||||
type: boolean
|
||||
input_address_template:
|
||||
description: Process input address template for WHIP.
|
||||
type: string
|
||||
type: object
|
||||
api.SRTChannels:
|
||||
properties:
|
||||
connections:
|
||||
@ -3186,6 +3226,69 @@ paths:
|
||||
summary: List all publishing SRT treams
|
||||
tags:
|
||||
- v16.9.0
|
||||
/api/v3/whip:
|
||||
get:
|
||||
description: List all currently active WHIP (WebRTC HTTP Ingestion Protocol)
|
||||
streams.
|
||||
operationId: whip-3-list-channels
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/api.WHIPChannel'
|
||||
type: array
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List all publishing WHIP streams
|
||||
tags:
|
||||
- v16.?.?
|
||||
/api/v3/whip/url:
|
||||
get:
|
||||
description: Returns the base publish URL and configuration so the UI can display
|
||||
it in a WHIP Server panel.
|
||||
operationId: whip-3-get-server-url
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.WHIPServerInfo'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get WHIP server base URL
|
||||
tags:
|
||||
- v16.?.?
|
||||
/api/v3/whip/{name}/url:
|
||||
get:
|
||||
description: Returns the URL to configure in OBS and the internal SDP relay
|
||||
URL for FFmpeg.
|
||||
operationId: whip-3-get-url
|
||||
parameters:
|
||||
- description: Stream key
|
||||
in: path
|
||||
name: name
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.WHIPURLs'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/api.Error'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get WHIP publish URL for a stream key
|
||||
tags:
|
||||
- v16.?.?
|
||||
/api/v3/widget/process/{id}:
|
||||
get:
|
||||
description: Fetch minimal statistics about a process, which is not protected
|
||||
|
||||
17
go.mod
17
go.mod
@ -22,6 +22,8 @@ require (
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/minio/minio-go/v7 v7.0.70
|
||||
github.com/pion/interceptor v0.1.29
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/prep/average v0.0.0-20200506183628-d26c465f48c3
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/puzpuzpuz/xsync/v3 v3.1.0
|
||||
@ -73,6 +75,20 @@ require (
|
||||
github.com/miekg/dns v1.1.59 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/rtp v1.8.7 // indirect
|
||||
github.com/pion/sctp v1.8.19 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
@ -88,6 +104,7 @@ require (
|
||||
github.com/urfave/cli/v2 v2.27.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
||||
|
||||
96
go.sum
96
go.sum
@ -77,6 +77,7 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
@ -98,8 +99,11 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-jwt v0.0.0-20221127215225-c84d41a71003 h1:FyalHKl9hnJvhNbrABJXXjC2hG7gvIF0ioW9i0xHNQU=
|
||||
@ -134,6 +138,48 @@ github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJn
|
||||
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA=
|
||||
github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
|
||||
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
|
||||
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@ -176,6 +222,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
@ -199,6 +246,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/vektah/gqlparser/v2 v2.5.12 h1:COMhVVnql6RoaF7+aTBWiTADdpLGyZWU3K/NwW0ph98=
|
||||
github.com/vektah/gqlparser/v2 v2.5.12/go.mod h1:WQQjFc+I1YIzoPvZBhUQX7waZgg3pMLi0r8KymvAE2w=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
@ -208,6 +257,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@ -222,35 +272,81 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
|
||||
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
|
||||
@ -107,6 +107,7 @@ func (rscfg *SetConfig) MergeTo(cfg *config.Config) {
|
||||
cfg.Storage = rscfg.Storage
|
||||
cfg.RTMP = rscfg.RTMP
|
||||
cfg.SRT = rscfg.SRT
|
||||
cfg.WHIP = rscfg.WHIP
|
||||
cfg.FFmpeg = rscfg.FFmpeg
|
||||
cfg.Playout = rscfg.Playout
|
||||
cfg.Debug = rscfg.Debug
|
||||
|
||||
166
http/handler/api/whip.go
Normal file
166
http/handler/api/whip.go
Normal file
@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cfgstore "github.com/datarhei/core/v16/config/store"
|
||||
"github.com/datarhei/core/v16/whip"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// WHIPHandler provides HTTP API access to the WHIP server channel information.
|
||||
type WHIPHandler struct {
|
||||
whip whip.Server
|
||||
config cfgstore.Store
|
||||
}
|
||||
|
||||
// NewWHIP returns a new WHIPHandler backed by the provided WHIP server.
|
||||
func NewWHIP(whip whip.Server, config cfgstore.Store) *WHIPHandler {
|
||||
return &WHIPHandler{
|
||||
whip: whip,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// WHIPChannel represents a currently active WHIP publish session.
|
||||
type WHIPChannel struct {
|
||||
// Name is the stream identifier used in the WHIP URL path.
|
||||
Name string `json:"name" jsonschema:"minLength=1"`
|
||||
// PublishedAt is the RFC 3339 timestamp when the publisher connected.
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
// WHIPURLs holds the publish URL (for OBS) and the internal SDP relay URL (for FFmpeg).
|
||||
type WHIPURLs struct {
|
||||
// PublishURL is the URL to enter in OBS → Settings → Stream → Server.
|
||||
// Format: http://<public_host>:<port>/whip/<stream_key>
|
||||
PublishURL string `json:"publish_url"`
|
||||
// SDPURL is the internal FFmpeg-readable relay URL.
|
||||
// Format: http://localhost:<port>/whip/<stream_key>/sdp
|
||||
SDPURL string `json:"sdp_url"`
|
||||
// StreamKey is the key component used in both URLs.
|
||||
StreamKey string `json:"stream_key"`
|
||||
}
|
||||
|
||||
// whipPublicBase returns "http://<host>:<port>" derived from the active config.
|
||||
func (h *WHIPHandler) whipPublicBase() (string, string, error) {
|
||||
if h.config == nil {
|
||||
return "", "", fmt.Errorf("config not available")
|
||||
}
|
||||
cfg := h.config.GetActive()
|
||||
addr := cfg.WHIP.Address // e.g. ":8555" or "0.0.0.0:8555"
|
||||
|
||||
// Extract port from address.
|
||||
port := addr
|
||||
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
|
||||
port = addr[idx+1:]
|
||||
}
|
||||
|
||||
// Pick public host.
|
||||
host := "localhost"
|
||||
if len(cfg.Host.Name) > 0 && cfg.Host.Name[0] != "" {
|
||||
host = cfg.Host.Name[0]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%s", host, port),
|
||||
fmt.Sprintf("http://localhost:%s", port),
|
||||
nil
|
||||
}
|
||||
|
||||
// GetURL returns the WHIP publish URL for a given stream key.
|
||||
// @Summary Get WHIP publish URL for a stream key
|
||||
// @Description Returns the URL to configure in OBS and the internal SDP relay URL for FFmpeg.
|
||||
// @Tags v16.?.?
|
||||
// @ID whip-3-get-url
|
||||
// @Produce json
|
||||
// @Param name path string true "Stream key"
|
||||
// @Success 200 {object} WHIPURLs
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v3/whip/{name}/url [get]
|
||||
func (h *WHIPHandler) GetURL(c echo.Context) error {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "stream key is required"})
|
||||
}
|
||||
|
||||
publicBase, localBase, err := h.whipPublicBase()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, WHIPURLs{
|
||||
PublishURL: fmt.Sprintf("%s/whip/%s", publicBase, name),
|
||||
SDPURL: fmt.Sprintf("%s/whip/%s/sdp", localBase, name),
|
||||
StreamKey: name,
|
||||
})
|
||||
}
|
||||
|
||||
// GetServerURL returns the base WHIP server URL (without a stream key).
|
||||
// @Summary Get WHIP server base URL
|
||||
// @Description Returns the base publish URL and configuration so the UI can display it in a WHIP Server panel.
|
||||
// @Tags v16.?.?
|
||||
// @ID whip-3-get-server-url
|
||||
// @Produce json
|
||||
// @Success 200 {object} WHIPServerInfo
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v3/whip/url [get]
|
||||
func (h *WHIPHandler) GetServerURL(c echo.Context) error {
|
||||
publicBase, localBase, err := h.whipPublicBase()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
var token string
|
||||
if h.config != nil {
|
||||
token = h.config.GetActive().WHIP.Token
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, WHIPServerInfo{
|
||||
BasePublishURL: fmt.Sprintf("%s/whip/", publicBase),
|
||||
BaseSDPURL: fmt.Sprintf("%s/whip/", localBase),
|
||||
HasToken: token != "",
|
||||
ExampleOBS: fmt.Sprintf("%s/whip/<stream-key>", publicBase),
|
||||
InputAddressTemplate: "{whip:name=<stream-key>}",
|
||||
})
|
||||
}
|
||||
|
||||
// WHIPServerInfo holds the base URLs and metadata shown in the WHIP Server panel.
|
||||
type WHIPServerInfo struct {
|
||||
// BasePublishURL is the base URL for OBS. Append the stream key.
|
||||
BasePublishURL string `json:"base_publish_url"`
|
||||
// BaseSDPURL is the base internal SDP relay URL.
|
||||
BaseSDPURL string `json:"base_sdp_url"`
|
||||
// HasToken indicates whether a bearer token is required.
|
||||
HasToken bool `json:"has_token"`
|
||||
// ExampleOBS shows an example URL with placeholder stream key.
|
||||
ExampleOBS string `json:"example_obs_url"`
|
||||
// InputAddressTemplate is the process input address template for WHIP.
|
||||
InputAddressTemplate string `json:"input_address_template"`
|
||||
}
|
||||
|
||||
// ListChannels lists all currently active WHIP publishers.
|
||||
// @Summary List all publishing WHIP streams
|
||||
// @Description List all currently active WHIP (WebRTC HTTP Ingestion Protocol) streams.
|
||||
// @Tags v16.?.?
|
||||
// @ID whip-3-list-channels
|
||||
// @Produce json
|
||||
// @Success 200 {array} WHIPChannel
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v3/whip [get]
|
||||
func (h *WHIPHandler) ListChannels(c echo.Context) error {
|
||||
channels := h.whip.Channels()
|
||||
|
||||
list := make([]WHIPChannel, 0, len(channels.Publisher))
|
||||
for name, since := range channels.Publisher {
|
||||
list = append(list, WHIPChannel{
|
||||
Name: name,
|
||||
PublishedAt: since,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, list)
|
||||
}
|
||||
@ -51,6 +51,7 @@ import (
|
||||
"github.com/datarhei/core/v16/rtmp"
|
||||
"github.com/datarhei/core/v16/session"
|
||||
"github.com/datarhei/core/v16/srt"
|
||||
"github.com/datarhei/core/v16/whip"
|
||||
|
||||
mwcache "github.com/datarhei/core/v16/http/middleware/cache"
|
||||
mwcors "github.com/datarhei/core/v16/http/middleware/cors"
|
||||
@ -86,6 +87,7 @@ type Config struct {
|
||||
Cors CorsConfig
|
||||
RTMP rtmp.Server
|
||||
SRT srt.Server
|
||||
WHIP whip.Server
|
||||
JWT jwt.JWT
|
||||
Config cfgstore.Store
|
||||
Cache cache.Cacher
|
||||
@ -120,6 +122,7 @@ type server struct {
|
||||
playout *api.PlayoutHandler
|
||||
rtmp *api.RTMPHandler
|
||||
srt *api.SRTHandler
|
||||
whip *api.WHIPHandler
|
||||
config *api.ConfigHandler
|
||||
session *api.SessionHandler
|
||||
widget *api.WidgetHandler
|
||||
@ -268,6 +271,13 @@ func NewServer(config Config) (Server, error) {
|
||||
)
|
||||
}
|
||||
|
||||
if config.WHIP != nil {
|
||||
s.v3handler.whip = api.NewWHIP(
|
||||
config.WHIP,
|
||||
config.Config,
|
||||
)
|
||||
}
|
||||
|
||||
if config.Config != nil {
|
||||
s.v3handler.config = api.NewConfig(
|
||||
config.Config,
|
||||
@ -628,6 +638,13 @@ func (s *server) setRoutesV3(v3 *echo.Group) {
|
||||
v3.GET("/srt", s.v3handler.srt.ListChannels)
|
||||
}
|
||||
|
||||
// v3 WHIP
|
||||
if s.v3handler.whip != nil {
|
||||
v3.GET("/whip", s.v3handler.whip.ListChannels)
|
||||
v3.GET("/whip/url", s.v3handler.whip.GetServerURL)
|
||||
v3.GET("/whip/:name/url", s.v3handler.whip.GetURL)
|
||||
}
|
||||
|
||||
// v3 Config
|
||||
if s.v3handler.config != nil {
|
||||
v3.GET("/config", s.v3handler.config.Get)
|
||||
|
||||
@ -1546,6 +1546,25 @@ func resolvePlaceholders(config *app.Config, r replace.Replacer) {
|
||||
input.Address = r.Replace(input.Address, "fs:*", "", vars, config, "input")
|
||||
input.Address = r.Replace(input.Address, "rtmp", "", vars, config, "input")
|
||||
input.Address = r.Replace(input.Address, "srt", "", vars, config, "input")
|
||||
input.Address = r.Replace(input.Address, "whip", "", vars, config, "input")
|
||||
|
||||
// WHIP SDP relay URLs (http://…/whip/…/sdp) require FFmpeg to open nested
|
||||
// rtp:// and udp:// sub-protocols, which are blocked by default when the
|
||||
// top-level protocol is HTTP. Inject -protocol_whitelist automatically so
|
||||
// that both the probe and the live process can open the relay without
|
||||
// requiring the user to set it manually.
|
||||
if strings.Contains(input.Address, "/whip/") && strings.HasSuffix(input.Address, "/sdp") {
|
||||
hasWhitelist := false
|
||||
for _, opt := range input.Options {
|
||||
if strings.Contains(opt, "protocol_whitelist") {
|
||||
hasWhitelist = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWhitelist {
|
||||
input.Options = append([]string{"-protocol_whitelist", "file,crypto,http,rtp,udp,tcp"}, input.Options...)
|
||||
}
|
||||
}
|
||||
|
||||
for j, option := range input.Options {
|
||||
// Replace any known placeholders
|
||||
|
||||
102
vendor/modules.txt
vendored
102
vendor/modules.txt
vendored
@ -255,6 +255,102 @@ github.com/minio/minio-go/v7/pkg/tags
|
||||
# github.com/mitchellh/mapstructure v1.5.0
|
||||
## explicit; go 1.14
|
||||
github.com/mitchellh/mapstructure
|
||||
# github.com/pion/datachannel v1.5.8
|
||||
## explicit; go 1.19
|
||||
github.com/pion/datachannel
|
||||
# github.com/pion/dtls/v2 v2.2.12
|
||||
## explicit; go 1.13
|
||||
github.com/pion/dtls/v2
|
||||
github.com/pion/dtls/v2/internal/ciphersuite
|
||||
github.com/pion/dtls/v2/internal/ciphersuite/types
|
||||
github.com/pion/dtls/v2/internal/closer
|
||||
github.com/pion/dtls/v2/internal/util
|
||||
github.com/pion/dtls/v2/pkg/crypto/ccm
|
||||
github.com/pion/dtls/v2/pkg/crypto/ciphersuite
|
||||
github.com/pion/dtls/v2/pkg/crypto/clientcertificate
|
||||
github.com/pion/dtls/v2/pkg/crypto/elliptic
|
||||
github.com/pion/dtls/v2/pkg/crypto/fingerprint
|
||||
github.com/pion/dtls/v2/pkg/crypto/hash
|
||||
github.com/pion/dtls/v2/pkg/crypto/prf
|
||||
github.com/pion/dtls/v2/pkg/crypto/signature
|
||||
github.com/pion/dtls/v2/pkg/crypto/signaturehash
|
||||
github.com/pion/dtls/v2/pkg/protocol
|
||||
github.com/pion/dtls/v2/pkg/protocol/alert
|
||||
github.com/pion/dtls/v2/pkg/protocol/extension
|
||||
github.com/pion/dtls/v2/pkg/protocol/handshake
|
||||
github.com/pion/dtls/v2/pkg/protocol/recordlayer
|
||||
# github.com/pion/ice/v2 v2.3.38
|
||||
## explicit; go 1.13
|
||||
github.com/pion/ice/v2
|
||||
github.com/pion/ice/v2/internal/atomic
|
||||
github.com/pion/ice/v2/internal/fakenet
|
||||
github.com/pion/ice/v2/internal/stun
|
||||
# github.com/pion/interceptor v0.1.29
|
||||
## explicit; go 1.19
|
||||
github.com/pion/interceptor
|
||||
github.com/pion/interceptor/internal/ntp
|
||||
github.com/pion/interceptor/internal/sequencenumber
|
||||
github.com/pion/interceptor/pkg/nack
|
||||
github.com/pion/interceptor/pkg/report
|
||||
github.com/pion/interceptor/pkg/twcc
|
||||
# github.com/pion/logging v0.2.2
|
||||
## explicit; go 1.12
|
||||
github.com/pion/logging
|
||||
# github.com/pion/mdns v0.0.12
|
||||
## explicit; go 1.19
|
||||
github.com/pion/mdns
|
||||
# github.com/pion/randutil v0.1.0
|
||||
## explicit; go 1.14
|
||||
github.com/pion/randutil
|
||||
# github.com/pion/rtcp v1.2.14
|
||||
## explicit; go 1.13
|
||||
github.com/pion/rtcp
|
||||
# github.com/pion/rtp v1.8.7
|
||||
## explicit; go 1.19
|
||||
github.com/pion/rtp
|
||||
github.com/pion/rtp/codecs
|
||||
github.com/pion/rtp/codecs/av1/obu
|
||||
github.com/pion/rtp/codecs/vp9
|
||||
# github.com/pion/sctp v1.8.19
|
||||
## explicit; go 1.19
|
||||
github.com/pion/sctp
|
||||
# github.com/pion/sdp/v3 v3.0.9
|
||||
## explicit; go 1.13
|
||||
github.com/pion/sdp/v3
|
||||
# github.com/pion/srtp/v2 v2.0.20
|
||||
## explicit; go 1.14
|
||||
github.com/pion/srtp/v2
|
||||
# github.com/pion/stun v0.6.1
|
||||
## explicit; go 1.12
|
||||
github.com/pion/stun
|
||||
github.com/pion/stun/internal/hmac
|
||||
# github.com/pion/transport/v2 v2.2.10
|
||||
## explicit; go 1.12
|
||||
github.com/pion/transport/v2
|
||||
github.com/pion/transport/v2/connctx
|
||||
github.com/pion/transport/v2/deadline
|
||||
github.com/pion/transport/v2/packetio
|
||||
github.com/pion/transport/v2/replaydetector
|
||||
github.com/pion/transport/v2/stdnet
|
||||
github.com/pion/transport/v2/udp
|
||||
github.com/pion/transport/v2/utils/xor
|
||||
github.com/pion/transport/v2/vnet
|
||||
# github.com/pion/turn/v2 v2.1.6
|
||||
## explicit; go 1.13
|
||||
github.com/pion/turn/v2
|
||||
github.com/pion/turn/v2/internal/allocation
|
||||
github.com/pion/turn/v2/internal/client
|
||||
github.com/pion/turn/v2/internal/ipnet
|
||||
github.com/pion/turn/v2/internal/proto
|
||||
github.com/pion/turn/v2/internal/server
|
||||
# github.com/pion/webrtc/v3 v3.3.6
|
||||
## explicit; go 1.17
|
||||
github.com/pion/webrtc/v3
|
||||
github.com/pion/webrtc/v3/internal/fmtp
|
||||
github.com/pion/webrtc/v3/internal/mux
|
||||
github.com/pion/webrtc/v3/internal/util
|
||||
github.com/pion/webrtc/v3/pkg/media
|
||||
github.com/pion/webrtc/v3/pkg/rtcerr
|
||||
# github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||
## explicit
|
||||
github.com/pmezard/go-difflib/difflib
|
||||
@ -343,6 +439,9 @@ github.com/vektah/gqlparser/v2/lexer
|
||||
github.com/vektah/gqlparser/v2/parser
|
||||
github.com/vektah/gqlparser/v2/validator
|
||||
github.com/vektah/gqlparser/v2/validator/rules
|
||||
# github.com/wlynxg/anet v0.0.3
|
||||
## explicit; go 1.20
|
||||
github.com/wlynxg/anet
|
||||
# github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb
|
||||
## explicit
|
||||
github.com/xeipuuv/gojsonpointer
|
||||
@ -392,6 +491,8 @@ golang.org/x/crypto/argon2
|
||||
golang.org/x/crypto/blake2b
|
||||
golang.org/x/crypto/cryptobyte
|
||||
golang.org/x/crypto/cryptobyte/asn1
|
||||
golang.org/x/crypto/curve25519
|
||||
golang.org/x/crypto/curve25519/internal/field
|
||||
golang.org/x/crypto/ocsp
|
||||
golang.org/x/crypto/pbkdf2
|
||||
golang.org/x/crypto/sha3
|
||||
@ -403,6 +504,7 @@ golang.org/x/mod/semver
|
||||
# golang.org/x/net v0.25.0
|
||||
## explicit; go 1.18
|
||||
golang.org/x/net/bpf
|
||||
golang.org/x/net/dns/dnsmessage
|
||||
golang.org/x/net/html
|
||||
golang.org/x/net/html/atom
|
||||
golang.org/x/net/http/httpguts
|
||||
|
||||
176
whip/pion_provider.go
Normal file
176
whip/pion_provider.go
Normal file
@ -0,0 +1,176 @@
|
||||
package whip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
// pionProvider implements WebRTCProvider using pion/webrtc v3.
|
||||
// It handles full ICE + DTLS-SRTP negotiation and forwards the decrypted
|
||||
// RTP to the local UDP relay sockets that FFmpeg reads.
|
||||
type pionProvider struct {
|
||||
mu sync.Mutex
|
||||
pcs map[string]*webrtc.PeerConnection
|
||||
}
|
||||
|
||||
// NewPionProvider returns a WebRTCProvider backed by pion/webrtc v3.
|
||||
func NewPionProvider() WebRTCProvider {
|
||||
return &pionProvider{
|
||||
pcs: make(map[string]*webrtc.PeerConnection),
|
||||
}
|
||||
}
|
||||
|
||||
// OpenSession implements WebRTCProvider.
|
||||
func (p *pionProvider) OpenSession(offerSDP string, videoPort, audioPort int) (string, error) {
|
||||
m := &webrtc.MediaEngine{}
|
||||
if err := m.RegisterDefaultCodecs(); err != nil {
|
||||
return "", fmt.Errorf("pion: register codecs: %w", err)
|
||||
}
|
||||
|
||||
ir := &interceptor.Registry{}
|
||||
if err := webrtc.RegisterDefaultInterceptors(m, ir); err != nil {
|
||||
return "", fmt.Errorf("pion: register interceptors: %w", err)
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(ir))
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pion: new peer connection: %w", err)
|
||||
}
|
||||
|
||||
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
|
||||
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: add video transceiver: %w", err)
|
||||
}
|
||||
|
||||
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
|
||||
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: add audio transceiver: %w", err)
|
||||
}
|
||||
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: offerSDP,
|
||||
}); err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: set remote description: %w", err)
|
||||
}
|
||||
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: create answer: %w", err)
|
||||
}
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: set local description: %w", err)
|
||||
}
|
||||
|
||||
<-gatherComplete
|
||||
|
||||
finalSDP := pc.LocalDescription().SDP
|
||||
|
||||
videoDst, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("127.0.0.1:%d", videoPort))
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: resolve video relay addr: %w", err)
|
||||
}
|
||||
audioDst, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("127.0.0.1:%d", audioPort))
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: resolve audio relay addr: %w", err)
|
||||
}
|
||||
|
||||
videoConn, err := net.DialUDP("udp4", nil, videoDst)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: dial video relay: %w", err)
|
||||
}
|
||||
audioConn, err := net.DialUDP("udp4", nil, audioDst)
|
||||
if err != nil {
|
||||
videoConn.Close()
|
||||
pc.Close()
|
||||
return "", fmt.Errorf("pion: dial audio relay: %w", err)
|
||||
}
|
||||
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
var dst *net.UDPConn
|
||||
if strings.EqualFold(track.Kind().String(), "video") {
|
||||
dst = videoConn
|
||||
// Request a keyframe immediately so consumers (ffprobe, FFmpeg) can
|
||||
// determine the video resolution without waiting for the next IDR.
|
||||
// Then send PLI every 2 s to keep keyframes flowing.
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
pc.WriteRTCP([]rtcp.Packet{&rtcp.PictureLossIndication{
|
||||
MediaSSRC: uint32(track.SSRC()),
|
||||
}})
|
||||
<-ticker.C
|
||||
if pc.ConnectionState() != webrtc.PeerConnectionStateConnected {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
dst = audioConn
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, _, err := track.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dst.Write(buf[:n])
|
||||
}
|
||||
})
|
||||
|
||||
ufrag := extractICEUfrag(offerSDP)
|
||||
p.mu.Lock()
|
||||
p.pcs[ufrag] = pc
|
||||
p.mu.Unlock()
|
||||
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
if state == webrtc.PeerConnectionStateClosed ||
|
||||
state == webrtc.PeerConnectionStateFailed ||
|
||||
state == webrtc.PeerConnectionStateDisconnected {
|
||||
videoConn.Close()
|
||||
audioConn.Close()
|
||||
p.mu.Lock()
|
||||
delete(p.pcs, ufrag)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
return finalSDP, nil
|
||||
}
|
||||
|
||||
func extractICEUfrag(sdp string) string {
|
||||
for _, line := range strings.Split(sdp, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(line, "a=ice-ufrag:") {
|
||||
return strings.TrimPrefix(line, "a=ice-ufrag:")
|
||||
}
|
||||
}
|
||||
if len(sdp) > 32 {
|
||||
return sdp[:32]
|
||||
}
|
||||
return sdp
|
||||
}
|
||||
852
whip/whip.go
Normal file
852
whip/whip.go
Normal file
@ -0,0 +1,852 @@
|
||||
// Package whip provides a WHIP (WebRTC HTTP Ingestion Protocol, RFC 9328) server.
|
||||
//
|
||||
// The server listens on an HTTP port for WHIP publishing requests. Clients
|
||||
// (OBS Studio 30+, GStreamer, FFmpeg, browsers) POST an SDP offer to
|
||||
// /whip/{streamID}. The server allocates UDP receive sockets, responds with
|
||||
// an SDP answer containing the server's RTP receive ports, and forwards the
|
||||
// received RTP packets to internal relay sockets that FFmpeg reads via a
|
||||
// dynamically-generated SDP served at /whip/{streamID}/sdp.
|
||||
//
|
||||
// Usage in a Restreamer process config (input address template):
|
||||
//
|
||||
// http://localhost:8555/whip/{name}/sdp
|
||||
//
|
||||
// This uses the {whip} template, which expands to the above URL pattern.
|
||||
//
|
||||
// # WebRTC / ICE / DTLS-SRTP
|
||||
//
|
||||
// This implementation uses plain UDP RTP without ICE negotiation or DTLS-SRTP
|
||||
// encryption. It is compatible with encoders that support plain-RTP mode
|
||||
// (e.g. FFmpeg with -f whip pointed at this server, GStreamer without DTLS).
|
||||
//
|
||||
// For full browser and OBS WHIP support (which requires ICE + DTLS-SRTP),
|
||||
// inject a WebRTCProvider implementation (e.g. via github.com/pion/webrtc/v3)
|
||||
// through Config.WebRTCProvider. When set, the provider handles ICE negotiation
|
||||
// and SRTP decryption and delivers decrypted RTP to the server's relay layer.
|
||||
package whip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/datarhei/core/v16/log"
|
||||
"github.com/datarhei/core/v16/session"
|
||||
)
|
||||
|
||||
// ErrServerClosed is returned by ListenAndServe when the server is closed via Close().
|
||||
var ErrServerClosed = http.ErrServerClosed
|
||||
|
||||
// WebRTCProvider is an optional interface for full WebRTC (ICE + DTLS-SRTP) support.
|
||||
// When provided, the server delegates SDP negotiation and media reception to the
|
||||
// provider rather than using plain UDP RTP.
|
||||
//
|
||||
// To add browser/OBS WHIP support, implement this interface using pion/webrtc/v3:
|
||||
//
|
||||
// type pionProvider struct { ... }
|
||||
// func (p *pionProvider) OpenSession(offerSDP string, videoPort, audioPort int) (answerSDP string, err error) { ... }
|
||||
type WebRTCProvider interface {
|
||||
// OpenSession performs ICE + DTLS-SRTP negotiation.
|
||||
// offerSDP is the SDP offer from the client.
|
||||
// videoPort and audioPort are the local UDP ports where the provider should
|
||||
// deliver decrypted RTP after the handshake.
|
||||
// Returns the SDP answer to send back to the client, or an error.
|
||||
OpenSession(offerSDP string, videoPort, audioPort int) (answerSDP string, err error)
|
||||
}
|
||||
|
||||
// Config is the configuration for the WHIP server.
|
||||
type Config struct {
|
||||
// Addr is the HTTP listen address for the WHIP endpoint, e.g. ":8555".
|
||||
Addr string
|
||||
|
||||
// Token is an optional bearer token required from WHIP clients.
|
||||
// If empty, no authentication is required.
|
||||
Token string
|
||||
|
||||
// Logger is optional.
|
||||
Logger log.Logger
|
||||
|
||||
// Collector is optional. Used for session statistics.
|
||||
Collector session.Collector
|
||||
|
||||
// WebRTCProvider is optional. When provided, it handles ICE negotiation and
|
||||
// DTLS-SRTP for full browser/OBS WebRTC support. When nil, the server falls
|
||||
// back to plain UDP RTP (no ICE, no encryption).
|
||||
WebRTCProvider WebRTCProvider
|
||||
}
|
||||
|
||||
// Server defines the WHIP server interface.
|
||||
type Server interface {
|
||||
// ListenAndServe starts the WHIP HTTP server. Blocks until Close() is called.
|
||||
ListenAndServe() error
|
||||
|
||||
// Close shuts down the server and releases all resources.
|
||||
Close()
|
||||
|
||||
// Channels returns the currently active (publishing) WHIP streams.
|
||||
Channels() Channels
|
||||
}
|
||||
|
||||
// Channels describes the active WHIP streams.
|
||||
type Channels struct {
|
||||
// Publisher maps stream name to its publish start time.
|
||||
Publisher map[string]time.Time
|
||||
}
|
||||
|
||||
// channel holds the relay sockets and state for a single WHIP stream.
|
||||
type channel struct {
|
||||
name string
|
||||
|
||||
// videoRelayPort / audioRelayPort: loopback UDP ports that FFmpeg reads from.
|
||||
// Ports are pre-allocated by briefly binding to :0, then released so that
|
||||
// FFmpeg (or any RTP consumer) can bind to them.
|
||||
videoRelayPort int
|
||||
audioRelayPort int
|
||||
|
||||
// videoRx / audioRx: UDP sockets that receive RTP from the WHIP client.
|
||||
// These are present only while a client is actively publishing.
|
||||
videoRx *net.UDPConn
|
||||
audioRx *net.UDPConn
|
||||
|
||||
// codec parameters negotiated from the SDP offer.
|
||||
videoPayloadType int
|
||||
videoCodec string
|
||||
videoClockRate int
|
||||
videoFmtp string // extra fmtp params from offer (e.g. sprop-parameter-sets)
|
||||
|
||||
// SPS/PPS captured from in-band RTP stream (populated after first keyframe).
|
||||
videoSPS []byte
|
||||
videoPPS []byte
|
||||
spropped bool // true once sprop-parameter-sets has been built from in-band NALs
|
||||
|
||||
audioPayloadType int
|
||||
audioCodec string
|
||||
audioClockRate int
|
||||
audioChannels int
|
||||
|
||||
publishedAt time.Time
|
||||
collector session.Collector
|
||||
|
||||
rxCancel context.CancelFunc
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func newChannel(name string, collector session.Collector) (*channel, error) {
|
||||
ch := &channel{
|
||||
name: name,
|
||||
collector: collector,
|
||||
// Defaults matching the most common WebRTC codecs.
|
||||
videoPayloadType: 96,
|
||||
videoCodec: "H264",
|
||||
videoClockRate: 90000,
|
||||
audioPayloadType: 111,
|
||||
audioCodec: "opus",
|
||||
audioClockRate: 48000,
|
||||
audioChannels: 2,
|
||||
}
|
||||
|
||||
// Pre-allocate relay port numbers. We bind briefly to get an ephemeral port
|
||||
// assigned by the OS, then immediately close so FFmpeg can bind to that port.
|
||||
vp, err := allocateRelayPort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whip: allocate video relay port: %w", err)
|
||||
}
|
||||
ch.videoRelayPort = vp
|
||||
|
||||
ap, err := allocateRelayPort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whip: allocate audio relay port: %w", err)
|
||||
}
|
||||
ch.audioRelayPort = ap
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// allocateRelayPort binds a UDP socket on a random loopback port, records the
|
||||
// port number, then immediately closes the socket so that an external process
|
||||
// (FFmpeg) can bind to the same port shortly afterwards.
|
||||
func allocateRelayPort() (int, error) {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := conn.LocalAddr().(*net.UDPAddr).Port
|
||||
conn.Close()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// relayVideoPort returns the relay port for the video RTP stream (FFmpeg reads this).
|
||||
func (ch *channel) relayVideoPort() int {
|
||||
return ch.videoRelayPort
|
||||
}
|
||||
|
||||
// relayAudioPort returns the relay port for the audio RTP stream (FFmpeg reads this).
|
||||
func (ch *channel) relayAudioPort() int {
|
||||
return ch.audioRelayPort
|
||||
}
|
||||
|
||||
// ffmpegSDP returns the SDP describing the relay streams for FFmpeg to consume.
|
||||
func (ch *channel) ffmpegSDP() string {
|
||||
ch.lock.RLock()
|
||||
vpt := ch.videoPayloadType
|
||||
vpc := ch.videoCodec
|
||||
vclk := ch.videoClockRate
|
||||
vfmtp := ch.videoFmtp
|
||||
apt := ch.audioPayloadType
|
||||
apc := ch.audioCodec
|
||||
aclk := ch.audioClockRate
|
||||
ach := ch.audioChannels
|
||||
ch.lock.RUnlock()
|
||||
|
||||
videoPort := ch.relayVideoPort()
|
||||
audioPort := ch.relayAudioPort()
|
||||
|
||||
sdp := "v=0\r\n"
|
||||
sdp += "o=- 0 0 IN IP4 127.0.0.1\r\n"
|
||||
sdp += "s=WHIP Stream\r\n"
|
||||
sdp += "c=IN IP4 127.0.0.1\r\n"
|
||||
sdp += "t=0 0\r\n"
|
||||
sdp += fmt.Sprintf("m=video %d RTP/AVP %d\r\n", videoPort, vpt)
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d\r\n", vpt, vpc, vclk)
|
||||
if vfmtp != "" {
|
||||
sdp += fmt.Sprintf("a=fmtp:%d %s\r\n", vpt, vfmtp)
|
||||
} else if vpc == "H264" {
|
||||
sdp += fmt.Sprintf("a=fmtp:%d packetization-mode=1\r\n", vpt)
|
||||
}
|
||||
sdp += fmt.Sprintf("m=audio %d RTP/AVP %d\r\n", audioPort, apt)
|
||||
if ach > 1 {
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d/%d\r\n", apt, apc, aclk, ach)
|
||||
} else {
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d\r\n", apt, apc, aclk)
|
||||
}
|
||||
|
||||
return sdp
|
||||
}
|
||||
|
||||
// startRx allocates receive sockets, starts the relay goroutines, and begins
|
||||
// forwarding incoming RTP packets from the WHIP client to the relay sockets.
|
||||
// It returns the SDP answer to send to the WHIP client.
|
||||
func (ch *channel) startRx(serverIP string, provider WebRTCProvider, offerSDP string) (answerSDP string, videoRxPort, audioRxPort int, err error) {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
// Stop any previous rx session.
|
||||
if ch.rxCancel != nil {
|
||||
ch.rxCancel()
|
||||
}
|
||||
if ch.videoRx != nil {
|
||||
ch.videoRx.Close()
|
||||
}
|
||||
if ch.audioRx != nil {
|
||||
ch.audioRx.Close()
|
||||
}
|
||||
|
||||
// Parse codec parameters from the SDP offer (best-effort).
|
||||
ch.updateCodecsFromSDP(offerSDP)
|
||||
|
||||
// Allocate rx sockets for the WHIP client to send RTP to.
|
||||
ch.videoRx, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP(serverIP), Port: 0})
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("whip: allocate video rx socket: %w", err)
|
||||
}
|
||||
|
||||
ch.audioRx, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP(serverIP), Port: 0})
|
||||
if err != nil {
|
||||
ch.videoRx.Close()
|
||||
ch.videoRx = nil
|
||||
return "", 0, 0, fmt.Errorf("whip: allocate audio rx socket: %w", err)
|
||||
}
|
||||
|
||||
videoRxPort = ch.videoRx.LocalAddr().(*net.UDPAddr).Port
|
||||
audioRxPort = ch.audioRx.LocalAddr().(*net.UDPAddr).Port
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch.rxCancel = cancel
|
||||
ch.publishedAt = time.Now()
|
||||
|
||||
// Build the SDP answer for the WHIP client.
|
||||
if provider != nil {
|
||||
answerSDP, err = provider.OpenSession(offerSDP, videoRxPort, audioRxPort)
|
||||
if err != nil {
|
||||
cancel()
|
||||
ch.videoRx.Close()
|
||||
ch.audioRx.Close()
|
||||
ch.videoRx = nil
|
||||
ch.audioRx = nil
|
||||
return "", 0, 0, fmt.Errorf("whip: WebRTC provider: %w", err)
|
||||
}
|
||||
} else {
|
||||
answerSDP = ch.buildPlainRTPAnswer(serverIP, videoRxPort, audioRxPort)
|
||||
}
|
||||
|
||||
// Start relay goroutines (send decrypted RTP to the loopback ports where FFmpeg listens).
|
||||
go ch.relayUDP(ctx, ch.videoRx, ch.videoRelayPort, ch.snoopH264NALs)
|
||||
go ch.relayUDP(ctx, ch.audioRx, ch.audioRelayPort, nil)
|
||||
|
||||
// Track session bandwidth ingress.
|
||||
if ch.collector.IsCollectableIP(serverIP) {
|
||||
ch.collector.RegisterAndActivate(ch.name, ch.name, "publish:"+ch.name, serverIP)
|
||||
}
|
||||
|
||||
return answerSDP, videoRxPort, audioRxPort, nil
|
||||
}
|
||||
|
||||
// stopRx tears down the receive side of the channel.
|
||||
func (ch *channel) stopRx() {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if ch.rxCancel != nil {
|
||||
ch.rxCancel()
|
||||
ch.rxCancel = nil
|
||||
}
|
||||
if ch.videoRx != nil {
|
||||
ch.videoRx.Close()
|
||||
ch.videoRx = nil
|
||||
}
|
||||
if ch.audioRx != nil {
|
||||
ch.audioRx.Close()
|
||||
ch.audioRx = nil
|
||||
}
|
||||
ch.publishedAt = time.Time{}
|
||||
// Reset SPS/PPS snoop so next publisher gets fresh capture.
|
||||
ch.videoSPS = nil
|
||||
ch.videoPPS = nil
|
||||
ch.spropped = false
|
||||
}
|
||||
|
||||
// close releases all resources held by the channel.
|
||||
func (ch *channel) close() {
|
||||
ch.stopRx()
|
||||
}
|
||||
|
||||
// isPublishing returns true when a WHIP client is currently sending to the channel.
|
||||
func (ch *channel) isPublishing() bool {
|
||||
ch.lock.RLock()
|
||||
defer ch.lock.RUnlock()
|
||||
return !ch.publishedAt.IsZero()
|
||||
}
|
||||
|
||||
// publishedSince returns when the current publisher started.
|
||||
func (ch *channel) publishedSince() time.Time {
|
||||
ch.lock.RLock()
|
||||
defer ch.lock.RUnlock()
|
||||
return ch.publishedAt
|
||||
}
|
||||
|
||||
// relayUDP reads RTP packets from rx and forwards them to relayPort on loopback.
|
||||
// FFmpeg (or any RTP consumer) is expected to be listening on relayPort.
|
||||
// onPacket, if non-nil, is called with a copy of each RTP packet before forwarding.
|
||||
func (ch *channel) relayUDP(ctx context.Context, rx *net.UDPConn, relayPort int, onPacket func([]byte)) {
|
||||
buf := make([]byte, 1500)
|
||||
|
||||
// Send to loopback so FFmpeg (listening on relayPort) receives.
|
||||
dst := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: relayPort}
|
||||
|
||||
sendConn, err := net.DialUDP("udp4", nil, dst)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sendConn.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
rx.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, _, err := rx.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if isNetTimeout(err) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sendConn.Write(buf[:n])
|
||||
if onPacket != nil {
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
go onPacket(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// snoopH264NALs inspects an RTP packet for H.264 SPS (type 7) and PPS (type 8)
|
||||
// NAL units. Once both are found, it updates ch.videoFmtp with sprop-parameter-sets
|
||||
// so that FFmpeg can determine the video resolution from the relay SDP.
|
||||
func (ch *channel) snoopH264NALs(pkt []byte) {
|
||||
ch.lock.RLock()
|
||||
done := ch.spropped
|
||||
ch.lock.RUnlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
nals := parseH264NALsFromRTP(pkt)
|
||||
if len(nals) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
if ch.spropped {
|
||||
return
|
||||
}
|
||||
for _, nal := range nals {
|
||||
if len(nal) == 0 {
|
||||
continue
|
||||
}
|
||||
nalType := nal[0] & 0x1f
|
||||
if nalType == 7 && ch.videoSPS == nil {
|
||||
ch.videoSPS = nal
|
||||
} else if nalType == 8 && ch.videoPPS == nil {
|
||||
ch.videoPPS = nal
|
||||
}
|
||||
}
|
||||
if ch.videoSPS != nil && ch.videoPPS != nil {
|
||||
ch.videoFmtp = "sprop-parameter-sets=" +
|
||||
base64.StdEncoding.EncodeToString(ch.videoSPS) + "," +
|
||||
base64.StdEncoding.EncodeToString(ch.videoPPS) +
|
||||
";packetization-mode=1"
|
||||
ch.spropped = true
|
||||
}
|
||||
}
|
||||
|
||||
// parseH264NALsFromRTP returns the H.264 NAL units contained in a raw RTP packet.
|
||||
// It handles Single NAL unit packets and STAP-A aggregation packets.
|
||||
// FU-A fragmented packets are skipped (SPS/PPS are never fragmented in practice).
|
||||
func parseH264NALsFromRTP(rtpPkt []byte) [][]byte {
|
||||
if len(rtpPkt) < 13 {
|
||||
return nil
|
||||
}
|
||||
// Skip fixed 12-byte RTP header + optional CSRC list.
|
||||
cc := int(rtpPkt[0] & 0x0f)
|
||||
offset := 12 + cc*4
|
||||
// Skip RTP extension header if present.
|
||||
if rtpPkt[0]&0x10 != 0 {
|
||||
if offset+4 > len(rtpPkt) {
|
||||
return nil
|
||||
}
|
||||
extLen := int(rtpPkt[offset+2])<<8 | int(rtpPkt[offset+3])
|
||||
offset += 4 + extLen*4
|
||||
}
|
||||
if offset >= len(rtpPkt) {
|
||||
return nil
|
||||
}
|
||||
payload := rtpPkt[offset:]
|
||||
nalType := payload[0] & 0x1f
|
||||
|
||||
switch {
|
||||
case nalType >= 1 && nalType <= 23: // Single NAL unit packet
|
||||
return [][]byte{payload}
|
||||
case nalType == 24: // STAP-A
|
||||
var nals [][]byte
|
||||
pos := 1
|
||||
for pos+2 <= len(payload) {
|
||||
size := int(payload[pos])<<8 | int(payload[pos+1])
|
||||
pos += 2
|
||||
if size == 0 || pos+size > len(payload) {
|
||||
break
|
||||
}
|
||||
nals = append(nals, payload[pos:pos+size])
|
||||
pos += size
|
||||
}
|
||||
return nals
|
||||
}
|
||||
// FU-A and other types: ignore
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlainRTPAnswer constructs a minimal SDP answer for plain (non-DTLS) RTP.
|
||||
func (ch *channel) buildPlainRTPAnswer(serverIP string, videoPort, audioPort int) string {
|
||||
sdp := "v=0\r\n"
|
||||
sdp += "o=- 0 0 IN IP4 " + serverIP + "\r\n"
|
||||
sdp += "s=WHIP Answer\r\n"
|
||||
sdp += "c=IN IP4 " + serverIP + "\r\n"
|
||||
sdp += "t=0 0\r\n"
|
||||
sdp += fmt.Sprintf("m=video %d RTP/AVP %d\r\n", videoPort, ch.videoPayloadType)
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d\r\n", ch.videoPayloadType, ch.videoCodec, ch.videoClockRate)
|
||||
sdp += "a=recvonly\r\n"
|
||||
sdp += fmt.Sprintf("m=audio %d RTP/AVP %d\r\n", audioPort, ch.audioPayloadType)
|
||||
if ch.audioChannels > 1 {
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d/%d\r\n", ch.audioPayloadType, ch.audioCodec, ch.audioClockRate, ch.audioChannels)
|
||||
} else {
|
||||
sdp += fmt.Sprintf("a=rtpmap:%d %s/%d\r\n", ch.audioPayloadType, ch.audioCodec, ch.audioClockRate)
|
||||
}
|
||||
sdp += "a=recvonly\r\n"
|
||||
return sdp
|
||||
}
|
||||
|
||||
// updateCodecsFromSDP performs a best-effort parse of the SDP offer to extract
|
||||
// the first video and audio payload types and codec names.
|
||||
func (ch *channel) updateCodecsFromSDP(sdp string) {
|
||||
type mediaState struct {
|
||||
inVideo bool
|
||||
inAudio bool
|
||||
}
|
||||
var ms mediaState
|
||||
|
||||
for _, line := range strings.Split(sdp, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(line, "m=video") {
|
||||
ms.inVideo = true
|
||||
ms.inAudio = false
|
||||
// Extract payload type from "m=video 9 ... 96 97 ..."
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 4 {
|
||||
var pt int
|
||||
if _, err := fmt.Sscanf(parts[3], "%d", &pt); err == nil {
|
||||
ch.videoPayloadType = pt
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "m=audio") {
|
||||
ms.inAudio = true
|
||||
ms.inVideo = false
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 4 {
|
||||
var pt int
|
||||
if _, err := fmt.Sscanf(parts[3], "%d", &pt); err == nil {
|
||||
ch.audioPayloadType = pt
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "a=fmtp:") {
|
||||
// Format: a=fmtp:<pt> <params>
|
||||
var pt int
|
||||
if _, err := fmt.Sscanf(line, "a=fmtp:%d ", &pt); err == nil {
|
||||
params := strings.SplitN(line, " ", 2)
|
||||
if len(params) == 2 && ms.inVideo && pt == ch.videoPayloadType {
|
||||
ch.videoFmtp = params[1]
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "a=rtpmap:") {
|
||||
// Format: a=rtpmap:<pt> <codec>/<clockrate>[/<channels>]
|
||||
var pt int
|
||||
var rest string
|
||||
if _, err := fmt.Sscanf(line, "a=rtpmap:%d %s", &pt, &rest); err != nil {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
codec := parts[0]
|
||||
var clk int
|
||||
if len(parts) >= 2 {
|
||||
fmt.Sscanf(parts[1], "%d", &clk)
|
||||
}
|
||||
var channels int = 1
|
||||
if len(parts) >= 3 {
|
||||
fmt.Sscanf(parts[2], "%d", &channels)
|
||||
}
|
||||
|
||||
if ms.inVideo && pt == ch.videoPayloadType {
|
||||
ch.videoCodec = codec
|
||||
if clk > 0 {
|
||||
ch.videoClockRate = clk
|
||||
}
|
||||
} else if ms.inAudio && pt == ch.audioPayloadType {
|
||||
ch.audioCodec = codec
|
||||
if clk > 0 {
|
||||
ch.audioClockRate = clk
|
||||
}
|
||||
ch.audioChannels = channels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server implementation ---
|
||||
|
||||
type server struct {
|
||||
addr string
|
||||
token string
|
||||
logger log.Logger
|
||||
collector session.Collector
|
||||
provider WebRTCProvider
|
||||
|
||||
httpServer *http.Server
|
||||
|
||||
channels map[string]*channel
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new WHIP server from the given config.
|
||||
func New(config Config) (Server, error) {
|
||||
if config.Addr == "" {
|
||||
return nil, fmt.Errorf("whip: listen address is required")
|
||||
}
|
||||
|
||||
s := &server{
|
||||
addr: config.Addr,
|
||||
token: config.Token,
|
||||
logger: config.Logger,
|
||||
collector: config.Collector,
|
||||
provider: config.WebRTCProvider,
|
||||
channels: make(map[string]*channel),
|
||||
}
|
||||
|
||||
if s.logger == nil {
|
||||
s.logger = log.New("")
|
||||
}
|
||||
|
||||
if s.collector == nil {
|
||||
s.collector = session.NewNullCollector()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/whip/", s.dispatch)
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Addr: s.addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListenAndServe starts the WHIP HTTP server.
|
||||
func (s *server) ListenAndServe() error {
|
||||
s.logger.Info().Log("Server started")
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err == http.ErrServerClosed {
|
||||
return ErrServerClosed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Close shuts down the WHIP server.
|
||||
func (s *server) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.httpServer.Shutdown(ctx)
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
for _, ch := range s.channels {
|
||||
ch.close()
|
||||
}
|
||||
s.channels = make(map[string]*channel)
|
||||
|
||||
s.logger.Info().Log("Server closed")
|
||||
}
|
||||
|
||||
// Channels returns the current active publishers.
|
||||
func (s *server) Channels() Channels {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
c := Channels{Publisher: make(map[string]time.Time)}
|
||||
for name, ch := range s.channels {
|
||||
if ch.isPublishing() {
|
||||
c.Publisher[name] = ch.publishedSince()
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// dispatch routes incoming HTTP requests to the appropriate handler.
|
||||
func (s *server) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
// Trim "/whip/" prefix.
|
||||
path := strings.TrimPrefix(r.URL.Path, "/whip/")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
// GET /whip/{name}/sdp → SDP for FFmpeg to consume the relay.
|
||||
if strings.HasSuffix(path, "/sdp") && r.Method == http.MethodGet {
|
||||
name := strings.TrimSuffix(path, "/sdp")
|
||||
if !isValidStreamName(name) {
|
||||
http.Error(w, "invalid stream name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.handleSDPRead(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
// All other methods require token validation.
|
||||
if s.token != "" && !s.checkToken(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="WHIP"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := path
|
||||
if !isValidStreamName(name) {
|
||||
http.Error(w, "invalid stream name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
s.handlePublish(w, r, name)
|
||||
case http.MethodDelete:
|
||||
s.handleDelete(w, r, name)
|
||||
case http.MethodPatch:
|
||||
// Trickle ICE (RFC 9328 §4.3) — respond 405 if not supported.
|
||||
if s.provider == nil {
|
||||
http.Error(w, "trickle ICE not supported in plain-RTP mode", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
http.Error(w, "trickle ICE not implemented", http.StatusNotImplemented)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSDPRead serves the SDP file that FFmpeg uses to read the WHIP relay stream.
|
||||
// The relay sockets (and thus their ports) are pre-allocated here if the channel
|
||||
// does not yet exist, so that FFmpeg can bind to them before a WHIP client arrives.
|
||||
// If a publisher is active, this handler waits up to 5 seconds for in-band SPS/PPS
|
||||
// to be captured so that FFmpeg can determine the video resolution immediately.
|
||||
func (s *server) handleSDPRead(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ch := s.getOrCreateChannel(name)
|
||||
|
||||
// If a publisher is active, wait for SPS/PPS to be snooped from the RTP stream.
|
||||
if ch.isPublishing() {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
ch.lock.RLock()
|
||||
done := ch.spropped
|
||||
ch.lock.RUnlock()
|
||||
if done {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
sdp := ch.ffmpegSDP()
|
||||
w.Header().Set("Content-Type", "application/sdp")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, sdp)
|
||||
}
|
||||
|
||||
// handlePublish handles the WHIP POST request (SDP offer/answer exchange).
|
||||
func (s *server) handlePublish(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/sdp") {
|
||||
http.Error(w, "Content-Type must be application/sdp", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 16*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read SDP offer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offerSDP := string(body)
|
||||
|
||||
// Identify the client IP so we can send RTP back on the same interface.
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
serverIP := s.serverIPFor(clientIP)
|
||||
|
||||
ch := s.getOrCreateChannel(name)
|
||||
|
||||
answerSDP, _, _, err := ch.startRx(serverIP, s.provider, offerSDP)
|
||||
if err != nil {
|
||||
s.logger.Error().WithField("stream", name).WithField("error", err).Log("Failed to start WHIP session")
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info().WithField("stream", name).WithField("client", clientIP).Log("WHIP publisher connected")
|
||||
|
||||
// Respond per RFC 9328: 201 Created with Location header and SDP answer.
|
||||
location := "/whip/" + name
|
||||
w.Header().Set("Content-Type", "application/sdp")
|
||||
w.Header().Set("Location", location)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, answerSDP)
|
||||
}
|
||||
|
||||
// handleDelete ends a WHIP session (RFC 9328 §4.2).
|
||||
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
|
||||
s.lock.RLock()
|
||||
ch, ok := s.channels[name]
|
||||
s.lock.RUnlock()
|
||||
|
||||
if !ok || !ch.isPublishing() {
|
||||
http.Error(w, "stream not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ch.stopRx()
|
||||
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
s.logger.Info().WithField("stream", name).WithField("client", clientIP).Log("WHIP publisher disconnected")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// getOrCreateChannel returns the channel for the given name, creating it if needed.
|
||||
func (s *server) getOrCreateChannel(name string) *channel {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
ch, ok := s.channels[name]
|
||||
if !ok {
|
||||
var err error
|
||||
ch, err = newChannel(name, s.collector)
|
||||
if err != nil {
|
||||
s.logger.Error().WithField("stream", name).WithField("error", err).Log("Failed to create channel")
|
||||
// Return a dummy channel rather than nil to avoid panics; it won't relay.
|
||||
ch = &channel{name: name}
|
||||
}
|
||||
s.channels[name] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// checkToken validates the bearer token from the Authorization header or ?token= query param.
|
||||
func (s *server) checkToken(r *http.Request) bool {
|
||||
if t := r.URL.Query().Get("token"); t != "" {
|
||||
return t == s.token
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ") == s.token
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serverIPFor returns the server-side IP to use in SDP based on the client IP.
|
||||
// For loopback clients, returns 127.0.0.1; for external clients returns 0.0.0.0
|
||||
// (the OS will choose an appropriate local address when binding).
|
||||
func (s *server) serverIPFor(clientIP string) string {
|
||||
ip := net.ParseIP(clientIP)
|
||||
if ip == nil || ip.IsLoopback() {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
// isValidStreamName checks that a stream name contains only safe characters.
|
||||
func isValidStreamName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isNetTimeout returns true for Go net timeout errors.
|
||||
func isNetTimeout(err error) bool {
|
||||
if ne, ok := err.(net.Error); ok {
|
||||
return ne.Timeout()
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user