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 Agent

Local Management Agent

Version `+config.Version+`

Endpoints

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

+ +
+
+
+
+
+ + + +
+

+ + + + + +
NameTypeSizeModified
+
+
+
+ + + +
+ +
+
+
+ +
+
+ + +
+

+

+    
+
+ + + diff --git a/localagent.exe b/localagent.exe new file mode 100755 index 0000000..3047a1e Binary files /dev/null and b/localagent.exe differ