encrypted logs, more customization for admins.

This commit is contained in:
KingKDot
2026-06-27 16:40:54 -05:00
parent 95447f41cd
commit 84de7050fb
24 changed files with 952 additions and 30 deletions
@@ -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;