From 7a8073eeddebee48b02193b14cb8709a2074c919 Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Sat, 14 Mar 2026 12:26:24 -0700 Subject: [PATCH] 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. --- .gitignore | 2 +- Dockerfile.whip-test | 40 ++ app/api/api.go | 81 ++++ config/config.go | 6 + config/data.go | 14 + docs/swagger.json | 152 +++++++ docs/swagger.yaml | 103 +++++ go.mod | 17 + go.sum | 96 +++++ http/api/config.go | 1 + http/handler/api/whip.go | 166 ++++++++ http/server.go | 17 + restream/restream.go | 19 + vendor/modules.txt | 102 +++++ whip/pion_provider.go | 176 ++++++++ whip/whip.go | 852 +++++++++++++++++++++++++++++++++++++++ 16 files changed, 1843 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.whip-test create mode 100644 http/handler/api/whip.go create mode 100644 whip/pion_provider.go create mode 100644 whip/whip.go diff --git a/.gitignore b/.gitignore index e39532a4..ca91bca4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /data/** /test/** .vscode - +/vendor *.ts *.ts.tmp *.m3u8 diff --git a/Dockerfile.whip-test b/Dockerfile.whip-test new file mode 100644 index 00000000..bf74cdad --- /dev/null +++ b/Dockerfile.whip-test @@ -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 diff --git a/app/api/api.go b/app/api/api.go index cfc6d895..244c6728 100644 --- a/app/api/api.go +++ b/app/api/api.go @@ -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 ...") diff --git a/config/config.go b/config/config.go index 33d9492b..7068ec98 100644 --- a/config/config.go +++ b/config/config.go @@ -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) diff --git a/config/data.go b/config/data.go index 35507888..ce094828 100644 --- a/config/data.go +++ b/config/data.go @@ -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"` diff --git a/docs/swagger.json b/docs/swagger.json index 8351c34c..5eab3577 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ffe87a64..5119e4c5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/go.mod b/go.mod index 8698b3f7..2b569026 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 123fa56b..bd1e7927 100644 --- a/go.sum +++ b/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= diff --git a/http/api/config.go b/http/api/config.go index 47040cc2..e4687979 100644 --- a/http/api/config.go +++ b/http/api/config.go @@ -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 diff --git a/http/handler/api/whip.go b/http/handler/api/whip.go new file mode 100644 index 00000000..fe3fd04b --- /dev/null +++ b/http/handler/api/whip.go @@ -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://:/whip/ + PublishURL string `json:"publish_url"` + // SDPURL is the internal FFmpeg-readable relay URL. + // Format: http://localhost:/whip//sdp + SDPURL string `json:"sdp_url"` + // StreamKey is the key component used in both URLs. + StreamKey string `json:"stream_key"` +} + +// whipPublicBase returns "http://:" 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/", publicBase), + InputAddressTemplate: "{whip:name=}", + }) +} + +// 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) +} diff --git a/http/server.go b/http/server.go index 3b0f02a6..bfd752c6 100644 --- a/http/server.go +++ b/http/server.go @@ -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) diff --git a/restream/restream.go b/restream/restream.go index f3ce897a..910dd9b7 100644 --- a/restream/restream.go +++ b/restream/restream.go @@ -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 diff --git a/vendor/modules.txt b/vendor/modules.txt index 46822f21..9e3c8a1f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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 diff --git a/whip/pion_provider.go b/whip/pion_provider.go new file mode 100644 index 00000000..a27e7579 --- /dev/null +++ b/whip/pion_provider.go @@ -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 +} diff --git a/whip/whip.go b/whip/whip.go new file mode 100644 index 00000000..5c0b8ca6 --- /dev/null +++ b/whip/whip.go @@ -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: + 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: /[/] + 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 +}