diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..864117b --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,124 @@ +# Forgejo Actions CI for Datarhei — Dragon Fork. +# +# Mirrors the upstream go-tests.yml shape (GitHub Actions syntax), +# but pinned to Go 1.24 to match go.mod and adds the M3 race-detector +# pass. The forgejo-runner picks this up automatically. +# +# Triggered on every push and pull request. Two jobs: +# - lint-and-vet: cheap, fast feedback (~30s) +# - test: full test suite with -race, ~3 minutes including +# the integration tests in app/webrtc that bind UDP +# sockets and run a real Pion handshake. + +name: ci + +on: + push: + branches: + - main + - 'm[0-9]*-*' + - 'fix/**' + pull_request: + +jobs: + lint-and-vet: + name: vet + build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: go vet + run: go vet ./... + + - name: go build + run: go build ./... + + test: + name: race tests + runs-on: ubuntu-22.04 + needs: lint-and-vet + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + # Integration tests need ephemeral UDP ports above 32768; the + # default sysctl on ubuntu runners covers this, so no extra + # setup is required. + + - name: go test -race -short + run: go test -race -short -count=1 ./... + env: + # The integration tests start Pion peers; tighten the timeout + # so a flaky network-bound test never sits the whole job. + GORACE: 'halt_on_error=1' + + - name: go test (coverage, no race) + # Race detector + coverage in one pass slows things meaningfully; + # do them separately. This step's purpose is the coverage.out + # artifact, not a second correctness signal. + run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./... + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: coverage-go-${{ github.sha }} + path: coverage.out + if-no-files-found: warn + retention-days: 14 + + # --- WebRTC subsystem-only smoke --------------------------------- + # The 5-viewer fanout test catches the largest class of regressions + # for the egress path. Promoted to its own job so a failure on the + # WebRTC side reads cleanly in the actions log instead of being + # buried among ~80 packages of unrelated Core tests. + webrtc-smoke: + name: WebRTC smoke (5-viewer fanout) + runs-on: ubuntu-22.04 + needs: lint-and-vet + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: WebRTC integration tests (race) + run: | + go test -race -count=1 -v \ + -run 'TestIntegration_|TestSubsystem_TeardownHookFiresOnProcessStop|TestHandler_' \ + ./app/webrtc/... ./core/webrtc/... + + # --- Latency gate ---------------------------------------------------- + # Server-hop p95 latency check. Build-tagged so it doesn't run in the + # default `go test ./...` invocation; this dedicated job exists to + # catch regressions that would otherwise hide behind 'all tests pass'. + # Threshold: p95 < 50ms (locally observed: sub-ms; gate is generous + # to absorb CI runner noise without false alarms). + latency-gate: + name: WebRTC latency p95 gate + runs-on: ubuntu-22.04 + needs: lint-and-vet + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Server-hop latency p95 < 50ms + run: | + go test -tags latency -timeout 90s -race -count=1 \ + -run TestLatencyServerHop \ + ./app/webrtc/... -v diff --git a/.gitignore b/.gitignore index e39532a..c586771 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,17 @@ /test/** .vscode +# Dragon Fork additions: source trees under /core/ and /test/ must be tracked. +!/core/ +!/core/webrtc/ +!/core/webrtc/** +!/test/ +!/test/publish.sh +!/test/whep-client/ +!/test/whep-client/** +!/test/whep-player.html +!/test/TESTING.md + *.ts *.ts.tmp *.m3u8 @@ -16,3 +27,4 @@ *.flv .VSCodeCounter +whep-client diff --git a/CHANGELOG.md b/CHANGELOG.md index eba47a4..c34d318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,72 @@ -# Core +# Datarhei — Dragon Fork + +## v0.1.0-dragonfork (2026-05-03) + +The first tagged Dragon Fork release. Forked from upstream datarhei +Core v16.16.0; everything upstream does is preserved unchanged. New: +WebRTC (WHEP) egress, integrated with the existing process supervisor. + +### Added + +- **WebRTC subsystem** under `app/webrtc/`, mirroring the shape of + upstream's RTMP and SRT servers (Server interface, Echo handlers, + process-graph hooks, admin endpoints). +- **Per-process opt-in** via `config.webrtc.enabled` on every restream + process; resolver auto-injects two RTP output legs and allocates + loopback UDP ports. +- **`POST /api/v3/whep/{id}`** — WebRTC-HTTP Egress Protocol subscribe. + JWT-protected by the existing Core auth. +- **`DELETE /api/v3/whep/{id}/{resource}`** — idempotent teardown + (returns 204 even on unknown resource per WHEP spec). +- **`PATCH /api/v3/whep/{id}/{resource}`** — trickle ICE. +- **CORS preflight** on every WHEP route + `Access-Control-Expose-Headers` + for `Location` and `ETag` so browser-side WHEP players work + cross-origin. +- **Configurable stream maps** via `webrtc.video_map` / `webrtc.audio_map` + on the per-process config — defaults to `0:v:0` / `0:a:0` for + RTMP/SRT publishers, overridable for multi-input pipelines. +- **`webrtc.*` global config block** with `CORE_WEBRTC_*` env-var + bindings parallel to RTMP and SRT. +- **Admin API:** `GET /api/v3/webrtc/streams` + `/streams/{id}/peers`. +- **Browser smoke player** at `test/whep-player.html` with ICE / codec + / bitrate diagnostics, JWT field, and `?url=&token=` shareable + URLs. +- **Server-hop latency p95 gate** in CI (`-tags latency`), enforced at + 50ms on the runner; locally observed p95 ≈ 240µs. +- **TrueNAS deploy bundle** at `deploy/truenas/core/` — host-networked + Docker stack with bundled FFmpeg, env-driven config. +- **Multi-viewer correctness:** per-stream peer cap, ICE-failure + auto-cleanup goroutines, process-stop broadcast tear-down. +- **Error matrix:** 406 codec mismatch, 504 ICE timeout, 503 cap + reached (separate body for total vs per-stream), 204 DELETE + idempotent. + +### Fixed + +- `Config.Clone()` now preserves the `WebRTC` section. Pre-fix, + `cfg.WebRTC.Enable` was always zero at runtime regardless of + `CORE_WEBRTC_ENABLE`. Caught on the first M2 TrueNAS deploy. +- `http/api.ProcessConfig` Marshal/Unmarshal now carry the per-process + `webrtc` block. Pre-fix, `POST /api/v3/process` silently dropped + `webrtc.enabled=true` on its way to the restream config layer. + +### Forking notes + +- Module path stays `github.com/datarhei/core/v16` — internal imports + don't churn, the fork is distinguished by repo location and branch + history. +- `cmd/webrtc-poc` from M1 is preserved as a manual-testing harness. + Production deploys use the main `core` binary. + +### Acknowledgements + +Built on upstream Datarhei Core (Apache 2.0) and Pion WebRTC v4 +(MIT). Full attribution in `NOTICE` and `CREDITS`. + +--- + +# Core (upstream) + ### Core v16.15.0 > v16.16.0 diff --git a/CREDITS b/CREDITS new file mode 100644 index 0000000..1764dd1 --- /dev/null +++ b/CREDITS @@ -0,0 +1,47 @@ +# Credits + +Datarhei — Dragon Fork stands on the shoulders of the open-source +projects below. Required-attribution notices and the corresponding +licenses live in NOTICE and the per-vendor LICENSE files under +vendor/. + +## Direct ancestor + +- **datarhei/core** (Apache-2.0) — the base codebase this fork tracks. + https://github.com/datarhei/core + +## Major Go dependencies + +- **github.com/pion/webrtc/v4** (MIT) — the Go WebRTC stack the egress + path is built on. https://github.com/pion/webrtc +- **github.com/pion/rtp** (MIT) — RTP packet types. +- **github.com/pion/dtls/v2** (MIT) — DTLS for SRTP key exchange. +- **github.com/pion/ice/v3** (MIT) — ICE candidate gathering. +- **github.com/pion/sdp/v3** (MIT) — SDP parsing. +- **github.com/labstack/echo/v4** (MIT) — HTTP routing. +- **github.com/swaggo/echo-swagger** (MIT) — OpenAPI / Swagger UI + middleware. +- **github.com/caddyserver/certmagic** (Apache-2.0) — Let's Encrypt + TLS automation. +- **github.com/datarhei/joy4** (Apache-2.0) — RTMP server primitives + (forked from joy4). +- **github.com/datarhei/gosrt** (Apache-2.0) — pure-Go SRT. +- **go.uber.org/zap** (MIT) — structured logging. + +## Subprocess + +- **FFmpeg** (LGPL-2.1-or-later / GPL-2.0-or-later, build-flag + dependent) — used as an out-of-process child by the `restream` + subsystem for transcoding and RTP packetisation. Dragon Fork does + not link against the FFmpeg libraries. + +## Brand assets + +- **"Wild Dragon" mark** — © Wild Dragon, used as the project mark + for Dragon Fork builds. + +## Full list + +The complete dependency tree, including transitive dependencies and +their licenses, is enumerated in `vendor/modules.txt` and the +per-vendor LICENSE / COPYING files under `vendor/`. diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..b115634 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,26 @@ +# Datarhei - Dragon Fork — Implementation Notes + +This file tracks observations, gotchas, and decisions made during the Dragon +Fork WebRTC egress implementation. Keep entries chronological; each milestone +adds a new section. + +## Baseline (M1, 2026-04-17) + +- Forked from upstream `datarhei/core` commit `0de97f4` ("Add linux/arm/v8 build"). +- Upstream module path: `github.com/datarhei/core/v16`. The Dragon Fork keeps + this module path so internal imports don't churn; the fork is distinguished + by its repo location (`forge.wilddragon.net/zgaetano/datarhei-dragonfork-core`) + and branch history, not its Go module identity. +- Toolchain: Go 1.22.8, FFmpeg 4.4.2 in the sandbox. FFmpeg 6.x recommended + for publishers in Task 10; 4.4.2 is sufficient for the PoC (libx264 + + libopus + RTP muxer all present). +- `go build ./...` on the clean fork: succeeds. +- `go test -short ./...` on the clean fork: all packages pass. No upstream + flakes observed. + +### Pre-existing state of note +- None flagged. + +--- + + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..b7196dc --- /dev/null +++ b/NOTICE @@ -0,0 +1,41 @@ +Datarhei — Dragon Fork +Copyright (c) 2026 Wild Dragon + +This product includes software developed by datarhei. + + datarhei Core + Copyright (c) datarhei + https://github.com/datarhei/core + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. A copy of the +License is in the LICENSE file at the root of this repository, and is +also available at: + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +This fork additionally bundles or depends on: + + Pion WebRTC and related Pion libraries + Copyright (c) The Pion authors + https://github.com/pion + MIT License + + Echo HTTP framework + Copyright (c) LabStack + https://github.com/labstack/echo + MIT License + + FFmpeg (used as a subprocess by the restream subsystem; not linked) + Copyright (c) The FFmpeg developers + https://ffmpeg.org + LGPL-2.1-or-later / GPL-2.0-or-later (build-flag dependent) + +A complete list of dependencies and their licenses lives in the +CREDITS file at the root of this repository. diff --git a/README.md b/README.md index bd92149..98bca23 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,155 @@ -# Core +# Datarhei — Dragon Fork -![dsdsds](https://github.com/datarhei/misc/blob/main/img/media-core.png?raw=true) +A fork of [datarhei/core](https://github.com/datarhei/core) that adds a +native **WebRTC (WHEP) egress** path. Everything upstream Datarhei +already does — RTMP / SRT / RTSP ingest, FFmpeg process orchestration, +HLS / DASH outputs, S3 mounts, the HTTP API and Swagger UI — works +unchanged. WebRTC sits alongside as another output type, opt-in +per process. -[![License: Apache2](https://img.shields.io/badge/License-Apache%202.0-brightgreen.svg)](<[https://opensource.org/licenses/MI](https://www.apache.org/licenses/LICENSE-2.0)>) -[![CodeQL](https://github.com/datarhei/core/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/datarhei/core/actions/workflows/codeql-analysis.yml) -[![tests](https://github.com/datarhei/core/actions/workflows/go-tests.yml/badge.svg)](https://github.com/datarhei/core/actions/workflows/go-tests.yml) -[![codecov](https://codecov.io/gh/datarhei/core/branch/main/graph/badge.svg?token=90YMPZRAFK)](https://codecov.io/gh/datarhei/core) -[![Go Report Card](https://goreportcard.com/badge/github.com/datarhei/core)](https://goreportcard.com/report/github.com/datarhei/core) -[![PkgGoDev](https://pkg.go.dev/badge/github.com/datarhei/core)](https://pkg.go.dev/github.com/datarhei/core) -[![Gitbook](https://img.shields.io/badge/GitBook-quick%20start-green)](https://docs.datarhei.com/core/guides/beginner) +``` +publisher (OBS / FFmpeg / SRT) ──▶ datarhei Core ──▶ WebRTC peers + │ │ (1–5 viewers per stream) + │ ├──▶ HLS / DASH (existing) + │ ├──▶ RTMP relay (existing) + └──▶ ingest (RTMP / SRT / …) └──▶ recording (existing) +``` -The datarhei Core is a process management solution for FFmpeg that offers a range of interfaces for media content, including HTTP, RTMP, SRT, and storage options. It is optimized for use in virtual environments such as Docker. It has been implemented in various contexts, from small-scale applications like Restreamer to large-scale, multi-instance frameworks spanning multiple locations, such as dedicated servers, cloud instances, and single-board computers. The datarhei Core stands out from traditional media servers by emphasizing FFmpeg and its capabilities rather than focusing on media conversion. +Sub-second glass-to-glass on a LAN over WHEP, no SFU dependencies, +single binary, single Docker image. -## Objectives of development +> **Status:** M1–M4 complete, M5 (release) in flight. Live deploy +> running on TrueNAS since 2026-04-17. -The objectives of development are: +## What this fork adds -- Unhindered use of FFmpeg processes -- Portability of FFmpeg, including management across development and production environments -- Scalability of FFmpeg-based applications through the ability to offload processes to additional instances -- Streamlining of media product development by focusing on features and design. +- **`webrtc.*` config block** alongside `rtmp.*` and `srt.*`, with the + same `CORE_*` env-var binding pattern. +- **Per-process `webrtc.enabled` toggle** on the existing process + config. Once true, Core auto-injects two RTP output legs (video + + audio), allocates UDP ports, and the WHEP endpoint is live. +- **`POST /api/v3/whep/{processID}`** — WebRTC-HTTP Egress Protocol + subscribe; SDP offer in, SDP answer out. JWT-protected by the + existing Core auth. +- **`DELETE /api/v3/whep/{processID}/{resourceID}`** — idempotent + teardown. +- **`PATCH …/{resourceID}`** — trickle ICE. +- **Browser-side smoke player** at `test/whep-player.html` — + zero-dependency WHEP subscriber, ICE/codec/bitrate stats, JWT + field, shareable `?url=&token=` URLs. +- **Multi-viewer correctness:** per-stream peer cap, ICE-failure + auto-cleanup, process-stop broadcast tear-down. +- **Error matrix** per the design spec: `406` on codec mismatch, + `504` on ICE timeout, `503` on cap, `204` on idempotent DELETE, + CORS preflights on every WHEP route. -## What issues have been resolved thus far? - -### Process management - -- Run multiple processes via API -- Unrestricted FFmpeg commands in process configuration. -- Error detection and recovery (e.g., FFmpeg stalls, dumps) -- Referencing for process chaining (pipelines) -- Placeholders for storage, RTMP, and SRT usage (automatic credentials management and URL resolution) -- Logs (access to current stdout/stderr) -- Log history (configurable log history, e.g., for error analysis) -- Resource limitation (max. CPU and MEMORY usage per process) -- Statistics (like FFmpeg progress per input and output, CPU and MEMORY, state, uptime) -- Input verification (like FFprobe) -- Metadata (option to store additional information like a title) - -### Media delivery - -- Configurable file systems (in-memory, disk-mount, S3) -- HTTP/S, RTMP/S, and SRT services, including Let's Encrypt -- Bandwidth and session limiting for HLS/MPEG DASH sessions (protects restreams from congestion) -- Viewer session API and logging - -### Misc - -- HTTP REST and GraphQL API -- Swagger documentation -- Metrics incl. Prometheus support (also detects POSIX and cgroups resources) -- Docker images for fast setup of development environments up to the integration of cloud resources - -## Docker images - -- datarhei/core:latest (AMD64, ARM64, ARMv7) -- datarhei/core:cuda-latest (Nvidia CUDA 11.7.1, AMD64) -- datarhei/core:rpi-latest (Raspberry Pi / OMX/V4L2-M2M, AMD64/ARMv7) -- datarhei/core:vaapi-latest (Intel VAAPI, AMD64) +The existing upstream Datarhei feature set is intact — see "From +upstream Datarhei" below. ## Quick start -1. Run the Docker image +### Docker (TrueNAS / any host with Docker + LAN-reachable IP) ```sh -docker run --name core -d \ - -e CORE_API_AUTH_USERNAME=admin \ - -e CORE_API_AUTH_PASSWORD=secret \ - -p 8080:8080 \ - -v ${HOME}/core/config:/core/config \ - -v ${HOME}/core/data:/core/data \ - datarhei/core:latest +git clone https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core.git +cd datarhei-dragonfork-core/deploy/truenas/core + +cat > .env < Basic authorization > Username: admin, Password: secret +- Swagger UI: `http://:8080/api/swagger/index.html` +- WHEP smoke player: open `test/whep-player.html` in a browser + +### Sample process JSON + +```json +{ + "id": "live", + "input": [ + { "address": "{rtmp,name=live.stream}", "options": [] } + ], + "output": [], + "webrtc": { "enabled": true } +} +``` + +That's it. No `webrtc://` URL scheme to learn — the toggle on +`config.webrtc.enabled` is the entire surface. The resolver allocates +ports, injects `-f rtp udp://…` legs into the FFmpeg command, and the +WHEP endpoint at `/api/v3/whep/live` becomes live the moment the +process starts. + +For multi-input pipelines (lavfi test sources, multi-camera switches, +SDI + file audio), use the `video_map` and `audio_map` fields: + +```json +"webrtc": { + "enabled": true, + "video_map": "0:v:0", + "audio_map": "1:a:0", + "force_transcode": true +} +``` ## Documentation -Documentation is available on [docs.datarhei.com/core](https://docs.datarhei.com/core). +| Topic | Where | +| ----- | ----- | +| Design spec | [`docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md`](docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md) | +| M1 (PoC) plan | [`docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md`](docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md) | +| M2 (Core integration) spec | [`docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`](docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md) | +| Testing | [`test/TESTING.md`](test/TESTING.md) | +| Changelog (Dragon Fork) | [`CHANGELOG.md`](CHANGELOG.md) | +| Upstream Datarhei docs | [docs.datarhei.com/core](https://docs.datarhei.com/core) | -- [Quick start](https://docs.datarhei.com/core/guides/beginner) -- [Installation](https://docs.datarhei.com/core/installation) -- [Configuration](https://docs.datarhei.com/core/configuration) -- [Coding](https://docs.datarhei.com/core/development/coding) +## Building from source + +Go 1.24 required (vendored). + +```sh +make release # cross-compiles linux/amd64 to ./core/core +make test # full suite, race detector +go test -tags latency -timeout 90s -count=1 \ + -run TestLatencyServerHop ./app/webrtc/... # latency p95 gate +``` + +## From upstream Datarhei + +This fork preserves everything upstream Datarhei Core does — Dragon +Fork is purely additive. If a feature isn't WebRTC-related, the +behaviour is unchanged from upstream and the upstream documentation +applies as-is. + +| Subsystem | Upstream feature set | +| --- | --- | +| Process management | API-driven FFmpeg, error detection / recovery, log history, resource limits, statistics, FFprobe input verification, process metadata | +| Media delivery | HTTP/S, RTMP/S, SRT services with Let's Encrypt, configurable file systems (in-memory / disk / S3), HLS/DASH session limits, viewer session API | +| Misc | HTTP REST + GraphQL, Swagger, Prometheus metrics, multi-arch Docker images | + +## Attribution + +Dragon Fork is built on: + +- **datarhei Core** — Apache 2.0, © datarhei. The base repository this + fork tracks. See [`NOTICE`](NOTICE) for the required attribution. +- **Pion WebRTC** — MIT. The Go WebRTC stack the egress path is built + on. +- **FFmpeg** — LGPL / GPL (build-flag dependent). Used as a subprocess + for transcoding and RTP packetisation; Dragon Fork doesn't link + against it. + +Full third-party credits in [`CREDITS`](CREDITS). ## License -datarhei/core is licensed under the Apache License 2.0 +Apache License 2.0 — same as upstream. See [`LICENSE`](LICENSE). diff --git a/app/api/api.go b/app/api/api.go index cfc6d89..701349c 100644 --- a/app/api/api.go +++ b/app/api/api.go @@ -16,6 +16,7 @@ import ( "time" "github.com/datarhei/core/v16/app" + appwebrtc "github.com/datarhei/core/v16/app/webrtc" "github.com/datarhei/core/v16/config" configstore "github.com/datarhei/core/v16/config/store" configvars "github.com/datarhei/core/v16/config/vars" @@ -73,6 +74,8 @@ type api struct { s3fs map[string]fs.Filesystem rtmpserver rtmp.Server srtserver srt.Server + webrtcsub *appwebrtc.Subsystem + webrtchandler *appwebrtc.Handler metrics monitor.HistoryMonitor prom prometheus.Metrics service service.Service @@ -216,6 +219,8 @@ func (a *api) Reload() error { logfields := log.Fields{ "application": app.Name, + "variant": app.Variant, + "fork": app.Fork, "version": app.Version.String(), "repository": "https://github.com/datarhei/core", "license": "Apache License Version 2.0", @@ -617,6 +622,22 @@ func (a *api) start() error { a.restream = restream + // Build the WebRTC egress subsystem if the operator enabled it. + // Failure to construct the subsystem (e.g., invalid NAT1To1 IP) + // is logged and the subsystem declines to install hooks — Core + // starts normally without WebRTC support, consistent with how + // disabling the subsystem at runtime is handled. + if cfg.WebRTC.Enable { + webrtcSub, werr := appwebrtc.New(cfg.WebRTC, a.log.logger.core) + if werr != nil { + a.log.logger.core.Warn().WithError(werr).Log("WebRTC subsystem disabled: construction failed") + } else { + a.restream.SetHooks(webrtcSub.Hooks()) + a.webrtcsub = webrtcSub + a.webrtchandler = appwebrtc.NewHandler(webrtcSub, 0) + } + } + var httpjwt jwt.JWT if cfg.API.Auth.Enable { @@ -1014,6 +1035,7 @@ func (a *api) start() error { }, RTMP: a.rtmpserver, SRT: a.srtserver, + WebRTC: a.webrtchandler, JWT: a.httpjwt, Config: a.config.store, Sessions: a.sessions, @@ -1354,6 +1376,17 @@ func (a *api) stop() { a.srtserver = nil } + // Tear down the WebRTC subsystem: close any active WHEP peers + // first, then release all per-process UDP sockets. + if a.webrtchandler != nil { + a.webrtchandler.Close() + a.webrtchandler = nil + } + if a.webrtcsub != nil { + a.webrtcsub.Close() + a.webrtcsub = nil + } + // Stop the RTMP server if a.rtmpserver != nil { a.log.logger.rtmp.Info().Log("Stopping ...") diff --git a/app/version.go b/app/version.go index d07d3e5..835da99 100644 --- a/app/version.go +++ b/app/version.go @@ -8,6 +8,19 @@ import ( // Name of the app const Name = "datarhei-core" +// Variant distinguishes a Dragon Fork build from upstream Datarhei +// Core in the startup banner and in the /api/v3/about endpoint +// payload. Empty would imply an upstream build; we override the +// linker default with the fork identity. +// +// Kept as a var (not const) so a downstream packager can override it +// at build time via -ldflags="-X github.com/datarhei/core/v16/app.Variant=…" +// without forking the source. +var Variant = "dragonfork" + +// Fork carries the human-readable fork name surfaced in logs. +var Fork = "Datarhei — Dragon Fork" + type versionInfo struct { Major int Minor int diff --git a/app/webrtc/ffmpeg_args.go b/app/webrtc/ffmpeg_args.go new file mode 100644 index 0000000..1b99dc1 --- /dev/null +++ b/app/webrtc/ffmpeg_args.go @@ -0,0 +1,61 @@ +package webrtc + +import ( + "fmt" + + appcfg "github.com/datarhei/core/v16/restream/app" +) + +// BuildArgs emits the FFmpeg output-leg args for the WebRTC side of a +// process. It produces two separate "outputs" — one for video on +// videoPort, one for audio on videoPort+1. Each output ends with its +// UDP address so the slice is structured for consumption by +// restream.AppendOutput after splitting on the track boundary. +// +// Copy vs. re-encode: if ForceTranscode is false, we assume the upstream +// source is already H.264 + Opus and pass them through (copy). When the +// source doesn't match, FFmpeg will fail at runtime and the process will +// restart — the user can flip ForceTranscode on to get a baseline-profile +// H.264 + Opus re-encode. +func BuildArgs(cfg appcfg.ConfigWebRTC, videoPort int) []string { + vcopy := []string{"-c:v", "copy"} + acopy := []string{"-c:a", "copy"} + if cfg.ForceTranscode { + vcopy = []string{ + "-c:v", "libx264", + "-preset", "veryfast", + "-profile:v", "baseline", + "-pix_fmt", "yuv420p", + "-tune", "zerolatency", + "-g", "60", + } + acopy = []string{"-c:a", "libopus", "-b:a", "96k"} + } + + videoMap := cfg.VideoMap + if videoMap == "" { + videoMap = "0:v:0" + } + audioMap := cfg.AudioMap + if audioMap == "" { + audioMap = "0:a:0" + } + + args := []string{"-map", videoMap} + args = append(args, vcopy...) + args = append(args, + "-payload_type", fmt.Sprint(cfg.VideoPT), + "-f", "rtp", + fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort), + ) + + args = append(args, "-map", audioMap) + args = append(args, acopy...) + args = append(args, + "-payload_type", fmt.Sprint(cfg.AudioPT), + "-f", "rtp", + fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort+1), + ) + + return args +} diff --git a/app/webrtc/ffmpeg_args_test.go b/app/webrtc/ffmpeg_args_test.go new file mode 100644 index 0000000..6fec28d --- /dev/null +++ b/app/webrtc/ffmpeg_args_test.go @@ -0,0 +1,132 @@ +package webrtc + +import ( + "strings" + "testing" + + appcfg "github.com/datarhei/core/v16/restream/app" +) + +func TestBuildArgs_CopyCodecs(t *testing.T) { + cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111} + got := BuildArgs(cfg, 49200) + + // Must contain -c:v copy and -c:a copy when ForceTranscode is false. + if !contains(got, "-c:v", "copy") { + t.Fatalf("expected -c:v copy, got %v", got) + } + if !contains(got, "-c:a", "copy") { + t.Fatalf("expected -c:a copy, got %v", got) + } + + // Two UDP addresses, one per track, with port+1 for audio. + if !any(got, "udp://127.0.0.1:49200?") { + t.Fatalf("expected video udp on 49200, got %v", got) + } + if !any(got, "udp://127.0.0.1:49201?") { + t.Fatalf("expected audio udp on 49201, got %v", got) + } + + // Payload types must be stringified. + if !contains(got, "-payload_type", "102") { + t.Fatalf("expected video PT 102, got %v", got) + } + if !contains(got, "-payload_type", "111") { + t.Fatalf("expected audio PT 111, got %v", got) + } +} + +func TestBuildArgs_ForceTranscode(t *testing.T) { + cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111, ForceTranscode: true} + got := BuildArgs(cfg, 49200) + + if !contains(got, "-c:v", "libx264") { + t.Fatalf("expected -c:v libx264, got %v", got) + } + if !contains(got, "-profile:v", "baseline") { + t.Fatalf("expected baseline profile, got %v", got) + } + if !contains(got, "-c:a", "libopus") { + t.Fatalf("expected -c:a libopus, got %v", got) + } +} + +func TestBuildArgs_TwoTrackBoundary(t *testing.T) { + cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111} + got := BuildArgs(cfg, 49200) + + // The second `-map` marks the start of the audio leg — the split + // point restream.AppendOutput callers use. + mapCount := 0 + for _, a := range got { + if a == "-map" { + mapCount++ + } + } + if mapCount != 2 { + t.Fatalf("expected exactly 2 -map tokens, got %d in %v", mapCount, got) + } +} + +// contains reports whether the two-token sequence appears consecutively. +func contains(haystack []string, a, b string) bool { + for i := 0; i+1 < len(haystack); i++ { + if haystack[i] == a && haystack[i+1] == b { + return true + } + } + return false +} + +// any reports whether any element of haystack starts with prefix. +func any(haystack []string, prefix string) bool { + for _, h := range haystack { + if strings.HasPrefix(h, prefix) { + return true + } + } + return false +} + +// TestBuildArgs_DefaultMaps confirms 0:v:0 / 0:a:0 are emitted when +// VideoMap / AudioMap are empty (regression on the fix for issue #2 — +// the prior version had these as hardcoded literals; if VideoMap is +// ever empty unexpectedly, BuildArgs must still produce a working +// command line). +func TestBuildArgs_DefaultMaps(t *testing.T) { + cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111} + got := BuildArgs(cfg, 50000) + if !contains(got, "-map", "0:v:0") { + t.Fatalf("expected default video map 0:v:0, got %v", got) + } + if !contains(got, "-map", "0:a:0") { + t.Fatalf("expected default audio map 0:a:0, got %v", got) + } +} + +// TestBuildArgs_CustomMaps drives the issue-#2 fix: when the user +// configures a multi-input pipeline (audio on input #1, etc.), the +// emitted -map values must follow the user's choice rather than the +// "0:v:0"/"0:a:0" assumption. +func TestBuildArgs_CustomMaps(t *testing.T) { + cfg := appcfg.ConfigWebRTC{ + Enabled: true, + VideoPT: 102, + AudioPT: 111, + VideoMap: "0:v:1", + AudioMap: "1:a:0", + } + got := BuildArgs(cfg, 50000) + if !contains(got, "-map", "0:v:1") { + t.Fatalf("expected custom video map 0:v:1, got %v", got) + } + if !contains(got, "-map", "1:a:0") { + t.Fatalf("expected custom audio map 1:a:0, got %v", got) + } + // The default literals should NOT appear when overridden. + for _, opt := range got { + if opt == "0:v:0" || opt == "0:a:0" { + t.Errorf("expected no default maps in output, found %q in %v", opt, got) + } + } +} diff --git a/app/webrtc/handler.go b/app/webrtc/handler.go new file mode 100644 index 0000000..3b1fe5c --- /dev/null +++ b/app/webrtc/handler.go @@ -0,0 +1,387 @@ +package webrtc + +import ( + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + + "github.com/labstack/echo/v4" + "github.com/pion/webrtc/v4" + + corewebrtc "github.com/datarhei/core/v16/core/webrtc" +) + +// Default per-stream peer cap when the caller passes 0. The total cap +// (passed to NewHandler) is enforced separately and takes precedence. +const defaultMaxPeersPerStream = 8 + +// Handler exposes the subsystem's WHEP Echo handlers. Wire them into +// the /api/v3 group (or a sibling group) via Handler.Register. +// +// Lifecycle: peers are tracked in a streamID→resourceID→Peer index. +// On every Subscribe we spin a tiny goroutine watching the new peer's +// Done() channel; when ICE fails or Close() runs the index entry is +// removed and the counters tick back down — no leaks if the browser +// rage-quits. +type Handler struct { + sub *Subsystem + + mu sync.Mutex + peersByStream map[string]map[string]*corewebrtc.Peer // streamID -> resource -> peer + peerStream map[string]string // resource -> streamID (reverse index) + count int64 // atomic + maxCapTotal int64 + maxCapPerStrm int64 +} + +// NewHandler wraps the subsystem in an Echo-compatible HTTP handler. +// The maxPeers argument caps concurrent subscribers across all streams; +// pass 0 to use a generous default (matches corewebrtc.DefaultConfig). +// The per-stream cap is taken from the corewebrtc default; pass a +// non-zero value to override via NewHandlerWithCaps. +func NewHandler(s *Subsystem, maxPeers int) *Handler { + return NewHandlerWithCaps(s, maxPeers, 0) +} + +// NewHandlerWithCaps is NewHandler plus an explicit per-stream cap. +// maxPeersPerStream <= 0 falls back to defaultMaxPeersPerStream. +func NewHandlerWithCaps(s *Subsystem, maxPeers, maxPeersPerStream int) *Handler { + total := int64(maxPeers) + if total <= 0 { + total = int64(corewebrtc.DefaultConfig().MaxPeersTotal) + } + perStream := int64(maxPeersPerStream) + if perStream <= 0 { + perStream = defaultMaxPeersPerStream + } + h := &Handler{ + sub: s, + peersByStream: make(map[string]map[string]*corewebrtc.Peer), + peerStream: make(map[string]string), + maxCapTotal: total, + maxCapPerStrm: perStream, + } + // Subsystem broadcasts process-stop via this hook so the handler + // can yank stale peer entries before their Sources close out + // from underneath them. + if s != nil { + s.SetTeardownHook(h.tearDownStreamPeers) + } + return h +} + +// Register mounts the WHEP routes on the provided Echo group. +// +// CORS preflights are answered on every WHEP path; regular WHEP +// responses also carry the Access-Control-* headers so browser-side +// players living on a different origin can subscribe. +func (h *Handler) Register(g *echo.Group) { + g.OPTIONS("/whep/:id", h.preflight) + g.OPTIONS("/whep/:id/:resource", h.preflight) + g.POST("/whep/:id", h.Subscribe) + g.DELETE("/whep/:id/:resource", h.Unsubscribe) + g.PATCH("/whep/:id/:resource", h.Trickle) +} + +// Subscribe handles POST /whep/:id. Request body is an SDP offer, +// response is an SDP answer with a Location header pointing at the +// DELETE/PATCH resource. +// +// @Summary Subscribe to a WebRTC stream via WHEP +// @Description Subscribe to a process's WebRTC egress stream. Body is the SDP offer (Content-Type: application/sdp). Response is the SDP answer; the Location header points at the DELETE/PATCH resource for teardown and trickle ICE. +// @Tags v16.16.0 +// @ID webrtc-3-whep-subscribe +// @Accept application/sdp +// @Produce application/sdp +// @Param id path string true "Process ID with config.webrtc.enabled=true" +// @Success 201 {string} string "SDP answer" +// @Failure 400 {string} string "missing stream id, malformed body, or invalid SDP" +// @Failure 404 {string} string "no stream registered for this process id" +// @Failure 406 {string} string "offer SDP missing required H264 / Opus rtpmap" +// @Failure 503 {string} string "peer cap reached (per-stream or total)" +// @Failure 504 {string} string "ICE gathering timeout" +// @Security ApiKeyAuth +// @Router /api/v3/whep/{id} [post] +func (h *Handler) Subscribe(c echo.Context) error { + addCORS(c) + + id := c.Param("id") + if id == "" { + return c.String(http.StatusBadRequest, "missing stream id") + } + + // Total cap: cheap atomic check before doing real work. + if atomic.LoadInt64(&h.count) >= h.maxCapTotal { + return c.String(http.StatusServiceUnavailable, corewebrtc.ErrPeerCapReached.Error()) + } + + stream, ok := h.sub.lookup(id) + if !ok { + return c.String(http.StatusNotFound, corewebrtc.ErrStreamNotFound.Error()) + } + + // Per-stream cap: needs the lock since we're indexing per stream. + h.mu.Lock() + if int64(len(h.peersByStream[id])) >= h.maxCapPerStrm { + h.mu.Unlock() + return c.String(http.StatusServiceUnavailable, "webrtc: per-stream peer cap reached") + } + h.mu.Unlock() + + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return c.String(http.StatusBadRequest, "read body: "+err.Error()) + } + if len(body) == 0 || !strings.HasPrefix(string(body), "v=") { + return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error()) + } + if err := requireH264AndOpus(string(body)); err != nil { + return c.String(http.StatusNotAcceptable, err.Error()) + } + + offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} + peer, err := h.sub.factory.CreatePeerFromSources(c.Request().Context(), stream.video, stream.audio, offer) + if err != nil { + // Surface the design's error matrix. + switch err { + case corewebrtc.ErrICETimeout: + return c.String(http.StatusGatewayTimeout, err.Error()) + case corewebrtc.ErrCodecMismatch: + return c.String(http.StatusNotAcceptable, err.Error()) + default: + return c.String(http.StatusInternalServerError, "create peer: "+err.Error()) + } + } + + rid := peer.ResourceID() + h.mu.Lock() + if h.peersByStream[id] == nil { + h.peersByStream[id] = make(map[string]*corewebrtc.Peer) + } + h.peersByStream[id][rid] = peer + h.peerStream[rid] = id + h.mu.Unlock() + atomic.AddInt64(&h.count, 1) + + // Auto-cleanup: when Pion's OnConnectionStateChange triggers + // peer.Close() (ICE failed/disconnected), the Done channel + // closes and we yank the index entry. Without this the map + // leaks for the lifetime of the handler. + go h.awaitPeerClose(rid, peer) + + c.Response().Header().Set("Content-Type", "application/sdp") + c.Response().Header().Set("Location", "/whep/"+id+"/"+rid) + c.Response().Header().Set("ETag", `"`+rid+`"`) + return c.String(http.StatusCreated, peer.Answer().SDP) +} + +// Unsubscribe handles DELETE /whep/:id/:resource. Per WHEP spec we +// return 204 even when the resource is unknown — DELETE is idempotent +// and a re-issued tear-down should never error out. +// +// @Summary Tear down a WHEP subscription +// @Description Idempotent peer teardown by resource id (returned in the Location header by Subscribe). Returns 204 even when the resource is unknown, per the WHEP spec. +// @Tags v16.16.0 +// @ID webrtc-3-whep-unsubscribe +// @Param id path string true "Process ID" +// @Param resource path string true "Resource ID from the Subscribe Location header" +// @Success 204 "no content" +// @Failure 400 {string} string "missing resource id" +// @Security ApiKeyAuth +// @Router /api/v3/whep/{id}/{resource} [delete] +func (h *Handler) Unsubscribe(c echo.Context) error { + addCORS(c) + + resource := c.Param("resource") + if resource == "" { + return c.String(http.StatusBadRequest, "missing resource id") + } + + h.mu.Lock() + streamID := h.peerStream[resource] + var peer *corewebrtc.Peer + if streamID != "" { + peer = h.peersByStream[streamID][resource] + delete(h.peersByStream[streamID], resource) + if len(h.peersByStream[streamID]) == 0 { + delete(h.peersByStream, streamID) + } + delete(h.peerStream, resource) + } + h.mu.Unlock() + + if peer != nil { + _ = peer.Close() + } + if streamID != "" { + atomic.AddInt64(&h.count, -1) + } + return c.NoContent(http.StatusNoContent) +} + +// Trickle handles PATCH /whep/:id/:resource — adds ICE candidates +// from a trickle-ice-sdpfrag body. Empty body is a no-op (clients +// signal end-of-candidates via an a=end-of-candidates line, which +// AddICECandidate accepts). +// +// @Summary Trickle ICE candidates for a WHEP subscription +// @Description Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag. +// @Tags v16.16.0 +// @ID webrtc-3-whep-trickle +// @Accept application/trickle-ice-sdpfrag +// @Param id path string true "Process ID" +// @Param resource path string true "Resource ID from the Subscribe Location header" +// @Success 204 "no content" +// @Failure 400 {string} string "missing resource id or unreadable body" +// @Failure 404 {string} string "peer not found" +// @Security ApiKeyAuth +// @Router /api/v3/whep/{id}/{resource} [patch] +func (h *Handler) Trickle(c echo.Context) error { + addCORS(c) + + resource := c.Param("resource") + if resource == "" { + return c.String(http.StatusBadRequest, "missing resource id") + } + + h.mu.Lock() + streamID := h.peerStream[resource] + var peer *corewebrtc.Peer + if streamID != "" { + peer = h.peersByStream[streamID][resource] + } + h.mu.Unlock() + if peer == nil { + return c.NoContent(http.StatusNotFound) + } + + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return c.String(http.StatusBadRequest, "read body: "+err.Error()) + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "a=candidate:") { + continue + } + cand := strings.TrimPrefix(line, "a=") + _ = peer.AddICECandidate(webrtc.ICECandidateInit{Candidate: cand}) + } + return c.NoContent(http.StatusNoContent) +} + +// preflight answers a CORS OPTIONS request; the headers are also +// echoed on every other response. +func (h *Handler) preflight(c echo.Context) error { + addCORS(c) + return c.NoContent(http.StatusNoContent) +} + +// Close tears down every active peer (e.g., during Core shutdown). +func (h *Handler) Close() { + h.mu.Lock() + peers := make([]*corewebrtc.Peer, 0) + for _, m := range h.peersByStream { + for _, p := range m { + peers = append(peers, p) + } + } + h.peersByStream = make(map[string]map[string]*corewebrtc.Peer) + h.peerStream = make(map[string]string) + h.mu.Unlock() + + for _, p := range peers { + if p != nil { + _ = p.Close() + } + } + atomic.StoreInt64(&h.count, 0) +} + +// awaitPeerClose blocks on peer.Done() and yanks the index entry when +// the peer self-closes (ICE failed/disconnected). Idempotent with +// the Unsubscribe path: if Unsubscribe ran first the index is already +// empty and we just decrement the counter once on first arrival. +func (h *Handler) awaitPeerClose(resource string, peer *corewebrtc.Peer) { + <-peer.Done() + h.mu.Lock() + streamID := h.peerStream[resource] + _, present := h.peerStream[resource] + if present { + delete(h.peerStream, resource) + if streamID != "" { + delete(h.peersByStream[streamID], resource) + if len(h.peersByStream[streamID]) == 0 { + delete(h.peersByStream, streamID) + } + } + } + h.mu.Unlock() + if present { + atomic.AddInt64(&h.count, -1) + } +} + +// tearDownStreamPeers is the callback the Subsystem runs in its +// onProcessStop hook. It closes every peer subscribed to that +// stream (driving each one's Done() and indirectly awaitPeerClose). +func (h *Handler) tearDownStreamPeers(streamID string) { + h.mu.Lock() + bucket := h.peersByStream[streamID] + peers := make([]*corewebrtc.Peer, 0, len(bucket)) + for _, p := range bucket { + peers = append(peers, p) + } + h.mu.Unlock() + + for _, p := range peers { + if p != nil { + _ = p.Close() + } + } +} + +// addCORS emits the response headers a browser-side WHEP player +// expects. WHEP's Location and ETag headers must be exposed for +// fetch() to read them across origins. +func addCORS(c echo.Context) { + hh := c.Response().Header() + hh.Set("Access-Control-Allow-Origin", "*") + hh.Set("Access-Control-Allow-Methods", "POST, DELETE, PATCH, OPTIONS") + hh.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, If-Match, If-None-Match") + hh.Set("Access-Control-Expose-Headers", "Location, ETag") +} + +// requireH264AndOpus does a coarse SDP scan to confirm the offer +// includes both an H.264 video rtpmap and an Opus audio rtpmap. The +// design treats codec mismatch as a 406, never a silent black frame. +// +// This is intentionally a string scan rather than a full SDP parse: +// every modern browser advertises H.264 and Opus by name, and a +// dependency on a real SDP parser for one validation step is +// disproportionate. M4 may swap this for pion/sdp.v3 when other +// surfaces also need parsing. +func requireH264AndOpus(sdp string) error { + lower := strings.ToLower(sdp) + hasH264 := strings.Contains(lower, "h264/90000") || strings.Contains(lower, " h264/") + hasOpus := strings.Contains(lower, "opus/48000") || strings.Contains(lower, " opus/") + if hasH264 && hasOpus { + return nil + } + missing := []string{} + if !hasH264 { + missing = append(missing, "H264") + } + if !hasOpus { + missing = append(missing, "Opus") + } + return &codecMismatchError{missing: missing} +} + +type codecMismatchError struct{ missing []string } + +func (e *codecMismatchError) Error() string { + return "webrtc: codec mismatch — offer is missing: " + strings.Join(e.missing, ", ") +} diff --git a/app/webrtc/handler_m3_test.go b/app/webrtc/handler_m3_test.go new file mode 100644 index 0000000..393e318 --- /dev/null +++ b/app/webrtc/handler_m3_test.go @@ -0,0 +1,251 @@ +package webrtc + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/labstack/echo/v4" + + corewebrtc "github.com/datarhei/core/v16/core/webrtc" +) + +// minimalH264OpusOffer returns an SDP offer that includes both H264 +// and Opus rtpmap lines — passes requireH264AndOpus but is otherwise +// nonsense, so CreatePeerFromSources will fail downstream when this +// is wired through. Use it only in tests that don't reach the +// PeerConnection path. +func minimalH264OpusOffer() string { + return "v=0\r\n" + + "o=- 0 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 102\r\n" + + "a=rtpmap:102 H264/90000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" +} + +// nonH264Offer is missing H264 entirely. Triggers requireH264AndOpus. +func nonH264Offer() string { + return "v=0\r\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + "a=rtpmap:96 VP8/90000\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" +} + +// TestHandler_Subscribe_406OnCodecMismatch verifies an offer that +// doesn't include H264 yields 406, per the design's error matrix. +func TestHandler_Subscribe_406OnCodecMismatch(t *testing.T) { + sub := newTestSubsystem(t) + sub.mu.Lock() + sub.streams["s"] = &processStream{id: "s"} + sub.mu.Unlock() + h := NewHandler(sub, 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(nonH264Offer())) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("s") + + if err := h.Subscribe(c); err != nil { + t.Fatalf("Subscribe: %v", err) + } + if rec.Code != http.StatusNotAcceptable { + t.Fatalf("expected 406, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "H264") { + t.Errorf("body should mention missing codec: %q", rec.Body.String()) + } +} + +// TestHandler_Subscribe_503OnTotalCap simulates the total cap being +// exhausted by another subscriber. We don't actually create real peers +// (would need a real PeerConnection); instead we pre-load the atomic +// counter so the cap check fires. +func TestHandler_Subscribe_503OnTotalCap(t *testing.T) { + sub := newTestSubsystem(t) + sub.mu.Lock() + sub.streams["s"] = &processStream{id: "s"} + sub.mu.Unlock() + h := NewHandlerWithCaps(sub, 1, 100) + atomic.StoreInt64(&h.count, 1) // simulate one in-flight peer + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer())) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("s") + _ = h.Subscribe(c) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), corewebrtc.ErrPeerCapReached.Error()) { + t.Errorf("body should mention peer cap: %q", rec.Body.String()) + } +} + +// TestHandler_Subscribe_503OnPerStreamCap simulates the per-stream cap +// being exhausted. Same trick as above but populating the per-stream +// index directly. +func TestHandler_Subscribe_503OnPerStreamCap(t *testing.T) { + sub := newTestSubsystem(t) + sub.mu.Lock() + sub.streams["s"] = &processStream{id: "s"} + sub.mu.Unlock() + h := NewHandlerWithCaps(sub, 100, 1) + // Drop a placeholder peer into the per-stream bucket so the cap + // arithmetic trips on the next subscribe. + h.mu.Lock() + h.peersByStream["s"] = map[string]*corewebrtc.Peer{"existing": nil} + h.mu.Unlock() + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer())) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("s") + _ = h.Subscribe(c) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "per-stream") { + t.Errorf("body should mention per-stream cap: %q", rec.Body.String()) + } +} + +// TestHandler_Trickle_404WhenUnknown verifies a PATCH for an unknown +// resource returns 404 (we still treat the resource as authoritative +// here; only DELETE is idempotent per spec). +func TestHandler_Trickle_404WhenUnknown(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodPatch, "/whep/id/unknown", strings.NewReader("")) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "resource") + c.SetParamValues("id", "unknown") + + if err := h.Trickle(c); err != nil { + t.Fatalf("Trickle: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +// TestHandler_PreflightCORS verifies OPTIONS returns 204 with the +// browser-friendly CORS headers. +func TestHandler_PreflightCORS(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodOptions, "/whep/x", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("x") + + if err := h.preflight(c); err != nil { + t.Fatalf("preflight: %v", err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rec.Code) + } + hh := rec.Header() + for _, k := range []string{ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Expose-Headers", + } { + if hh.Get(k) == "" { + t.Errorf("missing CORS header %q", k) + } + } +} + +// TestHandler_RegisterMountsAllRoutes is a sanity check that +// Handler.Register installs OPTIONS / POST / DELETE / PATCH on the +// expected paths. Echo's Group has no public route enumerator, so we +// dispatch synthetic requests and assert the right methods are +// reachable. +func TestHandler_RegisterMountsAllRoutes(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + e := echo.New() + g := e.Group("") + h.Register(g) + + cases := []struct { + method, path string + want int + }{ + {http.MethodOptions, "/whep/foo", http.StatusNoContent}, + {http.MethodOptions, "/whep/foo/bar", http.StatusNoContent}, + {http.MethodPost, "/whep/foo", http.StatusNotFound}, // stream missing -> 404 + {http.MethodDelete, "/whep/foo/bar", http.StatusNoContent}, + {http.MethodPatch, "/whep/foo/bar", http.StatusNotFound}, + } + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader("")) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Errorf("%s %s: got %d want %d (%s)", tc.method, tc.path, rec.Code, tc.want, rec.Body.String()) + } + } +} + +// TestHandler_Close_DrainsPeers seeds a fake peer into the index and +// verifies Close clears it without panicking. +func TestHandler_Close_DrainsPeers(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + h.mu.Lock() + h.peersByStream["s"] = map[string]*corewebrtc.Peer{"r1": nil} + h.peerStream["r1"] = "s" + atomic.StoreInt64(&h.count, 1) + h.mu.Unlock() + + h.Close() + if got := atomic.LoadInt64(&h.count); got != 0 { + t.Errorf("count after Close = %d, want 0", got) + } + h.mu.Lock() + if len(h.peersByStream) != 0 || len(h.peerStream) != 0 { + t.Errorf("indexes not cleared") + } + h.mu.Unlock() +} + +// TestRequireH264AndOpus covers the SDP scanner's positive + +// negative cases. +func TestRequireH264AndOpus(t *testing.T) { + cases := []struct { + name string + sdp string + ok bool + }{ + {"both", minimalH264OpusOffer(), true}, + {"missing h264", nonH264Offer(), false}, + {"missing opus", "m=video 9 UDP/TLS/RTP/SAVPF 102\r\na=rtpmap:102 H264/90000\r\n", false}, + {"capitalized", "a=rtpmap:111 OPUS/48000\r\na=rtpmap:102 H264/90000", true}, + {"empty", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := requireH264AndOpus(c.sdp) + if c.ok && err != nil { + t.Errorf("expected ok, got %v", err) + } + if !c.ok && err == nil { + t.Errorf("expected error") + } + }) + } +} diff --git a/app/webrtc/handler_test.go b/app/webrtc/handler_test.go new file mode 100644 index 0000000..b36dfd6 --- /dev/null +++ b/app/webrtc/handler_test.go @@ -0,0 +1,91 @@ +package webrtc + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + + "github.com/datarhei/core/v16/config" +) + +func newTestSubsystem(t *testing.T) *Subsystem { + t.Helper() + s, err := New(config.DataWebRTC{Enable: true}, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +// TestHandler_Subscribe_404WhenStreamMissing verifies the WHEP POST +// returns 404 when no process has registered a stream for that id. +func TestHandler_Subscribe_404WhenStreamMissing(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/whep/ghost", strings.NewReader("v=0\r\n")) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("ghost") + + if err := h.Subscribe(c); err != nil { + t.Fatalf("Subscribe returned error: %v", err) + } + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// TestHandler_Subscribe_400OnEmptyBody verifies invalid SDP offers +// short-circuit before any peer is created. Requires a registered +// stream so lookup doesn't 404 first. +func TestHandler_Subscribe_400OnEmptyBody(t *testing.T) { + sub := newTestSubsystem(t) + // Register a dummy stream so the handler reaches body validation. + sub.mu.Lock() + sub.streams["probe"] = &processStream{id: "probe"} // video/audio nil is fine here — we never get past body parse + sub.mu.Unlock() + + h := NewHandler(sub, 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/whep/probe", strings.NewReader("")) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("probe") + + if err := h.Subscribe(c); err != nil { + t.Fatalf("Subscribe returned error: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// TestHandler_Unsubscribe_204WhenUnknown verifies a DELETE with an +// unknown resource id returns 204 (idempotent), per the WHEP spec +// and the M2/M3 design's error matrix. Pre-M3 this returned 404; the +// updated semantics let clients re-issue DELETE without erroring. +func TestHandler_Unsubscribe_204WhenUnknown(t *testing.T) { + h := NewHandler(newTestSubsystem(t), 0) + + e := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/whep/id/unknown", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "resource") + c.SetParamValues("id", "unknown") + + if err := h.Unsubscribe(c); err != nil { + t.Fatalf("Unsubscribe returned error: %v", err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rec.Code) + } +} diff --git a/app/webrtc/integration_test.go b/app/webrtc/integration_test.go new file mode 100644 index 0000000..e9573f2 --- /dev/null +++ b/app/webrtc/integration_test.go @@ -0,0 +1,275 @@ +package webrtc + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/pion/rtp" + pionwebrtc "github.com/pion/webrtc/v4" + + "github.com/datarhei/core/v16/config" + appcfg "github.com/datarhei/core/v16/restream/app" +) + +// TestIntegration_SyntheticRTPToWHEP wires the full M2 subsystem end to +// end using in-process UDP sockets and a Pion WHEP subscriber: +// +// 1. Build a Subsystem and Handler (no Core/HTTP server needed). +// 2. Fire the OnStart hook directly — this allocates two adjacent +// loopback UDP ports and registers a process stream. +// 3. Extract the allocated video + audio ports from the returned +// ConfigIO legs. +// 4. Build a Pion PeerConnection (recvonly video + audio) and POST its +// SDP offer through the Echo Handler. +// 5. Plumb the returned answer into the PC. +// 6. Spray synthetic RTP packets at both UDP ports. +// 7. Assert that the PC sees OnTrack for both kinds and at least one +// RTP packet arrives on each track inside the timeout budget. +// +// This is the single highest-leverage integration test for M2 — it +// catches the whole stack: port allocation, hook contract, two-track +// forwarding, WHEP handshake, and JWT-mounted routing doesn't interfere +// with the handler's internal flow. +func TestIntegration_SyntheticRTPToWHEP(t *testing.T) { + // --- 1. Construct subsystem + handler. --- + sub, err := New(config.DataWebRTC{Enable: true}, nil) + if err != nil { + t.Fatalf("subsystem New: %v", err) + } + defer sub.Close() + + h := NewHandler(sub, 0) + defer h.Close() + + // --- 2. Fire OnStart directly to populate the stream registry + // and allocate ports. We bypass the restream manager by + // invoking the hook the subsystem would have registered. + processID := "integration-probe" + legs, err := sub.onProcessStart(processID, &appcfg.Config{ + ID: processID, + WebRTC: appcfg.ConfigWebRTC{ + Enabled: true, + VideoPT: 102, + AudioPT: 111, + }, + }) + if err != nil { + t.Fatalf("onProcessStart: %v", err) + } + if len(legs) != 2 { + t.Fatalf("expected 2 output legs, got %d", len(legs)) + } + defer sub.onProcessStop(processID) + + // --- 3. Extract UDP ports from leg addresses. --- + videoPort, err := portFromLegAddress(legs[0].Address) + if err != nil { + t.Fatalf("video leg address %q: %v", legs[0].Address, err) + } + audioPort, err := portFromLegAddress(legs[1].Address) + if err != nil { + t.Fatalf("audio leg address %q: %v", legs[1].Address, err) + } + if audioPort != videoPort+1 { + t.Fatalf("expected adjacent ports, got video=%d audio=%d", videoPort, audioPort) + } + + // --- 4. Mount the handler in an Echo server (httptest) so we + // exercise the real route registration path. --- + e := echo.New() + g := e.Group("") + h.Register(g) + srv := httptest.NewServer(e) + defer srv.Close() + + // --- 5. Build the WHEP subscriber PeerConnection. --- + me := &pionwebrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + t.Fatalf("register default codecs: %v", err) + } + api := pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me)) + pc, err := api.NewPeerConnection(pionwebrtc.Configuration{}) + if err != nil { + t.Fatalf("new PC: %v", err) + } + defer pc.Close() + + if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("add video transceiver: %v", err) + } + if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("add audio transceiver: %v", err) + } + + // Signal when each track has produced its first RTP packet. + var videoGot, audioGot atomic.Bool + videoCh := make(chan struct{}, 1) + audioCh := make(chan struct{}, 1) + + pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) { + // Read a single RTP packet and signal the appropriate channel. + go func() { + if _, _, readErr := tr.ReadRTP(); readErr != nil { + return + } + switch tr.Kind() { + case pionwebrtc.RTPCodecTypeVideo: + if videoGot.CompareAndSwap(false, true) { + videoCh <- struct{}{} + } + case pionwebrtc.RTPCodecTypeAudio: + if audioGot.CompareAndSwap(false, true) { + audioCh <- struct{}{} + } + } + }() + }) + + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatalf("create offer: %v", err) + } + gatherLocal := pionwebrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + t.Fatalf("set local: %v", err) + } + select { + case <-gatherLocal: + case <-time.After(5 * time.Second): + t.Fatalf("local ICE gathering timeout") + } + offerSDP := pc.LocalDescription().SDP + + // --- 6. POST the offer to the WHEP endpoint. --- + resp, err := http.Post(srv.URL+"/whep/"+processID, "application/sdp", + strings.NewReader(offerSDP)) + if err != nil { + t.Fatalf("POST /whep: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("POST /whep status = %d, want 201", resp.StatusCode) + } + + answerBuf := make([]byte, 1<<15) + n, _ := resp.Body.Read(answerBuf) + answerSDP := string(answerBuf[:n]) + if !strings.Contains(answerSDP, "v=0") { + t.Fatalf("answer SDP malformed: %q", answerSDP) + } + + loc := resp.Header.Get("Location") + if loc == "" || !strings.HasPrefix(loc, "/whep/"+processID+"/") { + t.Fatalf("Location header bad: %q", loc) + } + + if err := pc.SetRemoteDescription(pionwebrtc.SessionDescription{ + Type: pionwebrtc.SDPTypeAnswer, + SDP: answerSDP, + }); err != nil { + t.Fatalf("set remote: %v", err) + } + + // --- 7. Spray synthetic RTP into both UDP ports. --- + videoSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort)) + if err != nil { + t.Fatalf("dial video: %v", err) + } + defer videoSender.Close() + audioSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(audioPort)) + if err != nil { + t.Fatalf("dial audio: %v", err) + } + defer audioSender.Close() + + stopSend := make(chan struct{}) + defer close(stopSend) + go func() { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + var vseq, aseq uint16 + for { + select { + case <-stopSend: + return + case <-ticker.C: + vseq++ + aseq++ + vpkt := synthRTPPacket(102, vseq, uint32(vseq)*3000, 0xcafe0000, []byte("vvvvvvvv")) + _, _ = videoSender.Write(vpkt) + apkt := synthRTPPacket(111, aseq, uint32(aseq)*960, 0xbeef0000, []byte("aaaaaaaa")) + _, _ = audioSender.Write(apkt) + } + } + }() + + // --- 8. Wait for both tracks' first packet. --- + waitFor := func(name string, ch chan struct{}) { + select { + case <-ch: + // success + case <-time.After(10 * time.Second): + t.Fatalf("%s: no RTP received via WHEP within 10s", name) + } + } + waitFor("video", videoCh) + waitFor("audio", audioCh) + + // Sanity: the Location path should DELETE cleanly. + parsedLoc, err := url.Parse(loc) + if err != nil { + t.Fatalf("parse Location: %v", err) + } + deleteReq, _ := http.NewRequest(http.MethodDelete, srv.URL+parsedLoc.Path, nil) + delResp, err := http.DefaultClient.Do(deleteReq) + if err != nil { + t.Fatalf("DELETE /whep/.../resource: %v", err) + } + _ = delResp.Body.Close() + if delResp.StatusCode != http.StatusNoContent { + t.Fatalf("DELETE status = %d, want 204", delResp.StatusCode) + } +} + +// portFromLegAddress pulls the UDP port out of a leg Address like +// "udp://127.0.0.1:49200?pkt_size=1316". +func portFromLegAddress(addr string) (int, error) { + re := regexp.MustCompile(`udp://[^:]+:(\d+)`) + m := re.FindStringSubmatch(addr) + if len(m) != 2 { + return 0, &portParseError{addr: addr} + } + return strconv.Atoi(m[1]) +} + +type portParseError struct{ addr string } + +func (e *portParseError) Error() string { return "cannot parse port from " + e.addr } + +// synthRTPPacket builds a minimal valid RTP packet for injection testing. +func synthRTPPacket(pt uint8, seq uint16, ts uint32, ssrc uint32, payload []byte) []byte { + p := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: pt, + SequenceNumber: seq, + Timestamp: ts, + SSRC: ssrc, + Marker: false, + }, + Payload: payload, + } + b, _ := p.Marshal() + return b +} diff --git a/app/webrtc/latency_test.go b/app/webrtc/latency_test.go new file mode 100644 index 0000000..835651b --- /dev/null +++ b/app/webrtc/latency_test.go @@ -0,0 +1,289 @@ +//go:build latency +// +build latency + +package webrtc + +// Server-hop latency benchmark. Build-tagged off the default test +// suite because it's a load test, not a unit test: +// +// go test -tags latency -timeout 60s -count=1 ./app/webrtc/... \ +// -run TestLatencyServerHop -v +// +// What this measures +// ------------------- +// RTP packet arrival latency end-to-end through the Core WebRTC +// egress path: +// +// publisher (this test) ── UDP ──▶ corewebrtc.Source +// │ +// ▼ subscriber fan-out +// Peer ── ICE+SRTP ──▶ Pion subscriber +// │ +// ▼ ReadRTP +// +// What it does NOT measure (and why) +// ---------------------------------- +// The design (docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md +// §7) calls for true glass-to-glass latency: publisher embeds a frame +// counter via FFmpeg drawtext, subscriber decodes H.264 and samples a +// pixel bounding box, diff is the e2e number. Implementing that in +// pure Go would require a cgo H.264 decoder or an FFmpeg-as-sidecar +// pipe. Both are heavier than the ~150 LOC this test costs and add a +// dependency that doesn't pay off for the dominant CI question +// ("did anybody regress the server hop?"). Encode/decode latency +// is roughly fixed by the codec stack and isn't something Core code +// changes can move. +// +// We sidestep the decoder by embedding a wall-clock timestamp in the +// RTP packet payload (first 8 bytes, big-endian UnixNano). The +// subscriber reads it via track.ReadRTP() and diffs against time.Now() +// at arrival. This gives us a true server-hop measurement that +// exercises: +// +// - Source.readLoop unmarshalling +// - Source.subscribers fan-out +// - forwardRTPSplit goroutine +// - Pion's TrackLocalStaticRTP.WriteRTP +// - DTLS-SRTP encrypt +// - ICE socket write +// - DTLS-SRTP decrypt at the subscriber +// - subscriber TrackRemote.ReadRTP unmarshal +// +// Threshold +// --------- +// p95 < 50ms on a quiet Linux host (loopback + Pion). The CI runner +// is shared so we set the gate at 200ms — generous, but a regression +// that crosses it indicates a genuine slowdown rather than runner +// noise. + +import ( + "encoding/binary" + "net" + "net/http" + "net/http/httptest" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/pion/rtp" + pionwebrtc "github.com/pion/webrtc/v4" + + "github.com/datarhei/core/v16/config" + appcfg "github.com/datarhei/core/v16/restream/app" +) + +const ( + latencyPackets = 1000 + latencyRateHz = 60 + latencyP95Budget = 50 * time.Millisecond // CI gate; p95 is sub-ms locally +) + +func TestLatencyServerHop(t *testing.T) { + sub, err := New(config.DataWebRTC{Enable: true}, nil) + if err != nil { + t.Fatalf("subsystem New: %v", err) + } + defer sub.Close() + + h := NewHandler(sub, 0) + defer h.Close() + + processID := "latency-probe" + legs, err := sub.onProcessStart(processID, &appcfg.Config{ + ID: processID, + WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}, + }) + if err != nil { + t.Fatalf("onProcessStart: %v", err) + } + defer sub.onProcessStop(processID) + + videoPort, err := portFromLegAddress(legs[0].Address) + if err != nil { + t.Fatalf("video port: %v", err) + } + + e := echo.New() + g := e.Group("") + h.Register(g) + srv := httptest.NewServer(e) + defer srv.Close() + + pc, samples := buildSubscriber(t, srv.URL, processID) + defer pc.Close() + + // Sender: synthetic RTP packets with UnixNano in the first 8 bytes + // of payload. We only stream video (latency on audio is identical + // in this path). + conn, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort)) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + tick := time.NewTicker(time.Second / latencyRateHz) + defer tick.Stop() + var seq uint16 + for i := 0; i < latencyPackets; i++ { + <-tick.C + seq++ + payload := make([]byte, 200) + binary.BigEndian.PutUint64(payload, uint64(time.Now().UnixNano())) + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: 102, + SequenceNumber: seq, + Timestamp: uint32(seq) * 3000, + SSRC: 0xdeadbeef, + }, + Payload: payload, + } + b, _ := pkt.Marshal() + _, _ = conn.Write(b) + } + + // Wait for the receiver to drain — give it 2× the send window. + deadline := time.After(time.Duration(latencyPackets*2) * time.Second / latencyRateHz) + for { + if int(samples.Load()) >= latencyPackets-50 { + break // 5% tolerance for in-flight loss; loopback rarely loses + } + select { + case <-deadline: + break + case <-time.After(10 * time.Millisecond): + continue + } + break + } + + got := samples.Drain() + if len(got) < latencyPackets/2 { + t.Fatalf("only %d/%d samples received — too lossy to gate", len(got), latencyPackets) + } + p50, p95, p99 := percentile(got, 50), percentile(got, 95), percentile(got, 99) + t.Logf("latency over %d samples: p50=%v p95=%v p99=%v", + len(got), p50, p95, p99) + + if p95 > latencyP95Budget { + t.Fatalf("p95 latency %v exceeds budget %v (%d samples)", + p95, latencyP95Budget, len(got)) + } +} + +// latencySamples is a goroutine-safe append-only sample buffer. The +// receiver goroutine appends; the test goroutine reads via Drain +// after the run completes. +type latencySamples struct { + mu sync.Mutex + samples []time.Duration + count atomic.Int32 +} + +func (s *latencySamples) Add(d time.Duration) { + s.mu.Lock() + s.samples = append(s.samples, d) + s.mu.Unlock() + s.count.Add(1) +} +func (s *latencySamples) Load() int32 { return s.count.Load() } +func (s *latencySamples) Drain() []time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]time.Duration, len(s.samples)) + copy(out, s.samples) + return out +} + +// buildSubscriber spins up a Pion peer, performs the WHEP handshake, +// returns a samples buffer that latencyArrival fills as packets land. +func buildSubscriber(t *testing.T, srvURL, processID string) (*pionwebrtc.PeerConnection, *latencySamples) { + t.Helper() + me := &pionwebrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + t.Fatalf("register codecs: %v", err) + } + api := pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me)) + pc, err := api.NewPeerConnection(pionwebrtc.Configuration{}) + if err != nil { + t.Fatalf("new PC: %v", err) + } + if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("add video tx: %v", err) + } + if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("add audio tx: %v", err) + } + + samples := &latencySamples{} + pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) { + if tr.Kind() != pionwebrtc.RTPCodecTypeVideo { + return + } + go func() { + for { + p, _, err := tr.ReadRTP() + if err != nil { + return + } + if len(p.Payload) < 8 { + continue + } + sentNs := int64(binary.BigEndian.Uint64(p.Payload[:8])) + samples.Add(time.Duration(time.Now().UnixNano() - sentNs)) + } + }() + }) + + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatalf("offer: %v", err) + } + gather := pionwebrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + t.Fatalf("set local: %v", err) + } + <-gather + + resp, err := http.Post(srvURL+"/whep/"+processID, "application/sdp", + strings.NewReader(pc.LocalDescription().SDP)) + if err != nil { + t.Fatalf("POST /whep: %v", err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("WHEP status = %d", resp.StatusCode) + } + buf := make([]byte, 1<<15) + n, _ := resp.Body.Read(buf) + resp.Body.Close() + if err := pc.SetRemoteDescription(pionwebrtc.SessionDescription{ + Type: pionwebrtc.SDPTypeAnswer, + SDP: string(buf[:n]), + }); err != nil { + t.Fatalf("set remote: %v", err) + } + // Give ICE a moment to settle before the publisher fires. + time.Sleep(500 * time.Millisecond) + return pc, samples +} + +func percentile(samples []time.Duration, p int) time.Duration { + if len(samples) == 0 { + return 0 + } + sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] }) + idx := (p * len(samples)) / 100 + if idx >= len(samples) { + idx = len(samples) - 1 + } + return samples[idx] +} + diff --git a/app/webrtc/lifecycle.go b/app/webrtc/lifecycle.go new file mode 100644 index 0000000..2583d09 --- /dev/null +++ b/app/webrtc/lifecycle.go @@ -0,0 +1,202 @@ +package webrtc + +import ( + "fmt" + + corewebrtc "github.com/datarhei/core/v16/core/webrtc" + appcfg "github.com/datarhei/core/v16/restream/app" +) + +// Default payload types. These match the values the M1 PoC / M2 +// forwarder expects (H.264 = 102, Opus = 111). Operators can override +// per-process via the restream Config. +const ( + defaultVideoPT = 102 + defaultAudioPT = 111 +) + +// allocAttempts is the maximum number of times onProcessStart will +// retry port allocation to find two adjacent free loopback UDP ports. +// The kernel sometimes hands us an odd port for video, making V+1 +// unavailable — in practice 2-3 retries is plenty. +const allocAttempts = 10 + +// onProcessStart is registered as the restream ProcessStartHook. It +// fires with the restream write lock held, just before FFmpeg Start. +// +// When the per-process WebRTC config is disabled, it returns (nil, nil) +// — FFmpeg starts normally without any extra output legs. When enabled +// it: +// +// 1. Allocates two adjacent loopback UDP ports (video on V, audio on V+1). +// 2. Binds Pion Sources on those ports and registers the pair under +// the process ID. +// 3. Builds the two RTP ConfigIO output legs via BuildArgs and returns +// them to the restream manager, which appends them to cfg.Output +// and rebuilds the FFmpeg command. +// +// Any error aborts the process start. On partial allocation failure, +// all allocated resources are cleaned up before returning. +func (s *Subsystem) onProcessStart(id string, cfg *appcfg.Config) ([]appcfg.ConfigIO, error) { + if cfg == nil || !cfg.WebRTC.Enabled { + return nil, nil + } + + // Normalize PTs — zero values mean "use defaults". + wcfg := cfg.WebRTC + if wcfg.VideoPT == 0 { + wcfg.VideoPT = defaultVideoPT + } + if wcfg.AudioPT == 0 { + wcfg.AudioPT = defaultAudioPT + } + + // Refuse to re-register — the restream manager should never + // double-start a process but defensive check avoids a silent + // Source leak if it does. + s.mu.Lock() + if _, exists := s.streams[id]; exists { + s.mu.Unlock() + return nil, fmt.Errorf("webrtc: process %q already has an active stream", id) + } + s.mu.Unlock() + + videoPort, videoSrc, audioSrc, err := s.allocAdjacentPair(id) + if err != nil { + return nil, err + } + + // Start the UDP readers so they're draining packets the moment + // FFmpeg comes online. + videoSrc.Start() + audioSrc.Start() + + s.mu.Lock() + s.streams[id] = &processStream{id: id, video: videoSrc, audio: audioSrc} + s.mu.Unlock() + + s.logger.WithFields(map[string]interface{}{ + "id": id, + "video_port": videoPort, + "audio_port": videoPort + 1, + "video_pt": wcfg.VideoPT, + "audio_pt": wcfg.AudioPT, + }).Info().Log("WebRTC egress registered for process") + + args := BuildArgs(wcfg, videoPort) + return splitRTPLegs(args), nil +} + +// onProcessStop is registered as the restream ProcessStopHook. It +// fires with the restream write lock held, just after FFmpeg has been +// stopped. It tears down the per-process Sources (which closes their +// sockets and hangs up any subscribed peers). +func (s *Subsystem) onProcessStop(id string) { + s.mu.Lock() + st, ok := s.streams[id] + teardown := s.teardown + if ok { + delete(s.streams, id) + } + s.mu.Unlock() + + if !ok { + return + } + + // Broadcast first, so any subscribed peers get torn down while + // the streamID is still meaningful. The handler's tearDownStreamPeers + // drives each Peer.Close() which in turn unsubscribes from the + // Sources we're about to shut down — preventing a "subscribers fan + // out into a closed channel" race. + if teardown != nil { + teardown(id) + } + + if st.video != nil { + _ = st.video.Close() + } + if st.audio != nil { + _ = st.audio.Close() + } + s.logger.WithField("id", id).Info().Log("WebRTC egress torn down for process") +} + +// allocAdjacentPair finds a pair of free loopback UDP ports (V, V+1) +// and binds a Source to each. It retries up to allocAttempts times +// because the kernel's ephemeral picker may hand us a port whose +1 +// neighbor is already taken. Caller owns the returned Sources; on +// error all partial allocations are cleaned up. +func (s *Subsystem) allocAdjacentPair(id string) (int, *corewebrtc.Source, *corewebrtc.Source, error) { + var lastErr error + for attempt := 0; attempt < allocAttempts; attempt++ { + port, err := Alloc() + if err != nil { + lastErr = err + continue + } + + videoSrc, err := corewebrtc.NewSourceOn(id, "127.0.0.1", port) + if err != nil { + lastErr = err + continue + } + audioSrc, err := corewebrtc.NewSourceOn(id+":audio", "127.0.0.1", port+1) + if err != nil { + _ = videoSrc.Close() + lastErr = err + continue + } + return port, videoSrc, audioSrc, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("unknown allocation failure") + } + return 0, nil, nil, fmt.Errorf("webrtc: allocate adjacent UDP port pair after %d attempts: %w", allocAttempts, lastErr) +} + +// splitRTPLegs converts the flat BuildArgs output into two ConfigIO +// entries — one per RTP output leg. It splits on the second "-map" +// token, which marks the audio leg's start (see ffmpeg_args_test.go). +// The Address of each ConfigIO is the last argument (the udp:// URL); +// everything preceding it forms that output's Options. +func splitRTPLegs(args []string) []appcfg.ConfigIO { + // Find the two -map indices. + mapIdx := []int{} + for i, a := range args { + if a == "-map" { + mapIdx = append(mapIdx, i) + } + } + if len(mapIdx) != 2 { + // BuildArgs always emits exactly 2 -maps; a different count + // means an upstream bug. Return a single leg covering + // everything to avoid silent truncation. + return []appcfg.ConfigIO{toLeg(args)} + } + + videoTokens := args[mapIdx[0]:mapIdx[1]] + audioTokens := args[mapIdx[1]:] + + return []appcfg.ConfigIO{ + toLeg(videoTokens), + toLeg(audioTokens), + } +} + +// toLeg splits a contiguous RTP-output token slice into a ConfigIO: +// the trailing token is the udp:// Address; everything before is the +// Options slice. +func toLeg(tokens []string) appcfg.ConfigIO { + if len(tokens) == 0 { + return appcfg.ConfigIO{} + } + addr := tokens[len(tokens)-1] + opts := make([]string, len(tokens)-1) + copy(opts, tokens[:len(tokens)-1]) + return appcfg.ConfigIO{ + ID: "webrtc", + Address: addr, + Options: opts, + } +} diff --git a/app/webrtc/lifecycle_test.go b/app/webrtc/lifecycle_test.go new file mode 100644 index 0000000..8d90de6 --- /dev/null +++ b/app/webrtc/lifecycle_test.go @@ -0,0 +1,60 @@ +package webrtc + +import ( + "strings" + "testing" + + appcfg "github.com/datarhei/core/v16/restream/app" +) + +// TestSplitRTPLegs_TwoLegs feeds the real BuildArgs output through +// the splitter and checks both legs come out with the correct shape. +func TestSplitRTPLegs_TwoLegs(t *testing.T) { + args := BuildArgs(appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}, 49200) + + legs := splitRTPLegs(args) + if len(legs) != 2 { + t.Fatalf("expected 2 legs, got %d: %+v", len(legs), legs) + } + + video := legs[0] + audio := legs[1] + + // Leg 0 is video: address ends with :49200 + if !strings.HasSuffix(video.Address, ":49200?pkt_size=1316") { + t.Fatalf("video Address unexpected: %q", video.Address) + } + // Leg 1 is audio: address ends with :49201 + if !strings.HasSuffix(audio.Address, ":49201?pkt_size=1316") { + t.Fatalf("audio Address unexpected: %q", audio.Address) + } + + // Each leg's options start with -map, end with -f rtp. + if len(video.Options) < 2 || video.Options[0] != "-map" { + t.Fatalf("video leg should start with -map, got %v", video.Options) + } + if video.Options[len(video.Options)-2] != "-f" || video.Options[len(video.Options)-1] != "rtp" { + t.Fatalf("video leg should end with -f rtp, got %v", video.Options) + } + if len(audio.Options) < 2 || audio.Options[0] != "-map" { + t.Fatalf("audio leg should start with -map, got %v", audio.Options) + } + + // Neither leg's Options should contain the address itself. + for _, opt := range video.Options { + if strings.HasPrefix(opt, "udp://") { + t.Fatalf("video Options must not contain udp:// address: %v", video.Options) + } + } +} + +// TestSplitRTPLegs_FallbackOnUnexpectedShape ensures we don't panic +// or drop data if BuildArgs ever changes shape — the splitter returns +// a single leg wrapping everything. +func TestSplitRTPLegs_FallbackOnUnexpectedShape(t *testing.T) { + // Single -map: shouldn't happen, but don't panic. + legs := splitRTPLegs([]string{"-map", "0:v:0", "udp://1.2.3.4:5000"}) + if len(legs) != 1 { + t.Fatalf("expected single fallback leg, got %d", len(legs)) + } +} diff --git a/app/webrtc/multiviewer_test.go b/app/webrtc/multiviewer_test.go new file mode 100644 index 0000000..1f9910f --- /dev/null +++ b/app/webrtc/multiviewer_test.go @@ -0,0 +1,257 @@ +package webrtc + +import ( + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/labstack/echo/v4" + pionwebrtc "github.com/pion/webrtc/v4" + + "github.com/datarhei/core/v16/config" + appcfg "github.com/datarhei/core/v16/restream/app" +) + +// TestIntegration_FiveViewerFanout drives the M3 acceptance criterion +// "5 concurrent viewers, all error paths correct, clean teardown" in +// the wide direction. Five Pion subscribers attach to a single +// process's stream pair and each receives RTP without crosstalk; on +// teardown every subscriber's PeerConnection observes its tracks +// closing. +// +// Verifies (in order): +// * subsystem.onProcessStart returns adjacent UDP ports +// * 5 WHEP POSTs in parallel succeed (per-stream cap default = 8) +// * every subscriber's video and audio track receives at least one +// RTP packet within the timeout +// * onProcessStop tears every subscriber down (PeerConnection +// transitions away from connected/connecting) +func TestIntegration_FiveViewerFanout(t *testing.T) { + const N = 5 + + sub, err := New(config.DataWebRTC{Enable: true}, nil) + if err != nil { + t.Fatalf("subsystem New: %v", err) + } + defer sub.Close() + + h := NewHandler(sub, 0) + defer h.Close() + + processID := "fanout" + legs, err := sub.onProcessStart(processID, &appcfg.Config{ + ID: processID, + WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}, + }) + if err != nil { + t.Fatalf("onProcessStart: %v", err) + } + if len(legs) != 2 { + t.Fatalf("expected 2 legs, got %d", len(legs)) + } + videoPort, err := portFromLegAddress(legs[0].Address) + if err != nil { + t.Fatalf("video port: %v", err) + } + audioPort, err := portFromLegAddress(legs[1].Address) + if err != nil { + t.Fatalf("audio port: %v", err) + } + + e := echo.New() + g := e.Group("") + h.Register(g) + srv := httptest.NewServer(e) + defer srv.Close() + + // Each subscriber tracks first-RTP-received signals for V and A. + type viewer struct { + pc *pionwebrtc.PeerConnection + videoCh chan struct{} + audioCh chan struct{} + } + viewers := make([]*viewer, N) + api := func() *pionwebrtc.API { + me := &pionwebrtc.MediaEngine{} + _ = me.RegisterDefaultCodecs() + return pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me)) + }() + + subscribe := func(i int) error { + pc, err := api.NewPeerConnection(pionwebrtc.Configuration{}) + if err != nil { + return err + } + v := &viewer{pc: pc, videoCh: make(chan struct{}, 1), audioCh: make(chan struct{}, 1)} + viewers[i] = v + var vGot, aGot atomic.Bool + pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) { + go func() { + if _, _, rerr := tr.ReadRTP(); rerr != nil { + return + } + switch tr.Kind() { + case pionwebrtc.RTPCodecTypeVideo: + if vGot.CompareAndSwap(false, true) { + v.videoCh <- struct{}{} + } + case pionwebrtc.RTPCodecTypeAudio: + if aGot.CompareAndSwap(false, true) { + v.audioCh <- struct{}{} + } + } + }() + }) + _, _ = pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}) + _, _ = pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio, + pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}) + offer, err := pc.CreateOffer(nil) + if err != nil { + return err + } + gather := pionwebrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + return err + } + <-gather + resp, err := http.Post(srv.URL+"/whep/"+processID, "application/sdp", + strings.NewReader(pc.LocalDescription().SDP)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Errorf("viewer %d: WHEP %d", i, resp.StatusCode) + return nil + } + buf := make([]byte, 1<<15) + n, _ := resp.Body.Read(buf) + return pc.SetRemoteDescription(pionwebrtc.SessionDescription{ + Type: pionwebrtc.SDPTypeAnswer, + SDP: string(buf[:n]), + }) + } + + // Subscribe all N viewers in parallel. + var wg sync.WaitGroup + for i := 0; i < N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := subscribe(i); err != nil { + t.Errorf("viewer %d subscribe: %v", i, err) + } + }(i) + } + wg.Wait() + + for i := 0; i < N; i++ { + if viewers[i] == nil || viewers[i].pc == nil { + t.Fatalf("viewer %d not constructed", i) + } + defer viewers[i].pc.Close() + } + + // Spray RTP into both ports until every viewer reports first-RTP. + videoSender, _ := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort)) + audioSender, _ := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(audioPort)) + defer videoSender.Close() + defer audioSender.Close() + stop := make(chan struct{}) + go func() { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + var seq uint16 + for { + select { + case <-stop: + return + case <-ticker.C: + seq++ + _, _ = videoSender.Write(synthRTPPacket(102, seq, uint32(seq)*3000, 0xcafe0000, []byte("vvvvvvvv"))) + _, _ = audioSender.Write(synthRTPPacket(111, seq, uint32(seq)*960, 0xbeef0000, []byte("aaaaaaaa"))) + } + } + }() + defer close(stop) + + deadline := time.After(15 * time.Second) + for i, v := range viewers { + select { + case <-v.videoCh: + case <-deadline: + t.Fatalf("viewer %d: no video RTP within 15s", i) + } + select { + case <-v.audioCh: + case <-deadline: + t.Fatalf("viewer %d: no audio RTP within 15s", i) + } + } + + // Confirm the per-stream peer index has all N entries. + h.mu.Lock() + got := len(h.peersByStream[processID]) + h.mu.Unlock() + if got != N { + t.Errorf("peersByStream[%s] = %d, want %d", processID, got, N) + } + + // Tear the process down — every viewer's PC should observe state + // transitioning away from connected within a short window. + sub.onProcessStop(processID) + + // After teardown the peer index for this stream should be empty. + // Closing peers is async (driven by Done channel), so poll briefly. + deadline2 := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline2) { + h.mu.Lock() + empty := len(h.peersByStream[processID]) == 0 + h.mu.Unlock() + if empty { + break + } + time.Sleep(50 * time.Millisecond) + } + h.mu.Lock() + leftover := len(h.peersByStream[processID]) + h.mu.Unlock() + if leftover != 0 { + t.Errorf("after onProcessStop, %d peers remain in index", leftover) + } +} + +// TestSubsystem_TeardownHookFiresOnProcessStop is a unit-level check +// that the teardown callback the Handler installs actually runs. +func TestSubsystem_TeardownHookFiresOnProcessStop(t *testing.T) { + sub, err := New(config.DataWebRTC{Enable: true}, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + defer sub.Close() + + var fired atomic.Int32 + sub.SetTeardownHook(func(streamID string) { + if streamID == "p1" { + fired.Add(1) + } + }) + + if _, err := sub.onProcessStart("p1", &appcfg.Config{ + ID: "p1", + WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}, + }); err != nil { + t.Fatalf("onProcessStart: %v", err) + } + sub.onProcessStop("p1") + if got := fired.Load(); got != 1 { + t.Errorf("teardown fired %d times, want 1", got) + } +} diff --git a/app/webrtc/portalloc.go b/app/webrtc/portalloc.go new file mode 100644 index 0000000..9dd1d70 --- /dev/null +++ b/app/webrtc/portalloc.go @@ -0,0 +1,31 @@ +// Package webrtc is the datarhei Core subsystem that turns WebRTC into +// a first-class output alongside RTMP, SRT, and HLS. It owns the WHEP +// HTTP handler, wires FFmpeg's RTP output into per-process Pion +// Sources, and tracks active peer connections. +// +// See docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md +// for the full design. +package webrtc + +import ( + "fmt" + "net" +) + +// Alloc binds :0 on loopback UDPv4, records the port the kernel assigned, +// closes the socket, and returns the port number. +// +// The caller is expected to re-bind that exact port via +// core/webrtc.NewSourceOn immediately. There is a microsecond-sized race +// window where another process on the host could grab the port; if that +// happens, the caller's rebind will fail and the error should be +// propagated. In practice this is rare enough that a retry loop would be +// unnecessary churn. +func Alloc() (int, error) { + c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + return 0, fmt.Errorf("webrtc: portalloc: %w", err) + } + defer c.Close() + return c.LocalAddr().(*net.UDPAddr).Port, nil +} diff --git a/app/webrtc/portalloc_test.go b/app/webrtc/portalloc_test.go new file mode 100644 index 0000000..e8d0f9f --- /dev/null +++ b/app/webrtc/portalloc_test.go @@ -0,0 +1,43 @@ +package webrtc + +import ( + "net" + "testing" +) + +// TestAlloc_ReturnsRebindablePort exercises the alloc/close/rebind +// sequence 100 times. If a fast rebind race existed in normal +// conditions, this would surface it. +func TestAlloc_ReturnsRebindablePort(t *testing.T) { + for i := 0; i < 100; i++ { + p, err := Alloc() + if err != nil { + t.Fatalf("iter %d: Alloc: %v", i, err) + } + if p == 0 { + t.Fatalf("iter %d: expected non-zero port", i) + } + c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: p}) + if err != nil { + t.Fatalf("iter %d: rebind port %d: %v", i, p, err) + } + c.Close() + } +} + +// TestAlloc_DistinctPorts confirms the OS doesn't hand us the same +// ephemeral port twice in quick succession (it shouldn't — the socket +// is briefly held in the bound state on close). +func TestAlloc_DistinctPorts(t *testing.T) { + seen := map[int]bool{} + for i := 0; i < 10; i++ { + p, err := Alloc() + if err != nil { + t.Fatal(err) + } + if seen[p] { + t.Fatalf("duplicate port %d", p) + } + seen[p] = true + } +} diff --git a/app/webrtc/subsystem.go b/app/webrtc/subsystem.go new file mode 100644 index 0000000..a9257d4 --- /dev/null +++ b/app/webrtc/subsystem.go @@ -0,0 +1,139 @@ +package webrtc + +import ( + "fmt" + "sync" + + "github.com/datarhei/core/v16/config" + corewebrtc "github.com/datarhei/core/v16/core/webrtc" + "github.com/datarhei/core/v16/log" + "github.com/datarhei/core/v16/restream" +) + +// Subsystem is the app-level WebRTC egress manager. It sits alongside +// api.API as a sibling — both consume the Restream service, both wire +// themselves into the Echo HTTP router. The subsystem is responsible +// for: +// +// - Translating the global config.DataWebRTC into the core-level +// corewebrtc.Config used by the PeerFactory. +// - Installing ProcessHooks on Restreamer so that per-process start +// events allocate a pair of UDP ports, create Pion Sources, and +// inject RTP output legs into the FFmpeg command line. +// - Serving the WHEP Echo handler (see handler.go). +// +// The zero value is not usable; call New. +type Subsystem struct { + globalCfg config.DataWebRTC + coreCfg corewebrtc.Config + factory *corewebrtc.PeerFactory + logger log.Logger + + mu sync.Mutex + streams map[string]*processStream // processID -> stream pair + + // teardown is set by the Handler (or any other consumer) so the + // Subsystem can broadcast process-stop events. Called *before* + // the per-stream Sources are closed, so consumers can yank their + // own indexes while the stream id is still valid. + teardown func(streamID string) +} + +// processStream captures the two Sources (video + audio) backing a +// single running process's WHEP egress. +type processStream struct { + id string + video *corewebrtc.Source + audio *corewebrtc.Source +} + +// New constructs a Subsystem from the global WebRTC config section. +// The provided ffmpegUDPMax is advisory for logs only (M2 uses the +// OS's ephemeral range via Alloc). Returns an error if the PeerFactory +// cannot be built (e.g., bad NAT1To1 IPs). +func New(dataCfg config.DataWebRTC, logger log.Logger) (*Subsystem, error) { + if logger == nil { + logger = log.New("") + } + + coreCfg := corewebrtc.DefaultConfig() + coreCfg.Enabled = dataCfg.Enable + coreCfg.PublicIP = dataCfg.PublicIP + + // If the operator configured multiple NAT1To1 IPs (e.g., dual + // LAN/public), they take precedence over PublicIP. Wire them + // through via PublicIP as the first entry; core/webrtc currently + // reads a single PublicIP, so M2 joins the list with the first + // entry winning. (Multi-IP NAT1To1 is an M3 enhancement.) + if len(dataCfg.NAT1To1IPs) > 0 && coreCfg.PublicIP == "" { + coreCfg.PublicIP = dataCfg.NAT1To1IPs[0] + } + + factory, err := corewebrtc.NewPeerFactory(coreCfg) + if err != nil { + return nil, fmt.Errorf("webrtc subsystem: build peer factory: %w", err) + } + + return &Subsystem{ + globalCfg: dataCfg, + coreCfg: coreCfg, + factory: factory, + logger: logger.WithComponent("WebRTC"), + streams: make(map[string]*processStream), + }, nil +} + +// Enabled reports whether the subsystem should register hooks and +// serve the WHEP endpoint. Called by the API wiring layer to decide +// whether to install anything. +func (s *Subsystem) Enabled() bool { + return s.globalCfg.Enable +} + +// Hooks returns the restream.ProcessHooks the subsystem expects to be +// installed via restream.Restreamer.SetHooks. Exactly one Subsystem +// instance should be installed per Restreamer. +func (s *Subsystem) Hooks() restream.ProcessHooks { + return restream.ProcessHooks{ + OnStart: s.onProcessStart, + OnStop: s.onProcessStop, + } +} + +// Close tears down every active per-process stream. It is safe to +// call during Core shutdown; subsequent WHEP requests will 404. +func (s *Subsystem) Close() { + s.mu.Lock() + defer s.mu.Unlock() + for id, st := range s.streams { + if st.video != nil { + _ = st.video.Close() + } + if st.audio != nil { + _ = st.audio.Close() + } + delete(s.streams, id) + } +} + +// SetTeardownHook registers a callback invoked just before a stream's +// Sources are closed in onProcessStop. The callback is expected to +// tear down any external resources keyed by streamID — most importantly +// the WHEP Handler's per-stream peer index. +// +// Calling SetTeardownHook again replaces the previous callback; pass +// nil to detach. Only one consumer is supported by design. +func (s *Subsystem) SetTeardownHook(fn func(streamID string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.teardown = fn +} + +// lookup returns the per-process stream pair for id, or nil, false. +// Used by the WHEP handler. +func (s *Subsystem) lookup(id string) (*processStream, bool) { + s.mu.Lock() + defer s.mu.Unlock() + st, ok := s.streams[id] + return st, ok +} diff --git a/cmd/webrtc-poc/main.go b/cmd/webrtc-poc/main.go new file mode 100644 index 0000000..6fffd71 --- /dev/null +++ b/cmd/webrtc-poc/main.go @@ -0,0 +1,56 @@ +// Command webrtc-poc runs a minimal Dragon Fork WebRTC egress server for +// manual end-to-end testing. It listens for RTP on 127.0.0.1:10000 as +// stream "test" and serves WHEP at :8787. +// +// This is NOT part of the datarhei Core binary. It will be removed or +// demoted to an internal test helper once milestone M2 lands. +package main + +import ( + "flag" + "log" + "net/http" + + "github.com/datarhei/core/v16/core/webrtc" +) + +func main() { + var ( + streamID = flag.String("stream", "test", "stream id to serve") + rtpHost = flag.String("rtp-host", "127.0.0.1", "bind address for RTP UDP socket (use 0.0.0.0 for LAN publishers)") + rtpPort = flag.Int("rtp-port", 10000, "UDP port to receive RTP on") + listen = flag.String("listen", ":8787", "WHEP HTTP listen address") + publicIP = flag.String("public-ip", "", "server public IP for NAT1To1 (optional)") + ) + flag.Parse() + + cfg := webrtc.DefaultConfig() + cfg.WHEPListen = *listen + cfg.PublicIP = *publicIP + + src, err := webrtc.NewSourceOn(*streamID, *rtpHost, *rtpPort) + if err != nil { + log.Fatalf("NewSource: %v", err) + } + src.Start() + defer src.Close() + log.Printf("listening for RTP on %s", src.LocalAddr()) + + reg := webrtc.NewRegistry() + if err := reg.Register(*streamID, src); err != nil { + log.Fatalf("Register: %v", err) + } + + factory, err := webrtc.NewPeerFactory(cfg) + if err != nil { + log.Fatalf("NewPeerFactory: %v", err) + } + + handler := webrtc.NewWHEPHandler(reg, factory, cfg) + + mux := http.NewServeMux() + mux.Handle("/whep/", handler) + + log.Printf("WHEP listening on %s — POST /whep/%s to subscribe", *listen, *streamID) + log.Fatal(http.ListenAndServe(*listen, mux)) +} diff --git a/config/config.go b/config/config.go index 33d9492..d026b4e 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.WebRTC = d.WebRTC data.FFmpeg = d.FFmpeg data.Playout = d.Playout data.Debug = d.Debug @@ -131,6 +132,8 @@ func (d *Config) Clone() *Config { data.SRT.Log.Topics = copy.Slice(d.SRT.Log.Topics) + data.WebRTC.NAT1To1IPs = copy.Slice(d.WebRTC.NAT1To1IPs) + data.Router.BlockedPrefixes = copy.Slice(d.Router.BlockedPrefixes) data.Router.Routes = copy.StringMap(d.Router.Routes) @@ -227,6 +230,12 @@ 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) + // WebRTC (Dragon Fork M2) + d.vars.Register(value.NewBool(&d.WebRTC.Enable, false), "webrtc.enable", "CORE_WEBRTC_ENABLE", nil, "Enable WebRTC egress subsystem", false, false) + d.vars.Register(value.NewString(&d.WebRTC.PublicIP, ""), "webrtc.public_ip", "CORE_WEBRTC_PUBLIC_IP", nil, "ICE NAT1To1 host candidate IP (LAN or public)", false, false) + d.vars.Register(value.NewStringList(&d.WebRTC.NAT1To1IPs, []string{}, " "), "webrtc.nat_1_to_1_ips", "CORE_WEBRTC_NAT_1_TO_1_IPS", nil, "Advanced: multiple NAT1To1 IPs", false, false) + d.vars.Register(value.NewInt(&d.WebRTC.UDPMuxPort, 0), "webrtc.udp_mux_port", "CORE_WEBRTC_UDP_MUX_PORT", nil, "Single UDP port for all ICE traffic (0 = ephemeral)", false, false) + // 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/config_test.go b/config/config_test.go index 132857f..a88298b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -56,6 +56,33 @@ func TestConfigCopy(t *testing.T) { require.Equal(t, []string{"foo.com"}, config2.Host.Name) } +// TestConfigCopyWebRTC is a regression test for Clone() silently dropping the +// WebRTC Data section. The first live M2 deploy surfaced this: env vars bound +// correctly onto the original Config, but Core handed the clone to app/api, so +// cfg.WebRTC.Enable was always the zero value and the subsystem was skipped. +func TestConfigCopyWebRTC(t *testing.T) { + fs, _ := fs.NewMemFilesystem(fs.MemConfig{}) + config1 := New(fs) + + config1.WebRTC.Enable = true + config1.WebRTC.PublicIP = "10.0.0.25" + config1.WebRTC.NAT1To1IPs = []string{"10.0.0.25", "203.0.113.10"} + config1.WebRTC.UDPMuxPort = 45000 + + config2 := config1.Clone() + + require.Equal(t, true, config2.WebRTC.Enable) + require.Equal(t, "10.0.0.25", config2.WebRTC.PublicIP) + require.Equal(t, []string{"10.0.0.25", "203.0.113.10"}, config2.WebRTC.NAT1To1IPs) + require.Equal(t, 45000, config2.WebRTC.UDPMuxPort) + + // NAT1To1IPs is a slice — mutating the clone must not affect the + // source, which is what every other section guarantees via + // copy.Slice. Same contract for WebRTC. + config2.WebRTC.NAT1To1IPs[0] = "mutated" + require.Equal(t, "10.0.0.25", config1.WebRTC.NAT1To1IPs[0]) +} + func TestValidateDefault(t *testing.T) { fs, err := fs.NewMemFilesystem(fs.MemConfig{}) require.NoError(t, err) diff --git a/config/data.go b/config/data.go index 3550788..7585bd9 100644 --- a/config/data.go +++ b/config/data.go @@ -113,6 +113,7 @@ type Data struct { Topics []string `json:"topics"` } `json:"log"` } `json:"srt"` + WebRTC DataWebRTC `json:"webrtc"` FFmpeg struct { Binary string `json:"binary"` MaxProcesses int64 `json:"max_processes" format:"int64"` @@ -334,3 +335,12 @@ func DowngradeV3toV2(d *Data) (*v2.Data, error) { return data, nil } + +// DataWebRTC is the global WebRTC egress configuration. Promoted to a +// named type so the app/webrtc subsystem can accept it by value. +type DataWebRTC struct { + Enable bool `json:"enable"` + PublicIP string `json:"public_ip"` + NAT1To1IPs []string `json:"nat_1_to_1_ips"` + UDPMuxPort int `json:"udp_mux_port" format:"int"` +} diff --git a/core/webrtc/config.go b/core/webrtc/config.go new file mode 100644 index 0000000..521e009 --- /dev/null +++ b/core/webrtc/config.go @@ -0,0 +1,59 @@ +package webrtc + +import "fmt" + +// PortRange represents an inclusive UDP port range. +type PortRange struct { + Low, High int +} + +// Config controls the WebRTC egress module. +type Config struct { + // Enabled toggles the entire module. When false, no endpoints are served. + Enabled bool + + // WHEPListen is the address the WHEP HTTP endpoint binds to (e.g. ":8787"). + WHEPListen string + + // PublicIP is the server's externally-reachable IP, advertised in ICE + // candidates via NAT1To1. Empty means rely on STUN discovery. + PublicIP string + + // UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP. + UDPPortRange PortRange + + // ICEServers is the list of STUN/TURN URIs given to each PeerConnection. + ICEServers []string + + // MaxPeersTotal is a hard safety cap on concurrent subscribers. + MaxPeersTotal int +} + +// DefaultConfig returns production-reasonable defaults. +func DefaultConfig() Config { + return Config{ + Enabled: true, + WHEPListen: ":8787", + PublicIP: "", + UDPPortRange: PortRange{Low: 10000, High: 10100}, + ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}, + MaxPeersTotal: 32, + } +} + +// Validate returns an error if the config is internally inconsistent. +func (c Config) Validate() error { + if c.WHEPListen == "" { + return fmt.Errorf("webrtc: WHEPListen must not be empty") + } + if c.UDPPortRange.Low <= 0 || c.UDPPortRange.High <= 0 { + return fmt.Errorf("webrtc: UDPPortRange must have positive bounds, got %v", c.UDPPortRange) + } + if c.UDPPortRange.Low > c.UDPPortRange.High { + return fmt.Errorf("webrtc: UDPPortRange.Low > High (%d > %d)", c.UDPPortRange.Low, c.UDPPortRange.High) + } + if c.MaxPeersTotal <= 0 { + return fmt.Errorf("webrtc: MaxPeersTotal must be positive, got %d", c.MaxPeersTotal) + } + return nil +} diff --git a/core/webrtc/config_test.go b/core/webrtc/config_test.go new file mode 100644 index 0000000..95bb5f7 --- /dev/null +++ b/core/webrtc/config_test.go @@ -0,0 +1,48 @@ +package webrtc + +import ( + "testing" +) + +func TestConfig_Defaults(t *testing.T) { + c := DefaultConfig() + if !c.Enabled { + t.Error("default Enabled should be true") + } + if c.WHEPListen != ":8787" { + t.Errorf("default WHEPListen = %q, want :8787", c.WHEPListen) + } + if c.UDPPortRange.Low != 10000 || c.UDPPortRange.High != 10100 { + t.Errorf("default UDPPortRange = %v, want 10000-10100", c.UDPPortRange) + } + if c.MaxPeersTotal != 32 { + t.Errorf("default MaxPeersTotal = %d, want 32", c.MaxPeersTotal) + } + if len(c.ICEServers) == 0 { + t.Error("default ICEServers should have at least one STUN entry") + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + {"defaults are valid", func(c *Config) {}, false}, + {"empty listen", func(c *Config) { c.WHEPListen = "" }, true}, + {"inverted port range", func(c *Config) { c.UDPPortRange.Low = 20000; c.UDPPortRange.High = 10000 }, true}, + {"zero max peers", func(c *Config) { c.MaxPeersTotal = 0 }, true}, + {"negative max peers", func(c *Config) { c.MaxPeersTotal = -1 }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DefaultConfig() + tt.mutate(&c) + err := c.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/core/webrtc/doc.go b/core/webrtc/doc.go new file mode 100644 index 0000000..396d096 --- /dev/null +++ b/core/webrtc/doc.go @@ -0,0 +1,11 @@ +// Package webrtc implements the Dragon Fork WebRTC egress module. +// +// It exposes a WHEP (WebRTC-HTTP Egress Protocol) HTTP endpoint and serves +// live RTP produced by an FFmpeg process on a local UDP socket to one or +// more WebRTC peer connections built with Pion. +// +// This package is additive: it does not modify existing datarhei ingest, +// transcode, or non-WebRTC output code paths. The only contact with +// existing code is a new URL scheme ("webrtc://") registered with the +// output resolver (done in milestone M2, not here). +package webrtc diff --git a/core/webrtc/errors.go b/core/webrtc/errors.go new file mode 100644 index 0000000..3bf2ae2 --- /dev/null +++ b/core/webrtc/errors.go @@ -0,0 +1,26 @@ +package webrtc + +import "errors" + +// Sentinel errors returned by package functions. +var ( + // ErrStreamNotFound indicates a WHEP subscribe referenced a stream_id + // that has no registered source. Maps to HTTP 404. + ErrStreamNotFound = errors.New("webrtc: stream not found") + + // ErrPeerCapReached indicates max_peers_total has been exceeded. + // Maps to HTTP 503. + ErrPeerCapReached = errors.New("webrtc: peer capacity reached") + + // ErrCodecMismatch indicates the viewer's SDP offer does not include + // a codec the source can serve (expected H.264 + Opus). Maps to HTTP 406. + ErrCodecMismatch = errors.New("webrtc: codec mismatch") + + // ErrInvalidSDP indicates the request body was not a parseable SDP offer. + // Maps to HTTP 400. + ErrInvalidSDP = errors.New("webrtc: invalid SDP") + + // ErrICETimeout indicates ICE gathering did not complete within the + // configured timeout. Maps to HTTP 500. + ErrICETimeout = errors.New("webrtc: ICE gathering timeout") +) diff --git a/core/webrtc/forward.go b/core/webrtc/forward.go new file mode 100644 index 0000000..05ae1f9 --- /dev/null +++ b/core/webrtc/forward.go @@ -0,0 +1,62 @@ +package webrtc + +import ( + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +// forwardRTP reads packets from sub and writes them to the correct track +// based on payload type (H.264 → video, Opus → audio). Used by the M1 +// single-source PoC where FFmpeg emits both video and audio RTP to the +// same UDP port. +func forwardRTP(done <-chan struct{}, sub <-chan *rtp.Packet, + video, audio *webrtc.TrackLocalStaticRTP) { + for { + select { + case <-done: + return + case pkt, ok := <-sub: + if !ok { + return + } + // Pion default H.264 PT = 102, Opus PT = 111. If the publisher + // uses different PTs we'll revisit in M2 — for M1 PoC we + // configure FFmpeg to these values explicitly in the publisher + // script. + switch pkt.PayloadType { + case 102: + _ = video.WriteRTP(pkt) + case 111: + _ = audio.WriteRTP(pkt) + default: + // Unknown PT — drop. Log in M3. + } + } + } +} + +// forwardRTPSplit is the M2 variant: it reads from two independent +// per-track channels (one video, one audio) and writes each to its +// own Pion track. This is the mode used when the restream manager +// emits two FFmpeg RTP legs on separate UDP ports. Either channel +// closing or done firing terminates the loop. +func forwardRTPSplit(done <-chan struct{}, + videoSub <-chan *rtp.Packet, audioSub <-chan *rtp.Packet, + video, audio *webrtc.TrackLocalStaticRTP) { + for { + select { + case <-done: + return + case pkt, ok := <-videoSub: + if !ok { + return + } + _ = video.WriteRTP(pkt) + case pkt, ok := <-audioSub: + if !ok { + return + } + _ = audio.WriteRTP(pkt) + } + } +} diff --git a/core/webrtc/ice.go b/core/webrtc/ice.go new file mode 100644 index 0000000..a728055 --- /dev/null +++ b/core/webrtc/ice.go @@ -0,0 +1,47 @@ +package webrtc + +import ( + "github.com/pion/webrtc/v4" +) + +// BuildICEConfig translates a Config into the two Pion config pieces every +// PeerConnection needs: a webrtc.Configuration (with ICE servers) and a +// SettingEngine (with NAT1To1 and port range tuning). +// +// The returned *SettingEngine may be nil if no engine-level tuning is +// required (i.e. PublicIP unset and UDPPortRange at defaults). Callers +// should only pass it to webrtc.NewAPI when non-nil. +func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) { + if err := c.Validate(); err != nil { + return webrtc.Configuration{}, nil, err + } + + rtcConfig := webrtc.Configuration{ + ICEServers: make([]webrtc.ICEServer, 0, len(c.ICEServers)), + } + for _, uri := range c.ICEServers { + rtcConfig.ICEServers = append(rtcConfig.ICEServers, webrtc.ICEServer{ + URLs: []string{uri}, + }) + } + + var se *webrtc.SettingEngine + if c.PublicIP != "" || c.UDPPortRange.Low > 0 { + engine := webrtc.SettingEngine{} + if c.PublicIP != "" { + engine.SetNAT1To1IPs([]string{c.PublicIP}, webrtc.ICECandidateTypeHost) + } + // Constrain the ephemeral UDP range Pion allocates for ICE candidates. + // Note: this is a separate concern from our FFmpeg→Source UDP ports; + // Pion uses its own port pool for the WebRTC media path. + if c.UDPPortRange.Low > 0 && c.UDPPortRange.High >= c.UDPPortRange.Low { + if err := engine.SetEphemeralUDPPortRange( + uint16(c.UDPPortRange.Low), uint16(c.UDPPortRange.High)); err != nil { + return webrtc.Configuration{}, nil, err + } + } + se = &engine + } + + return rtcConfig, se, nil +} diff --git a/core/webrtc/ice_test.go b/core/webrtc/ice_test.go new file mode 100644 index 0000000..99294c1 --- /dev/null +++ b/core/webrtc/ice_test.go @@ -0,0 +1,50 @@ +package webrtc + +import ( + "testing" + + "github.com/pion/webrtc/v4" +) + +func TestBuildICEConfig_Defaults(t *testing.T) { + c := DefaultConfig() + rtcConfig, _, err := BuildICEConfig(c) + if err != nil { + t.Fatalf("BuildICEConfig: %v", err) + } + if len(rtcConfig.ICEServers) == 0 { + t.Error("ICEServers should not be empty") + } + // First default is Cloudflare STUN. + if rtcConfig.ICEServers[0].URLs[0] != "stun:stun.cloudflare.com:3478" { + t.Errorf("first ICE server = %q, want stun:stun.cloudflare.com:3478", + rtcConfig.ICEServers[0].URLs[0]) + } +} + +func TestBuildICEConfig_PublicIP(t *testing.T) { + c := DefaultConfig() + c.PublicIP = "203.0.113.10" + _, se, err := BuildICEConfig(c) + if err != nil { + t.Fatalf("BuildICEConfig: %v", err) + } + if se == nil { + t.Fatal("SettingEngine should not be nil when PublicIP is set") + } + // We can't introspect NAT1To1IPs directly from Pion's public API; the + // smoke test is that building an API from this engine works. + api := webrtc.NewAPI(webrtc.WithSettingEngine(*se)) + if api == nil { + t.Fatal("NewAPI returned nil") + } +} + +func TestBuildICEConfig_InvalidConfig(t *testing.T) { + c := DefaultConfig() + c.WHEPListen = "" + _, _, err := BuildICEConfig(c) + if err == nil { + t.Error("BuildICEConfig should reject invalid config") + } +} diff --git a/core/webrtc/peer.go b/core/webrtc/peer.go new file mode 100644 index 0000000..7782ed2 --- /dev/null +++ b/core/webrtc/peer.go @@ -0,0 +1,278 @@ +package webrtc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +// PeerFactory builds PeerConnections from a shared Pion API instance +// configured from Config. +type PeerFactory struct { + api *webrtc.API + rtcConfig webrtc.Configuration +} + +// NewPeerFactory initializes a Pion API with the codec set we support +// (H.264 + Opus) and applies the provided Config. +func NewPeerFactory(c Config) (*PeerFactory, error) { + if err := c.Validate(); err != nil { + return nil, err + } + + me := &webrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + return nil, fmt.Errorf("webrtc: register default codecs: %w", err) + } + + rtcConfig, se, err := BuildICEConfig(c) + if err != nil { + return nil, err + } + + opts := []func(*webrtc.API){webrtc.WithMediaEngine(me)} + if se != nil { + opts = append(opts, webrtc.WithSettingEngine(*se)) + } + api := webrtc.NewAPI(opts...) + + return &PeerFactory{api: api, rtcConfig: rtcConfig}, nil +} + +// Peer wraps a Pion PeerConnection bound to either a single Source +// subscription (M1, payload-type split forwarding) or to a pair of +// video+audio Source subscriptions (M2, per-track forwarding). +type Peer struct { + resourceID string + pc *webrtc.PeerConnection + answer webrtc.SessionDescription + + // M1 single-source mode: source+sub are set, videoSource/audioSource are nil. + source *Source + sub chan *rtp.Packet + + // M2 two-source mode: videoSource/audioSource and their subs are set, + // source/sub are nil. + videoSource *Source + audioSource *Source + videoSub chan *rtp.Packet + audioSub chan *rtp.Packet + + done chan struct{} + once sync.Once +} + +// CreatePeer builds a PeerConnection, sets the remote offer, generates an +// answer, attaches video+audio tracks fed from src, and blocks until ICE +// gathering completes or ctx expires. +func (f *PeerFactory) CreatePeer(ctx context.Context, src *Source, offer webrtc.SessionDescription) (*Peer, error) { + pc, err := f.api.NewPeerConnection(f.rtcConfig) + if err != nil { + return nil, fmt.Errorf("webrtc: new peer connection: %w", err) + } + + videoTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, + "video", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new video track: %w", err) + } + audioTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + "audio", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new audio track: %w", err) + } + if _, err := pc.AddTrack(videoTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add video track: %w", err) + } + if _, err := pc.AddTrack(audioTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add audio track: %w", err) + } + + if err := pc.SetRemoteDescription(offer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set remote: %w", err) + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: create answer: %w", err) + } + + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(answer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set local: %w", err) + } + + select { + case <-gatherComplete: + case <-ctx.Done(): + _ = pc.Close() + return nil, ErrICETimeout + } + + sub := src.Subscribe(64) + + p := &Peer{ + resourceID: newResourceID(), + pc: pc, + answer: *pc.LocalDescription(), + source: src, + sub: sub, + done: make(chan struct{}), + } + + pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) { + if st == webrtc.PeerConnectionStateFailed || + st == webrtc.PeerConnectionStateDisconnected || + st == webrtc.PeerConnectionStateClosed { + _ = p.Close() + } + }) + + go forwardRTP(p.done, sub, videoTrack, audioTrack) + + return p, nil +} + +// Answer returns the locally-created SDP answer. Valid after CreatePeer. +func (p *Peer) Answer() webrtc.SessionDescription { return p.answer } + +// ResourceID returns the stable resource id used in the WHEP Location header. +func (p *Peer) ResourceID() string { return p.resourceID } + +// Done returns a channel that is closed when the Peer has been torn down +// (either explicitly via Close, or because Pion observed an ICE +// failure / disconnection). Consumers can range over it to drive +// index cleanup without polling. +func (p *Peer) Done() <-chan struct{} { return p.done } + +// Close tears down the peer connection and unsubscribes from each +// source. Safe to call multiple times. +func (p *Peer) Close() error { + var err error + p.once.Do(func() { + close(p.done) + if p.source != nil && p.sub != nil { + p.source.Unsubscribe(p.sub) + } + if p.videoSource != nil && p.videoSub != nil { + p.videoSource.Unsubscribe(p.videoSub) + } + if p.audioSource != nil && p.audioSub != nil { + p.audioSource.Unsubscribe(p.audioSub) + } + err = p.pc.Close() + }) + return err +} + +// CreatePeerFromSources is the M2 entry point: it builds a +// PeerConnection with video+audio tracks and subscribes each to its +// own dedicated Source. Used when the restream manager emits two +// FFmpeg RTP legs on separate UDP ports — there is no payload-type +// sniffing required, each Source feeds its matching track directly. +func (f *PeerFactory) CreatePeerFromSources(ctx context.Context, + videoSrc, audioSrc *Source, offer webrtc.SessionDescription) (*Peer, error) { + pc, err := f.api.NewPeerConnection(f.rtcConfig) + if err != nil { + return nil, fmt.Errorf("webrtc: new peer connection: %w", err) + } + + videoTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, + "video", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new video track: %w", err) + } + audioTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + "audio", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new audio track: %w", err) + } + if _, err := pc.AddTrack(videoTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add video track: %w", err) + } + if _, err := pc.AddTrack(audioTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add audio track: %w", err) + } + + if err := pc.SetRemoteDescription(offer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set remote: %w", err) + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: create answer: %w", err) + } + + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(answer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set local: %w", err) + } + + select { + case <-gatherComplete: + case <-ctx.Done(): + _ = pc.Close() + return nil, ErrICETimeout + } + + videoSub := videoSrc.Subscribe(64) + audioSub := audioSrc.Subscribe(64) + + p := &Peer{ + resourceID: newResourceID(), + pc: pc, + answer: *pc.LocalDescription(), + videoSource: videoSrc, + audioSource: audioSrc, + videoSub: videoSub, + audioSub: audioSub, + done: make(chan struct{}), + } + + pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) { + if st == webrtc.PeerConnectionStateFailed || + st == webrtc.PeerConnectionStateDisconnected || + st == webrtc.PeerConnectionStateClosed { + _ = p.Close() + } + }) + + go forwardRTPSplit(p.done, videoSub, audioSub, videoTrack, audioTrack) + + return p, nil +} + + +// AddICECandidate forwards a trickle-ICE candidate to the underlying +// PeerConnection. Returns the underlying error if the candidate is +// malformed or the connection has already been closed. +func (p *Peer) AddICECandidate(c webrtc.ICECandidateInit) error { + return p.pc.AddICECandidate(c) +} + +func newResourceID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/core/webrtc/peer_test.go b/core/webrtc/peer_test.go new file mode 100644 index 0000000..46e615a --- /dev/null +++ b/core/webrtc/peer_test.go @@ -0,0 +1,96 @@ +package webrtc + +import ( + "context" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +// minimalOfferSDP returns an SDP offer that advertises H.264 (video) and +// Opus (audio) as recvonly — the minimum a WHEP client sends. +func minimalOfferSDP(t *testing.T) webrtc.SessionDescription { + t.Helper() + // Create a throwaway PC to generate a valid offer. + me := &webrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + t.Fatalf("RegisterDefaultCodecs: %v", err) + } + api := webrtc.NewAPI(webrtc.WithMediaEngine(me)) + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + defer pc.Close() + + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("AddTransceiver video: %v", err) + } + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("AddTransceiver audio: %v", err) + } + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer: %v", err) + } + return offer +} + +func TestPeerFactory_CreateAnswer(t *testing.T) { + src, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer src.Close() + src.Start() + + cfg := DefaultConfig() + factory, err := NewPeerFactory(cfg) + if err != nil { + t.Fatalf("NewPeerFactory: %v", err) + } + + offer := minimalOfferSDP(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + peer, err := factory.CreatePeer(ctx, src, offer) + if err != nil { + t.Fatalf("CreatePeer: %v", err) + } + defer peer.Close() + + if peer.Answer().Type != webrtc.SDPTypeAnswer { + t.Errorf("Answer().Type = %v, want answer", peer.Answer().Type) + } + if peer.ResourceID() == "" { + t.Error("ResourceID should be non-empty") + } +} + +func TestPeerFactory_ClosesCleanly(t *testing.T) { + src, _ := NewSource("streamA", 0) + defer src.Close() + src.Start() + + factory, _ := NewPeerFactory(DefaultConfig()) + offer := minimalOfferSDP(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, err := factory.CreatePeer(ctx, src, offer) + if err != nil { + t.Fatalf("CreatePeer: %v", err) + } + if err := peer.Close(); err != nil { + t.Errorf("Close: %v", err) + } + // Second close should be a no-op, not panic. + if err := peer.Close(); err != nil { + t.Errorf("second Close: %v", err) + } +} diff --git a/core/webrtc/registry.go b/core/webrtc/registry.go new file mode 100644 index 0000000..fd924b2 --- /dev/null +++ b/core/webrtc/registry.go @@ -0,0 +1,51 @@ +package webrtc + +import ( + "fmt" + "sync" +) + +// SourceHandle is the minimal interface the Registry stores per stream_id. +// The concrete type is *Source, defined in source.go. +type SourceHandle interface { + ID() string +} + +// Registry is a thread-safe map from stream_id to active SourceHandle. +type Registry struct { + mu sync.RWMutex + streams map[string]SourceHandle +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{streams: make(map[string]SourceHandle)} +} + +// Register associates src with streamID. Returns an error if streamID is +// already registered. +func (r *Registry) Register(streamID string, src SourceHandle) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.streams[streamID]; exists { + return fmt.Errorf("webrtc: stream %q already registered", streamID) + } + r.streams[streamID] = src + return nil +} + +// Lookup returns the handle for streamID. The second return value is false +// if no source is registered. +func (r *Registry) Lookup(streamID string) (SourceHandle, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + src, ok := r.streams[streamID] + return src, ok +} + +// Deregister removes streamID. No-op if not present. +func (r *Registry) Deregister(streamID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.streams, streamID) +} diff --git a/core/webrtc/registry_test.go b/core/webrtc/registry_test.go new file mode 100644 index 0000000..543777e --- /dev/null +++ b/core/webrtc/registry_test.go @@ -0,0 +1,74 @@ +package webrtc + +import ( + "sync" + "testing" +) + +// mockSource implements the minimum Source-like shape needed by the registry. +// The real Source type is defined in Task 5; the registry only needs a +// stable type to store and retrieve. +type mockSource struct { + id string +} + +func (m *mockSource) ID() string { return m.id } + +func TestRegistry_RegisterAndLookup(t *testing.T) { + r := NewRegistry() + src := &mockSource{id: "streamA"} + + if err := r.Register("streamA", src); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + got, ok := r.Lookup("streamA") + if !ok { + t.Fatal("Lookup(streamA) returned ok=false, want true") + } + if got != src { + t.Errorf("Lookup returned %v, want %v", got, src) + } +} + +func TestRegistry_LookupMissing(t *testing.T) { + r := NewRegistry() + _, ok := r.Lookup("nope") + if ok { + t.Error("Lookup on empty registry returned ok=true, want false") + } +} + +func TestRegistry_DuplicateRegister(t *testing.T) { + r := NewRegistry() + _ = r.Register("streamA", &mockSource{id: "streamA"}) + + if err := r.Register("streamA", &mockSource{id: "streamA"}); err == nil { + t.Error("duplicate Register should return error, got nil") + } +} + +func TestRegistry_Deregister(t *testing.T) { + r := NewRegistry() + _ = r.Register("streamA", &mockSource{id: "streamA"}) + r.Deregister("streamA") + + if _, ok := r.Lookup("streamA"); ok { + t.Error("after Deregister, Lookup should return ok=false") + } +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + r := NewRegistry() + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(3) + id := string(rune('a' + (i % 26))) + go func() { defer wg.Done(); _ = r.Register(id, &mockSource{id: id}) }() + go func() { defer wg.Done(); _, _ = r.Lookup(id) }() + go func() { defer wg.Done(); r.Deregister(id) }() + } + wg.Wait() + // No assertion — test passes if -race doesn't flag anything. +} diff --git a/core/webrtc/source.go b/core/webrtc/source.go new file mode 100644 index 0000000..521ec7f --- /dev/null +++ b/core/webrtc/source.go @@ -0,0 +1,149 @@ +package webrtc + +import ( + "fmt" + "net" + "sync" + + "github.com/pion/rtp" +) + +// Source reads RTP packets from a local UDP socket and fans them out to +// subscribed peers via per-subscriber buffered channels. +type Source struct { + id string + conn *net.UDPConn + + mu sync.Mutex + subscribers map[chan *rtp.Packet]struct{} + started bool + closed bool + done chan struct{} +} + +// NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS +// assign an ephemeral port (useful for tests). Equivalent to +// NewSourceOn(streamID, "127.0.0.1", port). +func NewSource(streamID string, port int) (*Source, error) { + return NewSourceOn(streamID, "127.0.0.1", port) +} + +// NewSourceOn binds a UDP socket on host:port. Use "0.0.0.0" to accept +// RTP from any LAN publisher — required when running in a container +// with host networking that needs to receive from other hosts. Empty +// host is treated as 127.0.0.1 for backward compatibility. +func NewSourceOn(streamID, host string, port int) (*Source, error) { + if host == "" { + host = "127.0.0.1" + } + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("webrtc: invalid host %q", host) + } + addr := &net.UDPAddr{IP: ip, Port: port} + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return nil, fmt.Errorf("webrtc: listen udp: %w", err) + } + return &Source{ + id: streamID, + conn: conn, + subscribers: make(map[chan *rtp.Packet]struct{}), + done: make(chan struct{}), + }, nil +} + +// ID returns the registered stream identifier. +func (s *Source) ID() string { return s.id } + +// LocalAddr returns the UDP address the source is listening on. +func (s *Source) LocalAddr() *net.UDPAddr { + return s.conn.LocalAddr().(*net.UDPAddr) +} + +// Subscribe returns a new buffered channel that receives every RTP packet +// read from the UDP socket. bufDepth is the channel buffer size; when full, +// packets are dropped (preventing a slow subscriber from back-pressuring +// the reader). +func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet { + ch := make(chan *rtp.Packet, bufDepth) + s.mu.Lock() + s.subscribers[ch] = struct{}{} + s.mu.Unlock() + return ch +} + +// Unsubscribe removes ch from the subscriber set and closes it. +func (s *Source) Unsubscribe(ch chan *rtp.Packet) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.subscribers[ch]; ok { + delete(s.subscribers, ch) + close(ch) + } +} + +// Start begins the RTP reader goroutine. Safe to call once; subsequent calls +// are no-ops. +func (s *Source) Start() { + s.mu.Lock() + if s.started || s.closed { + s.mu.Unlock() + return + } + s.started = true + s.mu.Unlock() + + go s.readLoop() +} + +func (s *Source) readLoop() { + buf := make([]byte, 1500) // MTU-sized; RTP over UDP should fit + for { + select { + case <-s.done: + return + default: + } + + n, _, err := s.conn.ReadFromUDP(buf) + if err != nil { + // Socket closed or error — exit the loop. + return + } + + pkt := &rtp.Packet{} + if err := pkt.Unmarshal(buf[:n]); err != nil { + // Malformed packet; skip without crashing. + continue + } + + s.mu.Lock() + for ch := range s.subscribers { + select { + case ch <- pkt: + default: + // Subscriber full — drop to protect the reader. + } + } + s.mu.Unlock() + } +} + +// Close stops the reader goroutine, closes the UDP socket, and closes every +// subscriber channel. +func (s *Source) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + close(s.done) + for ch := range s.subscribers { + delete(s.subscribers, ch) + close(ch) + } + s.mu.Unlock() + return s.conn.Close() +} diff --git a/core/webrtc/source_test.go b/core/webrtc/source_test.go new file mode 100644 index 0000000..c26def0 --- /dev/null +++ b/core/webrtc/source_test.go @@ -0,0 +1,129 @@ +package webrtc + +import ( + "net" + "testing" + "time" + + "github.com/pion/rtp" +) + +func TestSource_ID(t *testing.T) { + s, err := NewSource("streamA", 0) // 0 = ephemeral port + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + if s.ID() != "streamA" { + t.Errorf("ID() = %q, want streamA", s.ID()) + } +} + +func TestSource_ReceiveAndFanout(t *testing.T) { + s, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + // Subscribe before sending. + sub := s.Subscribe(16) // buffer depth 16 + defer s.Unsubscribe(sub) + + s.Start() + + // Build and send a minimal RTP packet to the source's UDP port. + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: 96, + SequenceNumber: 1, + Timestamp: 1000, + SSRC: 0xDEADBEEF, + }, + Payload: []byte{0x01, 0x02, 0x03, 0x04}, + } + raw, err := pkt.Marshal() + if err != nil { + t.Fatalf("pkt.Marshal: %v", err) + } + + conn, err := net.Dial("udp", s.LocalAddr().String()) + if err != nil { + t.Fatalf("net.Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write(raw); err != nil { + t.Fatalf("conn.Write: %v", err) + } + + select { + case got := <-sub: + if got.SSRC != 0xDEADBEEF { + t.Errorf("received SSRC = %x, want DEADBEEF", got.SSRC) + } + if got.SequenceNumber != 1 { + t.Errorf("received SeqNum = %d, want 1", got.SequenceNumber) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for RTP packet on subscriber channel") + } +} + +func TestSource_MultipleSubscribers(t *testing.T) { + s, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + subs := []chan *rtp.Packet{ + s.Subscribe(8), + s.Subscribe(8), + s.Subscribe(8), + } + for _, sub := range subs { + defer s.Unsubscribe(sub) + } + + s.Start() + + raw, _ := (&rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: 42, SSRC: 1}, + Payload: []byte{0xAA}, + }).Marshal() + conn, _ := net.Dial("udp", s.LocalAddr().String()) + defer conn.Close() + _, _ = conn.Write(raw) + + for i, sub := range subs { + select { + case got := <-sub: + if got.SequenceNumber != 42 { + t.Errorf("sub %d got seq %d, want 42", i, got.SequenceNumber) + } + case <-time.After(2 * time.Second): + t.Errorf("sub %d timed out", i) + } + } +} + +func TestSource_UnsubscribeStopsDelivery(t *testing.T) { + s, _ := NewSource("streamA", 0) + defer s.Close() + sub := s.Subscribe(8) + s.Start() + s.Unsubscribe(sub) + + // After Unsubscribe, the channel should be closed. + select { + case _, ok := <-sub: + if ok { + t.Error("expected channel closed after Unsubscribe, got value") + } + case <-time.After(500 * time.Millisecond): + t.Error("timed out waiting for channel close") + } +} diff --git a/core/webrtc/whep.go b/core/webrtc/whep.go new file mode 100644 index 0000000..87e07ac --- /dev/null +++ b/core/webrtc/whep.go @@ -0,0 +1,93 @@ +package webrtc + +import ( + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + + "github.com/pion/webrtc/v4" +) + +// WHEPHandler serves the WebRTC-HTTP Egress Protocol POST. +type WHEPHandler struct { + registry *Registry + factory *PeerFactory + config Config + + mu sync.Mutex + peers map[string]*Peer // resourceID → Peer + peersCount int64 // atomic, for cap check without lock +} + +// NewWHEPHandler constructs a handler with the given dependencies. +func NewWHEPHandler(r *Registry, f *PeerFactory, c Config) *WHEPHandler { + return &WHEPHandler{ + registry: r, + factory: f, + config: c, + peers: make(map[string]*Peer), + } +} + +// ServeHTTP handles POST /whep/{stream_id}. Other methods and paths return 405. +func (h *WHEPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract stream_id from path: /whep/{stream_id} + streamID := strings.TrimPrefix(r.URL.Path, "/whep/") + if streamID == "" || strings.Contains(streamID, "/") { + http.Error(w, "invalid stream id", http.StatusBadRequest) + return + } + + // Peer cap enforcement (happy path still respects the cap). + if atomic.LoadInt64(&h.peersCount) >= int64(h.config.MaxPeersTotal) { + http.Error(w, ErrPeerCapReached.Error(), http.StatusServiceUnavailable) + return + } + + handle, ok := h.registry.Lookup(streamID) + if !ok { + http.Error(w, ErrStreamNotFound.Error(), http.StatusNotFound) + return + } + src, ok := handle.(*Source) + if !ok { + http.Error(w, "registered source is not a *Source", http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + if len(body) == 0 { + http.Error(w, ErrInvalidSDP.Error(), http.StatusBadRequest) + return + } + + offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} + + peer, err := h.factory.CreatePeer(r.Context(), src, offer) + if err != nil { + http.Error(w, "create peer: "+err.Error(), http.StatusInternalServerError) + return + } + + h.mu.Lock() + h.peers[peer.ResourceID()] = peer + h.mu.Unlock() + atomic.AddInt64(&h.peersCount, 1) + + w.Header().Set("Content-Type", "application/sdp") + w.Header().Set("Location", "/whep/"+streamID+"/"+peer.ResourceID()) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, peer.Answer().SDP) +} diff --git a/core/webrtc/whep_test.go b/core/webrtc/whep_test.go new file mode 100644 index 0000000..fe5d755 --- /dev/null +++ b/core/webrtc/whep_test.go @@ -0,0 +1,64 @@ +package webrtc + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +func TestWHEP_POSTReturns201WithSDP(t *testing.T) { + // Set up a Source and register it. + src, _ := NewSource("streamA", 0) + defer src.Close() + src.Start() + + reg := NewRegistry() + _ = reg.Register("streamA", src) + + factory, _ := NewPeerFactory(DefaultConfig()) + + handler := NewWHEPHandler(reg, factory, DefaultConfig()) + + // Build an offer using a throwaway PC. + me := &webrtc.MediaEngine{} + _ = me.RegisterDefaultCodecs() + api := webrtc.NewAPI(webrtc.WithMediaEngine(me)) + pc, _ := api.NewPeerConnection(webrtc.Configuration{}) + defer pc.Close() + _, _ = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}) + _, _ = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}) + offer, _ := pc.CreateOffer(nil) + + req := httptest.NewRequest(http.MethodPost, "/whep/streamA", + strings.NewReader(offer.SDP)) + req.Header.Set("Content-Type", "application/sdp") + // Give the handler generous ICE gathering time in tests. + ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + body, _ := io.ReadAll(rr.Result().Body) + t.Fatalf("status = %d, want 201. body=%s", rr.Code, string(body)) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/sdp" { + t.Errorf("Content-Type = %q, want application/sdp", ct) + } + if loc := rr.Header().Get("Location"); !strings.HasPrefix(loc, "/whep/streamA/") { + t.Errorf("Location = %q, want /whep/streamA/", loc) + } + if !strings.Contains(rr.Body.String(), "v=0") { + t.Errorf("body does not look like SDP: %s", rr.Body.String()) + } +} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..ef00f75 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,34 @@ +# Dockerfile for the Dragon Fork WebRTC PoC (M1). +# +# Two-stage: +# 1. builder: compile a static linux/amd64 binary inside the repo +# 2. runtime: minimal scratch image with the binary only +# +# The PoC has no outbound HTTPS needs and no dynamic libraries, so +# `scratch` is safe. Image size ~14 MB. +# +# The binary's flags (-stream, -rtp-port, -listen, -public-ip) are +# passed via `command:` in docker-compose (or `docker run ...`). + +# ---- builder ---- +FROM golang:1.24-alpine AS builder + +WORKDIR /src +COPY . . + +# Static, stripped, no CGO — no shared libs needed in runtime stage. +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +RUN go build -trimpath -ldflags="-s -w" \ + -o /out/webrtc-poc \ + ./cmd/webrtc-poc + +# ---- runtime ---- +FROM scratch AS runtime + +COPY --from=builder /out/webrtc-poc /webrtc-poc + +# Defaults — override via `command:` or `docker run ...`. +EXPOSE 8787/tcp +EXPOSE 10000/udp + +ENTRYPOINT ["/webrtc-poc"] diff --git a/deploy/truenas/README.md b/deploy/truenas/README.md new file mode 100644 index 0000000..b6030f0 --- /dev/null +++ b/deploy/truenas/README.md @@ -0,0 +1,70 @@ +# TrueNAS deploy — WebRTC PoC (M1) + +Host-networked Docker stack that runs `cmd/webrtc-poc` on TrueNAS for +manual end-to-end testing. Not wired into the Core binary. + +## Prereqs + +- Docker on the TrueNAS host (TrueNAS SCALE includes it) +- LAN or public IP that clients can reach +- One free TCP port (WHEP) and one free UDP port (RTP ingest) + +## One-time setup + +``` +# On TrueNAS: +sudo mkdir -p /mnt/NVME/Docker/dragonfork-webrtc-poc +cd /mnt/NVME/Docker/dragonfork-webrtc-poc + +# Copy the repo's deploy/truenas/docker-compose.yml in here, and the +# whole repo (or just cmd/ + core/ + go.mod + vendor/) somewhere the +# Dockerfile build context can see. Simplest: clone the repo adjacent +# and symlink docker-compose.yml, or point `context:` at the clone. + +cat > .env < and write the binary back up to +# the repo root with a relative path, so those end up at /src/import +# and /src/ffmigrate. +COPY --from=builder /src/core/core /core/bin/core +COPY --from=builder /src/import /core/bin/import +COPY --from=builder /src/ffmigrate /core/bin/ffmigrate +COPY --from=builder /src/mime.types /core/mime.types +COPY --from=builder /src/run.sh /core/bin/run.sh + +RUN 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 + +VOLUME ["/core/data", "/core/config"] +EXPOSE 8080/tcp + +ENTRYPOINT ["/sbin/tini", "--", "/core/bin/run.sh"] +WORKDIR /core diff --git a/deploy/truenas/core/README.md b/deploy/truenas/core/README.md new file mode 100644 index 0000000..e79ff6f --- /dev/null +++ b/deploy/truenas/core/README.md @@ -0,0 +1,102 @@ +# TrueNAS deploy — datarhei Core (M2, WebRTC-in-Core) + +Host-networked Docker stack that runs the real root Core binary with +the M2 WebRTC egress subsystem wired in. This replaces the M1 +`webrtc-poc` stack — WebRTC is now a first-class output alongside +RTMP/SRT/HLS. + +## What changed from M1 + +| M1 (webrtc-poc) | M2 (this stack) | +| -------------------------------------- | -------------------------------------------- | +| Standalone `cmd/webrtc-poc` binary | Full Core with restream, HTTP API, storage | +| One hard-coded stream id | Every restream process can opt into WebRTC | +| Single UDP ingest, PT-split forwarding | Two UDP ports per process, per-track | +| Plain `/whep/:id` on a side port | `/api/v3/whep/:id` on the JWT-protected API | +| No auth | JWT (same creds as the rest of Core) | + +## Prereqs + +- Docker on the TrueNAS host (TrueNAS SCALE includes it) +- LAN or public IP that clients can reach (set in `.env` as `PUBLIC_IP`) +- Admin credentials for Core's API +- FFmpeg is bundled in the image — no host install required + +## One-time setup + +``` +sudo mkdir -p /mnt/NVME/Docker/dragonfork-core +cd /mnt/NVME/Docker/dragonfork-core + +# Pull the repo (or sync deploy files) onto the host. The compose +# build `context:` points at the repo root. +git clone https://forgejo.wilddragon.net/zgaetano/datarhei-dragonfork-core.git +cd datarhei-dragonfork-core/deploy/truenas/core + +cat > .env <"}' \ + http://10.0.0.25:8080/api/login | jq -r '.access_token') + +# Probe the WHEP endpoint — should 404 for an unknown id. +curl -i -H "Authorization: Bearer $TOKEN" \ + -X POST http://10.0.0.25:8080/api/v3/whep/nope +# → HTTP/1.1 404 Not Found + +# Create a process with WebRTC enabled, send RTMP to its input, then +# subscribe the Pion whep-client to /api/v3/whep/. +``` + +## Cutting over from the M1 PoC + +The M1 `webrtc-poc` stack is independent; it binds its own ports. You +can run both side-by-side during the cutover: + +``` +# Stop the M1 stack when you're ready to retire it: +cd /mnt/NVME/Docker/dragonfork-webrtc-poc +docker compose down +``` + +## Teardown + +``` +docker compose down +``` + +## Security notes + +- The WHEP endpoint is mounted under `/api/v3`, which is JWT-protected. + That's the M2 posture — WHEP clients (browsers) need a token. M3 + adds per-process signed-URL tokens so embeds don't require admin + credentials. +- The binary runs as root inside the container; if you need an unpriv + user, mount volumes owned by a fixed UID and add a `user:` directive. + This matches how the upstream datarhei/core image ships. +- Put Caddy or nginx in front for TLS. The media itself is + DTLS-SRTP-encrypted regardless. diff --git a/deploy/truenas/core/docker-compose.yml b/deploy/truenas/core/docker-compose.yml new file mode 100644 index 0000000..47a2136 --- /dev/null +++ b/deploy/truenas/core/docker-compose.yml @@ -0,0 +1,56 @@ +# Dragon Fork datarhei Core — M2 deployment with WebRTC egress. +# +# This replaces the M1 webrtc-poc stack. It runs the real root Core +# binary with the WebRTC subsystem wired into the restream manager, so +# every process whose config has `webrtc.enabled=true` will have its +# output fanned out to WHEP subscribers automatically. +# +# Host networking is required for the same reason as M1: ICE encodes +# host:port pairs into SDP candidates, and bridge-mode port mapping +# breaks that. +# +# Copy this file to /mnt/NVME/Docker/dragonfork-core/ alongside a .env: +# +# PUBLIC_IP=10.0.0.25 +# API_AUTH_USERNAME=admin +# API_AUTH_PASSWORD=change-me-please +# API_AUTH_JWT_SECRET=<32+ random bytes, base64> +# +# Then: +# docker compose up -d --build +# docker compose logs -f + +services: + core: + build: + context: ../../.. # repo root (where go.mod lives) + dockerfile: deploy/truenas/core/Dockerfile + container_name: dragonfork-core + restart: unless-stopped + network_mode: host + environment: + # --- API --- + CORE_ADDRESS: ":${CORE_HTTP_PORT:-8080}" + CORE_API_AUTH_ENABLE: "true" + CORE_API_AUTH_USERNAME: "${API_AUTH_USERNAME:?set in .env}" + CORE_API_AUTH_PASSWORD: "${API_AUTH_PASSWORD:?set in .env}" + CORE_API_AUTH_JWT_SECRET: "${API_AUTH_JWT_SECRET:?set in .env}" + + # --- WebRTC egress --- + CORE_WEBRTC_ENABLE: "true" + CORE_WEBRTC_PUBLIC_IP: "${PUBLIC_IP:?set in .env}" + # Leave NAT1To1_IPS empty unless you need multiple advertised IPs. + # CORE_WEBRTC_NAT_1_TO_1_IPS: "10.0.0.25 203.0.113.10" + + # --- Storage --- + # Let the volumes below provide durable paths; defaults are fine. + + # --- Logging --- + CORE_LOG_LEVEL: "${LOG_LEVEL:-info}" + + volumes: + - ./config:/core/config + - ./data:/core/data + + # No ports: host networking exposes whatever the process binds. + # The WHEP endpoint lives at /api/v3/whep/:id on CORE_HTTP_PORT. diff --git a/deploy/truenas/docker-compose.yml b/deploy/truenas/docker-compose.yml new file mode 100644 index 0000000..4eb4786 --- /dev/null +++ b/deploy/truenas/docker-compose.yml @@ -0,0 +1,36 @@ +# Dragon Fork WebRTC PoC — TrueNAS deployment template. +# +# Host networking is required: WebRTC ICE needs each container-visible +# UDP socket to be reachable from the peer using the LAN (or public) +# IP advertised in SDP. Bridge + port mapping breaks ICE because +# remote candidates encode the peer-visible host:port. +# +# Copy this file to /mnt/NVME/Docker/dragonfork-webrtc-poc/ +# alongside a .env like: +# +# WHEP_PORT=45121 # TCP, the WHEP HTTP listener +# RTP_PORT=49248 # UDP, publisher's RTP ingest port +# STREAM_ID=test +# PUBLIC_IP=10.0.0.25 # LAN IP; rewrites ICE host candidates via NAT1To1. +# Set to your public IP when exposing externally. +# +# Then: +# docker compose up -d --build + +services: + webrtc-poc: + build: + context: ../.. # repo root (where go.mod lives) + dockerfile: deploy/docker/Dockerfile + container_name: dragonfork-webrtc-poc + restart: unless-stopped + network_mode: host + command: + - -stream=${STREAM_ID:-test} + - -rtp-host=${RTP_HOST:-0.0.0.0} + - -rtp-port=${RTP_PORT:?set RTP_PORT} + - -listen=:${WHEP_PORT:?set WHEP_PORT} + - -public-ip=${PUBLIC_IP:-} + # No ports: host networking exposes whatever the process binds. + # No healthcheck: scratch image has no shell. Compose uses exit + # code only; the binary exits non-zero if it can't bind. diff --git a/docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md b/docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md new file mode 100755 index 0000000..e0776a0 --- /dev/null +++ b/docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md @@ -0,0 +1,2081 @@ +# Datarhei - Dragon Fork M1: Media-Path PoC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prove a working end-to-end WebRTC egress path: an FFmpeg publisher pushes RTP into a new Go package, which serves it to a Pion-based WHEP client that successfully decodes video frames. + +**Architecture:** New standalone Go package `core/webrtc` inside the datarhei Core fork. FFmpeg produces RTP on a local UDP socket → package reads RTP → WHEP HTTP endpoint serves it via Pion `PeerConnection` → test client subscribes and decodes. No datarhei process-model integration yet (that's M2). This milestone answers: *does the integration pattern actually work, and what are the gotchas?* + +**Tech Stack:** Go 1.22+, [Pion WebRTC v4](https://github.com/pion/webrtc) (`github.com/pion/webrtc/v4`), [Pion RTP](https://github.com/pion/rtp), FFmpeg 6.x (publisher + test pattern), standard library `net/http`. + +**Out of scope for this plan:** datarhei process-model integration (M2), multi-viewer fan-out polish (M3), CI test harness (M4), branding (M5). Separate plans will be written for each after M1 completes. + +--- + +## Prerequisites + +- Go 1.22+ installed locally +- `git` configured +- FFmpeg 6.x on PATH (`ffmpeg -version` reports 6.0 or newer) +- A Git host account (GitHub recommended for initial fork) +- Linux or macOS development machine (Windows works but UDP port behavior differs; document what you're on when filing any issues) + +--- + +## File Structure + +Files created in this milestone: + +``` +core/webrtc/ + config.go # Config struct + defaults + Validate() + registry.go # stream_id → *Source map, thread-safe + registry_test.go + source.go # RTP UDP reader + fan-out ring buffer + source_test.go + peer.go # PeerConnection factory, track attachment + peer_test.go + whep.go # HTTP handler: POST /whep/{stream_id} + whep_test.go + ice.go # SettingEngine builder (NAT1To1, ICE servers) + ice_test.go + errors.go # Typed error values (ErrStreamNotFound, etc.) + +cmd/webrtc-poc/ + main.go # Standalone PoC binary (NOT datarhei Core yet) + +test/ + publish.sh # FFmpeg publisher script (testsrc2 → local RTP) + whep-client/ + main.go # Pion-based test WHEP subscriber + main_test.go + +docs/design/ + (copy of the approved spec from brainstorming) +``` + +Total new Go files: 11 source + 5 test = 16 files. Total lines: ~1200-1500 including tests and comments. + +--- + +## Task 0: Fork the repo and set up the workspace + +**Files:** +- Create: new fork of `datarhei/core` + +**Rationale:** Everything else depends on having a fork to commit into. No code yet — just repo setup. + +- [ ] **Step 1: Fork datarhei/core on your Git host** + +On GitHub: navigate to https://github.com/datarhei/core, click Fork, name the fork `datarhei-dragonfork-core` under your `wilddragon` org (or personal account). + +- [ ] **Step 2: Clone the fork locally** + +Run: +```bash +git clone git@github.com:wilddragon/datarhei-dragonfork-core.git +cd datarhei-dragonfork-core +``` + +Expected: repo cloned, `git status` clean on `main` branch. + +- [ ] **Step 3: Create the M1 working branch** + +Run: +```bash +git checkout -b m1-webrtc-poc +``` + +Expected: branch created and checked out. + +- [ ] **Step 4: Copy the approved design spec into the repo** + +```bash +mkdir -p docs/design +cp /path/to/2026-04-16-datarhei-dragon-fork-webrtc-design.md docs/design/ +``` + +(Path will be wherever you saved the spec from the brainstorming session.) + +- [ ] **Step 5: Verify the repo builds unchanged** + +Run: +```bash +go build ./... +``` + +Expected: build succeeds. If it fails, the fork is broken before you started — stop and fix upstream issues first. + +- [ ] **Step 6: Run upstream tests** + +Run: +```bash +go test ./... +``` + +Expected: all tests pass (or at least match what upstream CI shows green). Document any pre-existing flakes in a NOTES.md file so you don't later blame your changes for them. + +- [ ] **Step 7: Commit the spec and a NOTES.md baseline** + +```bash +git add docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md NOTES.md +git commit -m "docs: add Dragon Fork WebRTC egress design spec" +``` + +--- + +## Task 1: Add Pion WebRTC dependency + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` + +- [ ] **Step 1: Add Pion dependencies** + +Run from repo root: +```bash +go get github.com/pion/webrtc/v4@latest +go get github.com/pion/rtp@latest +go get github.com/pion/rtcp@latest +``` + +Expected: `go.mod` updated with new `require` entries; `go.sum` updated. + +- [ ] **Step 2: Tidy dependencies** + +Run: +```bash +go mod tidy +``` + +Expected: no errors. `go.mod` stable. + +- [ ] **Step 3: Sanity-check that Pion loads** + +Create a throwaway file `/tmp/pion_smoke.go`: +```go +package main + +import ( + "fmt" + + "github.com/pion/webrtc/v4" +) + +func main() { + api := webrtc.NewAPI() + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + panic(err) + } + fmt.Println("Pion OK, state:", pc.ConnectionState()) + _ = pc.Close() +} +``` + +Run: +```bash +go run /tmp/pion_smoke.go +``` + +Expected output: `Pion OK, state: new` (or similar). Delete the file after. + +- [ ] **Step 4: Commit** + +```bash +git add go.mod go.sum +git commit -m "build: add Pion WebRTC v4 dependency" +``` + +--- + +## Task 2: Create the core/webrtc package skeleton + typed errors + +**Files:** +- Create: `core/webrtc/errors.go` +- Create: `core/webrtc/doc.go` + +- [ ] **Step 1: Create `core/webrtc/doc.go`** + +```go +// Package webrtc implements the Dragon Fork WebRTC egress module. +// +// It exposes a WHEP (WebRTC-HTTP Egress Protocol) HTTP endpoint and serves +// live RTP produced by an FFmpeg process on a local UDP socket to one or +// more WebRTC peer connections built with Pion. +// +// This package is additive: it does not modify existing datarhei ingest, +// transcode, or non-WebRTC output code paths. The only contact with +// existing code is a new URL scheme ("webrtc://") registered with the +// output resolver (done in milestone M2, not here). +package webrtc +``` + +- [ ] **Step 2: Create `core/webrtc/errors.go`** + +```go +package webrtc + +import "errors" + +// Sentinel errors returned by package functions. +var ( + // ErrStreamNotFound indicates a WHEP subscribe referenced a stream_id + // that has no registered source. Maps to HTTP 404. + ErrStreamNotFound = errors.New("webrtc: stream not found") + + // ErrPeerCapReached indicates max_peers_total has been exceeded. + // Maps to HTTP 503. + ErrPeerCapReached = errors.New("webrtc: peer capacity reached") + + // ErrCodecMismatch indicates the viewer's SDP offer does not include + // a codec the source can serve (expected H.264 + Opus). Maps to HTTP 406. + ErrCodecMismatch = errors.New("webrtc: codec mismatch") + + // ErrInvalidSDP indicates the request body was not a parseable SDP offer. + // Maps to HTTP 400. + ErrInvalidSDP = errors.New("webrtc: invalid SDP") + + // ErrICETimeout indicates ICE gathering did not complete within the + // configured timeout. Maps to HTTP 500. + ErrICETimeout = errors.New("webrtc: ICE gathering timeout") +) +``` + +- [ ] **Step 3: Verify the package compiles** + +Run: +```bash +go build ./core/webrtc/... +``` + +Expected: no output (successful build). + +- [ ] **Step 4: Commit** + +```bash +git add core/webrtc/doc.go core/webrtc/errors.go +git commit -m "feat(webrtc): add package skeleton and typed errors" +``` + +--- + +## Task 3: Config struct + +**Files:** +- Create: `core/webrtc/config.go` +- Create: `core/webrtc/config_test.go` + +- [ ] **Step 1: Write the failing test `core/webrtc/config_test.go`** + +```go +package webrtc + +import ( + "testing" +) + +func TestConfig_Defaults(t *testing.T) { + c := DefaultConfig() + if !c.Enabled { + t.Error("default Enabled should be true") + } + if c.WHEPListen != ":8787" { + t.Errorf("default WHEPListen = %q, want :8787", c.WHEPListen) + } + if c.UDPPortRange.Low != 10000 || c.UDPPortRange.High != 10100 { + t.Errorf("default UDPPortRange = %v, want 10000-10100", c.UDPPortRange) + } + if c.MaxPeersTotal != 32 { + t.Errorf("default MaxPeersTotal = %d, want 32", c.MaxPeersTotal) + } + if len(c.ICEServers) == 0 { + t.Error("default ICEServers should have at least one STUN entry") + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + {"defaults are valid", func(c *Config) {}, false}, + {"empty listen", func(c *Config) { c.WHEPListen = "" }, true}, + {"inverted port range", func(c *Config) { c.UDPPortRange.Low = 20000; c.UDPPortRange.High = 10000 }, true}, + {"zero max peers", func(c *Config) { c.MaxPeersTotal = 0 }, true}, + {"negative max peers", func(c *Config) { c.MaxPeersTotal = -1 }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DefaultConfig() + tt.mutate(&c) + err := c.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestConfig -v +``` + +Expected: FAIL with "undefined: Config" / "undefined: DefaultConfig". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/config.go`** + +```go +package webrtc + +import "fmt" + +// PortRange represents an inclusive UDP port range. +type PortRange struct { + Low, High int +} + +// Config controls the WebRTC egress module. +type Config struct { + // Enabled toggles the entire module. When false, no endpoints are served. + Enabled bool + + // WHEPListen is the address the WHEP HTTP endpoint binds to (e.g. ":8787"). + WHEPListen string + + // PublicIP is the server's externally-reachable IP, advertised in ICE + // candidates via NAT1To1. Empty means rely on STUN discovery. + PublicIP string + + // UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP. + UDPPortRange PortRange + + // ICEServers is the list of STUN/TURN URIs given to each PeerConnection. + ICEServers []string + + // MaxPeersTotal is a hard safety cap on concurrent subscribers. + MaxPeersTotal int +} + +// DefaultConfig returns production-reasonable defaults. +func DefaultConfig() Config { + return Config{ + Enabled: true, + WHEPListen: ":8787", + PublicIP: "", + UDPPortRange: PortRange{Low: 10000, High: 10100}, + ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}, + MaxPeersTotal: 32, + } +} + +// Validate returns an error if the config is internally inconsistent. +func (c Config) Validate() error { + if c.WHEPListen == "" { + return fmt.Errorf("webrtc: WHEPListen must not be empty") + } + if c.UDPPortRange.Low <= 0 || c.UDPPortRange.High <= 0 { + return fmt.Errorf("webrtc: UDPPortRange must have positive bounds, got %v", c.UDPPortRange) + } + if c.UDPPortRange.Low > c.UDPPortRange.High { + return fmt.Errorf("webrtc: UDPPortRange.Low > High (%d > %d)", c.UDPPortRange.Low, c.UDPPortRange.High) + } + if c.MaxPeersTotal <= 0 { + return fmt.Errorf("webrtc: MaxPeersTotal must be positive, got %d", c.MaxPeersTotal) + } + return nil +} +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestConfig -v +``` + +Expected: PASS for both `TestConfig_Defaults` and `TestConfig_Validate` (all subtests). + +- [ ] **Step 5: Commit** + +```bash +git add core/webrtc/config.go core/webrtc/config_test.go +git commit -m "feat(webrtc): add Config with defaults and validation" +``` + +--- + +## Task 4: Registry — stream_id → Source mapping + +**Files:** +- Create: `core/webrtc/registry.go` +- Create: `core/webrtc/registry_test.go` + +A **Source** (defined in the next task) represents a live RTP stream that peers can subscribe to. The **Registry** is the thread-safe map that maps stream IDs to active sources. Writing this first because Source depends on it less than it does on Source. + +- [ ] **Step 1: Write the failing test `core/webrtc/registry_test.go`** + +```go +package webrtc + +import ( + "sync" + "testing" +) + +// mockSource implements the minimum Source-like shape needed by the registry. +// The real Source type is defined in Task 5; the registry only needs a +// stable type to store and retrieve. +type mockSource struct { + id string +} + +func (m *mockSource) ID() string { return m.id } + +func TestRegistry_RegisterAndLookup(t *testing.T) { + r := NewRegistry() + src := &mockSource{id: "streamA"} + + if err := r.Register("streamA", src); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + got, ok := r.Lookup("streamA") + if !ok { + t.Fatal("Lookup(streamA) returned ok=false, want true") + } + if got != src { + t.Errorf("Lookup returned %v, want %v", got, src) + } +} + +func TestRegistry_LookupMissing(t *testing.T) { + r := NewRegistry() + _, ok := r.Lookup("nope") + if ok { + t.Error("Lookup on empty registry returned ok=true, want false") + } +} + +func TestRegistry_DuplicateRegister(t *testing.T) { + r := NewRegistry() + _ = r.Register("streamA", &mockSource{id: "streamA"}) + + if err := r.Register("streamA", &mockSource{id: "streamA"}); err == nil { + t.Error("duplicate Register should return error, got nil") + } +} + +func TestRegistry_Deregister(t *testing.T) { + r := NewRegistry() + _ = r.Register("streamA", &mockSource{id: "streamA"}) + r.Deregister("streamA") + + if _, ok := r.Lookup("streamA"); ok { + t.Error("after Deregister, Lookup should return ok=false") + } +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + r := NewRegistry() + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(3) + id := string(rune('a' + (i % 26))) + go func() { defer wg.Done(); _ = r.Register(id, &mockSource{id: id}) }() + go func() { defer wg.Done(); _, _ = r.Lookup(id) }() + go func() { defer wg.Done(); r.Deregister(id) }() + } + wg.Wait() + // No assertion — test passes if -race doesn't flag anything. +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestRegistry -v +``` + +Expected: FAIL with "undefined: NewRegistry". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/registry.go`** + +```go +package webrtc + +import ( + "fmt" + "sync" +) + +// SourceHandle is the minimal interface the Registry stores per stream_id. +// The concrete type is *Source, defined in source.go. +type SourceHandle interface { + ID() string +} + +// Registry is a thread-safe map from stream_id to active SourceHandle. +type Registry struct { + mu sync.RWMutex + streams map[string]SourceHandle +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{streams: make(map[string]SourceHandle)} +} + +// Register associates src with streamID. Returns an error if streamID is +// already registered. +func (r *Registry) Register(streamID string, src SourceHandle) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.streams[streamID]; exists { + return fmt.Errorf("webrtc: stream %q already registered", streamID) + } + r.streams[streamID] = src + return nil +} + +// Lookup returns the handle for streamID. The second return value is false +// if no source is registered. +func (r *Registry) Lookup(streamID string) (SourceHandle, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + src, ok := r.streams[streamID] + return src, ok +} + +// Deregister removes streamID. No-op if not present. +func (r *Registry) Deregister(streamID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.streams, streamID) +} +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestRegistry -v -race +``` + +Expected: PASS for all TestRegistry subtests, no data races. + +- [ ] **Step 5: Commit** + +```bash +git add core/webrtc/registry.go core/webrtc/registry_test.go +git commit -m "feat(webrtc): add thread-safe Registry for stream_id → SourceHandle" +``` + +--- + +## Task 5: Source — RTP UDP reader + subscriber fan-out + +**Files:** +- Create: `core/webrtc/source.go` +- Create: `core/webrtc/source_test.go` + +A Source owns a UDP socket bound to a local port, reads RTP packets, and forwards them to every subscribed peer's video/audio track. For M1, we deliberately keep the fan-out simple (per-subscriber goroutine writing to a buffered channel) because at 1–5 viewers the naive model is entirely sufficient. + +- [ ] **Step 1: Write the failing test `core/webrtc/source_test.go`** + +```go +package webrtc + +import ( + "net" + "testing" + "time" + + "github.com/pion/rtp" +) + +func TestSource_ID(t *testing.T) { + s, err := NewSource("streamA", 0) // 0 = ephemeral port + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + if s.ID() != "streamA" { + t.Errorf("ID() = %q, want streamA", s.ID()) + } +} + +func TestSource_ReceiveAndFanout(t *testing.T) { + s, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + // Subscribe before sending. + sub := s.Subscribe(16) // buffer depth 16 + defer s.Unsubscribe(sub) + + s.Start() + + // Build and send a minimal RTP packet to the source's UDP port. + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: 96, + SequenceNumber: 1, + Timestamp: 1000, + SSRC: 0xDEADBEEF, + }, + Payload: []byte{0x01, 0x02, 0x03, 0x04}, + } + raw, err := pkt.Marshal() + if err != nil { + t.Fatalf("pkt.Marshal: %v", err) + } + + conn, err := net.Dial("udp", s.LocalAddr().String()) + if err != nil { + t.Fatalf("net.Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write(raw); err != nil { + t.Fatalf("conn.Write: %v", err) + } + + select { + case got := <-sub: + if got.SSRC != 0xDEADBEEF { + t.Errorf("received SSRC = %x, want DEADBEEF", got.SSRC) + } + if got.SequenceNumber != 1 { + t.Errorf("received SeqNum = %d, want 1", got.SequenceNumber) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for RTP packet on subscriber channel") + } +} + +func TestSource_MultipleSubscribers(t *testing.T) { + s, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer s.Close() + + subs := []chan *rtp.Packet{ + s.Subscribe(8), + s.Subscribe(8), + s.Subscribe(8), + } + for _, sub := range subs { + defer s.Unsubscribe(sub) + } + + s.Start() + + raw, _ := (&rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: 42, SSRC: 1}, + Payload: []byte{0xAA}, + }).Marshal() + conn, _ := net.Dial("udp", s.LocalAddr().String()) + defer conn.Close() + _, _ = conn.Write(raw) + + for i, sub := range subs { + select { + case got := <-sub: + if got.SequenceNumber != 42 { + t.Errorf("sub %d got seq %d, want 42", i, got.SequenceNumber) + } + case <-time.After(2 * time.Second): + t.Errorf("sub %d timed out", i) + } + } +} + +func TestSource_UnsubscribeStopsDelivery(t *testing.T) { + s, _ := NewSource("streamA", 0) + defer s.Close() + sub := s.Subscribe(8) + s.Start() + s.Unsubscribe(sub) + + // After Unsubscribe, the channel should be closed. + select { + case _, ok := <-sub: + if ok { + t.Error("expected channel closed after Unsubscribe, got value") + } + case <-time.After(500 * time.Millisecond): + t.Error("timed out waiting for channel close") + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestSource -v +``` + +Expected: FAIL with "undefined: NewSource". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/source.go`** + +```go +package webrtc + +import ( + "fmt" + "net" + "sync" + + "github.com/pion/rtp" +) + +// Source reads RTP packets from a local UDP socket and fans them out to +// subscribed peers via per-subscriber buffered channels. +type Source struct { + id string + conn *net.UDPConn + + mu sync.Mutex + subscribers map[chan *rtp.Packet]struct{} + started bool + closed bool + done chan struct{} +} + +// NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS +// assign an ephemeral port (useful for tests). +func NewSource(streamID string, port int) (*Source, error) { + addr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port} + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return nil, fmt.Errorf("webrtc: listen udp: %w", err) + } + return &Source{ + id: streamID, + conn: conn, + subscribers: make(map[chan *rtp.Packet]struct{}), + done: make(chan struct{}), + }, nil +} + +// ID returns the registered stream identifier. +func (s *Source) ID() string { return s.id } + +// LocalAddr returns the UDP address the source is listening on. +func (s *Source) LocalAddr() *net.UDPAddr { + return s.conn.LocalAddr().(*net.UDPAddr) +} + +// Subscribe returns a new buffered channel that receives every RTP packet +// read from the UDP socket. bufDepth is the channel buffer size; when full, +// packets are dropped (preventing a slow subscriber from back-pressuring +// the reader). +func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet { + ch := make(chan *rtp.Packet, bufDepth) + s.mu.Lock() + s.subscribers[ch] = struct{}{} + s.mu.Unlock() + return ch +} + +// Unsubscribe removes ch from the subscriber set and closes it. +func (s *Source) Unsubscribe(ch chan *rtp.Packet) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.subscribers[ch]; ok { + delete(s.subscribers, ch) + close(ch) + } +} + +// Start begins the RTP reader goroutine. Safe to call once; subsequent calls +// are no-ops. +func (s *Source) Start() { + s.mu.Lock() + if s.started || s.closed { + s.mu.Unlock() + return + } + s.started = true + s.mu.Unlock() + + go s.readLoop() +} + +func (s *Source) readLoop() { + buf := make([]byte, 1500) // MTU-sized; RTP over UDP should fit + for { + select { + case <-s.done: + return + default: + } + + n, _, err := s.conn.ReadFromUDP(buf) + if err != nil { + // Socket closed or error — exit the loop. + return + } + + pkt := &rtp.Packet{} + if err := pkt.Unmarshal(buf[:n]); err != nil { + // Malformed packet; skip without crashing. + continue + } + + s.mu.Lock() + for ch := range s.subscribers { + select { + case ch <- pkt: + default: + // Subscriber full — drop to protect the reader. + } + } + s.mu.Unlock() + } +} + +// Close stops the reader goroutine, closes the UDP socket, and closes every +// subscriber channel. +func (s *Source) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + close(s.done) + for ch := range s.subscribers { + delete(s.subscribers, ch) + close(ch) + } + s.mu.Unlock() + return s.conn.Close() +} +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestSource -v -race +``` + +Expected: PASS for all TestSource subtests, no data races. + +- [ ] **Step 5: Commit** + +```bash +git add core/webrtc/source.go core/webrtc/source_test.go +git commit -m "feat(webrtc): add Source with UDP RTP reader and subscriber fan-out" +``` + +--- + +## Task 6: ICE config helper (SettingEngine builder) + +**Files:** +- Create: `core/webrtc/ice.go` +- Create: `core/webrtc/ice_test.go` + +Isolated helper that translates our `Config` into Pion's `SettingEngine` + `Configuration` pair. Keeping it separate makes peer.go simpler and the ICE config trivially testable. + +- [ ] **Step 1: Write the failing test `core/webrtc/ice_test.go`** + +```go +package webrtc + +import ( + "testing" + + "github.com/pion/webrtc/v4" +) + +func TestBuildICEConfig_Defaults(t *testing.T) { + c := DefaultConfig() + rtcConfig, _, err := BuildICEConfig(c) + if err != nil { + t.Fatalf("BuildICEConfig: %v", err) + } + if len(rtcConfig.ICEServers) == 0 { + t.Error("ICEServers should not be empty") + } + // First default is Cloudflare STUN. + if rtcConfig.ICEServers[0].URLs[0] != "stun:stun.cloudflare.com:3478" { + t.Errorf("first ICE server = %q, want stun:stun.cloudflare.com:3478", + rtcConfig.ICEServers[0].URLs[0]) + } +} + +func TestBuildICEConfig_PublicIP(t *testing.T) { + c := DefaultConfig() + c.PublicIP = "203.0.113.10" + _, se, err := BuildICEConfig(c) + if err != nil { + t.Fatalf("BuildICEConfig: %v", err) + } + if se == nil { + t.Fatal("SettingEngine should not be nil when PublicIP is set") + } + // We can't introspect NAT1To1IPs directly from Pion's public API; the + // smoke test is that building an API from this engine works. + api := webrtc.NewAPI(webrtc.WithSettingEngine(*se)) + if api == nil { + t.Fatal("NewAPI returned nil") + } +} + +func TestBuildICEConfig_InvalidConfig(t *testing.T) { + c := DefaultConfig() + c.WHEPListen = "" + _, _, err := BuildICEConfig(c) + if err == nil { + t.Error("BuildICEConfig should reject invalid config") + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestBuildICEConfig -v +``` + +Expected: FAIL with "undefined: BuildICEConfig". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/ice.go`** + +```go +package webrtc + +import ( + "github.com/pion/webrtc/v4" +) + +// BuildICEConfig translates a Config into the two Pion config pieces every +// PeerConnection needs: a webrtc.Configuration (with ICE servers) and a +// SettingEngine (with NAT1To1 and port range tuning). +// +// The returned *SettingEngine may be nil if no engine-level tuning is +// required (i.e. PublicIP unset and UDPPortRange at defaults). Callers +// should only pass it to webrtc.NewAPI when non-nil. +func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) { + if err := c.Validate(); err != nil { + return webrtc.Configuration{}, nil, err + } + + rtcConfig := webrtc.Configuration{ + ICEServers: make([]webrtc.ICEServer, 0, len(c.ICEServers)), + } + for _, uri := range c.ICEServers { + rtcConfig.ICEServers = append(rtcConfig.ICEServers, webrtc.ICEServer{ + URLs: []string{uri}, + }) + } + + var se *webrtc.SettingEngine + if c.PublicIP != "" || c.UDPPortRange.Low > 0 { + engine := webrtc.SettingEngine{} + if c.PublicIP != "" { + engine.SetNAT1To1IPs([]string{c.PublicIP}, webrtc.ICECandidateTypeHost) + } + // Constrain the ephemeral UDP range Pion allocates for ICE candidates. + // Note: this is a separate concern from our FFmpeg→Source UDP ports; + // Pion uses its own port pool for the WebRTC media path. + if c.UDPPortRange.Low > 0 && c.UDPPortRange.High >= c.UDPPortRange.Low { + if err := engine.SetEphemeralUDPPortRange( + uint16(c.UDPPortRange.Low), uint16(c.UDPPortRange.High)); err != nil { + return webrtc.Configuration{}, nil, err + } + } + se = &engine + } + + return rtcConfig, se, nil +} +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestBuildICEConfig -v +``` + +Expected: PASS for all TestBuildICEConfig subtests. + +- [ ] **Step 5: Commit** + +```bash +git add core/webrtc/ice.go core/webrtc/ice_test.go +git commit -m "feat(webrtc): add ICE config helper (Configuration + SettingEngine)" +``` + +--- + +## Task 7: Peer — PeerConnection factory + track attachment + +**Files:** +- Create: `core/webrtc/peer.go` +- Create: `core/webrtc/peer_test.go` + +Creates a Pion `PeerConnection`, adds video (H.264) and audio (Opus) `TrackLocalStaticRTP`, negotiates SDP, and starts a goroutine that forwards packets from a Source subscription into those tracks. + +- [ ] **Step 1: Write the failing test `core/webrtc/peer_test.go`** + +```go +package webrtc + +import ( + "context" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +// minimalOfferSDP returns an SDP offer that advertises H.264 (video) and +// Opus (audio) as recvonly — the minimum a WHEP client sends. +func minimalOfferSDP(t *testing.T) webrtc.SessionDescription { + t.Helper() + // Create a throwaway PC to generate a valid offer. + me := &webrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + t.Fatalf("RegisterDefaultCodecs: %v", err) + } + api := webrtc.NewAPI(webrtc.WithMediaEngine(me)) + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + defer pc.Close() + + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("AddTransceiver video: %v", err) + } + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + t.Fatalf("AddTransceiver audio: %v", err) + } + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer: %v", err) + } + return offer +} + +func TestPeerFactory_CreateAnswer(t *testing.T) { + src, err := NewSource("streamA", 0) + if err != nil { + t.Fatalf("NewSource: %v", err) + } + defer src.Close() + src.Start() + + cfg := DefaultConfig() + factory, err := NewPeerFactory(cfg) + if err != nil { + t.Fatalf("NewPeerFactory: %v", err) + } + + offer := minimalOfferSDP(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + peer, err := factory.CreatePeer(ctx, src, offer) + if err != nil { + t.Fatalf("CreatePeer: %v", err) + } + defer peer.Close() + + if peer.Answer().Type != webrtc.SDPTypeAnswer { + t.Errorf("Answer().Type = %v, want answer", peer.Answer().Type) + } + if peer.ResourceID() == "" { + t.Error("ResourceID should be non-empty") + } +} + +func TestPeerFactory_ClosesCleanly(t *testing.T) { + src, _ := NewSource("streamA", 0) + defer src.Close() + src.Start() + + factory, _ := NewPeerFactory(DefaultConfig()) + offer := minimalOfferSDP(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + peer, err := factory.CreatePeer(ctx, src, offer) + if err != nil { + t.Fatalf("CreatePeer: %v", err) + } + if err := peer.Close(); err != nil { + t.Errorf("Close: %v", err) + } + // Second close should be a no-op, not panic. + if err := peer.Close(); err != nil { + t.Errorf("second Close: %v", err) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestPeerFactory -v +``` + +Expected: FAIL with "undefined: NewPeerFactory". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/peer.go`** + +```go +package webrtc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +// PeerFactory builds PeerConnections from a shared Pion API instance +// configured from Config. +type PeerFactory struct { + api *webrtc.API + rtcConfig webrtc.Configuration +} + +// NewPeerFactory initializes a Pion API with the codec set we support +// (H.264 + Opus) and applies the provided Config. +func NewPeerFactory(c Config) (*PeerFactory, error) { + if err := c.Validate(); err != nil { + return nil, err + } + + me := &webrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + return nil, fmt.Errorf("webrtc: register default codecs: %w", err) + } + + rtcConfig, se, err := BuildICEConfig(c) + if err != nil { + return nil, err + } + + opts := []func(*webrtc.API){webrtc.WithMediaEngine(me)} + if se != nil { + opts = append(opts, webrtc.WithSettingEngine(*se)) + } + api := webrtc.NewAPI(opts...) + + return &PeerFactory{api: api, rtcConfig: rtcConfig}, nil +} + +// Peer wraps a Pion PeerConnection bound to a Source's subscription. +type Peer struct { + resourceID string + pc *webrtc.PeerConnection + answer webrtc.SessionDescription + source *Source + sub chan *rtp.Packet + done chan struct{} + once sync.Once +} + +// CreatePeer builds a PeerConnection, sets the remote offer, generates an +// answer, attaches video+audio tracks fed from src, and blocks until ICE +// gathering completes or ctx expires. +func (f *PeerFactory) CreatePeer(ctx context.Context, src *Source, offer webrtc.SessionDescription) (*Peer, error) { + pc, err := f.api.NewPeerConnection(f.rtcConfig) + if err != nil { + return nil, fmt.Errorf("webrtc: new peer connection: %w", err) + } + + videoTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, + "video", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new video track: %w", err) + } + audioTrack, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + "audio", "dragonfork") + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: new audio track: %w", err) + } + if _, err := pc.AddTrack(videoTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add video track: %w", err) + } + if _, err := pc.AddTrack(audioTrack); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: add audio track: %w", err) + } + + if err := pc.SetRemoteDescription(offer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set remote: %w", err) + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: create answer: %w", err) + } + + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(answer); err != nil { + _ = pc.Close() + return nil, fmt.Errorf("webrtc: set local: %w", err) + } + + select { + case <-gatherComplete: + case <-ctx.Done(): + _ = pc.Close() + return nil, ErrICETimeout + } + + sub := src.Subscribe(64) + + p := &Peer{ + resourceID: newResourceID(), + pc: pc, + answer: *pc.LocalDescription(), + source: src, + sub: sub, + done: make(chan struct{}), + } + + pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) { + if st == webrtc.PeerConnectionStateFailed || + st == webrtc.PeerConnectionStateDisconnected || + st == webrtc.PeerConnectionStateClosed { + _ = p.Close() + } + }) + + go forwardRTP(p.done, sub, videoTrack, audioTrack) + + return p, nil +} + +// Answer returns the locally-created SDP answer. Valid after CreatePeer. +func (p *Peer) Answer() webrtc.SessionDescription { return p.answer } + +// ResourceID returns the stable resource id used in the WHEP Location header. +func (p *Peer) ResourceID() string { return p.resourceID } + +// Close tears down the peer connection and unsubscribes from the source. +// Safe to call multiple times. +func (p *Peer) Close() error { + var err error + p.once.Do(func() { + close(p.done) + p.source.Unsubscribe(p.sub) + err = p.pc.Close() + }) + return err +} + +func newResourceID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} +``` + +- [ ] **Step 4: Create `core/webrtc/forward.go` with the RTP forwarder** + +```go +package webrtc + +import ( + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +// forwardRTP reads packets from sub and writes them to the correct track +// based on payload type (H.264 → video, Opus → audio). Payload-type +// inspection is the simplest M1 approach; M2 will switch to per-track +// source channels once the process resolver manages separate video/audio +// UDP ports. +func forwardRTP(done <-chan struct{}, sub <-chan *rtp.Packet, + video, audio *webrtc.TrackLocalStaticRTP) { + for { + select { + case <-done: + return + case pkt, ok := <-sub: + if !ok { + return + } + // Pion default H.264 PT = 102, Opus PT = 111. If the publisher + // uses different PTs we'll revisit in M2 — for M1 PoC we + // configure FFmpeg to these values explicitly in the publisher + // script. + switch pkt.PayloadType { + case 102: + _ = video.WriteRTP(pkt) + case 111: + _ = audio.WriteRTP(pkt) + default: + // Unknown PT — drop. Log in M3. + } + } + } +} +``` + +- [ ] **Step 5: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestPeerFactory -v +``` + +Expected: PASS for `TestPeerFactory_CreateAnswer` and `TestPeerFactory_ClosesCleanly`. + +- [ ] **Step 6: Commit** + +```bash +git add core/webrtc/peer.go core/webrtc/peer_test.go core/webrtc/forward.go +git commit -m "feat(webrtc): add PeerFactory, Peer, and RTP forwarder" +``` + +--- + +## Task 8: WHEP HTTP handler (happy path only) + +**Files:** +- Create: `core/webrtc/whep.go` +- Create: `core/webrtc/whep_test.go` + +For M1, the WHEP handler supports only `POST /whep/{stream_id}` happy path. Error paths (404/406/503) and DELETE/PATCH come in M3. + +- [ ] **Step 1: Write the failing test `core/webrtc/whep_test.go`** + +```go +package webrtc + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +func TestWHEP_POSTReturns201WithSDP(t *testing.T) { + // Set up a Source and register it. + src, _ := NewSource("streamA", 0) + defer src.Close() + src.Start() + + reg := NewRegistry() + _ = reg.Register("streamA", src) + + factory, _ := NewPeerFactory(DefaultConfig()) + + handler := NewWHEPHandler(reg, factory, DefaultConfig()) + + // Build an offer using a throwaway PC. + me := &webrtc.MediaEngine{} + _ = me.RegisterDefaultCodecs() + api := webrtc.NewAPI(webrtc.WithMediaEngine(me)) + pc, _ := api.NewPeerConnection(webrtc.Configuration{}) + defer pc.Close() + _, _ = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}) + _, _ = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}) + offer, _ := pc.CreateOffer(nil) + + req := httptest.NewRequest(http.MethodPost, "/whep/streamA", + strings.NewReader(offer.SDP)) + req.Header.Set("Content-Type", "application/sdp") + // Give the handler generous ICE gathering time in tests. + ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + body, _ := io.ReadAll(rr.Result().Body) + t.Fatalf("status = %d, want 201. body=%s", rr.Code, string(body)) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/sdp" { + t.Errorf("Content-Type = %q, want application/sdp", ct) + } + if loc := rr.Header().Get("Location"); !strings.HasPrefix(loc, "/whep/streamA/") { + t.Errorf("Location = %q, want /whep/streamA/", loc) + } + if !strings.Contains(rr.Body.String(), "v=0") { + t.Errorf("body does not look like SDP: %s", rr.Body.String()) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: +```bash +go test ./core/webrtc/ -run TestWHEP -v +``` + +Expected: FAIL with "undefined: NewWHEPHandler". + +- [ ] **Step 3: Write the minimal implementation `core/webrtc/whep.go`** + +```go +package webrtc + +import ( + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + + "github.com/pion/webrtc/v4" +) + +// WHEPHandler serves the WebRTC-HTTP Egress Protocol POST. +type WHEPHandler struct { + registry *Registry + factory *PeerFactory + config Config + + mu sync.Mutex + peers map[string]*Peer // resourceID → Peer + peersCount int64 // atomic, for cap check without lock +} + +// NewWHEPHandler constructs a handler with the given dependencies. +func NewWHEPHandler(r *Registry, f *PeerFactory, c Config) *WHEPHandler { + return &WHEPHandler{ + registry: r, + factory: f, + config: c, + peers: make(map[string]*Peer), + } +} + +// ServeHTTP handles POST /whep/{stream_id}. Other methods and paths return 405. +func (h *WHEPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract stream_id from path: /whep/{stream_id} + streamID := strings.TrimPrefix(r.URL.Path, "/whep/") + if streamID == "" || strings.Contains(streamID, "/") { + http.Error(w, "invalid stream id", http.StatusBadRequest) + return + } + + // Peer cap enforcement (happy path still respects the cap). + if atomic.LoadInt64(&h.peersCount) >= int64(h.config.MaxPeersTotal) { + http.Error(w, ErrPeerCapReached.Error(), http.StatusServiceUnavailable) + return + } + + handle, ok := h.registry.Lookup(streamID) + if !ok { + http.Error(w, ErrStreamNotFound.Error(), http.StatusNotFound) + return + } + src, ok := handle.(*Source) + if !ok { + http.Error(w, "registered source is not a *Source", http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + if len(body) == 0 { + http.Error(w, ErrInvalidSDP.Error(), http.StatusBadRequest) + return + } + + offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} + + peer, err := h.factory.CreatePeer(r.Context(), src, offer) + if err != nil { + http.Error(w, "create peer: "+err.Error(), http.StatusInternalServerError) + return + } + + h.mu.Lock() + h.peers[peer.ResourceID()] = peer + h.mu.Unlock() + atomic.AddInt64(&h.peersCount, 1) + + w.Header().Set("Content-Type", "application/sdp") + w.Header().Set("Location", "/whep/"+streamID+"/"+peer.ResourceID()) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, peer.Answer().SDP) +} +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: +```bash +go test ./core/webrtc/ -run TestWHEP -v +``` + +Expected: PASS for `TestWHEP_POSTReturns201WithSDP`. + +- [ ] **Step 5: Run the full package test suite** + +Run: +```bash +go test ./core/webrtc/... -v -race +``` + +Expected: ALL PASS. No race warnings. + +- [ ] **Step 6: Commit** + +```bash +git add core/webrtc/whep.go core/webrtc/whep_test.go +git commit -m "feat(webrtc): add WHEP POST handler (happy path)" +``` + +--- + +## Task 9: Standalone PoC binary + +**Files:** +- Create: `cmd/webrtc-poc/main.go` + +The PoC binary wires everything together: creates a Source, registers it, starts the WHEP handler, and blocks. M2 replaces this with integration into datarhei Core's normal startup. + +- [ ] **Step 1: Write `cmd/webrtc-poc/main.go`** + +```go +// Command webrtc-poc runs a minimal Dragon Fork WebRTC egress server for +// manual end-to-end testing. It listens for RTP on 127.0.0.1:10000 as +// stream "test" and serves WHEP at :8787. +// +// This is NOT part of the datarhei Core binary. It will be removed or +// demoted to an internal test helper once milestone M2 lands. +package main + +import ( + "flag" + "log" + "net/http" + + "/core/webrtc" +) + +func main() { + var ( + streamID = flag.String("stream", "test", "stream id to serve") + rtpPort = flag.Int("rtp-port", 10000, "UDP port to receive RTP on") + listen = flag.String("listen", ":8787", "WHEP HTTP listen address") + publicIP = flag.String("public-ip", "", "server public IP for NAT1To1 (optional)") + ) + flag.Parse() + + cfg := webrtc.DefaultConfig() + cfg.WHEPListen = *listen + cfg.PublicIP = *publicIP + + src, err := webrtc.NewSource(*streamID, *rtpPort) + if err != nil { + log.Fatalf("NewSource: %v", err) + } + src.Start() + defer src.Close() + log.Printf("listening for RTP on %s", src.LocalAddr()) + + reg := webrtc.NewRegistry() + if err := reg.Register(*streamID, src); err != nil { + log.Fatalf("Register: %v", err) + } + + factory, err := webrtc.NewPeerFactory(cfg) + if err != nil { + log.Fatalf("NewPeerFactory: %v", err) + } + + handler := webrtc.NewWHEPHandler(reg, factory, cfg) + + mux := http.NewServeMux() + mux.Handle("/whep/", handler) + + log.Printf("WHEP listening on %s — POST /whep/%s to subscribe", *listen, *streamID) + log.Fatal(http.ListenAndServe(*listen, mux)) +} +``` + +- [ ] **Step 2: Substitute the real module path** + +Open the file and replace `` with the actual module path from your fork's `go.mod` first line. For example, if `go.mod` starts with `module github.com/wilddragon/datarhei-dragonfork-core`, the import becomes: + +```go +import "github.com/wilddragon/datarhei-dragonfork-core/core/webrtc" +``` + +Do this once by editing the file — do not commit the placeholder string. + +- [ ] **Step 3: Build the binary** + +Run: +```bash +go build -o /tmp/webrtc-poc ./cmd/webrtc-poc +``` + +Expected: binary produced at `/tmp/webrtc-poc`, no build errors. + +- [ ] **Step 4: Smoke-run it** + +Run: +```bash +/tmp/webrtc-poc -stream test -rtp-port 10000 -listen :8787 & +sleep 1 +curl -sS -X POST -H 'Content-Type: application/sdp' \ + --data 'v=0' http://127.0.0.1:8787/whep/test +``` + +Expected: curl returns a response (will likely be 400 or 500 because the body isn't a real offer — that's fine; the important thing is the server is up and routing the request). The server log shows the POST arrived. + +Kill the server: +```bash +kill %1 +wait 2>/dev/null +``` + +- [ ] **Step 5: Commit** + +```bash +git add cmd/webrtc-poc/main.go +git commit -m "feat(webrtc): add standalone webrtc-poc binary for M1 testing" +``` + +--- + +## Task 10: FFmpeg publisher script + +**Files:** +- Create: `test/publish.sh` + +A shell script that runs FFmpeg to generate a test pattern and push it as RTP to the PoC binary's port. Uses `testsrc2` with a burned-in timecode (useful later for latency measurement). + +- [ ] **Step 1: Create `test/publish.sh`** + +```bash +#!/usr/bin/env bash +# test/publish.sh — Dragon Fork M1 publisher +# +# Pushes an FFmpeg testsrc2 pattern as H.264 + Opus RTP to the webrtc-poc +# binary's local UDP port(s). Requires FFmpeg 6.x. +set -euo pipefail + +HOST="${HOST:-127.0.0.1}" +VIDEO_PORT="${VIDEO_PORT:-10000}" +AUDIO_PORT="${AUDIO_PORT:-10002}" +FPS="${FPS:-30}" +SIZE="${SIZE:-640x360}" + +echo "publishing testsrc2 → $HOST:$VIDEO_PORT (video, PT=102)" +echo " $HOST:$AUDIO_PORT (audio, PT=111)" + +exec ffmpeg -hide_banner -re \ + -f lavfi -i "testsrc2=size=${SIZE}:rate=${FPS}" \ + -f lavfi -i "sine=frequency=440:sample_rate=48000" \ + -vf "drawtext=text='%{localtime\\:%H\\\\\\:%M\\\\\\:%S.%3N}':x=10:y=10:fontsize=32:fontcolor=white:box=1:boxcolor=black@0.8" \ + -c:v libx264 -preset ultrafast -tune zerolatency \ + -profile:v baseline -pix_fmt yuv420p \ + -b:v 1500k -maxrate 1500k -bufsize 500k \ + -g 60 -keyint_min 60 -x264-params "repeat-headers=1" \ + -payload_type 102 -f rtp "rtp://${HOST}:${VIDEO_PORT}?pkt_size=1200" \ + -c:a libopus -b:a 128k \ + -payload_type 111 -f rtp "rtp://${HOST}:${AUDIO_PORT}?pkt_size=1200" +``` + +- [ ] **Step 2: Make it executable** + +Run: +```bash +chmod +x test/publish.sh +``` + +- [ ] **Step 3: Smoke-test it against the PoC binary** + +In one terminal: +```bash +/tmp/webrtc-poc -stream test -rtp-port 10000 -listen :8787 +``` + +In another (will fail until we bind both ports — M1 uses a single source port; the script pushes audio to 10002 but the PoC only listens on 10000 — that's fine for M1 since we're primarily testing video): +```bash +./test/publish.sh +``` + +Expected: FFmpeg runs and prints RTP packet stats. The PoC binary logs nothing specifically (it's silently reading RTP — we haven't added verbose logging in M1). Let it run 3 seconds, then Ctrl+C both. + +(In M2 we'll separate video and audio into distinct sources; for M1 the single-port video-only path is enough to prove the pipeline.) + +- [ ] **Step 4: Commit** + +```bash +git add test/publish.sh +git commit -m "test: add FFmpeg publisher script for M1 PoC" +``` + +--- + +## Task 11: Pion-based test WHEP client + +**Files:** +- Create: `test/whep-client/main.go` +- Create: `test/whep-client/main_test.go` + +A Go binary that acts as a headless WHEP client: POSTs an SDP offer, parses the SDP answer, establishes a `PeerConnection`, and verifies that RTP packets arrive. Essential for automated end-to-end verification without a browser in the loop. + +- [ ] **Step 1: Create `test/whep-client/main.go`** + +```go +// Command whep-client subscribes to a Dragon Fork WHEP endpoint and logs +// the first N received RTP packets, then exits. Used for M1 end-to-end +// verification. +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "sync/atomic" + "time" + + "github.com/pion/webrtc/v4" +) + +func main() { + var ( + whepURL = flag.String("url", "http://127.0.0.1:8787/whep/test", "WHEP endpoint URL") + wantPkt = flag.Int("pkts", 30, "exit after receiving this many video RTP packets") + timeout = flag.Duration("timeout", 15*time.Second, "overall timeout") + ) + flag.Parse() + + if err := run(*whepURL, *wantPkt, *timeout); err != nil { + log.Fatalf("whep-client: %v", err) + } +} + +func run(whepURL string, wantPkt int, timeout time.Duration) error { + me := &webrtc.MediaEngine{} + if err := me.RegisterDefaultCodecs(); err != nil { + return fmt.Errorf("register codecs: %w", err) + } + api := webrtc.NewAPI(webrtc.WithMediaEngine(me)) + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return fmt.Errorf("new pc: %w", err) + } + defer pc.Close() + + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + return err + } + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { + return err + } + + var videoCount int64 + pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + kind := track.Kind().String() + log.Printf("OnTrack: kind=%s codec=%s", kind, track.Codec().MimeType) + go func() { + buf := make([]byte, 1500) + for { + n, _, err := track.Read(buf) + if err != nil { + log.Printf("track.Read (%s): %v", kind, err) + return + } + if kind == "video" { + atomic.AddInt64(&videoCount, 1) + } + _ = n + } + }() + }) + + offer, err := pc.CreateOffer(nil) + if err != nil { + return err + } + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + return err + } + <-gatherComplete + offer = *pc.LocalDescription() + + answerSDP, err := httpPostSDP(whepURL, offer.SDP) + if err != nil { + return fmt.Errorf("WHEP POST: %w", err) + } + + if err := pc.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, SDP: answerSDP, + }); err != nil { + return fmt.Errorf("set remote: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + deadline := time.After(timeout) + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + if atomic.LoadInt64(&videoCount) >= int64(wantPkt) { + log.Printf("OK: received %d video packets", videoCount) + fmt.Fprintln(os.Stdout, "PASS") + return nil + } + case <-deadline: + return fmt.Errorf("timeout after %s: only %d video packets received", + timeout, atomic.LoadInt64(&videoCount)) + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func httpPostSDP(url, sdp string) (string, error) { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(sdp)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/sdp") + req.Header.Set("Accept", "application/sdp") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + return string(body), nil +} +``` + +- [ ] **Step 2: Create `test/whep-client/main_test.go`** + +```go +package main + +import ( + "strings" + "testing" +) + +// Placeholder unit test. The real validation happens end-to-end in Task 12. +// This at least keeps `go test ./test/whep-client/...` happy in CI later. +func TestHTTPPostSDP_RejectsNon2xx(t *testing.T) { + _, err := httpPostSDP("http://127.0.0.1:1/whep/none", "v=0\n") + if err == nil { + t.Fatal("expected error from unreachable endpoint") + } + if !strings.Contains(err.Error(), "dial") && !strings.Contains(err.Error(), "connect") { + t.Errorf("expected dial/connect error, got: %v", err) + } +} +``` + +- [ ] **Step 3: Build and test** + +Run: +```bash +go build -o /tmp/whep-client ./test/whep-client +go test ./test/whep-client/... -v +``` + +Expected: binary builds; `TestHTTPPostSDP_RejectsNon2xx` passes. + +- [ ] **Step 4: Commit** + +```bash +git add test/whep-client/ +git commit -m "test: add Pion-based WHEP client for end-to-end M1 verification" +``` + +--- + +## Task 12: End-to-end PoC verification + +**Files:** None (runtime verification only) + +This is the moment of truth — FFmpeg → webrtc-poc → whep-client, all the way through, with the whep-client confirming it decoded video. + +- [ ] **Step 1: Open three terminals** + +Terminal A — the PoC server. +Terminal B — the FFmpeg publisher. +Terminal C — the test WHEP client. + +- [ ] **Step 2: Start the PoC server (Terminal A)** + +Run: +```bash +/tmp/webrtc-poc -stream test -rtp-port 10000 -listen :8787 +``` + +Expected: server logs `listening for RTP on 127.0.0.1:10000` and `WHEP listening on :8787 — POST /whep/test to subscribe`. Leave running. + +- [ ] **Step 3: Start the FFmpeg publisher (Terminal B)** + +Run: +```bash +./test/publish.sh +``` + +Expected: FFmpeg begins producing frames and reporting throughput (something like `frame= 30 fps=30 q=...`). Leave running. + +- [ ] **Step 4: Run the WHEP test client (Terminal C)** + +Run: +```bash +/tmp/whep-client -url http://127.0.0.1:8787/whep/test -pkts 30 -timeout 15s +``` + +Expected output within a few seconds: + +``` +OnTrack: kind=video codec=video/H264 +OK: received 30 video packets +PASS +``` + +The client exits 0. **This is M1's success criterion.** + +- [ ] **Step 5: If it fails, systematic debug** + +If the client times out: + +1. Confirm the FFmpeg publisher is actually producing RTP: run `tcpdump -i lo -n udp port 10000` in a fourth terminal — you should see packets. If not, the publisher is broken; re-check payload types and destination port in `publish.sh`. +2. Confirm the PoC server is receiving RTP: temporarily add a log line in `source.go` readLoop (`log.Printf("rtp seq=%d", pkt.SequenceNumber)`) and rebuild. If nothing logs, the UDP bind is wrong. +3. Confirm the PeerConnection is establishing: check the WHEP POST response was 201; check the client's `pc.ConnectionState()` transitions through `connecting` to `connected`. +4. Confirm codec payload types match: the publish script uses PT 102 for H.264 and 111 for Opus; Pion's default H.264 PT is 102 and Opus is 111. If you see RTP arriving in the reader but `forwardRTP` drops them, the PTs don't match — add a log line in `forward.go` to confirm. + +Log each issue and fix in `NOTES.md` as you go — those are exactly the gotchas we want recorded for M2+. + +- [ ] **Step 6: Tear down** + +Ctrl+C in all three terminals. + +- [ ] **Step 7: Document M1 success in NOTES.md** + +Append to `NOTES.md`: + +```markdown +## M1 PoC verified — + +FFmpeg (testsrc2 + drawtext timecode, H.264 baseline, Opus) → webrtc-poc → Pion WHEP client. + +- Video PT: 102 / Audio PT: 111 (matched between publisher and client) +- ICE gathering completed within ~s +- First video packet received ~s after POST (bounded by 2s GOP once we add forced keyframes in M2) +- + +Ready for M2 (datarhei process-model integration). +``` + +Run: +```bash +git add NOTES.md +git commit -m "docs: record M1 PoC success and observations" +``` + +- [ ] **Step 8: Push the branch** + +Run: +```bash +git push -u origin m1-webrtc-poc +``` + +Expected: branch pushed to your fork's remote. M1 complete. + +--- + +## Exit Criteria + +M1 is done when **all of the following are true**: + +1. `go build ./...` succeeds on the fork. +2. `go test ./core/webrtc/... -race` passes — all unit tests green, no race warnings. +3. `test/whep-client/main.go` prints `PASS` against a running `webrtc-poc` + `test/publish.sh` pair within 15 seconds. +4. `NOTES.md` records the verification run and any gotchas encountered. +5. Branch `m1-webrtc-poc` is pushed to the fork remote. + +--- + +## What comes next + +- **M2 plan** will be written after M1 is verified. It covers: adding the `webrtc://` URL scheme to datarhei Core's output resolver, wiring the Registry and WHEPHandler into Core's normal startup, separating video/audio onto distinct UDP ports, and hooking into the process lifecycle so sources register/deregister automatically with the existing FFmpeg processes datarhei already manages. +- **M3 plan** will cover multi-viewer robustness: DELETE/PATCH WHEP methods, error-path HTTP codes (404/406/503), the admin API endpoints, and PLI absorption + RTCP BYE on teardown. +- **M4 plan** will cover CI integration: running the end-to-end harness from Task 12 in CI, the pixel-sampling latency measurement, and the p95 gates. +- **M5 plan** will cover branding and release: logo swap in the Restreamer Vue UI, README rewrite with upstream attribution, `NOTICE`/`CREDITS`, Docker image publishing, and tagging `v0.1.0-dragonfork`. + +Each follow-on plan is small enough (~3–7 days of work) that writing it after M1 lets us incorporate lessons learned without re-planning from the top. diff --git a/docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md b/docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md new file mode 100755 index 0000000..c1614e8 --- /dev/null +++ b/docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md @@ -0,0 +1,282 @@ +# Datarhei - Dragon Fork: Low-Latency WebRTC Output + +**Status:** Draft for review +**Author:** Zac (Wild Dragon) +**Date:** 2026-04-16 +**Upstream:** [datarhei/core](https://github.com/datarhei/core), [datarhei/restreamer](https://github.com/datarhei/restreamer) + +--- + +## Summary + +Fork datarhei Core and add a native WebRTC egress module ("Dragon Fork") that delivers sub-second live video to a small audience (1–5 viewers) via the WHEP protocol. All existing datarhei ingest paths (RTMP, SRT, RTSP) and outputs (HLS, DASH, SRT, etc.) remain untouched. The new module taps the existing FFmpeg pipeline via local RTP and fans packets to browser clients using [Pion](https://github.com/pion/webrtc). + +The fork is branded **"Datarhei - Dragon Fork"** — preserving upstream attribution (Apache 2.0 / MIT) while marking it as a Wild Dragon-branded distribution. + +## Goals + +- Sub-second end-to-end latency for a 1-to-few live broadcast (target: glass-to-glass p95 < 300ms on RTMP ingest, < 200ms on SRT ingest). +- Zero changes to existing datarhei ingest, transcoding, or non-WebRTC outputs. +- Viewer connects with plain WHEP (HTTP POST with SDP offer, receives SDP answer). +- Additive package — reverting the fork's WebRTC work is a `git revert` away. +- Practical deployment: single binary, single Docker image, no new infrastructure dependencies beyond optional TURN. + +## Non-Goals (v1) + +- SFU clustering or cascading (irrelevant at 1–5 viewers). +- Simulcast, SVC, or adaptive bitrate on the WebRTC path. +- LL-HLS / LL-DASH outputs. +- WHIP *ingest* (accepting WebRTC as input). Tracked as a candidate for v2 — it is the only out-of-scope feature that would meaningfully tighten the latency budget further. +- In-memory keyframe cache for faster first-frame rendering (v2 optimization). +- DVR / recording tied to the WebRTC output. +- Bundled TURN server — users run `coturn` themselves if required. +- Any Ant Media Server or Millicast feature beyond WHEP egress (conference rooms, analytics, geo-routing, multi-view, token-gated playback, etc.). + +## Context & Constraints + +- **Scale:** 1–5 concurrent viewers per stream, typically 1. Single-node SFU is more than enough. +- **Ingest:** RTMP and SRT (both already supported by datarhei). +- **Publisher control:** Publisher codec settings are controllable. Expected feed: H.264 baseline/constrained-baseline + AAC (OBS default) or Opus where possible. +- **Latency budget:** + - RTMP ingest path: ~100–300ms publisher buffering + ~30ms server hop + ~50–150ms network + ~30ms decode ⇒ realistic p95 **250–500ms**. + - SRT (low-latency mode) ingest path: ~20–120ms publisher buffering + same server/network/decode ⇒ realistic p95 **150–300ms**. +- **Existing datarhei:** Already deployed and trusted. The fork builds on that trust, it does not replace it. + +## Architecture + +### Data flow + +``` +Publisher (OBS / encoder) + │ RTMP or SRT (H.264 + AAC/Opus) + ▼ +datarhei ingest [existing] + │ + ▼ +FFmpeg process [existing, orchestrated by datarhei Core] + │ -c:v copy (H.264 passthrough, no re-encode) + │ -c:a libopus (AAC → Opus, ~5–15ms) + │ -force_key_frames (2s GOP on the webrtc output) + │ -f rtp rtp://127.0.0.1: + │ -f rtp rtp://127.0.0.1: + ▼ +Local UDP sockets (RTP) + │ + ▼ +┌──────────────────────────────────────┐ +│ NEW: core/webrtc module (Pion) │ +│ • RTP reader per stream │ +│ • Registry: stream_id → source │ +│ • WHEP HTTP endpoint │ +│ • PeerConnection fan-out │ +└──────────────────────────────────────┘ + │ + ▼ +WebRTC peers (browsers, 1–5) +``` + +### Why this shape + +- **FFmpeg → local RTP → Pion** is the standard integration pattern for attaching WebRTC to a non-WebRTC media server. It reuses datarhei's existing FFmpeg supervision, keeps the new code strictly egress-side, and avoids writing RTP packetization in Go. +- **H.264 passthrough + Opus-only transcode** means no GPU dependency, minimal server CPU, and the smallest achievable added latency on the egress hop. +- **WHEP** (a simple HTTP request/response) sidesteps the complexity of custom WebSocket signaling. It is the protocol Ant Media Server and Millicast both standardized on, and is supported by modern players and browser libraries. +- **Purely additive:** existing ingest, transcode, and non-WebRTC output code paths are unchanged. The only contact with existing code is registering a new URL scheme (`webrtc://`) with the output resolver — a new handler, not a modification of existing handlers. Isolated blast radius. + +## Module Design + +### Package layout + +``` +core/webrtc/ + config.go # configuration struct + validation + registry.go # stream_id → Source mapping (thread-safe) + source.go # RTP reader from local UDP, fan-out to subscribers + peer.go # PeerConnection lifecycle + track attachment + whep.go # HTTP handlers for POST/DELETE/PATCH /whep/{stream} + ice.go # ICE server + NAT1To1 config + keyframe.go # GOP enforcement helpers +``` + +### Peer connection lifecycle (WHEP) + +1. Viewer sends `POST /whep/{stream_id}` with SDP offer (`Content-Type: application/sdp`). +2. Handler looks up `stream_id` in `Registry`. If missing, return `404 Not Found`. +3. If codec negotiation would fail (viewer does not offer H.264 or Opus), return `406 Not Acceptable` with a body describing the mismatch. +4. If `max_peers_total` would be exceeded, return `503 Service Unavailable`. +5. Create a Pion `PeerConnection`, add two `TrackLocalStaticRTP` tracks (video H.264, audio Opus) with SSRCs matching the source. +6. Set remote description, create answer, set local description, wait for ICE gathering (with a 5s timeout and trickle-ICE support via `PATCH`). +7. Return `201 Created`, `Location: /whep/{stream_id}/{resource_id}`, SDP answer in body. +8. A source goroutine now forwards RTP packets to this peer's tracks. +9. Teardown on either `DELETE /whep/{stream_id}/{resource_id}` or ICE state `disconnected`/`failed`. + +### Source fan-out + +One goroutine per active stream reads RTP packets from its local UDP socket and writes into an in-memory ring buffer. Each subscribed peer has a goroutine that reads from the ring and writes to its `TrackLocalStaticRTP`. At 1–5 viewers, overhead is negligible. + +### Keyframe strategy + +RTP from FFmpeg is one-way, so viewer-originated PLI/FIR cannot be propagated back to the encoder. We enforce a **2-second forced keyframe interval on the WebRTC output** via `-force_key_frames "expr:gte(t,n_forced*2)"`. Worst-case first-frame latency on join is ~2s. + +RTCP PLI from viewers is absorbed and logged. Pion's built-in NACK/retransmission handles typical packet-loss recovery transparently. + +### ICE / NAT / TURN + +- Default STUN servers: `stun:stun.cloudflare.com:3478`, `stun:stun.l.google.com:19302` (overridable). +- Optional TURN: config field accepts one or more TURN URIs with credentials. Not required at target scale but wired through for flexibility. +- Public IP advertised via Pion `SettingEngine.SetNAT1To1IPs` — the operator provides the server's public IP once in config; Pion inserts it into candidates. Avoids requiring a STUN round-trip from the server itself. + +## Datarhei Integration + +### New output type: `webrtc://` + +A new URL scheme recognized by the datarhei Core output resolver. Example process configuration: + +```json +{ + "id": "myStream", + "input": [{ "address": "{rtmp,name=myStream.stream}", "options": [] }], + "output": [ + { "address": "...existing HLS output..." }, + { + "address": "webrtc://internal/myStream", + "options": ["-c:v", "copy", "-an"] + }, + { + "address": "webrtc://internal/myStream?track=audio", + "options": ["-c:a", "libopus", "-b:a", "128k", "-vn"] + } + ] +} +``` + +### Resolver behavior + +On process start, each `webrtc://` output triggers the resolver to: + +1. Allocate a local UDP port from the configured `udp_port_range`. +2. Register `(stream_id, track, ssrc, port)` in `webrtc.Registry`. +3. Rewrite the FFmpeg output from `webrtc://internal/{stream_id}` to `rtp://127.0.0.1:?pkt_size=1200`, and (for video tracks only) prepend `-force_key_frames "expr:gte(t,n_forced*2)"` to the options list. Both transformations are done by the resolver — the user's process JSON never contains these details. + +On process stop (clean exit, crash, or user stop): + +1. Tear down all peer connections subscribed to this stream (RTCP BYE + `PeerConnection.Close()`). +2. Deregister from the registry. +3. Release UDP ports to the pool. + +Hooked into datarhei's existing process lifecycle events — no new supervision logic required. + +### API endpoints + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `POST` | `/whep/{stream_id}` | Subscribe (SDP offer in, SDP answer out) | Public or token-gated (see Open Questions) | +| `DELETE` | `/whep/{stream_id}/{resource_id}` | Unsubscribe | — | +| `PATCH` | `/whep/{stream_id}/{resource_id}` | Trickle ICE | — | +| `GET` | `/api/v3/webrtc/streams` | List active streams + subscriber counts | Admin | +| `GET` | `/api/v3/webrtc/streams/{id}/peers` | Per-stream peer stats | Admin | + +### Configuration + +Added to datarhei Core's config (HCL/JSON; example in HCL): + +```hcl +webrtc { + enabled = true + whep_listen = ":8787" + public_ip = "203.0.113.10" + udp_port_range = "10000-10100" + ice_servers = ["stun:stun.cloudflare.com:3478"] + max_peers_total = 32 +} +``` + +### UI + +**Out of scope for v1.** API-only first. The Restreamer Vue UI gets a minor addition in a later release: a "WebRTC" checkbox on each stream, the WHEP URL, and a live viewer count. UI work is decoupled and non-blocking. + +## Error Handling & Edge Cases + +| Scenario | Behavior | +|---|---| +| Publisher disconnects / FFmpeg exits | Registry emits "source removed"; all peers for that stream torn down with RTCP BYE; WHEP returns 404 until stream restarts. | +| Viewer disconnects (tab close, network) | Pion `OnConnectionStateChange` → cleanup; peer unsubscribed; no server-side retry. | +| First-frame on join | Up to ~2s (forced-GOP interval). Acceptable for broadcast. v2 optimization: in-memory keyframe cache. | +| Viewer codec mismatch | `406 Not Acceptable` with body describing mismatch. In practice never hit — every modern browser supports H.264 baseline + Opus via WebRTC. | +| UDP port exhaustion | Process start fails with clear error. At target scale (≤5 streams) irrelevant. | +| Peer cap reached | `503 Service Unavailable` on new WHEP POSTs. Hard safety rail. | +| ICE gathering timeout | 5s limit; return `500` with diagnostic error message. | +| TURN credential failure | Logged; surfaced in `/api/v3/webrtc/streams` so admins see it without tailing logs. | +| FFmpeg-to-UDP push failure (port conflict, etc.) | Piggybacks on existing datarhei FFmpeg supervision (restart with backoff). No new logic. | + +## Testing + +### Unit tests (`core/webrtc`) + +- `registry`: register/deregister, concurrent access, not-found paths. +- `source`: RTP reading, fan-out to N subscribers, subscriber cleanup on close. +- `whep`: handlers with mock peer-connection factory; verify `201`/`404`/`406`/`503`; SDP parse happy path + malformed input. +- `ice`: config → Pion `SettingEngine` translation. + +Coverage target: ~70% on this package. Not chasing 100% — some Pion paths are impractical to mock meaningfully. + +### Integration tests (end-to-end, in CI) + +1. Start forked datarhei Core in-process. +2. Launch an FFmpeg publisher sending a deterministic test pattern (`testsrc2` with burned-in frame counter + timecode) over RTMP. +3. Configure a process with `webrtc://` outputs. +4. Use a Pion-based test WHEP client (headless — no browser) to subscribe. +5. Assert: connection establishes, RTP arrives, keyframe seen within 3s of subscribe. + +### Latency measurement (CI pass/fail) + +- Publisher embeds a frame counter via `drawtext` in `testsrc2`. +- Test client decodes and extracts the frame counter (simple pixel sampling against a known bounding box — lighter than full OCR, no new dependency). +- Latency per frame = wall-clock at decode − publisher wall-clock at encode. +- 60-second run; record p50/p95/p99. +- CI gate: + - RTMP ingest path: p95 < 300ms. + - SRT ingest path: p95 < 200ms. + +### Browser smoke test (manual) + +A `test/whep-player.html` — plain HTML + `RTCPeerConnection` + a WHEP URL input. Used for real-browser / real-network human verification. Documented in `TESTING.md`, not automated. + +### Load test (one-shot, not CI) + +Script opens 5 concurrent WHEP peers against one stream, holds 10 minutes, reports CPU/memory/packet-loss/jitter. Run once before cutting v1. + +## Milestones + +| # | Scope | Duration | Exit criteria | +|---|---|---|---| +| M1 | Media-path PoC (hardcoded stream, manual FFmpeg, test WHEP client, no datarhei integration) | 1–2 weeks | 1 publisher → 1 viewer, decoded video | +| M2 | Process integration (`webrtc://` resolver, config, WHEP served from Core, lifecycle hooks) | 1 week | Standard datarhei process JSON with `webrtc://` output works end-to-end | +| M3 | Robustness + multi-viewer (fan-out, teardown paths, keyframe enforcement, error codes, admin API) | 1 week | 5 concurrent viewers, all error paths correct, clean teardown | +| M4 | Tests & CI (unit, integration, latency p95 gate, browser smoke, `TESTING.md`) | 3–5 days | CI green, latency targets met | +| M5 | Dragon Fork branding & release (UI logo swap, README, `NOTICE`/`CREDITS`, Docker image, tag `v0.1.0-dragonfork`) | 1–2 days | Publishable release | + +**Total realistic scope: ~4–5 weeks of focused work.** + +## Branding + +- **Project name:** Datarhei - Dragon Fork +- **Go module path:** `github.com/wilddragon/datarhei-dragonfork-core` (placeholder — confirm at M5) +- **Docker images:** `wilddragon/datarhei-dragonfork-core`, `wilddragon/datarhei-dragonfork-restreamer` +- **Logo asset:** Wild Dragon mark, used as Restreamer UI logo, README header, and any shipped WHEP viewer page +- **Upstream attribution:** `NOTICE` / `CREDITS` file referencing datarhei Core (Apache 2.0) and Restreamer (MIT); README header clearly labels the project as a fork. + +## Open Questions (to resolve during M1–M2) + +1. **WHEP auth model.** Public endpoint vs. simple bearer token vs. time-limited signed URL. Not decided; for an invite-only audience of 1–5 viewers, a shared bearer token is probably fine. Can revisit once M1 is working. +2. **Exact Go module path.** Depends on repo location. +3. **Restreamer UI version target.** Confirm which UI repo/branch to rebrand at M5. + +## References + +- [datarhei/core](https://github.com/datarhei/core) (Apache 2.0) +- [datarhei/restreamer](https://github.com/datarhei/restreamer) (MIT) +- [Pion WebRTC](https://github.com/pion/webrtc) (MIT) +- [WHEP draft spec (IETF)](https://datatracker.ietf.org/doc/draft-murillo-whep/) +- [WHIP draft spec (IETF)](https://datatracker.ietf.org/doc/draft-ietf-wish-whip/) — referenced for the future v2 ingest path +- [Ant Media Server Community](https://github.com/ant-media/Ant-Media-Server) — prior-art reference for WHEP/WHIP in a Java SFU +- [OvenMediaEngine](https://github.com/AirenSoft/OvenMediaEngine) — prior-art reference for sub-second WebRTC broadcast diff --git a/docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md b/docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md new file mode 100644 index 0000000..8b57f37 --- /dev/null +++ b/docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md @@ -0,0 +1,323 @@ +# M2 — WebRTC into datarhei Core proper + +**Status:** Design approved, implementation pending +**Date:** 2026-04-17 +**Author:** Zac (zgaetano@wilddragon.net), Dragon Fork +**Depends on:** M1 (`2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md`) +**Branch:** `m2-webrtc-core-integration` + +## 1. Purpose + +M1 produced a standalone `cmd/webrtc-poc` binary that proved the Pion-based +WHEP egress path end-to-end on TrueNAS. M2 promotes that work into the +datarhei Core binary so WebRTC becomes a first-class output alongside +RTMP, SRT, and HLS, surfaced in the core-ui dashboard. + +After M2 a user can: + +1. Create or edit a process in core-ui. +2. Toggle a "WebRTC" switch on that process's config. +3. Save → Core restarts the process with an extra RTP output leg. +4. Open the process's "Live (WebRTC)" tab and watch the feed in the + browser with sub-second latency, authenticated by the user's JWT. + +Out of scope for M2 (explicit): +- Public / unauthenticated embeds (handled in M3 via signed URLs). +- A separate "broadcast center" dashboard page (per-process tab is enough). +- Lazy / on-demand Source binding — eager binding only. +- WHIP ingest — that's M4. + +## 2. High-level architecture + +``` + ┌────────────────────────────────────────────┐ + │ datarhei Core │ + │ │ + FFmpeg (per │ ┌──────────────┐ ┌──────────────┐ │ + process, │ │ restream │─────▶│ app/webrtc │ │ + spawned by │──▶│ │◀─────│ (NEW) │ │ + restream) ───┐ │ │ - lifecycle │hooks │ │ │ + │ │ │ - AppendOut │ │ - registry │ │ + │ │ │ - config │ │ - sources │ │ + │ │ │ (now incl. │ │ - PeerFactory│ │ + │ │ │ WebRTC) │ │ - WHEP mux │ │ + │ │ └──────────────┘ └──────┬───────┘ │ + │ │ │ │ + udp:// │ │ ┌──────────────┐ │ │ + 127.0.0.1: └─▶│ │ core/webrtc │◀────uses────┘ │ + rtp │ │ (from M1, │ │ + │ │ unchanged) │ ┌────────────────┐ │ + │ └──────────────┘ │ http/server │ │ + │ │ │ │ + │ │ mounts │ │ + │ │ /api/v3/process│ │ + │ │ /:id/whep │ │ + │ └────────┬───────┘ │ + └────────────────────────────────┼───────────┘ + │ + (DTLS-SRTP over ICE) │ + ▼ + Browser (core-ui + player tab, RTCPeer) +``` + +Three boxes matter: + +- **existing `restream`** — grows two tiny hooks. +- **existing `core/webrtc`** (from M1) — unchanged. +- **new `app/webrtc`** — the glue subsystem. + +## 3. Key decisions (settled during brainstorming) + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Scope | Backend + full UI with embedded player | +| 2 | Stream addressing | `/whep/{processID}` — per-process | +| 3 | HTTP listener | Under Core's `/api/v3` group (inherits JWT) | +| 4 | Viewer auth | JWT only in M2 — public embeds are M3 | +| 5 | FFmpeg wiring | Auto-inject UDP RTP output; re-encode when needed | +| 6 | Enable state | Field on `restream.Config.WebRTC` | +| 7 | UI surface | New "Live (WebRTC)" tab on process detail view | +| 8 | Lifecycle | Eager — Source bound when process starts | +| 9 | Code placement | New `app/webrtc` sibling subsystem (not inside restream) | + +## 4. Components + +### 4.1 Config — `config/data.go` + `restream/app/process.go` + +Per-process: + +```go +// restream/app/process.go — new sibling of ConfigIO on Config +type ConfigWebRTC struct { + Enabled bool // master switch for this process + VideoPT uint8 // default 102 (H.264) + AudioPT uint8 // default 111 (Opus) + ForceTranscode bool // default false — true => always re-encode +} +``` + +Global (Core config, one block): + +```go +// config/data.go +type DataWebRTC struct { + Enable bool // master feature flag; default false for safety + PublicIP string // NAT1To1 / ICE host candidate rewrite (e.g. LAN IP) + NAT1To1IPs []string // advanced: multiple public IPs + UDPMuxPort int // optional: single UDP port for all ICE traffic + // (0 = ephemeral per peer, default) +} +``` + +Registered through the existing `vars.Register` mechanism in `config/config.go`. + +### 4.2 New package — `app/webrtc/` + +| File | Responsibility | +|------|----------------| +| `subsystem.go` | `type WebRTC struct` with `Start()` / `Stop()`; owns the `core/webrtc.Registry` and a single `core/webrtc.PeerFactory`. Implements the same shape as other Core subsystems. | +| `lifecycle.go` | `OnProcessStart(id, cfg)` / `OnProcessStop(id)` callbacks registered with restream. Allocates a UDP port, calls `restream.AppendOutput`, binds a `core/webrtc.Source`, registers it. | +| `portalloc.go` | `Alloc() (int, error)` — binds `:0` on loopback, reads the port, closes the listener, returns the number. Race window is microseconds; `NewSourceOn` re-binds immediately. If the rebind fails (rare: another process grabbed the port in the gap), `OnStart` returns the error, restream aborts the start, operator retries. Tested with 100× tight-loop. | +| `ffmpeg_args.go` | `BuildArgs(cfg ConfigWebRTC, port int) []string` — emits the `-map`, `-c:v`, `-c:a`, `-f rtp`, `udp://127.0.0.1:PORT?pkt_size=1316` fragments. Branches on `ForceTranscode`. | +| `handler.go` | HTTP handler for WHEP — wraps the M1 `core/webrtc.NewWHEPHandler`, but looks up the Source by `processID` path param. Adds `DELETE /api/v3/process/:id/whep/:peerid`. | + +### 4.3 Two additions to `restream` + +1. **Lifecycle callback pair.** Added as fields on the restream manager: + + ```go + type ProcessHook func(id string, cfg *app.Config) error + type ProcessHooks struct { + OnStart ProcessHook // fires after args are assembled, before exec + OnStop ProcessHook // fires after wait() returns + } + ``` + + Single consumer is fine — no event bus yet. `app/webrtc` registers itself at subsystem start. + +2. **`AppendOutput(id string, extra []string) error`** — mutates the *pending* + FFmpeg args for a process that has fired `OnStart` but has not yet exec'd. + Inside `OnStart`, the subsystem calls `AppendOutput` to add the + `-f rtp udp://…` fragment; restream then exec's with the augmented + args. Outside the `OnStart` window `AppendOutput` returns an error — + Core does not mutate running FFmpeg processes. + +These two additions are useful beyond WebRTC (stats consumers, future +sidecar modules), so the surface cost is justified. + +### 4.4 One route in `http/server.go` + +Inside the existing `/api/v3` group (inherits JWT auth): + +```go +api.POST("/process/:id/whep", webrtcHandler.Subscribe) +api.DELETE("/process/:id/whep/:peerid", webrtcHandler.Unsubscribe) +``` + +### 4.5 UI — `core-ui/src/views/Edit/LiveTab.jsx` (new) + +- Shown only when `process.config.webrtc.enabled === true`. +- `