large purgatory improvements
This commit is contained in:
@@ -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 = `
|
||||
<td class="px-4 py-3"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(c.id)}" /></td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.user || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.os || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${expandableCell(c.id, "cpu", c.cpu)}</td>
|
||||
@@ -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 `<div class="space-y-0.5">${pill}<div class="text-xs text-slate-500 italic max-w-[140px] truncate" title="${esc(denyReason)}">${esc(denyReason)}</div></div>`;
|
||||
}
|
||||
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 `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${color} border cursor-help" title="${esc(label)}"><i class="fa-solid fa-triangle-exclamation text-[9px]"></i>${esc(label.split(" ")[0])}</span>`;
|
||||
}).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();
|
||||
|
||||
@@ -36,16 +36,28 @@
|
||||
<span class="text-amber-400"><i class="fa-solid fa-clock mr-1"></i><span id="stat-pending">0</span> pending</span>
|
||||
<span class="text-emerald-400"><i class="fa-solid fa-check mr-1"></i><span id="stat-approved">0</span> approved</span>
|
||||
<span class="text-red-400"><i class="fa-solid fa-ban mr-1"></i><span id="stat-denied">0</span> denied</span>
|
||||
<span class="text-orange-400"><i class="fa-solid fa-triangle-exclamation mr-1"></i><span id="stat-suspicious">0</span> suspicious</span>
|
||||
<span class="text-rose-400"><i class="fa-solid fa-shield-halved mr-1"></i><span id="stat-banned-ips">0</span> banned IPs</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border border-amber-500/30 rounded-lg px-3 py-2 bg-amber-500/5 text-sm" title="When enabled, all connecting agents are approved automatically without review">
|
||||
<i class="fa-solid fa-door-open text-amber-400"></i>
|
||||
<span class="text-slate-300 font-medium">Always Allow</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-1">
|
||||
<input type="checkbox" id="auto-accept-toggle" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-slate-700 peer-checked:bg-amber-500 rounded-full transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 border border-amber-500/30 rounded-lg px-3 py-2 bg-amber-500/5 text-sm">
|
||||
<div class="flex items-center gap-2" title="When enabled, all connecting agents are approved automatically without review">
|
||||
<i class="fa-solid fa-door-open text-amber-400"></i>
|
||||
<span class="text-slate-300 font-medium">Always Allow</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-1">
|
||||
<input type="checkbox" id="auto-accept-toggle" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-slate-700 peer-checked:bg-amber-500 rounded-full transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="unless-suspicious-row" class="hidden flex items-center gap-2 pl-1" title="Still quarantine agents that look suspicious (HWID flood, IP flood, no hostname, etc.)">
|
||||
<i class="fa-solid fa-shield-halved text-slate-500 text-xs"></i>
|
||||
<span class="text-slate-400 text-xs">Unless suspicious</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="unless-suspicious-toggle" class="sr-only peer" />
|
||||
<div class="w-7 h-4 bg-slate-700 peer-checked:bg-emerald-600 rounded-full transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-3"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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">
|
||||
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
|
||||
</button>
|
||||
<button data-filter="suspicious"
|
||||
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">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1"></i>Suspicious
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button id="approve-all-btn"
|
||||
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-emerald-700 hover:bg-emerald-800 text-white transition-colors">
|
||||
<i class="fa-solid fa-check-double mr-1"></i>Approve All Pending
|
||||
<i class="fa-solid fa-check-double mr-1"></i>Approve All
|
||||
</button>
|
||||
<button id="deny-all-btn"
|
||||
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-red-700 hover:bg-red-800 text-white transition-colors">
|
||||
<i class="fa-solid fa-ban mr-1"></i>Deny All
|
||||
</button>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none"></i>
|
||||
@@ -212,6 +232,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deny Reason Modal -->
|
||||
<div id="deny-reason-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" id="deny-reason-modal-backdrop"></div>
|
||||
<div class="relative bg-slate-900 border border-red-500/40 rounded-2xl shadow-2xl max-w-md w-full mx-4 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<i class="fa-solid fa-ban text-red-400 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-100">Deny Client</h3>
|
||||
<p class="text-sm text-slate-400">Optionally record a reason for audit purposes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Reason <span class="text-slate-500 font-normal">(optional)</span></label>
|
||||
<input type="text" id="deny-reason-input" maxlength="200" placeholder="Suspicious activity, unauthorized machine..."
|
||||
class="w-full px-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button id="deny-reason-modal-cancel"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="deny-reason-modal-confirm"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
|
||||
<i class="fa-solid fa-ban mr-1"></i>Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always Allow Warning Modal -->
|
||||
<div id="auto-accept-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" id="auto-accept-modal-backdrop"></div>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+160
-2
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string>();
|
||||
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<string> {
|
||||
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<string> | null = null;
|
||||
let _cachedFloodedHardware: Set<string> | null = null;
|
||||
let _cachedFloodedIps: Set<string> | null = null;
|
||||
|
||||
function getFloodSetsWithCache(): { floodedHwids: Set<string>; floodedHardware: Set<string>; floodedIps: Set<string> } {
|
||||
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<string>;
|
||||
floodedHardware?: Set<string>;
|
||||
floodedIps?: Set<string>;
|
||||
},
|
||||
): SuspiciousFlags {
|
||||
const flags: string[] = [];
|
||||
const allProvided = precomputed?.floodedHwids !== undefined && precomputed?.floodedHardware !== undefined && precomputed?.floodedIps !== undefined;
|
||||
const sets = allProvided ? (precomputed as Required<typeof precomputed>) : 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<any>(
|
||||
`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;
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,6 +73,8 @@ export type ListItem = Omit<ClientInfo, 'ws'> & {
|
||||
online: boolean;
|
||||
hasThumbnail: boolean;
|
||||
thumbnailVersion: number;
|
||||
suspiciousFlags: string[];
|
||||
denyReason?: string | null;
|
||||
};
|
||||
|
||||
export type ListResult = {
|
||||
|
||||
Reference in New Issue
Block a user