Merge branch 'm2-webrtc-core-integration' into main
Lands the full Dragon Fork v0.1.0 stack: M2 — WebRTC into datarhei Core proper (PR #4) M3 — Robustness, multi-viewer, full error matrix (PR #5) M4 — CI, browser smoke player, server-hop latency p95 gate (PRs #8 + #9) M5 — Branding + v0.1.0-dragonfork release (PR #10) Issue #2 fix — configurable WebRTC stream maps (PR #6) Issue #3 fix — Swagger annotations on WHEP routes (PR #7) All race-clean, all integration tests green.
This commit is contained in:
commit
7df7ad2f6e
1177 changed files with 177229 additions and 79722 deletions
124
.forgejo/workflows/test.yml
Normal file
124
.forgejo/workflows/test.yml
Normal file
|
|
@ -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
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -7,6 +7,17 @@
|
||||||
/test/**
|
/test/**
|
||||||
.vscode
|
.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
|
||||||
*.ts.tmp
|
*.ts.tmp
|
||||||
*.m3u8
|
*.m3u8
|
||||||
|
|
@ -16,3 +27,4 @@
|
||||||
*.flv
|
*.flv
|
||||||
|
|
||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
|
whep-client
|
||||||
|
|
|
||||||
70
CHANGELOG.md
70
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
|
### Core v16.15.0 > v16.16.0
|
||||||
|
|
||||||
|
|
|
||||||
47
CREDITS
Normal file
47
CREDITS
Normal file
|
|
@ -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/`.
|
||||||
26
NOTES.md
Normal file
26
NOTES.md
Normal file
|
|
@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Add M1 verification notes here after Task 12 succeeds. -->
|
||||||
41
NOTICE
Normal file
41
NOTICE
Normal file
|
|
@ -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.
|
||||||
203
README.md
203
README.md
|
|
@ -1,92 +1,155 @@
|
||||||
# Core
|
# Datarhei — Dragon Fork
|
||||||
|
|
||||||

|
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.
|
||||||
|
|
||||||
[](<[https://opensource.org/licenses/MI](https://www.apache.org/licenses/LICENSE-2.0)>)
|
```
|
||||||
[](https://github.com/datarhei/core/actions/workflows/codeql-analysis.yml)
|
publisher (OBS / FFmpeg / SRT) ──▶ datarhei Core ──▶ WebRTC peers
|
||||||
[](https://github.com/datarhei/core/actions/workflows/go-tests.yml)
|
│ │ (1–5 viewers per stream)
|
||||||
[](https://codecov.io/gh/datarhei/core)
|
│ ├──▶ HLS / DASH (existing)
|
||||||
[](https://goreportcard.com/report/github.com/datarhei/core)
|
│ ├──▶ RTMP relay (existing)
|
||||||
[](https://pkg.go.dev/github.com/datarhei/core)
|
└──▶ ingest (RTMP / SRT / …) └──▶ recording (existing)
|
||||||
[](https://docs.datarhei.com/core/guides/beginner)
|
```
|
||||||
|
|
||||||
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
|
- **`webrtc.*` config block** alongside `rtmp.*` and `srt.*`, with the
|
||||||
- Portability of FFmpeg, including management across development and production environments
|
same `CORE_*` env-var binding pattern.
|
||||||
- Scalability of FFmpeg-based applications through the ability to offload processes to additional instances
|
- **Per-process `webrtc.enabled` toggle** on the existing process
|
||||||
- Streamlining of media product development by focusing on features and design.
|
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?
|
The existing upstream Datarhei feature set is intact — see "From
|
||||||
|
upstream Datarhei" below.
|
||||||
### 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)
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. Run the Docker image
|
### Docker (TrueNAS / any host with Docker + LAN-reachable IP)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --name core -d \
|
git clone https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core.git
|
||||||
-e CORE_API_AUTH_USERNAME=admin \
|
cd datarhei-dragonfork-core/deploy/truenas/core
|
||||||
-e CORE_API_AUTH_PASSWORD=secret \
|
|
||||||
-p 8080:8080 \
|
cat > .env <<EOF
|
||||||
-v ${HOME}/core/config:/core/config \
|
PUBLIC_IP=10.0.0.25
|
||||||
-v ${HOME}/core/data:/core/data \
|
CORE_HTTP_PORT=8080
|
||||||
datarhei/core:latest
|
API_AUTH_USERNAME=admin
|
||||||
|
API_AUTH_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
API_AUTH_JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Open Swagger
|
Then:
|
||||||
http://host-ip:8080/api/swagger/index.html
|
|
||||||
|
|
||||||
3. Log in with Swagger
|
- Swagger UI: `http://<host>:8080/api/swagger/index.html`
|
||||||
Authorize > Basic authorization > Username: admin, Password: secret
|
- 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
|
||||||
|
|
||||||
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)
|
## Building from source
|
||||||
- [Installation](https://docs.datarhei.com/core/installation)
|
|
||||||
- [Configuration](https://docs.datarhei.com/core/configuration)
|
Go 1.24 required (vendored).
|
||||||
- [Coding](https://docs.datarhei.com/core/development/coding)
|
|
||||||
|
```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
|
## License
|
||||||
|
|
||||||
datarhei/core is licensed under the Apache License 2.0
|
Apache License 2.0 — same as upstream. See [`LICENSE`](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/datarhei/core/v16/app"
|
"github.com/datarhei/core/v16/app"
|
||||||
|
appwebrtc "github.com/datarhei/core/v16/app/webrtc"
|
||||||
"github.com/datarhei/core/v16/config"
|
"github.com/datarhei/core/v16/config"
|
||||||
configstore "github.com/datarhei/core/v16/config/store"
|
configstore "github.com/datarhei/core/v16/config/store"
|
||||||
configvars "github.com/datarhei/core/v16/config/vars"
|
configvars "github.com/datarhei/core/v16/config/vars"
|
||||||
|
|
@ -73,6 +74,8 @@ type api struct {
|
||||||
s3fs map[string]fs.Filesystem
|
s3fs map[string]fs.Filesystem
|
||||||
rtmpserver rtmp.Server
|
rtmpserver rtmp.Server
|
||||||
srtserver srt.Server
|
srtserver srt.Server
|
||||||
|
webrtcsub *appwebrtc.Subsystem
|
||||||
|
webrtchandler *appwebrtc.Handler
|
||||||
metrics monitor.HistoryMonitor
|
metrics monitor.HistoryMonitor
|
||||||
prom prometheus.Metrics
|
prom prometheus.Metrics
|
||||||
service service.Service
|
service service.Service
|
||||||
|
|
@ -216,6 +219,8 @@ func (a *api) Reload() error {
|
||||||
|
|
||||||
logfields := log.Fields{
|
logfields := log.Fields{
|
||||||
"application": app.Name,
|
"application": app.Name,
|
||||||
|
"variant": app.Variant,
|
||||||
|
"fork": app.Fork,
|
||||||
"version": app.Version.String(),
|
"version": app.Version.String(),
|
||||||
"repository": "https://github.com/datarhei/core",
|
"repository": "https://github.com/datarhei/core",
|
||||||
"license": "Apache License Version 2.0",
|
"license": "Apache License Version 2.0",
|
||||||
|
|
@ -617,6 +622,22 @@ func (a *api) start() error {
|
||||||
|
|
||||||
a.restream = restream
|
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
|
var httpjwt jwt.JWT
|
||||||
|
|
||||||
if cfg.API.Auth.Enable {
|
if cfg.API.Auth.Enable {
|
||||||
|
|
@ -1014,6 +1035,7 @@ func (a *api) start() error {
|
||||||
},
|
},
|
||||||
RTMP: a.rtmpserver,
|
RTMP: a.rtmpserver,
|
||||||
SRT: a.srtserver,
|
SRT: a.srtserver,
|
||||||
|
WebRTC: a.webrtchandler,
|
||||||
JWT: a.httpjwt,
|
JWT: a.httpjwt,
|
||||||
Config: a.config.store,
|
Config: a.config.store,
|
||||||
Sessions: a.sessions,
|
Sessions: a.sessions,
|
||||||
|
|
@ -1354,6 +1376,17 @@ func (a *api) stop() {
|
||||||
a.srtserver = nil
|
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
|
// Stop the RTMP server
|
||||||
if a.rtmpserver != nil {
|
if a.rtmpserver != nil {
|
||||||
a.log.logger.rtmp.Info().Log("Stopping ...")
|
a.log.logger.rtmp.Info().Log("Stopping ...")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@ import (
|
||||||
// Name of the app
|
// Name of the app
|
||||||
const Name = "datarhei-core"
|
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 {
|
type versionInfo struct {
|
||||||
Major int
|
Major int
|
||||||
Minor int
|
Minor int
|
||||||
|
|
|
||||||
61
app/webrtc/ffmpeg_args.go
Normal file
61
app/webrtc/ffmpeg_args.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
132
app/webrtc/ffmpeg_args_test.go
Normal file
132
app/webrtc/ffmpeg_args_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
387
app/webrtc/handler.go
Normal file
387
app/webrtc/handler.go
Normal file
|
|
@ -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, ", ")
|
||||||
|
}
|
||||||
251
app/webrtc/handler_m3_test.go
Normal file
251
app/webrtc/handler_m3_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/webrtc/handler_test.go
Normal file
91
app/webrtc/handler_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
275
app/webrtc/integration_test.go
Normal file
275
app/webrtc/integration_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
289
app/webrtc/latency_test.go
Normal file
289
app/webrtc/latency_test.go
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
||||||
202
app/webrtc/lifecycle.go
Normal file
202
app/webrtc/lifecycle.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/webrtc/lifecycle_test.go
Normal file
60
app/webrtc/lifecycle_test.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/webrtc/multiviewer_test.go
Normal file
257
app/webrtc/multiviewer_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/webrtc/portalloc.go
Normal file
31
app/webrtc/portalloc.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
43
app/webrtc/portalloc_test.go
Normal file
43
app/webrtc/portalloc_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/webrtc/subsystem.go
Normal file
139
app/webrtc/subsystem.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
56
cmd/webrtc-poc/main.go
Normal file
56
cmd/webrtc-poc/main.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,7 @@ func (d *Config) Clone() *Config {
|
||||||
data.Storage = d.Storage
|
data.Storage = d.Storage
|
||||||
data.RTMP = d.RTMP
|
data.RTMP = d.RTMP
|
||||||
data.SRT = d.SRT
|
data.SRT = d.SRT
|
||||||
|
data.WebRTC = d.WebRTC
|
||||||
data.FFmpeg = d.FFmpeg
|
data.FFmpeg = d.FFmpeg
|
||||||
data.Playout = d.Playout
|
data.Playout = d.Playout
|
||||||
data.Debug = d.Debug
|
data.Debug = d.Debug
|
||||||
|
|
@ -131,6 +132,8 @@ func (d *Config) Clone() *Config {
|
||||||
|
|
||||||
data.SRT.Log.Topics = copy.Slice(d.SRT.Log.Topics)
|
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.BlockedPrefixes = copy.Slice(d.Router.BlockedPrefixes)
|
||||||
data.Router.Routes = copy.StringMap(d.Router.Routes)
|
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.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)
|
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
|
// 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.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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,33 @@ func TestConfigCopy(t *testing.T) {
|
||||||
require.Equal(t, []string{"foo.com"}, config2.Host.Name)
|
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) {
|
func TestValidateDefault(t *testing.T) {
|
||||||
fs, err := fs.NewMemFilesystem(fs.MemConfig{})
|
fs, err := fs.NewMemFilesystem(fs.MemConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ type Data struct {
|
||||||
Topics []string `json:"topics"`
|
Topics []string `json:"topics"`
|
||||||
} `json:"log"`
|
} `json:"log"`
|
||||||
} `json:"srt"`
|
} `json:"srt"`
|
||||||
|
WebRTC DataWebRTC `json:"webrtc"`
|
||||||
FFmpeg struct {
|
FFmpeg struct {
|
||||||
Binary string `json:"binary"`
|
Binary string `json:"binary"`
|
||||||
MaxProcesses int64 `json:"max_processes" format:"int64"`
|
MaxProcesses int64 `json:"max_processes" format:"int64"`
|
||||||
|
|
@ -334,3 +335,12 @@ func DowngradeV3toV2(d *Data) (*v2.Data, error) {
|
||||||
|
|
||||||
return data, nil
|
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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
59
core/webrtc/config.go
Normal file
59
core/webrtc/config.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
48
core/webrtc/config_test.go
Normal file
48
core/webrtc/config_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
11
core/webrtc/doc.go
Normal file
11
core/webrtc/doc.go
Normal file
|
|
@ -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
|
||||||
26
core/webrtc/errors.go
Normal file
26
core/webrtc/errors.go
Normal file
|
|
@ -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")
|
||||||
|
)
|
||||||
62
core/webrtc/forward.go
Normal file
62
core/webrtc/forward.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
core/webrtc/ice.go
Normal file
47
core/webrtc/ice.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
50
core/webrtc/ice_test.go
Normal file
50
core/webrtc/ice_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
278
core/webrtc/peer.go
Normal file
278
core/webrtc/peer.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
96
core/webrtc/peer_test.go
Normal file
96
core/webrtc/peer_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
core/webrtc/registry.go
Normal file
51
core/webrtc/registry.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
74
core/webrtc/registry_test.go
Normal file
74
core/webrtc/registry_test.go
Normal file
|
|
@ -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.
|
||||||
|
}
|
||||||
149
core/webrtc/source.go
Normal file
149
core/webrtc/source.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
129
core/webrtc/source_test.go
Normal file
129
core/webrtc/source_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
93
core/webrtc/whep.go
Normal file
93
core/webrtc/whep.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
64
core/webrtc/whep_test.go
Normal file
64
core/webrtc/whep_test.go
Normal file
|
|
@ -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/<id>", loc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "v=0") {
|
||||||
|
t.Errorf("body does not look like SDP: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
34
deploy/docker/Dockerfile
Normal file
34
deploy/docker/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
70
deploy/truenas/README.md
Normal file
70
deploy/truenas/README.md
Normal file
|
|
@ -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 <<EOF
|
||||||
|
WHEP_PORT=45121
|
||||||
|
RTP_PORT=49248
|
||||||
|
STREAM_ID=test
|
||||||
|
PUBLIC_IP=10.0.0.25
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
listening for RTP on 127.0.0.1:49248 # or 0.0.0.0:49248 on real deploy
|
||||||
|
WHEP listening on :45121 — POST /whep/test to subscribe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify from another host on the LAN
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -i -X GET http://10.0.0.25:45121/whep/test # → 405 (POST only)
|
||||||
|
curl -i -X POST http://10.0.0.25:45121/whep/nope # → 404 (stream not found)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a real end-to-end check, point the repo's `test/publish.sh` at
|
||||||
|
`10.0.0.25 49248` and the `whep-client` at `http://10.0.0.25:45121/whep/test`.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- WHEP is served plain HTTP. Put nginx-proxy-manager or Caddy in front
|
||||||
|
for TLS — but note that WHEP itself is fine over HTTPS; the real
|
||||||
|
media is DTLS-SRTP-encrypted regardless.
|
||||||
|
- No auth in M1. Anyone who can reach the port can subscribe.
|
||||||
|
M3 adds a token check.
|
||||||
|
- The binary runs as PID 1 in `scratch` — no shell, no package
|
||||||
|
manager, no privilege escalation path. Exit codes only.
|
||||||
60
deploy/truenas/core/Dockerfile
Normal file
60
deploy/truenas/core/Dockerfile
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Dragon Fork datarhei Core image (M2 + WebRTC egress).
|
||||||
|
#
|
||||||
|
# Builds the real root Core binary — the one that replaces the M1 PoC
|
||||||
|
# in production. FFmpeg is baked in so restream processes can run the
|
||||||
|
# RTP output legs emitted by the WebRTC subsystem.
|
||||||
|
#
|
||||||
|
# Two-stage:
|
||||||
|
# 1. builder: compile a static Go binary (CGO off — no dynamic libs)
|
||||||
|
# 2. runtime: alpine with ffmpeg for the subprocess path
|
||||||
|
#
|
||||||
|
# Usage via compose:
|
||||||
|
# docker compose -f deploy/truenas/core/docker-compose.yml up -d --build
|
||||||
|
#
|
||||||
|
# The compose file drives configuration via CORE_* env vars — see
|
||||||
|
# README.md in this directory.
|
||||||
|
|
||||||
|
# ---- builder ----
|
||||||
|
# go.mod requires go 1.24; pinning the image keeps Docker's toolchain
|
||||||
|
# download off the hot path and makes the build reproducible.
|
||||||
|
FROM golang:1.24-alpine3.20 AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||||
|
RUN make release && make import && make ffmigrate
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
# Alpine with ffmpeg (Core shells out to it for every restream process).
|
||||||
|
# Scratch isn't an option here because the process manager needs ffmpeg
|
||||||
|
# on PATH.
|
||||||
|
FROM alpine:3.20 AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg tini ca-certificates
|
||||||
|
|
||||||
|
# make release's `-o core` lands the binary inside the core/ Go
|
||||||
|
# package directory (Go cannot overwrite a directory with a file, so
|
||||||
|
# it places the output file _inside_ it). The `import` and `ffmigrate`
|
||||||
|
# Makefile targets cd into app/<name> 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
|
||||||
102
deploy/truenas/core/README.md
Normal file
102
deploy/truenas/core/README.md
Normal file
|
|
@ -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 <<EOF
|
||||||
|
PUBLIC_IP=10.0.0.25
|
||||||
|
CORE_HTTP_PORT=8080
|
||||||
|
API_AUTH_USERNAME=admin
|
||||||
|
API_AUTH_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
API_AUTH_JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p config data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see Core come up logging all configured listeners, including
|
||||||
|
a line from the WebRTC component confirming the subsystem is enabled.
|
||||||
|
|
||||||
|
## Smoke-test via API
|
||||||
|
|
||||||
|
```
|
||||||
|
# Issue a JWT against the admin creds from .env:
|
||||||
|
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"<from .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/<process-id>.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
56
deploy/truenas/core/docker-compose.yml
Normal file
56
deploy/truenas/core/docker-compose.yml
Normal file
|
|
@ -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.
|
||||||
36
deploy/truenas/docker-compose.yml
Normal file
36
deploy/truenas/docker-compose.yml
Normal file
|
|
@ -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.
|
||||||
2081
docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md
Executable file
2081
docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md
Executable file
File diff suppressed because it is too large
Load diff
282
docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md
Executable file
282
docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md
Executable file
|
|
@ -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:<video_port>
|
||||||
|
│ -f rtp rtp://127.0.0.1:<audio_port>
|
||||||
|
▼
|
||||||
|
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:<port>?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
|
||||||
|
|
@ -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────┘ │
|
||||||
|
<auto>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`.
|
||||||
|
- `<video autoplay muted playsinline />` driven by a small `useWHEP()` hook
|
||||||
|
that does:
|
||||||
|
1. `new RTCPeerConnection({ iceServers: [] })`
|
||||||
|
2. `pc.addTransceiver('video', { direction: 'recvonly' })`
|
||||||
|
3. `pc.addTransceiver('audio', { direction: 'recvonly' })`
|
||||||
|
4. `await pc.setLocalDescription(await pc.createOffer())`
|
||||||
|
5. POST offer SDP to `/api/v3/process/{id}/whep` with the JWT.
|
||||||
|
6. `pc.setRemoteDescription(answer)`.
|
||||||
|
7. `pc.ontrack` → attach stream to the `<video>`.
|
||||||
|
- "Copy WHEP URL" button.
|
||||||
|
- Status line derived from `pc.connectionState` + `pc.getStats()` (codec, bitrate).
|
||||||
|
- No external WebRTC dependency — browser-native `RTCPeerConnection`.
|
||||||
|
|
||||||
|
## 5. Data flow
|
||||||
|
|
||||||
|
### 5.1 Enabling WebRTC (write)
|
||||||
|
|
||||||
|
```
|
||||||
|
core-ui ──PUT /api/v3/process/{id} { ..., config: { webrtc: { enabled: true }}}──▶ http
|
||||||
|
http ──restream.UpdateProcess(id, cfg)──▶ restream
|
||||||
|
restream ──persist → stop old → about to exec new──▶ OnProcessStart(id, cfg)
|
||||||
|
app/webrtc ─port P = Alloc()
|
||||||
|
app/webrtc ─restream.AppendOutput(id, BuildArgs(cfg.WebRTC, P))
|
||||||
|
app/webrtc ─NewSourceOn(id, "127.0.0.1", P).Start() → registry[id] = src
|
||||||
|
restream ─exec ffmpeg with augmented args
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordering guarantee: Source is bound *before* FFmpeg execs. No race window.
|
||||||
|
|
||||||
|
### 5.2 WHEP subscribe (read)
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──POST /api/v3/process/{id}/whep (SDP offer, JWT)──▶ http
|
||||||
|
http (JWT ok) ──handler.Subscribe──▶ app/webrtc
|
||||||
|
app/webrtc ─src = registry[id] (404 if absent)
|
||||||
|
app/webrtc ─peer, answer = factory.NewPeer(src, offer)
|
||||||
|
app/webrtc ─go forwarder: src.Subscribe(ch) → peer.WriteRTP
|
||||||
|
http ──201 Created, Location: .../whep/{peerid}, body=answer──▶ browser
|
||||||
|
browser ──ICE, DTLS-SRTP──▶ peer ──▶ <video>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Process stop (teardown)
|
||||||
|
|
||||||
|
```
|
||||||
|
restream ─kill ffmpeg, wait()──▶ OnProcessStop(id)
|
||||||
|
app/webrtc ─for each peer in peers[id]: peer.Close()
|
||||||
|
app/webrtc ─src = registry.Remove(id); src.Close()
|
||||||
|
app/webrtc ─delete peers[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Disabling WebRTC on a running process
|
||||||
|
|
||||||
|
Same as 5.1 in reverse: new cfg has `webrtc.enabled = false`. Restream
|
||||||
|
persists → stops (fires `OnProcessStop` → 5.3 runs) → starts without RTP leg.
|
||||||
|
|
||||||
|
### 5.5 Core restart
|
||||||
|
|
||||||
|
Restream enumerates stored configs at boot and starts each process.
|
||||||
|
`OnProcessStart` fires inside that loop for every `webrtc.enabled = true`
|
||||||
|
process. WebRTC state rebuilds from the persisted config — no separate
|
||||||
|
bootstrap path.
|
||||||
|
|
||||||
|
## 6. Error handling
|
||||||
|
|
||||||
|
| Failure | Surface |
|
||||||
|
|---------|---------|
|
||||||
|
| Port alloc fails | `OnProcessStart` returns error → restream aborts start, logs `webrtc: port alloc failed`. Process shows failed in UI. |
|
||||||
|
| FFmpeg wiring fails (bad codec + !ForceTranscode) | Source binds; RTP counter stays zero. Log after N seconds of silence; expose `RTPPacketsReceived` to UI. |
|
||||||
|
| WHEP POST for unknown id | `404 stream not found` (same as M1). |
|
||||||
|
| Peer DELETE unknown peerid | `204 No Content` (idempotent). |
|
||||||
|
| JWT missing / invalid | `401` — inherited from `/api` group. No code in handler. |
|
||||||
|
| ICE fails on client | Browser `iceconnectionstatechange = failed` → UI retry button. Server no-op. |
|
||||||
|
| Subsystem Start fails at boot (bad `PublicIP`, etc.) | Subsystem logs the error and declines to start; the hooks are never registered; restream runs all processes without the RTP leg. Core does **not** exit — WebRTC is non-critical. |
|
||||||
|
| Subscriber backpressure | Already handled in `core/webrtc.Source` — full channel drops. No change. |
|
||||||
|
|
||||||
|
**Design rule:** a WebRTC subsystem failure must not prevent a process's
|
||||||
|
RTMP/SRT/HLS outputs from running. Hooks wrap their own errors and log;
|
||||||
|
restream does not abort a start because of a WebRTC problem *unless* the
|
||||||
|
`AppendOutput` itself fails (wrong args shape — a programming bug, not a
|
||||||
|
runtime condition).
|
||||||
|
|
||||||
|
## 7. Testing strategy
|
||||||
|
|
||||||
|
### 7.1 Unit (fast, in-package, no network)
|
||||||
|
|
||||||
|
- `app/webrtc/ffmpeg_args_test.go` — table-driven: video-only, audio-only,
|
||||||
|
both, transcode on/off. Asserts exact arg slice.
|
||||||
|
- `app/webrtc/portalloc_test.go` — `Alloc()` returns a port that a
|
||||||
|
subsequent `ListenUDP` can bind; run 100× to catch races.
|
||||||
|
- `app/webrtc/lifecycle_test.go` — fake restream calls `OnProcessStart` /
|
||||||
|
`OnProcessStop`; asserts registry state transitions and Source is closed
|
||||||
|
exactly once.
|
||||||
|
|
||||||
|
### 7.2 Integration (in-process, real HTTP, no FFmpeg)
|
||||||
|
|
||||||
|
- `app/api/api_webrtc_whep_test.go` — boot a Core with a fake process that
|
||||||
|
has `webrtc.enabled=true`; inject synthetic RTP on the allocated port;
|
||||||
|
POST a WHEP offer using the M1 `test/whep-client.Subscribe` helper (now
|
||||||
|
imported as a library); assert both tracks receive a packet within 2s.
|
||||||
|
- `app/api/api_webrtc_auth_test.go` — POST without JWT → 401; POST for
|
||||||
|
unknown id → 404; DELETE unknown peerid → 204.
|
||||||
|
- `app/api/config_persist_test.go` — create process with `webrtc.enabled`,
|
||||||
|
simulate Core restart, assert Source is re-bound and WHEP still works.
|
||||||
|
|
||||||
|
### 7.3 End-to-end (manual, TrueNAS)
|
||||||
|
|
||||||
|
- Replace the M1 `test/publish.sh` workflow with a real Core process
|
||||||
|
configured via core-ui (`testsrc2` as input), flip WebRTC on, open the
|
||||||
|
Live tab, verify the test pattern plays.
|
||||||
|
- Use `chrome://webrtc-internals` to confirm ICE completes and SRTP is
|
||||||
|
flowing.
|
||||||
|
|
||||||
|
No new test dependencies. `test/whep-client` graduates from binary to
|
||||||
|
importable helper package.
|
||||||
|
|
||||||
|
## 8. Acceptance criteria
|
||||||
|
|
||||||
|
M2 is done when, on a fresh TrueNAS deploy of the Core binary:
|
||||||
|
|
||||||
|
1. `POST /api/v3/config` with a `webrtc.enable=true` global block succeeds.
|
||||||
|
2. Creating a process with `config.webrtc.enabled=true` via core-ui
|
||||||
|
persists and starts.
|
||||||
|
3. `POST /api/v3/process/{id}/whep` with a valid JWT returns `201` with an
|
||||||
|
SDP answer, and the connection reaches `iceconnectionstate=connected`.
|
||||||
|
4. The core-ui "Live (WebRTC)" tab plays video within 3 seconds of opening.
|
||||||
|
5. Disabling WebRTC in the UI stops the stream and subsequent WHEP POSTs
|
||||||
|
return `404`.
|
||||||
|
6. Restarting the Core binary keeps the stream working without manual
|
||||||
|
reconfiguration.
|
||||||
|
7. All unit and integration tests pass with `-race`.
|
||||||
|
|
||||||
|
## 9. Rollback
|
||||||
|
|
||||||
|
Each layer has a rollback lever:
|
||||||
|
|
||||||
|
- **Operator:** set global `webrtc.enable = false` in Core config → subsystem
|
||||||
|
declines to start (no hooks registered); processes run without the RTP
|
||||||
|
leg; existing RTMP/SRT/HLS unaffected. Core continues to serve normally.
|
||||||
|
- **Per-process:** toggle `config.webrtc.enabled = false` in the process
|
||||||
|
config → restream restarts the process without the leg.
|
||||||
|
- **Code:** the `app/webrtc` subsystem is a single import in `main.go`.
|
||||||
|
Removing that import and the two restream hook wires restores pre-M2
|
||||||
|
behavior. `core/webrtc` stays in the tree as inert code.
|
||||||
|
|
||||||
|
## 10. Milestones inside M2
|
||||||
|
|
||||||
|
Not the full plan — that lives in a separate plan doc after this spec is
|
||||||
|
approved. This is a sanity breakdown:
|
||||||
|
|
||||||
|
1. **Config wiring** — add `DataWebRTC` and `ConfigWebRTC`; tests for
|
||||||
|
marshal/unmarshal and defaults.
|
||||||
|
2. **Restream hooks** — add `ProcessHooks` and `AppendOutput`; unit tests
|
||||||
|
using the existing restream test harness.
|
||||||
|
3. **`app/webrtc` package** — subsystem, lifecycle, portalloc, ffmpeg_args,
|
||||||
|
handler; unit tests per the testing strategy.
|
||||||
|
4. **Core main.go wiring** — instantiate subsystem, register hooks, mount
|
||||||
|
HTTP route.
|
||||||
|
5. **Integration tests** — in-process WHEP end-to-end, auth, persistence.
|
||||||
|
6. **core-ui LiveTab** — new React tab + WHEP hook.
|
||||||
|
7. **TrueNAS smoke test** — rebuild Core image, redeploy, verify live.
|
||||||
|
|
||||||
|
Each milestone ends with a commit. The feature branch is
|
||||||
|
`m2-webrtc-core-integration` (created from `m1-webrtc-poc`).
|
||||||
224
docs/docs.go
224
docs/docs.go
|
|
@ -1,4 +1,4 @@
|
||||||
// Code generated by swaggo/swag. DO NOT EDIT
|
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import "github.com/swaggo/swag"
|
import "github.com/swaggo/swag"
|
||||||
|
|
@ -1903,6 +1903,165 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v3/whep/{id}": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"consumes": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Subscribe to a WebRTC stream via WHEP",
|
||||||
|
"operationId": "webrtc-3-whep-subscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID with config.webrtc.enabled=true",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "SDP answer",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing stream id, malformed body, or invalid SDP",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "no stream registered for this process id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"406": {
|
||||||
|
"description": "offer SDP missing required H264 / Opus rtpmap",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "peer cap reached (per-stream or total)",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"504": {
|
||||||
|
"description": "ICE gathering timeout",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v3/whep/{id}/{resource}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"summary": "Tear down a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-unsubscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.",
|
||||||
|
"consumes": [
|
||||||
|
"application/trickle-ice-sdpfrag"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Trickle ICE candidates for a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-trickle",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id or unreadable body",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "peer not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v3/widget/process/{id}": {
|
"/api/v3/widget/process/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
||||||
|
|
@ -2082,6 +2241,10 @@ const docTemplate = `{
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"fork": {
|
||||||
|
"description": "Fork is the human-readable fork name (e.g. \"Datarhei — Dragon Fork\").",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -2091,6 +2254,10 @@ const docTemplate = `{
|
||||||
"uptime_seconds": {
|
"uptime_seconds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"variant": {
|
||||||
|
"description": "Variant identifies the build flavor — empty (or \"core\") for an\nupstream Datarhei build, \"dragonfork\" for the Dragon Fork.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"$ref": "#/definitions/api.Version"
|
"$ref": "#/definitions/api.Version"
|
||||||
}
|
}
|
||||||
|
|
@ -2629,6 +2796,9 @@ const docTemplate = `{
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3109,6 +3279,9 @@ const docTemplate = `{
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/api.ProcessConfigWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3176,6 +3349,29 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.ProcessConfigWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"audio_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"audio_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"force_transcode": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"video_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"video_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.ProcessReport": {
|
"api.ProcessReport": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4441,6 +4637,9 @@ const docTemplate = `{
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4709,6 +4908,27 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DataWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nat_1_to_1_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public_ip": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"udp_mux_port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_datarhei_core_v16_http_api.Config": {
|
"github_com_datarhei_core_v16_http_api.Config": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4831,6 +5051,8 @@ var SwaggerInfo = &swag.Spec{
|
||||||
Description: "Expose REST API for the datarhei Core",
|
Description: "Expose REST API for the datarhei Core",
|
||||||
InfoInstanceName: "swagger",
|
InfoInstanceName: "swagger",
|
||||||
SwaggerTemplate: docTemplate,
|
SwaggerTemplate: docTemplate,
|
||||||
|
LeftDelim: "{{",
|
||||||
|
RightDelim: "}}",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
839
docs/superpowers/plans/2026-04-17-m2-webrtc-core-integration.md
Normal file
839
docs/superpowers/plans/2026-04-17-m2-webrtc-core-integration.md
Normal file
|
|
@ -0,0 +1,839 @@
|
||||||
|
# M2 — WebRTC into datarhei Core proper — 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:** Wire the M1 `core/webrtc` package into the datarhei Core binary as a first-class output, served via WHEP under `/api/v3/process/{id}/whep`, with an eagerly bound `Source` per WebRTC-enabled process.
|
||||||
|
|
||||||
|
**Architecture:** New `app/webrtc` sibling subsystem that hooks into restream's process lifecycle. Two small additions to restream (`ProcessHooks` callbacks + `AppendOutput` method). Reuses the untouched M1 `core/webrtc` package. UI lives in a separate core-ui repo and is deferred to a sibling plan.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.24, Pion WebRTC v4 (via `core/webrtc` from M1), Echo v4 HTTP router, existing datarhei Core subsystem pattern.
|
||||||
|
|
||||||
|
**Spec:** `docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`
|
||||||
|
|
||||||
|
**Branch:** `m2-webrtc-core-integration` (already created from `m1-webrtc-poc`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `app/webrtc/portalloc.go` + `portalloc_test.go` — ephemeral UDP port allocation
|
||||||
|
- `app/webrtc/ffmpeg_args.go` + `ffmpeg_args_test.go` — builds `-f rtp …` output fragments
|
||||||
|
- `app/webrtc/lifecycle.go` + `lifecycle_test.go` — `OnStart`/`OnStop` hook bodies
|
||||||
|
- `app/webrtc/subsystem.go` + `subsystem_test.go` — `WebRTC` struct; `Start`/`Stop`
|
||||||
|
- `app/webrtc/handler.go` + `handler_test.go` — WHEP HTTP handler
|
||||||
|
- `core/webrtc/registry.go` already exists — no changes.
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `restream/app/process.go` — add `ConfigWebRTC` type and `WebRTC` field on `Config`. Update `Clone()` and `CreateCommand()`.
|
||||||
|
- `restream/restream.go` — add `ProcessHooks` and `AppendOutput`.
|
||||||
|
- `config/data.go` — add `WebRTC` block on `Data` struct.
|
||||||
|
- `config/config.go` — `vars.Register` entries for WebRTC fields.
|
||||||
|
- `app/api/api.go` — instantiate the WebRTC subsystem alongside restream.
|
||||||
|
- `http/server.go` — mount `/whep` routes under existing `/api/v3` group.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — `ConfigWebRTC` on restream's `Config`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `restream/app/process.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1.1 — Add `ConfigWebRTC` type + field**
|
||||||
|
|
||||||
|
Append after `ConfigIO` definition (~line 34), add field to `Config`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt"`
|
||||||
|
AudioPT uint8 `json:"audio_pt"`
|
||||||
|
ForceTranscode bool `json:"force_transcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w ConfigWebRTC) Clone() ConfigWebRTC { return w }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Config` struct:
|
||||||
|
```go
|
||||||
|
WebRTC ConfigWebRTC `json:"webrtc"`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.2 — Update `Config.Clone()` to carry WebRTC**
|
||||||
|
|
||||||
|
```go
|
||||||
|
clone.WebRTC = config.WebRTC.Clone()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.3 — Verify build**
|
||||||
|
|
||||||
|
Run: `go build ./restream/...`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 1.4 — Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add restream/app/process.go
|
||||||
|
git commit -m "feat(restream): add ConfigWebRTC per-process field"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — `DataWebRTC` on global config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/data.go`
|
||||||
|
- Modify: `config/config.go`
|
||||||
|
|
||||||
|
- [ ] **Step 2.1 — Add `WebRTC` block to `Data`**
|
||||||
|
|
||||||
|
In `config/data.go`, following the pattern of `SRT`/`FFmpeg` blocks, add near the similar service blocks:
|
||||||
|
|
||||||
|
```go
|
||||||
|
WebRTC struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
PublicIP string `json:"public_ip"`
|
||||||
|
NAT1To1IPs []string `json:"nat_1_to_1_ips"`
|
||||||
|
UDPMuxPort int `json:"udp_mux_port"`
|
||||||
|
} `json:"webrtc"`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2.2 — Register vars**
|
||||||
|
|
||||||
|
In `config/config.go`, at the end of the `vars.Register` block, add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
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", 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the project uses a different `vars.Register` signature, match the neighbors.)
|
||||||
|
|
||||||
|
- [ ] **Step 2.3 — Verify build and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./config/...
|
||||||
|
git add config/data.go config/config.go
|
||||||
|
git commit -m "feat(config): add webrtc global config block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — `ProcessHooks` + `AppendOutput` on restream
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `restream/restream.go`
|
||||||
|
|
||||||
|
- [ ] **Step 3.1 — Add `ProcessHook`, `ProcessHooks` types and field on restream struct**
|
||||||
|
|
||||||
|
Near the top (after imports, in the types region):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ProcessHook is called at well-defined points in a process's lifecycle.
|
||||||
|
// Return a non-nil error to abort the start (OnStart only; OnStop errors
|
||||||
|
// are logged and otherwise ignored).
|
||||||
|
type ProcessHook func(id string, cfg *app.Config) error
|
||||||
|
|
||||||
|
// ProcessHooks carries optional lifecycle callbacks for restream to invoke.
|
||||||
|
// A nil hook is a no-op.
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessHook // fires after args are assembled, before exec
|
||||||
|
OnStop ProcessHook // fires after wait() returns
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a field to the `restream` struct:
|
||||||
|
```go
|
||||||
|
hooks ProcessHooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `SetHooks` method:
|
||||||
|
```go
|
||||||
|
func (r *restream) SetHooks(h ProcessHooks) {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
r.hooks = h
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.2 — Wire OnStart / OnStop into the task lifecycle**
|
||||||
|
|
||||||
|
Find the `startProcess` / `ffmpeg.Start()` call site (~line 1065 per survey). Before the `Start()` call, insert:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if r.hooks.OnStart != nil {
|
||||||
|
if err := r.hooks.OnStart(task.id, task.config); err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Error().Log("OnStart hook aborted process start")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Find `stopProcess` / `ffmpeg.Stop()` (~line 1094). After the stop completes, add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if r.hooks.OnStop != nil {
|
||||||
|
if err := r.hooks.OnStop(task.id, task.config); err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Warn().Log("OnStop hook returned error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.3 — `AppendOutput`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// AppendOutput appends extra FFmpeg args to a process's pending command.
|
||||||
|
// Only valid during OnStart (between hook fire and exec). Returns an
|
||||||
|
// error otherwise.
|
||||||
|
func (r *restream) AppendOutput(id string, extra []string) error {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
t, ok := r.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("restream: no such process %q", id)
|
||||||
|
}
|
||||||
|
if t.config == nil {
|
||||||
|
return fmt.Errorf("restream: process %q has no config", id)
|
||||||
|
}
|
||||||
|
// Append to the free-form Options slice on a synthetic ConfigIO so
|
||||||
|
// CreateCommand picks it up. We model this as an extra Output with
|
||||||
|
// empty Address — address is carried inside extra itself.
|
||||||
|
t.config.Output = append(t.config.Output, app.ConfigIO{
|
||||||
|
ID: "webrtc",
|
||||||
|
Options: extra,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: callers build `extra` so that the last element is the UDP address; the appended `ConfigIO` has empty `Address` so `CreateCommand` won't double-append. Instead, fix `CreateCommand` to support this — or (cleaner) pass the address as the last entry of `Options` and set the inserted `ConfigIO.Address` to that last entry, dropping it from `Options`. Concretely:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *restream) AppendOutput(id string, extra []string) error {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
t, ok := r.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("restream: no such process %q", id)
|
||||||
|
}
|
||||||
|
if t.config == nil || len(extra) == 0 {
|
||||||
|
return fmt.Errorf("restream: append-output invalid args")
|
||||||
|
}
|
||||||
|
opts, addr := extra[:len(extra)-1], extra[len(extra)-1]
|
||||||
|
t.config.Output = append(t.config.Output, app.ConfigIO{
|
||||||
|
ID: "webrtc",
|
||||||
|
Address: addr,
|
||||||
|
Options: append([]string{}, opts...),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.4 — Verify build and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./restream/...
|
||||||
|
git add restream/restream.go
|
||||||
|
git commit -m "feat(restream): add ProcessHooks and AppendOutput"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — `app/webrtc/portalloc.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/portalloc.go`
|
||||||
|
- Create: `app/webrtc/portalloc_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 4.1 — Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlloc_ReturnsPortBindable(t *testing.T) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Alloc: %v", err)
|
||||||
|
}
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127,0,0,1), Port: p})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter %d: rebind %d: %v", i, p, err)
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlloc_Nonzero(t *testing.T) {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
if p == 0 { t.Fatal("expected non-zero port") }
|
||||||
|
fmt.Sprintf("%d", p)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.2 — Run test (should fail to compile)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -run TestAlloc -race
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.3 — Implement**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alloc binds :0 on loopback UDPv4, records the assigned port, closes the
|
||||||
|
// socket, and returns the port. Callers must re-bind immediately; if the
|
||||||
|
// port is taken in the gap (rare), the rebind will fail and the caller
|
||||||
|
// should propagate that error.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.4 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/portalloc.go app/webrtc/portalloc_test.go
|
||||||
|
git commit -m "feat(app/webrtc): ephemeral loopback UDP port allocator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — `app/webrtc/ffmpeg_args.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/ffmpeg_args.go`
|
||||||
|
- Create: `app/webrtc/ffmpeg_args_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 5.1 — Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"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)
|
||||||
|
want := []string{
|
||||||
|
"-map", "0:v:0", "-c:v", "copy", "-payload_type", "102", "-f", "rtp",
|
||||||
|
"udp://127.0.0.1:49200?pkt_size=1316",
|
||||||
|
"-map", "0:a:0", "-c:a", "copy", "-payload_type", "111", "-f", "rtp",
|
||||||
|
"udp://127.0.0.1:49201?pkt_size=1316",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("BuildArgs mismatch\ngot: %v\nwant: %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_ForceTranscode(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111, ForceTranscode: true}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
// video leg should include -c:v libx264 / profile=baseline
|
||||||
|
if !containsSeq(got, []string{"-c:v", "libx264"}) {
|
||||||
|
t.Fatalf("expected -c:v libx264, got %v", got)
|
||||||
|
}
|
||||||
|
if !containsSeq(got, []string{"-c:a", "libopus"}) {
|
||||||
|
t.Fatalf("expected -c:a libopus, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSeq(haystack, needle []string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
match := true
|
||||||
|
for j := range needle {
|
||||||
|
if haystack[i+j] != needle[j] { match = false; break }
|
||||||
|
}
|
||||||
|
if match { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5.2 — Implement**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildArgs returns the FFmpeg output-leg args for a WebRTC-enabled
|
||||||
|
// process. The caller passes a video RTP port; audio uses port+1.
|
||||||
|
// The returned slice is designed for restream.AppendOutput — the final
|
||||||
|
// element is the UDP address, the rest are options.
|
||||||
|
//
|
||||||
|
// We emit two separate outputs (one per track) so that -payload_type
|
||||||
|
// applies correctly to each. This produces *two* calls' worth of args
|
||||||
|
// but AppendOutput currently handles one output at a time. Callers
|
||||||
|
// should split on the boundary (the second `-map` token).
|
||||||
|
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"}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-map", "0:v:0"}
|
||||||
|
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", "0:a:0")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5.3 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/ffmpeg_args.go app/webrtc/ffmpeg_args_test.go
|
||||||
|
git commit -m "feat(app/webrtc): FFmpeg RTP output arg builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 — `app/webrtc/subsystem.go` + `lifecycle.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/subsystem.go`, `subsystem_test.go`
|
||||||
|
- Create: `app/webrtc/lifecycle.go`, `lifecycle_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 6.1 — Subsystem skeleton with dependency interface**
|
||||||
|
|
||||||
|
Because restream is a large package, define the dependency as an interface the subsystem needs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app/webrtc/subsystem.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Restreamer interface {
|
||||||
|
SetHooks(ProcessHooks)
|
||||||
|
AppendOutput(id string, extra []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessHook func(id string, cfg *appcfg.Config) error
|
||||||
|
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessHook
|
||||||
|
OnStop ProcessHook
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
PublicIP string
|
||||||
|
NAT1To1IPs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subsystem struct {
|
||||||
|
cfg Config
|
||||||
|
restream Restreamer
|
||||||
|
registry *core.Registry
|
||||||
|
factory *core.PeerFactory
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
peers map[string]map[string]*core.Peer // processID -> peerID -> peer
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config, r Restreamer) (*Subsystem, error) {
|
||||||
|
ccfg := core.DefaultConfig()
|
||||||
|
ccfg.PublicIP = cfg.PublicIP
|
||||||
|
ccfg.NAT1To1IPs = cfg.NAT1To1IPs
|
||||||
|
f, err := core.NewPeerFactory(ccfg)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
return &Subsystem{
|
||||||
|
cfg: cfg,
|
||||||
|
restream: r,
|
||||||
|
registry: core.NewRegistry(),
|
||||||
|
factory: f,
|
||||||
|
peers: make(map[string]map[string]*core.Peer),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.started { s.mu.Unlock(); return nil }
|
||||||
|
s.started = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.restream.SetHooks(ProcessHooks{
|
||||||
|
OnStart: s.onProcessStart,
|
||||||
|
OnStop: s.onProcessStop,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.started = false
|
||||||
|
s.restream.SetHooks(ProcessHooks{}) // clear
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** There's a type mismatch: `restream.ProcessHooks` is in package `restream`, this subsystem has its own `webrtc.ProcessHooks`. In the wiring task we either (a) import `restream.ProcessHooks` in the subsystem, or (b) define an adapter. Cleanest: the subsystem imports `restream` and uses `restream.ProcessHooks`. Let me rewrite using the real type — replace the local `ProcessHook`/`ProcessHooks` with `restream.ProcessHooks`. Do that in the actual implementation; the plan keeps the outline for readability.
|
||||||
|
|
||||||
|
- [ ] **Step 6.2 — Lifecycle (onProcessStart / onProcessStop)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app/webrtc/lifecycle.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Subsystem) onProcessStart(id string, cfg *appcfg.Config) error {
|
||||||
|
if cfg == nil || !cfg.WebRTC.Enabled { return nil }
|
||||||
|
|
||||||
|
port, err := Alloc()
|
||||||
|
if err != nil { return fmt.Errorf("webrtc: alloc port: %w", err) }
|
||||||
|
|
||||||
|
args := BuildArgs(cfg.WebRTC, port)
|
||||||
|
if err := s.restream.AppendOutput(id, args); err != nil {
|
||||||
|
return fmt.Errorf("webrtc: append output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := core.NewSourceOn(id, "127.0.0.1", port)
|
||||||
|
if err != nil { return fmt.Errorf("webrtc: bind source: %w", err) }
|
||||||
|
src.Start()
|
||||||
|
if err := s.registry.Register(id, src); err != nil {
|
||||||
|
src.Close()
|
||||||
|
return fmt.Errorf("webrtc: register source: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) onProcessStop(id string, _ *appcfg.Config) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
peers := s.peers[id]
|
||||||
|
delete(s.peers, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, p := range peers { _ = p.Close() }
|
||||||
|
if src, ok := s.registry.Get(id); ok {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
_ = src.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6.3 — Lifecycle test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// lifecycle_test.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRestream struct {
|
||||||
|
appended map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRestream) SetHooks(ProcessHooks) {}
|
||||||
|
func (f *fakeRestream) AppendOutput(id string, extra []string) error {
|
||||||
|
if f.appended == nil { f.appended = map[string][]string{} }
|
||||||
|
f.appended[id] = extra
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLifecycle_DisabledIsNoop(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, err := New(Config{}, f)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
cfg := &appcfg.Config{ID: "p1", WebRTC: appcfg.ConfigWebRTC{Enabled: false}}
|
||||||
|
if err := s.onProcessStart("p1", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if _, ok := f.appended["p1"]; ok { t.Fatal("expected no append for disabled") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLifecycle_EnabledAppendsAndRegisters(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, err := New(Config{}, f)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
cfg := &appcfg.Config{ID: "p2", WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}}
|
||||||
|
if err := s.onProcessStart("p2", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if len(f.appended["p2"]) == 0 { t.Fatal("expected append") }
|
||||||
|
if _, ok := s.registry.Get("p2"); !ok { t.Fatal("expected registered source") }
|
||||||
|
// teardown
|
||||||
|
if err := s.onProcessStop("p2", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if _, ok := s.registry.Get("p2"); ok { t.Fatal("expected removed") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6.4 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/subsystem.go app/webrtc/subsystem_test.go app/webrtc/lifecycle.go app/webrtc/lifecycle_test.go
|
||||||
|
git commit -m "feat(app/webrtc): subsystem skeleton + process lifecycle hooks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 — `app/webrtc/handler.go` (WHEP HTTP)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/handler.go`, `handler_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 7.1 — Handler: delegate to M1's WHEP handler with process-ID lookup**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// handler.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe handles POST /api/v3/process/:id/whep — look up the Source
|
||||||
|
// for the given process, run a WHEP offer/answer cycle, and forward
|
||||||
|
// RTP to the new peer.
|
||||||
|
func (s *Subsystem) Subscribe(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
src, ok := s.registry.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "stream not found")
|
||||||
|
}
|
||||||
|
// Delegate to the M1 WHEP handler — but we already have the source
|
||||||
|
// so we call the lower-level path.
|
||||||
|
offer, err := readBody(c)
|
||||||
|
if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) }
|
||||||
|
|
||||||
|
peer, answer, err := s.factory.NewPeerFromOffer(src, offer)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
peerID := peer.ID()
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.peers[id] == nil { s.peers[id] = map[string]*core.Peer{} }
|
||||||
|
s.peers[id][peerID] = peer
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
c.Response().Header().Set("Location",
|
||||||
|
"/api/v3/process/"+id+"/whep/"+peerID)
|
||||||
|
return c.Blob(http.StatusCreated, "application/sdp", []byte(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe handles DELETE /api/v3/process/:id/whep/:peerid.
|
||||||
|
func (s *Subsystem) Unsubscribe(c echo.Context) error {
|
||||||
|
id, peerID := c.Param("id"), c.Param("peerid")
|
||||||
|
s.mu.Lock()
|
||||||
|
peer := s.peers[id][peerID]
|
||||||
|
delete(s.peers[id], peerID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if peer != nil { _ = peer.Close() }
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(c echo.Context) (string, error) {
|
||||||
|
buf := make([]byte, 0, 8192)
|
||||||
|
for {
|
||||||
|
tmp := make([]byte, 4096)
|
||||||
|
n, err := c.Request().Body.Read(tmp)
|
||||||
|
if n > 0 { buf = append(buf, tmp[:n]...) }
|
||||||
|
if err != nil { break }
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If `core/webrtc.PeerFactory` doesn't expose `NewPeerFromOffer`, swap in whatever API M1 provided (`factory.NewPeer(...)` taking source+offer). If the M1 handler is higher-level, wrap it instead of reimplementing.
|
||||||
|
|
||||||
|
- [ ] **Step 7.2 — Handler test: 404 on unknown id**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscribe_404OnUnknown(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, _ := New(Config{}, f)
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id"); c.SetParamValues("missing")
|
||||||
|
err := s.Subscribe(c)
|
||||||
|
if he, ok := err.(*echo.HTTPError); !ok || he.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribe_IdempotentNoContent(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, _ := New(Config{}, f)
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "peerid"); c.SetParamValues("p", "nope")
|
||||||
|
if err := s.Unsubscribe(c); err != nil { t.Fatal(err) }
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7.3 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/handler.go app/webrtc/handler_test.go
|
||||||
|
git commit -m "feat(app/webrtc): WHEP HTTP handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 — Wire subsystem into app/api/api.go + http/server.go
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/api/api.go`
|
||||||
|
- Modify: `http/server.go`
|
||||||
|
|
||||||
|
- [ ] **Step 8.1 — Instantiate subsystem in api.New**
|
||||||
|
|
||||||
|
In `app/api/api.go`, after `restream := restream.New(...)`, when `cfg.WebRTC.Enable` is true, create the subsystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if cfg.WebRTC.Enable {
|
||||||
|
webrtcSub, err := webrtcapp.New(webrtcapp.Config{
|
||||||
|
PublicIP: cfg.WebRTC.PublicIP,
|
||||||
|
NAT1To1IPs: cfg.WebRTC.NAT1To1IPs,
|
||||||
|
}, restream)
|
||||||
|
if err != nil {
|
||||||
|
a.log.logger.core.Warn().WithError(err).Log("webrtc subsystem disabled")
|
||||||
|
} else {
|
||||||
|
_ = webrtcSub.Start()
|
||||||
|
a.webrtc = webrtcSub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store on the api struct: `webrtc *webrtcapp.Subsystem`.
|
||||||
|
|
||||||
|
- [ ] **Step 8.2 — Mount HTTP routes**
|
||||||
|
|
||||||
|
In `http/server.go` near line 568 (where `v3.POST("/process", ...)` lives):
|
||||||
|
|
||||||
|
```go
|
||||||
|
if s.webrtc != nil {
|
||||||
|
v3.POST("/process/:id/whep", s.webrtc.Subscribe)
|
||||||
|
v3.DELETE("/process/:id/whep/:peerid", s.webrtc.Unsubscribe)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plumb `s.webrtc` from api → http/server constructor.
|
||||||
|
|
||||||
|
- [ ] **Step 8.3 — Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8.4 — Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/api/api.go http/server.go
|
||||||
|
git commit -m "feat(core): wire webrtc subsystem + WHEP routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9 — Integration smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/integration_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 9.1 — Synthetic RTP → WHEP end-to-end**
|
||||||
|
|
||||||
|
Import M1's `test/whep-client` as a library. Boot a Subsystem, inject synthetic RTP on the allocated port (mimic Task 6's lifecycle), POST a WHEP offer, assert both tracks arrive. See M1's `test/whep-client/main_test.go` for reference.
|
||||||
|
|
||||||
|
- [ ] **Step 9.2 — Run with -race and commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10 — TrueNAS redeploy
|
||||||
|
|
||||||
|
- [ ] **Step 10.1 — Rebuild Core image (Dockerfile currently targets `cmd/webrtc-poc`; add a second target or switch to the root `./` build for Core proper).**
|
||||||
|
- [ ] **Step 10.2 — Redeploy via docker compose on TrueNAS; verify WHEP endpoint returns 404 before any process exists, 201 after enabling WebRTC on a process.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for this plan
|
||||||
|
|
||||||
|
- `core-ui/src/views/Edit/LiveTab.jsx` — core-ui is a separate repo and requires its own plan. Track as M2.5 once core-ui is cloned into the workspace.
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- Task 7 depends on `core/webrtc.PeerFactory.NewPeerFromOffer` signature from M1; if it's named differently, adjust the call site (don't rewrite the handler).
|
||||||
|
- Task 3 Step 3.3 assumes `restream.tasks` is a map keyed by id with a `*task` value that carries `config`. Confirm by reading around line 90 before implementing; the exact struct name may differ.
|
||||||
|
- Task 2 `vars.NewStringList` / `vars.NewInt` signatures need confirming against the real `config/vars/value` package.
|
||||||
|
|
@ -1896,6 +1896,165 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v3/whep/{id}": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"consumes": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Subscribe to a WebRTC stream via WHEP",
|
||||||
|
"operationId": "webrtc-3-whep-subscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID with config.webrtc.enabled=true",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "SDP answer",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing stream id, malformed body, or invalid SDP",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "no stream registered for this process id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"406": {
|
||||||
|
"description": "offer SDP missing required H264 / Opus rtpmap",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "peer cap reached (per-stream or total)",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"504": {
|
||||||
|
"description": "ICE gathering timeout",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v3/whep/{id}/{resource}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"summary": "Tear down a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-unsubscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.",
|
||||||
|
"consumes": [
|
||||||
|
"application/trickle-ice-sdpfrag"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Trickle ICE candidates for a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-trickle",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id or unreadable body",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "peer not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v3/widget/process/{id}": {
|
"/api/v3/widget/process/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
||||||
|
|
@ -2075,6 +2234,10 @@
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"fork": {
|
||||||
|
"description": "Fork is the human-readable fork name (e.g. \"Datarhei — Dragon Fork\").",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -2084,6 +2247,10 @@
|
||||||
"uptime_seconds": {
|
"uptime_seconds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"variant": {
|
||||||
|
"description": "Variant identifies the build flavor — empty (or \"core\") for an\nupstream Datarhei build, \"dragonfork\" for the Dragon Fork.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"$ref": "#/definitions/api.Version"
|
"$ref": "#/definitions/api.Version"
|
||||||
}
|
}
|
||||||
|
|
@ -2622,6 +2789,9 @@
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3102,6 +3272,9 @@
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/api.ProcessConfigWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3169,6 +3342,29 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.ProcessConfigWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"audio_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"audio_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"force_transcode": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"video_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"video_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.ProcessReport": {
|
"api.ProcessReport": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4434,6 +4630,9 @@
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4702,6 +4901,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DataWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nat_1_to_1_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public_ip": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"udp_mux_port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_datarhei_core_v16_http_api.Config": {
|
"github_com_datarhei_core_v16_http_api.Config": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,21 @@ definitions:
|
||||||
type: array
|
type: array
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
|
fork:
|
||||||
|
description: Fork is the human-readable fork name (e.g. "Datarhei — Dragon
|
||||||
|
Fork").
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
uptime_seconds:
|
uptime_seconds:
|
||||||
type: integer
|
type: integer
|
||||||
|
variant:
|
||||||
|
description: |-
|
||||||
|
Variant identifies the build flavor — empty (or "core") for an
|
||||||
|
upstream Datarhei build, "dragonfork" for the Dragon Fork.
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
$ref: '#/definitions/api.Version'
|
$ref: '#/definitions/api.Version'
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -420,6 +429,8 @@ definitions:
|
||||||
version:
|
version:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/config.DataWebRTC'
|
||||||
type: object
|
type: object
|
||||||
api.ConfigError:
|
api.ConfigError:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
|
@ -743,6 +754,8 @@ definitions:
|
||||||
- ffmpeg
|
- ffmpeg
|
||||||
- ""
|
- ""
|
||||||
type: string
|
type: string
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/api.ProcessConfigWebRTC'
|
||||||
required:
|
required:
|
||||||
- input
|
- input
|
||||||
- output
|
- output
|
||||||
|
|
@ -790,6 +803,21 @@ definitions:
|
||||||
format: uint64
|
format: uint64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
api.ProcessConfigWebRTC:
|
||||||
|
properties:
|
||||||
|
audio_map:
|
||||||
|
type: string
|
||||||
|
audio_pt:
|
||||||
|
type: integer
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
force_transcode:
|
||||||
|
type: boolean
|
||||||
|
video_map:
|
||||||
|
type: string
|
||||||
|
video_pt:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
api.ProcessReport:
|
api.ProcessReport:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
|
|
@ -1709,6 +1737,8 @@ definitions:
|
||||||
version:
|
version:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/config.DataWebRTC'
|
||||||
type: object
|
type: object
|
||||||
api.Skills:
|
api.Skills:
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -1882,6 +1912,20 @@ definitions:
|
||||||
uptime:
|
uptime:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
config.DataWebRTC:
|
||||||
|
properties:
|
||||||
|
enable:
|
||||||
|
type: boolean
|
||||||
|
nat_1_to_1_ips:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
public_ip:
|
||||||
|
type: string
|
||||||
|
udp_mux_port:
|
||||||
|
format: int
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
github_com_datarhei_core_v16_http_api.Config:
|
github_com_datarhei_core_v16_http_api.Config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
|
|
@ -3186,6 +3230,113 @@ paths:
|
||||||
summary: List all publishing SRT treams
|
summary: List all publishing SRT treams
|
||||||
tags:
|
tags:
|
||||||
- v16.9.0
|
- v16.9.0
|
||||||
|
/api/v3/whep/{id}:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/sdp
|
||||||
|
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.'
|
||||||
|
operationId: webrtc-3-whep-subscribe
|
||||||
|
parameters:
|
||||||
|
- description: Process ID with config.webrtc.enabled=true
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/sdp
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: SDP answer
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"400":
|
||||||
|
description: missing stream id, malformed body, or invalid SDP
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: no stream registered for this process id
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"406":
|
||||||
|
description: offer SDP missing required H264 / Opus rtpmap
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"503":
|
||||||
|
description: peer cap reached (per-stream or total)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"504":
|
||||||
|
description: ICE gathering timeout
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Subscribe to a WebRTC stream via WHEP
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
|
/api/v3/whep/{id}/{resource}:
|
||||||
|
delete:
|
||||||
|
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.
|
||||||
|
operationId: webrtc-3-whep-unsubscribe
|
||||||
|
parameters:
|
||||||
|
- description: Process ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Resource ID from the Subscribe Location header
|
||||||
|
in: path
|
||||||
|
name: resource
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: no content
|
||||||
|
"400":
|
||||||
|
description: missing resource id
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Tear down a WHEP subscription
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
|
patch:
|
||||||
|
consumes:
|
||||||
|
- application/trickle-ice-sdpfrag
|
||||||
|
description: Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.
|
||||||
|
operationId: webrtc-3-whep-trickle
|
||||||
|
parameters:
|
||||||
|
- description: Process ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Resource ID from the Subscribe Location header
|
||||||
|
in: path
|
||||||
|
name: resource
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: no content
|
||||||
|
"400":
|
||||||
|
description: missing resource id or unreadable body
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: peer not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Trickle ICE candidates for a WHEP subscription
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
/api/v3/widget/process/{id}:
|
/api/v3/widget/process/{id}:
|
||||||
get:
|
get:
|
||||||
description: Fetch minimal statistics about a process, which is not protected
|
description: Fetch minimal statistics about a process, which is not protected
|
||||||
|
|
|
||||||
39
go.mod
39
go.mod
|
|
@ -1,8 +1,6 @@
|
||||||
module github.com/datarhei/core/v16
|
module github.com/datarhei/core/v16
|
||||||
|
|
||||||
go 1.21.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.22.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/gqlgen v0.17.47
|
github.com/99designs/gqlgen v0.17.47
|
||||||
|
|
@ -22,17 +20,19 @@ require (
|
||||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/minio/minio-go/v7 v7.0.70
|
github.com/minio/minio-go/v7 v7.0.70
|
||||||
|
github.com/pion/rtp v1.10.1
|
||||||
|
github.com/pion/webrtc/v4 v4.2.11
|
||||||
github.com/prep/average v0.0.0-20200506183628-d26c465f48c3
|
github.com/prep/average v0.0.0-20200506183628-d26c465f48c3
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.1.0
|
github.com/puzpuzpuz/xsync/v3 v3.1.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.4
|
github.com/shirou/gopsutil/v3 v3.24.4
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/echo-swagger v1.4.1
|
github.com/swaggo/echo-swagger v1.4.1
|
||||||
github.com/swaggo/swag v1.16.3
|
github.com/swaggo/swag v1.16.3
|
||||||
github.com/vektah/gqlparser/v2 v2.5.12
|
github.com/vektah/gqlparser/v2 v2.5.12
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0
|
github.com/xeipuuv/gojsonschema v1.2.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/mod v0.17.0
|
golang.org/x/mod v0.32.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -73,6 +73,20 @@ require (
|
||||||
github.com/miekg/dns v1.1.59 // indirect
|
github.com/miekg/dns v1.1.59 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/pion/datachannel v1.6.0 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.2.2 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.44 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
|
github.com/pion/sctp v1.9.4 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||||
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
|
@ -88,19 +102,20 @@ require (
|
||||||
github.com/urfave/cli/v2 v2.27.2 // indirect
|
github.com/urfave/cli/v2 v2.27.2 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.10.0 // indirect
|
||||||
golang.org/x/tools v0.21.0 // indirect
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|
|
||||||
71
go.sum
71
go.sum
|
|
@ -134,6 +134,40 @@ github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJn
|
||||||
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo=
|
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||||
|
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||||
|
github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg=
|
||||||
|
github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
|
||||||
|
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
||||||
|
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
||||||
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||||
|
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||||
|
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||||
|
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
|
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
|
||||||
|
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
||||||
|
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||||
|
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||||
|
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||||
|
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||||
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
|
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||||
|
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|
@ -177,8 +211,9 @@ github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
||||||
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
||||||
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
|
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
|
||||||
|
|
@ -199,6 +234,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.12 h1:COMhVVnql6RoaF7+aTBWiTADdpLGyZWU3K/NwW0ph98=
|
github.com/vektah/gqlparser/v2 v2.5.12 h1:COMhVVnql6RoaF7+aTBWiTADdpLGyZWU3K/NwW0ph98=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.12/go.mod h1:WQQjFc+I1YIzoPvZBhUQX7waZgg3pMLi0r8KymvAE2w=
|
github.com/vektah/gqlparser/v2 v2.5.12/go.mod h1:WQQjFc+I1YIzoPvZBhUQX7waZgg3pMLi0r8KymvAE2w=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
|
@ -222,14 +259,14 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -239,14 +276,14 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ package api
|
||||||
// About is some general information about the API
|
// About is some general information about the API
|
||||||
type About struct {
|
type About struct {
|
||||||
App string `json:"app"`
|
App string `json:"app"`
|
||||||
|
// Variant identifies the build flavor — empty (or "core") for an
|
||||||
|
// upstream Datarhei build, "dragonfork" for the Dragon Fork.
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
// Fork is the human-readable fork name (e.g. "Datarhei — Dragon Fork").
|
||||||
|
Fork string `json:"fork,omitempty"`
|
||||||
Auths []string `json:"auths"`
|
Auths []string `json:"auths"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ type ProcessConfigLimits struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessConfig represents the configuration of an ffmpeg process
|
// ProcessConfig represents the configuration of an ffmpeg process
|
||||||
|
type ProcessConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt,omitempty"`
|
||||||
|
AudioPT uint8 `json:"audio_pt,omitempty"`
|
||||||
|
ForceTranscode bool `json:"force_transcode,omitempty"`
|
||||||
|
VideoMap string `json:"video_map,omitempty"`
|
||||||
|
AudioMap string `json:"audio_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProcessConfig struct {
|
type ProcessConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type" validate:"oneof='ffmpeg' ''" jsonschema:"enum=ffmpeg,enum="`
|
Type string `json:"type" validate:"oneof='ffmpeg' ''" jsonschema:"enum=ffmpeg,enum="`
|
||||||
|
|
@ -55,6 +64,7 @@ type ProcessConfig struct {
|
||||||
Autostart bool `json:"autostart"`
|
Autostart bool `json:"autostart"`
|
||||||
StaleTimeout uint64 `json:"stale_timeout_seconds" format:"uint64"`
|
StaleTimeout uint64 `json:"stale_timeout_seconds" format:"uint64"`
|
||||||
Limits ProcessConfigLimits `json:"limits"`
|
Limits ProcessConfigLimits `json:"limits"`
|
||||||
|
WebRTC ProcessConfigWebRTC `json:"webrtc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal converts a process config in API representation to a restreamer process config
|
// Marshal converts a process config in API representation to a restreamer process config
|
||||||
|
|
@ -70,6 +80,14 @@ func (cfg *ProcessConfig) Marshal() *app.Config {
|
||||||
LimitCPU: cfg.Limits.CPU,
|
LimitCPU: cfg.Limits.CPU,
|
||||||
LimitMemory: cfg.Limits.Memory * 1024 * 1024,
|
LimitMemory: cfg.Limits.Memory * 1024 * 1024,
|
||||||
LimitWaitFor: cfg.Limits.WaitFor,
|
LimitWaitFor: cfg.Limits.WaitFor,
|
||||||
|
WebRTC: app.ConfigWebRTC{
|
||||||
|
Enabled: cfg.WebRTC.Enabled,
|
||||||
|
VideoPT: cfg.WebRTC.VideoPT,
|
||||||
|
AudioPT: cfg.WebRTC.AudioPT,
|
||||||
|
ForceTranscode: cfg.WebRTC.ForceTranscode,
|
||||||
|
VideoMap: cfg.WebRTC.VideoMap,
|
||||||
|
AudioMap: cfg.WebRTC.AudioMap,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.generateInputOutputIDs(cfg.Input)
|
cfg.generateInputOutputIDs(cfg.Input)
|
||||||
|
|
@ -150,6 +168,13 @@ func (cfg *ProcessConfig) Unmarshal(c *app.Config) {
|
||||||
cfg.Limits.Memory = c.LimitMemory / 1024 / 1024
|
cfg.Limits.Memory = c.LimitMemory / 1024 / 1024
|
||||||
cfg.Limits.WaitFor = c.LimitWaitFor
|
cfg.Limits.WaitFor = c.LimitWaitFor
|
||||||
|
|
||||||
|
cfg.WebRTC.Enabled = c.WebRTC.Enabled
|
||||||
|
cfg.WebRTC.VideoPT = c.WebRTC.VideoPT
|
||||||
|
cfg.WebRTC.AudioPT = c.WebRTC.AudioPT
|
||||||
|
cfg.WebRTC.ForceTranscode = c.WebRTC.ForceTranscode
|
||||||
|
cfg.WebRTC.VideoMap = c.WebRTC.VideoMap
|
||||||
|
cfg.WebRTC.AudioMap = c.WebRTC.AudioMap
|
||||||
|
|
||||||
cfg.Options = make([]string, len(c.Options))
|
cfg.Options = make([]string, len(c.Options))
|
||||||
copy(cfg.Options, c.Options)
|
copy(cfg.Options, c.Options)
|
||||||
|
|
||||||
|
|
|
||||||
109
http/api/process_webrtc_test.go
Normal file
109
http/api/process_webrtc_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCRoundtrip locks down the API DTO ↔ restream
|
||||||
|
// app.Config mapping for the per-process WebRTC block.
|
||||||
|
//
|
||||||
|
// Regression: the M2 cut shipped without WebRTC on ProcessConfig, so
|
||||||
|
// JSON arriving at POST /api/v3/process was silently stripped of
|
||||||
|
// `webrtc.enabled`, the restream config never saw it, the start hook
|
||||||
|
// never bound a Source, and WHEP returned 404. This test fails on the
|
||||||
|
// pre-fix code (Marshal would yield `app.ConfigWebRTC{}`) and passes
|
||||||
|
// once the DTO carries the field.
|
||||||
|
func TestProcessConfigWebRTCRoundtrip(t *testing.T) {
|
||||||
|
// 1. JSON in → DTO → app.Config
|
||||||
|
body := []byte(`{
|
||||||
|
"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}],
|
||||||
|
"webrtc":{"enabled":true,"video_pt":102,"audio_pt":111,"force_transcode":true}
|
||||||
|
}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !dto.WebRTC.Enabled {
|
||||||
|
t.Fatalf("DTO.WebRTC.Enabled lost on JSON decode: %+v", dto.WebRTC)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if !cfg.WebRTC.Enabled || cfg.WebRTC.VideoPT != 102 || cfg.WebRTC.AudioPT != 111 || !cfg.WebRTC.ForceTranscode {
|
||||||
|
t.Fatalf("app.Config.WebRTC mapped wrong: %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. app.Config → DTO → JSON out
|
||||||
|
stored := &app.Config{
|
||||||
|
ID: "p",
|
||||||
|
Input: []app.ConfigIO{{ID: "i", Address: "x"}},
|
||||||
|
Output: []app.ConfigIO{{ID: "o", Address: "-"}},
|
||||||
|
WebRTC: app.ConfigWebRTC{
|
||||||
|
Enabled: true,
|
||||||
|
VideoPT: 102,
|
||||||
|
AudioPT: 111,
|
||||||
|
ForceTranscode: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var dto2 ProcessConfig
|
||||||
|
dto2.Unmarshal(stored)
|
||||||
|
if !dto2.WebRTC.Enabled || dto2.WebRTC.VideoPT != 102 {
|
||||||
|
t.Fatalf("Unmarshal lost WebRTC: %+v", dto2.WebRTC)
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(dto2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Decode again and compare.
|
||||||
|
var dto3 ProcessConfig
|
||||||
|
if err := json.Unmarshal(out, &dto3); err != nil {
|
||||||
|
t.Fatalf("re-unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if dto3.WebRTC != dto.WebRTC {
|
||||||
|
t.Fatalf("roundtrip diverged: in=%+v out=%+v", dto.WebRTC, dto3.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCDefaults: when "webrtc" is absent in the
|
||||||
|
// inbound JSON, Marshal must still produce a valid app.Config — the
|
||||||
|
// zero ConfigWebRTC means "disabled" and the start hook should no-op.
|
||||||
|
func TestProcessConfigWebRTCDefaults(t *testing.T) {
|
||||||
|
body := []byte(`{"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}]}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if cfg.WebRTC.Enabled {
|
||||||
|
t.Fatalf("default should be disabled, got %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCMapsRoundtrip extends the WebRTC DTO
|
||||||
|
// roundtrip with the issue-#2 VideoMap/AudioMap fields so the
|
||||||
|
// regression doesn't repeat: a multi-input pipeline that sets
|
||||||
|
// `audio_map: "1:a:0"` must reach the restream config layer
|
||||||
|
// unchanged.
|
||||||
|
func TestProcessConfigWebRTCMapsRoundtrip(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}],
|
||||||
|
"webrtc":{"enabled":true,"video_map":"0:v:1","audio_map":"1:a:0"}
|
||||||
|
}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if dto.WebRTC.VideoMap != "0:v:1" || dto.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("DTO maps lost: %+v", dto.WebRTC)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if cfg.WebRTC.VideoMap != "0:v:1" || cfg.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("app.Config maps lost: %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
var back ProcessConfig
|
||||||
|
back.Unmarshal(cfg)
|
||||||
|
if back.WebRTC.VideoMap != "0:v:1" || back.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("Unmarshal lost maps: %+v", back.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,8 @@ func (p *AboutHandler) About(c echo.Context) error {
|
||||||
|
|
||||||
about := api.About{
|
about := api.About{
|
||||||
App: app.Name,
|
App: app.Name,
|
||||||
|
Variant: app.Variant,
|
||||||
|
Fork: app.Fork,
|
||||||
Name: p.restream.Name(),
|
Name: p.restream.Name(),
|
||||||
Auths: p.auths,
|
Auths: p.auths,
|
||||||
ID: p.restream.ID(),
|
ID: p.restream.ID(),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
appwebrtc "github.com/datarhei/core/v16/app/webrtc"
|
||||||
cfgstore "github.com/datarhei/core/v16/config/store"
|
cfgstore "github.com/datarhei/core/v16/config/store"
|
||||||
"github.com/datarhei/core/v16/http/cache"
|
"github.com/datarhei/core/v16/http/cache"
|
||||||
"github.com/datarhei/core/v16/http/errorhandler"
|
"github.com/datarhei/core/v16/http/errorhandler"
|
||||||
|
|
@ -86,6 +87,7 @@ type Config struct {
|
||||||
Cors CorsConfig
|
Cors CorsConfig
|
||||||
RTMP rtmp.Server
|
RTMP rtmp.Server
|
||||||
SRT srt.Server
|
SRT srt.Server
|
||||||
|
WebRTC *appwebrtc.Handler
|
||||||
JWT jwt.JWT
|
JWT jwt.JWT
|
||||||
Config cfgstore.Store
|
Config cfgstore.Store
|
||||||
Cache cache.Cacher
|
Cache cache.Cacher
|
||||||
|
|
@ -124,6 +126,7 @@ type server struct {
|
||||||
session *api.SessionHandler
|
session *api.SessionHandler
|
||||||
widget *api.WidgetHandler
|
widget *api.WidgetHandler
|
||||||
resources *api.MetricsHandler
|
resources *api.MetricsHandler
|
||||||
|
webrtc *appwebrtc.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware struct {
|
middleware struct {
|
||||||
|
|
@ -238,6 +241,10 @@ func NewServer(config Config) (Server, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.WebRTC != nil {
|
||||||
|
s.v3handler.webrtc = config.WebRTC
|
||||||
|
}
|
||||||
|
|
||||||
if config.Prometheus != nil {
|
if config.Prometheus != nil {
|
||||||
s.handler.prometheus = handler.NewPrometheus(
|
s.handler.prometheus = handler.NewPrometheus(
|
||||||
config.Prometheus.HTTPHandler(),
|
config.Prometheus.HTTPHandler(),
|
||||||
|
|
@ -545,6 +552,12 @@ func (s *server) setRoutesV3(v3 *echo.Group) {
|
||||||
s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get)
|
s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v3 WebRTC (WHEP egress). Mounted on the v3 group so JWT auth
|
||||||
|
// covers it in M2; public embed tokens will ship in M3.
|
||||||
|
if s.v3handler.webrtc != nil {
|
||||||
|
s.v3handler.webrtc.Register(v3)
|
||||||
|
}
|
||||||
|
|
||||||
// v3 Restreamer
|
// v3 Restreamer
|
||||||
if s.v3handler.restream != nil {
|
if s.v3handler.restream != nil {
|
||||||
v3.GET("/skills", s.v3handler.restream.Skills)
|
v3.GET("/skills", s.v3handler.restream.Skills)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,31 @@ type ConfigIO struct {
|
||||||
Cleanup []ConfigIOCleanup `json:"cleanup"`
|
Cleanup []ConfigIOCleanup `json:"cleanup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigWebRTC carries per-process WebRTC egress settings.
|
||||||
|
//
|
||||||
|
// When Enabled is true the restream manager will (via the app/webrtc
|
||||||
|
// subsystem) append an additional FFmpeg output leg that emits H.264/Opus
|
||||||
|
// RTP to a loopback UDP port the subsystem allocates. The subsystem reads
|
||||||
|
// that RTP and fans it out to WHEP subscribers.
|
||||||
|
type ConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt"`
|
||||||
|
AudioPT uint8 `json:"audio_pt"`
|
||||||
|
ForceTranscode bool `json:"force_transcode"`
|
||||||
|
|
||||||
|
// VideoMap / AudioMap select which input stream the WebRTC RTP
|
||||||
|
// legs draw from. Defaults are "0:v:0" and "0:a:0" — correct for
|
||||||
|
// any RTMP / SRT publisher (single input, both A and V on input
|
||||||
|
// 0). For multi-input pipelines (lavfi test sources, SDI capture
|
||||||
|
// fed alongside file audio, etc.) the operator can override.
|
||||||
|
VideoMap string `json:"video_map,omitempty"`
|
||||||
|
AudioMap string `json:"audio_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy of the WebRTC config (currently a value copy;
|
||||||
|
// provided for symmetry with other Clone methods and future-proofing).
|
||||||
|
func (w ConfigWebRTC) Clone() ConfigWebRTC { return w }
|
||||||
|
|
||||||
func (io ConfigIO) Clone() ConfigIO {
|
func (io ConfigIO) Clone() ConfigIO {
|
||||||
clone := ConfigIO{
|
clone := ConfigIO{
|
||||||
ID: io.ID,
|
ID: io.ID,
|
||||||
|
|
@ -47,6 +72,7 @@ type Config struct {
|
||||||
LimitCPU float64 `json:"limit_cpu_usage"` // percent
|
LimitCPU float64 `json:"limit_cpu_usage"` // percent
|
||||||
LimitMemory uint64 `json:"limit_memory_bytes"` // bytes
|
LimitMemory uint64 `json:"limit_memory_bytes"` // bytes
|
||||||
LimitWaitFor uint64 `json:"limit_waitfor_seconds"` // seconds
|
LimitWaitFor uint64 `json:"limit_waitfor_seconds"` // seconds
|
||||||
|
WebRTC ConfigWebRTC `json:"webrtc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) Clone() *Config {
|
func (config *Config) Clone() *Config {
|
||||||
|
|
@ -61,6 +87,7 @@ func (config *Config) Clone() *Config {
|
||||||
LimitCPU: config.LimitCPU,
|
LimitCPU: config.LimitCPU,
|
||||||
LimitMemory: config.LimitMemory,
|
LimitMemory: config.LimitMemory,
|
||||||
LimitWaitFor: config.LimitWaitFor,
|
LimitWaitFor: config.LimitWaitFor,
|
||||||
|
WebRTC: config.WebRTC.Clone(),
|
||||||
}
|
}
|
||||||
|
|
||||||
clone.Input = make([]ConfigIO, len(config.Input))
|
clone.Input = make([]ConfigIO, len(config.Input))
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,30 @@ type Restreamer interface {
|
||||||
GetProcessMetadata(id, key string) (interface{}, error) // Get previously set metadata from a process
|
GetProcessMetadata(id, key string) (interface{}, error) // Get previously set metadata from a process
|
||||||
SetMetadata(key string, data interface{}) error // Set general metadata
|
SetMetadata(key string, data interface{}) error // Set general metadata
|
||||||
GetMetadata(key string) (interface{}, error) // Get previously set general metadata
|
GetMetadata(key string) (interface{}, error) // Get previously set general metadata
|
||||||
|
SetHooks(hooks ProcessHooks) // Install per-process lifecycle hooks (e.g., WebRTC subsystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessStartHook is invoked synchronously inside startProcess just
|
||||||
|
// before FFmpeg is started. It receives a pointer to the task config;
|
||||||
|
// returning a non-empty slice of ConfigIO appends those output legs to
|
||||||
|
// cfg.Output and causes the FFmpeg command to be rebuilt before
|
||||||
|
// Start(). Returning a non-nil error aborts the start.
|
||||||
|
//
|
||||||
|
// Hooks run with the restream write lock held, so they must not call
|
||||||
|
// back into the Restreamer interface (it would deadlock). They can,
|
||||||
|
// however, mutate cfg.WebRTC metadata or read cfg fields freely.
|
||||||
|
type ProcessStartHook func(id string, cfg *app.Config) ([]app.ConfigIO, error)
|
||||||
|
|
||||||
|
// ProcessStopHook is invoked synchronously inside stopProcess just
|
||||||
|
// after FFmpeg has been stopped. It is a notification for subsystems
|
||||||
|
// to tear down any per-process state they attached at start.
|
||||||
|
type ProcessStopHook func(id string)
|
||||||
|
|
||||||
|
// ProcessHooks bundles the lifecycle callbacks a sibling subsystem
|
||||||
|
// (currently: app/webrtc) installs via SetHooks.
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessStartHook
|
||||||
|
OnStop ProcessStopHook
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config is the required configuration for a new restreamer instance.
|
// Config is the required configuration for a new restreamer instance.
|
||||||
|
|
@ -102,12 +126,24 @@ type restream struct {
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
metadata map[string]interface{}
|
metadata map[string]interface{}
|
||||||
|
|
||||||
|
hooks ProcessHooks
|
||||||
|
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
|
|
||||||
startOnce sync.Once
|
startOnce sync.Once
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHooks installs the process lifecycle hooks. The caller is
|
||||||
|
// responsible for installing hooks before Start() is invoked; calling
|
||||||
|
// SetHooks on a running instance is safe but only affects subsequent
|
||||||
|
// start/stop transitions (not the one currently in flight).
|
||||||
|
func (r *restream) SetHooks(hooks ProcessHooks) {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
r.hooks = hooks
|
||||||
|
}
|
||||||
|
|
||||||
// New returns a new instance that implements the Restreamer interface
|
// New returns a new instance that implements the Restreamer interface
|
||||||
func New(config Config) (Restreamer, error) {
|
func New(config Config) (Restreamer, error) {
|
||||||
r := &restream{
|
r := &restream{
|
||||||
|
|
@ -1062,6 +1098,39 @@ func (r *restream) startProcess(id string) error {
|
||||||
|
|
||||||
task.process.Order = "start"
|
task.process.Order = "start"
|
||||||
|
|
||||||
|
// Invoke the per-process start hook (used by app/webrtc to append
|
||||||
|
// RTP output legs). If it returns ConfigIO entries, append them to
|
||||||
|
// the output list and rebuild the FFmpeg process with the new
|
||||||
|
// command before we start it.
|
||||||
|
if r.hooks.OnStart != nil {
|
||||||
|
extras, err := r.hooks.OnStart(task.id, task.config)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Error().Log("Start hook aborted process start")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(extras) > 0 {
|
||||||
|
task.config.Output = append(task.config.Output, extras...)
|
||||||
|
task.command = task.config.CreateCommand()
|
||||||
|
|
||||||
|
newFFmpeg, ferr := r.ffmpeg.New(ffmpeg.ProcessConfig{
|
||||||
|
Reconnect: task.config.Reconnect,
|
||||||
|
ReconnectDelay: time.Duration(task.config.ReconnectDelay) * time.Second,
|
||||||
|
StaleTimeout: time.Duration(task.config.StaleTimeout) * time.Second,
|
||||||
|
LimitCPU: task.config.LimitCPU,
|
||||||
|
LimitMemory: task.config.LimitMemory,
|
||||||
|
LimitDuration: time.Duration(task.config.LimitWaitFor) * time.Second,
|
||||||
|
Command: task.command,
|
||||||
|
Parser: task.parser,
|
||||||
|
Logger: task.logger,
|
||||||
|
})
|
||||||
|
if ferr != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(ferr).Error().Log("Failed to rebuild FFmpeg after start hook")
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
task.ffmpeg = newFFmpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
task.ffmpeg.Start()
|
task.ffmpeg.Start()
|
||||||
|
|
||||||
r.nProc++
|
r.nProc++
|
||||||
|
|
@ -1105,6 +1174,13 @@ func (r *restream) stopProcess(id string) error {
|
||||||
|
|
||||||
r.nProc--
|
r.nProc--
|
||||||
|
|
||||||
|
// Notify subsystems (app/webrtc) that this process has been
|
||||||
|
// stopped so they can tear down any per-process state. Hook is
|
||||||
|
// best-effort: errors are the hook's problem to log.
|
||||||
|
if r.hooks.OnStop != nil {
|
||||||
|
r.hooks.OnStop(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
86
test/TESTING.md
Normal file
86
test/TESTING.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Testing the WebRTC egress path
|
||||||
|
|
||||||
|
## In-process (CI)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -race -count=1 ./app/webrtc/... ./core/webrtc/...
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration tests under `app/webrtc/` allocate UDP ports on
|
||||||
|
loopback, spin up an Echo handler, attach a Pion subscriber, and
|
||||||
|
spray synthetic RTP into the registered Source. `TestIntegration_FiveViewerFanout`
|
||||||
|
covers the 5-concurrent-viewer acceptance path from the M3 design.
|
||||||
|
|
||||||
|
## Manual / browser
|
||||||
|
|
||||||
|
`whep-player.html` is a self-contained WHEP subscriber a human can
|
||||||
|
point at any live deploy. Open it directly in a browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
file:///path/to/datarhei-dragonfork-core/test/whep-player.html
|
||||||
|
```
|
||||||
|
|
||||||
|
…or copy it onto a static host (no server-side dependency). It accepts
|
||||||
|
the WHEP URL and an optional bearer token (the deploy uses Core's
|
||||||
|
JWT, so paste an `access_token` from `POST /api/login`). It POSTs an
|
||||||
|
SDP offer with a recvonly video + audio transceiver, applies the
|
||||||
|
answer, and renders the stream in `<video>`. Stats panel shows ICE +
|
||||||
|
PeerConnection states, the codec pulled from the answer SDP, and a
|
||||||
|
1-Hz inbound-bitrate sample. Disconnect issues a WHEP `DELETE` on
|
||||||
|
the resource URL the server returned in `Location`.
|
||||||
|
|
||||||
|
Shareable URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
file:///.../whep-player.html?url=http://10.0.0.25:8090/api/v3/whep/myStream&token=eyJhbGciOi...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pion CLI helper
|
||||||
|
|
||||||
|
`test/whep-client/` is the same handshake in Go, useful for scripting
|
||||||
|
or running on the same machine as Core for an apples-to-apples loopback
|
||||||
|
test:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd test/whep-client
|
||||||
|
go build -o /tmp/whep-client .
|
||||||
|
/tmp/whep-client -url http://10.0.0.25:8090/api/v3/whep/myStream -token "$JWT" -timeout 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
Exits 0 once both video and audio tracks have received their first
|
||||||
|
RTP packet. Used in the M2 deploy verification on TrueNAS.
|
||||||
|
|
||||||
|
## Latency p95 gate
|
||||||
|
|
||||||
|
Wired into CI via the `latency-gate` job in `.forgejo/workflows/test.yml`.
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -tags latency -timeout 90s -race -count=1 \
|
||||||
|
-run TestLatencyServerHop ./app/webrtc/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### What it measures
|
||||||
|
|
||||||
|
Server-hop latency from `corewebrtc.Source` ingest through Pion's
|
||||||
|
DTLS-SRTP egress to a subscriber's `track.ReadRTP()`. The publisher
|
||||||
|
embeds a wall-clock UnixNano timestamp in each RTP payload; the
|
||||||
|
subscriber reads it on arrival and diffs.
|
||||||
|
|
||||||
|
### What it does NOT measure
|
||||||
|
|
||||||
|
True glass-to-glass latency would include FFmpeg encode and a real
|
||||||
|
H.264 decoder on the subscriber side. The design (`webrtc-design.md`
|
||||||
|
§7) calls for `drawtext`-burned frame counters + decode-side pixel
|
||||||
|
sampling; implementing that in pure Go would require a cgo H.264
|
||||||
|
decoder or an FFmpeg-as-sidecar pipe, neither of which pays off for
|
||||||
|
the dominant CI question (*"did anybody regress the server hop?"*).
|
||||||
|
Encode/decode latency is fixed by the codec stack — Core code changes
|
||||||
|
won't move it.
|
||||||
|
|
||||||
|
### Threshold
|
||||||
|
|
||||||
|
`p95 < 50 ms` on the CI runner. Locally observed on a quiet host:
|
||||||
|
`p50 ≈ 110 µs`, `p95 ≈ 240 µs`, `p99 ≈ 320 µs`. The 50ms gate is two
|
||||||
|
orders of magnitude above that — generous, but a regression that
|
||||||
|
crosses it indicates a genuine slowdown rather than runner noise.
|
||||||
46
test/publish.sh
Executable file
46
test/publish.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# publish.sh — M1 PoC test publisher.
|
||||||
|
#
|
||||||
|
# Generates a synthetic test pattern + sine tone, encodes to H.264
|
||||||
|
# baseline (PT=102) and Opus (PT=111), then sends both RTP streams
|
||||||
|
# muxed on a single UDP port to match the M1 webrtc-poc server, which
|
||||||
|
# reads one UDP port and dispatches by payload type.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./test/publish.sh [host] [port]
|
||||||
|
#
|
||||||
|
# Defaults: host=127.0.0.1 port=10000
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-127.0.0.1}"
|
||||||
|
PORT="${2:-10000}"
|
||||||
|
|
||||||
|
echo "publishing test pattern + tone to rtp://${HOST}:${PORT}"
|
||||||
|
echo "video PT=102 (H.264 baseline), audio PT=111 (Opus)"
|
||||||
|
echo "press Ctrl-C to stop."
|
||||||
|
|
||||||
|
# -re real-time pace (wall-clock)
|
||||||
|
# testsrc / sine synthetic A/V so no devices needed
|
||||||
|
# libx264 baseline widely compatible profile for WebRTC
|
||||||
|
# -tune zerolatency minimize encoder buffering
|
||||||
|
# -bsf:v h264_mp4toannexb ensure Annex-B for RTP packetization
|
||||||
|
# -payload_type 102/111 match the hard-coded PTs in forward.go
|
||||||
|
# -f rtp_mpegts fails (we need plain rtp, not mpegts-in-rtp)
|
||||||
|
# Using two separate -f rtp outputs, both to the same UDP port.
|
||||||
|
# FFmpeg 4.x requires an SDP file per output; we write them to /tmp
|
||||||
|
# but the server doesn't use them — it only cares about PT.
|
||||||
|
exec ffmpeg -hide_banner -loglevel warning \
|
||||||
|
-re \
|
||||||
|
-f lavfi -i "testsrc2=size=640x360:rate=30" \
|
||||||
|
-f lavfi -i "sine=frequency=440:sample_rate=48000" \
|
||||||
|
-map 0:v:0 \
|
||||||
|
-c:v libx264 -profile:v baseline -preset veryfast -tune zerolatency \
|
||||||
|
-g 30 -keyint_min 30 -x264-params "repeat-headers=1" \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-bsf:v h264_mp4toannexb \
|
||||||
|
-payload_type 102 \
|
||||||
|
-f rtp "rtp://${HOST}:${PORT}?pkt_size=1200" \
|
||||||
|
-map 1:a:0 \
|
||||||
|
-c:a libopus -b:a 64k -ar 48000 -ac 2 \
|
||||||
|
-payload_type 111 \
|
||||||
|
-f rtp "rtp://${HOST}:${PORT}?pkt_size=1200"
|
||||||
156
test/whep-client/main.go
Normal file
156
test/whep-client/main.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// Command whep-client is a minimal Pion-based WHEP subscriber used for
|
||||||
|
// M1 end-to-end verification. It POSTs a recvonly SDP offer to a WHEP
|
||||||
|
// endpoint, applies the answer, then reports whether the video and
|
||||||
|
// audio tracks receive at least one RTP packet before a timeout.
|
||||||
|
//
|
||||||
|
// This is a test helper; it is NOT part of the Core binary.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
whepURL = flag.String("url", "http://127.0.0.1:8787/whep/test", "WHEP endpoint URL")
|
||||||
|
token = flag.String("token", "", "Authorization: Bearer <token>; empty means no auth header")
|
||||||
|
timeout = flag.Duration("timeout", 10*time.Second, "overall subscribe+receive timeout")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := Subscribe(ctx, *whepURL, *token); err != nil {
|
||||||
|
log.Fatalf("subscribe failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("OK: received video and audio RTP")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe performs a full WHEP subscribe against whepURL and returns
|
||||||
|
// nil when both a video and an audio RTP packet have been observed
|
||||||
|
// before ctx expires. It is exported so tests can exercise it.
|
||||||
|
func Subscribe(ctx context.Context, whepURL, token string) 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 peer connection: %w", err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
return fmt.Errorf("add video transceiver: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
return fmt.Errorf("add audio transceiver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoDone := make(chan struct{})
|
||||||
|
audioDone := make(chan struct{})
|
||||||
|
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||||
|
kind := t.Kind()
|
||||||
|
log.Printf("OnTrack: kind=%s codec=%s pt=%d", kind, t.Codec().MimeType, t.PayloadType())
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
// One successful ReadRTP is enough to prove egress.
|
||||||
|
if _, _, err := t.Read(buf); err != nil {
|
||||||
|
log.Printf("read %s: %v", kind, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case webrtc.RTPCodecTypeVideo:
|
||||||
|
select {
|
||||||
|
case <-videoDone:
|
||||||
|
default:
|
||||||
|
close(videoDone)
|
||||||
|
}
|
||||||
|
case webrtc.RTPCodecTypeAudio:
|
||||||
|
select {
|
||||||
|
case <-audioDone:
|
||||||
|
default:
|
||||||
|
close(audioDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create offer: %w", err)
|
||||||
|
}
|
||||||
|
gather := webrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
return fmt.Errorf("set local: %w", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-gather:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("ice gather: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, err := postOffer(ctx, whepURL, token, pc.LocalDescription().SDP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer,
|
||||||
|
SDP: answerSDP,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("set remote: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for one RTP packet on each track or ctx timeout.
|
||||||
|
select {
|
||||||
|
case <-videoDone:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("waiting for video: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-audioDone:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("waiting for audio: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postOffer(ctx context.Context, url, token, sdp string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
|
||||||
|
bytes.NewReader([]byte(sdp)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("POST %s: %w", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
return "", fmt.Errorf("WHEP %s: %d %s", url, resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
110
test/whep-client/main_test.go
Normal file
110
test/whep-client/main_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coreweb "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSubscribe_EndToEnd stands up an in-process webrtc-poc stack,
|
||||||
|
// injects synthetic H.264(PT=102) + Opus(PT=111) RTP into the Source's
|
||||||
|
// UDP port, and asserts Subscribe returns nil within the timeout.
|
||||||
|
//
|
||||||
|
// Network-heavy; skipped under -short.
|
||||||
|
func TestSubscribe_EndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping end-to-end subscribe test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := coreweb.NewSource("stream-e2e", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSource: %v", err)
|
||||||
|
}
|
||||||
|
src.Start()
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
reg := coreweb.NewRegistry()
|
||||||
|
if err := reg.Register("stream-e2e", src); err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
factory, err := coreweb.NewPeerFactory(coreweb.DefaultConfig())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPeerFactory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := coreweb.NewWHEPHandler(reg, factory, coreweb.DefaultConfig())
|
||||||
|
ts := httptest.NewServer(http.StripPrefix("", handler))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Begin injecting RTP into the source.
|
||||||
|
rtpAddr := src.LocalAddr()
|
||||||
|
conn, err := net.DialUDP("udp", nil, rtpAddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial udp: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
tick := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
var seq uint16
|
||||||
|
var ts uint32
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
// Video packet (PT=102).
|
||||||
|
pkt := &rtp.Packet{
|
||||||
|
Header: rtp.Header{
|
||||||
|
Version: 2,
|
||||||
|
PayloadType: 102,
|
||||||
|
SequenceNumber: seq,
|
||||||
|
Timestamp: ts,
|
||||||
|
SSRC: 0x1234,
|
||||||
|
},
|
||||||
|
Payload: []byte{0x00, 0x00, 0x00, 0x01, 0x09, 0x10},
|
||||||
|
}
|
||||||
|
if b, err := pkt.Marshal(); err == nil {
|
||||||
|
_, _ = conn.Write(b)
|
||||||
|
}
|
||||||
|
// Audio packet (PT=111).
|
||||||
|
pkt.PayloadType = 111
|
||||||
|
pkt.SSRC = 0x5678
|
||||||
|
pkt.SequenceNumber = seq
|
||||||
|
if b, err := pkt.Marshal(); err == nil {
|
||||||
|
_, _ = conn.Write(b)
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
ts += 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
close(stop)
|
||||||
|
wg.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// We don't care whether the test client's Subscribe can actually
|
||||||
|
// decode H.264 — just that it observed *some* RTP on both tracks.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
whepURL := strings.TrimRight(ts.URL, "/") + "/whep/stream-e2e"
|
||||||
|
if err := Subscribe(ctx, whepURL, ""); err != nil {
|
||||||
|
t.Fatalf("Subscribe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
354
test/whep-player.html
Normal file
354
test/whep-player.html
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Dragon Fork — WHEP Player</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #e7e7ea;
|
||||||
|
--bg: #0d0e12;
|
||||||
|
--accent: #ff6633;
|
||||||
|
--muted: #8b8e98;
|
||||||
|
--good: #5dd29c;
|
||||||
|
--warn: #ffb45e;
|
||||||
|
--bad: #ff6470;
|
||||||
|
--panel: #1a1c22;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #232530;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
header h1 .accent { color: var(--accent); }
|
||||||
|
header .subtitle { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: #0d0e12;
|
||||||
|
border: 1px solid #2a2c36;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input[type=text]:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.secondary { background: #2a2c36; color: var(--fg); }
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.stats .label { color: var(--muted); }
|
||||||
|
.stats .value { font-variant-numeric: tabular-nums; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #2a2c36;
|
||||||
|
}
|
||||||
|
.pill.good { background: rgba(93,210,156,0.18); color: var(--good); }
|
||||||
|
.pill.warn { background: rgba(255,180,94,0.18); color: var(--warn); }
|
||||||
|
.pill.bad { background: rgba(255,100,112,0.20); color: var(--bad); }
|
||||||
|
|
||||||
|
.log {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #0d0e12;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log .ts { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Dragon Fork <span class="accent">WHEP</span></h1>
|
||||||
|
<span class="subtitle">manual smoke test for the WebRTC egress path</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<label for="whep-url">WHEP endpoint</label>
|
||||||
|
<input id="whep-url" type="text" placeholder="http://10.0.0.25:8090/api/v3/whep/myStream"
|
||||||
|
value="">
|
||||||
|
<label for="bearer">JWT bearer token</label>
|
||||||
|
<input id="bearer" type="text" placeholder="eyJhbGciOi…">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-play">Subscribe</button>
|
||||||
|
<button id="btn-stop" class="secondary" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<span class="label">ICE</span> <span id="stat-ice" class="value pill">idle</span>
|
||||||
|
<span class="label">Connection</span> <span id="stat-conn" class="value pill">idle</span>
|
||||||
|
<span class="label">Resource</span> <span id="stat-res" class="value">—</span>
|
||||||
|
<span class="label">Video codec</span> <span id="stat-vcodec" class="value">—</span>
|
||||||
|
<span class="label">Audio codec</span> <span id="stat-acodec" class="value">—</span>
|
||||||
|
<span class="label">Inbound bitrate</span><span id="stat-bitrate" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log" class="log" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="padding:0;background:#000;">
|
||||||
|
<video id="video" controls autoplay playsinline muted></video>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- tiny state -------------------------------------------------
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const log = (line, level='info') => {
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span class="ts">${ts}</span> <span class="lvl-${level}">${line}</span>`;
|
||||||
|
$('log').prepend(div);
|
||||||
|
};
|
||||||
|
const setPill = (el, text, klass) => { el.textContent = text; el.className = 'value pill ' + klass; };
|
||||||
|
|
||||||
|
let pc = null;
|
||||||
|
let resourceURL = null; // absolute or path; whichever the server returned
|
||||||
|
let bitrateTimer = null;
|
||||||
|
|
||||||
|
// --- subscribe / disconnect -------------------------------------
|
||||||
|
$('btn-play').addEventListener('click', subscribe);
|
||||||
|
$('btn-stop').addEventListener('click', disconnect);
|
||||||
|
|
||||||
|
// Pre-populate WHEP endpoint from query string for shareable URLs
|
||||||
|
// (e.g. file:///.../whep-player.html?url=http://.../whep/foo&token=…).
|
||||||
|
(function bootstrap() {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
if (q.get('url')) $('whep-url').value = q.get('url');
|
||||||
|
if (q.get('token')) $('bearer').value = q.get('token');
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
if (pc) { log('already connected; disconnect first', 'warn'); return; }
|
||||||
|
const url = $('whep-url').value.trim();
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (!url) { log('WHEP URL is required', 'bad'); return; }
|
||||||
|
|
||||||
|
$('btn-play').disabled = true;
|
||||||
|
$('btn-stop').disabled = false;
|
||||||
|
setPill($('stat-ice'), 'gathering', 'warn');
|
||||||
|
setPill($('stat-conn'), 'connecting', 'warn');
|
||||||
|
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
// No ICE servers: production deploy advertises NAT1To1 host
|
||||||
|
// candidates, which work over the LAN. Add stun:/turn: here
|
||||||
|
// if you're testing across NAT.
|
||||||
|
iceServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.ontrack = (evt) => {
|
||||||
|
log(`ontrack: kind=${evt.track.kind}`, 'info');
|
||||||
|
// Both tracks share the same MediaStream; attach once.
|
||||||
|
if ($('video').srcObject !== evt.streams[0]) {
|
||||||
|
$('video').srcObject = evt.streams[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
const s = pc.iceConnectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected' || s === 'completed') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-ice'), s, klass);
|
||||||
|
log(`ICE state: ${s}`);
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
const s = pc.connectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-conn'), s, klass);
|
||||||
|
log(`PC state: ${s}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
// Wait for ICE gathering to complete so the offer is non-trickle.
|
||||||
|
await new Promise((res) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') return res();
|
||||||
|
pc.addEventListener('icegatheringstatechange', () => {
|
||||||
|
if (pc.iceGatheringState === 'complete') res();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/sdp' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: pc.localDescription.sdp,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`WHEP POST ${resp.status}: ${body || resp.statusText}`);
|
||||||
|
}
|
||||||
|
// Per WHEP spec: server returns SDP answer; Location is the resource.
|
||||||
|
const loc = resp.headers.get('Location');
|
||||||
|
if (loc) {
|
||||||
|
// Resolve relative Location against the WHEP URL.
|
||||||
|
try { resourceURL = new URL(loc, url).toString(); }
|
||||||
|
catch { resourceURL = loc; }
|
||||||
|
$('stat-res').textContent = resourceURL;
|
||||||
|
}
|
||||||
|
const answer = await resp.text();
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||||
|
log(`subscribed (${resp.status})`, 'good');
|
||||||
|
|
||||||
|
// Pull codec info out of the SDP for a quick UI hint.
|
||||||
|
const codec = (kind, sdp) => {
|
||||||
|
const m = new RegExp(`m=${kind}[^\r\n]*[\r\n](?:[abc][^\r\n]*[\r\n]){0,30}?a=rtpmap:\\d+ ([^/\r\n]+)`).exec(sdp);
|
||||||
|
return m ? m[1] : '?';
|
||||||
|
};
|
||||||
|
$('stat-vcodec').textContent = codec('video', answer);
|
||||||
|
$('stat-acodec').textContent = codec('audio', answer);
|
||||||
|
|
||||||
|
bitrateTimer = setInterval(updateBitrate, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
log(`error: ${err.message}`, 'bad');
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
if (bitrateTimer) { clearInterval(bitrateTimer); bitrateTimer = null; }
|
||||||
|
$('btn-play').disabled = false;
|
||||||
|
$('btn-stop').disabled = true;
|
||||||
|
|
||||||
|
// WHEP: best-effort DELETE on the resource URL the server gave us.
|
||||||
|
if (resourceURL) {
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const r = await fetch(resourceURL, { method: 'DELETE', headers });
|
||||||
|
log(`DELETE ${r.status}`, r.ok ? 'good' : 'warn');
|
||||||
|
} catch (e) {
|
||||||
|
log(`DELETE failed: ${e.message}`, 'warn');
|
||||||
|
}
|
||||||
|
resourceURL = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pc) { pc.close(); pc = null; }
|
||||||
|
$('video').srcObject = null;
|
||||||
|
setPill($('stat-ice'), 'idle', '');
|
||||||
|
setPill($('stat-conn'), 'idle', '');
|
||||||
|
$('stat-res').textContent = '—';
|
||||||
|
$('stat-vcodec').textContent = '—';
|
||||||
|
$('stat-acodec').textContent = '—';
|
||||||
|
$('stat-bitrate').textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bitrate sampling -------------------------------------------
|
||||||
|
let lastBytes = null;
|
||||||
|
let lastTs = null;
|
||||||
|
async function updateBitrate() {
|
||||||
|
if (!pc || pc.connectionState !== 'connected') return;
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
let bytes = 0;
|
||||||
|
stats.forEach((r) => {
|
||||||
|
if (r.type === 'inbound-rtp' && !r.isRemote) bytes += r.bytesReceived || 0;
|
||||||
|
});
|
||||||
|
const now = performance.now();
|
||||||
|
if (lastBytes !== null) {
|
||||||
|
const kbps = ((bytes - lastBytes) * 8) / ((now - lastTs) || 1);
|
||||||
|
$('stat-bitrate').textContent = kbps.toFixed(0) + ' kbps';
|
||||||
|
}
|
||||||
|
lastBytes = bytes;
|
||||||
|
lastTs = now;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
vendor/github.com/pion/datachannel/.gitignore
generated
vendored
Normal file
28
vendor/github.com/pion/datachannel/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
### JetBrains IDE ###
|
||||||
|
#####################
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
### Emacs Temporary Files ###
|
||||||
|
#############################
|
||||||
|
*~
|
||||||
|
|
||||||
|
### Folders ###
|
||||||
|
###############
|
||||||
|
bin/
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
### Files ###
|
||||||
|
#############
|
||||||
|
*.ivf
|
||||||
|
*.ogg
|
||||||
|
tags
|
||||||
|
cover.out
|
||||||
|
*.sw[poe]
|
||||||
|
*.wasm
|
||||||
|
examples/sfu-ws/cert.pem
|
||||||
|
examples/sfu-ws/key.pem
|
||||||
|
wasm_exec.js
|
||||||
147
vendor/github.com/pion/datachannel/.golangci.yml
generated
vendored
Normal file
147
vendor/github.com/pion/datachannel/.golangci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
version: "2"
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
|
||||||
|
- bidichk # Checks for dangerous unicode character sequences
|
||||||
|
- bodyclose # checks whether HTTP response body is closed successfully
|
||||||
|
- containedctx # containedctx is a linter that detects struct contained context.Context field
|
||||||
|
- contextcheck # check the function whether use a non-inherited context
|
||||||
|
- cyclop # checks function and package cyclomatic complexity
|
||||||
|
- decorder # check declaration order and count of types, constants, variables and functions
|
||||||
|
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
|
||||||
|
- dupl # Tool for code clone detection
|
||||||
|
- durationcheck # check for two durations multiplied together
|
||||||
|
- err113 # Golang linter to check the errors handling expressions
|
||||||
|
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
|
||||||
|
- errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and optionally reports occations, where the check for the returned error can be omitted.
|
||||||
|
- errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`.
|
||||||
|
- errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13.
|
||||||
|
- exhaustive # check exhaustiveness of enum switch statements
|
||||||
|
- forbidigo # Forbids identifiers
|
||||||
|
- forcetypeassert # finds forced type assertions
|
||||||
|
- gochecknoglobals # Checks that no globals are present in Go code
|
||||||
|
- gocognit # Computes and checks the cognitive complexity of functions
|
||||||
|
- goconst # Finds repeated strings that could be replaced by a constant
|
||||||
|
- gocritic # The most opinionated Go source code linter
|
||||||
|
- gocyclo # Computes and checks the cyclomatic complexity of functions
|
||||||
|
- godot # Check if comments end in a period
|
||||||
|
- godox # Tool for detection of FIXME, TODO and other comment keywords
|
||||||
|
- goheader # Checks is file header matches to pattern
|
||||||
|
- gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod.
|
||||||
|
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
|
||||||
|
- gosec # Inspects source code for security problems
|
||||||
|
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
|
||||||
|
- grouper # An analyzer to analyze expression groups.
|
||||||
|
- importas # Enforces consistent import aliases
|
||||||
|
- ineffassign # Detects when assignments to existing variables are not used
|
||||||
|
- lll # Reports long lines
|
||||||
|
- maintidx # maintidx measures the maintainability index of each function.
|
||||||
|
- makezero # Finds slice declarations with non-zero initial length
|
||||||
|
- misspell # Finds commonly misspelled English words in comments
|
||||||
|
- nakedret # Finds naked returns in functions greater than a specified function length
|
||||||
|
- nestif # Reports deeply nested if statements
|
||||||
|
- nilerr # Finds the code that returns nil even if it checks that the error is not nil.
|
||||||
|
- nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value.
|
||||||
|
- nlreturn # nlreturn checks for a new line before return and branch statements to increase code clarity
|
||||||
|
- noctx # noctx finds sending http request without context.Context
|
||||||
|
- predeclared # find code that shadows one of Go's predeclared identifiers
|
||||||
|
- revive # golint replacement, finds style mistakes
|
||||||
|
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
|
||||||
|
- tagliatelle # Checks the struct tags.
|
||||||
|
- thelper # thelper detects golang test helpers without t.Helper() call and checks the consistency of test helpers
|
||||||
|
- unconvert # Remove unnecessary type conversions
|
||||||
|
- unparam # Reports unused function parameters
|
||||||
|
- unused # Checks Go code for unused constants, variables, functions and types
|
||||||
|
- varnamelen # checks that the length of a variable's name matches its scope
|
||||||
|
- wastedassign # wastedassign finds wasted assignment statements
|
||||||
|
- whitespace # Tool for detection of leading and trailing whitespace
|
||||||
|
disable:
|
||||||
|
- depguard # Go linter that checks if package imports are in a list of acceptable packages
|
||||||
|
- funlen # Tool for detection of long functions
|
||||||
|
- gochecknoinits # Checks that no init functions are present in Go code
|
||||||
|
- gomodguard # Allow and block list linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations.
|
||||||
|
- interfacebloat # A linter that checks length of interface.
|
||||||
|
- ireturn # Accept Interfaces, Return Concrete Types
|
||||||
|
- mnd # An analyzer to detect magic numbers
|
||||||
|
- nolintlint # Reports ill-formed or insufficient nolint directives
|
||||||
|
- paralleltest # paralleltest detects missing usage of t.Parallel() method in your Go test
|
||||||
|
- prealloc # Finds slice declarations that could potentially be preallocated
|
||||||
|
- promlinter # Check Prometheus metrics naming via promlint
|
||||||
|
- rowserrcheck # checks whether Err of rows is checked successfully
|
||||||
|
- sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed.
|
||||||
|
- testpackage # linter that makes you use a separate _test package
|
||||||
|
- tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes
|
||||||
|
- wrapcheck # Checks that errors returned from external packages are wrapped
|
||||||
|
- wsl # Whitespace Linter - Forces you to use empty lines!
|
||||||
|
settings:
|
||||||
|
staticcheck:
|
||||||
|
checks:
|
||||||
|
- all
|
||||||
|
- -QF1008 # "could remove embedded field", to keep it explicit!
|
||||||
|
- -QF1003 # "could use tagged switch on enum", Cases conflicts with exhaustive!
|
||||||
|
exhaustive:
|
||||||
|
default-signifies-exhaustive: true
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- pattern: ^fmt.Print(f|ln)?$
|
||||||
|
- pattern: ^log.(Panic|Fatal|Print)(f|ln)?$
|
||||||
|
- pattern: ^os.Exit$
|
||||||
|
- pattern: ^panic$
|
||||||
|
- pattern: ^print(ln)?$
|
||||||
|
- pattern: ^testing.T.(Error|Errorf|Fatal|Fatalf|Fail|FailNow)$
|
||||||
|
pkg: ^testing$
|
||||||
|
msg: use testify/assert instead
|
||||||
|
analyze-types: true
|
||||||
|
gomodguard:
|
||||||
|
blocked:
|
||||||
|
modules:
|
||||||
|
- github.com/pkg/errors:
|
||||||
|
recommendations:
|
||||||
|
- errors
|
||||||
|
govet:
|
||||||
|
enable:
|
||||||
|
- shadow
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
# Prefer 'any' type alias over 'interface{}' for Go 1.18+ compatibility
|
||||||
|
- name: use-any
|
||||||
|
severity: warning
|
||||||
|
disabled: false
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
varnamelen:
|
||||||
|
max-distance: 12
|
||||||
|
min-name-length: 2
|
||||||
|
ignore-type-assert-ok: true
|
||||||
|
ignore-map-index-ok: true
|
||||||
|
ignore-chan-recv-ok: true
|
||||||
|
ignore-decls:
|
||||||
|
- i int
|
||||||
|
- n int
|
||||||
|
- w io.Writer
|
||||||
|
- r io.Reader
|
||||||
|
- b []byte
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
- gocognit
|
||||||
|
path: (examples|main\.go)
|
||||||
|
- linters:
|
||||||
|
- gocognit
|
||||||
|
path: _test\.go
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: cmd
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gci # Gci control golang package import order and make it always deterministic.
|
||||||
|
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
|
||||||
|
- gofumpt # Gofumpt checks whether code was gofumpt-ed.
|
||||||
|
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
5
vendor/github.com/pion/datachannel/.goreleaser.yml
generated
vendored
Normal file
5
vendor/github.com/pion/datachannel/.goreleaser.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- skip: true
|
||||||
9
vendor/github.com/pion/datachannel/LICENSE
generated
vendored
Normal file
9
vendor/github.com/pion/datachannel/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 The Pion community <https://pion.ly>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
34
vendor/github.com/pion/datachannel/README.md
generated
vendored
Normal file
34
vendor/github.com/pion/datachannel/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<h1 align="center">
|
||||||
|
<br>
|
||||||
|
Pion Data Channels
|
||||||
|
<br>
|
||||||
|
</h1>
|
||||||
|
<h4 align="center">A Go implementation of WebRTC Data Channels</h4>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pion.ly"><img src="https://img.shields.io/badge/pion-datachannel-gray.svg?longCache=true&colorB=brightgreen" alt="Pion Data Channels"></a>
|
||||||
|
<a href="https://discord.gg/PngbdqpFbt"><img src="https://img.shields.io/badge/join-us%20on%20discord-gray.svg?longCache=true&logo=discord&colorB=brightblue" alt="join us on Discord"></a> <a href="https://bsky.app/profile/pion.ly"><img src="https://img.shields.io/badge/follow-us%20on%20bluesky-gray.svg?longCache=true&logo=bluesky&colorB=brightblue" alt="Follow us on Bluesky"></a>
|
||||||
|
<br>
|
||||||
|
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/pion/datachannel/test.yaml">
|
||||||
|
<a href="https://pkg.go.dev/github.com/pion/datachannel"><img src="https://pkg.go.dev/badge/github.com/pion/datachannel.svg" alt="Go Reference"></a>
|
||||||
|
<a href="https://codecov.io/gh/pion/datachannel"><img src="https://codecov.io/gh/pion/datachannel/branch/master/graph/badge.svg" alt="Coverage Status"></a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/pion/datachannel"><img src="https://goreportcard.com/badge/github.com/pion/datachannel" alt="Go Report Card"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Roadmap
|
||||||
|
The library is used as a part of our WebRTC implementation. Please refer to that [roadmap](https://github.com/pion/webrtc/issues/9) to track our major milestones.
|
||||||
|
|
||||||
|
### Community
|
||||||
|
Pion has an active community on the [Discord](https://discord.gg/PngbdqpFbt).
|
||||||
|
|
||||||
|
Follow the [Pion Bluesky](https://bsky.app/profile/pion.ly) or [Pion Twitter](https://twitter.com/_pion) for project updates and important WebRTC news.
|
||||||
|
|
||||||
|
We are always looking to support **your projects**. Please reach out if you have something to build!
|
||||||
|
If you need commercial support or don't want to use public methods you can contact us at [team@pion.ly](mailto:team@pion.ly)
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
Check out the [contributing wiki](https://github.com/pion/webrtc/wiki/Contributing) to join the group of amazing people making this project possible
|
||||||
|
|
||||||
|
### License
|
||||||
|
MIT License - see [LICENSE](LICENSE) for full text
|
||||||
22
vendor/github.com/pion/datachannel/codecov.yml
generated
vendored
Normal file
22
vendor/github.com/pion/datachannel/codecov.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#
|
||||||
|
# DO NOT EDIT THIS FILE
|
||||||
|
#
|
||||||
|
# It is automatically copied from https://github.com/pion/.goassets repository.
|
||||||
|
#
|
||||||
|
# SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
# Allow decreasing 2% of total coverage to avoid noise.
|
||||||
|
threshold: 2%
|
||||||
|
patch:
|
||||||
|
default:
|
||||||
|
target: 70%
|
||||||
|
only_pulls: true
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
- "examples/*"
|
||||||
|
- "examples/**/*"
|
||||||
445
vendor/github.com/pion/datachannel/datachannel.go
generated
vendored
Normal file
445
vendor/github.com/pion/datachannel/datachannel.go
generated
vendored
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package datachannel implements WebRTC Data Channels
|
||||||
|
package datachannel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/logging"
|
||||||
|
"github.com/pion/sctp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const receiveMTU = 8192
|
||||||
|
|
||||||
|
// Reader is an extended io.Reader
|
||||||
|
// that also returns if the message is text.
|
||||||
|
type Reader interface {
|
||||||
|
ReadDataChannel([]byte) (int, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDeadliner extends an io.Reader to expose setting a read deadline.
|
||||||
|
type ReadDeadliner interface {
|
||||||
|
SetReadDeadline(time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer is an extended io.Writer
|
||||||
|
// that also allows indicating if a message is text.
|
||||||
|
type Writer interface {
|
||||||
|
WriteDataChannel([]byte, bool) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDeadliner extends an io.Writer to expose setting a write deadline.
|
||||||
|
type WriteDeadliner interface {
|
||||||
|
SetWriteDeadline(time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWriteCloser is an extended io.ReadWriteCloser
|
||||||
|
// that also implements our Reader and Writer.
|
||||||
|
type ReadWriteCloser interface {
|
||||||
|
io.Reader
|
||||||
|
io.Writer
|
||||||
|
Reader
|
||||||
|
Writer
|
||||||
|
io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWriteCloserDeadliner is an extended ReadWriteCloser
|
||||||
|
// that also implements r/w deadline.
|
||||||
|
type ReadWriteCloserDeadliner interface {
|
||||||
|
ReadWriteCloser
|
||||||
|
ReadDeadliner
|
||||||
|
WriteDeadliner
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataChannel represents a data channel.
|
||||||
|
type DataChannel struct {
|
||||||
|
Config
|
||||||
|
|
||||||
|
// stats
|
||||||
|
messagesSent uint32
|
||||||
|
messagesReceived uint32
|
||||||
|
bytesSent uint64
|
||||||
|
bytesReceived uint64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
onOpenCompleteHandler func()
|
||||||
|
openCompleteHandlerOnce sync.Once
|
||||||
|
|
||||||
|
stream *sctp.Stream
|
||||||
|
log logging.LeveledLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is used to configure the data channel.
|
||||||
|
type Config struct {
|
||||||
|
ChannelType ChannelType
|
||||||
|
Negotiated bool
|
||||||
|
Priority uint16
|
||||||
|
ReliabilityParameter uint32
|
||||||
|
Label string
|
||||||
|
Protocol string
|
||||||
|
LoggerFactory logging.LoggerFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataChannel(stream *sctp.Stream, config *Config) *DataChannel {
|
||||||
|
return &DataChannel{
|
||||||
|
Config: *config,
|
||||||
|
stream: stream,
|
||||||
|
log: config.LoggerFactory.NewLogger("datachannel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a data channels over SCTP.
|
||||||
|
func Dial(a *sctp.Association, id uint16, config *Config) (*DataChannel, error) {
|
||||||
|
stream, err := a.OpenStream(id, sctp.PayloadTypeWebRTCBinary)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dc, err := Client(stream, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isReliable := dc.ChannelType == ChannelTypeReliable || dc.ChannelType == ChannelTypeReliableUnordered
|
||||||
|
if isReliable && dc.ReliabilityParameter != 0 {
|
||||||
|
dc.log.Warnf("DataChannel opened with channel type %s, but has a non-zero reliability parameter: %d (expected 0)",
|
||||||
|
dc.ChannelType,
|
||||||
|
dc.ReliabilityParameter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client opens a data channel over an SCTP stream.
|
||||||
|
func Client(stream *sctp.Stream, config *Config) (*DataChannel, error) {
|
||||||
|
msg := &channelOpen{
|
||||||
|
ChannelType: config.ChannelType,
|
||||||
|
Priority: config.Priority,
|
||||||
|
ReliabilityParameter: config.ReliabilityParameter,
|
||||||
|
|
||||||
|
Label: []byte(config.Label),
|
||||||
|
Protocol: []byte(config.Protocol),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.Negotiated {
|
||||||
|
rawMsg, err := msg.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal ChannelOpen %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = stream.WriteSCTP(rawMsg, sctp.PayloadTypeWebRTCDCEP); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send ChannelOpen %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newDataChannel(stream, config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept is used to accept incoming data channels over SCTP.
|
||||||
|
func Accept(a *sctp.Association, config *Config, existingChannels ...*DataChannel) (*DataChannel, error) {
|
||||||
|
stream, err := a.AcceptStream()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, ch := range existingChannels {
|
||||||
|
if ch.StreamIdentifier() == stream.StreamIdentifier() {
|
||||||
|
ch.stream.SetDefaultPayloadType(sctp.PayloadTypeWebRTCBinary)
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.SetDefaultPayloadType(sctp.PayloadTypeWebRTCBinary)
|
||||||
|
|
||||||
|
dc, err := Server(stream, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server accepts a data channel over an SCTP stream.
|
||||||
|
func Server(stream *sctp.Stream, config *Config) (*DataChannel, error) {
|
||||||
|
buffer := make([]byte, receiveMTU)
|
||||||
|
n, ppi, err := stream.ReadSCTP(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ppi != sctp.PayloadTypeWebRTCDCEP {
|
||||||
|
return nil, fmt.Errorf("%w %s", ErrInvalidPayloadProtocolIdentifier, ppi)
|
||||||
|
}
|
||||||
|
|
||||||
|
openMsg, err := parseExpectDataChannelOpen(buffer[:n])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse DataChannelOpen packet %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.ChannelType = openMsg.ChannelType
|
||||||
|
config.Priority = openMsg.Priority
|
||||||
|
config.ReliabilityParameter = openMsg.ReliabilityParameter
|
||||||
|
config.Label = string(openMsg.Label)
|
||||||
|
config.Protocol = string(openMsg.Protocol)
|
||||||
|
|
||||||
|
dataChannel := newDataChannel(stream, config)
|
||||||
|
|
||||||
|
err = dataChannel.writeDataChannelAck()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = dataChannel.commitReliabilityParams()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataChannel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads a packet of len(pkt) bytes as binary data.
|
||||||
|
func (c *DataChannel) Read(pkt []byte) (int, error) {
|
||||||
|
n, _, err := c.ReadDataChannel(pkt)
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDataChannel reads a packet of len(pkt) bytes.
|
||||||
|
func (c *DataChannel) ReadDataChannel(pkt []byte) (int, bool, error) {
|
||||||
|
for {
|
||||||
|
n, ppi, err := c.stream.ReadSCTP(pkt)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
// When the peer sees that an incoming stream was
|
||||||
|
// reset, it also resets its corresponding outgoing stream.
|
||||||
|
if closeErr := c.stream.Close(); closeErr != nil {
|
||||||
|
return 0, false, closeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ppi == sctp.PayloadTypeWebRTCDCEP {
|
||||||
|
if err = c.handleDCEP(pkt[:n]); err != nil {
|
||||||
|
c.log.Errorf("Failed to handle DCEP: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
} else if ppi == sctp.PayloadTypeWebRTCBinaryEmpty || ppi == sctp.PayloadTypeWebRTCStringEmpty {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddUint32(&c.messagesReceived, 1)
|
||||||
|
atomic.AddUint64(&c.bytesReceived, uint64(n)) //nolint:gosec //G115
|
||||||
|
|
||||||
|
isString := ppi == sctp.PayloadTypeWebRTCString || ppi == sctp.PayloadTypeWebRTCStringEmpty
|
||||||
|
|
||||||
|
return n, isString, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReadDeadline sets a deadline for reads to return.
|
||||||
|
func (c *DataChannel) SetReadDeadline(t time.Time) error {
|
||||||
|
return c.stream.SetReadDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWriteDeadline sets a deadline for writes to return,
|
||||||
|
// only available if the BlockWrite is enabled for sctp.
|
||||||
|
func (c *DataChannel) SetWriteDeadline(t time.Time) error {
|
||||||
|
return c.stream.SetWriteDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesSent returns the number of messages sent.
|
||||||
|
func (c *DataChannel) MessagesSent() uint32 {
|
||||||
|
return atomic.LoadUint32(&c.messagesSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesReceived returns the number of messages received.
|
||||||
|
func (c *DataChannel) MessagesReceived() uint32 {
|
||||||
|
return atomic.LoadUint32(&c.messagesReceived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOpen sets an event handler which is invoked when
|
||||||
|
// a DATA_CHANNEL_ACK message is received.
|
||||||
|
// The handler is called only on thefor the channel opened
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-rtcweb-data-protocol-09#section-5.2
|
||||||
|
func (c *DataChannel) OnOpen(f func()) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.openCompleteHandlerOnce = sync.Once{}
|
||||||
|
c.onOpenCompleteHandler = f
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DataChannel) onOpenComplete() {
|
||||||
|
c.mu.Lock()
|
||||||
|
hdlr := c.onOpenCompleteHandler
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if hdlr != nil {
|
||||||
|
go c.openCompleteHandlerOnce.Do(func() {
|
||||||
|
hdlr()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesSent returns the number of bytes sent.
|
||||||
|
func (c *DataChannel) BytesSent() uint64 {
|
||||||
|
return atomic.LoadUint64(&c.bytesSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesReceived returns the number of bytes received.
|
||||||
|
func (c *DataChannel) BytesReceived() uint64 {
|
||||||
|
return atomic.LoadUint64(&c.bytesReceived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamIdentifier returns the Stream identifier associated to the stream.
|
||||||
|
func (c *DataChannel) StreamIdentifier() uint16 {
|
||||||
|
return c.stream.StreamIdentifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DataChannel) handleDCEP(data []byte) error {
|
||||||
|
msg, err := parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse DataChannel packet %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *channelAck:
|
||||||
|
if err := c.commitReliabilityParams(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.onOpenComplete()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w, wanted ACK got %v", ErrUnexpectedDataChannelType, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes len(pkt) bytes from pkt as binary data.
|
||||||
|
func (c *DataChannel) Write(pkt []byte) (n int, err error) {
|
||||||
|
return c.WriteDataChannel(pkt, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDataChannel writes len(pkt) bytes from pkt.
|
||||||
|
func (c *DataChannel) WriteDataChannel(pkt []byte, isString bool) (n int, err error) {
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-rtcweb-data-channel-12#section-6.6
|
||||||
|
// SCTP does not support the sending of empty user messages. Therefore,
|
||||||
|
// if an empty message has to be sent, the appropriate PPID (WebRTC
|
||||||
|
// String Empty or WebRTC Binary Empty) is used and the SCTP user
|
||||||
|
// message of one zero byte is sent. When receiving an SCTP user
|
||||||
|
// message with one of these PPIDs, the receiver MUST ignore the SCTP
|
||||||
|
// user message and process it as an empty message.
|
||||||
|
var ppi sctp.PayloadProtocolIdentifier
|
||||||
|
switch {
|
||||||
|
case !isString && len(pkt) > 0:
|
||||||
|
ppi = sctp.PayloadTypeWebRTCBinary
|
||||||
|
case !isString && len(pkt) == 0:
|
||||||
|
ppi = sctp.PayloadTypeWebRTCBinaryEmpty
|
||||||
|
case isString && len(pkt) > 0:
|
||||||
|
ppi = sctp.PayloadTypeWebRTCString
|
||||||
|
case isString && len(pkt) == 0:
|
||||||
|
ppi = sctp.PayloadTypeWebRTCStringEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddUint32(&c.messagesSent, 1)
|
||||||
|
atomic.AddUint64(&c.bytesSent, uint64(len(pkt)))
|
||||||
|
|
||||||
|
if len(pkt) == 0 {
|
||||||
|
_, err := c.stream.WriteSCTP([]byte{0}, ppi)
|
||||||
|
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.stream.WriteSCTP(pkt, ppi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DataChannel) writeDataChannelAck() error {
|
||||||
|
ack := channelAck{}
|
||||||
|
ackMsg, err := ack.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal ChannelOpen ACK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = c.stream.WriteSCTP(ackMsg, sctp.PayloadTypeWebRTCDCEP); err != nil {
|
||||||
|
return fmt.Errorf("failed to send ChannelOpen ACK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the DataChannel and the underlying SCTP stream.
|
||||||
|
func (c *DataChannel) Close() error {
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-rtcweb-data-channel-13#section-6.7
|
||||||
|
// Closing of a data channel MUST be signaled by resetting the
|
||||||
|
// corresponding outgoing streams [RFC6525]. This means that if one
|
||||||
|
// side decides to close the data channel, it resets the corresponding
|
||||||
|
// outgoing stream. When the peer sees that an incoming stream was
|
||||||
|
// reset, it also resets its corresponding outgoing stream. Once this
|
||||||
|
// is completed, the data channel is closed. Resetting a stream sets
|
||||||
|
// the Stream Sequence Numbers (SSNs) of the stream back to 'zero' with
|
||||||
|
// a corresponding notification to the application layer that the reset
|
||||||
|
// has been performed. Streams are available for reuse after a reset
|
||||||
|
// has been performed.
|
||||||
|
return c.stream.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BufferedAmount returns the number of bytes of data currently queued to be
|
||||||
|
// sent over this stream.
|
||||||
|
func (c *DataChannel) BufferedAmount() uint64 {
|
||||||
|
return c.stream.BufferedAmount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BufferedAmountLowThreshold returns the number of bytes of buffered outgoing
|
||||||
|
// data that is considered "low." Defaults to 0.
|
||||||
|
func (c *DataChannel) BufferedAmountLowThreshold() uint64 {
|
||||||
|
return c.stream.BufferedAmountLowThreshold()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBufferedAmountLowThreshold is used to update the threshold.
|
||||||
|
// See BufferedAmountLowThreshold().
|
||||||
|
func (c *DataChannel) SetBufferedAmountLowThreshold(th uint64) {
|
||||||
|
c.stream.SetBufferedAmountLowThreshold(th)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnBufferedAmountLow sets the callback handler which would be called when the
|
||||||
|
// number of bytes of outgoing data buffered is lower than the threshold.
|
||||||
|
func (c *DataChannel) OnBufferedAmountLow(f func()) {
|
||||||
|
c.stream.OnBufferedAmountLow(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DataChannel) commitReliabilityParams() error {
|
||||||
|
switch c.Config.ChannelType {
|
||||||
|
case ChannelTypeReliable:
|
||||||
|
c.stream.SetReliabilityParams(false, sctp.ReliabilityTypeReliable, c.Config.ReliabilityParameter) // RFC 8832 sec 5.1
|
||||||
|
if c.Config.ReliabilityParameter != 0 {
|
||||||
|
c.log.Warnf("Channel type is Reliable but has a non-zero reliability parameter: %d (expected 0)",
|
||||||
|
c.Config.ReliabilityParameter)
|
||||||
|
}
|
||||||
|
case ChannelTypeReliableUnordered:
|
||||||
|
c.stream.SetReliabilityParams(true, sctp.ReliabilityTypeReliable, c.Config.ReliabilityParameter) // RFC 8832 sec 5.1
|
||||||
|
if c.Config.ReliabilityParameter != 0 {
|
||||||
|
c.log.Warnf("Channel type is ReliableUnordered but has a non-zero reliability parameter: %d (expected 0)",
|
||||||
|
c.Config.ReliabilityParameter)
|
||||||
|
}
|
||||||
|
case ChannelTypePartialReliableRexmit:
|
||||||
|
c.stream.SetReliabilityParams(false, sctp.ReliabilityTypeRexmit, c.Config.ReliabilityParameter)
|
||||||
|
case ChannelTypePartialReliableRexmitUnordered:
|
||||||
|
c.stream.SetReliabilityParams(true, sctp.ReliabilityTypeRexmit, c.Config.ReliabilityParameter)
|
||||||
|
case ChannelTypePartialReliableTimed:
|
||||||
|
c.stream.SetReliabilityParams(false, sctp.ReliabilityTypeTimed, c.Config.ReliabilityParameter)
|
||||||
|
case ChannelTypePartialReliableTimedUnordered:
|
||||||
|
c.stream.SetReliabilityParams(true, sctp.ReliabilityTypeTimed, c.Config.ReliabilityParameter)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w %v", ErrInvalidChannelType, c.Config.ChannelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
29
vendor/github.com/pion/datachannel/errors.go
generated
vendored
Normal file
29
vendor/github.com/pion/datachannel/errors.go
generated
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package datachannel
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDataChannelMessageTooShort means that the data isn't long enough to be a valid DataChannel message.
|
||||||
|
ErrDataChannelMessageTooShort = errors.New("DataChannel message is not long enough to determine type")
|
||||||
|
|
||||||
|
// ErrInvalidPayloadProtocolIdentifier means that we got a DataChannel messages with a Payload Protocol Identifier
|
||||||
|
// we don't know how to handle.
|
||||||
|
ErrInvalidPayloadProtocolIdentifier = errors.New(
|
||||||
|
"DataChannel message Payload Protocol Identifier is value we can't handle",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidChannelType means that the remote requested a channel type that we don't support.
|
||||||
|
ErrInvalidChannelType = errors.New("invalid Channel Type")
|
||||||
|
|
||||||
|
// ErrInvalidMessageType is returned when a DataChannel Message has a type we don't support.
|
||||||
|
ErrInvalidMessageType = errors.New("invalid Message Type")
|
||||||
|
|
||||||
|
// ErrExpectedAndActualLengthMismatch is when the declared length and actual length don't match.
|
||||||
|
ErrExpectedAndActualLengthMismatch = errors.New("expected and actual length do not match")
|
||||||
|
|
||||||
|
// ErrUnexpectedDataChannelType is when a message type does not match the expected type.
|
||||||
|
ErrUnexpectedDataChannelType = errors.New("expected and actual message type does not match")
|
||||||
|
)
|
||||||
92
vendor/github.com/pion/datachannel/message.go
generated
vendored
Normal file
92
vendor/github.com/pion/datachannel/message.go
generated
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package datachannel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// message is a parsed DataChannel message.
|
||||||
|
type message interface {
|
||||||
|
Marshal() ([]byte, error)
|
||||||
|
Unmarshal([]byte) error
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageType is the first byte in a DataChannel message that specifies type.
|
||||||
|
type messageType byte
|
||||||
|
|
||||||
|
// DataChannel Message Types.
|
||||||
|
const (
|
||||||
|
dataChannelAck messageType = 0x02
|
||||||
|
dataChannelOpen messageType = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t messageType) String() string {
|
||||||
|
switch t {
|
||||||
|
case dataChannelAck:
|
||||||
|
return "DataChannelAck"
|
||||||
|
case dataChannelOpen:
|
||||||
|
return "DataChannelOpen"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Unknown MessageType: %d", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse accepts raw input and returns a DataChannel message.
|
||||||
|
func parse(raw []byte) (message, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, ErrDataChannelMessageTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg message
|
||||||
|
switch messageType(raw[0]) {
|
||||||
|
case dataChannelOpen:
|
||||||
|
msg = &channelOpen{}
|
||||||
|
case dataChannelAck:
|
||||||
|
msg = &channelAck{}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w %v", ErrInvalidMessageType, messageType(raw[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := msg.Unmarshal(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExpectDataChannelOpen parses a DataChannelOpen message
|
||||||
|
// or throws an error.
|
||||||
|
func parseExpectDataChannelOpen(raw []byte) (*channelOpen, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, ErrDataChannelMessageTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualTyp := messageType(raw[0]); actualTyp != dataChannelOpen {
|
||||||
|
return nil, fmt.Errorf("%w expected(%s) actual(%s)", ErrUnexpectedDataChannelType, actualTyp, dataChannelOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &channelOpen{}
|
||||||
|
if err := msg.Unmarshal(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryMarshalUnmarshal attempts to marshal and unmarshal a message. Added for fuzzing.
|
||||||
|
func TryMarshalUnmarshal(msg []byte) int {
|
||||||
|
message, err := parse(msg)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = message.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
29
vendor/github.com/pion/datachannel/message_channel_ack.go
generated
vendored
Normal file
29
vendor/github.com/pion/datachannel/message_channel_ack.go
generated
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package datachannel
|
||||||
|
|
||||||
|
// channelAck is used to ACK a DataChannel open.
|
||||||
|
type channelAck struct{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
channelOpenAckLength = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// Marshal returns raw bytes for the given message.
|
||||||
|
func (c *channelAck) Marshal() ([]byte, error) {
|
||||||
|
raw := make([]byte, channelOpenAckLength)
|
||||||
|
raw[0] = uint8(dataChannelAck)
|
||||||
|
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal populates the struct with the given raw data.
|
||||||
|
func (c *channelAck) Unmarshal(_ []byte) error {
|
||||||
|
// Message type already checked in Parse and there is no further data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c channelAck) String() string {
|
||||||
|
return "ACK"
|
||||||
|
}
|
||||||
155
vendor/github.com/pion/datachannel/message_channel_open.go
generated
vendored
Normal file
155
vendor/github.com/pion/datachannel/message_channel_open.go
generated
vendored
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package datachannel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
channelOpen represents a DATA_CHANNEL_OPEN Message
|
||||||
|
|
||||||
|
0 1 2 3
|
||||||
|
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||||
|
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
| Message Type | Channel Type | Priority |
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
| Reliability Parameter |
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
| Label Length | Protocol Length |
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
| |
|
||||||
|
| Label |
|
||||||
|
| |
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
| |
|
||||||
|
| Protocol |
|
||||||
|
| |
|
||||||
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||||
|
.
|
||||||
|
*/
|
||||||
|
type channelOpen struct {
|
||||||
|
ChannelType ChannelType
|
||||||
|
Priority uint16
|
||||||
|
ReliabilityParameter uint32
|
||||||
|
|
||||||
|
Label []byte
|
||||||
|
Protocol []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
channelOpenHeaderLength = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelType determines the reliability of the WebRTC DataChannel.
|
||||||
|
type ChannelType byte
|
||||||
|
|
||||||
|
// ChannelType enums.
|
||||||
|
const (
|
||||||
|
// ChannelTypeReliable determines the Data Channel provides a
|
||||||
|
// reliable in-order bi-directional communication.
|
||||||
|
ChannelTypeReliable ChannelType = 0x00
|
||||||
|
// ChannelTypeReliableUnordered determines the Data Channel
|
||||||
|
// provides a reliable unordered bi-directional communication.
|
||||||
|
ChannelTypeReliableUnordered ChannelType = 0x80
|
||||||
|
// ChannelTypePartialReliableRexmit determines the Data Channel
|
||||||
|
// provides a partially-reliable in-order bi-directional communication.
|
||||||
|
// User messages will not be retransmitted more times than specified in the Reliability Parameter.
|
||||||
|
ChannelTypePartialReliableRexmit ChannelType = 0x01
|
||||||
|
// ChannelTypePartialReliableRexmitUnordered determines
|
||||||
|
// the Data Channel provides a partial reliable unordered bi-directional communication.
|
||||||
|
// User messages will not be retransmitted more times than specified in the Reliability Parameter.
|
||||||
|
ChannelTypePartialReliableRexmitUnordered ChannelType = 0x81
|
||||||
|
// ChannelTypePartialReliableTimed determines the Data Channel
|
||||||
|
// provides a partial reliable in-order bi-directional communication.
|
||||||
|
// User messages might not be transmitted or retransmitted after
|
||||||
|
// a specified life-time given in milli- seconds in the Reliability Parameter.
|
||||||
|
// This life-time starts when providing the user message to the protocol stack.
|
||||||
|
ChannelTypePartialReliableTimed ChannelType = 0x02
|
||||||
|
// The Data Channel provides a partial reliable unordered bi-directional
|
||||||
|
// communication. User messages might not be transmitted or retransmitted
|
||||||
|
// after a specified life-time given in milli- seconds in the Reliability Parameter.
|
||||||
|
// This life-time starts when providing the user message to the protocol stack.
|
||||||
|
ChannelTypePartialReliableTimedUnordered ChannelType = 0x82
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c ChannelType) String() string {
|
||||||
|
switch c {
|
||||||
|
case ChannelTypeReliable:
|
||||||
|
return "ReliableOrdered"
|
||||||
|
case ChannelTypeReliableUnordered:
|
||||||
|
return "ReliableUnordered"
|
||||||
|
case ChannelTypePartialReliableRexmit:
|
||||||
|
return "PartialReliableRexmit"
|
||||||
|
case ChannelTypePartialReliableRexmitUnordered:
|
||||||
|
return "PartialReliableRexmitUnordered"
|
||||||
|
case ChannelTypePartialReliableTimed:
|
||||||
|
return "PartialReliableTimed"
|
||||||
|
case ChannelTypePartialReliableTimedUnordered:
|
||||||
|
return "PartialReliableTimedUnordered"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelPriority enums.
|
||||||
|
const (
|
||||||
|
ChannelPriorityBelowNormal uint16 = 128
|
||||||
|
ChannelPriorityNormal uint16 = 256
|
||||||
|
ChannelPriorityHigh uint16 = 512
|
||||||
|
ChannelPriorityExtraHigh uint16 = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// Marshal returns raw bytes for the given message.
|
||||||
|
func (c *channelOpen) Marshal() ([]byte, error) {
|
||||||
|
labelLength := len(c.Label)
|
||||||
|
protocolLength := len(c.Protocol)
|
||||||
|
|
||||||
|
totalLen := channelOpenHeaderLength + labelLength + protocolLength
|
||||||
|
raw := make([]byte, totalLen)
|
||||||
|
|
||||||
|
raw[0] = uint8(dataChannelOpen)
|
||||||
|
raw[1] = byte(c.ChannelType)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(raw[2:], c.Priority)
|
||||||
|
binary.BigEndian.PutUint32(raw[4:], c.ReliabilityParameter)
|
||||||
|
binary.BigEndian.PutUint16(raw[8:], uint16(labelLength)) //nolint:gosec //G115
|
||||||
|
binary.BigEndian.PutUint16(raw[10:], uint16(protocolLength)) //nolint:gosec //G115
|
||||||
|
endLabel := channelOpenHeaderLength + labelLength
|
||||||
|
copy(raw[channelOpenHeaderLength:endLabel], c.Label)
|
||||||
|
copy(raw[endLabel:endLabel+protocolLength], c.Protocol)
|
||||||
|
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal populates the struct with the given raw data.
|
||||||
|
func (c *channelOpen) Unmarshal(raw []byte) error {
|
||||||
|
if len(raw) < channelOpenHeaderLength {
|
||||||
|
return fmt.Errorf("%w expected(%d) actual(%d)", ErrExpectedAndActualLengthMismatch, channelOpenHeaderLength, len(raw))
|
||||||
|
}
|
||||||
|
c.ChannelType = ChannelType(raw[1])
|
||||||
|
c.Priority = binary.BigEndian.Uint16(raw[2:])
|
||||||
|
c.ReliabilityParameter = binary.BigEndian.Uint32(raw[4:])
|
||||||
|
|
||||||
|
labelLength := binary.BigEndian.Uint16(raw[8:])
|
||||||
|
protocolLength := binary.BigEndian.Uint16(raw[10:])
|
||||||
|
|
||||||
|
if expectedLen := channelOpenHeaderLength + int(labelLength) + int(protocolLength); len(raw) != expectedLen {
|
||||||
|
return fmt.Errorf("%w expected(%d) actual(%d)", ErrExpectedAndActualLengthMismatch, expectedLen, len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Label = raw[channelOpenHeaderLength : channelOpenHeaderLength+labelLength]
|
||||||
|
c.Protocol = raw[channelOpenHeaderLength+labelLength : channelOpenHeaderLength+labelLength+protocolLength]
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c channelOpen) String() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Open ChannelType(%s) Priority(%v) ReliabilityParameter(%d) Label(%s) Protocol(%s)",
|
||||||
|
c.ChannelType, c.Priority, c.ReliabilityParameter, string(c.Label), string(c.Protocol),
|
||||||
|
)
|
||||||
|
}
|
||||||
6
vendor/github.com/pion/datachannel/renovate.json
generated
vendored
Normal file
6
vendor/github.com/pion/datachannel/renovate.json
generated
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"github>pion/renovate-config"
|
||||||
|
]
|
||||||
|
}
|
||||||
23
vendor/github.com/pion/dtls/v3/.editorconfig
generated
vendored
Normal file
23
vendor/github.com/pion/dtls/v3/.editorconfig
generated
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# http://editorconfig.org/
|
||||||
|
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[{*.yml,*.yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Makefiles always use tabs for indentation
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
28
vendor/github.com/pion/dtls/v3/.gitignore
generated
vendored
Normal file
28
vendor/github.com/pion/dtls/v3/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
### JetBrains IDE ###
|
||||||
|
#####################
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
### Emacs Temporary Files ###
|
||||||
|
#############################
|
||||||
|
*~
|
||||||
|
|
||||||
|
### Folders ###
|
||||||
|
###############
|
||||||
|
bin/
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
### Files ###
|
||||||
|
#############
|
||||||
|
*.ivf
|
||||||
|
*.ogg
|
||||||
|
tags
|
||||||
|
cover.out
|
||||||
|
*.sw[poe]
|
||||||
|
*.wasm
|
||||||
|
examples/sfu-ws/cert.pem
|
||||||
|
examples/sfu-ws/key.pem
|
||||||
|
wasm_exec.js
|
||||||
147
vendor/github.com/pion/dtls/v3/.golangci.yml
generated
vendored
Normal file
147
vendor/github.com/pion/dtls/v3/.golangci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
version: "2"
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
|
||||||
|
- bidichk # Checks for dangerous unicode character sequences
|
||||||
|
- bodyclose # checks whether HTTP response body is closed successfully
|
||||||
|
- containedctx # containedctx is a linter that detects struct contained context.Context field
|
||||||
|
- contextcheck # check the function whether use a non-inherited context
|
||||||
|
- cyclop # checks function and package cyclomatic complexity
|
||||||
|
- decorder # check declaration order and count of types, constants, variables and functions
|
||||||
|
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
|
||||||
|
- dupl # Tool for code clone detection
|
||||||
|
- durationcheck # check for two durations multiplied together
|
||||||
|
- err113 # Golang linter to check the errors handling expressions
|
||||||
|
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
|
||||||
|
- errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and optionally reports occations, where the check for the returned error can be omitted.
|
||||||
|
- errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`.
|
||||||
|
- errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13.
|
||||||
|
- exhaustive # check exhaustiveness of enum switch statements
|
||||||
|
- forbidigo # Forbids identifiers
|
||||||
|
- forcetypeassert # finds forced type assertions
|
||||||
|
- gochecknoglobals # Checks that no globals are present in Go code
|
||||||
|
- gocognit # Computes and checks the cognitive complexity of functions
|
||||||
|
- goconst # Finds repeated strings that could be replaced by a constant
|
||||||
|
- gocritic # The most opinionated Go source code linter
|
||||||
|
- gocyclo # Computes and checks the cyclomatic complexity of functions
|
||||||
|
- godot # Check if comments end in a period
|
||||||
|
- godox # Tool for detection of FIXME, TODO and other comment keywords
|
||||||
|
- goheader # Checks is file header matches to pattern
|
||||||
|
- gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod.
|
||||||
|
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
|
||||||
|
- gosec # Inspects source code for security problems
|
||||||
|
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
|
||||||
|
- grouper # An analyzer to analyze expression groups.
|
||||||
|
- importas # Enforces consistent import aliases
|
||||||
|
- ineffassign # Detects when assignments to existing variables are not used
|
||||||
|
- lll # Reports long lines
|
||||||
|
- maintidx # maintidx measures the maintainability index of each function.
|
||||||
|
- makezero # Finds slice declarations with non-zero initial length
|
||||||
|
- misspell # Finds commonly misspelled English words in comments
|
||||||
|
- nakedret # Finds naked returns in functions greater than a specified function length
|
||||||
|
- nestif # Reports deeply nested if statements
|
||||||
|
- nilerr # Finds the code that returns nil even if it checks that the error is not nil.
|
||||||
|
- nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value.
|
||||||
|
- nlreturn # nlreturn checks for a new line before return and branch statements to increase code clarity
|
||||||
|
- noctx # noctx finds sending http request without context.Context
|
||||||
|
- predeclared # find code that shadows one of Go's predeclared identifiers
|
||||||
|
- revive # golint replacement, finds style mistakes
|
||||||
|
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
|
||||||
|
- tagliatelle # Checks the struct tags.
|
||||||
|
- thelper # thelper detects golang test helpers without t.Helper() call and checks the consistency of test helpers
|
||||||
|
- unconvert # Remove unnecessary type conversions
|
||||||
|
- unparam # Reports unused function parameters
|
||||||
|
- unused # Checks Go code for unused constants, variables, functions and types
|
||||||
|
- varnamelen # checks that the length of a variable's name matches its scope
|
||||||
|
- wastedassign # wastedassign finds wasted assignment statements
|
||||||
|
- whitespace # Tool for detection of leading and trailing whitespace
|
||||||
|
disable:
|
||||||
|
- depguard # Go linter that checks if package imports are in a list of acceptable packages
|
||||||
|
- funlen # Tool for detection of long functions
|
||||||
|
- gochecknoinits # Checks that no init functions are present in Go code
|
||||||
|
- gomodguard # Allow and block list linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations.
|
||||||
|
- interfacebloat # A linter that checks length of interface.
|
||||||
|
- ireturn # Accept Interfaces, Return Concrete Types
|
||||||
|
- mnd # An analyzer to detect magic numbers
|
||||||
|
- nolintlint # Reports ill-formed or insufficient nolint directives
|
||||||
|
- paralleltest # paralleltest detects missing usage of t.Parallel() method in your Go test
|
||||||
|
- prealloc # Finds slice declarations that could potentially be preallocated
|
||||||
|
- promlinter # Check Prometheus metrics naming via promlint
|
||||||
|
- rowserrcheck # checks whether Err of rows is checked successfully
|
||||||
|
- sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed.
|
||||||
|
- testpackage # linter that makes you use a separate _test package
|
||||||
|
- tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes
|
||||||
|
- wrapcheck # Checks that errors returned from external packages are wrapped
|
||||||
|
- wsl # Whitespace Linter - Forces you to use empty lines!
|
||||||
|
settings:
|
||||||
|
staticcheck:
|
||||||
|
checks:
|
||||||
|
- all
|
||||||
|
- -QF1008 # "could remove embedded field", to keep it explicit!
|
||||||
|
- -QF1003 # "could use tagged switch on enum", Cases conflicts with exhaustive!
|
||||||
|
exhaustive:
|
||||||
|
default-signifies-exhaustive: true
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- pattern: ^fmt.Print(f|ln)?$
|
||||||
|
- pattern: ^log.(Panic|Fatal|Print)(f|ln)?$
|
||||||
|
- pattern: ^os.Exit$
|
||||||
|
- pattern: ^panic$
|
||||||
|
- pattern: ^print(ln)?$
|
||||||
|
- pattern: ^testing.T.(Error|Errorf|Fatal|Fatalf|Fail|FailNow)$
|
||||||
|
pkg: ^testing$
|
||||||
|
msg: use testify/assert instead
|
||||||
|
analyze-types: true
|
||||||
|
gomodguard:
|
||||||
|
blocked:
|
||||||
|
modules:
|
||||||
|
- github.com/pkg/errors:
|
||||||
|
recommendations:
|
||||||
|
- errors
|
||||||
|
govet:
|
||||||
|
enable:
|
||||||
|
- shadow
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
# Prefer 'any' type alias over 'interface{}' for Go 1.18+ compatibility
|
||||||
|
- name: use-any
|
||||||
|
severity: warning
|
||||||
|
disabled: false
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
varnamelen:
|
||||||
|
max-distance: 12
|
||||||
|
min-name-length: 2
|
||||||
|
ignore-type-assert-ok: true
|
||||||
|
ignore-map-index-ok: true
|
||||||
|
ignore-chan-recv-ok: true
|
||||||
|
ignore-decls:
|
||||||
|
- i int
|
||||||
|
- n int
|
||||||
|
- w io.Writer
|
||||||
|
- r io.Reader
|
||||||
|
- b []byte
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
- gocognit
|
||||||
|
path: (examples|main\.go)
|
||||||
|
- linters:
|
||||||
|
- gocognit
|
||||||
|
path: _test\.go
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: cmd
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gci # Gci control golang package import order and make it always deterministic.
|
||||||
|
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
|
||||||
|
- gofumpt # Gofumpt checks whether code was gofumpt-ed.
|
||||||
|
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
5
vendor/github.com/pion/dtls/v3/.goreleaser.yml
generated
vendored
Normal file
5
vendor/github.com/pion/dtls/v3/.goreleaser.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- skip: true
|
||||||
9
vendor/github.com/pion/dtls/v3/LICENSE
generated
vendored
Normal file
9
vendor/github.com/pion/dtls/v3/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 The Pion community <https://pion.ly>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
159
vendor/github.com/pion/dtls/v3/README.md
generated
vendored
Normal file
159
vendor/github.com/pion/dtls/v3/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<h1 align="center">
|
||||||
|
<br>
|
||||||
|
Pion DTLS
|
||||||
|
<br>
|
||||||
|
</h1>
|
||||||
|
<h4 align="center">A Go implementation of DTLS</h4>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pion.ly"><img src="https://img.shields.io/badge/pion-dtls-gray.svg?longCache=true&colorB=brightgreen" alt="Pion DTLS"></a>
|
||||||
|
<a href="https://sourcegraph.com/github.com/pion/dtls"><img src="https://sourcegraph.com/github.com/pion/dtls/-/badge.svg" alt="Sourcegraph Widget"></a>
|
||||||
|
<a href="https://discord.gg/PngbdqpFbt"><img src="https://img.shields.io/badge/join-us%20on%20discord-gray.svg?longCache=true&logo=discord&colorB=brightblue" alt="join us on Discord"></a> <a href="https://bsky.app/profile/pion.ly"><img src="https://img.shields.io/badge/follow-us%20on%20bluesky-gray.svg?longCache=true&logo=bluesky&colorB=brightblue" alt="Follow us on Bluesky"></a>
|
||||||
|
<br>
|
||||||
|
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/pion/dtls/test.yaml">
|
||||||
|
<a href="https://pkg.go.dev/github.com/pion/dtls/v3"><img src="https://pkg.go.dev/badge/github.com/pion/dtls/v3.svg" alt="Go Reference"></a>
|
||||||
|
<a href="https://codecov.io/gh/pion/dtls"><img src="https://codecov.io/gh/pion/dtls/branch/master/graph/badge.svg" alt="Coverage Status"></a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/pion/dtls/v3"><img src="https://goreportcard.com/badge/github.com/pion/dtls/v3" alt="Go Report Card"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Native [DTLS 1.2][rfc6347] implementation in the Go programming language.
|
||||||
|
|
||||||
|
A long term goal is a professional security review, and maybe an inclusion in stdlib.
|
||||||
|
|
||||||
|
### RFCs
|
||||||
|
#### Implemented
|
||||||
|
- **RFC 6347**: [Datagram Transport Layer Security Version 1.2][rfc6347]
|
||||||
|
- **RFC 5705**: [Keying Material Exporters for Transport Layer Security (TLS)][rfc5705]
|
||||||
|
- **RFC 7627**: [Transport Layer Security (TLS) - Session Hash and Extended Master Secret Extension][rfc7627]
|
||||||
|
- **RFC 7301**: [Transport Layer Security (TLS) - Application-Layer Protocol Negotiation Extension][rfc7301]
|
||||||
|
|
||||||
|
[rfc5289]: https://tools.ietf.org/html/rfc5289
|
||||||
|
[rfc5487]: https://tools.ietf.org/html/rfc5487
|
||||||
|
[rfc5489]: https://tools.ietf.org/html/rfc5489
|
||||||
|
[rfc5705]: https://tools.ietf.org/html/rfc5705
|
||||||
|
[rfc6347]: https://tools.ietf.org/html/rfc6347
|
||||||
|
[rfc6655]: https://tools.ietf.org/html/rfc6655
|
||||||
|
[rfc7301]: https://tools.ietf.org/html/rfc7301
|
||||||
|
[rfc7627]: https://tools.ietf.org/html/rfc7627
|
||||||
|
[rfc8422]: https://tools.ietf.org/html/rfc8422
|
||||||
|
[rfc9147]: https://tools.ietf.org/html/rfc9147
|
||||||
|
|
||||||
|
### Goals/Progress
|
||||||
|
This will only be targeting DTLS 1.2, and the most modern/common cipher suites.
|
||||||
|
We would love contributions that fall under the 'Planned Features' and any bug fixes!
|
||||||
|
|
||||||
|
#### Current features
|
||||||
|
* DTLS 1.2 Client/Server
|
||||||
|
* Key Exchange via ECDHE(curve25519, nistp256, nistp384) and PSK
|
||||||
|
* Packet loss and re-ordering is handled during handshaking
|
||||||
|
* Key export ([RFC 5705][rfc5705])
|
||||||
|
* Serialization and Resumption of sessions
|
||||||
|
* Extended Master Secret extension ([RFC 7627][rfc7627])
|
||||||
|
* ALPN extension ([RFC 7301][rfc7301])
|
||||||
|
|
||||||
|
#### Supported ciphers
|
||||||
|
|
||||||
|
##### ECDHE
|
||||||
|
|
||||||
|
* TLS_ECDHE_ECDSA_WITH_AES_128_CCM ([RFC 6655][rfc6655])
|
||||||
|
* TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 ([RFC 6655][rfc6655])
|
||||||
|
* TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 ([RFC 5289][rfc5289])
|
||||||
|
* TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 ([RFC 5289][rfc5289])
|
||||||
|
* TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 ([RFC 5289][rfc5289])
|
||||||
|
* TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 ([RFC 5289][rfc5289])
|
||||||
|
* TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA ([RFC 8422][rfc8422])
|
||||||
|
* TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA ([RFC 8422][rfc8422])
|
||||||
|
|
||||||
|
##### PSK
|
||||||
|
|
||||||
|
* TLS_PSK_WITH_AES_128_CCM ([RFC 6655][rfc6655])
|
||||||
|
* TLS_PSK_WITH_AES_128_CCM_8 ([RFC 6655][rfc6655])
|
||||||
|
* TLS_PSK_WITH_AES_256_CCM_8 ([RFC 6655][rfc6655])
|
||||||
|
* TLS_PSK_WITH_AES_128_GCM_SHA256 ([RFC 5487][rfc5487])
|
||||||
|
* TLS_PSK_WITH_AES_128_CBC_SHA256 ([RFC 5487][rfc5487])
|
||||||
|
|
||||||
|
##### ECDHE & PSK
|
||||||
|
|
||||||
|
* TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256 ([RFC 5489][rfc5489])
|
||||||
|
|
||||||
|
#### Planned Features
|
||||||
|
* DTLS 1.3 ([RFC 9147][rfc9147])
|
||||||
|
* Chacha20Poly1305
|
||||||
|
|
||||||
|
#### Excluded Features
|
||||||
|
* DTLS 1.0
|
||||||
|
* Renegotiation
|
||||||
|
* Compression
|
||||||
|
|
||||||
|
### Using
|
||||||
|
|
||||||
|
This library needs at least Go 1.21, and you should have [Go modules
|
||||||
|
enabled](https://github.com/golang/go/wiki/Modules).
|
||||||
|
|
||||||
|
#### Pion DTLS
|
||||||
|
For a DTLS 1.2 Server that listens on 127.0.0.1:4444
|
||||||
|
```sh
|
||||||
|
go run examples/listen/selfsign/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
For a DTLS 1.2 Client that connects to 127.0.0.1:4444
|
||||||
|
```sh
|
||||||
|
go run examples/dial/selfsign/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OpenSSL
|
||||||
|
Pion DTLS can connect to itself and OpenSSL.
|
||||||
|
```
|
||||||
|
// Generate a certificate
|
||||||
|
openssl ecparam -out key.pem -name prime256v1 -genkey
|
||||||
|
openssl req -new -sha256 -key key.pem -out server.csr
|
||||||
|
openssl x509 -req -sha256 -days 365 -in server.csr -signkey key.pem -out cert.pem
|
||||||
|
|
||||||
|
// Use with examples/dial/selfsign/main.go
|
||||||
|
openssl s_server -dtls1_2 -cert cert.pem -key key.pem -accept 4444
|
||||||
|
|
||||||
|
// Use with examples/listen/selfsign/main.go
|
||||||
|
openssl s_client -dtls1_2 -connect 127.0.0.1:4444 -debug -cert cert.pem -key key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using with PSK
|
||||||
|
Pion DTLS also comes with examples that do key exchange via PSK
|
||||||
|
|
||||||
|
#### Pion DTLS
|
||||||
|
```sh
|
||||||
|
go run examples/listen/psk/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run examples/dial/psk/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OpenSSL
|
||||||
|
```
|
||||||
|
// Use with examples/dial/psk/main.go
|
||||||
|
openssl s_server -dtls1_2 -accept 4444 -nocert -psk abc123 -cipher PSK-AES128-CCM8
|
||||||
|
|
||||||
|
// Use with examples/listen/psk/main.go
|
||||||
|
openssl s_client -dtls1_2 -connect 127.0.0.1:4444 -psk abc123 -cipher PSK-AES128-CCM8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Community
|
||||||
|
Pion has an active community on the [Discord](https://discord.gg/PngbdqpFbt).
|
||||||
|
|
||||||
|
Follow the [Pion Bluesky](https://bsky.app/profile/pion.ly) or [Pion Twitter](https://twitter.com/_pion) for project updates and important WebRTC news.
|
||||||
|
|
||||||
|
We are always looking to support **your projects**. Please reach out if you have something to build!
|
||||||
|
If you need commercial support or don't want to use public methods you can contact us at [team@pion.ly](mailto:team@pion.ly)
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
Check out the [contributing wiki](https://github.com/pion/webrtc/wiki/Contributing) to join the group of amazing people making this project possible
|
||||||
|
|
||||||
|
### Funding
|
||||||
|
<a href="https://nlnet.nl/"><img src="https://nlnet.nl/logo/banner.svg" alt="NLnet foundation logo" width="200"></a>
|
||||||
|
<a href="https://nlnet.nl/commonsfund/"><img src="https://nlnet.nl/image/logos/NGI0Core_tag.svg" alt="NLnet foundation logo" width="200"></a>
|
||||||
|
|
||||||
|
The DTLS 1.3 implementation in this project is funded through the [NGI0 Commons Fund](https://nlnet.nl/commonsfund), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of [DG Communications Networks, Content and Technology](https://commission.europa.eu/about-european-commission/departments-and-executive-agencies/communications-networks-content-and-technology_en) under grant agreement No [101135429](https://cordis.europa.eu/project/id/101135429). Additional funding is made available by the [Swiss State Secretariat for Education, Research and Innovation](https://www.sbfi.admin.ch/sbfi/en/home.html) (SERI). Learn more on the [NLnet project page](https://nlnet.nl/project/PION-DTLS1.3/).
|
||||||
|
|
||||||
|
### License
|
||||||
|
MIT License - see [LICENSE](LICENSE) for full text
|
||||||
167
vendor/github.com/pion/dtls/v3/certificate.go
generated
vendored
Normal file
167
vendor/github.com/pion/dtls/v3/certificate.go
generated
vendored
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/handshake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientHelloInfo contains information from a ClientHello message in order to
|
||||||
|
// guide application logic in the GetCertificate.
|
||||||
|
type ClientHelloInfo struct {
|
||||||
|
// ServerName indicates the name of the server requested by the client
|
||||||
|
// in order to support virtual hosting. ServerName is only set if the
|
||||||
|
// client is using SNI (see RFC 4366, Section 3.1).
|
||||||
|
ServerName string
|
||||||
|
|
||||||
|
// CipherSuites lists the CipherSuites supported by the client (e.g.
|
||||||
|
// TLS_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256).
|
||||||
|
CipherSuites []CipherSuiteID
|
||||||
|
|
||||||
|
// RandomBytes stores the client hello random bytes
|
||||||
|
RandomBytes [handshake.RandomBytesLength]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateRequestInfo contains information from a server's
|
||||||
|
// CertificateRequest message, which is used to demand a certificate and proof
|
||||||
|
// of control from a client.
|
||||||
|
type CertificateRequestInfo struct {
|
||||||
|
// AcceptableCAs contains zero or more, DER-encoded, X.501
|
||||||
|
// Distinguished Names. These are the names of root or intermediate CAs
|
||||||
|
// that the server wishes the returned certificate to be signed by. An
|
||||||
|
// empty slice indicates that the server has no preference.
|
||||||
|
AcceptableCAs [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportsCertificate returns nil if the provided certificate is supported by
|
||||||
|
// the server that sent the CertificateRequest. Otherwise, it returns an error
|
||||||
|
// describing the reason for the incompatibility.
|
||||||
|
// NOTE: original src:
|
||||||
|
// https://github.com/golang/go/blob/29b9a328d268d53833d2cc063d1d8b4bf6852675/src/crypto/tls/common.go#L1273
|
||||||
|
func (cri *CertificateRequestInfo) SupportsCertificate(c *tls.Certificate) error {
|
||||||
|
if len(cri.AcceptableCAs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for j, cert := range c.Certificate {
|
||||||
|
x509Cert := c.Leaf
|
||||||
|
// Parse the certificate if this isn't the leaf node, or if
|
||||||
|
// chain.Leaf was nil.
|
||||||
|
if j != 0 || x509Cert == nil {
|
||||||
|
var err error
|
||||||
|
if x509Cert, err = x509.ParseCertificate(cert); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse certificate #%d in the chain: %w", j, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ca := range cri.AcceptableCAs {
|
||||||
|
if bytes.Equal(x509Cert.RawIssuer, ca) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errNotAcceptableCertificateChain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *handshakeConfig) setNameToCertificateLocked() {
|
||||||
|
nameToCertificate := make(map[string]*tls.Certificate)
|
||||||
|
for i := range c.localCertificates {
|
||||||
|
cert := &c.localCertificates[i]
|
||||||
|
x509Cert := cert.Leaf
|
||||||
|
if x509Cert == nil {
|
||||||
|
var parseErr error
|
||||||
|
x509Cert, parseErr = x509.ParseCertificate(cert.Certificate[0])
|
||||||
|
if parseErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(x509Cert.Subject.CommonName) > 0 {
|
||||||
|
nameToCertificate[strings.ToLower(x509Cert.Subject.CommonName)] = cert
|
||||||
|
}
|
||||||
|
for _, san := range x509Cert.DNSNames {
|
||||||
|
nameToCertificate[strings.ToLower(san)] = cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.nameToCertificate = nameToCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:cyclop
|
||||||
|
func (c *handshakeConfig) getCertificate(clientHelloInfo *ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.localGetCertificate != nil &&
|
||||||
|
(len(c.localCertificates) == 0 || len(clientHelloInfo.ServerName) > 0) {
|
||||||
|
cert, err := c.localGetCertificate(clientHelloInfo)
|
||||||
|
if cert != nil || err != nil {
|
||||||
|
return cert, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.nameToCertificate == nil {
|
||||||
|
c.setNameToCertificateLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.localCertificates) == 0 {
|
||||||
|
return nil, errNoCertificates
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.localCertificates) == 1 {
|
||||||
|
// There's only one choice, so no point doing any work.
|
||||||
|
return &c.localCertificates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clientHelloInfo.ServerName) == 0 {
|
||||||
|
return &c.localCertificates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimRight(strings.ToLower(clientHelloInfo.ServerName), ".")
|
||||||
|
|
||||||
|
if cert, ok := c.nameToCertificate[name]; ok {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// try replacing labels in the name with wildcards until we get a
|
||||||
|
// match.
|
||||||
|
labels := strings.Split(name, ".")
|
||||||
|
for i := range labels {
|
||||||
|
labels[i] = "*"
|
||||||
|
candidate := strings.Join(labels, ".")
|
||||||
|
if cert, ok := c.nameToCertificate[candidate]; ok {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing matches, return the first certificate.
|
||||||
|
return &c.localCertificates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: original src:
|
||||||
|
// https://github.com/golang/go/blob/29b9a328d268d53833d2cc063d1d8b4bf6852675/src/crypto/tls/handshake_client.go#L974
|
||||||
|
func (c *handshakeConfig) getClientCertificate(cri *CertificateRequestInfo) (*tls.Certificate, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.localGetClientCertificate != nil {
|
||||||
|
return c.localGetClientCertificate(cri)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range c.localCertificates {
|
||||||
|
chain := c.localCertificates[i]
|
||||||
|
if err := cri.SupportsCertificate(&chain); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return &chain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No acceptable certificate found. Don't send a certificate.
|
||||||
|
return new(tls.Certificate), nil
|
||||||
|
}
|
||||||
295
vendor/github.com/pion/dtls/v3/cipher_suite.go
generated
vendored
Normal file
295
vendor/github.com/pion/dtls/v3/cipher_suite.go
generated
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/internal/ciphersuite"
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/clientcertificate"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/recordlayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CipherSuiteID is an ID for our supported CipherSuites.
|
||||||
|
type CipherSuiteID = ciphersuite.ID
|
||||||
|
|
||||||
|
// Supported Cipher Suites.
|
||||||
|
const (
|
||||||
|
|
||||||
|
// nolint: godot
|
||||||
|
// AES-128-CCM
|
||||||
|
TLS_ECDHE_ECDSA_WITH_AES_128_CCM CipherSuiteID = ciphersuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM // nolint: revive,staticcheck,lll
|
||||||
|
TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 CipherSuiteID = ciphersuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 // nolint: revive,staticcheck,lll
|
||||||
|
|
||||||
|
// nolint: godot
|
||||||
|
// AES-128-GCM-SHA256
|
||||||
|
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 CipherSuiteID = ciphersuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 // nolint: revive,staticcheck,lll
|
||||||
|
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 CipherSuiteID = ciphersuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 // nolint: revive,staticcheck,lll
|
||||||
|
|
||||||
|
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 CipherSuiteID = ciphersuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 // nolint: revive,staticcheck,lll
|
||||||
|
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 CipherSuiteID = ciphersuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 // nolint: revive,staticcheck,lll
|
||||||
|
|
||||||
|
// nolint: godot
|
||||||
|
// AES-256-CBC-SHA
|
||||||
|
TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA CipherSuiteID = ciphersuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA // nolint: revive,staticcheck,lll
|
||||||
|
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA CipherSuiteID = ciphersuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA // nolint: revive,staticcheck,lll
|
||||||
|
|
||||||
|
TLS_PSK_WITH_AES_128_CCM CipherSuiteID = ciphersuite.TLS_PSK_WITH_AES_128_CCM // nolint: revive,staticcheck,lll
|
||||||
|
TLS_PSK_WITH_AES_128_CCM_8 CipherSuiteID = ciphersuite.TLS_PSK_WITH_AES_128_CCM_8 // nolint: revive,staticcheck,lll
|
||||||
|
TLS_PSK_WITH_AES_256_CCM_8 CipherSuiteID = ciphersuite.TLS_PSK_WITH_AES_256_CCM_8 // nolint: revive,staticcheck,lll
|
||||||
|
TLS_PSK_WITH_AES_128_GCM_SHA256 CipherSuiteID = ciphersuite.TLS_PSK_WITH_AES_128_GCM_SHA256 // nolint: revive,staticcheck,lll
|
||||||
|
TLS_PSK_WITH_AES_128_CBC_SHA256 CipherSuiteID = ciphersuite.TLS_PSK_WITH_AES_128_CBC_SHA256 // nolint: revive,staticcheck,lll
|
||||||
|
|
||||||
|
TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256 CipherSuiteID = ciphersuite.TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256 // nolint: revive,staticcheck,lll
|
||||||
|
)
|
||||||
|
|
||||||
|
// CipherSuiteAuthenticationType controls what authentication method is using during the handshake for a CipherSuite.
|
||||||
|
type CipherSuiteAuthenticationType = ciphersuite.AuthenticationType
|
||||||
|
|
||||||
|
// AuthenticationType Enums.
|
||||||
|
const (
|
||||||
|
CipherSuiteAuthenticationTypeCertificate CipherSuiteAuthenticationType = ciphersuite.AuthenticationTypeCertificate
|
||||||
|
CipherSuiteAuthenticationTypePreSharedKey CipherSuiteAuthenticationType = ciphersuite.AuthenticationTypePreSharedKey
|
||||||
|
CipherSuiteAuthenticationTypeAnonymous CipherSuiteAuthenticationType = ciphersuite.AuthenticationTypeAnonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
// CipherSuiteKeyExchangeAlgorithm controls what exchange algorithm is using during the handshake for a CipherSuite.
|
||||||
|
type CipherSuiteKeyExchangeAlgorithm = ciphersuite.KeyExchangeAlgorithm
|
||||||
|
|
||||||
|
// CipherSuiteKeyExchangeAlgorithm Bitmask.
|
||||||
|
const (
|
||||||
|
CipherSuiteKeyExchangeAlgorithmNone CipherSuiteKeyExchangeAlgorithm = ciphersuite.KeyExchangeAlgorithmNone
|
||||||
|
CipherSuiteKeyExchangeAlgorithmPsk CipherSuiteKeyExchangeAlgorithm = ciphersuite.KeyExchangeAlgorithmPsk
|
||||||
|
CipherSuiteKeyExchangeAlgorithmEcdhe CipherSuiteKeyExchangeAlgorithm = ciphersuite.KeyExchangeAlgorithmEcdhe
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = allCipherSuites() // Necessary until this function isn't only used by Go 1.14
|
||||||
|
|
||||||
|
// CipherSuite is an interface that all DTLS CipherSuites must satisfy.
|
||||||
|
type CipherSuite interface {
|
||||||
|
// String of CipherSuite, only used for logging
|
||||||
|
String() string
|
||||||
|
|
||||||
|
// ID of CipherSuite.
|
||||||
|
ID() CipherSuiteID
|
||||||
|
|
||||||
|
// What type of Certificate does this CipherSuite use
|
||||||
|
CertificateType() clientcertificate.Type
|
||||||
|
|
||||||
|
// What Hash function is used during verification
|
||||||
|
HashFunc() func() hash.Hash
|
||||||
|
|
||||||
|
// AuthenticationType controls what authentication method is using during the handshake
|
||||||
|
AuthenticationType() CipherSuiteAuthenticationType
|
||||||
|
|
||||||
|
// KeyExchangeAlgorithm controls what exchange algorithm is using during the handshake
|
||||||
|
KeyExchangeAlgorithm() CipherSuiteKeyExchangeAlgorithm
|
||||||
|
|
||||||
|
// ECC (Elliptic Curve Cryptography) determines whether ECC extesions will be send during handshake.
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4492#page-10
|
||||||
|
ECC() bool
|
||||||
|
|
||||||
|
// Called when keying material has been generated, should initialize the internal cipher
|
||||||
|
Init(masterSecret, clientRandom, serverRandom []byte, isClient bool) error
|
||||||
|
IsInitialized() bool
|
||||||
|
Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
|
||||||
|
Decrypt(h recordlayer.Header, in []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CipherSuiteName provides the same functionality as tls.CipherSuiteName
|
||||||
|
// that appeared first in Go 1.14.
|
||||||
|
//
|
||||||
|
// Our implementation differs slightly in that it takes in a CiperSuiteID,
|
||||||
|
// like the rest of our library, instead of a uint16 like crypto/tls.
|
||||||
|
func CipherSuiteName(id CipherSuiteID) string {
|
||||||
|
suite := cipherSuiteForID(id, nil)
|
||||||
|
if suite != nil {
|
||||||
|
return suite.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("0x%04X", uint16(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken from https://www.iana.org/assignments/tls-parameters/tls-parameters.xml
|
||||||
|
// A cipherSuite is a specific combination of key agreement, cipher and MAC
|
||||||
|
// function.
|
||||||
|
func cipherSuiteForID(id CipherSuiteID, customCiphers func() []CipherSuite) CipherSuite { //nolint:cyclop
|
||||||
|
switch id { //nolint:exhaustive
|
||||||
|
case TLS_ECDHE_ECDSA_WITH_AES_128_CCM:
|
||||||
|
return ciphersuite.NewTLSEcdheEcdsaWithAes128Ccm()
|
||||||
|
case TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8:
|
||||||
|
return ciphersuite.NewTLSEcdheEcdsaWithAes128Ccm8()
|
||||||
|
case TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:
|
||||||
|
return &ciphersuite.TLSEcdheEcdsaWithAes128GcmSha256{}
|
||||||
|
case TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:
|
||||||
|
return &ciphersuite.TLSEcdheRsaWithAes128GcmSha256{}
|
||||||
|
case TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA:
|
||||||
|
return &ciphersuite.TLSEcdheEcdsaWithAes256CbcSha{}
|
||||||
|
case TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:
|
||||||
|
return &ciphersuite.TLSEcdheRsaWithAes256CbcSha{}
|
||||||
|
case TLS_PSK_WITH_AES_128_CCM:
|
||||||
|
return ciphersuite.NewTLSPskWithAes128Ccm()
|
||||||
|
case TLS_PSK_WITH_AES_128_CCM_8:
|
||||||
|
return ciphersuite.NewTLSPskWithAes128Ccm8()
|
||||||
|
case TLS_PSK_WITH_AES_256_CCM_8:
|
||||||
|
return ciphersuite.NewTLSPskWithAes256Ccm8()
|
||||||
|
case TLS_PSK_WITH_AES_128_GCM_SHA256:
|
||||||
|
return &ciphersuite.TLSPskWithAes128GcmSha256{}
|
||||||
|
case TLS_PSK_WITH_AES_128_CBC_SHA256:
|
||||||
|
return &ciphersuite.TLSPskWithAes128CbcSha256{}
|
||||||
|
case TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:
|
||||||
|
return &ciphersuite.TLSEcdheEcdsaWithAes256GcmSha384{}
|
||||||
|
case TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:
|
||||||
|
return &ciphersuite.TLSEcdheRsaWithAes256GcmSha384{}
|
||||||
|
case TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256:
|
||||||
|
return ciphersuite.NewTLSEcdhePskWithAes128CbcSha256()
|
||||||
|
}
|
||||||
|
|
||||||
|
if customCiphers != nil {
|
||||||
|
for _, c := range customCiphers() {
|
||||||
|
if c.ID() == id {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CipherSuites we support in order of preference.
|
||||||
|
func defaultCipherSuites() []CipherSuite {
|
||||||
|
return []CipherSuite{
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes128GcmSha256{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes128GcmSha256{},
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes256CbcSha{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes256CbcSha{},
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes256GcmSha384{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes256GcmSha384{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allCipherSuites() []CipherSuite {
|
||||||
|
return []CipherSuite{
|
||||||
|
ciphersuite.NewTLSEcdheEcdsaWithAes128Ccm(),
|
||||||
|
ciphersuite.NewTLSEcdheEcdsaWithAes128Ccm8(),
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes128GcmSha256{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes128GcmSha256{},
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes256CbcSha{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes256CbcSha{},
|
||||||
|
ciphersuite.NewTLSPskWithAes128Ccm(),
|
||||||
|
ciphersuite.NewTLSPskWithAes128Ccm8(),
|
||||||
|
ciphersuite.NewTLSPskWithAes256Ccm8(),
|
||||||
|
&ciphersuite.TLSPskWithAes128GcmSha256{},
|
||||||
|
&ciphersuite.TLSEcdheEcdsaWithAes256GcmSha384{},
|
||||||
|
&ciphersuite.TLSEcdheRsaWithAes256GcmSha384{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cipherSuiteIDs(cipherSuites []CipherSuite) []uint16 {
|
||||||
|
rtrn := []uint16{}
|
||||||
|
for _, c := range cipherSuites {
|
||||||
|
rtrn = append(rtrn, uint16(c.ID()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrn
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:cyclop
|
||||||
|
func parseCipherSuites(
|
||||||
|
userSelectedSuites []CipherSuiteID,
|
||||||
|
customCipherSuites func() []CipherSuite,
|
||||||
|
includeCertificateSuites, includePSKSuites bool,
|
||||||
|
) ([]CipherSuite, error) {
|
||||||
|
cipherSuitesForIDs := func(ids []CipherSuiteID) ([]CipherSuite, error) {
|
||||||
|
cipherSuites := []CipherSuite{}
|
||||||
|
for _, id := range ids {
|
||||||
|
c := cipherSuiteForID(id, nil)
|
||||||
|
if c == nil {
|
||||||
|
return nil, &invalidCipherSuiteError{id}
|
||||||
|
}
|
||||||
|
cipherSuites = append(cipherSuites, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cipherSuites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cipherSuites []CipherSuite
|
||||||
|
err error
|
||||||
|
i int
|
||||||
|
)
|
||||||
|
if userSelectedSuites != nil {
|
||||||
|
cipherSuites, err = cipherSuitesForIDs(userSelectedSuites)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipherSuites = defaultCipherSuites()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put CustomCipherSuites before ID selected suites
|
||||||
|
if customCipherSuites != nil {
|
||||||
|
cipherSuites = append(customCipherSuites(), cipherSuites...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundCertificateSuite, foundPSKSuite, foundAnonymousSuite bool
|
||||||
|
for _, c := range cipherSuites {
|
||||||
|
switch {
|
||||||
|
case includeCertificateSuites && c.AuthenticationType() == CipherSuiteAuthenticationTypeCertificate:
|
||||||
|
foundCertificateSuite = true
|
||||||
|
case includePSKSuites && c.AuthenticationType() == CipherSuiteAuthenticationTypePreSharedKey:
|
||||||
|
foundPSKSuite = true
|
||||||
|
case c.AuthenticationType() == CipherSuiteAuthenticationTypeAnonymous:
|
||||||
|
foundAnonymousSuite = true
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cipherSuites[i] = c
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case includeCertificateSuites && !foundCertificateSuite && !foundAnonymousSuite:
|
||||||
|
return nil, errNoAvailableCertificateCipherSuite
|
||||||
|
case includePSKSuites && !foundPSKSuite:
|
||||||
|
return nil, errNoAvailablePSKCipherSuite
|
||||||
|
case i == 0:
|
||||||
|
return nil, errNoAvailableCipherSuites
|
||||||
|
}
|
||||||
|
|
||||||
|
return cipherSuites[:i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCipherSuitesForCertificate(cert *tls.Certificate, cipherSuites []CipherSuite) []CipherSuite {
|
||||||
|
if cert == nil || cert.PrivateKey == nil {
|
||||||
|
return cipherSuites
|
||||||
|
}
|
||||||
|
signer, ok := cert.PrivateKey.(crypto.Signer)
|
||||||
|
if !ok {
|
||||||
|
return cipherSuites
|
||||||
|
}
|
||||||
|
|
||||||
|
var certType clientcertificate.Type
|
||||||
|
switch signer.Public().(type) {
|
||||||
|
case ed25519.PublicKey, *ecdsa.PublicKey:
|
||||||
|
certType = clientcertificate.ECDSASign
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
certType = clientcertificate.RSASign
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := []CipherSuite{}
|
||||||
|
for _, c := range cipherSuites {
|
||||||
|
if c.AuthenticationType() != CipherSuiteAuthenticationTypeCertificate || certType == c.CertificateType() {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
46
vendor/github.com/pion/dtls/v3/cipher_suite_go114.go
generated
vendored
Normal file
46
vendor/github.com/pion/dtls/v3/cipher_suite_go114.go
generated
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build go1.14
|
||||||
|
// +build go1.14
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionDTLS12 is the DTLS version in the same style as
|
||||||
|
// VersionTLSXX from crypto/tls.
|
||||||
|
const VersionDTLS12 = 0xfefd
|
||||||
|
|
||||||
|
// Convert from our cipherSuite interface to a tls.CipherSuite struct.
|
||||||
|
func toTLSCipherSuite(c CipherSuite) *tls.CipherSuite {
|
||||||
|
return &tls.CipherSuite{
|
||||||
|
ID: uint16(c.ID()),
|
||||||
|
Name: c.String(),
|
||||||
|
SupportedVersions: []uint16{VersionDTLS12},
|
||||||
|
Insecure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CipherSuites returns a list of cipher suites currently implemented by this
|
||||||
|
// package, excluding those with security issues, which are returned by
|
||||||
|
// InsecureCipherSuites.
|
||||||
|
func CipherSuites() []*tls.CipherSuite {
|
||||||
|
suites := allCipherSuites()
|
||||||
|
res := make([]*tls.CipherSuite, len(suites))
|
||||||
|
for i, c := range suites {
|
||||||
|
res[i] = toTLSCipherSuite(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsecureCipherSuites returns a list of cipher suites currently implemented by
|
||||||
|
// this package and which have security issues.
|
||||||
|
func InsecureCipherSuites() []*tls.CipherSuite {
|
||||||
|
var res []*tls.CipherSuite
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
22
vendor/github.com/pion/dtls/v3/codecov.yml
generated
vendored
Normal file
22
vendor/github.com/pion/dtls/v3/codecov.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#
|
||||||
|
# DO NOT EDIT THIS FILE
|
||||||
|
#
|
||||||
|
# It is automatically copied from https://github.com/pion/.goassets repository.
|
||||||
|
#
|
||||||
|
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
# Allow decreasing 2% of total coverage to avoid noise.
|
||||||
|
threshold: 2%
|
||||||
|
patch:
|
||||||
|
default:
|
||||||
|
target: 70%
|
||||||
|
only_pulls: true
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
- "examples/*"
|
||||||
|
- "examples/**/*"
|
||||||
12
vendor/github.com/pion/dtls/v3/compression_method.go
generated
vendored
Normal file
12
vendor/github.com/pion/dtls/v3/compression_method.go
generated
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import "github.com/pion/dtls/v3/pkg/protocol"
|
||||||
|
|
||||||
|
func defaultCompressionMethods() []*protocol.CompressionMethod {
|
||||||
|
return []*protocol.CompressionMethod{
|
||||||
|
{},
|
||||||
|
}
|
||||||
|
}
|
||||||
306
vendor/github.com/pion/dtls/v3/config.go
generated
vendored
Normal file
306
vendor/github.com/pion/dtls/v3/config.go
generated
vendored
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/elliptic"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/handshake"
|
||||||
|
"github.com/pion/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyLogLabelTLS12 = "CLIENT_RANDOM"
|
||||||
|
|
||||||
|
// Config is used to configure a DTLS client or server.
|
||||||
|
// After a Config is passed to a DTLS function it must not be modified.
|
||||||
|
//
|
||||||
|
// Deprecated: prefer the options-based APIs (`*WithOptions`) to construct immutable configs,
|
||||||
|
// This will be removed in the next major version.
|
||||||
|
type Config struct { //nolint:dupl
|
||||||
|
// Certificates contains certificate chain to present to the other side of the connection.
|
||||||
|
// Server MUST set this if PSK is non-nil
|
||||||
|
// client SHOULD sets this so CertificateRequests can be handled if PSK is non-nil
|
||||||
|
Certificates []tls.Certificate
|
||||||
|
|
||||||
|
// CipherSuites is a list of supported cipher suites.
|
||||||
|
// If CipherSuites is nil, a default list is used
|
||||||
|
CipherSuites []CipherSuiteID
|
||||||
|
|
||||||
|
// CustomCipherSuites is a list of CipherSuites that can be
|
||||||
|
// provided by the user. This allow users to user Ciphers that are reserved
|
||||||
|
// for private usage.
|
||||||
|
CustomCipherSuites func() []CipherSuite
|
||||||
|
|
||||||
|
// SignatureSchemes contains the signature and hash schemes that the peer requests to verify.
|
||||||
|
SignatureSchemes []tls.SignatureScheme
|
||||||
|
|
||||||
|
// CertificateSignatureSchemes contains the signature and hash schemes that may be used
|
||||||
|
// in digital signatures for X.509 certificates. If not set, the signature_algorithms_cert
|
||||||
|
// extension is not sent, and SignatureSchemes is used for both handshake signatures and
|
||||||
|
// certificate chain validation, as specified in RFC 8446 Section 4.2.3.
|
||||||
|
CertificateSignatureSchemes []tls.SignatureScheme
|
||||||
|
|
||||||
|
// SRTPProtectionProfiles are the supported protection profiles
|
||||||
|
// Clients will send this via use_srtp and assert that the server properly responds
|
||||||
|
// Servers will assert that clients send one of these profiles and will respond as needed
|
||||||
|
SRTPProtectionProfiles []SRTPProtectionProfile
|
||||||
|
|
||||||
|
// SRTPMasterKeyIdentifier value (if any) is sent via the use_srtp
|
||||||
|
// extension for Clients and Servers
|
||||||
|
SRTPMasterKeyIdentifier []byte
|
||||||
|
|
||||||
|
// ClientAuth determines the server's policy for
|
||||||
|
// TLS Client Authentication. The default is NoClientCert.
|
||||||
|
ClientAuth ClientAuthType
|
||||||
|
|
||||||
|
// RequireExtendedMasterSecret determines if the "Extended Master Secret" extension
|
||||||
|
// should be disabled, requested, or required (default requested).
|
||||||
|
ExtendedMasterSecret ExtendedMasterSecretType
|
||||||
|
|
||||||
|
// FlightInterval controls how often we send outbound handshake messages
|
||||||
|
// defaults to time.Second
|
||||||
|
FlightInterval time.Duration
|
||||||
|
|
||||||
|
// DisableRetransmitBackoff can be used to the disable the backoff feature
|
||||||
|
// when sending outbound messages as specified in RFC 4347 4.2.4.1
|
||||||
|
DisableRetransmitBackoff bool
|
||||||
|
|
||||||
|
// PSK sets the pre-shared key used by this DTLS connection
|
||||||
|
// If PSK is non-nil only PSK CipherSuites will be used
|
||||||
|
PSK PSKCallback
|
||||||
|
PSKIdentityHint []byte
|
||||||
|
|
||||||
|
// InsecureSkipVerify controls whether a client verifies the
|
||||||
|
// server's certificate chain and host name.
|
||||||
|
// If InsecureSkipVerify is true, TLS accepts any certificate
|
||||||
|
// presented by the server and any host name in that certificate.
|
||||||
|
// In this mode, TLS is susceptible to man-in-the-middle attacks.
|
||||||
|
// This should be used only for testing.
|
||||||
|
InsecureSkipVerify bool
|
||||||
|
|
||||||
|
// InsecureHashes allows the use of hashing algorithms that are known
|
||||||
|
// to be vulnerable.
|
||||||
|
InsecureHashes bool
|
||||||
|
|
||||||
|
// VerifyPeerCertificate, if not nil, is called after normal
|
||||||
|
// certificate verification by either a client or server. It
|
||||||
|
// receives the certificate provided by the peer and also a flag
|
||||||
|
// that tells if normal verification has succeedded. If it returns a
|
||||||
|
// non-nil error, the handshake is aborted and that error results.
|
||||||
|
//
|
||||||
|
// If normal verification fails then the handshake will abort before
|
||||||
|
// considering this callback. If normal verification is disabled by
|
||||||
|
// setting InsecureSkipVerify, or (for a server) when ClientAuth is
|
||||||
|
// RequestClientCert or RequireAnyClientCert, then this callback will
|
||||||
|
// be considered but the verifiedChains will always be nil.
|
||||||
|
VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
|
||||||
|
|
||||||
|
// VerifyConnection, if not nil, is called after normal certificate
|
||||||
|
// verification/PSK and after VerifyPeerCertificate by either a TLS client
|
||||||
|
// or server. If it returns a non-nil error, the handshake is aborted
|
||||||
|
// and that error results.
|
||||||
|
//
|
||||||
|
// If normal verification fails then the handshake will abort before
|
||||||
|
// considering this callback. This callback will run for all connections
|
||||||
|
// regardless of InsecureSkipVerify or ClientAuth settings.
|
||||||
|
VerifyConnection func(*State) error
|
||||||
|
|
||||||
|
// RootCAs defines the set of root certificate authorities
|
||||||
|
// that one peer uses when verifying the other peer's certificates.
|
||||||
|
// If RootCAs is nil, TLS uses the host's root CA set.
|
||||||
|
RootCAs *x509.CertPool
|
||||||
|
|
||||||
|
// ClientCAs defines the set of root certificate authorities
|
||||||
|
// that servers use if required to verify a client certificate
|
||||||
|
// by the policy in ClientAuth.
|
||||||
|
ClientCAs *x509.CertPool
|
||||||
|
|
||||||
|
// ServerName is used to verify the hostname on the returned
|
||||||
|
// certificates unless InsecureSkipVerify is given.
|
||||||
|
ServerName string
|
||||||
|
|
||||||
|
LoggerFactory logging.LoggerFactory
|
||||||
|
|
||||||
|
// MTU is the length at which handshake messages will be fragmented to
|
||||||
|
// fit within the maximum transmission unit (default is 1200 bytes)
|
||||||
|
MTU int
|
||||||
|
|
||||||
|
// ReplayProtectionWindow is the size of the replay attack protection window.
|
||||||
|
// Duplication of the sequence number is checked in this window size.
|
||||||
|
// Packet with sequence number older than this value compared to the latest
|
||||||
|
// accepted packet will be discarded. (default is 64)
|
||||||
|
ReplayProtectionWindow int
|
||||||
|
|
||||||
|
// KeyLogWriter optionally specifies a destination for TLS master secrets
|
||||||
|
// in NSS key log format that can be used to allow external programs
|
||||||
|
// such as Wireshark to decrypt TLS connections.
|
||||||
|
// See https://developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS/Key_Log_Format.
|
||||||
|
// Use of KeyLogWriter compromises security and should only be
|
||||||
|
// used for debugging.
|
||||||
|
KeyLogWriter io.Writer
|
||||||
|
|
||||||
|
// SessionStore is the container to store session for resumption.
|
||||||
|
SessionStore SessionStore
|
||||||
|
|
||||||
|
// List of application protocols the peer supports, for ALPN
|
||||||
|
SupportedProtocols []string
|
||||||
|
|
||||||
|
// List of Elliptic Curves to use
|
||||||
|
//
|
||||||
|
// If an ECC ciphersuite is configured and EllipticCurves is empty
|
||||||
|
// it will default to X25519, P-256, P-384 in this specific order.
|
||||||
|
EllipticCurves []elliptic.Curve
|
||||||
|
|
||||||
|
// GetCertificate returns a Certificate based on the given
|
||||||
|
// ClientHelloInfo. It will only be called if the client supplies SNI
|
||||||
|
// information or if Certificates is empty.
|
||||||
|
//
|
||||||
|
// If GetCertificate is nil or returns nil, then the certificate is
|
||||||
|
// retrieved from NameToCertificate. If NameToCertificate is nil, the
|
||||||
|
// best element of Certificates will be used.
|
||||||
|
GetCertificate func(*ClientHelloInfo) (*tls.Certificate, error)
|
||||||
|
|
||||||
|
// GetClientCertificate, if not nil, is called when a server requests a
|
||||||
|
// certificate from a client. If set, the contents of Certificates will
|
||||||
|
// be ignored.
|
||||||
|
//
|
||||||
|
// If GetClientCertificate returns an error, the handshake will be
|
||||||
|
// aborted and that error will be returned. Otherwise
|
||||||
|
// GetClientCertificate must return a non-nil Certificate. If
|
||||||
|
// Certificate.Certificate is empty then no certificate will be sent to
|
||||||
|
// the server. If this is unacceptable to the server then it may abort
|
||||||
|
// the handshake.
|
||||||
|
GetClientCertificate func(*CertificateRequestInfo) (*tls.Certificate, error)
|
||||||
|
|
||||||
|
// InsecureSkipVerifyHello, if true and when acting as server, allow client to
|
||||||
|
// skip hello verify phase and receive ServerHello after initial ClientHello.
|
||||||
|
// This have implication on DoS attack resistance.
|
||||||
|
InsecureSkipVerifyHello bool
|
||||||
|
|
||||||
|
// ConnectionIDGenerator generates connection identifiers that should be
|
||||||
|
// sent by the remote party if it supports the DTLS Connection Identifier
|
||||||
|
// extension, as determined during the handshake. Generated connection
|
||||||
|
// identifiers must always have the same length. Returning a zero-length
|
||||||
|
// connection identifier indicates that the local party supports sending
|
||||||
|
// connection identifiers but does not require the remote party to send
|
||||||
|
// them. A nil ConnectionIDGenerator indicates that connection identifiers
|
||||||
|
// are not supported.
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9146
|
||||||
|
ConnectionIDGenerator func() []byte
|
||||||
|
|
||||||
|
// PaddingLengthGenerator generates the number of padding bytes used to
|
||||||
|
// inflate ciphertext size in order to obscure content size from observers.
|
||||||
|
// The length of the content is passed to the generator such that both
|
||||||
|
// deterministic and random padding schemes can be applied while not
|
||||||
|
// exceeding maximum record size.
|
||||||
|
// If no PaddingLengthGenerator is specified, padding will not be applied.
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9146#section-4
|
||||||
|
PaddingLengthGenerator func(uint) uint
|
||||||
|
|
||||||
|
// HelloRandomBytesGenerator generates custom client hello random bytes.
|
||||||
|
HelloRandomBytesGenerator func() [handshake.RandomBytesLength]byte
|
||||||
|
|
||||||
|
// Handshake hooks: hooks can be used for testing invalid messages,
|
||||||
|
// mimicking other implementations or randomizing fields, which is valuable
|
||||||
|
// for applications that need censorship-resistance by making
|
||||||
|
// fingerprinting more difficult.
|
||||||
|
|
||||||
|
// ClientHelloMessageHook, if not nil, is called when a Client Hello message is sent
|
||||||
|
// from a client. The returned handshake message replaces the original message.
|
||||||
|
ClientHelloMessageHook func(handshake.MessageClientHello) handshake.Message
|
||||||
|
|
||||||
|
// ServerHelloMessageHook, if not nil, is called when a Server Hello message is sent
|
||||||
|
// from a server. The returned handshake message replaces the original message.
|
||||||
|
ServerHelloMessageHook func(handshake.MessageServerHello) handshake.Message
|
||||||
|
|
||||||
|
// CertificateRequestMessageHook, if not nil, is called when a Certificate Request
|
||||||
|
// message is sent from a server. The returned handshake message replaces the original message.
|
||||||
|
CertificateRequestMessageHook func(handshake.MessageCertificateRequest) handshake.Message
|
||||||
|
|
||||||
|
// OnConnectionAttempt is fired Whenever a connection attempt is made,
|
||||||
|
// the server or application can call this callback function.
|
||||||
|
// The callback function can then implement logic to handle the connection attempt, such as logging the attempt,
|
||||||
|
// checking against a list of blocked IPs, or counting the attempts to prevent brute force attacks.
|
||||||
|
// If the callback function returns an error, the connection attempt will be aborted.
|
||||||
|
OnConnectionAttempt func(net.Addr) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) includeCertificateSuites() bool {
|
||||||
|
return c.PSK == nil || len(c.Certificates) > 0 || c.GetCertificate != nil || c.GetClientCertificate != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMTU = 1200 // bytes
|
||||||
|
|
||||||
|
var defaultCurves = []elliptic.Curve{elliptic.X25519, elliptic.P256, elliptic.P384} //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// PSKCallback is called once we have the remote's PSKIdentityHint.
|
||||||
|
// If the remote provided none it will be nil.
|
||||||
|
type PSKCallback func([]byte) ([]byte, error)
|
||||||
|
|
||||||
|
// ClientAuthType declares the policy the server will follow for
|
||||||
|
// TLS Client Authentication.
|
||||||
|
type ClientAuthType int
|
||||||
|
|
||||||
|
// ClientAuthType enums.
|
||||||
|
const (
|
||||||
|
NoClientCert ClientAuthType = iota
|
||||||
|
RequestClientCert
|
||||||
|
RequireAnyClientCert
|
||||||
|
VerifyClientCertIfGiven
|
||||||
|
RequireAndVerifyClientCert
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtendedMasterSecretType declares the policy the client and server
|
||||||
|
// will follow for the Extended Master Secret extension.
|
||||||
|
type ExtendedMasterSecretType int
|
||||||
|
|
||||||
|
// ExtendedMasterSecretType enums.
|
||||||
|
const (
|
||||||
|
RequestExtendedMasterSecret ExtendedMasterSecretType = iota
|
||||||
|
RequireExtendedMasterSecret
|
||||||
|
DisableExtendedMasterSecret
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateConfig(config *Config) error { //nolint:cyclop
|
||||||
|
switch {
|
||||||
|
case config == nil:
|
||||||
|
return errNoConfigProvided
|
||||||
|
case config.PSKIdentityHint != nil && config.PSK == nil:
|
||||||
|
return errIdentityNoPSK
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cert := range config.Certificates {
|
||||||
|
if cert.Certificate == nil {
|
||||||
|
return errInvalidCertificate
|
||||||
|
}
|
||||||
|
if cert.PrivateKey != nil {
|
||||||
|
signer, ok := cert.PrivateKey.(crypto.Signer)
|
||||||
|
if !ok {
|
||||||
|
return errInvalidPrivateKey
|
||||||
|
}
|
||||||
|
switch signer.Public().(type) {
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
default:
|
||||||
|
return errInvalidPrivateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := parseCipherSuites(
|
||||||
|
config.CipherSuites, config.CustomCipherSuites, config.includeCertificateSuites(), config.PSK != nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
1397
vendor/github.com/pion/dtls/v3/conn.go
generated
vendored
Normal file
1397
vendor/github.com/pion/dtls/v3/conn.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load diff
105
vendor/github.com/pion/dtls/v3/connection_id.go
generated
vendored
Normal file
105
vendor/github.com/pion/dtls/v3/connection_id.go
generated
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/extension"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/handshake"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/recordlayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RandomCIDGenerator is a random Connection ID generator where CID is the
|
||||||
|
// specified size. Specifying a size of 0 will indicate to peers that sending a
|
||||||
|
// Connection ID is not necessary.
|
||||||
|
func RandomCIDGenerator(size int) func() []byte {
|
||||||
|
return func() []byte {
|
||||||
|
cid := make([]byte, size)
|
||||||
|
if _, err := rand.Read(cid); err != nil {
|
||||||
|
panic(err) //nolint -- nonrecoverable
|
||||||
|
}
|
||||||
|
|
||||||
|
return cid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnlySendCIDGenerator enables sending Connection IDs negotiated with a peer,
|
||||||
|
// but indicates to the peer that sending Connection IDs in return is not
|
||||||
|
// necessary.
|
||||||
|
func OnlySendCIDGenerator() func() []byte {
|
||||||
|
return func() []byte {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidDatagramRouter extracts connection IDs from incoming datagram payloads and
|
||||||
|
// uses them to route to the proper connection.
|
||||||
|
// NOTE: properly routing datagrams based on connection IDs requires using
|
||||||
|
// constant size connection IDs.
|
||||||
|
func cidDatagramRouter(size int) func([]byte) (string, bool) {
|
||||||
|
return func(packet []byte) (string, bool) {
|
||||||
|
pkts, err := recordlayer.ContentAwareUnpackDatagram(packet, size)
|
||||||
|
if err != nil || len(pkts) < 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, pkt := range pkts {
|
||||||
|
h := &recordlayer.Header{
|
||||||
|
ConnectionID: make([]byte, size),
|
||||||
|
}
|
||||||
|
if err := h.Unmarshal(pkt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h.ContentType != protocol.ContentTypeConnectionID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(h.ConnectionID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidConnIdentifier extracts connection IDs from outgoing ServerHello records
|
||||||
|
// and associates them with the associated connection.
|
||||||
|
// NOTE: a ServerHello should always be the first record in a datagram if
|
||||||
|
// multiple are present, so we avoid iterating through all packets if the first
|
||||||
|
// is not a ServerHello.
|
||||||
|
func cidConnIdentifier() func([]byte) (string, bool) { //nolint:cyclop
|
||||||
|
return func(packet []byte) (string, bool) {
|
||||||
|
pkts, err := recordlayer.UnpackDatagram(packet)
|
||||||
|
if err != nil || len(pkts) < 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var h recordlayer.Header
|
||||||
|
if hErr := h.Unmarshal(pkts[0]); hErr != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.ContentType != protocol.ContentTypeHandshake {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var hh handshake.Header
|
||||||
|
var sh handshake.MessageServerHello
|
||||||
|
for _, pkt := range pkts {
|
||||||
|
if hhErr := hh.Unmarshal(pkt[recordlayer.FixedHeaderSize:]); hhErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = sh.Unmarshal(pkt[recordlayer.FixedHeaderSize+handshake.HeaderLength:]); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, ext := range sh.Extensions {
|
||||||
|
if e, ok := ext.(*extension.ConnectionID); ok {
|
||||||
|
return string(e.CID), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
457
vendor/github.com/pion/dtls/v3/crypto.go
generated
vendored
Normal file
457
vendor/github.com/pion/dtls/v3/crypto.go
generated
vendored
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/binary"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/elliptic"
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/hash"
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/signature"
|
||||||
|
"github.com/pion/dtls/v3/pkg/crypto/signaturehash"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ecdsaSignature struct {
|
||||||
|
R, S *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueKeyMessage(clientRandom, serverRandom, publicKey []byte, namedCurve elliptic.Curve) []byte {
|
||||||
|
serverECDHParams := make([]byte, 4)
|
||||||
|
serverECDHParams[0] = 3 // named curve
|
||||||
|
binary.BigEndian.PutUint16(serverECDHParams[1:], uint16(namedCurve))
|
||||||
|
serverECDHParams[3] = byte(len(publicKey))
|
||||||
|
|
||||||
|
plaintext := []byte{}
|
||||||
|
plaintext = append(plaintext, clientRandom...)
|
||||||
|
plaintext = append(plaintext, serverRandom...)
|
||||||
|
plaintext = append(plaintext, serverECDHParams...)
|
||||||
|
plaintext = append(plaintext, publicKey...)
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSignatureAlgOID validates that the signature scheme matches the
|
||||||
|
// certificate's public key algorithm OID. This is required by RFC 8446 Section 4.2.3:
|
||||||
|
// - RSA_PSS_RSAE requires rsaEncryption OID
|
||||||
|
// - RSA_PSS_PSS requires id-RSASSA-PSS OID
|
||||||
|
//
|
||||||
|
// Note: returns nil if the given signature.Algorithm is not PSS based.
|
||||||
|
//
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc8446#section-4.2.3
|
||||||
|
func validateSignatureAlgOID(cert *x509.Certificate, sigAlg signature.Algorithm) error {
|
||||||
|
if !sigAlg.IsPSS() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the certificate's public key algorithm OID from the raw certificate
|
||||||
|
// We need to parse the SubjectPublicKeyInfo to get the algorithm OID
|
||||||
|
var spki struct {
|
||||||
|
Algorithm pkix.AlgorithmIdentifier
|
||||||
|
PublicKey asn1.BitString
|
||||||
|
}
|
||||||
|
if _, err := asn1.Unmarshal(cert.RawSubjectPublicKeyInfo, &spki); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
certOID := spki.Algorithm.Algorithm
|
||||||
|
|
||||||
|
switch sigAlg {
|
||||||
|
// Check RSAE variants (0x0804-0x0806) require rsaEncryption OID
|
||||||
|
case signature.RSA_PSS_RSAE_SHA256, signature.RSA_PSS_RSAE_SHA384, signature.RSA_PSS_RSAE_SHA512:
|
||||||
|
oidPublicKeyRSA := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1} // OID: rsaEncryption
|
||||||
|
if !certOID.Equal(oidPublicKeyRSA) {
|
||||||
|
return errInvalidCertificateOID
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Check PSS variants (0x0809-0x080b) require id-RSASSA-PSS OID
|
||||||
|
case signature.RSA_PSS_PSS_SHA256, signature.RSA_PSS_PSS_SHA384, signature.RSA_PSS_PSS_SHA512:
|
||||||
|
oidPublicKeyRSAPSS := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 10} // OID: id-RSASSA-PSS
|
||||||
|
if !certOID.Equal(oidPublicKeyRSAPSS) {
|
||||||
|
return errInvalidCertificateOID
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client provided a "signature_algorithms" extension, then all
|
||||||
|
// certificates provided by the server MUST be signed by a
|
||||||
|
// hash/signature algorithm pair that appears in that extension
|
||||||
|
//
|
||||||
|
// https://tools.ietf.org/html/rfc5246#section-7.4.2
|
||||||
|
func generateKeySignature(
|
||||||
|
clientRandom, serverRandom, publicKey []byte,
|
||||||
|
namedCurve elliptic.Curve,
|
||||||
|
signer crypto.Signer,
|
||||||
|
hashAlgorithm hash.Algorithm,
|
||||||
|
signatureAlgorithm signature.Algorithm,
|
||||||
|
) ([]byte, error) {
|
||||||
|
msg := valueKeyMessage(clientRandom, serverRandom, publicKey, namedCurve)
|
||||||
|
switch signer.Public().(type) {
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
// https://crypto.stackexchange.com/a/55483
|
||||||
|
return signer.Sign(rand.Reader, msg, crypto.Hash(0))
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
hashed := hashAlgorithm.Digest(msg)
|
||||||
|
|
||||||
|
return signer.Sign(rand.Reader, hashed, hashAlgorithm.CryptoHash())
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
hashed := hashAlgorithm.Digest(msg)
|
||||||
|
|
||||||
|
// Use RSA-PSS if the signature algorithm is PSS
|
||||||
|
if signatureAlgorithm.IsPSS() {
|
||||||
|
pssOpts := &rsa.PSSOptions{
|
||||||
|
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||||
|
Hash: hashAlgorithm.CryptoHash(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer.Sign(rand.Reader, hashed, pssOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use PKCS#1 v1.5
|
||||||
|
return signer.Sign(rand.Reader, hashed, hashAlgorithm.CryptoHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errKeySignatureGenerateUnimplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:dupl,cyclop
|
||||||
|
func verifyKeySignature(
|
||||||
|
message, remoteKeySignature []byte,
|
||||||
|
hashAlgorithm hash.Algorithm,
|
||||||
|
signatureAlgorithm signature.Algorithm,
|
||||||
|
rawCertificates [][]byte,
|
||||||
|
) error {
|
||||||
|
if len(rawCertificates) == 0 {
|
||||||
|
return errLengthMismatch
|
||||||
|
}
|
||||||
|
certificate, err := x509.ParseCertificate(rawCertificates[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the signature algorithm matches the certificate's OID
|
||||||
|
if err := validateSignatureAlgOID(certificate, signatureAlgorithm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pubKey := certificate.PublicKey.(type) {
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
if ok := ed25519.Verify(pubKey, message, remoteKeySignature); !ok {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
ecdsaSig := &ecdsaSignature{}
|
||||||
|
if _, err := asn1.Unmarshal(remoteKeySignature, ecdsaSig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ecdsaSig.R.Sign() <= 0 || ecdsaSig.S.Sign() <= 0 {
|
||||||
|
return errInvalidECDSASignature
|
||||||
|
}
|
||||||
|
hashed := hashAlgorithm.Digest(message)
|
||||||
|
if !ecdsa.Verify(pubKey, hashed, ecdsaSig.R, ecdsaSig.S) {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
hashed := hashAlgorithm.Digest(message)
|
||||||
|
|
||||||
|
// Use RSA-PSS verification if the signature algorithm is PSS
|
||||||
|
if signatureAlgorithm.IsPSS() {
|
||||||
|
pssOpts := &rsa.PSSOptions{
|
||||||
|
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||||
|
Hash: hashAlgorithm.CryptoHash(),
|
||||||
|
}
|
||||||
|
if err := rsa.VerifyPSS(pubKey, hashAlgorithm.CryptoHash(), hashed, remoteKeySignature, pssOpts); err != nil {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use PKCS#1 v1.5
|
||||||
|
if rsa.VerifyPKCS1v15(pubKey, hashAlgorithm.CryptoHash(), hashed, remoteKeySignature) != nil {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errKeySignatureVerifyUnimplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server has sent a CertificateRequest message, the client MUST send the Certificate
|
||||||
|
// message. The ClientKeyExchange message is now sent, and the content
|
||||||
|
// of that message will depend on the public key algorithm selected
|
||||||
|
// between the ClientHello and the ServerHello. If the client has sent
|
||||||
|
// a certificate with signing ability, a digitally-signed
|
||||||
|
// CertificateVerify message is sent to explicitly verify possession of
|
||||||
|
// the private key in the certificate.
|
||||||
|
// https://tools.ietf.org/html/rfc5246#section-7.3
|
||||||
|
func generateCertificateVerify(
|
||||||
|
handshakeBodies []byte,
|
||||||
|
signer crypto.Signer,
|
||||||
|
hashAlgorithm hash.Algorithm,
|
||||||
|
signatureAlgorithm signature.Algorithm,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if _, ok := signer.Public().(ed25519.PublicKey); ok {
|
||||||
|
// https://pkg.go.dev/crypto/ed25519#PrivateKey.Sign
|
||||||
|
// Sign signs the given message with priv. Ed25519 performs two passes over
|
||||||
|
// messages to be signed and therefore cannot handle pre-hashed messages.
|
||||||
|
return signer.Sign(rand.Reader, handshakeBodies, crypto.Hash(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed := hashAlgorithm.Digest(handshakeBodies)
|
||||||
|
|
||||||
|
switch signer.Public().(type) {
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
return signer.Sign(rand.Reader, hashed, hashAlgorithm.CryptoHash())
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
// Use RSA-PSS if the signature algorithm is PSS
|
||||||
|
if signatureAlgorithm.IsPSS() {
|
||||||
|
pssOpts := &rsa.PSSOptions{
|
||||||
|
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||||
|
Hash: hashAlgorithm.CryptoHash(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer.Sign(rand.Reader, hashed, pssOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use PKCS#1 v1.5
|
||||||
|
return signer.Sign(rand.Reader, hashed, hashAlgorithm.CryptoHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errInvalidSignatureAlgorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:dupl,cyclop
|
||||||
|
func verifyCertificateVerify(
|
||||||
|
handshakeBodies []byte,
|
||||||
|
hashAlgorithm hash.Algorithm,
|
||||||
|
signatureAlgorithm signature.Algorithm,
|
||||||
|
remoteKeySignature []byte,
|
||||||
|
rawCertificates [][]byte,
|
||||||
|
) error {
|
||||||
|
if len(rawCertificates) == 0 {
|
||||||
|
return errLengthMismatch
|
||||||
|
}
|
||||||
|
certificate, err := x509.ParseCertificate(rawCertificates[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the signature algorithm matches the certificate's OID
|
||||||
|
if err := validateSignatureAlgOID(certificate, signatureAlgorithm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pubKey := certificate.PublicKey.(type) {
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
if ok := ed25519.Verify(pubKey, handshakeBodies, remoteKeySignature); !ok {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
ecdsaSig := &ecdsaSignature{}
|
||||||
|
if _, err := asn1.Unmarshal(remoteKeySignature, ecdsaSig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ecdsaSig.R.Sign() <= 0 || ecdsaSig.S.Sign() <= 0 {
|
||||||
|
return errInvalidECDSASignature
|
||||||
|
}
|
||||||
|
hash := hashAlgorithm.Digest(handshakeBodies)
|
||||||
|
if !ecdsa.Verify(pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
hash := hashAlgorithm.Digest(handshakeBodies)
|
||||||
|
|
||||||
|
// Use RSA-PSS verification if the signature algorithm is PSS
|
||||||
|
if signatureAlgorithm.IsPSS() {
|
||||||
|
pssOpts := &rsa.PSSOptions{
|
||||||
|
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||||
|
Hash: hashAlgorithm.CryptoHash(),
|
||||||
|
}
|
||||||
|
if err := rsa.VerifyPSS(pubKey, hashAlgorithm.CryptoHash(), hash, remoteKeySignature, pssOpts); err != nil {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use PKCS#1 v1.5
|
||||||
|
if rsa.VerifyPKCS1v15(pubKey, hashAlgorithm.CryptoHash(), hash, remoteKeySignature) != nil {
|
||||||
|
return errKeySignatureMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errKeySignatureVerifyUnimplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCerts(rawCertificates [][]byte) ([]*x509.Certificate, error) {
|
||||||
|
if len(rawCertificates) == 0 {
|
||||||
|
return nil, errLengthMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
certs := make([]*x509.Certificate, 0, len(rawCertificates))
|
||||||
|
for _, rawCert := range rawCertificates {
|
||||||
|
cert, err := x509.ParseCertificate(rawCert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
certs = append(certs, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
return certs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyClientCert(
|
||||||
|
rawCertificates [][]byte,
|
||||||
|
roots *x509.CertPool,
|
||||||
|
certSignatureSchemes []signaturehash.Algorithm,
|
||||||
|
) (chains [][]*x509.Certificate, err error) {
|
||||||
|
certificate, err := loadCerts(rawCertificates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
intermediateCAPool := x509.NewCertPool()
|
||||||
|
for _, cert := range certificate[1:] {
|
||||||
|
intermediateCAPool.AddCert(cert)
|
||||||
|
}
|
||||||
|
opts := x509.VerifyOptions{
|
||||||
|
Roots: roots,
|
||||||
|
CurrentTime: time.Now(),
|
||||||
|
Intermediates: intermediateCAPool,
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
}
|
||||||
|
|
||||||
|
chains, err = certificate[0].Verify(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate certificate signature algorithms if specified.
|
||||||
|
// At least one chain must use only allowed signature algorithms.
|
||||||
|
if len(certSignatureSchemes) > 0 && len(chains) > 0 {
|
||||||
|
var validChainFound bool
|
||||||
|
for _, chain := range chains {
|
||||||
|
if err := validateCertificateSignatureAlgorithms(chain, certSignatureSchemes); err == nil {
|
||||||
|
validChainFound = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validChainFound {
|
||||||
|
return nil, errInvalidCertificateSignatureAlgorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyServerCert(
|
||||||
|
rawCertificates [][]byte,
|
||||||
|
roots *x509.CertPool,
|
||||||
|
serverName string,
|
||||||
|
certSignatureSchemes []signaturehash.Algorithm,
|
||||||
|
) (chains [][]*x509.Certificate, err error) {
|
||||||
|
certificate, err := loadCerts(rawCertificates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
intermediateCAPool := x509.NewCertPool()
|
||||||
|
for _, cert := range certificate[1:] {
|
||||||
|
intermediateCAPool.AddCert(cert)
|
||||||
|
}
|
||||||
|
opts := x509.VerifyOptions{
|
||||||
|
Roots: roots,
|
||||||
|
CurrentTime: time.Now(),
|
||||||
|
DNSName: serverName,
|
||||||
|
Intermediates: intermediateCAPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
chains, err = certificate[0].Verify(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate certificate signature algorithms if specified.
|
||||||
|
// At least one chain must use only allowed signature algorithms.
|
||||||
|
if len(certSignatureSchemes) > 0 && len(chains) > 0 {
|
||||||
|
var validChainFound bool
|
||||||
|
for _, chain := range chains {
|
||||||
|
if err := validateCertificateSignatureAlgorithms(chain, certSignatureSchemes); err == nil {
|
||||||
|
validChainFound = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validChainFound {
|
||||||
|
return nil, errInvalidCertificateSignatureAlgorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCertificateSignatureAlgorithms validates that all certificates in the chain
|
||||||
|
// use signature algorithms that are in the allowed list. This implements the
|
||||||
|
// signature_algorithms_cert extension validation per RFC 8446 Section 4.2.3.
|
||||||
|
func validateCertificateSignatureAlgorithms(
|
||||||
|
certs []*x509.Certificate,
|
||||||
|
allowedAlgorithms []signaturehash.Algorithm,
|
||||||
|
) error {
|
||||||
|
if len(allowedAlgorithms) == 0 {
|
||||||
|
// No restrictions specified
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each certificate's signature algorithm (except the root, which we trust)
|
||||||
|
for i := 0; i < len(certs)-1; i++ {
|
||||||
|
cert := certs[i]
|
||||||
|
certAlg, err := signaturehash.FromCertificate(cert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this algorithm is in the allowed list
|
||||||
|
found := false
|
||||||
|
for _, allowed := range allowedAlgorithms {
|
||||||
|
if certAlg.Hash == allowed.Hash && certAlg.Signature == allowed.Signature {
|
||||||
|
found = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errInvalidCertificateSignatureAlgorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
5
vendor/github.com/pion/dtls/v3/dtls.go
generated
vendored
Normal file
5
vendor/github.com/pion/dtls/v3/dtls.go
generated
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package dtls implements Datagram Transport Layer Security (DTLS) 1.2
|
||||||
|
package dtls
|
||||||
308
vendor/github.com/pion/dtls/v3/errors.go
generated
vendored
Normal file
308
vendor/github.com/pion/dtls/v3/errors.go
generated
vendored
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol"
|
||||||
|
"github.com/pion/dtls/v3/pkg/protocol/alert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Typed errors.
|
||||||
|
var (
|
||||||
|
ErrConnClosed = &FatalError{Err: errors.New("conn is closed")} //nolint:err113
|
||||||
|
|
||||||
|
errDeadlineExceeded = &TimeoutError{Err: fmt.Errorf("read/write timeout: %w", context.DeadlineExceeded)}
|
||||||
|
errInvalidContentType = &TemporaryError{Err: errors.New("invalid content type")} //nolint:err113
|
||||||
|
|
||||||
|
//nolint:err113
|
||||||
|
errBufferTooSmall = &TemporaryError{Err: errors.New("buffer is too small")}
|
||||||
|
//nolint:err113
|
||||||
|
errContextUnsupported = &TemporaryError{Err: errors.New("context is not supported for ExportKeyingMaterial")}
|
||||||
|
//nolint:err113
|
||||||
|
errHandshakeInProgress = &TemporaryError{Err: errors.New("handshake is in progress")}
|
||||||
|
//nolint:err113
|
||||||
|
errReservedExportKeyingMaterial = &TemporaryError{
|
||||||
|
Err: errors.New("ExportKeyingMaterial can not be used with a reserved label"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errApplicationDataEpochZero = &TemporaryError{Err: errors.New("ApplicationData with epoch of 0")}
|
||||||
|
//nolint:err113
|
||||||
|
errUnhandledContextType = &TemporaryError{Err: errors.New("unhandled contentType")}
|
||||||
|
|
||||||
|
//nolint:err113
|
||||||
|
errCertificateVerifyNoCertificate = &FatalError{
|
||||||
|
Err: errors.New("client sent certificate verify but we have no certificate to verify"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errCipherSuiteNoIntersection = &FatalError{Err: errors.New("client+server do not support any shared cipher suites")}
|
||||||
|
//nolint:err113
|
||||||
|
errClientCertificateNotVerified = &FatalError{Err: errors.New("client sent certificate but did not verify it")}
|
||||||
|
//nolint:err113
|
||||||
|
errClientCertificateRequired = &FatalError{Err: errors.New("server required client verification, but got none")}
|
||||||
|
//nolint:err113
|
||||||
|
errClientNoMatchingSRTPProfile = &FatalError{Err: errors.New("server responded with SRTP Profile we do not support")}
|
||||||
|
//nolint:err113
|
||||||
|
errClientRequiredButNoServerEMS = &FatalError{
|
||||||
|
Err: errors.New("client required Extended Master Secret extension, but server does not support it"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errCookieMismatch = &FatalError{Err: errors.New("client+server cookie does not match")}
|
||||||
|
//nolint:err113
|
||||||
|
errIdentityNoPSK = &FatalError{Err: errors.New("PSK Identity Hint provided but PSK is nil")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidCertificate = &FatalError{Err: errors.New("no certificate provided")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidCipherSuite = &FatalError{Err: errors.New("invalid or unknown cipher suite")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidClientAuthType = &FatalError{Err: errors.New("invalid client auth type")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidECDSASignature = &FatalError{Err: errors.New("ECDSA signature contained zero or negative values")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidPrivateKey = &FatalError{Err: errors.New("invalid private key type")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidSignatureAlgorithm = &FatalError{Err: errors.New("invalid signature algorithm")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidExtendedMasterSecretType = &FatalError{Err: errors.New("invalid extended master secret type")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidCertificateSignatureAlgorithm = &FatalError{
|
||||||
|
Err: errors.New("certificate uses a signature algorithm that is not allowed"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errKeySignatureMismatch = &FatalError{Err: errors.New("expected and actual key signature do not match")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidCertificateOID = &FatalError{Err: errors.New("certificate OID does not match signature algorithm")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilNextConn = &FatalError{Err: errors.New("Conn can not be created with a nil nextConn")}
|
||||||
|
//nolint:err113
|
||||||
|
errNoAvailableCipherSuites = &FatalError{
|
||||||
|
Err: errors.New("connection can not be created, no CipherSuites satisfy this Config"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNoAvailablePSKCipherSuite = &FatalError{
|
||||||
|
Err: errors.New("connection can not be created, pre-shared key present but no compatible CipherSuite"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNoAvailableCertificateCipherSuite = &FatalError{
|
||||||
|
Err: errors.New("connection can not be created, certificate present but no compatible CipherSuite"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNoAvailableSignatureSchemes = &FatalError{
|
||||||
|
Err: errors.New("connection can not be created, no SignatureScheme satisfy this Config"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNoCertificates = &FatalError{Err: errors.New("no certificates configured")}
|
||||||
|
//nolint:err113
|
||||||
|
errNoConfigProvided = &FatalError{Err: errors.New("no config provided")}
|
||||||
|
//nolint:err113
|
||||||
|
errNoSupportedEllipticCurves = &FatalError{
|
||||||
|
Err: errors.New("client requested zero or more elliptic curves that are not supported by the server"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errUnsupportedProtocolVersion = &FatalError{Err: errors.New("unsupported protocol version")}
|
||||||
|
//nolint:err113
|
||||||
|
errPSKAndIdentityMustBeSetForClient = &FatalError{
|
||||||
|
Err: errors.New("PSK and PSK Identity Hint must both be set for client"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errRequestedButNoSRTPExtension = &FatalError{
|
||||||
|
Err: errors.New("SRTP support was requested but server did not respond with use_srtp extension"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errServerNoMatchingSRTPProfile = &FatalError{Err: errors.New("client requested SRTP but we have no matching profiles")}
|
||||||
|
//nolint:err113
|
||||||
|
errServerRequiredButNoClientEMS = &FatalError{
|
||||||
|
Err: errors.New("server requires the Extended Master Secret extension, but the client does not support it"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errVerifyDataMismatch = &FatalError{Err: errors.New("expected and actual verify data does not match")}
|
||||||
|
//nolint:err113
|
||||||
|
errNotAcceptableCertificateChain = &FatalError{Err: errors.New("certificate chain is not signed by an acceptable CA")}
|
||||||
|
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidFlight = &InternalError{Err: errors.New("invalid flight number")}
|
||||||
|
//nolint:err113
|
||||||
|
errKeySignatureGenerateUnimplemented = &InternalError{
|
||||||
|
Err: errors.New("unable to generate key signature, unimplemented"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errKeySignatureVerifyUnimplemented = &InternalError{Err: errors.New("unable to verify key signature, unimplemented")}
|
||||||
|
//nolint:err113
|
||||||
|
errLengthMismatch = &InternalError{Err: errors.New("data length and declared length do not match")}
|
||||||
|
//nolint:err113
|
||||||
|
errSequenceNumberOverflow = &InternalError{Err: errors.New("sequence number overflow")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidFSMTransition = &InternalError{Err: errors.New("invalid state machine transition")}
|
||||||
|
//nolint:err113
|
||||||
|
errFailedToAccessPoolReadBuffer = &InternalError{Err: errors.New("failed to access pool read buffer")}
|
||||||
|
//nolint:err113
|
||||||
|
errFragmentBufferOverflow = &InternalError{Err: errors.New("fragment buffer overflow")}
|
||||||
|
|
||||||
|
//nolint:err113
|
||||||
|
errEmptyCertificates = &FatalError{Err: errors.New("certificates option requires at least one certificate")}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptyCipherSuites = &FatalError{Err: errors.New("cipher suites option requires at least one cipher suite")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilCustomCipherSuites = &FatalError{Err: errors.New("custom cipher suites option requires a non-nil function")}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptySignatureSchemes = &FatalError{Err: errors.New("signature schemes option requires at least one scheme")}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptyCertificateSignatureSchemes = &FatalError{
|
||||||
|
Err: errors.New("certificate signature schemes option requires at least one scheme"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptySRTPProtectionProfiles = &FatalError{
|
||||||
|
Err: errors.New("SRTP protection profiles option requires at least one profile"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidFlightInterval = &FatalError{Err: errors.New("flight interval must be positive")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilPSKCallback = &FatalError{Err: errors.New("PSK option requires a non-nil callback")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilVerifyPeerCertificate = &FatalError{
|
||||||
|
Err: errors.New("verify peer certificate option requires a non-nil callback"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilVerifyConnection = &FatalError{Err: errors.New("verify connection option requires a non-nil callback")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidMTU = &FatalError{Err: errors.New("MTU must be positive")}
|
||||||
|
//nolint:err113
|
||||||
|
errInvalidReplayProtectionWindow = &FatalError{Err: errors.New("replay protection window must be non-negative")}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptySupportedProtocols = &FatalError{
|
||||||
|
Err: errors.New("supported protocols option requires at least one protocol"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errEmptyEllipticCurves = &FatalError{Err: errors.New("elliptic curves option requires at least one curve")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilGetClientCertificate = &FatalError{
|
||||||
|
Err: errors.New("get client certificate option requires a non-nil callback"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilConnectionIDGenerator = &FatalError{
|
||||||
|
Err: errors.New("connection ID generator option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilPaddingLengthGenerator = &FatalError{
|
||||||
|
Err: errors.New("padding length generator option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilHelloRandomBytesGenerator = &FatalError{
|
||||||
|
Err: errors.New("hello random bytes generator option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilClientHelloMessageHook = &FatalError{
|
||||||
|
Err: errors.New("client hello message hook option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilGetCertificate = &FatalError{Err: errors.New("get certificate option requires a non-nil callback")}
|
||||||
|
//nolint:err113
|
||||||
|
errNilServerHelloMessageHook = &FatalError{
|
||||||
|
Err: errors.New("server hello message hook option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilCertificateRequestMessageHook = &FatalError{
|
||||||
|
Err: errors.New("certificate request message hook option requires a non-nil function"),
|
||||||
|
}
|
||||||
|
//nolint:err113
|
||||||
|
errNilOnConnectionAttempt = &FatalError{
|
||||||
|
Err: errors.New("on connection attempt option requires a non-nil callback"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// FatalError indicates that the DTLS connection is no longer available.
|
||||||
|
// It is mainly caused by wrong configuration of server or client.
|
||||||
|
type FatalError = protocol.FatalError
|
||||||
|
|
||||||
|
// InternalError indicates and internal error caused by the implementation,
|
||||||
|
// and the DTLS connection is no longer available.
|
||||||
|
// It is mainly caused by bugs or tried to use unimplemented features.
|
||||||
|
type InternalError = protocol.InternalError
|
||||||
|
|
||||||
|
// TemporaryError indicates that the DTLS connection is still available, but the request was failed temporary.
|
||||||
|
type TemporaryError = protocol.TemporaryError
|
||||||
|
|
||||||
|
// TimeoutError indicates that the request was timed out.
|
||||||
|
type TimeoutError = protocol.TimeoutError
|
||||||
|
|
||||||
|
// HandshakeError indicates that the handshake failed.
|
||||||
|
type HandshakeError = protocol.HandshakeError
|
||||||
|
|
||||||
|
// errInvalidCipherSuite indicates an attempt at using an unsupported cipher suite.
|
||||||
|
type invalidCipherSuiteError struct {
|
||||||
|
id CipherSuiteID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invalidCipherSuiteError) Error() string {
|
||||||
|
return fmt.Sprintf("CipherSuite with id(%d) is not valid", e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invalidCipherSuiteError) Is(err error) bool {
|
||||||
|
var other *invalidCipherSuiteError
|
||||||
|
if errors.As(err, &other) {
|
||||||
|
return e.id == other.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// errAlert wraps DTLS alert notification as an error.
|
||||||
|
type alertError struct {
|
||||||
|
*alert.Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertError) Error() string {
|
||||||
|
return fmt.Sprintf("alert: %s", e.Alert.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertError) IsFatalOrCloseNotify() bool {
|
||||||
|
return e.Level == alert.Fatal || e.Description == alert.CloseNotify
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertError) Is(err error) bool {
|
||||||
|
var other *alertError
|
||||||
|
if errors.As(err, &other) {
|
||||||
|
return e.Level == other.Level && e.Description == other.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// netError translates an error from underlying Conn to corresponding net.Error.
|
||||||
|
func netError(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||||
|
// Return io.EOF and context errors as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ne net.Error
|
||||||
|
opError *net.OpError
|
||||||
|
se *os.SyscallError
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.As(err, &opError) { //nolint:nestif
|
||||||
|
if errors.As(opError, &se) {
|
||||||
|
if se.Timeout() {
|
||||||
|
return &TimeoutError{Err: err}
|
||||||
|
}
|
||||||
|
if isOpErrorTemporary(se) {
|
||||||
|
return &TemporaryError{Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.As(err, &ne) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FatalError{Err: err}
|
||||||
|
}
|
||||||
22
vendor/github.com/pion/dtls/v3/errors_errno.go
generated
vendored
Normal file
22
vendor/github.com/pion/dtls/v3/errors_errno.go
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build aix || darwin || dragonfly || freebsd || linux || nacl || nacljs || netbsd || openbsd || solaris || windows
|
||||||
|
// +build aix darwin dragonfly freebsd linux nacl nacljs netbsd openbsd solaris windows
|
||||||
|
|
||||||
|
// For systems having syscall.Errno.
|
||||||
|
// Update build targets by following command:
|
||||||
|
// $ grep -R ECONN $(go env GOROOT)/src/syscall/zerrors_*.go \
|
||||||
|
// | tr "." "_" | cut -d"_" -f"2" | sort | uniq
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isOpErrorTemporary(err *os.SyscallError) bool {
|
||||||
|
return errors.Is(err.Err, syscall.ECONNREFUSED)
|
||||||
|
}
|
||||||
18
vendor/github.com/pion/dtls/v3/errors_noerrno.go
generated
vendored
Normal file
18
vendor/github.com/pion/dtls/v3/errors_noerrno.go
generated
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !nacl && !nacljs && !netbsd && !openbsd && !solaris && !windows
|
||||||
|
// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!nacl,!nacljs,!netbsd,!openbsd,!solaris,!windows
|
||||||
|
|
||||||
|
// For systems without syscall.Errno.
|
||||||
|
// Build targets must be inverse of errors_errno.go
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isOpErrorTemporary(err *os.SyscallError) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
104
vendor/github.com/pion/dtls/v3/flight.go
generated
vendored
Normal file
104
vendor/github.com/pion/dtls/v3/flight.go
generated
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package dtls
|
||||||
|
|
||||||
|
/*
|
||||||
|
DTLS messages are grouped into a series of message flights, according
|
||||||
|
to the diagrams below. Although each flight of messages may consist
|
||||||
|
of a number of messages, they should be viewed as monolithic for the
|
||||||
|
purpose of timeout and retransmission.
|
||||||
|
https://tools.ietf.org/html/rfc4347#section-4.2.4
|
||||||
|
|
||||||
|
Message flights for full handshake:
|
||||||
|
|
||||||
|
Client Server
|
||||||
|
------ ------
|
||||||
|
Waiting Flight 0
|
||||||
|
|
||||||
|
ClientHello --------> Flight 1
|
||||||
|
|
||||||
|
<------- HelloVerifyRequest Flight 2
|
||||||
|
|
||||||
|
ClientHello --------> Flight 3
|
||||||
|
|
||||||
|
ServerHello \
|
||||||
|
Certificate* \
|
||||||
|
ServerKeyExchange* Flight 4
|
||||||
|
CertificateRequest* /
|
||||||
|
<-------- ServerHelloDone /
|
||||||
|
|
||||||
|
Certificate* \
|
||||||
|
ClientKeyExchange \
|
||||||
|
CertificateVerify* Flight 5
|
||||||
|
[ChangeCipherSpec] /
|
||||||
|
Finished --------> /
|
||||||
|
|
||||||
|
[ChangeCipherSpec] \ Flight 6
|
||||||
|
<-------- Finished /
|
||||||
|
|
||||||
|
Message flights for session-resuming handshake (no cookie exchange):
|
||||||
|
|
||||||
|
Client Server
|
||||||
|
------ ------
|
||||||
|
Waiting Flight 0
|
||||||
|
|
||||||
|
ClientHello --------> Flight 1
|
||||||
|
|
||||||
|
ServerHello \
|
||||||
|
[ChangeCipherSpec] Flight 4b
|
||||||
|
<-------- Finished /
|
||||||
|
|
||||||
|
[ChangeCipherSpec] \ Flight 5b
|
||||||
|
Finished --------> /
|
||||||
|
|
||||||
|
[ChangeCipherSpec] \ Flight 6
|
||||||
|
<-------- Finished /
|
||||||
|
*/
|
||||||
|
|
||||||
|
type flightVal uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
flight0 flightVal = iota + 1
|
||||||
|
flight1
|
||||||
|
flight2
|
||||||
|
flight3
|
||||||
|
flight4
|
||||||
|
flight4b
|
||||||
|
flight5
|
||||||
|
flight5b
|
||||||
|
flight6
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f flightVal) String() string { //nolint:cyclop
|
||||||
|
switch f {
|
||||||
|
case flight0:
|
||||||
|
return "Flight 0"
|
||||||
|
case flight1:
|
||||||
|
return "Flight 1"
|
||||||
|
case flight2:
|
||||||
|
return "Flight 2"
|
||||||
|
case flight3:
|
||||||
|
return "Flight 3"
|
||||||
|
case flight4:
|
||||||
|
return "Flight 4"
|
||||||
|
case flight4b:
|
||||||
|
return "Flight 4b"
|
||||||
|
case flight5:
|
||||||
|
return "Flight 5"
|
||||||
|
case flight5b:
|
||||||
|
return "Flight 5b"
|
||||||
|
case flight6:
|
||||||
|
return "Flight 6"
|
||||||
|
default:
|
||||||
|
return "Invalid Flight"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f flightVal) isLastSendFlight() bool {
|
||||||
|
return f == flight6 || f == flight5b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f flightVal) isLastRecvFlight() bool {
|
||||||
|
return f == flight5 || f == flight4b
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue