Compare commits

...

76 commits

Author SHA1 Message Date
8557a1c65e webrtc: add GET /webrtc/stats endpoint and SetWHIPHandler (issue #24)
Some checks failed
ci / vet + build (push) Failing after 5m7s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-10 21:28:24 -04:00
b3e667c835 webrtc: add TestSubsystem_ICEServers_OperatorOverride test for issue #23
Some checks failed
ci / vet + build (push) Failing after 5m3s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-10 21:16:26 -04:00
4364d9176f webrtc: apply operator ICEServers override in subsystem.New for issue #23
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:15:42 -04:00
b6d2a77f8b config: register CORE_WEBRTC_ICE_SERVERS and copy slice in Clone for issue #23
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:14:50 -04:00
9aaba9bdf6 config: add ICEServers []string to DataWebRTC for issue #23
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:12:45 -04:00
278ebaa087 webrtc: add 409 single-publisher enforcement test and SetMetrics/PublisherCount tests (issues #22, #26)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:08:41 -04:00
498aaefa0f webrtc: wire met field + real recordRequest + trackICE into WHIPHandler (issue #22)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:08:03 -04:00
eaa798e77b webrtc: add WHIP request metrics to webrtcMetrics, expose SetMetrics on WHIPHandler (issue #22)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:06:37 -04:00
07db6ebb4e docs: add v0.4.0-dragonfork CHANGELOG entry (issues #18-#21)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 21:06:07 -04:00
429ff9b595 docs: add v0.4.0-dragonfork CHANGELOG entry (issues #18–#21)
Some checks failed
ci / vet + build (push) Failing after 5m2s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-10 20:37:31 -04:00
1648deccf4 webrtc: add whip_handler_test.go — Publish/Unpublish/TrickleIngest/CORS tests (issue #21)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 20:35:16 -04:00
f1062a4c36 webrtc: emit RFC 9261 §5.2 Link headers on WHIP 201 Publish response (issue #21)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 20:34:47 -04:00
6ec0328b19 webrtc: wire full NAT1To1IPs list into core config, replace single-IP workaround (issue #20)
Some checks failed
ci / vet + build (push) Failing after 5m2s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-10 14:02:57 -04:00
841335d14b webrtc: add NAT1To1IPs multi-IP and fallback tests (issue #20)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 14:02:10 -04:00
b045b26f17 webrtc: BuildICEConfig uses NAT1To1IPs list, falls back to PublicIP (issue #20)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 14:01:53 -04:00
57542a3d80 webrtc: add NAT1To1IPs []string to Config for multi-IP support (issue #20)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 14:01:40 -04:00
10eaaff6b7 webrtc: tests for ICEServerURIs and Link CORS exposure (issue #19)
Some checks failed
ci / vet + build (push) Failing after 5m9s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-10 13:32:43 -04:00
5f4ac74080 webrtc: emit RFC 9429 §4.3 Link headers on WHEP 201 response (issue #19)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 13:31:52 -04:00
e257deb744 webrtc: expose ICEServerURIs on Subsystem for WHEP Link header (issue #19)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-10 13:30:35 -04:00
38d75b10b0 test(webrtc): add STAP-A IDR detection tests (issue #18)
Some checks failed
ci / vet + build (push) Failing after 5m2s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
Three new test functions covering the STAP-A (NAL type 24) packetisation
mode added to isH264IDRStart:
- LeadingIDR: STAP-A where first NAL is type 5 → true
- LeadingNonIDR: STAP-A where first NAL is SPS (type 7) → false
- Truncated: STAP-A with < 4 bytes → false, no panic
2026-05-10 13:20:59 -04:00
8266ca72e6 fix(webrtc): detect STAP-A IDR start in keyframe cache (issue #18)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Extend isH264IDRStart to handle STAP-A aggregates (NAL type 24, RFC 6184
§5.7.1). The first NAL in the aggregate starts at byte 3 (after the
2-byte size field); if its type is 5 (IDR slice) the packet is treated
as an IDR start and the burst cache is reset.

This closes the gap noted in NOTES.md: a publisher using STAP-A for IDR
(e.g. a custom GStreamer pipeline or hardware encoder) will now correctly
reset the burst rather than accumulating packets until hitting the 512-
packet / 2 MiB capacity cap.
2026-05-10 13:19:56 -04:00
891f65dff6 docs: add v0.2 and v0.3 implementation notes
Some checks failed
ci / vet + build (push) Failing after 5m0s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
Documents the SDK gap and monkey-patch workaround, seed-data.sh
no-clobber problem, keyframe cache lock ordering rationale, and
the STAP-A IDR detection gap.
2026-05-10 13:07:04 -04:00
c4857f5581 docs: add v0.3.0-dragonfork CHANGELOG entry
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Covers WHIP ingest backend, keyframe cache, Wild Dragon UI WHIP toggle,
seed-data.sh always-overwrite fix, and the full core/webrtc test suite.
2026-05-10 13:06:06 -04:00
228ed4b09b test(webrtc): Source Subscribe pre-fill, Close, and EnableKeyFrameCache
Some checks failed
ci / vet + build (push) Failing after 5m0s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
Covers:
- Subscribe pre-fills channel from IDR cache immediately on call
- No pre-fill when cache is not enabled
- Labeled-break stops pre-fill when bufDepth < burst length
- Close closes all subscriber channels (no goroutine leak)
- EnableKeyFrameCache is idempotent (second call is a no-op)
2026-05-10 09:23:40 -04:00
293536563f test(webrtc): unit tests for keyFrameCache and isH264IDRStart
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Covers:
- isH264IDRStart: empty, single-NAL IDR (type 5), single-NAL non-IDR
  (SPS/PPS/P-frame), FU-A start IDR, FU-A start non-IDR, FU-A
  continuation, truncated FU-A, Opus payload
- push/snapshot: IDR reset, burst accumulation, double-IDR reset
- Capacity caps: maxPackets, maxBytes
- Snapshot independence: copy isolated from subsequent mutations
- Concurrent safety: 1 writer + 4 readers (-race clean)
2026-05-10 09:23:12 -04:00
7490edd770 feat(webrtc): enable keyframe cache on video sources (issue #17)
Some checks failed
ci / vet + build (push) Failing after 5m1s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
Call videoSrc.EnableKeyFrameCache() immediately after binding the video
UDP Source in allocAdjacentPair(). Audio sources are intentionally left
uncached — IDR detection on Opus packets would accumulate the entire
audio stream without ever resetting the burst.
2026-05-09 19:05:11 -04:00
020a1800ce feat(webrtc): wire keyframe cache into Source (issue #17)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
- Add cache *keyFrameCache field to Source (nil by default).
- Add EnableKeyFrameCache() — call before Start() on video sources to
  activate IDR burst caching.
- readLoop: call cache.push(pkt) after each successful unmarshal, before
  the subscriber fanout. No lock held at push time — push acquires its
  own mutex internally.
- Subscribe: snapshot the cache outside s.mu to avoid any cross-lock
  complexity, then pre-fill the new channel with the burst before
  registering it in the subscriber set. Uses a labeled break to stop
  pre-filling if the channel is full (bufDepth too small for the burst;
  the subscriber will wait for the next live keyframe instead).
2026-05-09 19:04:17 -04:00
a2e0a8c083 feat(webrtc): add H.264 keyframe burst cache (issue #17)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Introduces keyFrameCache — a bounded ring buffer that retains all RTP
packets from the most recent H.264 IDR NAL unit until the packet just
before the next one. New WHEP subscribers receive this burst immediately
on Subscribe(), cutting first-frame latency from up to one IDR interval
(typically ~2 s at GOP=60/30fps) to nearly zero.

Design notes:
- Detection covers single-NAL (type 5) and FU-A start (type 28, start
  bit set, inner type 5). STAP-A IDR leading is not handled — FFmpeg
  never uses STAP-A for IDR slices in practice.
- Bounded at 512 packets / 2 MiB per source to cap memory per stream.
- push() is called only from the single-goroutine readLoop; the lock
  it holds is tiny and brief.
- snapshot() returns a shallow copy; *rtp.Packet values are immutable
  after being placed in the cache so sharing is safe.
2026-05-09 19:03:33 -04:00
353fa0f3f3 seed-data.sh: always refresh static/ and index.html from image
Some checks failed
ci / vet + build (push) Failing after 5m7s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
The React bundle hash changes every time the UI is rebuilt. With the old
no-clobber approach, a redeployed container kept serving the old bundle
because the static/ directory already existed on the data volume.

Fix: always overwrite index.html, asset-manifest.json, and the entire
static/ subdirectory from /core/static on every container start. These
are build artifacts (not operator-edited content) so overwriting is safe.
All other top-level entries (channels/, config/, etc.) remain no-clobber.
2026-05-09 17:21:05 -04:00
4d94c88d74 Add ProcessConfigWHIPIngest to API layer with Marshal/Unmarshal wiring
Some checks failed
ci / vet + build (push) Failing after 5m1s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
Adds ProcessConfigWHIPIngest struct to http/api/process.go and wires it
into ProcessConfig.Marshal() and ProcessConfig.Unmarshal() so that the
WHIPIngest.Enabled flag can be set via API and correctly propagates to
app.Config.WHIPIngest, which is checked by onWHIPProcessStart in
app/webrtc/whip_lifecycle.go.

Without this change, app.Config.WHIPIngest.Enabled was always false and
WHIP ingest could never activate regardless of what the UI sent.
2026-05-09 17:01:49 -04:00
4ac63ddfc6 feat(whip): wire WHIPHandler into API — struct field, MergedHooks, NewWHIPHandler, serverConfig, cleanup
Some checks failed
ci / vet + build (push) Failing after 5m0s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-09 16:46:49 -04:00
7f545962f6 fix(whip): wire teardown hook in NewWHIPHandler constructor (mirrors WHEP NewHandler pattern)
Some checks failed
ci / vet + build (push) Failing after 5m1s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-09 16:38:29 -04:00
1be78a8185 feat(whip): wire WHIPHandler into HTTP server Config and v3 routes
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:35:48 -04:00
a22b8c68f0 fix(whip): clean up lifecycle — proper net import and checkPortFree implementation
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:34:20 -04:00
b1057756d2 fix(whip): rewrite lifecycle hooks — correct port allocation, clean FFmpeg RTP input legs
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:33:35 -04:00
4bef6563c7 feat(whip): extend Subsystem with WHIP ingest state, lookupIngest, WHIPHooks
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:32:49 -04:00
6c9d1864dd feat(restream): extend ProcessHooks with OnInputStart for WHIP ingest input legs
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:32:03 -04:00
ca3501f888 feat(whip): add WHIP process lifecycle hooks — port allocation and FFmpeg RTP input legs
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:27:25 -04:00
d72aa8afe1 feat(whip): add WHIPHandler — Echo HTTP handler for WHIP ingest endpoints
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:26:42 -04:00
2a4c8d5f52 fix(docker): chmod apply-overlay.sh before execution (exit 126)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-09 16:25:28 -04:00
01c456cd1a feat(core/webrtc): add IngestPeer for WHIP publish side (issue #16)
Some checks failed
ci / vet + build (push) Failing after 5m3s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
IngestPeer is the symmetric inverse of the WHEP Peer:
- Creates a recvonly PeerConnection (Pion receives tracks from the publisher)
- OnTrack -> reads RTP packets from the remote track and writes them to
  loopback UDP ports (videoPort, audioPort) that FFmpeg is listening on
- Full lifecycle: Done(), Connected(), Close(), AddICECandidate()

PeerFactory.CreateIngestPeer() follows the same ctx/offer/ICE-gather
pattern as CreatePeerFromSources() so the app/webrtc handler layer can
use a uniform error matrix.
2026-05-09 16:20:09 -04:00
5f9ba6f764 feat(config): add ConfigWHIPIngest to per-process config (issue #16)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Adds the ConfigWHIPIngest struct alongside the existing ConfigWebRTC.
When Enabled=true the app/webrtc subsystem (next commit) will prepend
RTP UDP input legs to the FFmpeg command, binding on loopback ports
that WHIP publisher peers write received WebRTC tracks to.
2026-05-09 16:18:43 -04:00
890b09a33c fix(build): remove promauto dependency, use explicit reg.MustRegister
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
promauto is not in the vendor tree. Replace promauto.With(reg).NewXxx()
with prometheus.NewXxx() + reg.MustRegister() — functionally identical
but uses only the already-vendored prometheus/client_golang/prometheus
package. Fixes the vendor-mode build error:

  cannot find module providing package .../prometheus/promauto
2026-05-09 16:16:31 -04:00
70d0ddb2e3 gui: redesign WebRTC admin page with Wild Dragon brand
Some checks failed
ci / vet + build (push) Failing after 4m50s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
- Rajdhani + JetBrains Mono typefaces via Google Fonts
- Deep dark palette: #09090d bg, #ff5c28 accent
- 22px dot-grid background texture
- Sticky frosted-glass header with WD monogram SVG
- Wild Dragon wordmark SVG on login panel
- Process cards with border-left state indicator (green/amber/red)
- Animated pulse dots on state badges
- Cleaner WHEP URL row with Copy + Open buttons
- Log panels hidden until first entry (no empty box on load)
- All JS functionality preserved (JWT auth, toggle+restart, WHEP copy)
2026-05-09 12:27:08 -04:00
dd639b697f feat(ui): source UI build from wilddragon-restreamer-ui fork (issue #15)
Some checks failed
ci / vet + build (push) Failing after 4m49s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-06 16:21:23 -04:00
917225c994 chore: create test/load/results/ directory for load test reports
Some checks failed
ci / vet + build (push) Failing after 4m50s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped
2026-05-06 16:04:13 -04:00
9e9c7eb8f1 docs: update README quick-start with Prometheus/Grafana and Docker publish
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 16:04:02 -04:00
55b61dd0e5 docs: update CHANGELOG for v0.2 backlog work (closes #11, #12, #13, #14)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 16:03:09 -04:00
561a93e044 feat(test): add 5-peer sustained WHEP load test (closes #14)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 16:01:22 -04:00
60f64fe76b feat(ci): add Docker image publish workflow on tag push (closes #12)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 16:00:32 -04:00
28a280b9b3 feat(deploy): add Prometheus + Grafana observability stack (closes #11)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 16:00:15 -04:00
4beab3423d feat(deploy): add Grafana WebRTC health dashboard
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:59:56 -04:00
6b637a35e6 feat(deploy): add Grafana dashboard provisioning config
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:59:24 -04:00
7471507be7 feat(deploy): add Grafana Prometheus datasource provisioning
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:59:18 -04:00
e8f39daa75 feat(deploy): add WebRTC Prometheus alert rules
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:59:11 -04:00
4b8d9f0e8c feat(deploy): add Prometheus scrape config
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:59:00 -04:00
1748f9102d test(webrtc): add metrics unit tests
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:58:50 -04:00
47a28bf9d4 feat(webrtc): instrument WHEP handler with Prometheus metrics
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:58:26 -04:00
1d7cd5b520 feat(webrtc): add StreamCount() for metrics snapshot
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:57:13 -04:00
15af16ce97 test(prometheus): add WebRTC snapshot collector unit tests
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:56:43 -04:00
23636e4a76 feat(prometheus): add WebRTC snapshot collector
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:56:27 -04:00
eaf62b7397 feat(webrtc): add WebRTC Prometheus metrics (direct instrumentation)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:56:12 -04:00
70324aad28 feat(webrtc): add Connected() channel to Peer for ICE establishment timing
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:55:42 -04:00
2283a32f2a docs: add upstream rebase policy (closes #13)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-06 15:55:00 -04:00
99c568e53e Update rsLogo component to import from local images directory
Some checks failed
ci / vet + build (push) Successful in 9m49s
ci / race tests (push) Failing after 8m16s
ci / WebRTC smoke (5-viewer fanout) (push) Successful in 9m58s
ci / WebRTC latency p95 gate (push) Successful in 10m7s
2026-05-03 23:26:20 -04:00
6c3f887faa Update Logo component to import from local images directory
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-03 23:26:13 -04:00
6449f65468 feat(branding): replace placeholder logo192 with real Wild Dragon logo
Some checks failed
ci / vet + build (push) Successful in 9m51s
ci / race tests (push) Failing after 8m11s
ci / WebRTC smoke (5-viewer fanout) (push) Successful in 10m1s
ci / WebRTC latency p95 gate (push) Successful in 10m10s
2026-05-03 17:25:42 -04:00
9a618f0b70 docs(readme): mention the GUI surface in the quick-start
Some checks failed
ci / vet + build (push) Successful in 9m50s
ci / race tests (push) Failing after 8m8s
ci / WebRTC smoke (5-viewer fanout) (push) Successful in 9m54s
ci / WebRTC latency p95 gate (push) Successful in 10m4s
Users running v0.2 already have a full UI; calling it out so it isn't
just discovered by accident.
2026-05-03 16:34:44 -04:00
86a5a50dec docs(deploy): document the GUI surface (Restreamer UI + Wild Dragon WebRTC admin)
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
2026-05-03 16:33:48 -04:00
2d2bd0e5c6 docs(changelog): v0.2.0-dragonfork — GUI ship
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Calls out the Restreamer UI bundle (which has been in the deploy
bundle since M2 but undocumented) and the new wilddragon-webrtc.html
admin page.
2026-05-03 16:32:56 -04:00
27cc39dab0 feat(deploy): add Wild Dragon WebRTC admin page
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
Single-file HTML/JS admin page seeded into /core/data alongside
whep-player.html. Lets an operator log in with the API_AUTH_USERNAME
+ API_AUTH_PASSWORD creds, list every process, and toggle webrtc.enabled
per process with a single button. WHEP URL displayed for enabled
processes with a one-click "open in WHEP player" link.

Closes the v0.1 GUI gap: the upstream Restreamer UI we ship doesn't
know about Core's webrtc config block, so toggling WebRTC required
direct API calls. This page is the user-friendly path. Reachable at
/wilddragon-webrtc.html on any deploy.

No build step — drops in via the existing seed-data.sh flow.
2026-05-03 16:31:13 -04:00
949daa26b5 docs(design): WebRTC Prometheus metrics + Grafana stack design
Some checks failed
ci / vet + build (push) Successful in 9m51s
ci / race tests (push) Failing after 8m5s
ci / WebRTC smoke (5-viewer fanout) (push) Successful in 9m53s
ci / WebRTC latency p95 gate (push) Successful in 10m4s
Closes the v0.1 observability gap. Eleven new metrics in the
dragonfork_webrtc_* namespace (RED-method on the WHEP surface plus
state gauges from the WebRTC subsystem), Prom + Grafana containers
added to deploy/truenas/core/, four pre-loaded alert rules, one
pre-provisioned dashboard.

Hybrid instrumentation: direct client_golang in app/webrtc/ for
hot-path counters and histograms; snapshot collector in
prometheus/webrtc.go for slow-changing gauges. Rationale and
trade-offs against the upstream monitor/metric bus pattern documented
in the Approach section.

Targets v0.2.0-dragonfork.
2026-05-03 14:50:56 -04:00
75afcbc0d1 deploy(compose): pass RTMP/SRT/TLS port overrides through from .env
Some checks failed
ci / vet + build (push) Successful in 9m50s
ci / race tests (push) Failing after 6m30s
ci / WebRTC smoke (5-viewer fanout) (push) Successful in 9m47s
ci / WebRTC latency p95 gate (push) Successful in 10m2s
The compose file's environment: block only forwarded the variables it
explicitly referenced — CORE_ADDRESS, CORE_API_AUTH_*, CORE_WEBRTC_*,
CORE_LOG_LEVEL. Everything else got the upstream Core defaults
regardless of what was in .env. So 'CORE_RTMP_ADDRESS=:1937' in .env
was silently ignored and Core kept binding 1935.

Hit on the live TrueNAS host where another datarhei/restreamer
container was already on 1935 with active stream state — couldn't
just stop it. Adding explicit env passthrough for the four common
collision points (RTMP, RTMPS, SRT, TLS) so an operator can remap
each individually without editing this file:

  CORE_RTMP_ADDRESS=:1937
  CORE_RTMP_ADDRESS_TLS=:1938
  CORE_SRT_ADDRESS=:6002
  CORE_TLS_ADDRESS=:8183

Defaults are unchanged — empty .env keeps :1935/:1936/:6000/:8181.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 13:30:02 +00:00
7621f88fea feat(ui): Wild Dragon reskin overlay on the Restreamer UI
Some checks are pending
ci / vet + build (push) Waiting to run
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
Layers Wild Dragon branding on top of upstream restreamer-ui v1.14.0
without forking the whole repo — keeps upstream UI updates flowing in
when we bump RESTREAMER_UI_REF.

Overlay (deploy/truenas/core/ui-overlay/):
  public/index.html       Wild Dragon title, theme color #0d0e12
  public/manifest.json    PWA name/short_name/colors
  public/favicon.ico      multi-res ICO (16/32/64) generated from
                          a 'WD' monogram in orange #ff6633 on dark
  public/logo192.png      Apple touch icon
  public/logo512.png      PWA install icon
  src/misc/Logo/images/   rs-logo.svg (square mark, used in the
                          Header) and logo.svg (wordmark, used in
                          the Footer) — both Wild-Dragon-themed
  src/misc/Logo/{index,rsLogo}.js
                          link the logos to forge.wilddragon.net
                          instead of datarhei.com

apply-overlay.sh runs in the Docker ui-builder stage just after the
upstream git clone and just before yarn install. Two phases:
  1. rsync the overlay's public/ and src/ on top of the cloned
     upstream tree
  2. Targeted in-place patches for one-line UI strings (header
     title, two welcome captions). Each patch is anchored to a
     unique surrounding context and the script fails loudly if the
     anchor isn't present — so a future upstream rename surfaces
     immediately rather than silently shipping un-rebranded UI.

Image size: ~+50KB (the overlay assets), no measurable build-time
delta. PWA installs and OS bookmarks now show Wild Dragon. The
remaining 'Restreamer'/'datarhei' references in views/Welcome.js,
views/Login.js, views/Settings.js, etc. are deeper-page strings
that aren't worth a one-off overlay; they'll go away when we fork
the UI repo properly for the WebRTC tab milestone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 13:14:41 +00:00
10f3e20a6a fix(deploy): make seed-data.sh recursive for directory entries
Some checks are pending
ci / vet + build (push) Waiting to run
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
The Restreamer UI bundle includes subdirectories (_player,
_playersite, static, locales) and the Dockerfile copies the whole
tree into /core/static. seed-data.sh on first boot was using flat
'cp -p' which errors on directories with 'omitting directory ...';
set -e then exits, the container restarts forever in a crash loop,
and Core never starts.

Fix: 'cp -Rp' so directories are copied as trees. The no-clobber
check on the top-level name still keeps operator-edited content
safe — if /core/data/_player exists we don't replace it, even if
its internals diverge from the bundled version.

Also defends against dotfiles via the second glob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 13:01:51 +00:00
26991ec463 deploy: bundle the official Datarhei Restreamer UI
Some checks are pending
ci / vet + build (push) Waiting to run
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
Replaces the placeholder Dragon Fork landing page at / with the real
React SPA — the same UI that ships in upstream's datarhei/restreamer
image. Operators get the full process management dashboard, log
viewer, restream config, and so on.

Implementation: a new Docker stage 'ui-builder' (node:21-alpine3.20)
clones datarhei/restreamer-ui at a pinned tag (v1.14.0), runs
'yarn install + yarn build' with PUBLIC_URL="./" so all asset
references are relative, and the runtime stage pulls /ui/build into
/core/static. The existing seed-data.sh script then copies it into
/core/data on first boot.

Stacking order in /core/static:
  1. UI bundle from ui-builder — provides index.html, the SPA bundle
     and assets, _player, _playersite, etc.
  2. Dragon Fork deploy/static/* — currently only whep-player.html;
     the placeholder index.html was removed so the UI's wins.

Pinned to v1.14.0 (the most recent tagged restreamer-ui release)
rather than 'main' for reproducible builds. Bumping the pin is a
one-line ARG override.

Image size: ~+25MB compressed (Restreamer UI bundle is ~3MB
gzipped, plus the build-stage layer overhead until pruned).

UI-side configuration: the SPA defaults to talking to the
same-origin /api endpoints, which is exactly what we want when
serving from Core. No '?address=' query string needed on the URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 12:58:51 +00:00
59 changed files with 5996 additions and 487 deletions

View file

@ -0,0 +1,79 @@
# Forgejo Actions — Docker image publish for Dragon Fork.
#
# Triggers on semver tags (v*.*.*-dragonfork or v*.*.*). Builds a
# multi-arch image (linux/amd64 + linux/arm64) and pushes to the
# configured registry. The image name and registry are controlled by
# repository variables:
#
# REGISTRY — e.g. ghcr.io or registry.wilddragon.net
# IMAGE_NAME — e.g. zgaetano/dragonfork-core (defaults to repo name)
#
# The push credential must be stored as a repository secret:
# REGISTRY_TOKEN — password / token for the registry user
# REGISTRY_USER — registry username (defaults to repo owner)
#
# Quick-start after setting the variables/secrets:
# git tag v0.2.0-dragonfork && git push origin v0.2.0-dragonfork
name: publish
on:
push:
tags:
- 'v*.*.*'
- 'v*.*.*-dragonfork'
jobs:
build-and-push:
name: Build and push multi-arch image
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU (for arm64 cross-build)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Derive image metadata
id: meta
run: |
REGISTRY="${{ vars.REGISTRY || 'ghcr.io' }}"
IMAGE="${{ vars.IMAGE_NAME || github.repository }}"
TAG="${GITHUB_REF_NAME}"
# Normalise: strip leading 'v' for the semver part, keep full tag too
SEMVER="${TAG#v}"
echo "image=${REGISTRY}/${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT"
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY || 'ghcr.io' }}
username: ${{ vars.REGISTRY_USER || github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }}
${{ steps.meta.outputs.image }}:latest
labels: |
org.opencontainers.image.title=Dragon Fork Core
org.opencontainers.image.description=Datarhei Core with WebRTC egress
org.opencontainers.image.version=${{ steps.meta.outputs.semver }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1,5 +1,260 @@
# Datarhei — Dragon Fork # Datarhei — Dragon Fork
## v0.4.0-dragonfork (2026-05-10)
WebRTC protocol compliance milestone. Focuses on correctness and operator
experience: complete H.264 packetisation coverage, RFC-compliant signalling
headers, proper multi-homed NAT support, and comprehensive test coverage
for both WHEP and WHIP.
Resolves issues #18, #19, #20, #21.
### Added
- **STAP-A IDR detection** (`core/webrtc/keyframecache.go`). The H.264
keyframe cache now handles all three RTP packetisation modes for IDR
slices: single-NAL (type 5), FU-A start (type 28 with start bit),
and STAP-A aggregation packets (type 24, first inner NAL type 5).
Without this, an encoder that sends the SPS/PPS/IDR as a single STAP-A
aggregate would never trigger a cache reset and late-joining subscribers
would not receive a reference frame. Closes #18.
- Four new tests in `keyframecache_test.go` exercise STAP-A leading IDR,
non-IDR first NAL, and truncated (1-/2-/3-byte) payloads.
- **WHEP Link headers — RFC 9429 §4.3** (`app/webrtc/handler.go`).
The WHEP 201 Subscribe response now emits one `Link: <uri>; rel="ice-server"`
header per configured ICE server URI. Browsers can discover STUN/TURN
endpoints from the response without a separate signalling round-trip.
`addCORS()` updated to expose `Link` in `Access-Control-Expose-Headers`.
`ICEServerURIs() []string` added to `Subsystem`. Closes #19.
- Tests: `TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs`,
`TestAddCORS_ExposesLinkHeader` in `handler_test.go`.
- **Multi-IP NAT1To1 support** (`core/webrtc/config.go`,
`core/webrtc/ice.go`, `app/webrtc/subsystem.go`). The NAT1To1 config now
accepts a list of IPs (`NAT1To1IPs []string`) instead of a single string.
Pion's `SetNAT1To1IPs` receives the full list so dual-homed servers
(e.g., LAN + public) advertise host candidates on all interfaces.
`BuildICEConfig` falls back to the legacy `PublicIP` field as a
single-element list for backward compatibility. Subsystem merges
`PublicIP` + `NAT1To1IPs` with deduplication. Closes #20.
- Five new tests in `ice_test.go`: multi-IP list, PublicIP fallback,
both-set, neither-set, invalid config rejection.
- **WHIP Link headers — RFC 9261 §5.2** (`app/webrtc/whip_handler.go`).
Symmetric with the WHEP change: the WHIP 201 Publish response now emits
`Link` headers for each ICE server, allowing OBS, GStreamer, and
browser-based publishers to discover STUN/TURN from the offer response.
Closes #21.
- **WHIP handler test suite** (`app/webrtc/whip_handler_test.go`, new).
Nine tests covering Publish/Unpublish/TrickleIngest routes and CORS
preflight. Verifies 404 on missing ingest, 400 on bad body, 204 on
idempotent DELETE of unknown resource, 404 on PATCH to unknown peer,
and Link/Location/ETag present in CORS expose-headers.
### Upgrade (from v0.3)
No config changes required. The new `NAT1To1IPs` field in `DataWebRTC`
defaults to empty, preserving the existing single-IP behaviour via `PublicIP`.
---
## v0.3.0-dragonfork (2026-05-10)
WebRTC ingest (WHIP) milestone. Browsers and OBS can now push a
WebRTC stream into a channel, and the first-frame experience for WHEP
viewers is dramatically improved by the in-memory keyframe cache.
Resolves issues #15, #16, #17.
### Added
- **WHIP ingest path** — browsers and OBS Studio can push a WebRTC
stream (H.264 + Opus) into any Dragon Fork channel via
`POST /api/v3/whip/{id}`. The publisher sends an SDP offer; Core
answers, allocates a loopback UDP pair, and injects RTP input legs
into the FFmpeg command line — the exact mirror of the WHEP egress
path. `DELETE /api/v3/whip/{id}/{resource}` tears down the publisher
cleanly. Closes #16.
- **`ProcessConfigWHIPIngest` API struct** in `http/api/process.go`
mapping `whip_ingest.{enabled,video_pt,audio_pt}` between the JSON
API and `app.ConfigWHIPIngest`. Without this struct, `WHIPIngest.Enabled`
was always false and WHIP could never activate via the API.
- **WHIP ingest lifecycle hooks**`onWHIPProcessStart` /
`onWHIPProcessStop` in `app/webrtc/whip_lifecycle.go` allocate and
teardown the ingest UDP port pair, controlled by the per-process
`whip_ingest.enabled` flag. Merged via `MergedHooks()` alongside the
existing WHEP egress hooks.
- **Wild Dragon UI — WHIP toggle control** (`overlay/src/misc/controls/WHIP.js`
in the `wilddragon-restreamer-ui` overlay). Mirrors WHEP.js exactly.
Renders an Enable checkbox with caption in the channel edit view.
- **Wild Dragon UI — Edit/index.js wiring** — renders the WHIP control
in the Edit view and patches `props.restreamer._upsertProcess` in the
`save()` handler to inject `whip_ingest.enabled` into the process
config before the SDK PUT reaches Core. The patch is required because
the Restreamer SDK's `UpsertIngest` does not forward `webrtc` or
`whip_ingest` fields (SDK gap).
- **In-memory H.264 keyframe cache** in `core/webrtc/keyframecache.go`.
Retains the most recent IDR burst (all RTP packets from the first IDR
NAL fragment until the next one) per video Source. Bounded at 512
packets / 2 MiB. Detects single-NAL IDR (type 5) and FU-A start
fragments (type 28, start bit set, inner type 5). Closes #17.
- **Subscribe pre-fill**`Source.Subscribe()` snapshots the keyframe
cache before registering the new subscriber, then drains the burst
into the channel immediately. New WHEP peers receive a complete
reference frame on join instead of waiting up to one GOP (≈ 2 s at
30 fps / GOP=60).
- **`Source.EnableKeyFrameCache()`** — opt-in method; called only on
video sources in `allocAdjacentPair()`. Audio sources are
intentionally uncached (Opus payloads would accumulate without ever
triggering a reset).
- **Test suite for `core/webrtc`**`keyframecache_test.go` (18
functions) and `source_test.go` (5 functions). Covers IDR detection
in all packetisation modes, cache reset, burst accumulation, capacity
caps, snapshot independence, concurrent read/write under `-race`, and
Subscribe pre-fill behaviour. All 34 tests in `core/webrtc` green
under `go test -race`.
### Fixed
- **`deploy/truenas/core/seed-data.sh`** — the old no-clobber-only
approach kept stale JS bundles alive on the data volume after image
rebuilds (`static/` was never refreshed because it already existed).
Fixed by splitting into two phases: always-overwrite for `index.html`,
`asset-manifest.json`, and `static/`; no-clobber for everything else
(channel data, player bundles, operator content). Prevents a class of
"new code never runs" deployment bugs.
### Upgrade (from v0.2)
```sh
cd deploy/truenas/core
git pull
docker compose build --no-cache core
docker compose up -d core
```
The `seed-data.sh` fix means there is no longer a need to manually
`docker exec` a static-bundle copy after rebuilds — it happens
automatically on container start.
---
## v0.2 backlog (2026-05-06)
Completes the open v0.2 issues from the post-GUI-ship backlog.
Resolves issues #11, #12, #13, #14.
### Added
- **WebRTC Prometheus metrics** — eleven metrics in the
`dragonfork_webrtc_*` namespace using RED-method principles.
Hybrid instrumentation: direct `client_golang` counters/histograms
for hot-path WHEP routes and ICE establishment in `app/webrtc/metrics.go`,
plus a snapshot collector for gauges in `prometheus/webrtc.go`.
Metrics: `whep_requests_total`, `whep_request_duration_seconds`,
`ice_establishment_duration_seconds`, `ice_failures_total`,
`codec_mismatches_total`, `cap_rejections_total`,
`ffmpeg_leg_failures_total`, `active_streams`, `active_peers`,
`udp_ports_in_use`. Closes #11.
- **Grafana observability stack** in `deploy/truenas/core/`:\n Prometheus v2.55 and Grafana OSS 11.3 containers on a `dragonfork-mon`
bridge network reaching Core via `host.docker.internal`. Pre-loaded
WebRTC Health dashboard (5 rows: WHEP API, ICE, streams/peers, capacity,
silent-degradation canary). Four pre-loaded Prometheus alert rules.
Deploy upgrade: add `GRAFANA_ADMIN_PASSWORD` to `.env`,
`docker compose pull && docker compose up -d`. Closes #11.
- **Docker image CI publish workflow** at `.forgejo/workflows/publish.yml`.
Triggers on semver tags. Builds multi-arch (`linux/amd64` + `linux/arm64`)
and pushes to the configured registry (`REGISTRY` repo variable,
defaults to `ghcr.io`). Requires `REGISTRY_TOKEN` secret and optional
`REGISTRY_USER` / `IMAGE_NAME` variables. Layer cache via GitHub Actions
cache. Closes #12.
- **Upstream rebase policy** at `docs/REBASE.md`. Documents monthly
cadence, rebase-not-merge strategy, Dragon Fork divergence boundaries,
pre/post-rebase checklist, vendored-dependency procedure, first-rebase
runbook, and record-keeping table. First rebase against upstream is
pending (to be run locally per the procedure in `docs/REBASE.md`).
Closes #13.
- **WHEP sustained load test** at `test/load/sustained.go`.
Headless Go program (`//go:build ignore`, run with `go run`) that drives
N concurrent WHEP subscribers against a single stream for a configurable
duration. Measures: ICE establishment (p50/p95), jitter (RFC 3550 running
average), packet loss estimate (sequence-number gaps), packets received.
Outputs a markdown report to `test/load/results/`. Staggered connection
setup, trickle-ICE, and graceful DELETE on teardown. Closes #14.
- **`core/webrtc.Peer.Connected()` channel** — closed on first
`PeerConnectionStateConnected` event. Required by the ICE establishment
histogram (allows async measurement after the WHEP POST returns).
### Changed
- `deploy/truenas/core/docker-compose.yml`: adds `prom` and `grafana`
services + `dragonfork-mon` bridge network + named volumes. `core`
service is unchanged (stays on `network_mode: host`).
- `app/webrtc/handler.go`: WHEP route handlers now record request duration,
status code, codec mismatch, and cap rejection metrics. `tearDownStreamPeers`
records FFmpeg leg failures when peers were active at stop time.
- `app/webrtc/subsystem.go`: adds `StreamCount()` accessor for the
snapshot collector.
### Upgrade (from v0.2.0-dragonfork)
```sh
cd deploy/truenas/core
git pull
# Add new lines to .env:
# GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
# GRAFANA_PORT=3000
# PROM_PORT=9090
docker compose pull # pulls prom + grafana images
docker compose up -d # core unchanged, prom + grafana start fresh
```
---
## v0.2.0-dragonfork (2026-05-03)
The "GUI ship" release. Everything from v0.1 is preserved; this round
documents and ships a usable graphical surface for the WebRTC feature
that v0.1 only exposed through the API.
### Added
- **Wild Dragon WebRTC admin page** at `/wilddragon-webrtc.html`. Single-file
HTML/JS; no build step. Sign in with the API_AUTH_USERNAME / PASSWORD
creds, see every process, toggle `webrtc.enabled` per-process with one
click, restart on change, copy the WHEP URL, jump straight to the
smoke player. Closes the v0.1 GUI gap — the upstream Restreamer UI
ships with v0.2 but doesn't know about Core's `webrtc` config block,
so toggling WebRTC previously required direct API calls.
### Documented (was present, just unannounced)
- **Restreamer UI bundle** in the TrueNAS deploy. The `deploy/truenas/core/`
Dockerfile builds the upstream `datarhei/restreamer-ui` v1.14.0 React
bundle with the Wild Dragon overlay applied (logo / favicon / header
title / welcome card), copies the result into Core's disk filesystem
via `seed-data.sh`, and Core serves it at `/`. Was added during M2
but not called out in the v0.1 CHANGELOG.
- **WHEP smoke player** at `/whep-player.html`. Standalone WebRTC
subscriber with ICE/codec/bitrate diagnostics. Was added during M4.
---
## v0.1.0-dragonfork (2026-05-03) ## v0.1.0-dragonfork (2026-05-03)
The first tagged Dragon Fork release. Forked from upstream datarhei The first tagged Dragon Fork release. Forked from upstream datarhei
@ -43,25 +298,9 @@ WebRTC (WHEP) egress, integrated with the existing process supervisor.
### Fixed ### Fixed
- `Config.Clone()` now preserves the `WebRTC` section. Pre-fix, - `Config.Clone()` now preserves the `WebRTC` section.
`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 - `http/api.ProcessConfig` Marshal/Unmarshal now carry the per-process
`webrtc` block. Pre-fix, `POST /api/v3/process` silently dropped `webrtc` block.
`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`.
--- ---
@ -122,83 +361,3 @@ Built on upstream Datarhei Core (Apache 2.0) and Pion WebRTC v4
- Fix URL validation if the path contains FFmpeg specific placeholders - Fix URL validation if the path contains FFmpeg specific placeholders
- Fix RTMP DoS attack (thx Johannes Frank) - Fix RTMP DoS attack (thx Johannes Frank)
- Deprecate ENV names that do not correspond to JSON name - Deprecate ENV names that do not correspond to JSON name
### Core v16.11.0 > v16.12.0
- Add S3 storage support
- Add support for variables in placeholde parameter
- Add support for RTMP token as stream key as last element in path
- Add support for soft memory limit with debug.memory_limit_mbytes in config
- Add support for partial process config updates
- Add support for alternative syntax for auth0 tenants as environment variable
- Fix config timestamps created_at and loaded_at
- Fix /config/reload return type
- Fix modifying DTS in RTMP packets ([restreamer/#487](https://github.com/datarhei/restreamer/issues/487), [restreamer/#367](https://github.com/datarhei/restreamer/issues/367))
- Fix default internal SRT latency to 20ms
### Core v16.10.1 > v16.11.0
- Add FFmpeg 4.4 to FFmpeg 5.1 migration tool
- Add alternative SRT streamid
- Mod bump FFmpeg to v5.1.2 (datarhei/core:tag bundles)
- Fix crash with custom SSL certificates ([restreamer/#425](https://github.com/datarhei/restreamer/issues/425))
- Fix proper version handling for config
- Fix widged session data
- Fix resetting process stats when process stopped
- Fix stale FFmpeg process detection for streams with only audio
- Fix wrong return status code ([#6](https://github.com/datarhei/core/issues/6)))
- Fix use SRT defaults for key material exchange
### Core v16.10.0 > v16.10.1
- Add email address in TLS config for Let's Encrypt
- Fix use of Let's Encrypt production CA
### Core v16.9.1 > v16.10.0
- Add HLS session middleware to diskfs
- Add /v3/metrics (get) endpoint to list all known metrics
- Add logging HTTP request and response body sizes
- Add process id and reference glob pattern matching
- Add cache block list for extensions not to cache
- Mod exclude .m3u8 and .mpd files from disk cache by default
- Mod replaces x/crypto/acme/autocert with caddyserver/certmagic
- Mod exposes ports (Docker desktop)
- Fix assigning cleanup rules for diskfs
- Fix wrong path for swagger definition
- Fix process cleanup on delete, remove empty directories from disk
- Fix SRT blocking port on restart (upgrade datarhei/gosrt)
- Fix RTMP communication (Blackmagic Web Presenter, thx 235 MEDIA)
- Fix RTMP communication (Blackmagic ATEM Mini, [#385](https://github.com/datarhei/restreamer/issues/385))
- Fix injecting commit, branch, and build info
- Fix API metadata endpoints responses
#### Core v16.9.0 > v16.9.1^
- Fix v1 import app
- Fix race condition
#### Core v16.8.0 > v16.9.0
- Add new placeholders and parameters for placeholder
- Allow RTMP server if RTMPS server is enabled. In case you already had RTMPS enabled it will listen on the same port as before. An RTMP server will be started additionally listening on a lower port number. The RTMP app is required to start with a slash.
- Add optional escape character to process placeholder
- Fix output address validation for tee outputs
- Fix updating process config
- Add experimental SRT connection stats and logs API
- Hide /config/reload endpoint in reade-only mode
- Add experimental SRT server (datarhei/gosrt)
- Create v16 in go.mod
- Fix data races, tests, lint, and update dependencies
- Add trailing slash for routed directories (datarhei/restreamer#340)
- Allow relative URLs in content in static routes
#### Core v16.7.2 > v16.8.0
- Add purge_on_delete function
- Mod updated dependencies
- Mod updated API docs
- Fix disabled session logging
- Fix FFmpeg skills reload
- Fix ignores processes with invalid references (thx Patron Ramakrishna Chillara)
- Fix code scanning alerts

View file

@ -23,4 +23,95 @@ adds a new section.
--- ---
<!-- Add M1 verification notes here after Task 12 succeeds. --> ## v0.2 (2026-05-06)
### Restreamer SDK gap — `UpsertIngest` does not forward `webrtc` or `whip_ingest`
The datarhei Restreamer SDK's `UpsertIngest` builds the FFmpeg process config
from `control.hls`, `control.rtmp`, `control.srt`, and `control.process`, but
silently discards `control.webrtc` and `control.whip_ingest`. This was confirmed
by inspecting the minified SDK bundle inside the running container.
**Implication:** toggling WebRTC egress or WHIP ingest from the Restreamer UI
required a monkey-patch. The Edit view (`overlay/src/views/Edit/index.js`)
overrides `props.restreamer._upsertProcess` immediately before the
`UpsertIngest` call to inject `whip_ingest.enabled` (and in future,
`webrtc.enabled`) into the process config JSON, then restores the original
method in a `finally` block. The patch is narrow and reversible.
**Why not patch the SDK?** The SDK is a vendored minified bundle inside the
upstream Restreamer UI. Patching it would require either maintaining a fork of
the minifier input (the full SDK source is not in the UI repo) or deobfuscating
and re-minifying. The monkey-patch is pragmatic and self-documenting.
### UI state uses `enable` (no d); Core API uses `enabled` (with d)
WHEP.js and WHIP.js use `enable` (matching existing convention in the UI
controls). The monkey-patch performs the mapping:
```js
config.whip_ingest = { enabled: !!(control.whip_ingest && control.whip_ingest.enable) };
```
If this causes confusion in the future, unify on `enabled` in both places.
---
## v0.3 (2026-05-10)
### `ProcessConfigWHIPIngest` was the critical backend gap
`http/api/process.go` had `ProcessConfigWebRTC` for WHEP but no corresponding
struct for WHIP. Without it, the JSON field `whip_ingest` was dropped on
unmarshal and `app.Config.WHIPIngest.Enabled` was always `false`. WHIP
could never activate via the API regardless of what the UI sent. Adding
`ProcessConfigWHIPIngest` and wiring it into `Marshal()`/`Unmarshal()` was
the essential fix (commit `4d94c88`).
### seed-data.sh no-clobber bug
`seed-data.sh` used `cp -n` (no-clobber) for all files including the built
JS bundle. On first deploy the `static/` directory doesn't exist and the copy
succeeds. On every subsequent deploy the directory already exists and the copy
is skipped — so a rebuilt container silently serves the old bundle. The symptom
was confusing: the binary had new code, but the UI didn't reflect it.
**Fix:** split into two phases. `index.html`, `asset-manifest.json`, and
`static/` are always overwritten (`cp -Rfp`). All other content (channel
database, player bundles, operator-uploaded media) remains no-clobber to
protect live data.
### Keyframe cache lock ordering
`keyFrameCache` has its own mutex `c.mu` distinct from `Source.mu`. The two
locks are never nested:
- `readLoop` calls `cache.push(pkt)` (acquires/releases `c.mu`), then
acquires `s.mu` for the subscriber fanout — sequential, not nested.
- `Source.Subscribe()` takes the snapshot outside `s.mu` (acquires/releases
`c.mu`), then acquires `s.mu` to register the subscriber — also sequential.
This means there is no deadlock risk even though two separate mutexes are
involved. The snapshot-before-lock pattern in `Subscribe` was chosen for
clarity, not necessity; but it documents the intent explicitly.
### STAP-A IDR detection is not implemented
`isH264IDRStart` handles single-NAL (type 5) and FU-A start (type 28, start
bit, inner type 5). It does **not** handle STAP-A aggregates (type 24) that
happen to lead with an IDR NAL.
In practice, FFmpeg and GStreamer never emit IDR slices inside STAP-A — IDR
frames are large and STAP-A is designed for small NALs (SPS + PPS combos).
If a publisher that does use STAP-A for IDR ever appears, the cache will miss
the keyframe boundary; the worst outcome is a larger-than-expected burst (the
cache grows until the next correctly-detected IDR) rather than a crash or
incorrect video. Add STAP-A handling in a future revision if needed.
### `go test -race ./core/webrtc/...` baseline
All 34 tests in `core/webrtc` pass under the race detector as of v0.3
(commit `228ed4b`). The suite covers config, ICE, registry, peer creation,
WHEP handler, keyframe cache, and Source subscribe/pre-fill/close. Total
runtime ≈ 16 s (dominated by the two 5-second ICE gathering timeouts in
`TestPeerFactory_*`).

View file

@ -18,8 +18,10 @@ publisher (OBS / FFmpeg / SRT) ──▶ datarhei Core ──▶ WebRTC peers
Sub-second glass-to-glass on a LAN over WHEP, no SFU dependencies, Sub-second glass-to-glass on a LAN over WHEP, no SFU dependencies,
single binary, single Docker image. single binary, single Docker image.
> **Status:** M1M4 complete, M5 (release) in flight. Live deploy > **Status:** v0.2 in progress (last work 2026-05-06). Full GUI bundled
> running on TrueNAS since 2026-04-17. > (Restreamer UI + Wild Dragon WebRTC admin). Prometheus + Grafana
> observability stack shipped. Live deploy running on TrueNAS since
> 2026-04-17.
## What this fork adds ## What this fork adds
@ -34,9 +36,15 @@ single binary, single Docker image.
- **`DELETE /api/v3/whep/{processID}/{resourceID}`** — idempotent - **`DELETE /api/v3/whep/{processID}/{resourceID}`** — idempotent
teardown. teardown.
- **`PATCH …/{resourceID}`** — trickle ICE. - **`PATCH …/{resourceID}`** — trickle ICE.
- **Browser-side smoke player** at `test/whep-player.html` - **Bundled GUI** — the upstream Restreamer React UI is built into the
zero-dependency WHEP subscriber, ICE/codec/bitrate stats, JWT TrueNAS deploy image with Wild Dragon branding, plus a single-file
field, shareable `?url=&token=` URLs. Wild Dragon WebRTC admin page for one-click `webrtc.enabled` toggling.
- **Browser-side smoke player** at `whep-player.html` — zero-dependency
WHEP subscriber, ICE/codec/bitrate stats, JWT field, shareable
`?url=&token=` URLs.
- **Prometheus observability** — eleven `dragonfork_webrtc_*` metrics
(RED-method counters/histograms + state gauges). Grafana health
dashboard with 5 rows and 4 pre-loaded alert rules.
- **Multi-viewer correctness:** per-stream peer cap, ICE-failure - **Multi-viewer correctness:** per-stream peer cap, ICE-failure
auto-cleanup, process-stop broadcast tear-down. auto-cleanup, process-stop broadcast tear-down.
- **Error matrix** per the design spec: `406` on codec mismatch, - **Error matrix** per the design spec: `406` on codec mismatch,
@ -60,15 +68,35 @@ CORE_HTTP_PORT=8080
API_AUTH_USERNAME=admin API_AUTH_USERNAME=admin
API_AUTH_PASSWORD=$(openssl rand -base64 24) API_AUTH_PASSWORD=$(openssl rand -base64 24)
API_AUTH_JWT_SECRET=$(openssl rand -base64 48) API_AUTH_JWT_SECRET=$(openssl rand -base64 48)
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
GRAFANA_PORT=3000
PROM_PORT=9090
EOF EOF
docker compose up -d --build docker compose up -d --build
``` ```
Then: Then open in a browser (replace `<host>` with your `PUBLIC_IP`):
- Swagger UI: `http://<host>:8080/api/swagger/index.html` | URL | What it does |
- WHEP smoke player: open `test/whep-player.html` in a browser | --- | --- |
| `http://<host>:8080/` | **Restreamer UI** — manage processes, ingests, outputs |
| `http://<host>:8080/wilddragon-webrtc.html` | **Wild Dragon WebRTC admin** — toggle `webrtc.enabled` per process, copy WHEP URL |
| `http://<host>:8080/whep-player.html` | **WHEP smoke player** — verify the WebRTC stream renders |
| `http://<host>:3000/` | **Grafana** — WebRTC Health dashboard (login with `GRAFANA_ADMIN_PASSWORD`) |
| `http://<host>:9090/` | **Prometheus** — raw metrics, alert rules |
| `http://<host>:8080/api/swagger/index.html` | **Swagger** — full API docs |
The Restreamer UI doesn't yet have a WebRTC checkbox in its process
editor — use `/wilddragon-webrtc.html` for that. Tracked in issue #15.
### Pulling a pre-built image (after first tag is published)
```sh
# Update .env, then:
docker compose pull # pulls pre-built multi-arch image
docker compose up -d # no --build needed
```
### Sample process JSON ### Sample process JSON
@ -108,6 +136,9 @@ SDI + file audio), use the `video_map` and `audio_map` fields:
| Design spec | [`docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md`](docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md) | | 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) | | 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) | | 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) |
| Prometheus metrics design | [`docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md`](docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md) |
| Upstream rebase policy | [`docs/REBASE.md`](docs/REBASE.md) |
| TrueNAS deploy guide | [`deploy/truenas/core/README.md`](deploy/truenas/core/README.md) |
| Testing | [`test/TESTING.md`](test/TESTING.md) | | Testing | [`test/TESTING.md`](test/TESTING.md) |
| Changelog (Dragon Fork) | [`CHANGELOG.md`](CHANGELOG.md) | | Changelog (Dragon Fork) | [`CHANGELOG.md`](CHANGELOG.md) |
| Upstream Datarhei docs | [docs.datarhei.com/core](https://docs.datarhei.com/core) | | Upstream Datarhei docs | [docs.datarhei.com/core](https://docs.datarhei.com/core) |
@ -123,6 +154,21 @@ go test -tags latency -timeout 90s -count=1 \
-run TestLatencyServerHop ./app/webrtc/... # latency p95 gate -run TestLatencyServerHop ./app/webrtc/... # latency p95 gate
``` ```
## Load testing
```sh
go run ./test/load/sustained.go \
-url http://<host>:8080 \
-stream <processID> \
-peers 5 \
-duration 10m \
-auth "Bearer <TOKEN>" \
-out test/load/results/
```
Reports are written to `test/load/results/`. Observe the Grafana
WebRTC Health dashboard during the run.
## From upstream Datarhei ## From upstream Datarhei
This fork preserves everything upstream Datarhei Core does — Dragon This fork preserves everything upstream Datarhei Core does — Dragon
@ -142,6 +188,8 @@ Dragon Fork is built on:
- **datarhei Core** — Apache 2.0, © datarhei. The base repository this - **datarhei Core** — Apache 2.0, © datarhei. The base repository this
fork tracks. See [`NOTICE`](NOTICE) for the required attribution. fork tracks. See [`NOTICE`](NOTICE) for the required attribution.
- **datarhei Restreamer UI** — Apache 2.0, © datarhei. The React frontend
bundled into the TrueNAS deploy image with Wild Dragon overlays.
- **Pion WebRTC** — MIT. The Go WebRTC stack the egress path is built - **Pion WebRTC** — MIT. The Go WebRTC stack the egress path is built
on. on.
- **FFmpeg** — LGPL / GPL (build-flag dependent). Used as a subprocess - **FFmpeg** — LGPL / GPL (build-flag dependent). Used as a subprocess

View file

@ -76,6 +76,7 @@ type api struct {
srtserver srt.Server srtserver srt.Server
webrtcsub *appwebrtc.Subsystem webrtcsub *appwebrtc.Subsystem
webrtchandler *appwebrtc.Handler webrtchandler *appwebrtc.Handler
whiphandler *appwebrtc.WHIPHandler
metrics monitor.HistoryMonitor metrics monitor.HistoryMonitor
prom prometheus.Metrics prom prometheus.Metrics
service service.Service service service.Service
@ -632,9 +633,10 @@ func (a *api) start() error {
if werr != nil { if werr != nil {
a.log.logger.core.Warn().WithError(werr).Log("WebRTC subsystem disabled: construction failed") a.log.logger.core.Warn().WithError(werr).Log("WebRTC subsystem disabled: construction failed")
} else { } else {
a.restream.SetHooks(webrtcSub.Hooks()) a.restream.SetHooks(webrtcSub.MergedHooks())
a.webrtcsub = webrtcSub a.webrtcsub = webrtcSub
a.webrtchandler = appwebrtc.NewHandler(webrtcSub, 0) a.webrtchandler = appwebrtc.NewHandler(webrtcSub, 0)
a.whiphandler = appwebrtc.NewWHIPHandler(webrtcSub, 0)
} }
} }
@ -1036,6 +1038,7 @@ func (a *api) start() error {
RTMP: a.rtmpserver, RTMP: a.rtmpserver,
SRT: a.srtserver, SRT: a.srtserver,
WebRTC: a.webrtchandler, WebRTC: a.webrtchandler,
WHIP: a.whiphandler,
JWT: a.httpjwt, JWT: a.httpjwt,
Config: a.config.store, Config: a.config.store,
Sessions: a.sessions, Sessions: a.sessions,
@ -1382,6 +1385,10 @@ func (a *api) stop() {
a.webrtchandler.Close() a.webrtchandler.Close()
a.webrtchandler = nil a.webrtchandler = nil
} }
if a.whiphandler != nil {
a.whiphandler.Close()
a.whiphandler = nil
}
if a.webrtcsub != nil { if a.webrtcsub != nil {
a.webrtcsub.Close() a.webrtcsub.Close()
a.webrtcsub = nil a.webrtcsub = nil

View file

@ -1,11 +1,13 @@
package webrtc package webrtc
import ( import (
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
@ -17,16 +19,31 @@ import (
// (passed to NewHandler) is enforced separately and takes precedence. // (passed to NewHandler) is enforced separately and takes precedence.
const defaultMaxPeersPerStream = 8 const defaultMaxPeersPerStream = 8
// WebRTCStats is the JSON response for GET /webrtc/stats.
type WebRTCStats struct {
// ActiveStreams is the number of running FFmpeg processes with a
// registered WHEP egress pair (video + audio Sources).
ActiveStreams int `json:"active_streams"`
// ActivePeers is the total count of live WHEP subscriber sessions
// (each call to Subscribe that has not yet been torn down).
ActivePeers int64 `json:"active_peers"`
// ActivePublishers is the total count of live WHIP ingest sessions
// (each call to WHIPHandler.Publish that has not yet been unpublished).
ActivePublishers int64 `json:"active_publishers"`
// UDPPortsInUse is an approximation of the number of UDP ports
// allocated for ICE traffic. When using ephemeral ports (default)
// each stream uses two ports (one video, one audio).
UDPPortsInUse int `json:"udp_ports_in_use"`
}
// Handler exposes the subsystem's WHEP Echo handlers. Wire them into // Handler exposes the subsystem's WHEP Echo handlers. Wire them into
// the /api/v3 group (or a sibling group) via Handler.Register. // 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 { type Handler struct {
sub *Subsystem sub *Subsystem
whip *WHIPHandler // optional; enables active_publishers in /webrtc/stats
mu sync.Mutex mu sync.Mutex
peersByStream map[string]map[string]*corewebrtc.Peer // streamID -> resource -> peer peersByStream map[string]map[string]*corewebrtc.Peer // streamID -> resource -> peer
@ -34,19 +51,16 @@ type Handler struct {
count int64 // atomic count int64 // atomic
maxCapTotal int64 maxCapTotal int64
maxCapPerStrm int64 maxCapPerStrm int64
met *webrtcMetrics
} }
// NewHandler wraps the subsystem in an Echo-compatible HTTP handler. // 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 { func NewHandler(s *Subsystem, maxPeers int) *Handler {
return NewHandlerWithCaps(s, maxPeers, 0) return NewHandlerWithCaps(s, maxPeers, 0)
} }
// NewHandlerWithCaps is NewHandler plus an explicit per-stream cap. // NewHandlerWithCaps is NewHandler plus an explicit per-stream cap.
// maxPeersPerStream <= 0 falls back to defaultMaxPeersPerStream.
func NewHandlerWithCaps(s *Subsystem, maxPeers, maxPeersPerStream int) *Handler { func NewHandlerWithCaps(s *Subsystem, maxPeers, maxPeersPerStream int) *Handler {
total := int64(maxPeers) total := int64(maxPeers)
if total <= 0 { if total <= 0 {
@ -63,21 +77,31 @@ func NewHandlerWithCaps(s *Subsystem, maxPeers, maxPeersPerStream int) *Handler
maxCapTotal: total, maxCapTotal: total,
maxCapPerStrm: perStream, 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 { if s != nil {
s.SetTeardownHook(h.tearDownStreamPeers) s.SetTeardownHook(h.tearDownStreamPeers)
} }
return h return h
} }
// Register mounts the WHEP routes on the provided Echo group. // SetWHIPHandler links the WHIP ingest handler so that /webrtc/stats
// can report active_publishers. Pass nil to disable that field (returns 0).
func (h *Handler) SetWHIPHandler(wh *WHIPHandler) {
h.whip = wh
}
// Register mounts the WHEP routes and the shared stats route on the
// provided Echo group.
// //
// CORS preflights are answered on every WHEP path; regular WHEP // Routes registered:
// responses also carry the Access-Control-* headers so browser-side //
// players living on a different origin can subscribe. // GET /webrtc/stats
// OPTIONS /whep/:id
// OPTIONS /whep/:id/:resource
// POST /whep/:id
// DELETE /whep/:id/:resource
// PATCH /whep/:id/:resource
func (h *Handler) Register(g *echo.Group) { func (h *Handler) Register(g *echo.Group) {
g.GET("/webrtc/stats", h.StatsHandler)
g.OPTIONS("/whep/:id", h.preflight) g.OPTIONS("/whep/:id", h.preflight)
g.OPTIONS("/whep/:id/:resource", h.preflight) g.OPTIONS("/whep/:id/:resource", h.preflight)
g.POST("/whep/:id", h.Subscribe) g.POST("/whep/:id", h.Subscribe)
@ -85,72 +109,105 @@ func (h *Handler) Register(g *echo.Group) {
g.PATCH("/whep/:id/:resource", h.Trickle) g.PATCH("/whep/:id/:resource", h.Trickle)
} }
// Subscribe handles POST /whep/:id. Request body is an SDP offer, // StatsHandler handles GET /webrtc/stats. Returns a JSON snapshot of
// response is an SDP answer with a Location header pointing at the // the current WebRTC subsystem state.
// DELETE/PATCH resource.
// //
// @Summary Subscribe to a WebRTC stream via WHEP // @Summary WebRTC subsystem stats
// @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. // @Description Returns a live snapshot: active egress streams, subscriber peer count, ingest publisher count, and approximate UDP port usage.
// @Tags v16.16.0 // @Tags v16.16.0
// @ID webrtc-3-whep-subscribe // @ID webrtc-3-stats
// @Accept application/sdp // @Produce json
// @Produce application/sdp // @Success 200 {object} WebRTCStats
// @Param id path string true "Process ID with config.webrtc.enabled=true" // @Router /api/v3/webrtc/stats [get]
// @Success 201 {string} string "SDP answer" func (h *Handler) StatsHandler(c echo.Context) error {
// @Failure 400 {string} string "missing stream id, malformed body, or invalid SDP" sc := 0
// @Failure 404 {string} string "no stream registered for this process id" if h.sub != nil {
// @Failure 406 {string} string "offer SDP missing required H264 / Opus rtpmap" sc = h.sub.StreamCount()
// @Failure 503 {string} string "peer cap reached (per-stream or total)" }
// @Failure 504 {string} string "ICE gathering timeout"
// @Security ApiKeyAuth var publishers int64
// @Router /api/v3/whep/{id} [post] if h.whip != nil {
publishers = h.whip.PublisherCount()
}
stats := WebRTCStats{
ActiveStreams: sc,
ActivePeers: atomic.LoadInt64(&h.count),
ActivePublishers: publishers,
UDPPortsInUse: sc * 2,
}
return c.JSON(http.StatusOK, stats)
}
// Subscribe handles POST /whep/:id.
func (h *Handler) Subscribe(c echo.Context) error { func (h *Handler) Subscribe(c echo.Context) error {
addCORS(c) addCORS(c)
t0 := time.Now()
id := c.Param("id") id := c.Param("id")
if id == "" { if id == "" {
h.recordRequest("subscribe", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing stream 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 { if atomic.LoadInt64(&h.count) >= h.maxCapTotal {
if h.met != nil {
h.met.capRejections.WithLabelValues("", "global").Inc()
}
h.recordRequest("subscribe", id, http.StatusServiceUnavailable, t0)
return c.String(http.StatusServiceUnavailable, corewebrtc.ErrPeerCapReached.Error()) return c.String(http.StatusServiceUnavailable, corewebrtc.ErrPeerCapReached.Error())
} }
stream, ok := h.sub.lookup(id) stream, ok := h.sub.lookup(id)
if !ok { if !ok {
h.recordRequest("subscribe", id, http.StatusNotFound, t0)
return c.String(http.StatusNotFound, corewebrtc.ErrStreamNotFound.Error()) return c.String(http.StatusNotFound, corewebrtc.ErrStreamNotFound.Error())
} }
// Per-stream cap: needs the lock since we're indexing per stream.
h.mu.Lock() h.mu.Lock()
if int64(len(h.peersByStream[id])) >= h.maxCapPerStrm { if int64(len(h.peersByStream[id])) >= h.maxCapPerStrm {
h.mu.Unlock() h.mu.Unlock()
if h.met != nil {
h.met.capRejections.WithLabelValues(id, "stream").Inc()
}
h.recordRequest("subscribe", id, http.StatusServiceUnavailable, t0)
return c.String(http.StatusServiceUnavailable, "webrtc: per-stream peer cap reached") return c.String(http.StatusServiceUnavailable, "webrtc: per-stream peer cap reached")
} }
h.mu.Unlock() h.mu.Unlock()
body, err := io.ReadAll(c.Request().Body) body, err := io.ReadAll(c.Request().Body)
if err != nil { if err != nil {
h.recordRequest("subscribe", id, http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "read body: "+err.Error()) return c.String(http.StatusBadRequest, "read body: "+err.Error())
} }
if len(body) == 0 || !strings.HasPrefix(string(body), "v=") { if len(body) == 0 || !strings.HasPrefix(string(body), "v=") {
h.recordRequest("subscribe", id, http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error()) return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error())
} }
if err := requireH264AndOpus(string(body)); err != nil { if err := requireH264AndOpus(string(body)); err != nil {
if h.met != nil {
if cme, ok2 := err.(*codecMismatchError); ok2 {
for _, kind := range cme.missing {
h.met.codecMismatches.WithLabelValues(id, strings.ToLower(kind)).Inc()
}
}
}
h.recordRequest("subscribe", id, http.StatusNotAcceptable, t0)
return c.String(http.StatusNotAcceptable, err.Error()) return c.String(http.StatusNotAcceptable, err.Error())
} }
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)}
peer, err := h.sub.factory.CreatePeerFromSources(c.Request().Context(), stream.video, stream.audio, offer) peer, err := h.sub.factory.CreatePeerFromSources(c.Request().Context(), stream.video, stream.audio, offer)
if err != nil { if err != nil {
// Surface the design's error matrix.
switch err { switch err {
case corewebrtc.ErrICETimeout: case corewebrtc.ErrICETimeout:
h.recordRequest("subscribe", id, http.StatusGatewayTimeout, t0)
return c.String(http.StatusGatewayTimeout, err.Error()) return c.String(http.StatusGatewayTimeout, err.Error())
case corewebrtc.ErrCodecMismatch: case corewebrtc.ErrCodecMismatch:
h.recordRequest("subscribe", id, http.StatusNotAcceptable, t0)
return c.String(http.StatusNotAcceptable, err.Error()) return c.String(http.StatusNotAcceptable, err.Error())
default: default:
h.recordRequest("subscribe", id, http.StatusInternalServerError, t0)
return c.String(http.StatusInternalServerError, "create peer: "+err.Error()) return c.String(http.StatusInternalServerError, "create peer: "+err.Error())
} }
} }
@ -165,37 +222,29 @@ func (h *Handler) Subscribe(c echo.Context) error {
h.mu.Unlock() h.mu.Unlock()
atomic.AddInt64(&h.count, 1) 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) go h.awaitPeerClose(rid, peer)
go h.trackICE(id, peer, time.Now())
h.recordRequest("subscribe", id, http.StatusCreated, t0)
// RFC 9429 §4.3: emit one Link header per configured ICE server.
for _, uri := range h.sub.ICEServerURIs() {
c.Response().Header().Add("Link", "<"+uri+`>; rel="ice-server"`)
}
c.Response().Header().Set("Content-Type", "application/sdp") c.Response().Header().Set("Content-Type", "application/sdp")
c.Response().Header().Set("Location", "/whep/"+id+"/"+rid) c.Response().Header().Set("Location", "/whep/"+id+"/"+rid)
c.Response().Header().Set("ETag", `"`+rid+`"`) c.Response().Header().Set("ETag", `"`+rid+`"`)
return c.String(http.StatusCreated, peer.Answer().SDP) return c.String(http.StatusCreated, peer.Answer().SDP)
} }
// Unsubscribe handles DELETE /whep/:id/:resource. Per WHEP spec we // Unsubscribe handles DELETE /whep/:id/:resource.
// 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 { func (h *Handler) Unsubscribe(c echo.Context) error {
addCORS(c) addCORS(c)
t0 := time.Now()
resource := c.Param("resource") resource := c.Param("resource")
if resource == "" { if resource == "" {
h.recordRequest("unsubscribe", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing resource id") return c.String(http.StatusBadRequest, "missing resource id")
} }
@ -218,31 +267,19 @@ func (h *Handler) Unsubscribe(c echo.Context) error {
if streamID != "" { if streamID != "" {
atomic.AddInt64(&h.count, -1) atomic.AddInt64(&h.count, -1)
} }
h.recordRequest("unsubscribe", streamID, http.StatusNoContent, t0)
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
// Trickle handles PATCH /whep/:id/:resource — adds ICE candidates // Trickle handles PATCH /whep/:id/:resource.
// 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 { func (h *Handler) Trickle(c echo.Context) error {
addCORS(c) addCORS(c)
t0 := time.Now()
resource := c.Param("resource") resource := c.Param("resource")
if resource == "" { if resource == "" {
h.recordRequest("trickle", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing resource id") return c.String(http.StatusBadRequest, "missing resource id")
} }
@ -254,11 +291,13 @@ func (h *Handler) Trickle(c echo.Context) error {
} }
h.mu.Unlock() h.mu.Unlock()
if peer == nil { if peer == nil {
h.recordRequest("trickle", streamID, http.StatusNotFound, t0)
return c.NoContent(http.StatusNotFound) return c.NoContent(http.StatusNotFound)
} }
body, err := io.ReadAll(c.Request().Body) body, err := io.ReadAll(c.Request().Body)
if err != nil { if err != nil {
h.recordRequest("trickle", streamID, http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "read body: "+err.Error()) return c.String(http.StatusBadRequest, "read body: "+err.Error())
} }
for _, line := range strings.Split(string(body), "\n") { for _, line := range strings.Split(string(body), "\n") {
@ -269,11 +308,20 @@ func (h *Handler) Trickle(c echo.Context) error {
cand := strings.TrimPrefix(line, "a=") cand := strings.TrimPrefix(line, "a=")
_ = peer.AddICECandidate(webrtc.ICECandidateInit{Candidate: cand}) _ = peer.AddICECandidate(webrtc.ICECandidateInit{Candidate: cand})
} }
h.recordRequest("trickle", streamID, http.StatusNoContent, t0)
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
// preflight answers a CORS OPTIONS request; the headers are also func (h *Handler) recordRequest(route, streamID string, code int, t0 time.Time) {
// echoed on every other response. if h.met == nil {
return
}
codeStr := fmt.Sprintf("%d", code)
h.met.whepRequests.WithLabelValues(route, codeStr, streamID).Inc()
h.met.whepRequestDuration.WithLabelValues(route, streamID).Observe(time.Since(t0).Seconds())
}
func (h *Handler) preflight(c echo.Context) error { func (h *Handler) preflight(c echo.Context) error {
addCORS(c) addCORS(c)
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@ -300,10 +348,6 @@ func (h *Handler) Close() {
atomic.StoreInt64(&h.count, 0) 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) { func (h *Handler) awaitPeerClose(resource string, peer *corewebrtc.Peer) {
<-peer.Done() <-peer.Done()
h.mu.Lock() h.mu.Lock()
@ -324,12 +368,10 @@ func (h *Handler) awaitPeerClose(resource string, peer *corewebrtc.Peer) {
} }
} }
// 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) { func (h *Handler) tearDownStreamPeers(streamID string) {
h.mu.Lock() h.mu.Lock()
bucket := h.peersByStream[streamID] bucket := h.peersByStream[streamID]
hadPeers := len(bucket) > 0
peers := make([]*corewebrtc.Peer, 0, len(bucket)) peers := make([]*corewebrtc.Peer, 0, len(bucket))
for _, p := range bucket { for _, p := range bucket {
peers = append(peers, p) peers = append(peers, p)
@ -341,28 +383,21 @@ func (h *Handler) tearDownStreamPeers(streamID string) {
_ = p.Close() _ = p.Close()
} }
} }
if hadPeers && h.met != nil {
h.met.ffmpegLegFailures.WithLabelValues(streamID, "video").Inc()
h.met.ffmpegLegFailures.WithLabelValues(streamID, "audio").Inc()
}
} }
// 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) { func addCORS(c echo.Context) {
hh := c.Response().Header() hh := c.Response().Header()
hh.Set("Access-Control-Allow-Origin", "*") hh.Set("Access-Control-Allow-Origin", "*")
hh.Set("Access-Control-Allow-Methods", "POST, DELETE, PATCH, OPTIONS") 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-Allow-Headers", "Content-Type, Authorization, If-Match, If-None-Match")
hh.Set("Access-Control-Expose-Headers", "Location, ETag") hh.Set("Access-Control-Expose-Headers", "Location, ETag, Link")
} }
// 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 { func requireH264AndOpus(sdp string) error {
lower := strings.ToLower(sdp) lower := strings.ToLower(sdp)
hasH264 := strings.Contains(lower, "h264/90000") || strings.Contains(lower, " h264/") hasH264 := strings.Contains(lower, "h264/90000") || strings.Contains(lower, " h264/")
@ -383,5 +418,5 @@ func requireH264AndOpus(sdp string) error {
type codecMismatchError struct{ missing []string } type codecMismatchError struct{ missing []string }
func (e *codecMismatchError) Error() string { func (e *codecMismatchError) Error() string {
return "webrtc: codec mismatch offer is missing: " + strings.Join(e.missing, ", ") return "webrtc: codec mismatch -- offer is missing: " + strings.Join(e.missing, ", ")
} }

View file

@ -0,0 +1,127 @@
package webrtc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
)
// TestStatsHandler_EmptySubsystem verifies that GET /webrtc/stats returns
// a well-formed JSON body with all-zero counts when no streams or peers
// are active and no WHIP handler is linked.
func TestStatsHandler_EmptySubsystem(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.StatsHandler(c); err != nil {
t.Fatalf("StatsHandler returned error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var stats WebRTCStats
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
t.Fatalf("invalid JSON: %v\nbody: %s", err, rec.Body.String())
}
if stats.ActiveStreams != 0 {
t.Errorf("ActiveStreams: want 0, got %d", stats.ActiveStreams)
}
if stats.ActivePeers != 0 {
t.Errorf("ActivePeers: want 0, got %d", stats.ActivePeers)
}
if stats.ActivePublishers != 0 {
t.Errorf("ActivePublishers: want 0, got %d", stats.ActivePublishers)
}
if stats.UDPPortsInUse != 0 {
t.Errorf("UDPPortsInUse: want 0, got %d", stats.UDPPortsInUse)
}
}
// TestStatsHandler_WithWHIPHandler verifies that SetWHIPHandler links the
// WHIP publisher count into the stats response.
func TestStatsHandler_WithWHIPHandler(t *testing.T) {
sub := newTestSubsystem(t)
h := NewHandler(sub, 0)
// Link a real WHIPHandler so that StatsHandler calls PublisherCount().
wh := NewWHIPHandler(sub, 0)
h.SetWHIPHandler(wh)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.StatsHandler(c); err != nil {
t.Fatalf("StatsHandler returned error: %v", err)
}
var stats WebRTCStats
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// With no active publishers the count should be 0 — validates the
// link does not panic and that PublisherCount() is being called.
if stats.ActivePublishers != 0 {
t.Errorf("ActivePublishers: want 0, got %d", stats.ActivePublishers)
}
}
// TestStatsHandler_NilSub verifies that a nil Subsystem (possible during
// early wiring) does not panic and returns zeros.
func TestStatsHandler_NilSub(t *testing.T) {
h := NewHandler(nil, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.StatsHandler(c); err != nil {
t.Fatalf("StatsHandler returned error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var stats WebRTCStats
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if stats.ActiveStreams != 0 || stats.UDPPortsInUse != 0 {
t.Errorf("expected all zeros with nil sub, got %+v", stats)
}
}
// TestStatsHandler_JSONFieldNames verifies the JSON key names match the
// contract defined in the issue so consumer scripts don't break.
func TestStatsHandler_JSONFieldNames(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.StatsHandler(c); err != nil {
t.Fatalf("StatsHandler returned error: %v", err)
}
var raw map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, key := range []string{"active_streams", "active_peers", "active_publishers", "udp_ports_in_use"} {
if _, ok := raw[key]; !ok {
t.Errorf("JSON response missing required field %q", key)
}
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/datarhei/core/v16/config" "github.com/datarhei/core/v16/config"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
) )
func newTestSubsystem(t *testing.T) *Subsystem { func newTestSubsystem(t *testing.T) *Subsystem {
@ -89,3 +90,90 @@ func TestHandler_Unsubscribe_204WhenUnknown(t *testing.T) {
t.Fatalf("expected 204, got %d", rec.Code) t.Fatalf("expected 204, got %d", rec.Code)
} }
} }
// TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs verifies that
// ICEServerURIs() surfaces the URIs from the core config — the same
// values Pion uses when building its PeerConnection. A default-config
// subsystem must return at least the two bundled STUN servers.
func TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs(t *testing.T) {
sub := newTestSubsystem(t)
uris := sub.ICEServerURIs()
defaultURIs := corewebrtc.DefaultConfig().ICEServers
if len(uris) != len(defaultURIs) {
t.Fatalf("expected %d ICE server URIs, got %d", len(defaultURIs), len(uris))
}
for i, want := range defaultURIs {
if uris[i] != want {
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
}
}
}
// TestSubsystem_ICEServers_OperatorOverride verifies that when the operator
// supplies ICEServers via config (CORE_WEBRTC_ICE_SERVERS), those URIs
// completely replace the built-in STUN defaults rather than being appended.
// This exercises the override branch added in subsystem.New for issue #23.
func TestSubsystem_ICEServers_OperatorOverride(t *testing.T) {
custom := []string{
"stun:stun.example.com:3478",
"turn:user:secret@turn.example.com:3478",
}
sub, err := New(config.DataWebRTC{
Enable: true,
ICEServers: custom,
}, nil)
if err != nil {
t.Fatalf("New with custom ICEServers: %v", err)
}
uris := sub.ICEServerURIs()
if len(uris) != len(custom) {
t.Fatalf("expected %d URIs (custom), got %d: %v", len(custom), len(uris), uris)
}
for i, want := range custom {
if uris[i] != want {
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
}
}
// Confirm the built-in defaults are NOT present.
defaults := corewebrtc.DefaultConfig().ICEServers
for _, def := range defaults {
for _, got := range uris {
if got == def {
t.Errorf("built-in default URI %q should have been replaced but was found in override list", def)
}
}
}
}
// TestAddCORS_ExposesLinkHeader verifies that CORS preflight responses
// include "Link" in Access-Control-Expose-Headers so browsers can read
// the RFC 9429 §4.3 Link headers returned on the 201 Subscribe response.
func TestAddCORS_ExposesLinkHeader(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodOptions, "/whep/any", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("any")
if err := h.preflight(c); err != nil {
t.Fatalf("preflight returned error: %v", err)
}
expose := rec.Header().Get("Access-Control-Expose-Headers")
if !strings.Contains(expose, "Link") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
}
if !strings.Contains(expose, "Location") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
}
if !strings.Contains(expose, "ETag") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
}
}

View file

@ -141,6 +141,11 @@ func (s *Subsystem) allocAdjacentPair(id string) (int, *corewebrtc.Source, *core
lastErr = err lastErr = err
continue continue
} }
// Activate IDR keyframe caching on the video source so that
// late-joining WHEP peers receive a reference frame immediately
// instead of waiting up to one full keyframe interval.
videoSrc.EnableKeyFrameCache()
audioSrc, err := corewebrtc.NewSourceOn(id+":audio", "127.0.0.1", port+1) audioSrc, err := corewebrtc.NewSourceOn(id+":audio", "127.0.0.1", port+1)
if err != nil { if err != nil {
_ = videoSrc.Close() _ = videoSrc.Close()

190
app/webrtc/metrics.go Normal file
View file

@ -0,0 +1,190 @@
package webrtc
import (
"time"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
coreprom "github.com/datarhei/core/v16/prometheus"
"github.com/prometheus/client_golang/prometheus"
)
var iceHistBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
type webrtcMetrics struct {
// WHEP egress metrics
whepRequests *prometheus.CounterVec
whepRequestDuration *prometheus.HistogramVec
iceEstablishment *prometheus.HistogramVec
iceFailures *prometheus.CounterVec
codecMismatches *prometheus.CounterVec
capRejections *prometheus.CounterVec
ffmpegLegFailures *prometheus.CounterVec
// WHIP ingest metrics — symmetric with WHEP where applicable.
// ICE establishment/failure reuse the WHEP histograms (shared labels).
whipRequests *prometheus.CounterVec
whipRequestDuration *prometheus.HistogramVec
whipCapRejections *prometheus.CounterVec
}
// mustRegisterCounter creates a CounterVec and registers it with reg.
// Panics on duplicate registration (same semantics as promauto).
func mustRegisterCounter(reg prometheus.Registerer, opts prometheus.CounterOpts, labels []string) *prometheus.CounterVec {
m := prometheus.NewCounterVec(opts, labels)
reg.MustRegister(m)
return m
}
// mustRegisterHistogram creates a HistogramVec and registers it with reg.
func mustRegisterHistogram(reg prometheus.Registerer, opts prometheus.HistogramOpts, labels []string) *prometheus.HistogramVec {
m := prometheus.NewHistogramVec(opts, labels)
reg.MustRegister(m)
return m
}
func initMetrics(reg prometheus.Registerer, core string) *webrtcMetrics {
cl := prometheus.Labels{"core": core}
return &webrtcMetrics{
// --- WHEP ---
whepRequests: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_whep_requests_total",
Help: "Count of WHEP HTTP requests by route, HTTP status code, and stream.",
ConstLabels: cl,
}, []string{"route", "code", "stream_id"}),
whepRequestDuration: mustRegisterHistogram(reg, prometheus.HistogramOpts{
Name: "dragonfork_webrtc_whep_request_duration_seconds",
Help: "Server-side WHEP request latency in seconds, by route and stream.",
ConstLabels: cl,
Buckets: iceHistBuckets,
}, []string{"route", "stream_id"}),
iceEstablishment: mustRegisterHistogram(reg, prometheus.HistogramOpts{
Name: "dragonfork_webrtc_ice_establishment_duration_seconds",
Help: "Duration from peer creation to first connected or failed ICE state (shared by WHEP and WHIP).",
ConstLabels: cl,
Buckets: iceHistBuckets,
}, []string{"stream_id", "result"}),
iceFailures: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_ice_failures_total",
Help: "Count of ICE failures by stream and reason (shared by WHEP and WHIP).",
ConstLabels: cl,
}, []string{"stream_id", "reason"}),
codecMismatches: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_codec_mismatches_total",
Help: "Count of 406 codec-mismatch rejections by stream and codec kind.",
ConstLabels: cl,
}, []string{"stream_id", "kind"}),
capRejections: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_cap_rejections_total",
Help: "Count of 503 WHEP peer-cap rejections by stream and scope (global or stream).",
ConstLabels: cl,
}, []string{"stream_id", "scope"}),
ffmpegLegFailures: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_ffmpeg_leg_failures_total",
Help: "Count of FFmpeg RTP output leg failures (process stopped while peers were active).",
ConstLabels: cl,
}, []string{"stream_id", "leg"}),
// --- WHIP ---
whipRequests: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_whip_requests_total",
Help: "Count of WHIP HTTP requests by route, HTTP status code, and stream.",
ConstLabels: cl,
}, []string{"route", "code", "stream_id"}),
whipRequestDuration: mustRegisterHistogram(reg, prometheus.HistogramOpts{
Name: "dragonfork_webrtc_whip_request_duration_seconds",
Help: "Server-side WHIP request latency in seconds, by route and stream.",
ConstLabels: cl,
Buckets: iceHistBuckets,
}, []string{"route", "stream_id"}),
whipCapRejections: mustRegisterCounter(reg, prometheus.CounterOpts{
Name: "dragonfork_webrtc_whip_cap_rejections_total",
Help: "Count of 503/409 WHIP publisher-cap or conflict rejections by stream and scope.",
ConstLabels: cl,
}, []string{"stream_id", "scope"}),
}
}
// InitMetrics initialises WebRTC direct-instrumentation metrics on h and
// registers the snapshot collector with reg. Call once after construction,
// before the handler serves requests. Panics on duplicate registration.
func (h *Handler) InitMetrics(reg prometheus.Registerer, core string) {
h.met = initMetrics(reg, core)
}
// SetMetrics attaches a shared *webrtcMetrics to the WHIPHandler so that
// WHIP ingest routes emit Prometheus observations. If both the WHEP Handler
// and the WHIP Handler are in use, call Handler.InitMetrics once and pass
// the result to WHIPHandler.SetMetrics — registering the metrics twice
// on the same Registerer panics.
func (h *WHIPHandler) SetMetrics(met *webrtcMetrics) {
h.met = met
}
// Stats implements coreprom.WebRTCStatsSource for the Prometheus snapshot
// collector. Returns a consistent snapshot under h.mu.
func (h *Handler) Stats() coreprom.WebRTCStats {
h.mu.Lock()
peers := make(map[string]int, len(h.peersByStream))
for id, pm := range h.peersByStream {
peers[id] = len(pm)
}
sc := 0
if h.sub != nil {
sc = h.sub.StreamCount()
}
h.mu.Unlock()
return coreprom.WebRTCStats{
StreamCount: sc,
PeersByStream: peers,
UDPPortsInUse: sc * 2,
}
}
// PublisherCount returns the number of currently active WHIP publishers.
// Safe to call from any goroutine (atomic read).
func (h *WHIPHandler) PublisherCount() int64 {
return h.count
}
// trackICE waits for the first terminal ICE event and records establishment
// duration and failure metrics. t0 should be captured immediately before
// CreatePeerFromSources returns. Runs in a goroutine per Subscribe call.
func (h *Handler) trackICE(streamID string, peer *corewebrtc.Peer, t0 time.Time) {
if h.met == nil {
return
}
select {
case <-peer.Connected():
h.met.iceEstablishment.WithLabelValues(streamID, "connected").Observe(time.Since(t0).Seconds())
case <-peer.Done():
dur := time.Since(t0)
h.met.iceEstablishment.WithLabelValues(streamID, "failed").Observe(dur.Seconds())
h.met.iceFailures.WithLabelValues(streamID, "reason").Inc()
}
}
// trackICE waits for the first terminal ICE event on a WHIP IngestPeer and
// records establishment duration using the same shared histograms as the
// WHEP egress ICE tracker. This gives a unified ICE health view across
// both publish and subscribe paths.
func (h *WHIPHandler) trackICE(streamID string, peer *corewebrtc.IngestPeer, t0 time.Time) {
if h.met == nil {
return
}
select {
case <-peer.Connected():
h.met.iceEstablishment.WithLabelValues(streamID, "connected").Observe(time.Since(t0).Seconds())
case <-peer.Done():
dur := time.Since(t0)
h.met.iceEstablishment.WithLabelValues(streamID, "failed").Observe(dur.Seconds())
h.met.iceFailures.WithLabelValues(streamID, "ingest").Inc()
}
}

149
app/webrtc/metrics_test.go Normal file
View file

@ -0,0 +1,149 @@
package webrtc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/datarhei/core/v16/config"
)
func newTestHandler(t *testing.T) (*Handler, *prometheus.Registry) {
t.Helper()
s, err := New(config.DataWebRTC{Enable: true}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
h := NewHandler(s, 0)
reg := prometheus.NewRegistry()
h.InitMetrics(reg, "test")
return h, reg
}
// TestMetrics_Subscribe404BumpsCounter checks that a 404 on unknown stream
// increments the request counter with the correct labels.
func TestMetrics_Subscribe404BumpsCounter(t *testing.T) {
h, reg := newTestHandler(t)
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")
_ = h.Subscribe(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
# HELP dragonfork_webrtc_whep_requests_total Count of WHEP HTTP requests by route, HTTP status code, and stream.
# TYPE dragonfork_webrtc_whep_requests_total counter
dragonfork_webrtc_whep_requests_total{code="404",core="test",route="subscribe",stream_id="ghost"} 1
`), "dragonfork_webrtc_whep_requests_total"); err != nil {
t.Fatal(err)
}
}
// TestMetrics_GlobalCapBumpsCapRejection checks that a global cap 503 fires
// the cap_rejections counter with scope=global.
func TestMetrics_GlobalCapBumpsCapRejection(t *testing.T) {
s, err := New(config.DataWebRTC{Enable: true}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
// maxPeers=1, but inject a stream so we get past lookup
s.mu.Lock()
s.streams["mystream"] = &processStream{id: "mystream"}
s.mu.Unlock()
h := NewHandlerWithCaps(s, 1, 0)
reg := prometheus.NewRegistry()
h.InitMetrics(reg, "test")
// Force count to be at cap
h.count = 1
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whep/mystream", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("mystream")
_ = h.Subscribe(c)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
n := testutil.ToFloat64(h.met.capRejections.WithLabelValues("", "global"))
if n != 1 {
t.Fatalf("cap_rejections{scope=global}: want 1, got %v", n)
}
}
// TestMetrics_CodecMismatchBumpsCounter checks that a 406 SDP with no H264
// increments codec_mismatches{kind=h264}.
func TestMetrics_CodecMismatchBumpsCounter(t *testing.T) {
s, err := New(config.DataWebRTC{Enable: true}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
s.mu.Lock()
s.streams["cam"] = &processStream{id: "cam"}
s.mu.Unlock()
h := NewHandler(s, 0)
reg := prometheus.NewRegistry()
h.InitMetrics(reg, "test")
// SDP with Opus but no H264
sdp := "v=0\r\na=rtpmap:111 opus/48000/2\r\n"
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whep/cam", strings.NewReader(sdp))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("cam")
_ = h.Subscribe(c)
if rec.Code != http.StatusNotAcceptable {
t.Fatalf("expected 406, got %d", rec.Code)
}
n := testutil.ToFloat64(h.met.codecMismatches.WithLabelValues("cam", "h264"))
if n != 1 {
t.Fatalf("codec_mismatches{kind=h264}: want 1, got %v", n)
}
}
// TestMetrics_Stats returns consistent snapshots at zero and with streams.
func TestMetrics_Stats(t *testing.T) {
h, _ := newTestHandler(t)
got := h.Stats()
if got.StreamCount != 0 {
t.Fatalf("expected 0 streams, got %d", got.StreamCount)
}
if got.UDPPortsInUse != 0 {
t.Fatalf("expected 0 udp ports, got %d", got.UDPPortsInUse)
}
// Inject a stream to verify counts update
h.sub.mu.Lock()
h.sub.streams["test"] = &processStream{id: "test"}
h.sub.mu.Unlock()
got = h.Stats()
if got.StreamCount != 1 {
t.Fatalf("expected 1 stream, got %d", got.StreamCount)
}
if got.UDPPortsInUse != 2 {
t.Fatalf("expected 2 udp ports, got %d", got.UDPPortsInUse)
}
}

View file

@ -12,15 +12,17 @@ import (
// Subsystem is the app-level WebRTC egress manager. It sits alongside // Subsystem is the app-level WebRTC egress manager. It sits alongside
// api.API as a sibling — both consume the Restream service, both wire // api.API as a sibling — both consume the Restream service, both wire
// themselves into the Echo HTTP router. The subsystem is responsible // themselves into the Echo HTTP router. The subsystem is responsible for:
// for:
// //
// - Translating the global config.DataWebRTC into the core-level // - Translating the global config.DataWebRTC into the core-level
// corewebrtc.Config used by the PeerFactory. // corewebrtc.Config used by the PeerFactory.
// - Installing ProcessHooks on Restreamer so that per-process start // - Installing ProcessHooks on Restreamer so that per-process start
// events allocate a pair of UDP ports, create Pion Sources, and // events allocate a pair of UDP ports, create Pion Sources, and
// inject RTP output legs into the FFmpeg command line. // inject RTP output legs into the FFmpeg command line (WHEP egress).
// - Serving the WHEP Echo handler (see handler.go). // - Optionally installing OnInputStart/OnInputStop hooks for WHIP
// ingest: allocates an adjacent UDP pair and injects RTP input legs.
// - Serving the WHEP Echo handler (see handler.go) and the WHIP Echo
// handler (see whip_handler.go).
// //
// The zero value is not usable; call New. // The zero value is not usable; call New.
type Subsystem struct { type Subsystem struct {
@ -30,13 +32,18 @@ type Subsystem struct {
logger log.Logger logger log.Logger
mu sync.Mutex mu sync.Mutex
streams map[string]*processStream // processID -> stream pair streams map[string]*processStream // processID -> WHEP egress stream pair
// teardown is set by the Handler (or any other consumer) so the // WHIP ingest: active port-pair allocations keyed by processID.
// Subsystem can broadcast process-stop events. Called *before* whipIngests map[string]*ingestStream
// the per-stream Sources are closed, so consumers can yank their
// own indexes while the stream id is still valid. // teardown is set by the WHEP Handler to be called before egress
// Sources close in onProcessStop.
teardown func(streamID string) teardown func(streamID string)
// whipTeardown is set by the WHIPHandler to be called before the
// ingest port allocation is removed in onWHIPProcessStop.
whipTeardown func(streamID string)
} }
// processStream captures the two Sources (video + audio) backing a // processStream captures the two Sources (video + audio) backing a
@ -60,13 +67,30 @@ func New(dataCfg config.DataWebRTC, logger log.Logger) (*Subsystem, error) {
coreCfg.Enabled = dataCfg.Enable coreCfg.Enabled = dataCfg.Enable
coreCfg.PublicIP = dataCfg.PublicIP coreCfg.PublicIP = dataCfg.PublicIP
// If the operator configured multiple NAT1To1 IPs (e.g., dual // Build the NAT1To1IPs list that Pion will use for host candidates.
// LAN/public), they take precedence over PublicIP. Wire them // Strategy: merge PublicIP and NAT1To1IPs, deduplicating.
// through via PublicIP as the first entry; core/webrtc currently // - If PublicIP is set it comes first.
// reads a single PublicIP, so M2 joins the list with the first // - Any entries in NAT1To1IPs that differ from PublicIP are appended.
// entry winning. (Multi-IP NAT1To1 is an M3 enhancement.) // This replaces the old single-IP workaround and allows dual-homed
if len(dataCfg.NAT1To1IPs) > 0 && coreCfg.PublicIP == "" { // servers (e.g., a LAN IP + a public IP) to advertise host candidates
coreCfg.PublicIP = dataCfg.NAT1To1IPs[0] // on all interfaces simultaneously.
nat1to1IPs := make([]string, 0, len(dataCfg.NAT1To1IPs)+1)
if dataCfg.PublicIP != "" {
nat1to1IPs = append(nat1to1IPs, dataCfg.PublicIP)
}
for _, ip := range dataCfg.NAT1To1IPs {
if ip != dataCfg.PublicIP {
nat1to1IPs = append(nat1to1IPs, ip)
}
}
coreCfg.NAT1To1IPs = nat1to1IPs
// If the operator supplied explicit ICE server URIs via config/env,
// override the built-in defaults (typically Google's public STUN servers).
// An empty list means "keep the built-in defaults".
if len(dataCfg.ICEServers) > 0 {
coreCfg.ICEServers = make([]string, len(dataCfg.ICEServers))
copy(coreCfg.ICEServers, dataCfg.ICEServers)
} }
factory, err := corewebrtc.NewPeerFactory(coreCfg) factory, err := corewebrtc.NewPeerFactory(coreCfg)
@ -80,6 +104,7 @@ func New(dataCfg config.DataWebRTC, logger log.Logger) (*Subsystem, error) {
factory: factory, factory: factory,
logger: logger.WithComponent("WebRTC"), logger: logger.WithComponent("WebRTC"),
streams: make(map[string]*processStream), streams: make(map[string]*processStream),
whipIngests: make(map[string]*ingestStream),
}, nil }, nil
} }
@ -93,6 +118,10 @@ func (s *Subsystem) Enabled() bool {
// Hooks returns the restream.ProcessHooks the subsystem expects to be // Hooks returns the restream.ProcessHooks the subsystem expects to be
// installed via restream.Restreamer.SetHooks. Exactly one Subsystem // installed via restream.Restreamer.SetHooks. Exactly one Subsystem
// instance should be installed per Restreamer. // instance should be installed per Restreamer.
//
// This returns only the WHEP egress hooks (OnStart/OnStop). Call
// WHIPHooks() to get the WHIP ingest hooks, and merge them with
// SetHooks if WHIP is also required.
func (s *Subsystem) Hooks() restream.ProcessHooks { func (s *Subsystem) Hooks() restream.ProcessHooks {
return restream.ProcessHooks{ return restream.ProcessHooks{
OnStart: s.onProcessStart, OnStart: s.onProcessStart,
@ -100,6 +129,28 @@ func (s *Subsystem) Hooks() restream.ProcessHooks {
} }
} }
// WHIPHooks returns the restream.ProcessHooks for WHIP ingest
// (OnInputStart / OnInputStop). Merge these with the output from
// Hooks() before calling restream.Restreamer.SetHooks.
func (s *Subsystem) WHIPHooks() restream.ProcessHooks {
return restream.ProcessHooks{
OnInputStart: s.onWHIPProcessStart,
OnInputStop: s.onWHIPProcessStop,
}
}
// MergedHooks returns ProcessHooks with both WHEP egress (OnStart/OnStop)
// and WHIP ingest (OnInputStart/OnInputStop) wired in. Convenience
// helper so callers don't have to merge manually.
func (s *Subsystem) MergedHooks() restream.ProcessHooks {
return restream.ProcessHooks{
OnStart: s.onProcessStart,
OnStop: s.onProcessStop,
OnInputStart: s.onWHIPProcessStart,
OnInputStop: s.onWHIPProcessStop,
}
}
// Close tears down every active per-process stream. It is safe to // Close tears down every active per-process stream. It is safe to
// call during Core shutdown; subsequent WHEP requests will 404. // call during Core shutdown; subsequent WHEP requests will 404.
func (s *Subsystem) Close() { func (s *Subsystem) Close() {
@ -129,7 +180,34 @@ func (s *Subsystem) SetTeardownHook(fn func(streamID string)) {
s.teardown = fn s.teardown = fn
} }
// lookup returns the per-process stream pair for id, or nil, false. // SetWHIPTeardownHook registers a callback invoked just before a WHIP
// ingest allocation is removed in onWHIPProcessStop. The WHIPHandler
// uses this to close any active publisher when FFmpeg stops.
func (s *Subsystem) SetWHIPTeardownHook(fn func(streamID string)) {
s.mu.Lock()
defer s.mu.Unlock()
s.whipTeardown = fn
}
// StreamCount returns the number of processes currently registered with
// active WebRTC egress. Used by the Prometheus snapshot collector.
func (s *Subsystem) StreamCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.streams)
}
// ICEServerURIs returns the ICE server URI list from the core config.
// Used by the WHEP and WHIP handlers to emit RFC 9429 / RFC 9261 Link
// headers so that browsers can discover STUN/TURN servers without a
// separate signalling round-trip. If the operator configured explicit
// servers via CORE_WEBRTC_ICE_SERVERS those are returned; otherwise
// the built-in Pion defaults are returned.
func (s *Subsystem) ICEServerURIs() []string {
return s.coreCfg.ICEServers
}
// lookup returns the per-process WHEP stream pair for id, or nil, false.
// Used by the WHEP handler. // Used by the WHEP handler.
func (s *Subsystem) lookup(id string) (*processStream, bool) { func (s *Subsystem) lookup(id string) (*processStream, bool) {
s.mu.Lock() s.mu.Lock()
@ -137,3 +215,12 @@ func (s *Subsystem) lookup(id string) (*processStream, bool) {
st, ok := s.streams[id] st, ok := s.streams[id]
return st, ok return st, ok
} }
// lookupIngest returns the per-process WHIP ingest port allocation for
// id, or nil, false. Used by the WHIPHandler.
func (s *Subsystem) lookupIngest(id string) (*ingestStream, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.whipIngests[id]
return st, ok
}

378
app/webrtc/whip_handler.go Normal file
View file

@ -0,0 +1,378 @@
package webrtc
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/labstack/echo/v4"
"github.com/pion/webrtc/v4"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
)
// WHIPHandler exposes the subsystem's WHIP Echo handlers. Wire them
// into the /api/v3 group alongside the WHEP Handler via
// WHIPHandler.Register.
//
// Lifecycle: ingest peers are tracked in a streamID→resourceID→IngestPeer
// index. On every Publish a goroutine watches the peer's Done() channel;
// when the publisher disconnects or Close() runs the entry is removed
// and the counters tick back down — no leaks if OBS rage-quits.
type WHIPHandler struct {
sub *Subsystem
mu sync.Mutex
ingestByStream map[string]map[string]*corewebrtc.IngestPeer // streamID -> resource -> peer
ingestStream map[string]string // resource -> streamID (reverse index)
count int64 // atomic; concurrent publishers
maxCapTotal int64
met *webrtcMetrics // nil until SetMetrics is called
}
// NewWHIPHandler wraps the subsystem in an Echo-compatible WHIP handler.
// maxPublishers caps concurrent ingest sessions across all streams;
// pass 0 to default to 64.
//
// The constructor registers a teardown hook on the Subsystem so that
// when a process stops, any active WHIP publisher is closed automatically
// (mirroring the pattern used by the WHEP NewHandler).
func NewWHIPHandler(s *Subsystem, maxPublishers int) *WHIPHandler {
total := int64(maxPublishers)
if total <= 0 {
total = 64
}
h := &WHIPHandler{
sub: s,
ingestByStream: make(map[string]map[string]*corewebrtc.IngestPeer),
ingestStream: make(map[string]string),
maxCapTotal: total,
}
// Wire the WHIP teardown hook so onWHIPProcessStop notifies us
// before releasing the port allocation — same pattern as WHEP's
// NewHandler → s.SetTeardownHook(h.tearDownStreamPeers).
if s != nil {
s.SetWHIPTeardownHook(h.tearDownStreamIngests)
}
return h
}
// Register mounts the WHIP routes on the provided Echo group.
//
// POST /whip/:id — start a publish session (SDP offer → answer)
// DELETE /whip/:id/:resource — tear down a publish session
// PATCH /whip/:id/:resource — trickle ICE candidates
// OPTIONS /whip/* — CORS preflight
func (h *WHIPHandler) Register(g *echo.Group) {
g.OPTIONS("/whip/:id", h.preflight)
g.OPTIONS("/whip/:id/:resource", h.preflight)
g.POST("/whip/:id", h.Publish)
g.DELETE("/whip/:id/:resource", h.Unpublish)
g.PATCH("/whip/:id/:resource", h.TrickleIngest)
}
// Publish handles POST /whip/:id.
//
// The request body is an SDP offer (Content-Type: application/sdp).
// Response is the SDP answer; the Location header identifies the
// DELETE/PATCH resource for teardown and trickle ICE.
//
// The target process must have WHIPIngest.Enabled=true in its config,
// and an active ingest port pair must have been allocated by
// onWHIPProcessStart.
//
// @Summary Publish a WebRTC stream via WHIP
// @Description Start a WHIP ingest session. Body is the SDP offer (Content-Type: application/sdp). Response is the SDP answer; Location header points at DELETE/PATCH resource.
// @Tags v16.16.0
// @ID webrtc-3-whip-publish
// @Accept application/sdp
// @Produce application/sdp
// @Param id path string true "Process ID with whip_ingest.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 ingest stream registered for this process id"
// @Failure 409 {string} string "a publisher is already active on this stream (single-publisher enforcement)"
// @Failure 503 {string} string "global publisher cap reached"
// @Failure 504 {string} string "ICE gathering timeout"
// @Security ApiKeyAuth
// @Router /api/v3/whip/{id} [post]
func (h *WHIPHandler) Publish(c echo.Context) error {
addCORS(c)
t0 := time.Now()
id := c.Param("id")
if id == "" {
h.recordRequest("publish", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing stream id")
}
// Global cap: cheap atomic check before real work.
if atomic.LoadInt64(&h.count) >= h.maxCapTotal {
if h.met != nil {
h.met.whipCapRejections.WithLabelValues(id, "global").Inc()
}
h.recordRequest("publish", id, http.StatusServiceUnavailable, t0)
return c.String(http.StatusServiceUnavailable, "webrtc: whip: publisher cap reached")
}
ingest, ok := h.sub.lookupIngest(id)
if !ok {
h.recordRequest("publish", id, http.StatusNotFound, t0)
return c.String(http.StatusNotFound, "webrtc: whip: no ingest registered for process")
}
// Single-publisher enforcement: WHIP is point-to-point —
// only one active publisher per stream at a time.
h.mu.Lock()
if len(h.ingestByStream[id]) > 0 {
h.mu.Unlock()
if h.met != nil {
h.met.whipCapRejections.WithLabelValues(id, "conflict").Inc()
}
h.recordRequest("publish", id, http.StatusConflict, t0)
return c.String(http.StatusConflict, "webrtc: whip: stream already has an active publisher")
}
h.mu.Unlock()
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.recordRequest("publish", id, http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "read body: "+err.Error())
}
if len(body) == 0 || !strings.HasPrefix(string(body), "v=") {
h.recordRequest("publish", id, http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error())
}
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)}
peer, err := h.sub.factory.CreateIngestPeer(
c.Request().Context(),
offer,
ingest.videoPort,
ingest.audioPort,
)
if err != nil {
switch err {
case corewebrtc.ErrICETimeout:
h.recordRequest("publish", id, http.StatusGatewayTimeout, t0)
return c.String(http.StatusGatewayTimeout, err.Error())
default:
h.recordRequest("publish", id, http.StatusInternalServerError, t0)
return c.String(http.StatusInternalServerError, "create ingest peer: "+err.Error())
}
}
rid := peer.ResourceID()
h.mu.Lock()
if h.ingestByStream[id] == nil {
h.ingestByStream[id] = make(map[string]*corewebrtc.IngestPeer)
}
h.ingestByStream[id][rid] = peer
h.ingestStream[rid] = id
h.mu.Unlock()
atomic.AddInt64(&h.count, 1)
// Auto-cleanup on disconnect.
go h.awaitIngestClose(rid, peer)
// Track ICE establishment duration using the shared ICE histograms
// (same metric family as WHEP egress, disambiguated by result label).
go h.trackICE(id, peer, time.Now())
h.recordRequest("publish", id, http.StatusCreated, t0)
// RFC 9261 §5.2: emit one Link header per configured ICE server so
// that the publisher (OBS, browser, GStreamer, etc.) can discover
// STUN/TURN without a separate signalling round-trip — symmetric
// with the WHEP Subscribe Link header added in issue #19.
for _, uri := range h.sub.ICEServerURIs() {
c.Response().Header().Add("Link", "<"+uri+">; rel=\"ice-server\"")
}
c.Response().Header().Set("Content-Type", "application/sdp")
c.Response().Header().Set("Location", "/whip/"+id+"/"+rid)
c.Response().Header().Set("ETag", `"`+rid+`"`)
return c.String(http.StatusCreated, peer.Answer().SDP)
}
// Unpublish handles DELETE /whip/:id/:resource. Returns 204 even when
// the resource is unknown (DELETE is idempotent, per the WHIP spec).
//
// @Summary Tear down a WHIP publish session
// @Tags v16.16.0
// @ID webrtc-3-whip-unpublish
// @Param id path string true "Process ID"
// @Param resource path string true "Resource ID from the Publish Location header"
// @Success 204 "no content"
// @Failure 400 {string} string "missing resource id"
// @Security ApiKeyAuth
// @Router /api/v3/whip/{id}/{resource} [delete]
func (h *WHIPHandler) Unpublish(c echo.Context) error {
addCORS(c)
t0 := time.Now()
resource := c.Param("resource")
if resource == "" {
h.recordRequest("unpublish", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing resource id")
}
h.mu.Lock()
streamID := h.ingestStream[resource]
var peer *corewebrtc.IngestPeer
if streamID != "" {
peer = h.ingestByStream[streamID][resource]
delete(h.ingestByStream[streamID], resource)
if len(h.ingestByStream[streamID]) == 0 {
delete(h.ingestByStream, streamID)
}
delete(h.ingestStream, resource)
}
h.mu.Unlock()
if peer != nil {
_ = peer.Close()
}
if streamID != "" {
atomic.AddInt64(&h.count, -1)
}
h.recordRequest("unpublish", streamID, http.StatusNoContent, t0)
return c.NoContent(http.StatusNoContent)
}
// TrickleIngest handles PATCH /whip/:id/:resource — adds ICE candidates
// from a trickle-ice-sdpfrag body.
//
// @Summary Trickle ICE candidates for a WHIP publish session
// @Tags v16.16.0
// @ID webrtc-3-whip-trickle
// @Accept application/trickle-ice-sdpfrag
// @Param id path string true "Process ID"
// @Param resource path string true "Resource ID from the Publish 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/whip/{id}/{resource} [patch]
func (h *WHIPHandler) TrickleIngest(c echo.Context) error {
addCORS(c)
t0 := time.Now()
resource := c.Param("resource")
if resource == "" {
h.recordRequest("trickle", "", http.StatusBadRequest, t0)
return c.String(http.StatusBadRequest, "missing resource id")
}
h.mu.Lock()
streamID := h.ingestStream[resource]
var peer *corewebrtc.IngestPeer
if streamID != "" {
peer = h.ingestByStream[streamID][resource]
}
h.mu.Unlock()
if peer == nil {
h.recordRequest("trickle", streamID, http.StatusNotFound, t0)
return c.NoContent(http.StatusNotFound)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.recordRequest("trickle", streamID, http.StatusBadRequest, t0)
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})
}
h.recordRequest("trickle", streamID, http.StatusNoContent, t0)
return c.NoContent(http.StatusNoContent)
}
// Close tears down every active ingest peer (e.g., during Core shutdown).
func (h *WHIPHandler) Close() {
h.mu.Lock()
peers := make([]*corewebrtc.IngestPeer, 0)
for _, m := range h.ingestByStream {
for _, p := range m {
peers = append(peers, p)
}
}
h.ingestByStream = make(map[string]map[string]*corewebrtc.IngestPeer)
h.ingestStream = make(map[string]string)
h.mu.Unlock()
for _, p := range peers {
if p != nil {
_ = p.Close()
}
}
atomic.StoreInt64(&h.count, 0)
}
// awaitIngestClose blocks on peer.Done() and yanks the index entry
// when the publisher disconnects. Idempotent with Unpublish.
func (h *WHIPHandler) awaitIngestClose(resource string, peer *corewebrtc.IngestPeer) {
<-peer.Done()
h.mu.Lock()
streamID := h.ingestStream[resource]
_, present := h.ingestStream[resource]
if present {
delete(h.ingestStream, resource)
if streamID != "" {
delete(h.ingestByStream[streamID], resource)
if len(h.ingestByStream[streamID]) == 0 {
delete(h.ingestByStream, streamID)
}
}
}
h.mu.Unlock()
if present {
atomic.AddInt64(&h.count, -1)
}
}
// tearDownStreamIngests is called by the Subsystem's SetWHIPTeardownHook
// to close any active publisher when the FFmpeg process stops.
func (h *WHIPHandler) tearDownStreamIngests(streamID string) {
h.mu.Lock()
bucket := h.ingestByStream[streamID]
peers := make([]*corewebrtc.IngestPeer, 0, len(bucket))
for _, p := range bucket {
peers = append(peers, p)
}
h.mu.Unlock()
for _, p := range peers {
if p != nil {
_ = p.Close()
}
}
}
// recordRequest logs request metrics to the shared Prometheus metrics
// instance. No-ops if SetMetrics has not been called.
func (h *WHIPHandler) recordRequest(route, streamID string, code int, t0 time.Time) {
if h.met == nil {
return
}
codeStr := fmt.Sprintf("%d", code)
h.met.whipRequests.WithLabelValues(route, codeStr, streamID).Inc()
h.met.whipRequestDuration.WithLabelValues(route, streamID).Observe(time.Since(t0).Seconds())
}
// preflight answers CORS OPTIONS requests.
func (h *WHIPHandler) preflight(c echo.Context) error {
addCORS(c)
return c.NoContent(http.StatusNoContent)
}

View file

@ -0,0 +1,278 @@
package webrtc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
)
// TestWHIPHandler_Publish_404WhenNoIngest verifies POST /whip/:id returns
// 404 when no process has registered a WHIP ingest for that id.
func TestWHIPHandler_Publish_404WhenNoIngest(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("ghost")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_400OnEmptyBody verifies that an empty SDP body
// is rejected before any peer negotiation. A dummy ingest is registered so
// the handler reaches body validation.
func TestWHIPHandler_Publish_400OnEmptyBody(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5100, audioPort: 5101}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe", strings.NewReader(""))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("probe")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_400OnNonSDP verifies that a body which doesn't
// start with "v=" is rejected as an invalid SDP.
func TestWHIPHandler_Publish_400OnNonSDP(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5102, audioPort: 5103}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
strings.NewReader("not-an-sdp-body"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("probe")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_409OnSecondPublisher verifies that attempting to
// publish a second time on the same stream while a publisher is already
// active returns 409 Conflict, not 201, and does not increment the counter.
func TestWHIPHandler_Publish_409OnSecondPublisher(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5104, audioPort: 5105}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
// Inject a fake active publisher directly into the handler's index.
// We use a nil *IngestPeer because the 409 check only tests map length
// and never dereferences the peer pointer.
h.mu.Lock()
h.ingestByStream["probe"] = map[string]*corewebrtc.IngestPeer{
"existing-rid": nil,
}
h.ingestStream["existing-rid"] = "probe"
h.mu.Unlock()
// Verify initial count is 0 (the fake was injected, not published).
if c := h.PublisherCount(); c != 0 {
t.Fatalf("expected initial count 0, got %d", c)
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
strings.NewReader("v=0\r\nm=video 0 RTP/AVP 96\r\n"))
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
ctx.SetParamNames("id")
ctx.SetParamValues("probe")
if err := h.Publish(ctx); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String())
}
// Count must not have incremented on the rejected request.
if c := h.PublisherCount(); c != 0 {
t.Errorf("expected count still 0 after 409, got %d", c)
}
}
// TestWHIPHandler_Unpublish_204WhenUnknown verifies DELETE returns 204
// even for unknown resource ids — idempotent per the WHIP spec.
func TestWHIPHandler_Unpublish_204WhenUnknown(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/whip/id/unknown-resource", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "unknown-resource")
if err := h.Unpublish(c); err != nil {
t.Fatalf("Unpublish returned error: %v", err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rec.Code)
}
}
// TestWHIPHandler_Unpublish_400OnMissingResource verifies DELETE without
// a resource id param returns 400.
func TestWHIPHandler_Unpublish_400OnMissingResource(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/whip/id/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "")
if err := h.Unpublish(c); err != nil {
t.Fatalf("Unpublish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
// TestWHIPHandler_TrickleIngest_404WhenPeerUnknown verifies PATCH returns
// 404 when there is no peer registered for the resource id.
func TestWHIPHandler_TrickleIngest_404WhenPeerUnknown(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPatch, "/whip/id/ghost",
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "ghost")
if err := h.TrickleIngest(c); err != nil {
t.Fatalf("TrickleIngest returned error: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
// TestWHIPHandler_TrickleIngest_400OnMissingResource verifies PATCH
// without a resource id returns 400.
func TestWHIPHandler_TrickleIngest_400OnMissingResource(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPatch, "/whip/id/",
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "")
if err := h.TrickleIngest(c); err != nil {
t.Fatalf("TrickleIngest returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
// TestWHIPHandler_Preflight_ExposesLinkHeader verifies that CORS preflight
// responses include "Link" in Access-Control-Expose-Headers so browsers
// can read the RFC 9261 §5.2 Link headers on the 201 Publish response.
func TestWHIPHandler_Preflight_ExposesLinkHeader(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodOptions, "/whip/any", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("any")
if err := h.preflight(c); err != nil {
t.Fatalf("preflight returned error: %v", err)
}
expose := rec.Header().Get("Access-Control-Expose-Headers")
if !strings.Contains(expose, "Link") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
}
if !strings.Contains(expose, "Location") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
}
if !strings.Contains(expose, "ETag") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
}
}
// TestWHIPHandler_SetMetrics_DoesNotPanic verifies that SetMetrics accepts
// a nil argument without panicking (nil-safe guard for wiring code).
func TestWHIPHandler_SetMetrics_DoesNotPanic(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
// nil metrics is explicitly allowed — recordRequest guards on h.met == nil.
h.SetMetrics(nil)
}
// TestWHIPHandler_PublisherCount_ZeroOnEmpty verifies that a freshly
// constructed handler reports 0 active publishers.
func TestWHIPHandler_PublisherCount_ZeroOnEmpty(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
if n := h.PublisherCount(); n != 0 {
t.Errorf("expected 0 publishers on empty handler, got %d", n)
}
}
// TestWHIPHandler_Publish_CORSHeadersPresent verifies that every Publish
// response (even a 404) carries the CORS headers required for cross-origin
// browser-based publishers.
func TestWHIPHandler_Publish_CORSHeadersPresent(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("ghost")
_ = h.Publish(c)
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("expected Access-Control-Allow-Origin: *")
}
}

View file

@ -0,0 +1,184 @@
package webrtc
import (
"fmt"
"net"
appcfg "github.com/datarhei/core/v16/restream/app"
)
// ingestStream captures the two loopback UDP ports that FFmpeg binds
// for WHIP ingest — video on videoPort, audio on audioPort.
// The WHIPHandler writes received WebRTC RTP to these ports.
type ingestStream struct {
id string
videoPort int
audioPort int
}
// onWHIPProcessStart is registered as the restream OnInputStart hook.
// It fires just before FFmpeg starts, holding the restream write lock.
//
// When the per-process WHIPIngest config is disabled it returns (nil, nil)
// so FFmpeg starts normally. When enabled it:
//
// 1. Allocates two adjacent loopback UDP ports (video on V, audio on V+1)
// using the same retry strategy as the WHEP egress allocator.
// 2. Registers the pair under the process ID in whipIngests so the
// WHIPHandler can route incoming WebRTC RTP to them.
// 3. Returns two RTP ConfigIO input legs that FFmpeg will open as UDP
// listeners. The restream manager prepends them to cfg.Input and
// rebuilds the FFmpeg command before Start().
func (s *Subsystem) onWHIPProcessStart(id string, cfg *appcfg.Config) ([]appcfg.ConfigIO, error) {
if cfg == nil || !cfg.WHIPIngest.Enabled {
return nil, nil
}
// Normalize PTs — zero values mean "use defaults".
wcfg := cfg.WHIPIngest
if wcfg.VideoPT == 0 {
wcfg.VideoPT = defaultVideoPT
}
if wcfg.AudioPT == 0 {
wcfg.AudioPT = defaultAudioPT
}
// Refuse to re-register.
s.mu.Lock()
if _, exists := s.whipIngests[id]; exists {
s.mu.Unlock()
return nil, fmt.Errorf("webrtc: whip: process %q already has an active ingest", id)
}
s.mu.Unlock()
// Find an adjacent pair (V, V+1). The same retry logic used by
// the WHEP egress allocator (allocAdjacentPair) except we only
// need port numbers, not Source objects.
videoPort, err := allocAdjacentPortPair()
if err != nil {
return nil, fmt.Errorf("webrtc: whip: allocate port pair for process %q: %w", id, err)
}
audioPort := videoPort + 1
s.mu.Lock()
s.whipIngests[id] = &ingestStream{
id: id,
videoPort: videoPort,
audioPort: audioPort,
}
s.mu.Unlock()
s.logger.WithFields(map[string]interface{}{
"id": id,
"video_port": videoPort,
"audio_port": audioPort,
"video_pt": wcfg.VideoPT,
"audio_pt": wcfg.AudioPT,
}).Info().Log("WebRTC WHIP ingest registered for process")
return buildWHIPInputLegs(wcfg, videoPort), nil
}
// onWHIPProcessStop is registered as the restream OnInputStop hook.
// It fires just after FFmpeg has been stopped. It removes the port
// allocation and signals the WHIPHandler to close any active publisher.
func (s *Subsystem) onWHIPProcessStop(id string) {
s.mu.Lock()
_, ok := s.whipIngests[id]
teardown := s.whipTeardown
if ok {
delete(s.whipIngests, id)
}
s.mu.Unlock()
if !ok {
return
}
if teardown != nil {
teardown(id)
}
s.logger.WithField("id", id).Info().Log("WebRTC WHIP ingest torn down for process")
}
// allocAdjacentPortPair finds two consecutive free loopback UDP ports
// (V, V+1) and returns V. It retries up to allocAttempts times because
// the kernel may hand us a port whose +1 neighbor is already taken.
//
// The caller owns the returned port numbers; FFmpeg will bind them
// immediately on process start via the ConfigIO input legs.
func allocAdjacentPortPair() (int, error) {
var lastErr error
for attempt := 0; attempt < allocAttempts; attempt++ {
videoPort, err := Alloc()
if err != nil {
lastErr = err
continue
}
audioPort := videoPort + 1
if audioPort > 65535 {
lastErr = fmt.Errorf("video port %d would push audio port above 65535", videoPort)
continue
}
// Verify the audio port (videoPort+1) is also free by attempting
// a momentary bind. TOCTOU race is accepted; FFmpeg will fail-fast
// on the actual bind and the process will restart cleanly.
if err := checkPortFree(audioPort); err != nil {
lastErr = err
continue
}
return videoPort, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("unknown allocation failure")
}
return 0, fmt.Errorf("after %d attempts: %w", allocAttempts, lastErr)
}
// checkPortFree attempts a momentary UDP bind on the given loopback
// port. Returns nil if the port appears available, non-nil otherwise.
func checkPortFree(port int) error {
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err != nil {
return fmt.Errorf("port %d not free: %w", port, err)
}
_ = c.Close()
return nil
}
// buildWHIPInputLegs produces the two FFmpeg input ConfigIO legs for
// WHIP ingest. FFmpeg opens each as a UDP RTP listener:
//
// -i udp://127.0.0.1:V?overrun_nonfatal=1&fifo_size=50000000
// -i udp://127.0.0.1:A?overrun_nonfatal=1&fifo_size=50000000
//
// The IngestPeer.forwardTrack goroutine writes received WebRTC RTP to
// these ports once the WHIP publisher connects.
func buildWHIPInputLegs(cfg appcfg.ConfigWHIPIngest, videoPort int) []appcfg.ConfigIO {
audioPort := videoPort + 1
return []appcfg.ConfigIO{
{
ID: "whip:video",
Address: fmt.Sprintf("udp://127.0.0.1:%d?overrun_nonfatal=1&fifo_size=50000000", videoPort),
Options: []string{
"-re",
"-protocol_whitelist", "udp,rtp",
"-fflags", "+genpts",
"-payload_type", fmt.Sprint(cfg.VideoPT),
"-codec:v", "copy",
},
},
{
ID: "whip:audio",
Address: fmt.Sprintf("udp://127.0.0.1:%d?overrun_nonfatal=1&fifo_size=50000000", audioPort),
Options: []string{
"-re",
"-protocol_whitelist", "udp,rtp",
"-fflags", "+genpts",
"-payload_type", fmt.Sprint(cfg.AudioPT),
"-codec:a", "copy",
},
},
}
}

View file

@ -133,6 +133,7 @@ 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.WebRTC.NAT1To1IPs = copy.Slice(d.WebRTC.NAT1To1IPs)
data.WebRTC.ICEServers = copy.Slice(d.WebRTC.ICEServers)
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)
@ -235,6 +236,7 @@ func (d *Config) init() {
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.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.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) 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)
d.vars.Register(value.NewStringList(&d.WebRTC.ICEServers, []string{}, ","), "webrtc.ice_servers", "CORE_WEBRTC_ICE_SERVERS", nil, "Comma-separated STUN/TURN URIs overriding built-in defaults (e.g. stun:stun.example.com:3478)", 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)

View file

@ -343,4 +343,9 @@ type DataWebRTC struct {
PublicIP string `json:"public_ip"` PublicIP string `json:"public_ip"`
NAT1To1IPs []string `json:"nat_1_to_1_ips"` NAT1To1IPs []string `json:"nat_1_to_1_ips"`
UDPMuxPort int `json:"udp_mux_port" format:"int"` UDPMuxPort int `json:"udp_mux_port" format:"int"`
// ICEServers is an optional operator-supplied list of STUN/TURN URIs
// (e.g. "stun:stun.example.com:3478", "turn:user:pass@turn.example.com").
// When non-empty it overrides the built-in default STUN servers used by
// the WebRTC subsystem. Exposed via CORE_WEBRTC_ICE_SERVERS (comma-separated).
ICEServers []string `json:"ice_servers"`
} }

View file

@ -17,8 +17,18 @@ type Config struct {
// PublicIP is the server's externally-reachable IP, advertised in ICE // PublicIP is the server's externally-reachable IP, advertised in ICE
// candidates via NAT1To1. Empty means rely on STUN discovery. // candidates via NAT1To1. Empty means rely on STUN discovery.
// Deprecated in favour of NAT1To1IPs for multi-homed servers; when both
// are set, PublicIP is treated as the first entry in NAT1To1IPs.
PublicIP string PublicIP string
// NAT1To1IPs is the list of NAT1To1 IPs for ICE host candidates.
// When non-empty, Pion advertises a host candidate for each IP so that
// peers can reach this server through NAT on any of the listed addresses.
// Takes precedence over PublicIP when set; PublicIP is treated as a member
// of this list when both are configured. Typical use: dual-homed servers
// with both a LAN IP and a public IP.
NAT1To1IPs []string
// UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP. // UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP.
UDPPortRange PortRange UDPPortRange PortRange
@ -35,6 +45,7 @@ func DefaultConfig() Config {
Enabled: true, Enabled: true,
WHEPListen: ":8787", WHEPListen: ":8787",
PublicIP: "", PublicIP: "",
NAT1To1IPs: nil,
UDPPortRange: PortRange{Low: 10000, High: 10100}, UDPPortRange: PortRange{Low: 10000, High: 10100},
ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}, ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"},
MaxPeersTotal: 32, MaxPeersTotal: 32,

View file

@ -8,8 +8,14 @@ import (
// PeerConnection needs: a webrtc.Configuration (with ICE servers) and a // PeerConnection needs: a webrtc.Configuration (with ICE servers) and a
// SettingEngine (with NAT1To1 and port range tuning). // SettingEngine (with NAT1To1 and port range tuning).
// //
// NAT1To1 IP resolution order:
// 1. NAT1To1IPs — the full list is passed directly to Pion when non-empty.
// 2. PublicIP — promoted to a single-element NAT1To1IPs list for backward
// compatibility with configs that only set PublicIP.
// 3. Neither set — STUN-only mode; no host candidates are injected.
//
// The returned *SettingEngine may be nil if no engine-level tuning is // The returned *SettingEngine may be nil if no engine-level tuning is
// required (i.e. PublicIP unset and UDPPortRange at defaults). Callers // required (i.e. no NAT1To1 IPs and UDPPortRange at defaults). Callers
// should only pass it to webrtc.NewAPI when non-nil. // should only pass it to webrtc.NewAPI when non-nil.
func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) { func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) {
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
@ -25,11 +31,20 @@ func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, erro
}) })
} }
// Build the effective NAT1To1 IP list.
// Prefer the explicit NAT1To1IPs slice; fall back to PublicIP as a
// single-element list so that legacy configs (PublicIP only) continue
// to work without operator changes.
nat1to1 := c.NAT1To1IPs
if len(nat1to1) == 0 && c.PublicIP != "" {
nat1to1 = []string{c.PublicIP}
}
var se *webrtc.SettingEngine var se *webrtc.SettingEngine
if c.PublicIP != "" || c.UDPPortRange.Low > 0 { if len(nat1to1) > 0 || c.UDPPortRange.Low > 0 {
engine := webrtc.SettingEngine{} engine := webrtc.SettingEngine{}
if c.PublicIP != "" { if len(nat1to1) > 0 {
engine.SetNAT1To1IPs([]string{c.PublicIP}, webrtc.ICECandidateTypeHost) engine.SetNAT1To1IPs(nat1to1, webrtc.ICECandidateTypeHost)
} }
// Constrain the ephemeral UDP range Pion allocates for ICE candidates. // Constrain the ephemeral UDP range Pion allocates for ICE candidates.
// Note: this is a separate concern from our FFmpeg→Source UDP ports; // Note: this is a separate concern from our FFmpeg→Source UDP ports;

View file

@ -48,3 +48,76 @@ func TestBuildICEConfig_InvalidConfig(t *testing.T) {
t.Error("BuildICEConfig should reject invalid config") t.Error("BuildICEConfig should reject invalid config")
} }
} }
// TestBuildICEConfig_NAT1To1IPs_Multi verifies that a list of multiple
// NAT1To1 IPs is accepted and a SettingEngine is returned, allowing
// dual-homed servers to advertise host candidates on all interfaces.
func TestBuildICEConfig_NAT1To1IPs_Multi(t *testing.T) {
c := DefaultConfig()
c.NAT1To1IPs = []string{"10.0.0.1", "203.0.113.10"}
_, se, err := BuildICEConfig(c)
if err != nil {
t.Fatalf("BuildICEConfig with multiple NAT1To1IPs: %v", err)
}
if se == nil {
t.Fatal("SettingEngine should not be nil when NAT1To1IPs is set")
}
// Smoke-test: Pion should accept the engine without panicking.
api := webrtc.NewAPI(webrtc.WithSettingEngine(*se))
if api == nil {
t.Fatal("NewAPI returned nil")
}
}
// TestBuildICEConfig_NAT1To1IPs_FallsBackToPublicIP verifies that when
// NAT1To1IPs is empty but PublicIP is set, the single IP is promoted to
// the NAT1To1 list (backward-compat path).
func TestBuildICEConfig_NAT1To1IPs_FallsBackToPublicIP(t *testing.T) {
c := DefaultConfig()
c.PublicIP = "198.51.100.1"
c.NAT1To1IPs = nil
_, se, err := BuildICEConfig(c)
if err != nil {
t.Fatalf("BuildICEConfig (PublicIP fallback): %v", err)
}
if se == nil {
t.Fatal("SettingEngine should be set when PublicIP is used as fallback")
}
}
// TestBuildICEConfig_NAT1To1IPs_TakesPrecedenceOverPublicIP verifies that
// when both NAT1To1IPs and PublicIP are set, a SettingEngine is still
// returned (the subsystem merges them; BuildICEConfig sees only NAT1To1IPs).
func TestBuildICEConfig_NAT1To1IPs_TakesPrecedenceOverPublicIP(t *testing.T) {
c := DefaultConfig()
c.PublicIP = "198.51.100.1"
c.NAT1To1IPs = []string{"10.0.0.1", "198.51.100.1"}
_, se, err := BuildICEConfig(c)
if err != nil {
t.Fatalf("BuildICEConfig (NAT1To1IPs + PublicIP): %v", err)
}
if se == nil {
t.Fatal("SettingEngine should not be nil when NAT1To1IPs is set")
}
}
// TestBuildICEConfig_NAT1To1IPs_NeitherSet verifies that with no IP hints
// the function still succeeds (STUN-only mode). A SettingEngine is still
// returned because the default UDPPortRange is non-zero.
func TestBuildICEConfig_NAT1To1IPs_NeitherSet(t *testing.T) {
c := DefaultConfig()
c.PublicIP = ""
c.NAT1To1IPs = nil
_, se, err := BuildICEConfig(c)
if err != nil {
t.Fatalf("BuildICEConfig (STUN-only): %v", err)
}
// UDPPortRange.Low > 0 in DefaultConfig, so se is non-nil; verify it
// builds without error.
if se != nil {
api := webrtc.NewAPI(webrtc.WithSettingEngine(*se))
if api == nil {
t.Fatal("NewAPI returned nil in STUN-only mode")
}
}
}

View file

@ -0,0 +1,102 @@
package webrtc
import (
"sync"
"github.com/pion/rtp"
)
// keyFrameCache retains the most recent H.264 keyframe burst so that
// new WHEP subscribers can receive it immediately on Subscribe(),
// cutting first-frame latency from up to one IDR interval (typically
// 2 s at a 0.5 Hz keyframe rate) to nearly zero.
//
// A "burst" spans all RTP packets from the first fragment of an IDR NAL
// until (but not including) the next IDR NAL. The cache is bounded by
// maxPackets and maxBytes to cap per-stream memory usage.
//
// Thread safety: all public methods are safe for concurrent use.
// push() is intended to be called only from the single-goroutine
// readLoop — the lock it holds is small and brief.
type keyFrameCache struct {
mu sync.Mutex
packets []*rtp.Packet
byteLen int
maxPackets int
maxBytes int
}
// newKeyFrameCache returns a cache bounded to 512 packets / 2 MiB.
// At typical H.264 streaming bitrates (14 Mbps), an IDR frame plus a
// handful of subsequent P-frames fits comfortably within these limits.
func newKeyFrameCache() *keyFrameCache {
return &keyFrameCache{
packets: make([]*rtp.Packet, 0, 64),
maxPackets: 512,
maxBytes: 2 << 20, // 2 MiB
}
}
// isH264IDRStart returns true if pkt begins an H.264 IDR (keyframe)
// NAL. It recognises three RFC 6184 packetisation modes:
//
// - Single NAL unit (type 5): the entire payload is one IDR slice.
// - FU-A fragment (type 28): the FU header byte has the start bit set
// (0x80) and the inner NAL type is 5.
// - STAP-A aggregate (type 24): the first NAL in the aggregate is an
// IDR slice. STAP-A format: byte 0 = NAL header (type 24), bytes
// 12 = first NAL size (big-endian uint16), byte 3 = first NAL
// header. Minimum valid payload: 4 bytes.
func isH264IDRStart(pkt *rtp.Packet) bool {
p := pkt.Payload
if len(p) == 0 {
return false
}
nalType := p[0] & 0x1F
switch nalType {
case 5: // Single NAL unit, IDR slice
return true
case 24: // STAP-A — bytes 12 are the first NAL's size; byte 3 is its header
return len(p) >= 4 && p[3]&0x1F == 5
case 28: // FU-A — byte 1 is the FU header: bit 7 = start, bits 40 = inner type
return len(p) >= 2 && p[1]&0x80 != 0 && p[1]&0x1F == 5
}
return false
}
// push appends pkt to the cache. If pkt is the start of an H.264 IDR
// NAL the existing burst is cleared first so the cache always holds
// exactly one complete keyframe burst. Packets beyond the capacity
// limits are silently dropped.
//
// push is called exclusively from readLoop (a single goroutine); the
// isH264IDRStart check outside the lock is therefore safe.
func (c *keyFrameCache) push(pkt *rtp.Packet) {
isIDR := isH264IDRStart(pkt)
payloadLen := len(pkt.Payload)
c.mu.Lock()
if isIDR {
c.packets = c.packets[:0]
c.byteLen = 0
}
if len(c.packets) < c.maxPackets && c.byteLen+payloadLen <= c.maxBytes {
c.packets = append(c.packets, pkt)
c.byteLen += payloadLen
}
c.mu.Unlock()
}
// snapshot returns a shallow copy of the current burst. The returned
// slice is safe to iterate without holding any lock; the *rtp.Packet
// values are never mutated after being placed in the cache.
// Returns nil when the cache is empty.
func (c *keyFrameCache) snapshot() []*rtp.Packet {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.packets) == 0 {
return nil
}
snap := make([]*rtp.Packet, len(c.packets))
copy(snap, c.packets)
return snap
}

View file

@ -0,0 +1,311 @@
package webrtc
import (
"sync"
"testing"
"github.com/pion/rtp"
)
// makePacket returns a minimal *rtp.Packet with the given payload bytes.
func makePacket(payload []byte) *rtp.Packet {
return &rtp.Packet{Payload: payload}
}
// --- isH264IDRStart ---
func TestIsH264IDRStart_Empty(t *testing.T) {
if isH264IDRStart(makePacket(nil)) {
t.Error("empty payload should not be IDR")
}
if isH264IDRStart(makePacket([]byte{})) {
t.Error("zero-length payload should not be IDR")
}
}
func TestIsH264IDRStart_SingleNAL_IDR(t *testing.T) {
// NAL type 5 = IDR slice. Forbidden zero bit + NRI can be anything.
p := makePacket([]byte{0x65, 0xb8, 0x00}) // 0x65 = 0110_0101 → type=5
if !isH264IDRStart(p) {
t.Error("single NAL type 5 should be detected as IDR start")
}
}
func TestIsH264IDRStart_SingleNAL_NonIDR(t *testing.T) {
tests := []struct {
name string
payload []byte
}{
{"SPS (type 7)", []byte{0x67, 0x42, 0x00}},
{"PPS (type 8)", []byte{0x68, 0xce, 0x38}},
{"P-frame (type 1)", []byte{0x41, 0x9a}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nalType := tt.payload[0] & 0x1F
if isH264IDRStart(makePacket(tt.payload)) {
t.Errorf("NAL type %d should not be IDR start", nalType)
}
})
}
}
func TestIsH264IDRStart_FUA_Start_IDR(t *testing.T) {
// FU-A: header byte NAL type = 28 (0x1C), FU header start bit set (0x80), inner type = 5
p := makePacket([]byte{0x7c, 0x85, 0x00, 0x00}) // 0x7c = type 28; 0x85 = start|IDR
if !isH264IDRStart(p) {
t.Error("FU-A with start bit + inner type 5 should be IDR start")
}
}
func TestIsH264IDRStart_FUA_Start_NonIDR(t *testing.T) {
// FU-A start, but inner NAL type = 1 (P-frame fragment)
p := makePacket([]byte{0x7c, 0x81}) // start bit set, inner type = 1
if isH264IDRStart(p) {
t.Error("FU-A P-frame start should not be IDR")
}
}
func TestIsH264IDRStart_FUA_Continuation(t *testing.T) {
// FU-A continuation: start bit NOT set, even if inner type byte = 5
p := makePacket([]byte{0x7c, 0x05}) // 0x05 & 0x80 == 0 — no start bit
if isH264IDRStart(p) {
t.Error("FU-A continuation should not be IDR start")
}
}
func TestIsH264IDRStart_FUA_TruncatedPayload(t *testing.T) {
// FU-A with only 1 byte — no FU header byte present
p := makePacket([]byte{0x7c})
if isH264IDRStart(p) {
t.Error("truncated FU-A (1 byte) should not panic or return true")
}
}
func TestIsH264IDRStart_STAPA_LeadingIDR(t *testing.T) {
// STAP-A (type 24): byte 0 = NAL header (0x78 = type 24),
// bytes 1-2 = first NAL size (big-endian), byte 3 = first NAL header.
// First NAL type = 5 (IDR) → should be detected.
p := makePacket([]byte{
0x78, // STAP-A header: NRI=3, type=24
0x00, 0x03, // first NAL size = 3 bytes
0x65, 0x88, 0x84, // first NAL: type 5 (IDR slice)
0x00, 0x02, // second NAL size = 2 bytes (SPS, doesn't matter)
0x67, 0x42, // second NAL: SPS
})
if !isH264IDRStart(p) {
t.Error("STAP-A with leading IDR NAL (type 5) should be detected as IDR start")
}
}
func TestIsH264IDRStart_STAPA_LeadingNonIDR(t *testing.T) {
// STAP-A where the first NAL is SPS (type 7), not IDR.
// Common pattern: encoders bundle SPS+PPS+IDR in separate STAP-A,
// then IDR in a single NAL or FU-A. This STAP-A should not trigger reset.
p := makePacket([]byte{
0x78, // STAP-A header
0x00, 0x03, // first NAL size = 3
0x67, 0x42, 0x00, // first NAL: SPS (type 7)
})
if isH264IDRStart(p) {
t.Error("STAP-A with leading SPS (type 7) should not be IDR start")
}
}
func TestIsH264IDRStart_STAPA_Truncated(t *testing.T) {
// STAP-A with fewer than 4 bytes — cannot safely read first NAL header.
tests := []struct {
name string
payload []byte
}{
{"1 byte (header only)", []byte{0x78}},
{"2 bytes (header + 1 size byte)", []byte{0x78, 0x00}},
{"3 bytes (header + size, no NAL)", []byte{0x78, 0x00, 0x01}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if isH264IDRStart(makePacket(tt.payload)) {
t.Errorf("truncated STAP-A (%d bytes) should not panic or return true", len(tt.payload))
}
})
}
}
func TestIsH264IDRStart_Opus(t *testing.T) {
// Opus RTP payload starts with a TOC byte — definitely not H.264
p := makePacket([]byte{0xf8, 0xff, 0xfe})
if isH264IDRStart(p) {
t.Error("Opus payload should not be detected as IDR")
}
}
// --- keyFrameCache push / snapshot ---
func TestKeyFrameCache_EmptySnapshot(t *testing.T) {
c := newKeyFrameCache()
if snap := c.snapshot(); snap != nil {
t.Errorf("expected nil snapshot from empty cache, got %d packets", len(snap))
}
}
func TestKeyFrameCache_IDRResetsPreviousBurst(t *testing.T) {
c := newKeyFrameCache()
// Push some non-IDR packets first.
for i := 0; i < 5; i++ {
c.push(makePacket([]byte{0x41, byte(i)}))
}
// Now push an IDR start — cache should reset to just this packet.
idrPkt := makePacket([]byte{0x65, 0x88, 0x84})
c.push(idrPkt)
snap := c.snapshot()
if len(snap) != 1 {
t.Errorf("expected exactly 1 packet after IDR reset, got %d", len(snap))
}
if snap[0] != idrPkt {
t.Error("snapshot should contain the IDR packet itself")
}
}
func TestKeyFrameCache_BurstAccumulation(t *testing.T) {
c := newKeyFrameCache()
idr := makePacket([]byte{0x65, 0x88, 0x84})
c.push(idr)
// Push 9 more packets (P-frames)
for i := 0; i < 9; i++ {
c.push(makePacket([]byte{0x41, byte(i)}))
}
snap := c.snapshot()
if len(snap) != 10 {
t.Errorf("expected 10 packets in burst, got %d", len(snap))
}
}
func TestKeyFrameCache_SecondIDRResetsAgain(t *testing.T) {
c := newKeyFrameCache()
c.push(makePacket([]byte{0x65, 0x01})) // first IDR
for i := 0; i < 4; i++ {
c.push(makePacket([]byte{0x41, byte(i)}))
}
second := makePacket([]byte{0x65, 0x02}) // second IDR — resets burst
c.push(second)
snap := c.snapshot()
if len(snap) != 1 {
t.Errorf("second IDR should reset burst to 1 packet, got %d", len(snap))
}
if snap[0] != second {
t.Error("snapshot should contain the second IDR packet")
}
}
func TestKeyFrameCache_STAPA_IDR_ResetsCache(t *testing.T) {
// Verify that a STAP-A with a leading IDR NAL correctly resets the burst,
// just like a single-NAL IDR packet does.
c := newKeyFrameCache()
// Pre-load some P-frames.
for i := 0; i < 5; i++ {
c.push(makePacket([]byte{0x41, byte(i)}))
}
stapA := makePacket([]byte{
0x78, // STAP-A header
0x00, 0x03, // first NAL size = 3
0x65, 0x88, 0x84, // first NAL: IDR (type 5)
})
c.push(stapA)
snap := c.snapshot()
if len(snap) != 1 {
t.Errorf("STAP-A IDR should reset burst to 1 packet, got %d", len(snap))
}
if snap[0] != stapA {
t.Error("snapshot should contain the STAP-A IDR packet")
}
}
func TestKeyFrameCache_MaxPacketsCap(t *testing.T) {
c := newKeyFrameCache()
c.maxPackets = 5
c.maxBytes = 1 << 20 // generous byte cap
idr := makePacket([]byte{0x65, 0x88})
c.push(idr)
for i := 0; i < 20; i++ {
c.push(makePacket([]byte{0x41, byte(i)}))
}
snap := c.snapshot()
if len(snap) != 5 {
t.Errorf("cache should stop at maxPackets=5, got %d", len(snap))
}
}
func TestKeyFrameCache_MaxBytesCap(t *testing.T) {
c := newKeyFrameCache()
c.maxPackets = 512
c.maxBytes = 10 // tiny — only 2 packets of 4 bytes each fit (8 ≤ 10 < 12)
idr := makePacket([]byte{0x65, 0x88, 0x84, 0x21}) // 4 bytes payload
c.push(idr)
c.push(makePacket([]byte{0x41, 0x9a, 0xab, 0xcd})) // 4 bytes → total 8 ≤ 10 ✓
c.push(makePacket([]byte{0x41, 0x01, 0x02, 0x03})) // 4 bytes → would be 12 > 10 ✗
snap := c.snapshot()
if len(snap) != 2 {
t.Errorf("expected 2 packets within byte cap, got %d", len(snap))
}
}
func TestKeyFrameCache_SnapshotIsACopy(t *testing.T) {
c := newKeyFrameCache()
c.push(makePacket([]byte{0x65, 0x88}))
c.push(makePacket([]byte{0x41, 0x01}))
snap1 := c.snapshot()
// Push another IDR — clears the internal slice.
c.push(makePacket([]byte{0x65, 0x99}))
// snap1 should still hold the original 2 packets.
if len(snap1) != 2 {
t.Errorf("snapshot should be independent of later cache mutations, got %d", len(snap1))
}
}
func TestKeyFrameCache_ConcurrentSnapshotAndPush(t *testing.T) {
c := newKeyFrameCache()
var wg sync.WaitGroup
// Single writer: 1000 pushes alternating IDR / P-frame.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
if i%50 == 0 {
c.push(makePacket([]byte{0x65, byte(i)}))
} else {
c.push(makePacket([]byte{0x41, byte(i)}))
}
}
}()
// Multiple readers: concurrent snapshots — must not data-race or panic.
for r := 0; r < 4; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 250; i++ {
_ = c.snapshot()
}
}()
}
wg.Wait()
}

View file

@ -65,6 +65,8 @@ type Peer struct {
done chan struct{} done chan struct{}
once sync.Once once sync.Once
connected chan struct{}
connOnce sync.Once
} }
// CreatePeer builds a PeerConnection, sets the remote offer, generates an // CreatePeer builds a PeerConnection, sets the remote offer, generates an
@ -131,9 +133,13 @@ func (f *PeerFactory) CreatePeer(ctx context.Context, src *Source, offer webrtc.
source: src, source: src,
sub: sub, sub: sub,
done: make(chan struct{}), done: make(chan struct{}),
connected: make(chan struct{}),
} }
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) { pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
if st == webrtc.PeerConnectionStateConnected {
p.connOnce.Do(func() { close(p.connected) })
}
if st == webrtc.PeerConnectionStateFailed || if st == webrtc.PeerConnectionStateFailed ||
st == webrtc.PeerConnectionStateDisconnected || st == webrtc.PeerConnectionStateDisconnected ||
st == webrtc.PeerConnectionStateClosed { st == webrtc.PeerConnectionStateClosed {
@ -158,6 +164,12 @@ func (p *Peer) ResourceID() string { return p.resourceID }
// index cleanup without polling. // index cleanup without polling.
func (p *Peer) Done() <-chan struct{} { return p.done } func (p *Peer) Done() <-chan struct{} { return p.done }
// Connected returns a channel that is closed the first time Pion reports
// PeerConnectionStateConnected. Callers that need to measure ICE
// establishment duration select on Connected() vs Done() from the moment
// the peer is created.
func (p *Peer) Connected() <-chan struct{} { return p.connected }
// Close tears down the peer connection and unsubscribes from each // Close tears down the peer connection and unsubscribes from each
// source. Safe to call multiple times. // source. Safe to call multiple times.
func (p *Peer) Close() error { func (p *Peer) Close() error {
@ -248,9 +260,13 @@ func (f *PeerFactory) CreatePeerFromSources(ctx context.Context,
videoSub: videoSub, videoSub: videoSub,
audioSub: audioSub, audioSub: audioSub,
done: make(chan struct{}), done: make(chan struct{}),
connected: make(chan struct{}),
} }
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) { pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
if st == webrtc.PeerConnectionStateConnected {
p.connOnce.Do(func() { close(p.connected) })
}
if st == webrtc.PeerConnectionStateFailed || if st == webrtc.PeerConnectionStateFailed ||
st == webrtc.PeerConnectionStateDisconnected || st == webrtc.PeerConnectionStateDisconnected ||
st == webrtc.PeerConnectionStateClosed { st == webrtc.PeerConnectionStateClosed {
@ -263,7 +279,6 @@ func (f *PeerFactory) CreatePeerFromSources(ctx context.Context,
return p, nil return p, nil
} }
// AddICECandidate forwards a trickle-ICE candidate to the underlying // AddICECandidate forwards a trickle-ICE candidate to the underlying
// PeerConnection. Returns the underlying error if the candidate is // PeerConnection. Returns the underlying error if the candidate is
// malformed or the connection has already been closed. // malformed or the connection has already been closed.

View file

@ -19,6 +19,11 @@ type Source struct {
started bool started bool
closed bool closed bool
done chan struct{} done chan struct{}
// cache is non-nil only for video sources that have had
// EnableKeyFrameCache() called. It holds the most recent H.264 IDR
// burst so new subscribers can receive a keyframe immediately.
cache *keyFrameCache
} }
// NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS // NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS
@ -61,13 +66,58 @@ func (s *Source) LocalAddr() *net.UDPAddr {
return s.conn.LocalAddr().(*net.UDPAddr) return s.conn.LocalAddr().(*net.UDPAddr)
} }
// EnableKeyFrameCache activates H.264 IDR keyframe burst caching for
// this source. Once enabled, new calls to Subscribe() will pre-fill the
// returned channel with the most recent IDR burst before registering it
// in the live fanout, cutting first-frame latency for late-joining peers
// from up to one keyframe interval to nearly zero.
//
// Call this on video sources only; calling it on audio sources is
// harmless but wastes memory accumulating non-IDR packets that will
// never trigger a cache reset.
//
// Must be called before Start(). Subsequent calls are no-ops.
func (s *Source) EnableKeyFrameCache() {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache == nil {
s.cache = newKeyFrameCache()
}
}
// Subscribe returns a new buffered channel that receives every RTP packet // Subscribe returns a new buffered channel that receives every RTP packet
// read from the UDP socket. bufDepth is the channel buffer size; when full, // read from the UDP socket. bufDepth is the channel buffer size; when full,
// packets are dropped (preventing a slow subscriber from back-pressuring // packets are dropped (preventing a slow subscriber from back-pressuring
// the reader). // the reader).
//
// If a keyframe cache is active (EnableKeyFrameCache was called), the
// channel is pre-filled with the most recent IDR burst before being
// registered in the live fanout, so the subscriber receives a complete
// reference frame immediately rather than waiting for the next keyframe.
func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet { func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet {
ch := make(chan *rtp.Packet, bufDepth) ch := make(chan *rtp.Packet, bufDepth)
// Snapshot outside s.mu to avoid any cross-lock ordering issue:
// readLoop acquires cache.mu (in push) then s.mu (in fanout), so
// we must not hold s.mu while calling snapshot (which acquires
// cache.mu). s.cache itself is immutable after EnableKeyFrameCache.
var burst []*rtp.Packet
if s.cache != nil {
burst = s.cache.snapshot()
}
s.mu.Lock() s.mu.Lock()
// Pre-fill with the IDR burst. Use a labeled break so that a full
// channel (bufDepth smaller than burst length) stops pre-filling
// gracefully — the subscriber will catch the next live keyframe.
prefill:
for _, pkt := range burst {
select {
case ch <- pkt:
default:
break prefill
}
}
s.subscribers[ch] = struct{}{} s.subscribers[ch] = struct{}{}
s.mu.Unlock() s.mu.Unlock()
return ch return ch
@ -118,6 +168,15 @@ func (s *Source) readLoop() {
continue continue
} }
// Update the keyframe cache (video sources only; push is a
// no-op on audio sources because isH264IDRStart returns false
// for Opus payload types). Called before the fanout so that a
// subscriber joining concurrently gets a snapshot that includes
// this packet if it is an IDR start.
if s.cache != nil {
s.cache.push(pkt)
}
s.mu.Lock() s.mu.Lock()
for ch := range s.subscribers { for ch := range s.subscribers {
select { select {

View file

@ -1,129 +1,150 @@
package webrtc package webrtc
import ( import (
"net"
"testing" "testing"
"time" "time"
"github.com/pion/rtp"
) )
func TestSource_ID(t *testing.T) { // TestSourceSubscribe_PreFillFromCache verifies that a subscriber joining
s, err := NewSource("streamA", 0) // 0 = ephemeral port // after an IDR packet has been pushed immediately receives the cached burst
// before any live packets arrive.
func TestSourceSubscribe_PreFillFromCache(t *testing.T) {
src, err := NewSource("test-prefill", 0)
if err != nil { if err != nil {
t.Fatalf("NewSource: %v", err) t.Fatalf("NewSource: %v", err)
} }
defer s.Close() defer src.Close()
if s.ID() != "streamA" { src.EnableKeyFrameCache()
t.Errorf("ID() = %q, want streamA", s.ID()) src.Start()
// Push directly into the cache — no need to go through UDP.
idrPkt := makePacket([]byte{0x65, 0x88, 0x84})
src.cache.push(idrPkt)
for i := 0; i < 3; i++ {
src.cache.push(makePacket([]byte{0x41, byte(i)}))
} }
// Subscribe with a buffer big enough to hold the burst.
ch := src.Subscribe(64)
// Channel should already contain 4 packets — no live UDP required.
if len(ch) != 4 {
t.Errorf("expected 4 pre-filled packets, got %d", len(ch))
}
// First packet must be the IDR.
first := <-ch
if first.Payload[0]&0x1F != 5 {
t.Errorf("first pre-fill packet should be IDR (type 5), got type %d", first.Payload[0]&0x1F)
}
src.Unsubscribe(ch)
} }
func TestSource_ReceiveAndFanout(t *testing.T) { // TestSourceSubscribe_NoCacheByDefault verifies that without
s, err := NewSource("streamA", 0) // EnableKeyFrameCache the channel starts empty.
func TestSourceSubscribe_NoCacheByDefault(t *testing.T) {
src, err := NewSource("test-nocache", 0)
if err != nil { if err != nil {
t.Fatalf("NewSource: %v", err) t.Fatalf("NewSource: %v", err)
} }
defer s.Close() defer src.Close()
// Subscribe before sending. src.Start()
sub := s.Subscribe(16) // buffer depth 16 ch := src.Subscribe(64)
defer s.Unsubscribe(sub)
s.Start() if len(ch) != 0 {
t.Errorf("expected empty channel without cache, got %d packets", len(ch))
// 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")
} }
src.Unsubscribe(ch)
} }
func TestSource_MultipleSubscribers(t *testing.T) { // TestSourceSubscribe_PreFillStopsOnFullChannel verifies that pre-fill does
s, err := NewSource("streamA", 0) // not block when bufDepth is smaller than the burst length.
func TestSourceSubscribe_PreFillStopsOnFullChannel(t *testing.T) {
src, err := NewSource("test-smallbuf", 0)
if err != nil { if err != nil {
t.Fatalf("NewSource: %v", err) t.Fatalf("NewSource: %v", err)
} }
defer s.Close() defer src.Close()
subs := []chan *rtp.Packet{ src.EnableKeyFrameCache()
s.Subscribe(8), src.Start()
s.Subscribe(8),
s.Subscribe(8), // Push 10 packets into the cache.
} src.cache.push(makePacket([]byte{0x65, 0x88})) // IDR
for _, sub := range subs { for i := 0; i < 9; i++ {
defer s.Unsubscribe(sub) src.cache.push(makePacket([]byte{0x41, byte(i)}))
} }
s.Start() // Subscribe with bufDepth=3 — only 3 should land.
ch := src.Subscribe(3)
raw, _ := (&rtp.Packet{ if len(ch) != 3 {
Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: 42, SSRC: 1}, t.Errorf("expected exactly 3 pre-filled packets (bufDepth cap), got %d", len(ch))
Payload: []byte{0xAA}, }
}).Marshal() src.Unsubscribe(ch)
conn, _ := net.Dial("udp", s.LocalAddr().String()) }
defer conn.Close()
_, _ = conn.Write(raw)
for i, sub := range subs { // TestSourceClose_UnsubscribesAll verifies that Close closes every subscriber
// channel so goroutines ranging over them terminate cleanly.
func TestSourceClose_UnsubscribesAll(t *testing.T) {
src, err := NewSource("test-close", 0)
if err != nil {
t.Fatalf("NewSource: %v", err)
}
src.Start()
ch1 := src.Subscribe(8)
ch2 := src.Subscribe(8)
if err := src.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
done := make(chan struct{}, 2)
go func() {
for range ch1 {
}
done <- struct{}{}
}()
go func() {
for range ch2 {
}
done <- struct{}{}
}()
timeout := time.After(500 * time.Millisecond)
for i := 0; i < 2; i++ {
select { select {
case got := <-sub: case <-done:
if got.SequenceNumber != 42 { case <-timeout:
t.Errorf("sub %d got seq %d, want 42", i, got.SequenceNumber) t.Error("subscriber channel not closed within 500ms of src.Close()")
} return
case <-time.After(2 * time.Second):
t.Errorf("sub %d timed out", i)
} }
} }
} }
func TestSource_UnsubscribeStopsDelivery(t *testing.T) { // TestEnableKeyFrameCache_Idempotent verifies that calling EnableKeyFrameCache
s, _ := NewSource("streamA", 0) // twice does not replace or reset an existing cache.
defer s.Close() func TestEnableKeyFrameCache_Idempotent(t *testing.T) {
sub := s.Subscribe(8) src, err := NewSource("test-idempotent", 0)
s.Start() if err != nil {
s.Unsubscribe(sub) t.Fatalf("NewSource: %v", err)
// 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): defer src.Close()
t.Error("timed out waiting for channel close")
src.EnableKeyFrameCache()
firstCache := src.cache
src.cache.push(makePacket([]byte{0x65, 0x01}))
src.EnableKeyFrameCache() // second call — must be a no-op
if src.cache != firstCache {
t.Error("EnableKeyFrameCache should not replace an existing cache")
}
if len(src.cache.snapshot()) != 1 {
t.Error("second EnableKeyFrameCache call should not clear the cache contents")
} }
} }

195
core/webrtc/whip.go Normal file
View file

@ -0,0 +1,195 @@
package webrtc
import (
"context"
"fmt"
"net"
"sync"
"github.com/pion/webrtc/v4"
)
// IngestPeer receives a WebRTC publish stream (WHIP protocol) and
// forwards the received RTP tracks to loopback UDP ports for FFmpeg
// consumption. It is the symmetric inverse of the egress Peer:
//
// Publisher (browser / OBS) -> WebRTC -> IngestPeer -> UDP -> FFmpeg input
//
// FFmpeg must already be bound on videoPort/audioPort (i.e., the process
// has started with those ports as its RTP input legs) before the first
// RTP packets arrive — the loopback UDP writes are fire-and-forget and
// harmless if FFmpeg hasn't opened the socket yet.
type IngestPeer struct {
resourceID string
pc *webrtc.PeerConnection
answer webrtc.SessionDescription
// Destination UDP addresses — FFmpeg's bound RTP input sockets.
videoAddr *net.UDPAddr
audioAddr *net.UDPAddr
// Shared sender socket used for all forwarded packets.
udpConn *net.UDPConn
done chan struct{}
once sync.Once
connected chan struct{}
connOnce sync.Once
}
// CreateIngestPeer builds a recvonly PeerConnection, sets the remote
// offer (from the WHIP publisher), creates and gathers the answer, then
// wires OnTrack to forward received video and audio RTP to videoPort and
// audioPort on localhost respectively.
//
// videoPort and audioPort must be loopback UDP ports that FFmpeg (or any
// other RTP consumer) is already listening on. The caller owns the returned
// peer and must call Close() when done.
func (f *PeerFactory) CreateIngestPeer(ctx context.Context,
offer webrtc.SessionDescription,
videoPort, audioPort int) (*IngestPeer, error) {
pc, err := f.api.NewPeerConnection(f.rtcConfig)
if err != nil {
return nil, fmt.Errorf("webrtc: whip: new peer connection: %w", err)
}
// Add recvonly transceivers so the SDP negotiation offers to
// receive both video and audio from the publisher.
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: add video transceiver: %w", err)
}
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: add audio transceiver: %w", err)
}
if err := pc.SetRemoteDescription(offer); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: set remote: %w", err)
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: create answer: %w", err)
}
gatherComplete := webrtc.GatheringCompletePromise(pc)
if err := pc.SetLocalDescription(answer); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: set local: %w", err)
}
select {
case <-gatherComplete:
case <-ctx.Done():
_ = pc.Close()
return nil, ErrICETimeout
}
// Shared UDP sender socket for forwarding RTP to FFmpeg.
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
if err != nil {
_ = pc.Close()
return nil, fmt.Errorf("webrtc: whip: bind sender socket: %w", err)
}
p := &IngestPeer{
resourceID: newResourceID(),
pc: pc,
answer: *pc.LocalDescription(),
videoAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: videoPort},
audioAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: audioPort},
udpConn: udpConn,
done: make(chan struct{}),
connected: make(chan struct{}),
}
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
if st == webrtc.PeerConnectionStateConnected {
p.connOnce.Do(func() { close(p.connected) })
}
if st == webrtc.PeerConnectionStateFailed ||
st == webrtc.PeerConnectionStateDisconnected ||
st == webrtc.PeerConnectionStateClosed {
_ = p.Close()
}
})
// Wire each incoming track to its UDP destination. OnTrack fires
// once per negotiated media section; we expect at most one video
// and one audio track.
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
var dst *net.UDPAddr
switch track.Kind() {
case webrtc.RTPCodecTypeVideo:
dst = p.videoAddr
case webrtc.RTPCodecTypeAudio:
dst = p.audioAddr
default:
// Unknown media kind — ignore.
return
}
go p.forwardTrack(track, dst)
})
return p, nil
}
// forwardTrack reads raw RTP packets from the remote track and writes
// them verbatim to dst via the shared UDP sender socket. Exits when
// p.done is closed or the track read errors (e.g., peer connection
// closed by the remote).
func (p *IngestPeer) forwardTrack(track *webrtc.TrackRemote, dst *net.UDPAddr) {
buf := make([]byte, 1500) // MTU-sized; same as Source.readLoop
for {
select {
case <-p.done:
return
default:
}
n, _, err := track.Read(buf)
if err != nil {
// Track closed or peer gone — exit cleanly.
return
}
// WriteToUDP is non-blocking from the caller's perspective:
// if FFmpeg hasn't bound the port yet the OS will ICMP-reject
// and we'll get a net.Error. We ignore write errors to avoid
// thrashing on transient startup races.
_, _ = p.udpConn.WriteToUDP(buf[:n], dst)
}
}
// Answer returns the locally-created SDP answer. Valid after CreateIngestPeer.
func (p *IngestPeer) Answer() webrtc.SessionDescription { return p.answer }
// ResourceID returns the stable resource id used in the WHIP Location header.
func (p *IngestPeer) ResourceID() string { return p.resourceID }
// Done returns a channel closed when the peer has been torn down.
func (p *IngestPeer) Done() <-chan struct{} { return p.done }
// Connected returns a channel closed when ICE first reaches Connected state.
func (p *IngestPeer) Connected() <-chan struct{} { return p.connected }
// AddICECandidate forwards a trickle-ICE candidate to the underlying
// PeerConnection.
func (p *IngestPeer) AddICECandidate(c webrtc.ICECandidateInit) error {
return p.pc.AddICECandidate(c)
}
// Close tears down the peer connection and stops all track forwarders.
// Safe to call multiple times.
func (p *IngestPeer) Close() error {
var err error
p.once.Do(func() {
close(p.done)
_ = p.udpConn.Close()
err = p.pc.Close()
})
return err
}

View file

@ -6,7 +6,10 @@
# #
# Two-stage: # Two-stage:
# 1. builder: compile a static Go binary (CGO off — no dynamic libs) # 1. builder: compile a static Go binary (CGO off — no dynamic libs)
# 2. runtime: alpine with ffmpeg for the subprocess path # 2. ui-builder: clone wilddragon-restreamer-ui, apply its overlay on
# top of upstream datarhei/restreamer-ui v1.14.0, yarn build.
# See https://forge.wilddragon.net/zgaetano/wilddragon-restreamer-ui
# 3. runtime: alpine with ffmpeg for the subprocess path
# #
# Usage via compose: # Usage via compose:
# docker compose -f deploy/truenas/core/docker-compose.yml up -d --build # docker compose -f deploy/truenas/core/docker-compose.yml up -d --build
@ -27,6 +30,38 @@ COPY . .
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN make release && make import && make ffmigrate RUN make release && make import && make ffmigrate
# ---- ui-builder ----
# Clones wilddragon-restreamer-ui (the Wild Dragon UI fork) which
# provides apply-overlay.sh and all overlay files (branding, WebRTC
# WHEP controls). Then clones upstream restreamer-ui at the pinned
# tag, applies the overlay, and runs yarn build.
#
# WD_UI_REF: branch or tag in wilddragon-restreamer-ui to build from.
# RESTREAMER_UI_REF: upstream datarhei/restreamer-ui tag to base on.
FROM node:21-alpine3.20 AS ui-builder
ARG WD_UI_REF=main
ARG RESTREAMER_UI_REF=v1.14.0
RUN apk add --no-cache git
# Wild Dragon UI fork: overlay files + apply-overlay.sh
RUN git clone --depth=1 --branch ${WD_UI_REF} \
https://forge.wilddragon.net/zgaetano/wilddragon-restreamer-ui.git /wd-ui
# Upstream React SPA at the pinned version
RUN git clone --depth=1 --branch ${RESTREAMER_UI_REF} \
https://github.com/datarhei/restreamer-ui.git /ui
WORKDIR /ui
RUN yarn install --frozen-lockfile --network-timeout 600000
# Apply Wild Dragon branding + WebRTC controls.
# chmod is required because git may clone the script without +x.
RUN chmod +x /wd-ui/apply-overlay.sh && \
OVERLAY=/wd-ui/overlay UI=/ui /wd-ui/apply-overlay.sh
RUN PUBLIC_URL="./" GENERATE_SOURCEMAP=false yarn build
# ---- runtime ---- # ---- runtime ----
# Alpine with ffmpeg (Core shells out to it for every restream process). # Alpine with ffmpeg (Core shells out to it for every restream process).
# Scratch isn't an option here because the process manager needs ffmpeg # Scratch isn't an option here because the process manager needs ffmpeg
@ -47,9 +82,15 @@ COPY --from=builder /src/ffmigrate /core/bin/ffmigrate
COPY --from=builder /src/mime.types /core/mime.types COPY --from=builder /src/mime.types /core/mime.types
COPY --from=builder /src/run.sh /core/bin/run.sh COPY --from=builder /src/run.sh /core/bin/run.sh
# Dragon Fork landing page + browser WHEP player. Seeded into # Static content for /core/data, seeded on first boot by seed-data.sh.
# /core/data on first boot by /core/bin/seed-data.sh below; the seed # Stacking order:
# is a no-op when the operator has already put content in /core/data. # 1. Restreamer UI bundle (the React SPA — gives us index.html)
# 2. Dragon Fork extras (whep-player.html, etc.) — won't overwrite
# the UI's index.html (seed-data is no-clobber).
#
# The result: GET / serves the Wild Dragon dashboard, and
# /whep-player.html serves the standalone WHEP smoke player.
COPY --from=ui-builder /ui/build/ /core/static/
COPY --from=builder /src/deploy/truenas/core/static/ /core/static/ COPY --from=builder /src/deploy/truenas/core/static/ /core/static/
COPY --from=builder /src/deploy/truenas/core/seed-data.sh /core/bin/seed-data.sh COPY --from=builder /src/deploy/truenas/core/seed-data.sh /core/bin/seed-data.sh

View file

@ -55,7 +55,45 @@ docker compose logs -f
You should see Core come up logging all configured listeners, including You should see Core come up logging all configured listeners, including
a line from the WebRTC component confirming the subsystem is enabled. a line from the WebRTC component confirming the subsystem is enabled.
## Smoke-test via API The first build takes ~5 minutes — it compiles Core from source AND
builds the React UI bundle. Subsequent rebuilds are faster (Docker
layer cache).
## GUI surfaces
Once the stack is up, three browser-reachable UIs ship out of the box:
| URL | What it is |
| --- | --- |
| `http://<host>:8080/` | The full Restreamer UI (rebranded "Wild Dragon"). Manage processes, configure ingests, set up RTMP/SRT/HLS outputs, view logs. The standard Datarhei admin experience. |
| `http://<host>:8080/wilddragon-webrtc.html` | Wild Dragon WebRTC admin. Sign in, pick a process, click "Enable WebRTC". The page restarts the process so the new RTP output legs go live, then surfaces the WHEP URL with a one-click jump to the smoke player. **The fastest path from "I want WebRTC on this stream" to "the smoke player is rendering it."** |
| `http://<host>:8080/whep-player.html` | Standalone WHEP subscriber (the smoke player). ICE / codec / bitrate diagnostics, JWT input, shareable URLs. Use to verify WebRTC actually works after enabling it. |
| `http://<host>:8080/api/swagger/index.html` | Swagger API docs. Same auth. Hit the WHEP endpoints directly when scripting. |
The Restreamer UI doesn't (yet) have a WebRTC checkbox in its process editor —
that's why the standalone admin page exists. A proper UI fork that adds
WebRTC controls inline is tracked in issue #15.
## End-to-end smoke test
```
1. Open http://<host>:8080/ (Restreamer UI). Sign in with admin / <API_AUTH_PASSWORD>.
2. Create a new "Source" with type RTMP. Note the RTMP push URL it shows.
3. Push your test source to that URL (OBS, ffmpeg, etc.). Confirm it
shows "running" in the UI.
4. Open http://<host>:8080/wilddragon-webrtc.html. Sign in with the same creds.
5. Click "Enable WebRTC" on the process you just created.
6. Click the "open ↗" link next to the WHEP URL to load the smoke player.
7. Click "Subscribe" in the smoke player. Within ~1s you should see your
RTMP source rendering as WebRTC.
```
If step 7 hangs, the most common cause is `PUBLIC_IP` in `.env` not
matching what the browser can actually reach (host firewall, wrong
LAN IP, etc.). Check the WHEP smoke player's log panel — it'll
surface the ICE state transitions.
## Smoke-test via API directly
``` ```
# Issue a JWT against the admin creds from .env: # Issue a JWT against the admin creds from .env:
@ -100,3 +138,7 @@ docker compose down
This matches how the upstream datarhei/core image ships. This matches how the upstream datarhei/core image ships.
- Put Caddy or nginx in front for TLS. The media itself is - Put Caddy or nginx in front for TLS. The media itself is
DTLS-SRTP-encrypted regardless. DTLS-SRTP-encrypted regardless.
- The Wild Dragon WebRTC admin page (`/wilddragon-webrtc.html`) talks to
the same JWT-protected API. The token is held in `localStorage` and
cleared when you click "Sign out". If you've configured Core's API
to require auth — which you should — this page is gated by it.

View file

@ -1,13 +1,12 @@
# Dragon Fork datarhei Core — M2 deployment with WebRTC egress. # Dragon Fork datarhei Core — v0.2 deployment with WebRTC egress and observability.
# #
# This replaces the M1 webrtc-poc stack. It runs the real root Core # This replaces the M2 stack. Adds Prometheus and Grafana containers so the
# binary with the WebRTC subsystem wired into the restream manager, so # operator can answer "is WebRTC healthy right now?" from a single dashboard
# every process whose config has `webrtc.enabled=true` will have its # without tailing logs or hitting the API.
# output fanned out to WHEP subscribers automatically.
# #
# Host networking is required for the same reason as M1: ICE encodes # Host networking is required for WebRTC ICE (see deploy/truenas/docker-compose.yml).
# host:port pairs into SDP candidates, and bridge-mode port mapping # Prometheus and Grafana sit on a bridge network (dragonfork-mon) and reach
# breaks that. # Core via host.docker.internal:CORE_HTTP_PORT.
# #
# Copy this file to /mnt/NVME/Docker/dragonfork-core/ alongside a .env: # Copy this file to /mnt/NVME/Docker/dragonfork-core/ alongside a .env:
# #
@ -15,6 +14,7 @@
# API_AUTH_USERNAME=admin # API_AUTH_USERNAME=admin
# API_AUTH_PASSWORD=change-me-please # API_AUTH_PASSWORD=change-me-please
# API_AUTH_JWT_SECRET=<32+ random bytes, base64> # API_AUTH_JWT_SECRET=<32+ random bytes, base64>
# GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
# #
# Then: # Then:
# docker compose up -d --build # docker compose up -d --build
@ -39,11 +39,12 @@ services:
# --- WebRTC egress --- # --- WebRTC egress ---
CORE_WEBRTC_ENABLE: "true" CORE_WEBRTC_ENABLE: "true"
CORE_WEBRTC_PUBLIC_IP: "${PUBLIC_IP:?set in .env}" 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 --- # --- Port overrides ---
# Let the volumes below provide durable paths; defaults are fine. CORE_RTMP_ADDRESS: "${CORE_RTMP_ADDRESS:-:1935}"
CORE_RTMP_ADDRESS_TLS: "${CORE_RTMP_ADDRESS_TLS:-:1936}"
CORE_SRT_ADDRESS: "${CORE_SRT_ADDRESS:-:6000}"
CORE_TLS_ADDRESS: "${CORE_TLS_ADDRESS:-:8181}"
# --- Logging --- # --- Logging ---
CORE_LOG_LEVEL: "${LOG_LEVEL:-info}" CORE_LOG_LEVEL: "${LOG_LEVEL:-info}"
@ -52,5 +53,47 @@ services:
- ./config:/core/config - ./config:/core/config
- ./data:/core/data - ./data:/core/data
# No ports: host networking exposes whatever the process binds. prom:
# The WHEP endpoint lives at /api/v3/whep/:id on CORE_HTTP_PORT. image: prom/prometheus:v2.55.0
container_name: dragonfork-prom
restart: unless-stopped
networks: [dragonfork-mon]
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./prom/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prom/rules:/etc/prometheus/rules:ro
- prom-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=${PROM_RETENTION:-15d}
- --storage.tsdb.path=/prometheus
- --web.console.libraries=/usr/share/prometheus/console_libraries
- --web.console.templates=/usr/share/prometheus/consoles
ports:
- "${PROM_PORT:-9090}:9090"
grafana:
image: grafana/grafana-oss:11.3.0
container_name: dragonfork-grafana
restart: unless-stopped
networks: [dragonfork-mon]
depends_on: [prom]
environment:
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?set in .env}"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
ports:
- "${GRAFANA_PORT:-3000}:3000"
networks:
dragonfork-mon:
driver: bridge
volumes:
prom-data:
grafana-data:

View file

@ -0,0 +1,213 @@
{
"__inputs": [],
"__requires": [
{"type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.3.0"},
{"type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0"}
],
"annotations": {"list": []},
"description": "Dragon Fork WebRTC egress health: WHEP API, ICE establishment, active streams/peers, capacity, and silent-degradation canary.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
"id": 1,
"title": "WHEP API Health",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 0.1}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 1},
"id": 2,
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [{"expr": "sum(rate(dragonfork_webrtc_whep_requests_total{code=~\"4..|5..\"}[5m]))", "legendFormat": "error rate/s"}],
"title": "WHEP Error Rate",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
"gridPos": {"h": 8, "w": 9, "x": 6, "y": 1},
"id": 3,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "sum by (route) (rate(dragonfork_webrtc_whep_requests_total{code=~\"2..\"}[5m]))", "legendFormat": "{{route}} 2xx"},
{"expr": "sum by (route, code) (rate(dragonfork_webrtc_whep_requests_total{code=~\"4..|5..\"}[5m]))", "legendFormat": "{{route}} {{code}}"}
],
"title": "WHEP Request Rate by Route",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "s"}, "overrides": []},
"gridPos": {"h": 8, "w": 9, "x": 15, "y": 1},
"id": 4,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "histogram_quantile(0.95, sum by (le, route) (rate(dragonfork_webrtc_whep_request_duration_seconds_bucket[5m])))", "legendFormat": "p95 {{route}}"},
{"expr": "histogram_quantile(0.50, sum by (le, route) (rate(dragonfork_webrtc_whep_request_duration_seconds_bucket[5m])))", "legendFormat": "p50 {{route}}"}
],
"title": "WHEP Request Duration (p50/p95)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 9},
"id": 10,
"title": "ICE Establishment",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "s"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 10},
"id": 11,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "histogram_quantile(0.95, sum by (le, stream_id, result) (rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])))", "legendFormat": "p95 {{stream_id}} {{result}}"},
{"expr": "histogram_quantile(0.50, sum by (le, stream_id, result) (rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])))", "legendFormat": "p50 {{stream_id}} {{result}}"}
],
"title": "ICE Establishment Duration (p50/p95)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "cps"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 10},
"id": 12,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "sum by (stream_id, reason) (rate(dragonfork_webrtc_ice_failures_total[5m]))", "legendFormat": "{{stream_id}} {{reason}}"}
],
"title": "ICE Failure Rate",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 18},
"id": 20,
"title": "Active Streams & Peers",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}}, "overrides": []},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 19},
"id": 21,
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [{"expr": "dragonfork_webrtc_active_streams", "legendFormat": "streams"}],
"title": "Active Streams",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"gridPos": {"h": 8, "w": 20, "x": 4, "y": 19},
"id": 22,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "dragonfork_webrtc_active_peers", "legendFormat": "{{stream_id}}"}
],
"title": "Active Peers per Stream",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 27},
"id": 30,
"title": "Capacity & Rejections",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 4}, {"color": "red", "value": 8}]}
},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 28},
"id": 31,
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [{"expr": "dragonfork_webrtc_udp_ports_in_use", "legendFormat": "in use"}],
"title": "UDP Ports In Use",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "cps"}, "overrides": []},
"gridPos": {"h": 8, "w": 20, "x": 4, "y": 28},
"id": 32,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "sum by (stream_id, scope) (rate(dragonfork_webrtc_cap_rejections_total[5m]))", "legendFormat": "{{stream_id}} {{scope}}"}
],
"title": "Cap Rejection Rate (503s)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 36},
"id": 40,
"title": "Silent Degradation Canary",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 37},
"id": 41,
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{"expr": "increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m])", "legendFormat": "{{stream_id}} {{leg}}"}
],
"title": "FFmpeg RTP Leg Failures (5m window)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 37},
"id": 42,
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["sum"]}},
"targets": [
{"expr": "sum by (stream_id, kind) (increase(dragonfork_webrtc_codec_mismatches_total[1h]))", "legendFormat": "{{stream_id}} {{kind}}"}
],
"title": "Codec Mismatches (1h)",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["dragonfork", "webrtc"],
"templating": {
"list": [
{
"current": {},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
}
]
},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "Dragon Fork — WebRTC Health",
"uid": "dragonfork-webrtc-health",
"version": 1
}

View file

@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: dragonfork
orgId: 1
folder: "Dragon Fork"
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards

View file

@ -0,0 +1,8 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prom:9090
isDefault: true
editable: false

View file

@ -0,0 +1,19 @@
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
external_labels:
core: dragonfork-truenas
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: dragonfork-core
static_configs:
- targets: ["host.docker.internal:${CORE_HTTP_PORT:-8080}"]
metrics_path: /metrics
# If API auth is enabled on /metrics, uncomment and add creds:
# basic_auth:
# username: <user>
# password: <pass>

View file

@ -0,0 +1,45 @@
groups:
- name: dragonfork-webrtc
rules:
- alert: WebRTCWHEPErrorRateHigh
expr: |
sum by (stream_id) (
rate(dragonfork_webrtc_whep_requests_total{code=~"4..|5.."}[5m])
) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "WHEP error rate high on stream {{ $labels.stream_id }}"
description: "Sustained 4xx/5xx rate >0.5/sec for 5m."
- alert: WebRTCICEEstablishmentSlow
expr: |
histogram_quantile(0.95,
sum by (le, stream_id) (
rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])
)
) > 3
for: 10m
labels:
severity: warning
annotations:
summary: "ICE establishment p95 >3s on {{ $labels.stream_id }}"
- alert: WebRTCICEFailureRateHigh
expr: |
sum by (stream_id) (rate(dragonfork_webrtc_ice_failures_total[5m])) > 0.2
for: 5m
labels:
severity: warning
annotations:
summary: "ICE failures sustained on {{ $labels.stream_id }}"
- alert: WebRTCFFmpegLegFailure
expr: |
increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m]) > 0
labels:
severity: critical
annotations:
summary: "FFmpeg RTP leg failed on {{ $labels.stream_id }} ({{ $labels.leg }})"
description: "Process stopped while peers were active. Check FFmpeg logs."

View file

@ -1,10 +1,16 @@
#!/bin/sh #!/bin/sh
# seed-data.sh — first-boot seed of /core/data with Dragon Fork # seed-data.sh — boot-time seed of /core/data with Dragon Fork
# landing page artifacts (index.html, whep-player.html). # landing page artifacts (index.html, whep-player.html, compiled UI bundle).
# #
# Runs from the entrypoint before bin/core. Skips itself if any of the # Runs from the entrypoint before bin/core.
# target files already exist, so user-supplied content (or content from #
# a previous deploy that they edited) is never clobbered. # Strategy:
# - Build artifacts (index.html, asset-manifest.json, static/) are ALWAYS
# overwritten so a redeployed container picks up the new UI bundle even
# when the data volume already exists. The React bundle hash changes every
# build, so leaving the old bundle in place would serve a stale UI.
# - Everything else (channels/, _player/, _playersite/, custom HTML files)
# is no-clobber so operator-edited content is never lost.
# #
# Source dir: /core/static (baked by the Dockerfile) # Source dir: /core/static (baked by the Dockerfile)
# Target dir: /core/data (operator-mounted; what Core serves at /) # Target dir: /core/data (operator-mounted; what Core serves at /)
@ -23,11 +29,27 @@ if [ ! -d "$DST" ]; then
mkdir -p "$DST" mkdir -p "$DST"
fi fi
for f in "$SRC"/*; do # Always-overwrite entries: build artifacts whose content changes every deploy.
for always in index.html asset-manifest.json static; do
src="$SRC/$always"
dst="$DST/$always"
[ -e "$src" ] || continue
if [ -d "$src" ]; then
cp -Rfp "$src" "$dst"
else
cp -fp "$src" "$dst"
fi
echo "seed-data: refreshed $always -> $dst"
done
# No-clobber entries: everything else (operator content, player bundles, etc.)
for f in "$SRC"/* "$SRC"/.[!.]*; do
[ -e "$f" ] || continue [ -e "$f" ] || continue
name=$(basename "$f") name=$(basename "$f")
# Skip entries already handled above.
case "$name" in index.html|asset-manifest.json|static) continue ;; esac
if [ ! -e "$DST/$name" ]; then if [ ! -e "$DST/$name" ]; then
cp -p "$f" "$DST/$name" cp -Rp "$f" "$DST/$name"
echo "seed-data: copied $name -> $DST/$name" echo "seed-data: copied $name -> $DST/$name"
fi fi
done done

View file

@ -1,69 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Datarhei — Dragon Fork</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark; --fg:#e7e7ea; --bg:#0d0e12; --accent:#ff6633; --muted:#8b8e98; --panel:#1a1c22; }
* { box-sizing: border-box; }
body { margin:0; font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--fg); min-height:100vh; }
header { padding:1.5rem 2rem; border-bottom:1px solid #232530; }
header h1 { margin:0; font-size:1.2rem; letter-spacing:0.02em; }
header h1 .accent { color:var(--accent); }
header .subtitle { color:var(--muted); font-size:0.85rem; margin-top:0.3rem; }
main { max-width:840px; margin:0 auto; padding:2rem; }
h2 { font-size:1.05rem; margin-top:2rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.06em; }
.card { background:var(--panel); border-radius:10px; padding:1.25rem 1.5rem; margin-bottom:1rem; }
.card a { color:var(--accent); text-decoration:none; font-weight:600; }
.card a:hover { text-decoration:underline; }
.card p { color:var(--muted); margin:0.4rem 0 0; font-size:0.9rem; }
code { background:#0d0e12; padding:0.1rem 0.4rem; border-radius:4px; font:0.85em ui-monospace,Menlo,Consolas,monospace; }
footer { padding:2rem; text-align:center; color:var(--muted); font-size:0.8rem; }
</style>
</head>
<body>
<header>
<h1>Datarhei <span class="accent">Dragon Fork</span></h1>
<div class="subtitle">a fork of <a href="https://github.com/datarhei/core" style="color:var(--muted)">datarhei/core</a> with native WebRTC (WHEP) egress</div>
</header>
<main>
<h2>Quick links</h2>
<div class="card">
<a href="/api/swagger/index.html">API documentation (Swagger UI)</a>
<p>Full HTTP API including <code>/api/v3/process</code>, <code>/api/v3/whep/{id}</code>, RTMP / SRT / config / metrics. Most endpoints require a JWT — issue one via <code>POST /api/login</code>.</p>
</div>
<div class="card">
<a href="/whep-player.html">Browser WHEP player</a>
<p>Self-contained subscriber for any process whose <code>config.webrtc.enabled = true</code>. Paste the WHEP URL and a JWT; press Subscribe.</p>
</div>
<h2>About this build</h2>
<div class="card" id="about">Loading…</div>
<h2>How to add a WebRTC stream</h2>
<div class="card">
<p style="color:var(--fg)">Create a process with <code>"webrtc": { "enabled": true }</code>. Once it starts, <code>POST /api/v3/whep/&lt;process-id&gt;</code> takes an SDP offer and returns an SDP answer.</p>
</div>
</main>
<footer>Datarhei — Dragon Fork &middot; Apache License 2.0 &middot; Built on datarhei Core + Pion WebRTC</footer>
<script>
fetch('/api')
.then(r => r.json())
.then(d => {
const el = document.getElementById('about');
const v = d.version || {};
el.innerHTML = '<p style="color:var(--fg)">' +
'<strong>' + (d.fork || d.app) + '</strong> &middot; ' +
'instance <code>' + (d.name || '?') + '</code> &middot; ' +
'version <code>' + (v.number || '?') + '</code> &middot; ' +
'commit <code>' + ((v.repository_commit || '?').slice(0,8)) + '</code>' +
'</p>';
})
.catch(() => {
document.getElementById('about').innerHTML =
'<p>Status panel needs an authenticated request. Visit <a href="/api/swagger/index.html">Swagger</a> to log in.</p>';
});
</script>
</body>
</html>

View file

@ -0,0 +1,711 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Wild Dragon — WebRTC Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #09090d;
--panel: #111118;
--panel2: #16161f;
--border: #1f1f2e;
--border2: #2a2a3d;
--fg: #e8e8f0;
--fg2: #9898b0;
--accent: #ff5c28;
--accent2: #ff7a4a;
--good: #4ecb8d;
--bad: #ff4d60;
--warn: #f5a623;
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Rajdhani', system-ui, sans-serif;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
flex-direction: column;
/* dot grid */
background-image: radial-gradient(circle, #1e1e30 1px, transparent 1px);
background-size: 22px 22px;
background-attachment: fixed;
}
/* ── HEADER ─────────────────────────────────────────────────── */
header {
position: sticky;
top: 0;
z-index: 100;
height: 52px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
background: rgba(9,9,13,0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
/* WD monogram — 28×28 */
.header-monogram {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.header-divider {
width: 1px;
height: 22px;
background: var(--border2);
flex-shrink: 0;
}
.header-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg);
}
.header-title span { color: var(--accent); }
.header-spacer { flex: 1; }
nav a {
color: var(--fg2);
text-decoration: none;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-left: 20px;
transition: color 0.15s;
}
nav a:hover { color: var(--accent); }
/* ── MAIN ───────────────────────────────────────────────────── */
main {
flex: 1;
padding: 32px 24px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
/* ── LOGIN PANEL ────────────────────────────────────────────── */
#login-panel {
max-width: 400px;
margin: 48px auto 0;
}
.login-wordmark {
display: block;
margin: 0 auto 28px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.panel-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg2);
}
/* ── FORM ELEMENTS ──────────────────────────────────────────── */
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg2);
margin-bottom: 6px;
}
.field input {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border2);
border-radius: 8px;
color: var(--fg);
font-family: 'Rajdhani', sans-serif;
font-size: 15px;
font-weight: 500;
transition: border-color 0.15s;
}
.field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255,92,40,0.12);
}
/* ── BUTTONS ────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border: none;
border-radius: 8px;
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
.btn-secondary { background: var(--panel2); color: var(--fg); border: 1px solid var(--border2); }
.btn-secondary:hover:not(:disabled) { border-color: var(--fg2); }
.btn-danger { background: rgba(255,77,96,0.12); color: var(--bad); border: 1px solid rgba(255,77,96,0.25); }
.btn-danger:hover:not(:disabled) { background: rgba(255,77,96,0.22); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
/* ── BADGES ─────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge-good { background: rgba(78,203,141,0.12); color: var(--good); }
.badge-bad { background: rgba(255,77,96,0.12); color: var(--bad); }
.badge-warn { background: rgba(245,166,35,0.12); color: var(--warn); }
.badge-neutral { background: var(--panel2); color: var(--fg2); }
.badge-accent { background: rgba(255,92,40,0.12); color: var(--accent); }
/* animated pulse dot */
.pulse-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.pulse-dot.good { background: var(--good); box-shadow: 0 0 0 0 rgba(78,203,141,0.4); animation: pulse-green 2s infinite; }
.pulse-dot.bad { background: var(--bad); box-shadow: 0 0 0 0 rgba(255,77,96,0.4); animation: pulse-red 2s infinite; }
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 0 0 rgba(245,166,35,0.4); animation: pulse-warn 2s infinite; }
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(78,203,141,0.5); }
70% { box-shadow: 0 0 0 6px rgba(78,203,141,0); }
100% { box-shadow: 0 0 0 0 rgba(78,203,141,0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(255,77,96,0.5); }
70% { box-shadow: 0 0 0 6px rgba(255,77,96,0); }
100% { box-shadow: 0 0 0 0 rgba(255,77,96,0); }
}
@keyframes pulse-warn {
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
}
/* ── PROCESS CARDS ──────────────────────────────────────────── */
.process-list { display: flex; flex-direction: column; gap: 10px; }
.process-card {
background: var(--panel2);
border: 1px solid var(--border);
border-left: 3px solid var(--border2);
border-radius: 10px;
padding: 16px 18px;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px 16px;
transition: border-color 0.2s;
}
.process-card.state-running { border-left-color: var(--good); }
.process-card.state-failed { border-left-color: var(--bad); }
.process-card.state-connecting { border-left-color: var(--warn); }
.process-card.state-finished { border-left-color: var(--fg2); }
.process-id {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
color: var(--fg);
word-break: break-all;
margin-bottom: 8px;
}
.process-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.process-ref {
font-size: 12px;
color: var(--fg2);
font-family: var(--mono);
}
.process-actions {
display: flex;
gap: 8px;
align-items: flex-start;
justify-content: flex-end;
}
.whep-row {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255,92,40,0.06);
border: 1px solid rgba(255,92,40,0.2);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
word-break: break-all;
}
.whep-row .whep-label {
font-family: 'Rajdhani', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg2);
flex-shrink: 0;
}
.whep-row .whep-url-text { flex: 1; }
.whep-row .whep-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ── LOG ────────────────────────────────────────────────────── */
.log {
margin-top: 16px;
max-height: 160px;
overflow-y: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.log .ts { color: var(--fg2); }
.log .l-bad { color: var(--bad); }
.log .l-good { color: var(--good); }
.log .l-warn { color: var(--warn); }
/* ── EMPTY STATE ─────────────────────────────────────────────── */
.empty {
text-align: center;
padding: 40px 20px;
color: var(--fg2);
font-size: 15px;
}
/* ── ROW UTIL ────────────────────────────────────────────────── */
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
</style>
</head>
<body>
<!-- ── HEADER ──────────────────────────────────────────────────── -->
<header>
<a class="header-logo" href="/">
<!-- WD monogram 28×28 -->
<svg class="header-monogram" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="28" height="28" rx="6" fill="#1a1a24"/>
<!-- flame chevron -->
<path d="M14 20 L8 10 L11.5 13 L14 8 L16.5 13 L20 10 Z" fill="#ff5c28" opacity="0.9"/>
<!-- WD text mark -->
<text x="3.5" y="26" font-family="'Rajdhani',sans-serif" font-size="8.5" font-weight="700" fill="#e8e8f0" letter-spacing="0.5">WD</text>
</svg>
</a>
<div class="header-divider"></div>
<span class="header-title">WebRTC <span>Admin</span></span>
<div class="header-spacer"></div>
<nav>
<a href="/">Restreamer</a>
<a href="/whep-player.html">WHEP Player</a>
<a href="/api/swagger/index.html">API</a>
<a href="#" id="logout-link" style="display:none">Sign out</a>
</nav>
</header>
<!-- ── MAIN ────────────────────────────────────────────────────── -->
<main>
<!-- LOGIN PANEL -->
<section id="login-panel">
<!-- Wild Dragon wordmark SVG -->
<svg class="login-wordmark" width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Wild Dragon">
<!-- rounded dark rect icon -->
<rect x="0" y="4" width="40" height="40" rx="8" fill="#1a1a24"/>
<!-- flame gradient defs -->
<defs>
<linearGradient id="flameG" x1="20" y1="38" x2="20" y2="12" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ff5c28"/>
<stop offset="100%" stop-color="#ffaa44"/>
</linearGradient>
</defs>
<!-- flame/chevron -->
<path d="M20 36 L11 20 L16 24 L20 14 L24 24 L29 20 Z" fill="url(#flameG)"/>
<!-- W mark -->
<text x="5" y="44" font-family="'Rajdhani',sans-serif" font-size="13" font-weight="700" fill="#c8c8e0" letter-spacing="1">WD</text>
<!-- WILD text -->
<text x="50" y="26" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="300" letter-spacing="4" fill="#e8e8f0">WILD</text>
<!-- DRAGON text -->
<text x="50" y="44" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="700" letter-spacing="4" fill="#ff5c28">DRAGON</text>
</svg>
<div class="panel">
<div class="panel-header">
<span class="panel-title">Sign in</span>
</div>
<p style="font-size:14px; color:var(--fg2); margin-bottom:18px;">
Use your Core API credentials (<code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_USERNAME</code> / <code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_PASSWORD</code>).
</p>
<div class="field">
<label for="login-user">Username</label>
<input id="login-user" type="text" autocomplete="username" placeholder="admin">
</div>
<div class="field">
<label for="login-pass">Password</label>
<input id="login-pass" type="password" autocomplete="current-password">
</div>
<div class="row" style="margin-top:18px;">
<button class="btn btn-primary" id="btn-login">Sign in</button>
</div>
<div id="login-log" class="log" aria-live="polite" style="display:none"></div>
</div>
</section>
<!-- ADMIN PANEL -->
<section id="admin-panel" style="display:none">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Processes</span>
<div class="row" style="gap:6px;">
<button class="btn btn-secondary btn-sm" id="btn-refresh">↻ Refresh</button>
</div>
</div>
<div id="process-list" class="process-list">
<div class="empty">Loading…</div>
</div>
<div id="admin-log" class="log" aria-live="polite" style="display:none"></div>
</div>
</section>
</main>
<script>
// ── tiny state ────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const TOKEN_KEY = 'dragonfork-admin-token';
let authToken = null;
function log(panel, line, level = 'info') {
panel.style.display = '';
const ts = new Date().toLocaleTimeString();
const cls = level === 'bad' ? 'l-bad' : level === 'good' ? 'l-good' : level === 'warn' ? 'l-warn' : '';
const div = document.createElement('div');
div.innerHTML = `<span class="ts">${ts}</span> <span class="${cls}">${escapeHTML(line)}</span>`;
panel.prepend(div);
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]);
}
// ── auth ──────────────────────────────────────────────────────────
function setAuth(token) {
authToken = token;
if (token) {
try { localStorage.setItem(TOKEN_KEY, token); } catch (e) {}
$('login-panel').style.display = 'none';
$('admin-panel').style.display = '';
$('logout-link').style.display = '';
refreshProcesses();
} else {
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
$('login-panel').style.display = '';
$('admin-panel').style.display = 'none';
$('logout-link').style.display = 'none';
$('process-list').innerHTML = '<div class="empty">Not signed in.</div>';
}
}
async function login() {
const user = $('login-user').value.trim();
const pass = $('login-pass').value;
if (!user || !pass) {
log($('login-log'), 'username and password required', 'bad');
return;
}
$('btn-login').disabled = true;
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }),
});
if (!r.ok) {
const body = await r.text();
throw new Error(`HTTP ${r.status}: ${body || r.statusText}`);
}
const data = await r.json();
const token = data.access_token || data.accessToken || data.token;
if (!token) throw new Error('login response missing access_token');
log($('login-log'), 'authenticated', 'good');
$('login-pass').value = '';
setAuth(token);
} catch (err) {
log($('login-log'), 'login failed: ' + err.message, 'bad');
} finally {
$('btn-login').disabled = false;
}
}
$('btn-login').addEventListener('click', login);
$('login-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
$('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
$('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); });
// Restore cached session (will bounce back to login on 401)
try {
const cached = localStorage.getItem(TOKEN_KEY);
if (cached) setAuth(cached);
} catch (e) {}
// ── API helpers ───────────────────────────────────────────────────
async function api(method, path, body) {
const headers = { 'Authorization': 'Bearer ' + authToken };
const init = { method, headers };
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const r = await fetch(path, init);
if (r.status === 401) {
log($('admin-log'), 'session expired, please sign in again', 'warn');
setAuth(null);
throw new Error('unauthorized');
}
if (!r.ok) {
const text = await r.text();
throw new Error(`${method} ${path} → ${r.status}: ${text || r.statusText}`);
}
if (r.status === 204) return null;
return r.json();
}
// ── process list ──────────────────────────────────────────────────
$('btn-refresh').addEventListener('click', refreshProcesses);
async function refreshProcesses() {
if (!authToken) return;
$('process-list').innerHTML = '<div class="empty">Loading…</div>';
try {
const procs = await api('GET', '/api/v3/process?filter=config,state');
renderProcesses(procs);
} catch (err) {
if (err.message === 'unauthorized') return;
log($('admin-log'), 'list processes: ' + err.message, 'bad');
$('process-list').innerHTML = '<div class="empty">Failed to load processes.</div>';
}
}
function stateClass(state) {
if (state === 'running') return 'state-running';
if (state === 'failed' || state === 'killed') return 'state-failed';
if (state === 'finishing') return 'state-finished';
return 'state-connecting'; // starting, reconnecting, idle, etc.
}
function stateBadgeClass(state) {
if (state === 'running') return 'badge-good';
if (state === 'failed' || state === 'killed') return 'badge-bad';
if (state === 'finishing') return 'badge-neutral';
return 'badge-warn';
}
function stateDotClass(state) {
if (state === 'running') return 'good';
if (state === 'failed' || state === 'killed') return 'bad';
return 'warn';
}
function renderProcesses(procs) {
const list = $('process-list');
list.innerHTML = '';
if (!Array.isArray(procs) || procs.length === 0) {
list.innerHTML = '<div class="empty">No processes configured. Create one in the Restreamer UI.</div>';
return;
}
procs.sort((a, b) => (a.id || '').localeCompare(b.id || ''));
for (const p of procs) {
list.appendChild(renderProcess(p));
}
}
function renderProcess(proc) {
const cfg = proc.config || {};
const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled);
const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown';
const card = document.createElement('div');
card.className = 'process-card ' + stateClass(state);
// ── left ──
const left = document.createElement('div');
// id
const idEl = document.createElement('div');
idEl.className = 'process-id';
idEl.textContent = proc.id || '(no id)';
left.appendChild(idEl);
// meta row: state badge + webrtc badge + ref
const meta = document.createElement('div');
meta.className = 'process-meta';
const dotCls = stateDotClass(state);
const badgeCls = stateBadgeClass(state);
const stateBadge = document.createElement('span');
stateBadge.className = 'badge ' + badgeCls;
stateBadge.innerHTML = `<span class="pulse-dot ${dotCls}"></span>${escapeHTML(state)}`;
meta.appendChild(stateBadge);
const webrtcBadge = document.createElement('span');
webrtcBadge.className = 'badge ' + (webrtcEnabled ? 'badge-accent' : 'badge-neutral');
webrtcBadge.textContent = 'WebRTC ' + (webrtcEnabled ? 'on' : 'off');
meta.appendChild(webrtcBadge);
if (cfg.reference) {
const ref = document.createElement('span');
ref.className = 'process-ref';
ref.textContent = cfg.reference;
meta.appendChild(ref);
}
left.appendChild(meta);
card.appendChild(left);
// ── right: actions ──
const actions = document.createElement('div');
actions.className = 'process-actions';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-sm ' + (webrtcEnabled ? 'btn-danger' : 'btn-secondary');
toggleBtn.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
toggleBtn.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggleBtn));
actions.appendChild(toggleBtn);
card.appendChild(actions);
// ── WHEP URL row ──
if (webrtcEnabled) {
const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id);
const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL);
const row = document.createElement('div');
row.className = 'whep-row';
row.innerHTML = `
<span class="whep-label">WHEP</span>
<span class="whep-url-text">${escapeHTML(whepURL)}</span>
<span class="whep-actions">
<button class="btn btn-secondary btn-sm" data-copy="${escapeHTML(whepURL)}">Copy</button>
<a class="btn btn-secondary btn-sm" href="${escapeHTML(playerURL)}" target="_blank" rel="noopener" style="text-decoration:none">Open ↗</a>
</span>
`;
row.querySelector('[data-copy]').addEventListener('click', (e) => {
e.preventDefault();
navigator.clipboard?.writeText(whepURL).then(
() => log($('admin-log'), 'WHEP URL copied', 'good'),
() => log($('admin-log'), 'clipboard write failed', 'warn'),
);
});
card.appendChild(row);
}
return card;
}
// ── toggle webrtc.enabled ─────────────────────────────────────────
async function toggleWebRTC(id, enabled, btn) {
btn.disabled = true;
btn.textContent = enabled ? 'Enabling…' : 'Disabling…';
try {
const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config');
cfg.webrtc = cfg.webrtc || {};
cfg.webrtc.enabled = enabled;
await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg);
try {
await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' });
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good');
} catch (cmdErr) {
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn');
}
await refreshProcesses();
} catch (err) {
if (err.message === 'unauthorized') return;
log($('admin-log'), `toggle ${id}: ${err.message}`, 'bad');
btn.disabled = false;
btn.textContent = enabled ? 'Enable WebRTC' : 'Disable WebRTC';
}
}
</script>
</body>
</html>

View file

@ -0,0 +1,70 @@
#!/bin/sh
# apply-overlay.sh — Wild Dragon reskin patches applied to a freshly
# cloned datarhei/restreamer-ui tree. Two phases:
#
# 1. File overlay: rsync the contents of $OVERLAY/{public,src} on top
# of the upstream working tree. Whole-file replacements only —
# simple and idempotent.
#
# 2. Targeted in-place sed for one-line UI strings that aren't worth
# a whole-file overlay (the header title, a few welcome strings).
# Each pattern is anchored to a unique surrounding context so a
# future upstream rename doesn't silently rewrite the wrong line.
#
# Caller: the Dockerfile's ui-builder stage. Expects:
# $OVERLAY = /overlay (the COPY destination)
# $UI = /ui (the cloned upstream source root)
#
# Idempotent on a single source tree (rerunning is a no-op).
set -eu
OVERLAY="${OVERLAY:-/overlay}"
UI="${UI:-/ui}"
echo "wilddragon-overlay: layering $OVERLAY -> $UI"
# Phase 1 — file copies. -L follows any future symlinks, -p preserves
# perms, -R recursive. We deliberately avoid --delete: the upstream
# tree must stay intact except for the files we override.
for sub in public src; do
if [ -d "$OVERLAY/$sub" ]; then
cp -RLp "$OVERLAY/$sub/." "$UI/$sub/"
fi
done
# Phase 2 — targeted seds. Each replacement is wrapped in a check so
# the script fails loudly if upstream changed the line we're patching
# (rather than silently no-op'ing and shipping un-rebranded UI).
patch_line() {
file="$1"; needle="$2"; replacement="$3"
if ! grep -qF "$needle" "$file"; then
echo "wilddragon-overlay: ERROR — pattern not found in $file:"
echo " $needle"
exit 1
fi
# Use awk for safe literal substitution (sed's regex would mishandle
# special chars in the replacement).
tmp="$(mktemp)"
awk -v n="$needle" -v r="$replacement" '
index($0, n) { sub(n, r); }
{ print }
' "$file" > "$tmp"
mv "$tmp" "$file"
echo "wilddragon-overlay: patched $(basename "$file")$needle -> $replacement"
}
patch_line "$UI/src/Header.js" \
'<Typography className="headerTitle">Restreamer</Typography>' \
'<Typography className="headerTitle">Wild Dragon</Typography>'
# Welcome view top-of-page card.
patch_line "$UI/src/views/Welcome.js" \
'title="Welcome to Restreamer v2"' \
'title="Welcome to Wild Dragon"'
patch_line "$UI/src/views/Settings.js" \
'title="Welcome to Restreamer v2"' \
'title="Welcome to Wild Dragon"'
echo "wilddragon-overlay: done."

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
<meta name="theme-color" content="#0d0e12" />
<meta name="description" content="Wild Dragon — low-latency live video streaming dashboard" />
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>Wild Dragon</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,13 @@
{
"short_name": "Wild Dragon",
"name": "Wild Dragon — Live Streaming",
"icons": [
{ "src": "favicon.ico", "sizes": "64x64 32x32 16x16", "type": "image/x-icon" },
{ "src": "logo192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "logo512.png", "type": "image/png", "sizes": "512x512" }
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0d0e12",
"background_color": "#0d0e12"
}

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 70" width="320" height="70">
<!-- Wild Dragon wordmark: small ember+chevron icon followed by the text. -->
<defs>
<linearGradient id="ember-w" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff8855"/>
<stop offset="1" stop-color="#cc3300"/>
</linearGradient>
</defs>
<!-- icon -->
<g transform="translate(0,4)">
<rect x="2" y="2" width="58" height="58" rx="10" fill="#1a1c22"/>
<path d="M14 48 Q22 30 31 38 Q40 30 48 48 Q40 53 31 47 Q22 53 14 48 Z"
fill="url(#ember-w)" opacity="0.7"/>
<text x="31" y="40" text-anchor="middle"
font-family="'DejaVu Sans','Helvetica',sans-serif"
font-size="26" font-weight="700" fill="#ff6633">WD</text>
</g>
<!-- wordmark -->
<text x="76" y="48"
font-family="'Dosis','Roboto','Helvetica',sans-serif"
font-size="36" font-weight="300" letter-spacing="2" fill="#e7e7ea">
WILD <tspan fill="#ff6633" font-weight="500">DRAGON</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<!-- Wild Dragon mark: dark rounded panel with stylised flame chevron + 'WD' monogram. -->
<defs>
<linearGradient id="ember" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff6633"/>
<stop offset="1" stop-color="#cc3300"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="188" height="188" rx="32" fill="#0d0e12"/>
<!-- Flame chevron underneath the monogram -->
<path d="M40 150 Q60 110 100 130 Q140 110 160 150 Q140 165 100 152 Q60 165 40 150 Z"
fill="url(#ember)" opacity="0.55"/>
<!-- 'W' -->
<path d="M50 60 L62 130 L78 90 L94 130 L106 60"
stroke="#ff6633" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- 'D' -->
<path d="M118 60 L118 130 L138 130 Q165 130 165 95 Q165 60 138 60 L118 60 Z"
stroke="#ff6633" stroke-width="10" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View file

@ -0,0 +1,24 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import company_logo from './images/logo.svg';
const useStyles = makeStyles((theme) => ({
Logo: {
height: 27,
},
}));
export default function Logo(props) {
const classes = useStyles();
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
// eslint-disable-next-line no-useless-escape
return (
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
<img src={company_logo} alt="Wild Dragon logo" />
</a>
);
}

View file

@ -0,0 +1,24 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import company_logo from './images/rs-logo.svg';
const useStyles = makeStyles((theme) => ({
Logo: {
height: 95,
},
}));
export default function Logo(props) {
const classes = useStyles();
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
// eslint-disable-next-line no-useless-escape
return (
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
<img src={company_logo} alt="Wild Dragon mark" />
</a>
);
}

165
docs/REBASE.md Normal file
View file

@ -0,0 +1,165 @@
# Dragon Fork — Upstream Rebase Policy
Tracks the relationship between `forge.wilddragon.net/zgaetano/datarhei-dragonfork-core`
(this fork) and upstream `github.com/datarhei/core`.
---
## Baseline
| Point | Value |
|---|---|
| Fork date | 2026-04-17 |
| Fork commit | `0de97f4` ("Add linux/arm/v8 build") |
| Upstream module path | `github.com/datarhei/core/v16` (kept — see `NOTES.md`) |
| First rebase target | upstream `main` (post-v16.16.0) |
---
## Cadence
Rebase against upstream `main` **monthly**, timed to follow an upstream
minor release (`v16.X.0`) appearing on the upstream default branch.
Emergency rebases (CVE in a transitive dependency, critical upstream fix)
are performed ad-hoc following the same procedure, just sooner.
---
## Strategy: Rebase, Not Merge
Use `git rebase upstream/main` rather than `git merge upstream/main`.
- Keeps a linear history; `git log --oneline` stays readable.
- Dragon Fork commits remain visually distinct from upstream commits.
- Conflicts surface one commit at a time rather than in a single merge blob.
Merge commits are only used for feature branches merging into `main` via PR.
---
## Divergence Boundaries
### Files exclusively owned by Dragon Fork (expect zero conflicts)
These paths did not exist upstream at fork time and are not being upstreamed:
| Path | Description |
|---|---|
| `app/webrtc/` | WebRTC app subsystem |
| `core/webrtc/` | Pion peer factory, Source, ICE helpers |
| `deploy/truenas/` | TrueNAS deployment bundle |
| `docs/design/` | Dragon Fork design documents |
| `docs/REBASE.md` | This file |
| `.forgejo/` | Forgejo CI workflows |
| `test/load/` | WHEP load-test harness |
### Files modified from upstream (higher conflict risk)
| Path | Our change | Conflict strategy |
|---|---|---|
| `go.mod` / `go.sum` | Added Pion + Prometheus deps | Accept upstream base; re-add our `require` blocks; run `go mod tidy` |
| `http/server.go` | WebRTC handler registration | Search for `WebRTC` in diff; reapply our three-line wiring |
| `restream/` | `ProcessHooks` interface + `SetHooks` | Check if upstream changed hook shape; adapt our callbacks |
| `config/` | `DataWebRTC` config block | Keep our field additions; adopt upstream structural changes |
| `README.md` | Dragon Fork branding | Keep our content; cherry-pick upstream security notices |
| `CHANGELOG.md` | Dragon Fork version entries | Keep our entries at top; adopt upstream format changes |
---
## Pre-Rebase Checklist
```
[ ] git fetch upstream # confirm new upstream commits exist
[ ] CI is green on current main
[ ] go mod vendor is clean:
go mod vendor && git diff --quiet vendor/ # commit if dirty
[ ] Tag current tip:
git tag pre-rebase-v<upstream-ver> # e.g. pre-rebase-v16.17.0
```
---
## First Rebase Procedure
Run these commands locally (or on any machine with the repo checked out):
```sh
# 1. Add upstream remote (idempotent)
git remote add upstream https://github.com/datarhei/core.git 2>/dev/null || true
# 2. Fetch
git fetch upstream
# 3. Tag current tip
UPSTREAM_VER=$(git describe --tags upstream/main --abbrev=0 2>/dev/null || echo manual)
git tag pre-rebase-${UPSTREAM_VER}
# 4. Rebase
git rebase upstream/main
# On conflict:
# git diff — see what conflicted
# <edit the file>
# git add <file>
# git rebase --continue
# 5. Update vendored dependencies
go mod tidy
go mod vendor
git add vendor/ go.mod go.sum
git commit -m "chore: update vendor after upstream rebase to ${UPSTREAM_VER}"
# 6. Run verification gate (see below)
# 7. Push
git push origin main
```
---
## Post-Rebase Verification Gate
All of the following must pass before pushing the rebased `main`:
| Check | Command |
|---|---|
| Build | `go build ./...` |
| Vet | `go vet ./...` |
| Unit + race | `go test -race -short -count=1 ./...` |
| WebRTC smoke | `go test -race -count=1 -v -run 'TestIntegration_\|TestSubsystem_\|TestHandler_' ./app/webrtc/... ./core/webrtc/...` |
| Latency gate | `go test -tags latency -timeout 90s -race -count=1 -run TestLatencyServerHop ./app/webrtc/... -v` |
| TrueNAS smoke | Deploy to staging, subscribe one WHEP peer, verify video renders |
Forgejo CI covers the first four automatically on push. The latency gate and
TrueNAS smoke are manual.
---
## go.mod / Vendored Dependencies
After rebasing and running `go mod tidy`:
1. Confirm Pion packages (`github.com/pion/*`) remain in `vendor/` at our
required versions.
2. If upstream bumped a shared dep (e.g. `labstack/echo`), review that dep's
changelog before accepting the version bump.
3. Commit `vendor/`, `go.mod`, and `go.sum` together:
`chore: update vendor after upstream rebase to v<X>`
---
## CI Automation (Future)
Automated monthly rebase via a Forgejo-Actions scheduled workflow is a v0.3
consideration. Blocked on: runner having a git identity for push, and a
strategy for surfacing conflict PRs when automation fails.
---
## Record-Keeping
After each successful rebase, append a row to this table:
| Date | Upstream version | Pre-rebase tag | Conflicts | Notes |
|---|---|---|---|---|
| (first rebase pending) | v16.X.0 | pre-rebase-v16.X.0 | — | run locally per procedure above |

View file

@ -0,0 +1,666 @@
# Datarhei - Dragon Fork: WebRTC Prometheus Metrics
**Status:** Draft for review
**Author:** Zac (Wild Dragon)
**Date:** 2026-05-03
**Predecessors:**
- [`2026-04-16-datarhei-dragon-fork-webrtc-design.md`](2026-04-16-datarhei-dragon-fork-webrtc-design.md)
- [`2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`](2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md)
- v0.1.0-dragonfork released 2026-05-03
---
## Summary
Add Prometheus instrumentation to Dragon Fork's WebRTC subsystem and ship a
collection-and-dashboard stack in the existing TrueNAS deploy bundle. Closes
the v0.1 observability gap: the WHEP egress has been running in production
since 2026-04-17 with zero per-subsystem signal.
The deliverable is a RED-method dashboard ("rate, errors, duration") that
answers a single operator question — _is the WebRTC stack healthy right now?_
Eleven new metrics in the `dragonfork_webrtc_*` namespace, two new containers
(Prometheus + Grafana) in `deploy/truenas/core/`, four pre-loaded alert rules,
one pre-provisioned dashboard.
## Goals
- Operator can answer "is WebRTC healthy right now?" from a single Grafana
dashboard, without tailing logs or hitting the API.
- Per-stream drill-down available when the dashboard goes red — labels carry
`stream_id` everywhere it's meaningful, never `peer_id`.
- Deploy is one-command on a fresh TrueNAS box (`docker compose up -d`),
matching the existing v0.1 deploy ergonomics.
- Backwards-compatible: zero changes to upstream's `/metrics` payload. New
metrics are purely additive.
- Bucket choices and label sets are tuned for the realistic latency ranges
observed in v0.1 (server-hop p95 ≈ 240µs, ICE establishment seconds-scale).
## Non-Goals
- **Alertmanager bundling.** Alert rules are loaded into Prometheus but not
routed. Paging configuration is too opinionated to ship a default; separate
spec if/when paging is wanted.
- **Per-peer metric labels.** Peer-level forensics (individual session
lifetimes, per-resource teardown reasons) is out of scope. `peer_id` is
unbounded under churn and risks cardinality bloat.
- **Federated multi-Core scrape.** Single-deploy scrape config only. The
`core` label is set statically to `dragonfork-truenas`.
- **Latency p95 CI gate via Prometheus.** Server-hop latency stays a Go
test gate (`-tags latency`); not a Prometheus histogram.
- **Server-hop microsecond histogram.** The 240µs server-hop is well below
HTTP request scales and would need its own bucket set; it's already
covered by the latency CI test, no need to duplicate in Prom.
- **Custom monitor/metric bus integration.** Upstream pulls from
`monitor/metric.Reader`. We diverge — see Module Layout for rationale.
## Context
v0.1 surface area:
- WHEP HTTP routes: `POST /api/v3/whep/{id}`, `DELETE /api/v3/whep/{id}/{r}`,
`PATCH /api/v3/whep/{id}/{r}`, plus admin `GET /api/v3/webrtc/streams`
and `GET /api/v3/webrtc/streams/{id}/peers`.
- Error matrix in v0.1: `406` codec mismatch, `503` cap reached (split into
global vs per-stream in response body), `504` ICE timeout, `204` DELETE
idempotent, `404` unknown stream.
- Pion-mediated peer connection lifecycle in `app/webrtc/lifecycle.go`
ICE state transitions are the natural hook for ICE timing/failure metrics.
- FFmpeg RTP output legs supervised by the existing process supervisor;
silent leg failure is a known "quietly degrading" risk worth instrumenting.
Existing Prometheus integration (upstream):
- `prometheus/prometheus.go` exposes a `Metrics` interface with `Register`
and an `HTTPHandler()`. Single shared `prometheus.Registry`.
- `prometheus/restream.go` is the reference collector — pulls from
`monitor/metric.Reader` via `metric.Pattern` queries, emits via
`prometheus.MustNewConstMetric`. All upstream collectors carry a `core`
label as the first dimension.
- `/metrics` endpoint already exposed by Core; auth handled at the same
layer as the rest of the API.
## Approach
**Hybrid instrumentation, in two surfaces:**
1. **Direct `prometheus/client_golang` instrumentation** in `app/webrtc/`
for hot-path counters and histograms (request rate, request duration,
ICE establishment duration, error counters by reason). Histograms can't
be reconstructed from a scrape-time snapshot, so this is non-negotiable
for RED-method.
2. **Snapshot-style collector** in `prometheus/webrtc.go` for slow-changing
gauges (active streams, active peers per stream, UDP port pool usage).
Calls a new `Stats()` method on the WebRTC subsystem at scrape time.
Both surfaces register against the same `prometheus.Registerer` exposed by
`prometheus.Metrics`. No new HTTP endpoint, no new auth path. Both take a
`core` first-label dimension to match upstream collector convention.
### Why not pure snapshot?
Upstream's `prometheus/restream.go` pulls from a `monitor/metric` bus that
the FFmpeg supervision layer writes into. We could mirror that for WebRTC
— have `app/webrtc/lifecycle.go` and `handler.go` push events onto the bus,
have `prometheus/webrtc.go` pull them. Two reasons not to:
- **Histograms don't fit the pattern.** The bus stores point-in-time values
(gauges and counters), not distributions. RED-method needs duration p50
and p95; you'd end up maintaining an in-process sliding-window quantile
estimator inside the WebRTC subsystem, which is more code than just using
`client_golang.Histogram` directly.
- **The bus is FFmpeg-shaped.** `metric.Pattern` queries are designed for
process-state metrics (process IDs, FFmpeg states). Bolting WebRTC
semantics on requires defining new patterns the bus consumers all need
to know about, for a payload only the WebRTC collector cares about.
The hybrid keeps each metric type on the cleanest path. The cost is two
patterns in the codebase instead of one — accepted, with a comment in
`prometheus/webrtc.go` pointing at this rationale so the next contributor
doesn't try to "fix" the divergence.
### Why not pure direct?
Pure `client_golang` everywhere would mean the gauges (active streams,
active peers, UDP ports) sit in `app/webrtc/` alongside histograms. Workable,
but loses the "one collector file per subsystem in `prometheus/`" pattern
that anyone reading the repo's existing structure would expect. Snapshot
gauges are cheap to implement via the existing pattern, so we keep them
where a casual reader would look.
## Module Layout
### New files
```
app/webrtc/metrics.go (~150 LOC)
app/webrtc/metrics_test.go (~200 LOC)
prometheus/webrtc.go (~120 LOC)
prometheus/webrtc_test.go (~150 LOC)
deploy/truenas/core/prom/prometheus.yml
deploy/truenas/core/prom/rules/webrtc-alerts.yml
deploy/truenas/core/grafana/provisioning/datasources/prometheus.yml
deploy/truenas/core/grafana/provisioning/dashboards/webrtc.yml
deploy/truenas/core/grafana/dashboards/dragonfork-webrtc-health.json
```
### Modified files
```
app/webrtc/handler.go — add metric middleware around WHEP routes
app/webrtc/lifecycle.go — record ICE timing in OnConnectionStateChange
app/webrtc/subsystem.go — add Stats() method, instrument process hooks
deploy/truenas/core/docker-compose.yml — add prom + grafana services
deploy/truenas/core/README.md — document new env vars + ports
README.md — quick-start mentions Grafana URL
CHANGELOG.md — v0.2.0-dragonfork section
```
### `app/webrtc/metrics.go` — direct instrumentation
`promauto`-registered into the shared registry, exposed as package-level
vars so `handler.go` and `lifecycle.go` can increment without dependency
injection. Single `Init(reg prometheus.Registerer, core string)` called
from `subsystem.New` after the registry is available.
```go
// Sketch — exact wire format finalized at implementation.
package webrtc
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var histBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
type metrics struct {
whepRequests *prometheus.CounterVec // route, code, stream_id
whepRequestDuration *prometheus.HistogramVec // route, stream_id
iceEstablishment *prometheus.HistogramVec // stream_id, result
iceFailures *prometheus.CounterVec // stream_id, reason
codecMismatches *prometheus.CounterVec // stream_id, kind
capRejections *prometheus.CounterVec // stream_id, scope
ffmpegLegFailures *prometheus.CounterVec // stream_id, leg
}
func newMetrics(reg prometheus.Registerer, core string) *metrics {
factory := promauto.With(reg)
return &metrics{
whepRequests: factory.NewCounterVec(prometheus.CounterOpts{
Name: "dragonfork_webrtc_whep_requests_total",
Help: "Count of WHEP requests by route, status code, and stream.",
ConstLabels: prometheus.Labels{"core": core},
}, []string{"route", "code", "stream_id"}),
// ... etc
}
}
```
The `core` label is a `ConstLabels` (set once at construction) rather than a
per-request dimension — matches the upstream collector pattern and avoids
threading it through every call site.
### `prometheus/webrtc.go` — snapshot collector
Standard `prometheus.Collector` interface (Describe / Collect). Keeps a
reference to a `WebRTCStatsSource` interface, which the WebRTC subsystem
implements via its `Stats()` method. Avoids importing `app/webrtc` from
`prometheus/` — the dependency arrow points the right way.
```go
// Sketch.
type WebRTCStatsSource interface {
Stats() WebRTCStats
}
type WebRTCStats struct {
StreamCount int
PeersByStream map[string]int
UDPPortsInUse int
UDPPortsAvailable int
}
type webrtcCollector struct {
core string
source WebRTCStatsSource
activeStreamsDesc *prometheus.Desc
activePeersDesc *prometheus.Desc
udpPortsInUseDesc *prometheus.Desc
udpPortsAvailableDesc *prometheus.Desc
}
func NewWebRTCCollector(core string, source WebRTCStatsSource) prometheus.Collector { ... }
```
The `WebRTCStats` type lives in `prometheus/webrtc.go` (not in `app/webrtc/`)
so the dependency stays one-directional. The subsystem implements the
interface by satisfying the shape, not by importing from `prometheus/`.
### `app/webrtc/subsystem.go``Stats()` method
```go
func (s *Subsystem) Stats() prometheus.WebRTCStats {
s.mu.Lock()
defer s.mu.Unlock()
peers := make(map[string]int, len(s.streams))
for id, st := range s.streams {
peers[id] = len(st.peers) // assume peers tracked per-stream
}
return prometheus.WebRTCStats{
StreamCount: len(s.streams),
PeersByStream: peers,
UDPPortsInUse: s.portAlloc.InUse(),
UDPPortsAvailable: s.portAlloc.Available(),
}
}
```
The existing subsystem tracks streams in `s.streams` under `s.mu`. Peer
count per stream needs the per-stream peer index that already exists in
`handler.go` — the `Stats()` method consults it via the existing teardown
hook plumbing or a small new accessor on `Handler`. Pick whichever surface
introduces the smaller blast radius at implementation time.
## Metric Inventory
Eleven metrics. Eight new label dimensions across them. ~50 active series
at typical 1-5 stream scale.
### Direct instrumentation (`app/webrtc/metrics.go`)
| Name | Type | Labels | Description |
|---|---|---|---|
| `dragonfork_webrtc_whep_requests_total` | Counter | core, route, code, stream_id | Count of WHEP requests by route+status code. |
| `dragonfork_webrtc_whep_request_duration_seconds` | Histogram | core, route, stream_id | Server-side WHEP request duration. Buckets: `[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]`. |
| `dragonfork_webrtc_ice_establishment_duration_seconds` | Histogram | core, stream_id, result | Time from `SetLocalDescription` to first `connected` or `failed` ICE state. Same buckets. |
| `dragonfork_webrtc_ice_failures_total` | Counter | core, stream_id, reason | ICE failure count. `reason` ∈ {timeout, disconnected, failed}. |
| `dragonfork_webrtc_codec_mismatches_total` | Counter | core, stream_id, kind | 406 rejections by kind. `kind` ∈ {video, audio}. |
| `dragonfork_webrtc_cap_rejections_total` | Counter | core, stream_id, scope | 503 rejections. `scope` ∈ {global, stream}. |
| `dragonfork_webrtc_ffmpeg_leg_failures_total` | Counter | core, stream_id, leg | RTP output leg failures. `leg` ∈ {video, audio}. |
### Snapshot collector (`prometheus/webrtc.go`)
| Name | Type | Labels | Description |
|---|---|---|---|
| `dragonfork_webrtc_active_streams` | Gauge | core | Streams currently registered (processes with `webrtc.enabled=true` running). |
| `dragonfork_webrtc_active_peers` | Gauge | core, stream_id | Currently subscribed WHEP peers per stream. |
| `dragonfork_webrtc_udp_ports_in_use` | Gauge | core | UDP ports currently allocated from the pool. |
| `dragonfork_webrtc_udp_ports_available` | Gauge | core | Pool size minus in-use (explicit for alert friendliness). |
### Label rationale
- `whep_request_duration_seconds` deliberately omits `code` — separating
distributions per outcome makes p95 noisy, and per-route per-stream p95
is what an operator actually looks at. Errors get visibility through the
request-counter ratio.
- `ice_establishment_duration_seconds` includes both `connected` and
`failed` results in the same histogram via the `result` label —
intentionally — so the dashboard can compare success latency to
failure-tail latency on the same axis.
- `cap_rejections_total` keeps the `scope` label because v0.1's response
body already splits global vs per-stream rejections; metrics mirror that
distinction so the dashboard shows whether to raise `max_peers_total`
or just one stream's per-stream cap.
- `ffmpeg_leg_failures_total` is the "quietly degrading" canary — a silent
RTP-output-leg failure (port bind, encoder crash) is exactly what the
"is it healthy?" framing is meant to catch.
### Cardinality budget
At typical scale (5 streams, 3 routes, ~6 status codes seen in practice):
- `whep_requests_total`: 5 × 3 × 6 = 90 series (worst case)
- `whep_request_duration_seconds`: 5 × 3 × (8 buckets + sum + count) = 150 series
- `ice_establishment_duration_seconds`: 5 × 2 × 10 = 100 series
- All others: 515 series each
- **Total: <500 active series at 5-stream sustained load**
Well within Prometheus's comfort zone. At 15s scrape interval × 15-day
retention, on-disk storage ~80MB.
### Specifically excluded metrics
- **Per-peer session metrics.** Listed under non-goals.
- **Bytes-out / bandwidth.** Pion exposes RTP write bytes via stats; would
be useful but pulls peer-level state. Defer to a future v0.3 spec
("WebRTC bandwidth observability") if needed.
- **Server-hop latency (FFmpeg → peer).** Microsecond scale, already
covered by `-tags latency` test gate, would need its own bucket set.
## Deploy Bundle
### `deploy/truenas/core/docker-compose.yml` additions
Two new services on a new bridge network `dragonfork-mon`. Core continues
on `network_mode: host` unchanged. The new containers reach Core via
`host.docker.internal:${CORE_HTTP_PORT}` (Linux Docker resolves this when
`extra_hosts: ["host.docker.internal:host-gateway"]` is set on the service).
```yaml
services:
core:
# ... existing definition unchanged
prom:
image: prom/prometheus:v2.55.0
container_name: dragonfork-prom
restart: unless-stopped
networks: [dragonfork-mon]
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./prom/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prom/rules:/etc/prometheus/rules:ro
- ./prom-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=15d
- --storage.tsdb.path=/prometheus
- --web.console.libraries=/usr/share/prometheus/console_libraries
- --web.console.templates=/usr/share/prometheus/consoles
ports:
- "${PROM_PORT:-9090}:9090"
grafana:
image: grafana/grafana-oss:11.3.0
container_name: dragonfork-grafana
restart: unless-stopped
networks: [dragonfork-mon]
depends_on: [prom]
environment:
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?set in .env}"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
- ./grafana-data:/var/lib/grafana
ports:
- "${GRAFANA_PORT:-3000}:3000"
networks:
dragonfork-mon:
driver: bridge
```
### `prom/prometheus.yml`
```yaml
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
external_labels:
core: dragonfork-truenas
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: dragonfork-core
static_configs:
- targets: ["host.docker.internal:8080"]
metrics_path: /metrics
# If API auth is enabled on /metrics, uncomment and provide creds via
# env-substituted file. v0.1 leaves /metrics public by default.
# basic_auth:
# username_file: /run/secrets/prom_basic_user
# password_file: /run/secrets/prom_basic_pass
```
### `prom/rules/webrtc-alerts.yml`
```yaml
groups:
- name: dragonfork-webrtc
rules:
- alert: WebRTCWHEPErrorRateHigh
expr: |
sum by (stream_id) (
rate(dragonfork_webrtc_whep_requests_total{code=~"4..|5.."}[5m])
) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "WHEP error rate high on stream {{ $labels.stream_id }}"
description: "Sustained 4xx/5xx rate >0.5/sec for 5m."
- alert: WebRTCICEEstablishmentSlow
expr: |
histogram_quantile(0.95,
sum by (le, stream_id) (
rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])
)
) > 3
for: 10m
labels:
severity: warning
annotations:
summary: "ICE establishment p95 >3s on {{ $labels.stream_id }}"
- alert: WebRTCICEFailureRateHigh
expr: |
sum by (stream_id) (rate(dragonfork_webrtc_ice_failures_total[5m])) > 0.2
for: 5m
labels:
severity: warning
annotations:
summary: "ICE failures sustained on {{ $labels.stream_id }}"
- alert: WebRTCFFmpegLegFailure
expr: |
increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m]) > 0
labels:
severity: critical
annotations:
summary: "FFmpeg RTP leg failed on {{ $labels.stream_id }} ({{ $labels.leg }})"
description: "Silent degradation of RTP output. Check FFmpeg logs."
```
Alerts evaluate but route nowhere. Alertmanager bundling deferred — see
non-goals.
### Grafana provisioning
Datasource provisioning at `grafana/provisioning/datasources/prometheus.yml`:
```yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prom:9090
isDefault: true
editable: false
```
Dashboard provisioning at `grafana/provisioning/dashboards/webrtc.yml`:
```yaml
apiVersion: 1
providers:
- name: dragonfork
orgId: 1
folder: "Dragon Fork"
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
```
### Dashboard JSON: `dragonfork-webrtc-health.json`
Single dashboard, five rows aligned to the questions from the metric
inventory:
1. **WHEP API health** — request rate by route (stat panel), error rate
stacked by code (timeseries), p95 request duration by route (timeseries).
2. **ICE establishment** — success/failure rate (gauge), p50/p95
establishment duration (timeseries with a 3s threshold line for the
alert), failure breakdown by reason (table).
3. **What's flowing**`active_streams` (stat), `active_peers` per stream
(timeseries), top 5 streams by peer count (table).
4. **Capacity headroom**`udp_ports_available` (gauge with red-zone <10),
cap rejection rate by scope (timeseries).
5. **Silent degradation** — FFmpeg leg failure timeline (timeseries with
annotations), codec mismatch counter (stat).
Built in Grafana 11.3, exported as JSON, committed to the repo. Refresh
default 30s.
### `.env` template additions
Append to `deploy/truenas/core/README.md`'s example `.env`:
```sh
# --- Observability (added in v0.2) ---
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
GRAFANA_PORT=3000
PROM_PORT=9090
```
## Testing
### Unit tests — `prometheus/webrtc_test.go`
Mock `WebRTCStatsSource`. Drive the collector through three states (no
streams, one stream with N peers, multiple streams). Use
`testutil.CollectAndCompare` to assert exact metric/label/value output
against a golden plaintext fixture.
```go
// Golden fixture (excerpt):
// # HELP dragonfork_webrtc_active_streams ...
// # TYPE dragonfork_webrtc_active_streams gauge
// dragonfork_webrtc_active_streams{core="test"} 2
// # HELP dragonfork_webrtc_active_peers ...
// # TYPE dragonfork_webrtc_active_peers gauge
// dragonfork_webrtc_active_peers{core="test",stream_id="live"} 3
// dragonfork_webrtc_active_peers{core="test",stream_id="cam"} 1
```
### Unit tests — `app/webrtc/metrics_test.go`
Reuse `handler_test.go` setup (fake registry, in-process Echo router).
Hit each WHEP route, assert the corresponding counter and histogram have
the expected increment via `testutil.ToFloat64`. Drive forced error paths
(unknown stream → 404, codec-less SDP → 406, cap exceeded → 503, ICE
timeout → 504) and assert the right error-bucket counters bumped.
### Integration verification — `test/TESTING.md`
New section "Verifying Prometheus metrics":
```
1. docker compose up -d
2. curl -s http://<host>:8080/metrics | grep dragonfork_webrtc_
- expect: 11 metric families present, all with `core="dragonfork-truenas"`
3. Open http://<host>:3000 (Grafana), log in with GRAFANA_ADMIN_PASSWORD
4. Navigate to Dashboards → Dragon Fork → WebRTC Health
- expect: all 5 rows render, no "no data" panels except where stream traffic is absent
5. Trigger one of each error in test/whep-player.html (intentional codec
mismatch via SDP edit, kill the publisher mid-stream, etc.)
6. Watch the Grafana panels and verify counters tick within 15s.
```
### CI
Existing test runner picks up the new `_test.go` files. No new CI gates
beyond standard build+test — observability isn't a contract; the unit
tests verify shape only. Grafana dashboard JSON is *not* validated in CI
(no good lightweight validator); manual verification only.
### Load test alignment
The deferred 5-peer × 10-min load test (separate spec) will use this
dashboard as its primary observation surface. Recording rules for the
load test's specific aggregations can be added in that spec without
touching this one.
## Rollout
The TrueNAS v0.1.0-dragonfork deploy upgrades via:
```sh
cd deploy/truenas/core
git pull # latest main with this change
# Add new lines to .env (see template above)
docker compose pull # grabs prom + grafana images
docker compose up -d # core unchanged, prom + grafana new
```
Core continues on host networking. The new containers connect via
`host.docker.internal:host-gateway`, no firewall changes required for
intra-host traffic. External Grafana access is on `${GRAFANA_PORT}`.
### Backwards compatibility
- No upstream metric names or labels modified. New metrics are purely
additive in `dragonfork_webrtc_*` namespace.
- No API changes. `/metrics` payload grows but stays well-formed
Prometheus exposition.
- Existing config, env vars, and process JSON formats unchanged.
### Forward compatibility
- The `core` label being a `ConstLabels` value (not a per-event dimension)
means future federated multi-Core scrapes will distinguish series cleanly
by setting `core="dragonfork-truenas-east"` etc. in each deploy's config
loader. Spec'd here, implemented when needed.
- New metrics in this spec follow the `dragonfork_<subsystem>_<noun>` naming
pattern. Future Dragon-Fork-specific metrics (WHIP, keyframe cache,
bandwidth) should adopt the same convention.
### Known gaps post-rollout
- No paging. Alerts evaluate, no Alertmanager. If `WebRTCFFmpegLegFailure`
fires at 3am, no notification — operator notices at next dashboard check.
Acceptable for v0.2 single-operator deploy. Track as a v0.3 spec.
- Grafana dashboard JSON is hand-edited via Grafana UI then re-exported.
No JSON-as-code library used. If dashboard maintenance gets painful,
Grafonnet/Grafana-as-code is a v0.3+ refactor.
- `/metrics` itself is unauthenticated by default in v0.1 (matches
upstream). If Core's deploy bundle is exposed to untrusted networks,
the operator should already be using auth on Core's HTTP listener. Not
this spec's problem to solve, but worth a one-line note in
`deploy/truenas/core/README.md`.
## Open Decisions
1. **Should the `Stats()` method live on `Subsystem` or on `Handler`?**
The peer count is in `Handler`'s per-stream peer index; stream count
is in `Subsystem`'s registry; UDP port pool is in `portalloc`. Easiest
shape: `Subsystem.Stats()` is the public surface and internally
gathers from `Handler` (via the existing teardown-hook plumbing) and
`portalloc`. Decide at implementation time based on which surface
exposes the cleanest seams.
2. **Should histograms also include a `core` label, given it's already a
`ConstLabels`?** Yes — `ConstLabels` is automatically present on every
sample, no per-call overhead, and federations need it.
3. **Should Prometheus retention be configurable via `.env`?** Defaulting
to 15d covers the realistic window for "what happened last week?"
queries. Adding `PROM_RETENTION_DAYS=15d` to `.env` is a one-line
change. Including it as optional, defaulting to 15d.
4. **Import-alias collision.** The local package is `package prometheus`
(at `github.com/datarhei/core/v16/prometheus`) and `client_golang` is
also `package prometheus`. Files in `app/webrtc/` that need both must
alias one — convention is `coreprom "github.com/datarhei/core/v16/prometheus"`.
Implementation note only; doesn't change the design.
## References
- [Prometheus client_golang](https://pkg.go.dev/github.com/prometheus/client_golang/prometheus)
- [Prometheus instrumentation best practices](https://prometheus.io/docs/practices/instrumentation/)
- [Histogram bucket design](https://prometheus.io/docs/practices/histograms/)
- [Grafana provisioning docs](https://grafana.com/docs/grafana/latest/administration/provisioning/)
- v0.1 design: `docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md`
- M2 integration: `docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`

View file

@ -42,7 +42,7 @@ type ProcessConfigLimits struct {
WaitFor uint64 `json:"waitfor_seconds" jsonschema:"minimum=0" format:"uint64"` WaitFor uint64 `json:"waitfor_seconds" jsonschema:"minimum=0" format:"uint64"`
} }
// ProcessConfig represents the configuration of an ffmpeg process // ProcessConfigWebRTC represents the WHEP (egress) WebRTC configuration for a process
type ProcessConfigWebRTC struct { type ProcessConfigWebRTC struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
VideoPT uint8 `json:"video_pt,omitempty"` VideoPT uint8 `json:"video_pt,omitempty"`
@ -52,6 +52,13 @@ type ProcessConfigWebRTC struct {
AudioMap string `json:"audio_map,omitempty"` AudioMap string `json:"audio_map,omitempty"`
} }
// ProcessConfigWHIPIngest represents the WHIP (ingest) WebRTC configuration for a process
type ProcessConfigWHIPIngest struct {
Enabled bool `json:"enabled"`
VideoPT uint8 `json:"video_pt,omitempty"`
AudioPT uint8 `json:"audio_pt,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="`
@ -65,6 +72,7 @@ type ProcessConfig struct {
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"` WebRTC ProcessConfigWebRTC `json:"webrtc"`
WHIPIngest ProcessConfigWHIPIngest `json:"whip_ingest"`
} }
// 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
@ -88,6 +96,11 @@ func (cfg *ProcessConfig) Marshal() *app.Config {
VideoMap: cfg.WebRTC.VideoMap, VideoMap: cfg.WebRTC.VideoMap,
AudioMap: cfg.WebRTC.AudioMap, AudioMap: cfg.WebRTC.AudioMap,
}, },
WHIPIngest: app.ConfigWHIPIngest{
Enabled: cfg.WHIPIngest.Enabled,
VideoPT: cfg.WHIPIngest.VideoPT,
AudioPT: cfg.WHIPIngest.AudioPT,
},
} }
cfg.generateInputOutputIDs(cfg.Input) cfg.generateInputOutputIDs(cfg.Input)
@ -175,6 +188,10 @@ func (cfg *ProcessConfig) Unmarshal(c *app.Config) {
cfg.WebRTC.VideoMap = c.WebRTC.VideoMap cfg.WebRTC.VideoMap = c.WebRTC.VideoMap
cfg.WebRTC.AudioMap = c.WebRTC.AudioMap cfg.WebRTC.AudioMap = c.WebRTC.AudioMap
cfg.WHIPIngest.Enabled = c.WHIPIngest.Enabled
cfg.WHIPIngest.VideoPT = c.WHIPIngest.VideoPT
cfg.WHIPIngest.AudioPT = c.WHIPIngest.AudioPT
cfg.Options = make([]string, len(c.Options)) cfg.Options = make([]string, len(c.Options))
copy(cfg.Options, c.Options) copy(cfg.Options, c.Options)
@ -225,7 +242,7 @@ type ProcessReport struct {
History []ProcessReportHistoryEntry `json:"history"` History []ProcessReportHistoryEntry `json:"history"`
} }
// Unmarshal converts a restream log to a report // Unmarshal converts a restreamer log to a report
func (report *ProcessReport) Unmarshal(l *app.Log) { func (report *ProcessReport) Unmarshal(l *app.Log) {
if l == nil { if l == nil {
return return

View file

@ -87,7 +87,8 @@ type Config struct {
Cors CorsConfig Cors CorsConfig
RTMP rtmp.Server RTMP rtmp.Server
SRT srt.Server SRT srt.Server
WebRTC *appwebrtc.Handler WebRTC *appwebrtc.Handler // WHEP egress handler
WHIP *appwebrtc.WHIPHandler // WHIP ingest handler
JWT jwt.JWT JWT jwt.JWT
Config cfgstore.Store Config cfgstore.Store
Cache cache.Cacher Cache cache.Cacher
@ -126,7 +127,8 @@ type server struct {
session *api.SessionHandler session *api.SessionHandler
widget *api.WidgetHandler widget *api.WidgetHandler
resources *api.MetricsHandler resources *api.MetricsHandler
webrtc *appwebrtc.Handler webrtc *appwebrtc.Handler // WHEP egress
whip *appwebrtc.WHIPHandler // WHIP ingest
} }
middleware struct { middleware struct {
@ -245,6 +247,10 @@ func NewServer(config Config) (Server, error) {
s.v3handler.webrtc = config.WebRTC s.v3handler.webrtc = config.WebRTC
} }
if config.WHIP != nil {
s.v3handler.whip = config.WHIP
}
if config.Prometheus != nil { if config.Prometheus != nil {
s.handler.prometheus = handler.NewPrometheus( s.handler.prometheus = handler.NewPrometheus(
config.Prometheus.HTTPHandler(), config.Prometheus.HTTPHandler(),
@ -552,12 +558,18 @@ 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 // v3 WebRTC WHEP egress. Mounted on the v3 group so JWT auth
// covers it in M2; public embed tokens will ship in M3. // covers it in M2; public embed tokens will ship in M3.
if s.v3handler.webrtc != nil { if s.v3handler.webrtc != nil {
s.v3handler.webrtc.Register(v3) s.v3handler.webrtc.Register(v3)
} }
// v3 WebRTC WHIP ingest. Mounted alongside WHEP on the same v3
// group — both share JWT auth and live under /api/v3/whip/*.
if s.v3handler.whip != nil {
s.v3handler.whip.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)

75
prometheus/webrtc.go Normal file
View file

@ -0,0 +1,75 @@
package prometheus
import (
// Hybrid instrumentation rationale: direct client_golang instrumentation
// lives in app/webrtc/metrics.go (hot-path counters and histograms);
// this file owns the snapshot-style gauges. See the design doc at
// docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md.
"github.com/prometheus/client_golang/prometheus"
)
// WebRTCStats is a point-in-time snapshot of the WebRTC subsystem state.
// Populated by Handler.Stats() in app/webrtc and consumed by the collector
// at scrape time.
type WebRTCStats struct {
StreamCount int
PeersByStream map[string]int
UDPPortsInUse int
}
// WebRTCStatsSource is implemented by app/webrtc.Handler (via its Stats()
// method). The interface lives here so prometheus/ does not import app/webrtc,
// keeping the dependency arrow one-directional.
type WebRTCStatsSource interface {
Stats() WebRTCStats
}
type webrtcCollector struct {
core string
source WebRTCStatsSource
activeStreamsDesc *prometheus.Desc
activePeersDesc *prometheus.Desc
udpPortsInUseDesc *prometheus.Desc
}
// NewWebRTCCollector returns a prometheus.Collector that emits three gauge
// metrics at scrape time by calling source.Stats(). Register it with the
// shared Metrics registry before the /metrics endpoint starts serving.
func NewWebRTCCollector(core string, source WebRTCStatsSource) prometheus.Collector {
cl := prometheus.Labels{"core": core}
return &webrtcCollector{
core: core,
source: source,
activeStreamsDesc: prometheus.NewDesc(
"dragonfork_webrtc_active_streams",
"Streams currently registered (processes with webrtc.enabled=true running).",
nil, cl,
),
activePeersDesc: prometheus.NewDesc(
"dragonfork_webrtc_active_peers",
"Currently subscribed WHEP peers per stream.",
[]string{"stream_id"}, cl,
),
udpPortsInUseDesc: prometheus.NewDesc(
"dragonfork_webrtc_udp_ports_in_use",
"UDP ports currently allocated (2 per active stream).",
nil, cl,
),
}
}
func (c *webrtcCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.activeStreamsDesc
ch <- c.activePeersDesc
ch <- c.udpPortsInUseDesc
}
func (c *webrtcCollector) Collect(ch chan<- prometheus.Metric) {
stats := c.source.Stats()
ch <- prometheus.MustNewConstMetric(c.activeStreamsDesc, prometheus.GaugeValue, float64(stats.StreamCount))
ch <- prometheus.MustNewConstMetric(c.udpPortsInUseDesc, prometheus.GaugeValue, float64(stats.UDPPortsInUse))
for streamID, count := range stats.PeersByStream {
ch <- prometheus.MustNewConstMetric(c.activePeersDesc, prometheus.GaugeValue, float64(count), streamID)
}
}

97
prometheus/webrtc_test.go Normal file
View file

@ -0,0 +1,97 @@
package prometheus_test
import (
"strings"
"testing"
coreprom "github.com/datarhei/core/v16/prometheus"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// fakeStats implements coreprom.WebRTCStatsSource for testing.
type fakeStats struct{ s coreprom.WebRTCStats }
func (f *fakeStats) Stats() coreprom.WebRTCStats { return f.s }
func newReg(t *testing.T, source coreprom.WebRTCStatsSource) *prometheus.Registry {
t.Helper()
reg := prometheus.NewRegistry()
if err := reg.Register(coreprom.NewWebRTCCollector("test", source)); err != nil {
t.Fatalf("Register: %v", err)
}
return reg
}
func TestWebRTCCollector_NoStreams(t *testing.T) {
reg := newReg(t, &fakeStats{})
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
# HELP dragonfork_webrtc_active_streams Streams currently registered (processes with webrtc.enabled=true running).
# TYPE dragonfork_webrtc_active_streams gauge
dragonfork_webrtc_active_streams{core="test"} 0
# HELP dragonfork_webrtc_udp_ports_in_use UDP ports currently allocated (2 per active stream).
# TYPE dragonfork_webrtc_udp_ports_in_use gauge
dragonfork_webrtc_udp_ports_in_use{core="test"} 0
`),
"dragonfork_webrtc_active_streams",
"dragonfork_webrtc_udp_ports_in_use",
); err != nil {
t.Fatal(err)
}
}
func TestWebRTCCollector_OneStreamWithPeers(t *testing.T) {
src := &fakeStats{s: coreprom.WebRTCStats{
StreamCount: 1,
PeersByStream: map[string]int{"live": 3},
UDPPortsInUse: 2,
}}
reg := newReg(t, src)
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
# HELP dragonfork_webrtc_active_peers Currently subscribed WHEP peers per stream.
# TYPE dragonfork_webrtc_active_peers gauge
dragonfork_webrtc_active_peers{core="test",stream_id="live"} 3
# HELP dragonfork_webrtc_active_streams Streams currently registered (processes with webrtc.enabled=true running).
# TYPE dragonfork_webrtc_active_streams gauge
dragonfork_webrtc_active_streams{core="test"} 1
# HELP dragonfork_webrtc_udp_ports_in_use UDP ports currently allocated (2 per active stream).
# TYPE dragonfork_webrtc_udp_ports_in_use gauge
dragonfork_webrtc_udp_ports_in_use{core="test"} 2
`)); err != nil {
t.Fatal(err)
}
}
func TestWebRTCCollector_MultipleStreams(t *testing.T) {
src := &fakeStats{s: coreprom.WebRTCStats{
StreamCount: 2,
PeersByStream: map[string]int{"live": 3, "cam": 1},
UDPPortsInUse: 4,
}}
reg := newReg(t, src)
mfs, err := reg.Gather()
if err != nil {
t.Fatalf("Gather: %v", err)
}
// Check stream count and udp ports
for _, mf := range mfs {
switch mf.GetName() {
case "dragonfork_webrtc_active_streams":
if got := mf.GetMetric()[0].GetGauge().GetValue(); got != 2 {
t.Errorf("active_streams: want 2, got %v", got)
}
case "dragonfork_webrtc_udp_ports_in_use":
if got := mf.GetMetric()[0].GetGauge().GetValue(); got != 4 {
t.Errorf("udp_ports_in_use: want 4, got %v", got)
}
case "dragonfork_webrtc_active_peers":
total := 0.0
for _, m := range mf.GetMetric() {
total += m.GetGauge().GetValue()
}
if total != 4 {
t.Errorf("active_peers total: want 4, got %v", total)
}
}
}
}

View file

@ -43,6 +43,28 @@ type ConfigWebRTC struct {
// provided for symmetry with other Clone methods and future-proofing). // provided for symmetry with other Clone methods and future-proofing).
func (w ConfigWebRTC) Clone() ConfigWebRTC { return w } func (w ConfigWebRTC) Clone() ConfigWebRTC { return w }
// ConfigWHIPIngest carries per-process WHIP ingest settings.
//
// When Enabled is true the app/webrtc subsystem will, at process start,
// allocate two adjacent loopback UDP ports and prepend them as RTP input
// legs to the FFmpeg command. A browser or OBS publisher then connects
// to POST /api/v3/whip/{id} and the received WebRTC tracks are forwarded
// to those ports, giving FFmpeg its video+audio input via WebRTC.
//
// Flow (symmetric to WHEP egress):
// browser → WHIP → Pion → UDP → FFmpeg input → FFmpeg outputs (RTMP/SRT/HLS…)
//
// VideoPT / AudioPT are the RTP payload types Pion will stamp on forwarded
// packets. Defaults match the WHEP egress defaults (102/111).
type ConfigWHIPIngest struct {
Enabled bool `json:"enabled"`
VideoPT uint8 `json:"video_pt"`
AudioPT uint8 `json:"audio_pt"`
}
// Clone returns a value copy of the WHIP ingest config.
func (w ConfigWHIPIngest) Clone() ConfigWHIPIngest { return w }
func (io ConfigIO) Clone() ConfigIO { func (io ConfigIO) Clone() ConfigIO {
clone := ConfigIO{ clone := ConfigIO{
ID: io.ID, ID: io.ID,
@ -73,6 +95,7 @@ type Config struct {
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"` WebRTC ConfigWebRTC `json:"webrtc"`
WHIPIngest ConfigWHIPIngest `json:"whip_ingest,omitempty"`
} }
func (config *Config) Clone() *Config { func (config *Config) Clone() *Config {
@ -88,6 +111,7 @@ func (config *Config) Clone() *Config {
LimitMemory: config.LimitMemory, LimitMemory: config.LimitMemory,
LimitWaitFor: config.LimitWaitFor, LimitWaitFor: config.LimitWaitFor,
WebRTC: config.WebRTC.Clone(), WebRTC: config.WebRTC.Clone(),
WHIPIngest: config.WHIPIngest.Clone(),
} }
clone.Input = make([]ConfigIO, len(config.Input)) clone.Input = make([]ConfigIO, len(config.Input))

View file

@ -60,9 +60,11 @@ type Restreamer interface {
// ProcessStartHook is invoked synchronously inside startProcess just // ProcessStartHook is invoked synchronously inside startProcess just
// before FFmpeg is started. It receives a pointer to the task config; // before FFmpeg is started. It receives a pointer to the task config;
// returning a non-empty slice of ConfigIO appends those output legs to // returning a non-empty slice of ConfigIO causes the command to be
// cfg.Output and causes the FFmpeg command to be rebuilt before // rebuilt before Start(). Returning a non-nil error aborts the start.
// Start(). Returning a non-nil error aborts the start. //
// For OnStart the returned ConfigIO slices are appended to cfg.Output.
// For OnInputStart the returned ConfigIO slices are prepended to cfg.Input.
// //
// Hooks run with the restream write lock held, so they must not call // Hooks run with the restream write lock held, so they must not call
// back into the Restreamer interface (it would deadlock). They can, // back into the Restreamer interface (it would deadlock). They can,
@ -76,9 +78,15 @@ type ProcessStopHook func(id string)
// ProcessHooks bundles the lifecycle callbacks a sibling subsystem // ProcessHooks bundles the lifecycle callbacks a sibling subsystem
// (currently: app/webrtc) installs via SetHooks. // (currently: app/webrtc) installs via SetHooks.
//
// OnStart returns ConfigIO entries appended to cfg.Output (WHEP RTP egress legs).
// OnInputStart returns ConfigIO entries prepended to cfg.Input (WHIP RTP ingest legs).
// OnStop and OnInputStop are called after FFmpeg stops.
type ProcessHooks struct { type ProcessHooks struct {
OnStart ProcessStartHook OnStart ProcessStartHook
OnStop ProcessStopHook OnStop ProcessStopHook
OnInputStart ProcessStartHook // WHIP ingest: returned legs prepended to cfg.Input
OnInputStop ProcessStopHook // WHIP ingest: teardown notification
} }
// Config is the required configuration for a new restreamer instance. // Config is the required configuration for a new restreamer instance.
@ -1098,10 +1106,24 @@ 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 // Invoke the per-process lifecycle hooks. OnInputStart returns
// RTP output legs). If it returns ConfigIO entries, append them to // ConfigIO entries prepended to cfg.Input (WHIP ingest legs);
// the output list and rebuild the FFmpeg process with the new // OnStart returns ConfigIO entries appended to cfg.Output (WHEP
// command before we start it. // egress legs). Both rebuild the FFmpeg process if non-empty.
needsRebuild := false
if r.hooks.OnInputStart != nil {
inputExtras, err := r.hooks.OnInputStart(task.id, task.config)
if err != nil {
r.logger.WithField("id", task.id).WithError(err).Error().Log("WHIP input hook aborted process start")
return err
}
if len(inputExtras) > 0 {
task.config.Input = append(inputExtras, task.config.Input...)
needsRebuild = true
}
}
if r.hooks.OnStart != nil { if r.hooks.OnStart != nil {
extras, err := r.hooks.OnStart(task.id, task.config) extras, err := r.hooks.OnStart(task.id, task.config)
if err != nil { if err != nil {
@ -1110,6 +1132,11 @@ func (r *restream) startProcess(id string) error {
} }
if len(extras) > 0 { if len(extras) > 0 {
task.config.Output = append(task.config.Output, extras...) task.config.Output = append(task.config.Output, extras...)
needsRebuild = true
}
}
if needsRebuild {
task.command = task.config.CreateCommand() task.command = task.config.CreateCommand()
newFFmpeg, ferr := r.ffmpeg.New(ffmpeg.ProcessConfig{ newFFmpeg, ferr := r.ffmpeg.New(ffmpeg.ProcessConfig{
@ -1124,12 +1151,11 @@ func (r *restream) startProcess(id string) error {
Logger: task.logger, Logger: task.logger,
}) })
if ferr != nil { if ferr != nil {
r.logger.WithField("id", task.id).WithError(ferr).Error().Log("Failed to rebuild FFmpeg after start hook") r.logger.WithField("id", task.id).WithError(ferr).Error().Log("Failed to rebuild FFmpeg after hooks")
return ferr return ferr
} }
task.ffmpeg = newFFmpeg task.ffmpeg = newFFmpeg
} }
}
task.ffmpeg.Start() task.ffmpeg.Start()
@ -1175,11 +1201,14 @@ func (r *restream) stopProcess(id string) error {
r.nProc-- r.nProc--
// Notify subsystems (app/webrtc) that this process has been // Notify subsystems (app/webrtc) that this process has been
// stopped so they can tear down any per-process state. Hook is // stopped so they can tear down any per-process state. Hooks are
// best-effort: errors are the hook's problem to log. // best-effort: errors are the hook's problem to log.
if r.hooks.OnStop != nil { if r.hooks.OnStop != nil {
r.hooks.OnStop(task.id) r.hooks.OnStop(task.id)
} }
if r.hooks.OnInputStop != nil {
r.hooks.OnInputStop(task.id)
}
return nil return nil
} }

24
src/misc/Logo/index.js Normal file
View file

@ -0,0 +1,24 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import company_logo from './images/logo.png';
const useStyles = makeStyles((theme) => ({
Logo: {
height: 27,
},
}));
export default function Logo(props) {
const classes = useStyles();
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
// eslint-disable-next-line no-useless-escape
return (
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
<img src={company_logo} alt="Wild Dragon logo" />
</a>
);
}

24
src/misc/Logo/rsLogo.js Normal file
View file

@ -0,0 +1,24 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import company_logo from './images/logo.png';
const useStyles = makeStyles((theme) => ({
Logo: {
height: 95,
},
}));
export default function Logo(props) {
const classes = useStyles();
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
// eslint-disable-next-line no-useless-escape
return (
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
<img src={company_logo} alt="Wild Dragon mark" />
</a>
);
}

View file

385
test/load/sustained.go Normal file
View file

@ -0,0 +1,385 @@
// Dragon Fork — headless WHEP subscriber load test.
//
// Drives N concurrent WHEP peers against a single stream for a configurable
// duration and produces a markdown report suitable for committing to
// test/load/results/.
//
// Usage:
//
// go run ./test/load/sustained.go \
// -url http://10.0.0.25:8080 \
// -stream mystream \
// -peers 5 \
// -duration 10m \
// -auth "Bearer <TOKEN>" \
// -out test/load/results/
//
// The program exits 0 on success or 1 if any peer failed to connect.
// Run the Grafana dashboard alongside to observe Prometheus metrics
// during the test (see deploy/truenas/core/docker-compose.yml).
//go:build ignore
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
)
// peerResult holds per-peer stats collected over the run.
type peerResult struct {
index int
connected bool
iceEstablishMs float64
packetsReceived uint64
seqGaps uint64 // proxy for packet loss
jitterSamplesMs []float64
disconnectedAt time.Time
err string
}
func main() {
coreURL := flag.String("url", "http://localhost:8080", "Dragon Fork Core base URL")
streamID := flag.String("stream", "", "Process ID with webrtc.enabled=true")
nPeers := flag.Int("peers", 5, "Number of concurrent WHEP subscribers")
duration := flag.Duration("duration", 10*time.Minute, "Test duration")
auth := flag.String("auth", "", "Authorization header value (e.g. 'Bearer TOKEN')")
outDir := flag.String("out", "test/load/results", "Output directory for markdown report")
flag.Parse()
if *streamID == "" {
fmt.Fprintln(os.Stderr, "error: -stream is required")
os.Exit(1)
}
fmt.Printf("Dragon Fork WHEP load test\n")
fmt.Printf(" target: %s\n", *coreURL)
fmt.Printf(" stream: %s\n", *streamID)
fmt.Printf(" peers: %d\n", *nPeers)
fmt.Printf(" duration: %s\n", *duration)
fmt.Println()
ctx, cancel := context.WithTimeout(context.Background(), *duration+30*time.Second)
defer cancel()
results := make([]peerResult, *nPeers)
var wg sync.WaitGroup
var anyFail int32
for i := 0; i < *nPeers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
res := runPeer(ctx, idx, *coreURL, *streamID, *auth, *duration)
results[idx] = res
if !res.connected {
atomic.StoreInt32(&anyFail, 1)
}
}(i)
// Stagger connection attempts 200ms apart to avoid thundering herd.
time.Sleep(200 * time.Millisecond)
}
wg.Wait()
report := buildReport(*coreURL, *streamID, *nPeers, *duration, results)
fmt.Print(report)
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "mkdir %s: %v\n", *outDir, err)
} else {
ts := time.Now().UTC().Format("2006-01-02T150405Z")
fname := filepath.Join(*outDir, fmt.Sprintf("%s-%s-%dp.md", ts, *streamID, *nPeers))
if err := os.WriteFile(fname, []byte(report), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write report: %v\n", err)
} else {
fmt.Printf("\nReport written to %s\n", fname)
}
}
if anyFail != 0 {
os.Exit(1)
}
}
// runPeer connects one WHEP subscriber, reads packets for the test duration,
// and collects statistics.
func runPeer(ctx context.Context, idx int, coreURL, streamID, auth string, dur time.Duration) peerResult {
res := peerResult{index: idx}
// Build a minimal SDP offer using Pion.
me := &webrtc.MediaEngine{}
if err := me.RegisterDefaultCodecs(); err != nil {
res.err = fmt.Sprintf("media engine: %v", err)
return res
}
api := webrtc.NewAPI(webrtc.WithMediaEngine(me))
cfg := webrtc.Configuration{ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}}
pc, err := api.NewPeerConnection(cfg)
if err != nil {
res.err = fmt.Sprintf("new peer connection: %v", err)
return res
}
defer pc.Close()
// Add receive-only transceivers so the SDP offer contains the right m= lines.
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
res.err = fmt.Sprintf("add video transceiver: %v", err)
return res
}
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
res.err = fmt.Sprintf("add audio transceiver: %v", err)
return res
}
offer, err := pc.CreateOffer(nil)
if err != nil {
res.err = fmt.Sprintf("create offer: %v", err)
return res
}
if err := pc.SetLocalDescription(offer); err != nil {
res.err = fmt.Sprintf("set local desc: %v", err)
return res
}
// POST the offer to the WHEP endpoint.
whepURL := strings.TrimRight(coreURL, "/") + "/api/v3/whep/" + streamID
t0 := time.Now()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, whepURL, bytes.NewReader([]byte(offer.SDP)))
if err != nil {
res.err = fmt.Sprintf("build http request: %v", err)
return res
}
httpReq.Header.Set("Content-Type", "application/sdp")
if auth != "" {
httpReq.Header.Set("Authorization", auth)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
res.err = fmt.Sprintf("POST /whep: %v", err)
return res
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
res.err = fmt.Sprintf("WHEP POST returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return res
}
answerSDP, err := io.ReadAll(resp.Body)
if err != nil {
res.err = fmt.Sprintf("read answer: %v", err)
return res
}
answer := webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: string(answerSDP)}
if err := pc.SetRemoteDescription(answer); err != nil {
res.err = fmt.Sprintf("set remote desc: %v", err)
return res
}
// Wait for ICE connected.
connCh := make(chan struct{})
var connOnce sync.Once
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateConnected {
connOnce.Do(func() { close(connCh) })
}
})
select {
case <-connCh:
res.iceEstablishMs = float64(time.Since(t0).Milliseconds())
res.connected = true
case <-time.After(15 * time.Second):
res.err = "ICE connection timeout (15s)"
return res
case <-ctx.Done():
res.err = "context cancelled before ICE connected"
return res
}
fmt.Printf(" peer %d connected (ICE: %.0fms)\n", idx, res.iceEstablishMs)
// Collect RTP statistics for the test duration.
var mu sync.Mutex
var lastSeq uint16
var seenFirst bool
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
prevArrival := time.Now()
var prevRTPTimestamp uint32
var jitter float64
clockRate := float64(track.Codec().ClockRate)
buf := make([]byte, 1500)
for {
n, _, err := track.Read(buf)
if err != nil || n < 12 {
return
}
arrivalTime := time.Now()
// Sequence number gap tracking.
seq := uint16(buf[2])<<8 | uint16(buf[3])
mu.Lock()
atomic.AddUint64(&res.packetsReceived, 1)
if seenFirst {
expected := lastSeq + 1
if seq != expected {
gaps := uint64(seq - expected)
if gaps < 1000 {
atomic.AddUint64(&res.seqGaps, gaps)
}
}
} else {
seenFirst = true
}
lastSeq = seq
mu.Unlock()
// RFC 3550 jitter (simplified: interarrival time deviation).
rtpTS := uint32(buf[4])<<24 | uint32(buf[5])<<16 | uint32(buf[6])<<8 | uint32(buf[7])
if prevRTPTimestamp != 0 {
sendDiff := float64(int32(rtpTS-prevRTPTimestamp)) / clockRate
recvDiff := arrivalTime.Sub(prevArrival).Seconds()
d := math.Abs(recvDiff - sendDiff)
jitter += (d - jitter) / 16 // running average
mu.Lock()
res.jitterSamplesMs = append(res.jitterSamplesMs, jitter*1000)
mu.Unlock()
}
prevRTPTimestamp = rtpTS
prevArrival = arrivalTime
}
})
// Wait for test duration or context cancellation.
testTimer := time.NewTimer(dur)
defer testTimer.Stop()
select {
case <-testTimer.C:
case <-ctx.Done():
res.disconnectedAt = time.Now()
}
// DELETE the WHEP resource.
location := resp.Header.Get("Location")
if location != "" {
delURL := strings.TrimRight(coreURL, "/") + location
delReq, _ := http.NewRequest(http.MethodDelete, delURL, nil)
if auth != "" {
delReq.Header.Set("Authorization", auth)
}
_, _ = http.DefaultClient.Do(delReq)
}
return res
}
func buildReport(coreURL, streamID string, nPeers int, dur time.Duration, results []peerResult) string {
var b strings.Builder
ts := time.Now().UTC().Format(time.RFC3339)
b.WriteString("# Dragon Fork WHEP Sustained Load Test\n\n")
fmt.Fprintf(&b, "**Date:** %s \n", ts)
fmt.Fprintf(&b, "**Target:** %s \n", coreURL)
fmt.Fprintf(&b, "**Stream:** %s \n", streamID)
fmt.Fprintf(&b, "**Peers:** %d \n", nPeers)
fmt.Fprintf(&b, "**Duration:** %s \n\n", dur)
// Summary table.
connected := 0
var iceMs []float64
var allJitter []float64
var totalPkts, totalGaps uint64
for _, r := range results {
if r.connected {
connected++
iceMs = append(iceMs, r.iceEstablishMs)
allJitter = append(allJitter, r.jitterSamplesMs...)
totalPkts += r.packetsReceived
totalGaps += r.seqGaps
}
}
b.WriteString("## Summary\n\n")
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
fmt.Fprintf(&b, "| Peers connected | %d / %d |\n", connected, nPeers)
fmt.Fprintf(&b, "| Total packets received | %d |\n", totalPkts)
lossRate := 0.0
if totalPkts+totalGaps > 0 {
lossRate = float64(totalGaps) / float64(totalPkts+totalGaps) * 100
}
fmt.Fprintf(&b, "| Packet loss estimate | %.2f%% |\n", lossRate)
if len(iceMs) > 0 {
fmt.Fprintf(&b, "| ICE establishment p50 | %.0fms |\n", percentile(iceMs, 50))
fmt.Fprintf(&b, "| ICE establishment p95 | %.0fms |\n", percentile(iceMs, 95))
}
if len(allJitter) > 0 {
fmt.Fprintf(&b, "| Jitter p50 | %.2fms |\n", percentile(allJitter, 50))
fmt.Fprintf(&b, "| Jitter p95 | %.2fms |\n", percentile(allJitter, 95))
}
b.WriteString("\n")
// Per-peer detail.
b.WriteString("## Per-Peer Detail\n\n")
b.WriteString("| Peer | Connected | ICE ms | Packets | Loss est. | Jitter p95 |\n")
b.WriteString("|---|---|---|---|---|---|\n")
for _, r := range results {
if r.err != "" {
fmt.Fprintf(&b, "| %d | ❌ | — | — | — | %s |\n", r.index, r.err)
continue
}
lossEst := 0.0
if r.packetsReceived+r.seqGaps > 0 {
lossEst = float64(r.seqGaps) / float64(r.packetsReceived+r.seqGaps) * 100
}
jP95 := 0.0
if len(r.jitterSamplesMs) > 0 {
jP95 = percentile(r.jitterSamplesMs, 95)
}
fmt.Fprintf(&b, "| %d | ✓ | %.0f | %d | %.2f%% | %.2fms |\n",
r.index, r.iceEstablishMs, r.packetsReceived, lossEst, jP95)
}
b.WriteString("\n")
b.WriteString("---\n")
b.WriteString("*Generated by `test/load/sustained.go`. Observe Prometheus metrics during run via Grafana.*\n")
return b.String()
}
func percentile(data []float64, p float64) float64 {
if len(data) == 0 {
return 0
}
sorted := make([]float64, len(data))
copy(sorted, data)
sort.Float64s(sorted)
idx := (p / 100) * float64(len(sorted)-1)
lo := int(idx)
hi := lo + 1
if hi >= len(sorted) {
return sorted[lo]
}
frac := idx - float64(lo)
return sorted[lo]*(1-frac) + sorted[hi]*frac
}