Merge branch 'main' into dev
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildClientGraph, clientIpSubnet } from "./client-graph";
|
||||
|
||||
describe("client graph", () => {
|
||||
test("builds client relationship nodes and edges", () => {
|
||||
const graph = buildClientGraph([
|
||||
{
|
||||
id: "client-a",
|
||||
host: "alpha",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.40",
|
||||
country: "US",
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
{
|
||||
id: "client-b",
|
||||
host: "beta",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.55",
|
||||
country: "US",
|
||||
online: false,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const nodeIds = new Set(graph.nodes.map((node) => node.data.id));
|
||||
expect(nodeIds.has("client:client-a")).toBe(true);
|
||||
expect(nodeIds.has("client:client-b")).toBe(true);
|
||||
expect(nodeIds.has("group:7")).toBe(true);
|
||||
expect(nodeIds.has("build:prod-build")).toBe(true);
|
||||
expect(nodeIds.has("os:windows")).toBe(true);
|
||||
expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true);
|
||||
expect(nodeIds.has("user:alice")).toBe(true);
|
||||
expect(graph.summary.clients).toBe(2);
|
||||
expect(graph.summary.online).toBe(1);
|
||||
expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes IPv4 and IPv6 subnets", () => {
|
||||
expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24");
|
||||
expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64");
|
||||
expect(clientIpSubnet("not-an-ip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ListItem } from "../types";
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "client"
|
||||
| "group"
|
||||
| "build"
|
||||
| "os"
|
||||
| "country"
|
||||
| "subnet"
|
||||
| "user"
|
||||
| "status";
|
||||
|
||||
export type ClientGraphNode = {
|
||||
data: {
|
||||
id: string;
|
||||
label: string;
|
||||
type: GraphNodeKind;
|
||||
weight: number;
|
||||
count?: number;
|
||||
onlineCount?: number;
|
||||
clientId?: string;
|
||||
online?: boolean;
|
||||
href?: string;
|
||||
color?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraphEdge = {
|
||||
data: {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraph = {
|
||||
nodes: ClientGraphNode[];
|
||||
edges: ClientGraphEdge[];
|
||||
summary: {
|
||||
clients: number;
|
||||
totalClients?: number;
|
||||
online: number;
|
||||
categories: number;
|
||||
relationships: number;
|
||||
generatedAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
function compactLabel(value: unknown, fallback = "Unknown"): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return fallback;
|
||||
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
||||
}
|
||||
|
||||
function safeKey(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown";
|
||||
}
|
||||
|
||||
function ipv4Subnet(ip: string): string | null {
|
||||
const parts = ip.trim().split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
|
||||
}
|
||||
|
||||
function ipv6Subnet(ip: string): string | null {
|
||||
if (!ip.includes(":")) return null;
|
||||
const parts = ip.split(":").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return `${parts.slice(0, 4).join(":")}::/64`;
|
||||
}
|
||||
|
||||
export function clientIpSubnet(ip: unknown): string | null {
|
||||
const value = String(ip ?? "").trim();
|
||||
if (!value) return null;
|
||||
return ipv4Subnet(value) || ipv6Subnet(value);
|
||||
}
|
||||
|
||||
export function buildClientGraph(clients: ListItem[]): ClientGraph {
|
||||
const nodes = new Map<string, ClientGraphNode>();
|
||||
const edges = new Map<string, ClientGraphEdge>();
|
||||
|
||||
const addNode = (
|
||||
id: string,
|
||||
label: string,
|
||||
type: GraphNodeKind,
|
||||
data: Partial<ClientGraphNode["data"]> = {},
|
||||
) => {
|
||||
const existing = nodes.get(id);
|
||||
if (existing) {
|
||||
existing.data.count = (existing.data.count || 0) + 1;
|
||||
if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1;
|
||||
existing.data.weight = Math.max(existing.data.weight, data.weight || 1);
|
||||
return existing;
|
||||
}
|
||||
const node: ClientGraphNode = {
|
||||
data: {
|
||||
id,
|
||||
label,
|
||||
type,
|
||||
weight: data.weight || 1,
|
||||
count: data.count,
|
||||
onlineCount: data.online ? 1 : data.onlineCount,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const addEdge = (source: string, target: string, label: string, type: string) => {
|
||||
const id = `${source}->${target}:${type}`;
|
||||
if (edges.has(id)) return;
|
||||
edges.set(id, { data: { id, source, target, label, type } });
|
||||
};
|
||||
|
||||
for (const client of clients) {
|
||||
const clientNodeId = `client:${client.id}`;
|
||||
const clientLabel = compactLabel(client.nickname || client.host || client.id);
|
||||
addNode(clientNodeId, clientLabel, "client", {
|
||||
weight: client.online ? 5 : 3,
|
||||
clientId: client.id,
|
||||
online: client.online,
|
||||
href: `/?q=${encodeURIComponent(client.id)}`,
|
||||
meta: {
|
||||
id: client.id,
|
||||
host: client.host || "",
|
||||
user: client.user || "",
|
||||
os: client.os || "",
|
||||
ip: client.ip || "",
|
||||
country: client.country || "",
|
||||
groupName: client.groupName || "",
|
||||
buildTag: client.buildTag || "",
|
||||
lastSeen: client.lastSeen,
|
||||
},
|
||||
});
|
||||
|
||||
const statusNode = client.online
|
||||
? { id: "status:online", label: "Online", color: "#22c55e" }
|
||||
: { id: "status:offline", label: "Offline", color: "#64748b" };
|
||||
addNode(statusNode.id, statusNode.label, "status", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: statusNode.color,
|
||||
});
|
||||
addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status");
|
||||
|
||||
if (client.groupName || client.groupId) {
|
||||
const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`;
|
||||
addNode(groupId, compactLabel(client.groupName || "Group"), "group", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: client.groupColor || null,
|
||||
});
|
||||
addEdge(clientNodeId, groupId, "group", "group");
|
||||
}
|
||||
|
||||
if (client.buildTag) {
|
||||
const id = `build:${safeKey(client.buildTag)}`;
|
||||
addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "build", "build");
|
||||
}
|
||||
|
||||
if (client.os) {
|
||||
const id = `os:${safeKey(client.os)}`;
|
||||
addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "os", "os");
|
||||
}
|
||||
|
||||
if (client.country) {
|
||||
const label = String(client.country).toUpperCase();
|
||||
const id = `country:${safeKey(label)}`;
|
||||
addNode(id, label, "country", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "country", "country");
|
||||
}
|
||||
|
||||
const subnet = clientIpSubnet(client.ip);
|
||||
if (subnet) {
|
||||
const id = `subnet:${safeKey(subnet)}`;
|
||||
addNode(id, subnet, "subnet", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "subnet", "subnet");
|
||||
}
|
||||
|
||||
if (client.user) {
|
||||
const id = `user:${safeKey(client.user)}`;
|
||||
addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online });
|
||||
addEdge(clientNodeId, id, "user", "user");
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNodes = Array.from(nodes.values()).map((node) => {
|
||||
if (node.data.type !== "client") {
|
||||
node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1)));
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: sortedNodes,
|
||||
edges: Array.from(edges.values()),
|
||||
summary: {
|
||||
clients: clients.length,
|
||||
online: clients.filter((client) => client.online).length,
|
||||
categories: sortedNodes.filter((node) => node.data.type !== "client").length,
|
||||
relationships: edges.size,
|
||||
generatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { clearRequestRateLimitsForTests } from "../rateLimit";
|
||||
import { createHttpFetchHandler } from "./http-dispatch";
|
||||
import { createHttpFetchHandler, normalizeHttpRoute } from "./http-dispatch";
|
||||
|
||||
const metrics = {
|
||||
withHttpMetrics: (fn: () => Promise<Response>) => fn(),
|
||||
@@ -35,3 +35,22 @@ describe("createHttpFetchHandler rate limiting", () => {
|
||||
expect(limited.headers.get("Retry-After")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHttpRoute", () => {
|
||||
test("collapses dynamic route segments", () => {
|
||||
const req = new Request(
|
||||
"https://localhost/api/clients/0a44a8d4d61c7840540b43fd187fe3c43e2c46c9600b2aa1a61a6b1ab03c5b92/rd/ws?cache=1",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(normalizeHttpRoute(req, new URL(req.url))).toBe(
|
||||
"POST /api/clients/:id/rd/ws",
|
||||
);
|
||||
});
|
||||
|
||||
test("groups static asset files", () => {
|
||||
const req = new Request("https://localhost/assets/metrics.js");
|
||||
|
||||
expect(normalizeHttpRoute(req, new URL(req.url))).toBe("GET /assets/:file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { consumeUnauthorizedRateLimit } from "../rateLimit";
|
||||
|
||||
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise<Response | null>;
|
||||
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
|
||||
|
||||
function tooManyRequestsResponse(retryAfter = 60): Response {
|
||||
return new Response("Too many requests", {
|
||||
status: 429,
|
||||
@@ -13,17 +16,35 @@ function tooManyRequestsResponse(retryAfter = 60): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRouteSegment(segment: string): string {
|
||||
if (/^\d+$/.test(segment)) return ":id";
|
||||
if (UUID_SEGMENT.test(segment) || LONG_ID_SEGMENT.test(segment)) return ":id";
|
||||
if (segment.includes(".") && !segment.startsWith(".")) return ":file";
|
||||
if (segment.length >= 24 && /^[a-z0-9_-]+$/i.test(segment)) return ":id";
|
||||
return segment;
|
||||
}
|
||||
|
||||
export function normalizeHttpRoute(req: Request, url: URL): string {
|
||||
const path = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(normalizeRouteSegment)
|
||||
.join("/");
|
||||
return `${req.method.toUpperCase()} /${path}`;
|
||||
}
|
||||
|
||||
export function createHttpFetchHandler(deps: {
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>, route?: string) => Promise<Response> };
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
routes: RouteHandler[];
|
||||
}) {
|
||||
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const routeLabel = normalizeHttpRoute(req, url);
|
||||
return deps.metrics.withHttpMetrics(async () => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
|
||||
const ip = wrapped.requestIP(req)?.address || "unknown";
|
||||
|
||||
@@ -38,6 +59,6 @@ export function createHttpFetchHandler(deps: {
|
||||
}
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
}, routeLabel);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => {
|
||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("script-src 'self'");
|
||||
expect(csp).toContain("font-src 'self' data:");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
export const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
|
||||
@@ -103,6 +103,19 @@ describe("/api/auth/me permissions array", () => {
|
||||
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
|
||||
});
|
||||
|
||||
test("legacy system:configure extra grants the split settings permissions", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
setUserExtraPermissions(viewer.user.id, ["system:configure"]);
|
||||
const body = await fetchMe(viewer.token);
|
||||
const set = new Set(body.permissions);
|
||||
expect(set.has("system:configure")).toBe(true);
|
||||
expect(set.has("system:security")).toBe(true);
|
||||
expect(set.has("system:tls")).toBe(true);
|
||||
expect(set.has("system:registration")).toBe(true);
|
||||
expect(set.has("system:health")).toBe(true);
|
||||
expect(set.has("system:profiler")).toBe(true);
|
||||
});
|
||||
|
||||
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
|
||||
import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { buildClientGraph } from "../client-graph";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
getActiveProxies,
|
||||
@@ -329,13 +330,59 @@ export async function handleMiscRoutes(
|
||||
snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount();
|
||||
snapshot.sessions.process = deps.getProcessSessionCount();
|
||||
|
||||
const history = metrics.getHistory();
|
||||
const requestedHistoryLimit = Number(url.searchParams.get("historyLimit") || 240);
|
||||
const historyLimit = Number.isFinite(requestedHistoryLimit)
|
||||
? Math.max(1, Math.min(2000, requestedHistoryLimit))
|
||||
: 240;
|
||||
const history = metrics.getHistory().slice(-historyLimit);
|
||||
|
||||
return new Response(JSON.stringify({ snapshot, history }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/client-graph") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300)));
|
||||
const search = (url.searchParams.get("q") || "").trim().toLowerCase();
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const groupFilter = url.searchParams.get("group") || "all";
|
||||
let allowedClientIds: string[] | undefined;
|
||||
let deniedClientIds: string[] | undefined;
|
||||
|
||||
if (user.role !== "admin") {
|
||||
const scope = getUserClientAccessScope(user.userId);
|
||||
if (scope === "none") {
|
||||
return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
if (scope === "allowlist") {
|
||||
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
|
||||
} else if (scope === "denylist") {
|
||||
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
|
||||
}
|
||||
}
|
||||
|
||||
const clients = listClients({
|
||||
page: 1,
|
||||
pageSize: limit,
|
||||
search,
|
||||
sort: "last_seen_desc",
|
||||
statusFilter,
|
||||
osFilter: "all",
|
||||
countryFilter: "all",
|
||||
enrollmentFilter: "approved",
|
||||
groupFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
});
|
||||
|
||||
const graph = buildClientGraph(clients.items);
|
||||
graph.summary.totalClients = clients.total;
|
||||
return Response.json(graph, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
@@ -385,7 +432,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:security");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -447,7 +494,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:tls");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -502,7 +549,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:tls");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -743,7 +790,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:export-import");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -776,7 +823,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:export-import");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -828,7 +875,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:chat");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -869,7 +916,7 @@ export async function handleMiscRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:appearance");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -934,7 +981,7 @@ export async function handleMiscRoutes(
|
||||
if (url.pathname === "/api/settings/registration") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:registration"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
|
||||
@@ -970,7 +1017,7 @@ export async function handleMiscRoutes(
|
||||
if (url.pathname === "/api/settings/build-rate-limit") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:build-limits"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
|
||||
@@ -1011,7 +1058,7 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
try { requirePermission(user, "system:thumbnails"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {
|
||||
@@ -1040,7 +1087,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "GET" && url.pathname === "/api/settings/health") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:health"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
return Response.json({
|
||||
@@ -1069,7 +1116,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:health:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
|
||||
@@ -1083,7 +1130,7 @@ export async function handleMiscRoutes(
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
try { requirePermission(user, "system:profiler"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (activeServerProfile) {
|
||||
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function handleNotificationsConfigRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:notifications");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
@@ -95,7 +95,7 @@ export async function handleNotificationsConfigRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "system:configure");
|
||||
requirePermission(user, "system:notifications");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
|
||||
@@ -82,6 +82,7 @@ type QueryClientPageDef = {
|
||||
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||
const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
|
||||
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import * as clientManager from "../clientManager";
|
||||
import { decodeMessage } from "../protocol";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
import {
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
rdStreamingState,
|
||||
} from "./ws-console-rd-hvnc";
|
||||
|
||||
type MockWs = {
|
||||
data: SocketData;
|
||||
sent: unknown[];
|
||||
closedCode?: number;
|
||||
closedReason?: string;
|
||||
send: (msg: unknown) => void;
|
||||
close: (code: number, reason: string) => void;
|
||||
getBufferedAmount: () => number;
|
||||
};
|
||||
|
||||
const clientIdsToCleanup = new Set<string>();
|
||||
|
||||
function createMockWs(data: Partial<SocketData>): MockWs {
|
||||
return {
|
||||
data: {
|
||||
role: "rd_viewer",
|
||||
clientId: "rd-test-client",
|
||||
...data,
|
||||
} as SocketData,
|
||||
sent: [],
|
||||
send(msg: unknown) {
|
||||
this.sent.push(msg);
|
||||
},
|
||||
close(code: number, reason: string) {
|
||||
this.closedCode = code;
|
||||
this.closedReason = reason;
|
||||
},
|
||||
getBufferedAmount() {
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(id: string) {
|
||||
const agentWs = createMockWs({ role: "client", clientId: id });
|
||||
const info: ClientInfo = {
|
||||
id,
|
||||
role: "client",
|
||||
ws: agentWs,
|
||||
lastSeen: Date.now(),
|
||||
online: true,
|
||||
host: "rd-test-host",
|
||||
os: "windows",
|
||||
user: "tester",
|
||||
monitors: 1,
|
||||
};
|
||||
clientManager.addClient(id, info);
|
||||
clientIdsToCleanup.add(id);
|
||||
return { info, agentWs };
|
||||
}
|
||||
|
||||
function agentCommands(ws: MockWs) {
|
||||
return ws.sent.map((msg) => decodeMessage(msg as Uint8Array) as any);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const clientId of clientIdsToCleanup) {
|
||||
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
|
||||
sessionManager.deleteRdSession(session.id);
|
||||
}
|
||||
rdStreamingState.delete(clientId);
|
||||
clientManager.deleteClient(clientId);
|
||||
}
|
||||
clientIdsToCleanup.clear();
|
||||
});
|
||||
|
||||
describe("remote desktop viewer control", () => {
|
||||
test("starts once, ignores duplicate starts, and only stops after the last viewer leaves", () => {
|
||||
const clientId = `rd-control-${Date.now().toString(36)}`;
|
||||
const { agentWs } = createClient(clientId);
|
||||
const firstViewer = createMockWs({ clientId });
|
||||
const secondViewer = createMockWs({ clientId });
|
||||
|
||||
handleRemoteDesktopViewerOpen(firstViewer as any);
|
||||
handleRemoteDesktopViewerOpen(secondViewer as any);
|
||||
|
||||
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
|
||||
let commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_start")).toHaveLength(1);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
|
||||
|
||||
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_stop" }));
|
||||
|
||||
commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(0);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
|
||||
|
||||
sessionManager.deleteRdSession(firstViewer.data.sessionId!);
|
||||
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_stop" }));
|
||||
|
||||
commands = agentCommands(agentWs);
|
||||
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(1);
|
||||
expect(commands.filter((msg) => msg.commandType === "webrtc_stop")).toHaveLength(1);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
test("does not forward desktop_start when a macOS client is missing required permissions", () => {
|
||||
const clientId = `rd-mac-perms-${Date.now().toString(36)}`;
|
||||
const { info, agentWs } = createClient(clientId);
|
||||
info.os = "darwin";
|
||||
info.permissions = { screenRecording: false, accessibility: true };
|
||||
const viewer = createMockWs({ clientId });
|
||||
|
||||
handleRemoteDesktopViewerOpen(viewer as any);
|
||||
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_start" }));
|
||||
|
||||
expect(agentCommands(agentWs).filter((msg) => msg.commandType === "desktop_start")).toHaveLength(0);
|
||||
expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true);
|
||||
expect(viewer.sent.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,7 @@ function broadcastFrameToViewers(
|
||||
}
|
||||
|
||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||
const rdDebugFrameLogAt = new Map<string, number>();
|
||||
export const rdStreamingState = new Map<string, {
|
||||
isStreaming: boolean;
|
||||
display: number;
|
||||
@@ -223,9 +224,12 @@ function sendConsoleCommand(target: ClientInfo | undefined, commandType: string,
|
||||
}
|
||||
|
||||
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
|
||||
if (!target) return false;
|
||||
if (!target) {
|
||||
logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
logger.debug(`[rd] send command ${commandType} -> ${target.id}`);
|
||||
logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
|
||||
metrics.recordCommand(commandType);
|
||||
return true;
|
||||
@@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
case "desktop_start":
|
||||
logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`);
|
||||
if (!state.isStreaming) {
|
||||
const targetOs = String(target.os || "").toLowerCase();
|
||||
if ((targetOs.includes("darwin") || targetOs.includes("mac")) && target.permissions) {
|
||||
@@ -364,14 +369,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
sendDesktopCommand(target, "desktop_start", {});
|
||||
state.isStreaming = true;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
|
||||
logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`);
|
||||
} else {
|
||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
||||
logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
|
||||
}
|
||||
break;
|
||||
case "desktop_stop": {
|
||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||
.filter(s => s.id !== ws.data.sessionId);
|
||||
logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`);
|
||||
if (otherViewers.length === 0) {
|
||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
@@ -649,6 +655,12 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
const frameFps = Number(header?.fps) || 0;
|
||||
if (frameFps > 0) state.lastFps = frameFps;
|
||||
rdStreamingState.set(clientId, state);
|
||||
const now = Date.now();
|
||||
const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0;
|
||||
if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) {
|
||||
rdDebugFrameLogAt.set(clientId, now);
|
||||
logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`);
|
||||
}
|
||||
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||
}
|
||||
|
||||
@@ -661,7 +673,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
|
||||
}
|
||||
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||
const buf = buildViewerFrameBuffer(bytes, header);
|
||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||
const sessions = sessionManager.getRdSessionsForClient(clientId);
|
||||
const sent = broadcastFrameToViewers(sessions, buf, header);
|
||||
return sent;
|
||||
}
|
||||
|
||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
|
||||
Reference in New Issue
Block a user