Merge branch 'main' into dev

This commit is contained in:
KingKDot
2026-06-04 21:23:42 -05:00
41 changed files with 3961 additions and 387 deletions
@@ -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();
});
});
+213
View File
@@ -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");
});
});
+24 -3
View 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'");
});
+1 -1
View File
@@ -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 => {