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
+104 -2
View File
@@ -1,5 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test";
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
import { db } from "./db/connection";
import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
const createdClientIds: string[] = [];
@@ -110,4 +111,105 @@ describe("client list ordering", () => {
cleanupCreatedClients();
}
});
});
test("search tolerates typos across client metadata", () => {
try {
const prefix = `fuse-${Date.now().toString(36)}`;
const id = `${prefix}-finance-terminal`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "finance-terminal",
});
setClientTag(id, "Payroll Workstation", "Quarterly reporting machine");
const result = listClients({
page: 1,
pageSize: 12,
search: "payrol workstaton",
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.some((item) => item.id === id)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
});
describe("client search index", () => {
function getSearchIndexRow(id: string) {
return db
.query<{ id: string; host: string | null; customTag: string | null; customTagNote: string | null }>(
`SELECT id, host, custom_tag as customTag, custom_tag_note as customTagNote
FROM client_search_fts
WHERE id = ?`,
)
.get(id);
}
test("keeps the FTS search index in sync when client metadata changes", () => {
try {
const id = `fts-sync-${Date.now().toString(36)}`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "dispatch-terminal-old",
});
expect(getSearchIndexRow(id)?.host).toBe("dispatch-terminal-old");
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "dispatch-terminal-new",
});
setClientTag(id, "Priority Ops", "Shift handoff workstation");
const updatedRow = getSearchIndexRow(id);
expect(updatedRow?.host).toBe("dispatch-terminal-new");
expect(updatedRow?.customTag).toBe("Priority Ops");
expect(updatedRow?.customTagNote).toBe("Shift handoff workstation");
deleteClientRow(id);
expect(getSearchIndexRow(id)).toBeNull();
} finally {
cleanupCreatedClients();
}
});
test("uses indexed candidates before fuzzy matching so exact token searches stay fast", () => {
try {
const prefix = `fts-candidate-${Date.now().toString(36)}`;
const id = `${prefix}-ops-node`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "northbridge-ops-node",
});
setClientTag(id, "Incident Desk", "CPU spike triage host");
const result = listClients({
page: 1,
pageSize: 12,
search: "northbridge",
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.some((item) => item.id === id)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
});
+262 -85
View File
@@ -1,4 +1,5 @@
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
import Fuse from "fuse.js";
import { getThumbnailSummaries } from "../thumbnails";
import { db, dbPath } from "./connection";
import "./schema";
@@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags(
return flags;
}
const CLIENT_SEARCH_KEYS = [
{ name: "nickname", weight: 0.32 },
{ name: "host", weight: 0.28 },
{ name: "user", weight: 0.18 },
{ name: "id", weight: 0.18 },
{ name: "customTag", weight: 0.2 },
{ name: "customTagNote", weight: 0.1 },
{ name: "groupName", weight: 0.14 },
{ name: "os", weight: 0.12 },
{ name: "ip", weight: 0.1 },
{ name: "hwid", weight: 0.08 },
{ name: "country", weight: 0.06 },
{ name: "version", weight: 0.06 },
{ name: "buildTag", weight: 0.06 },
{ name: "cpu", weight: 0.05 },
{ name: "gpu", weight: 0.05 },
{ name: "ram", weight: 0.04 },
] as const;
const CLIENT_SEARCH_SQL_COLUMNS = [
"COALESCE(c.nickname,'')",
"COALESCE(c.host,'')",
"COALESCE(c.user,'')",
"c.id",
"COALESCE(c.custom_tag,'')",
"COALESCE(c.custom_tag_note,'')",
"COALESCE(g.name,'')",
"COALESCE(c.os,'')",
"COALESCE(c.ip,'')",
"COALESCE(c.hwid,'')",
"COALESCE(c.country,'')",
"COALESCE(c.version,'')",
"COALESCE(c.build_tag,'')",
"COALESCE(c.cpu,'')",
"COALESCE(c.gpu,'')",
"COALESCE(c.ram,'')",
] as const;
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } {
const query = search.trim().toLowerCase();
if (!query) return { sql: "", params: [] };
const terms = Array.from(new Set([
query,
...query.split(/[\s,;:|/\\]+/),
].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8);
if (!terms.length) return { sql: "", params: [] };
const fieldClause = CLIENT_SEARCH_SQL_COLUMNS
.map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`)
.join(" OR ");
const sql = terms.map(() => `(${fieldClause})`).join(" OR ");
const params = terms.flatMap((term) => {
const like = `%${escapeLikeTerm(term)}%`;
return CLIENT_SEARCH_SQL_COLUMNS.map(() => like);
});
return { sql: `(${sql})`, params };
}
function buildClientSearchFtsQuery(search: string): string {
const terms = Array.from(new Set(
search
.trim()
.split(/[\s,;:|/\\]+/)
.map((term) => term.replace(/["*]/g, "").trim())
.filter((term) => term.length >= 2),
)).slice(0, 8);
return terms.map((term) => `"${term}"*`).join(" OR ");
}
function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null {
const ftsQuery = buildClientSearchFtsQuery(search);
if (!ftsQuery) return [];
try {
return db
.query<{ id: string }>(
`SELECT id
FROM client_search_fts
WHERE client_search_fts MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(ftsQuery, limit)
.map((row) => row.id)
.filter(Boolean);
} catch {
return null;
}
}
function fuzzySearchClientRows(rows: any[], search: string): any[] {
const query = search.trim();
if (!query) return rows;
const fuse = new Fuse(rows, {
keys: CLIENT_SEARCH_KEYS as any,
threshold: 0.36,
distance: 120,
ignoreLocation: true,
shouldSort: true,
});
return fuse.search(query).map((result) => result.item);
}
export function listClients(filters: ListFilters): ListResult {
const {
page,
@@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult {
const where: string[] = [];
const params: any[] = [];
if (search) {
where.push(
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
);
const needle = `%${search}%`;
params.push(needle, needle, needle, needle, needle, needle, needle);
}
if (statusFilter === "online") {
where.push("c.online=1");
} else if (statusFilter === "offline") {
@@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult {
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const needsGroupJoinForFilter =
!!search ||
sort === "group_asc" ||
sort === "group_desc" ||
(groupFilter !== undefined && groupFilter !== "all");
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
const orderBy = (() => {
const online = "c.online DESC";
@@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult {
}
})();
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const clientFields =
"c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
const rows = needsGroupJoinForFilter
? db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset)
: db
.query<any>(
`WITH page_clients AS (
SELECT ${clientFields}
let rows: any[] | undefined;
let totalCount = 0;
let onlineCount = 0;
if (search) {
const searchWhere = [...where];
const searchParams = [...params];
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit);
if (ftsCandidateIds) {
if (!ftsCandidateIds.length) {
rows = [];
totalCount = 0;
onlineCount = 0;
} else {
searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`);
searchParams.push(...ftsCandidateIds);
}
} else {
const searchCandidate = buildFuzzyCandidateWhere(search);
if (searchCandidate.sql) {
searchWhere.push(searchCandidate.sql);
searchParams.push(...searchCandidate.params);
}
}
if (rows === undefined) {
const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : "";
const candidates = db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${searchWhereSql}
${orderBy}`,
)
.all(...searchParams);
const matches = fuzzySearchClientRows(candidates, search);
totalCount = matches.length;
onlineCount = matches.filter((row: any) => row.online === 1).length;
rows = matches.slice(offset, offset + pageSize);
}
} else {
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
totalCount = totalRow.c;
onlineCount = onlineRow.c;
rows = needsGroupJoinForFilter
? db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?
)
SELECT pc.*, g.name as groupName, g.color as groupColor
FROM page_clients pc
LEFT JOIN client_groups g ON g.id = pc.groupId`,
)
.all(...params, pageSize, offset);
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset)
: db
.query<any>(
`WITH page_clients AS (
SELECT ${clientFields}
FROM clients c
${whereSql}
${orderBy}
LIMIT ? OFFSET ?
)
SELECT pc.*, g.name as groupName, g.color as groupColor
FROM page_clients pc
LEFT JOIN client_groups g ON g.id = pc.groupId`,
)
.all(...params, pageSize, offset);
}
const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS;
const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS);
const suspiciousSets = shouldScanSuspicious
? getFloodSetsWithCache()
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
rows = rows || [];
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
const items = rows.map((c: any) => {
@@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult {
};
});
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
return { page, pageSize, total: totalCount, online: onlineCount, items };
}
export type ClientMetricsSummary = {
@@ -998,7 +1152,56 @@ export type ClientMetricsSummary = {
byCountryOnline: Record<string, number>;
};
const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000;
let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null;
const userClientMetricsSummaryCache = new Map<number, { expiresAt: number; summary: ClientMetricsSummary }>();
function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary {
return {
total: summary.total,
online: summary.online,
byOS: { ...summary.byOS },
byCountry: { ...summary.byCountry },
byOSOnline: { ...summary.byOSOnline },
byCountryOnline: { ...summary.byCountryOnline },
};
}
function makeClientMetricsSummary(
counts: { total: number | null; online: number | null },
osRows: { key: string; total: number; online: number | null }[],
countryRows: { key: string; total: number; online: number | null }[],
): ClientMetricsSummary {
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
}
export function getClientMetricsSummary(): ClientMetricsSummary {
const now = Date.now();
if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) {
return cloneClientMetricsSummary(clientMetricsSummaryCache.summary);
}
const counts = db
.query<{ total: number; online: number }>(
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`,
@@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary {
)
.all();
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
clientMetricsSummaryCache = {
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
summary,
};
return cloneClientMetricsSummary(summary);
}
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
const now = Date.now();
const cached = userClientMetricsSummaryCache.get(userId);
if (cached && cached.expiresAt > now) {
return cloneClientMetricsSummary(cached.summary);
}
const filter = `WHERE built_by_user_id = ?`;
const counts = db
@@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
)
.all(userId);
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
userClientMetricsSummaryCache.set(userId, {
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
summary,
});
return cloneClientMetricsSummary(summary);
}
export function getOnlineClientCountForUser(userId: number): number {
+76
View File
@@ -148,6 +148,82 @@ try {
} catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
try {
db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5(
id UNINDEXED,
host,
user,
nickname,
custom_tag,
custom_tag_note,
os,
ip,
hwid,
country,
version,
build_tag,
cpu,
gpu,
ram,
tokenize = 'unicode61'
);
`);
const counts = db
.query<{ clients: number; indexed: number }>(
`SELECT
(SELECT COUNT(*) FROM clients) as clients,
(SELECT COUNT(*) FROM client_search_fts) as indexed`,
)
.get() ?? { clients: 0, indexed: 0 };
if (counts.clients !== counts.indexed) {
db.run(`DELETE FROM client_search_fts`);
db.run(`
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
SELECT
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
FROM clients
`);
}
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
VALUES (
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
);
END;
`);
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN
DELETE FROM client_search_fts WHERE rowid = old.rowid;
END;
`);
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN
DELETE FROM client_search_fts WHERE rowid = old.rowid;
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
VALUES (
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
);
END;
`);
} catch {}
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
+45
View File
@@ -125,6 +125,31 @@ describe("MetricsCollector", () => {
const snap = metrics.getSnapshot();
expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0);
expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90);
expect(snap.http.latencyP99).toBeGreaterThanOrEqual(99);
});
test("tracks slow HTTP routes", () => {
metrics.recordHttpRequest(15, 200, "GET /api/metrics");
metrics.recordHttpRequest(120, 200, "GET /api/clients/:id/rd/ws");
metrics.recordHttpRequest(200, 500, "GET /api/clients/:id/rd/ws");
const snap = metrics.getSnapshot();
expect(snap.http.routes[0].route).toBe("GET /api/clients/:id/rd/ws");
expect(snap.http.routes[0].countLastMinute).toBe(2);
expect(snap.http.routes[0].errorsLastMinute).toBe(1);
expect(snap.http.routes[0].latencyP95).toBeGreaterThanOrEqual(120);
});
test("does not count the metrics dashboard poll as HTTP pressure", () => {
metrics.recordHttpRequest(100, 200, "GET /api/metrics");
metrics.recordHttpRequest(20, 200, "GET /api/clients");
const snap = metrics.getSnapshot();
expect(snap.http.total).toBe(1);
expect(snap.http.lastMinute).toBe(1);
expect(snap.http.routes.map((route) => route.route)).toEqual([
"GET /api/clients",
]);
});
});
@@ -177,6 +202,26 @@ describe("MetricsCollector", () => {
expect(history[0].timestamp).toBe(snap.timestamp);
});
test("history captures pressure metrics for charting", () => {
metrics.recordHttpRequest(25, 200);
metrics.recordHttpRequest(80, 500);
metrics.recordBytesSent(1024);
metrics.recordBytesReceived(2048);
const snap = metrics.getSnapshot();
metrics.recordHistoryEntry(snap);
const [entry] = metrics.getHistory();
expect(entry.httpRequestsPerMinute).toBeGreaterThanOrEqual(2);
expect(entry.httpErrorsPerMinute).toBeGreaterThanOrEqual(1);
expect(entry.httpLatencyAvg).toBeGreaterThan(0);
expect(entry.httpLatencyP95).toBeGreaterThan(0);
expect(entry.heapUsed).toBeGreaterThan(0);
expect(entry.rss).toBeGreaterThan(0);
expect(entry.systemMemoryUsedPercent).toBeGreaterThanOrEqual(0);
expect(entry.activeSessions).toBe(0);
});
test("history is capped at maxHistoryPoints", () => {
const weekOfFiveSecondSamples = 7 * 24 * 60 * 12;
for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) {
+148 -20
View File
@@ -59,6 +59,8 @@ export interface MetricsSnapshot {
lastMinuteErrors: number;
latencyAvg: number;
latencyP95: number;
latencyP99: number;
routes: HttpRouteStats[];
};
eventLoop: {
avg: number;
@@ -67,12 +69,41 @@ export interface MetricsSnapshot {
};
}
export interface HttpRouteStats {
route: string;
countLastMinute: number;
errorsLastMinute: number;
latencyAvg: number;
latencyP95: number;
latencyP99: number;
latencyMax: number;
lastDuration: number;
lastStatus: number;
}
export interface MetricsHistory {
timestamp: number;
clientsOnline: number;
commandsPerMinute: number;
bandwidthSent: number;
bandwidthReceived: number;
httpRequestsPerMinute?: number;
httpErrorsPerMinute?: number;
httpLatencyAvg?: number;
httpLatencyP95?: number;
httpLatencyP99?: number;
eventLoopAvg?: number;
eventLoopP95?: number;
heapUsed?: number;
rss?: number;
systemMemoryUsedPercent?: number;
activeSessions?: number;
}
interface TimedHttpSample {
ts: number;
duration: number;
statusCode: number;
}
class MetricsCollector {
@@ -103,8 +134,11 @@ class MetricsCollector {
private httpTotal: number = 0;
private httpTimestamps: number[] = [];
private httpErrorTimestamps: number[] = [];
private httpLatencies: number[] = [];
private maxHttpLatencyHistory: number = 1000;
private httpSamples: TimedHttpSample[] = [];
private httpRouteSamples: Map<string, TimedHttpSample[]> = new Map();
private maxHttpLatencyHistory: number = 2000;
private maxHttpRouteSamples: number = 300;
private ignoredHttpMetricRoutes: Set<string> = new Set(["GET /api/metrics"]);
private eventLoopDelays: number[] = [];
private maxEventLoopHistory: number = 300;
@@ -236,7 +270,74 @@ class MetricsCollector {
}, intervalMs);
}
recordHttpRequest(durationMs: number, statusCode: number) {
private percentile(samples: number[], percentile: number): number {
if (samples.length === 0) return 0;
const index = Math.max(0, Math.ceil(samples.length * percentile) - 1);
return samples[index] ?? 0;
}
private average(samples: number[]): number {
return samples.length
? samples.reduce((sum, value) => sum + value, 0) / samples.length
: 0;
}
private pruneHttpSamples(list: TimedHttpSample[], minTs: number): void {
let removeCount = 0;
while (removeCount < list.length && list[removeCount].ts <= minTs) {
removeCount += 1;
}
if (removeCount > 0) {
list.splice(0, removeCount);
}
}
private countHttpErrors(samples: TimedHttpSample[]): number {
let count = 0;
for (const sample of samples) {
if (sample.statusCode >= 400) count += 1;
}
return count;
}
private getTopHttpRoutes(minTs: number): HttpRouteStats[] {
const routes: HttpRouteStats[] = [];
for (const [route, samples] of this.httpRouteSamples.entries()) {
this.pruneHttpSamples(samples, minTs);
if (samples.length === 0) {
this.httpRouteSamples.delete(route);
continue;
}
const durations = samples
.map((sample) => sample.duration)
.sort((a, b) => a - b);
const last = samples[samples.length - 1];
routes.push({
route,
countLastMinute: samples.length,
errorsLastMinute: this.countHttpErrors(samples),
latencyAvg: this.average(durations),
latencyP95: this.percentile(durations, 0.95),
latencyP99: this.percentile(durations, 0.99),
latencyMax: durations[durations.length - 1] ?? 0,
lastDuration: last?.duration ?? 0,
lastStatus: last?.statusCode ?? 0,
});
}
return routes
.sort((a, b) => {
if (b.latencyP95 !== a.latencyP95) return b.latencyP95 - a.latencyP95;
if (b.latencyAvg !== a.latencyAvg) return b.latencyAvg - a.latencyAvg;
return b.countLastMinute - a.countLastMinute;
})
.slice(0, 8);
}
recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") {
if (this.ignoredHttpMetricRoutes.has(route)) return;
this.httpTotal++;
const now = Date.now();
this.httpTimestamps.push(now);
@@ -246,14 +347,25 @@ class MetricsCollector {
this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000);
}
if (Number.isFinite(durationMs)) {
this.httpLatencies.push(durationMs);
if (this.httpLatencies.length > this.maxHttpLatencyHistory) {
this.httpLatencies.shift();
const sample = { ts: now, duration: Math.max(0, durationMs), statusCode };
this.httpSamples.push(sample);
if (this.httpSamples.length > this.maxHttpLatencyHistory) {
this.httpSamples.splice(0, this.httpSamples.length - this.maxHttpLatencyHistory);
}
const routeSamples = this.httpRouteSamples.get(route) || [];
routeSamples.push(sample);
if (routeSamples.length > this.maxHttpRouteSamples) {
routeSamples.splice(0, routeSamples.length - this.maxHttpRouteSamples);
}
this.httpRouteSamples.set(route, routeSamples);
}
}
async withHttpMetrics<T extends Response>(handler: () => Promise<T>): Promise<T> {
async withHttpMetrics<T extends Response>(
handler: () => Promise<T>,
route = "unknown",
): Promise<T> {
const start = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
@@ -262,13 +374,13 @@ class MetricsCollector {
const end = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
this.recordHttpRequest(end - start, response?.status ?? 0);
this.recordHttpRequest(end - start, response?.status ?? 0, route);
return response;
} catch (err) {
const end = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
this.recordHttpRequest(end - start, 500);
this.recordHttpRequest(end - start, 500, route);
throw err;
}
}
@@ -280,6 +392,21 @@ class MetricsCollector {
commandsPerMinute: snapshot.commands.lastMinute,
bandwidthSent: this.sentPerSecond,
bandwidthReceived: this.receivedPerSecond,
httpRequestsPerMinute: snapshot.http.lastMinute,
httpErrorsPerMinute: snapshot.http.lastMinuteErrors,
httpLatencyAvg: snapshot.http.latencyAvg,
httpLatencyP95: snapshot.http.latencyP95,
httpLatencyP99: snapshot.http.latencyP99,
eventLoopAvg: snapshot.eventLoop.avg,
eventLoopP95: snapshot.eventLoop.p95,
heapUsed: snapshot.server.memoryUsage.heapUsed,
rss: snapshot.server.memoryUsage.rss,
systemMemoryUsedPercent: snapshot.server.systemMemory.usedPercent,
activeSessions:
snapshot.sessions.console +
snapshot.sessions.remoteDesktop +
snapshot.sessions.fileBrowser +
snapshot.sessions.process,
};
this.history.push(historyEntry);
@@ -297,6 +424,7 @@ class MetricsCollector {
this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
this.pruneHttpSamples(this.httpSamples, oneMinuteAgo);
const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
const commandsLastHour = this.commandTimestamps.length;
@@ -309,16 +437,13 @@ class MetricsCollector {
const httpLastMinute = this.httpTimestamps.length;
const httpErrorsLastMinute = this.httpErrorTimestamps.length;
const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b);
const httpLatencyAvg = httpLatencySamples.length
? httpLatencySamples.reduce((a, b) => a + b, 0) / httpLatencySamples.length
: 0;
const httpP95Index = httpLatencySamples.length
? Math.max(0, Math.floor(httpLatencySamples.length * 0.95) - 1)
: 0;
const httpLatencyP95 = httpLatencySamples.length
? httpLatencySamples[httpP95Index] ?? 0
: 0;
const httpLatencySamples = this.httpSamples
.map((sample) => sample.duration)
.sort((a, b) => a - b);
const httpLatencyAvg = this.average(httpLatencySamples);
const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95);
const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99);
const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo);
const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
const eventLoopAvg = eventLoopSamples.length
@@ -400,6 +525,8 @@ class MetricsCollector {
lastMinuteErrors: httpErrorsLastMinute,
latencyAvg: httpLatencyAvg,
latencyP95: httpLatencyP95,
latencyP99: httpLatencyP99,
routes: httpRoutes,
},
eventLoop: {
avg: eventLoopAvg,
@@ -428,7 +555,8 @@ class MetricsCollector {
this.httpTotal = 0;
this.httpTimestamps = [];
this.httpErrorTimestamps = [];
this.httpLatencies = [];
this.httpSamples = [];
this.httpRouteSamples.clear();
this.eventLoopDelays = [];
}
}
+60 -3
View File
@@ -84,7 +84,55 @@ const PERMISSIONS = {
roles: ["admin"],
},
"system:configure": {
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
description: "Legacy full server settings access (grants all system:* settings permissions)",
roles: ["admin"],
},
"system:security": {
description: "Change security policy settings",
roles: ["admin"],
},
"system:tls": {
description: "Change TLS and certificate settings",
roles: ["admin"],
},
"system:registration": {
description: "Change user registration policy settings",
roles: ["admin"],
},
"system:notifications": {
description: "Change global notification delivery settings",
roles: ["admin"],
},
"system:chat": {
description: "Change team chat settings",
roles: ["admin"],
},
"system:appearance": {
description: "Change custom CSS and appearance settings",
roles: ["admin"],
},
"system:thumbnails": {
description: "Change screenshot thumbnail settings",
roles: ["admin"],
},
"system:build-limits": {
description: "Change build rate limit settings",
roles: ["admin"],
},
"system:export-import": {
description: "Export and import server settings",
roles: ["admin"],
},
"system:health": {
description: "View server health diagnostics",
roles: ["admin"],
},
"system:health:manage": {
description: "Run server health maintenance actions",
roles: ["admin"],
},
"system:profiler": {
description: "Run server CPU and memory profiler captures",
roles: ["admin"],
},
"clients:elevate": {
@@ -128,8 +176,17 @@ export function hasPermission(
if (grantedByRole) return true;
// Permission groups + per-user extras are *additive only*: they can grant a
// permission the role doesn't have, but they cannot revoke one the role does.
if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
return true;
if (userId !== undefined) {
const granted = getUserGrantedPermissions(userId);
if (granted.has(permission)) return true;
if (
typeof permission === "string" &&
permission.startsWith("system:") &&
permission !== "system:configure" &&
granted.has("system:configure")
) {
return true;
}
}
return false;
}
@@ -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 => {
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "2.3.4";
export const SERVER_VERSION = "2.3.5";