diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js index 85efae0..fe13e43 100644 --- a/Overlord-Server/public/assets/purgatory-ui.js +++ b/Overlord-Server/public/assets/purgatory-ui.js @@ -12,7 +12,7 @@ const searchInput = document.getElementById("search-input"); const bannedIpsSection = document.getElementById("banned-ips-section"); const bannedIpsBody = document.getElementById("banned-ips-body"); const bannedIpsEmpty = document.getElementById("banned-ips-empty"); -const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50"); +const clientsTable = document.getElementById("clients-table-panel"); const addBanBtn = document.getElementById("add-ban-btn"); const manualBanForm = document.getElementById("manual-ban-form"); const banIpInput = document.getElementById("ban-ip-input"); @@ -80,6 +80,7 @@ async function loadStats() { statPending.textContent = s.pending ?? 0; statApproved.textContent = s.approved ?? 0; statDenied.textContent = s.denied ?? 0; + if (statSuspicious) statSuspicious.textContent = s.suspicious ?? 0; // Load banned IPs count try { @@ -105,7 +106,7 @@ async function loadClients() { loadingRow.classList.toggle("hidden", !!hasRows); emptyEl.classList.add("hidden"); - const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter; + const fetchFilter = currentFilter; try { const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`); clients = data.items || []; @@ -116,10 +117,6 @@ async function loadClients() { // Sort by newest first (highest lastSeen = most recent) clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)); - // Update suspicious stat from full loaded set - const suspiciousCount = clients.filter((c) => (c.suspiciousFlags || []).length > 0).length; - if (statSuspicious) statSuspicious.textContent = suspiciousCount; - // Apply suspicious tab filter let base = clients; if (currentFilter === "suspicious") { @@ -177,16 +174,27 @@ async function loadClients() { for (const c of filtered) { const tr = document.createElement("tr"); - tr.className = "hover:bg-slate-800/40 transition-colors"; + tr.className = "group hover:bg-slate-800/45 transition-colors"; tr.dataset.id = c.id; const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason); const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-"; const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-"; + const riskBadges = suspiciousBadges(c.suspiciousFlags); tr.innerHTML = ` - - ${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)} + + +
+ + + + + ${esc(c.host || c.id)} + ${riskBadges ? `${riskBadges}` : ""} + +
+ ${esc(c.user || "-")} ${esc(c.os || "-")} ${expandableCell(c.id, "cpu", c.cpu)} @@ -216,11 +224,11 @@ function tableDigest(items, fields) { function statusBadge(status) { const map = { pending: - 'Pending', + 'Pending', approved: - 'Approved', + 'Approved', denied: - 'Denied', + 'Denied', }; return map[status] || map.pending; } @@ -239,7 +247,7 @@ function suspiciousBadges(flags) { const label = SUSPICIOUS_FLAG_LABELS[f] || f; const isFlood = f.endsWith("_flood"); const color = isFlood ? "bg-red-500/20 text-red-300 border-red-500/40" : "bg-amber-500/20 text-amber-300 border-amber-500/40"; - return `${esc(label.split(" ")[0])}`; + return `${esc(label.split(" ")[0])}`; }).join(" "); } @@ -247,16 +255,16 @@ function actionButtons(c) { const status = c.enrollmentStatus || "pending"; let html = ""; if (status !== "approved") { - html += ``; + html += ``; } if (status !== "denied") { - html += ``; + html += ``; } if (status !== "pending") { - html += ``; + html += ``; } if (c.ip) { - html += ``; + html += ``; } return html; } @@ -363,7 +371,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => { currentFilter = tab.dataset.filter; document.querySelectorAll(".enrollment-tab").forEach((t) => { t.className = - "enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700"; + "enrollment-tab px-4 py-2 rounded-md text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700"; }); const colorMap = { pending: "bg-amber-500/20 text-amber-300 border-amber-500/40", @@ -372,7 +380,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => { "banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40", suspicious: "bg-orange-500/20 text-orange-300 border-orange-500/40", }; - tab.className = `enrollment-tab px-4 py-2 rounded-lg text-sm font-medium ${colorMap[currentFilter] || ""} border`; + tab.className = `enrollment-tab px-4 py-2 rounded-md text-sm font-medium ${colorMap[currentFilter] || ""} border`; if (currentFilter === "banned-ips") { if (clientsTable) clientsTable.classList.add("hidden"); diff --git a/Overlord-Server/public/purgatory.html b/Overlord-Server/public/purgatory.html index eec4570..39280e8 100644 --- a/Overlord-Server/public/purgatory.html +++ b/Overlord-Server/public/purgatory.html @@ -20,26 +20,60 @@
-
+
-
-
-

- Purgatory -

-

- Approve or deny clients before they can connect to the server. -

-
-
-
- 0 pending - 0 approved - 0 denied - 0 suspicious - 0 banned IPs +
+
+
+
+
-
+
+

Purgatory

+

+ Approve or deny clients before they can connect to the server. +

+
+
+
+
+ + + + 0 + + pending + + + + + 0 + + approved + + + + + 0 + + denied + + + + + 0 + + suspicious + + + + + 0 + + banned IPs + +
+
Always Allow @@ -60,28 +94,29 @@
+
-
+
@@ -96,7 +131,7 @@
+ class="w-56 pl-9 pr-3 py-2 rounded-md text-sm bg-slate-950/70 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent" />
-
+
- +
diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts index 2223e28..82cdf90 100644 --- a/Overlord-Server/src/client-order.test.ts +++ b/Overlord-Server/src/client-order.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, test } from "bun:test"; import { db } from "./db/connection"; -import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db"; +import { deleteClientRow, getClientMetricsSummary, getEnrollmentStats, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db"; const createdClientIds: string[] = []; @@ -157,6 +157,68 @@ describe("client list ordering", () => { }); describe("client metrics summary", () => { + test("suspicious enrollment filter and stats scan the full matching set", () => { + try { + const prefix = `suspicious-filter-${Date.now().toString(36)}`; + const suspiciousId = `${prefix}-missing-identity`; + const normalId = `${prefix}-normal`; + const uniqueOs = `${prefix}-os`; + const beforeStats = getEnrollmentStats(); + + upsertClientRow({ + id: suspiciousId, + hwid: suspiciousId, + role: "client", + host: "", + os: uniqueOs, + arch: "amd64", + version: "1.0.0", + user: "", + country: "US", + lastSeen: Date.now(), + online: 0, + enrollmentStatus: "pending", + }); + createdClientIds.push(suspiciousId); + + upsertClientRow({ + id: normalId, + hwid: normalId, + role: "client", + host: "normal-host", + os: uniqueOs, + arch: "amd64", + version: "1.0.0", + user: "tester", + country: "US", + lastSeen: Date.now() - 1, + online: 0, + enrollmentStatus: "pending", + }); + createdClientIds.push(normalId); + + const result = listClients({ + page: 1, + pageSize: 12, + search: "", + sort: "last_seen_desc", + statusFilter: "all", + osFilter: uniqueOs, + countryFilter: "all", + enrollmentFilter: "suspicious", + }); + const afterStats = getEnrollmentStats(); + + expect(result.total).toBe(1); + expect(result.items.map((item) => item.id)).toEqual([suspiciousId]); + expect(result.items[0]?.suspiciousFlags).toContain("no_hostname"); + expect(result.items[0]?.suspiciousFlags).toContain("no_user"); + expect(afterStats.suspicious).toBe(beforeStats.suspicious + 1); + } finally { + cleanupCreatedClients(); + } + }); + test("operating system breakdown excludes purgatory clients", () => { try { const prefix = `metrics-purgatory-${Date.now().toString(36)}`; diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 2300d93..9f9ff3b 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -969,6 +969,7 @@ export function listClients(filters: ListFilters): ListResult { } = filters; const where: string[] = []; const params: any[] = []; + const suspiciousEnrollmentFilter = enrollmentFilter === "suspicious"; if (statusFilter === "online") { where.push("c.online=1"); @@ -976,7 +977,7 @@ export function listClients(filters: ListFilters): ListResult { where.push("c.online=0"); } - if (enrollmentFilter && enrollmentFilter !== "all") { + if (enrollmentFilter && enrollmentFilter !== "all" && !suspiciousEnrollmentFilter) { if (enrollmentFilter === "pending") { where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)"); } else { @@ -1108,7 +1109,22 @@ export function listClients(filters: ListFilters): ListResult { let totalCount = 0; let onlineCount = 0; - if (search) { + if (suspiciousEnrollmentFilter && !search) { + const candidates = db + .query( + `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}`, + ) + .all(...params); + const suspiciousSets = getFloodSetsWithCache(); + const matches = candidates.filter((row: any) => computeClientSuspiciousFlags(row, suspiciousSets).length > 0); + totalCount = matches.length; + onlineCount = matches.filter((row: any) => row.online === 1).length; + rows = matches.slice(offset, offset + pageSize); + } else if (search) { const searchWhere = [...where]; const searchParams = [...params]; const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8)); @@ -2183,13 +2199,14 @@ export function getEnrollmentStats(opts?: { pending: number; approved: number; denied: number; + suspicious: number; } { let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`; const params: any[] = []; const where: string[] = []; if (opts?.allowedClientIds) { - if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 }; + if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0, suspicious: 0 }; const placeholders = opts.allowedClientIds.map(() => "?").join(","); where.push(`id IN (${placeholders})`); params.push(...opts.allowedClientIds); @@ -2214,12 +2231,19 @@ export function getEnrollmentStats(opts?: { sql += ` GROUP BY enrollment_status`; const rows = db.query<{ status: string; c: number }>(sql).all(...params); - const stats = { pending: 0, approved: 0, denied: 0 }; + const stats = { pending: 0, approved: 0, denied: 0, suspicious: 0 }; for (const r of rows) { if (r.status === "approved") stats.approved = Number(r.c); else if (r.status === "denied") stats.denied = Number(r.c); else stats.pending = Number(r.c); } + + const suspiciousSql = + `SELECT id, hwid, cpu, gpu, ram, os, host, user, ip, monitors FROM clients${where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""}`; + const suspiciousSets = getFloodSetsWithCache(); + const suspiciousRows = db.query(suspiciousSql).all(...params); + stats.suspicious = suspiciousRows.filter((row: any) => computeClientSuspiciousFlags(row, suspiciousSets).length > 0).length; + return stats; } diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 717e5a0..dc7991d 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -101,7 +101,9 @@ export async function handleClientRoutes( return new Response("Unauthorized", { status: 401 }); } const page = Math.max(1, Number(url.searchParams.get("page") || 1)); - const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") || 12))); + const isEnrollmentRequest = url.searchParams.has("enrollmentFilter"); + const maxPageSize = isEnrollmentRequest ? 1000 : 200; + const pageSize = Math.min(maxPageSize, Math.max(1, Number(url.searchParams.get("pageSize") || 12))); const search = (url.searchParams.get("q") || "").toLowerCase().trim(); const sort = url.searchParams.get("sort") || "last_seen_desc"; const statusFilter = url.searchParams.get("status") || "all"; @@ -110,7 +112,6 @@ export async function handleClientRoutes( const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved"; const groupFilter = url.searchParams.get("group") || "all"; const webcamFilter = url.searchParams.get("webcam") || "all"; - const isEnrollmentRequest = url.searchParams.has("enrollmentFilter"); if (user.role === "admin") { const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter });