optional purgatory, fix mem usage screenshot wall
This commit is contained in:
@@ -25,6 +25,12 @@ const banModalBody = document.getElementById("ban-modal-body");
|
|||||||
const banModalConfirm = document.getElementById("ban-modal-confirm");
|
const banModalConfirm = document.getElementById("ban-modal-confirm");
|
||||||
const banModalCancel = document.getElementById("ban-modal-cancel");
|
const banModalCancel = document.getElementById("ban-modal-cancel");
|
||||||
const banModalBackdrop = document.getElementById("ban-modal-backdrop");
|
const banModalBackdrop = document.getElementById("ban-modal-backdrop");
|
||||||
|
const autoAcceptToggle = document.getElementById("auto-accept-toggle");
|
||||||
|
const autoAcceptModal = document.getElementById("auto-accept-modal");
|
||||||
|
const autoAcceptModalConfirm = document.getElementById("auto-accept-modal-confirm");
|
||||||
|
const autoAcceptModalCancel = document.getElementById("auto-accept-modal-cancel");
|
||||||
|
const autoAcceptModalBackdrop = document.getElementById("auto-accept-modal-backdrop");
|
||||||
|
const approveAllBtn = document.getElementById("approve-all-btn");
|
||||||
|
|
||||||
let currentFilter = "pending";
|
let currentFilter = "pending";
|
||||||
let searchQuery = "";
|
let searchQuery = "";
|
||||||
@@ -38,6 +44,13 @@ async function api(path, opts = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const s = await api("/api/enrollment/settings");
|
||||||
|
autoAcceptToggle.checked = !s.requireApproval;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const s = await api("/api/enrollment/stats");
|
const s = await api("/api/enrollment/stats");
|
||||||
@@ -90,6 +103,9 @@ async function loadClients() {
|
|||||||
|
|
||||||
loadingRow.classList.add("hidden");
|
loadingRow.classList.add("hidden");
|
||||||
|
|
||||||
|
const hasPending = currentFilter === "pending" && filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
|
||||||
|
approveAllBtn.classList.toggle("hidden", !hasPending);
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
emptyEl.classList.remove("hidden");
|
emptyEl.classList.remove("hidden");
|
||||||
return;
|
return;
|
||||||
@@ -251,6 +267,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
|
|||||||
if (clientsTable) clientsTable.classList.add("hidden");
|
if (clientsTable) clientsTable.classList.add("hidden");
|
||||||
emptyEl.classList.add("hidden");
|
emptyEl.classList.add("hidden");
|
||||||
bannedIpsSection.classList.remove("hidden");
|
bannedIpsSection.classList.remove("hidden");
|
||||||
|
approveAllBtn.classList.add("hidden");
|
||||||
loadBannedIps();
|
loadBannedIps();
|
||||||
} else {
|
} else {
|
||||||
bannedIpsSection.classList.add("hidden");
|
bannedIpsSection.classList.add("hidden");
|
||||||
@@ -446,7 +463,70 @@ confirmBanBtn.addEventListener("click", async () => {
|
|||||||
await Promise.all([loadBannedIps(), loadStats()]);
|
await Promise.all([loadBannedIps(), loadStats()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Always Allow toggle ────────────────────────────────────────────
|
||||||
|
autoAcceptToggle.addEventListener("change", async () => {
|
||||||
|
const wantsAlwaysAllow = autoAcceptToggle.checked;
|
||||||
|
if (wantsAlwaysAllow) {
|
||||||
|
autoAcceptToggle.checked = false; // revert until confirmed
|
||||||
|
autoAcceptModal.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await api("/api/enrollment/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ requireApproval: true }),
|
||||||
|
});
|
||||||
|
if (window.showToast) window.showToast("Approval required — purgatory is active", "success");
|
||||||
|
} catch (e) {
|
||||||
|
autoAcceptToggle.checked = true; // revert on error
|
||||||
|
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeAutoAcceptModal() {
|
||||||
|
autoAcceptModal.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
autoAcceptModalCancel.addEventListener("click", closeAutoAcceptModal);
|
||||||
|
autoAcceptModalBackdrop.addEventListener("click", closeAutoAcceptModal);
|
||||||
|
|
||||||
|
autoAcceptModalConfirm.addEventListener("click", async () => {
|
||||||
|
closeAutoAcceptModal();
|
||||||
|
try {
|
||||||
|
await api("/api/enrollment/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ requireApproval: false }),
|
||||||
|
});
|
||||||
|
autoAcceptToggle.checked = true;
|
||||||
|
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 ────────────────────────────────────────────
|
||||||
|
approveAllBtn.addEventListener("click", async () => {
|
||||||
|
const pendingIds = clients
|
||||||
|
.filter((c) => (c.enrollmentStatus || "pending") === "pending")
|
||||||
|
.map((c) => c.id);
|
||||||
|
if (pendingIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
await api("/api/enrollment/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: pendingIds, action: "approve" }),
|
||||||
|
});
|
||||||
|
if (window.showToast) window.showToast(`Approved ${pendingIds.length} client${pendingIds.length !== 1 ? "s" : ""}`, "success");
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||||
|
}
|
||||||
|
await Promise.all([loadClients(), loadStats()]);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────
|
||||||
|
loadSettings();
|
||||||
loadStats();
|
loadStats();
|
||||||
loadClients();
|
loadClients();
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,22 @@
|
|||||||
Approve or deny clients before they can connect to the server.
|
Approve or deny clients before they can connect to the server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-3 flex-wrap justify-end">
|
||||||
<div id="stats-bar" class="flex items-center gap-3 text-sm">
|
<div id="stats-bar" class="flex items-center gap-3 text-sm">
|
||||||
<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-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-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-red-400"><i class="fa-solid fa-ban mr-1"></i><span id="stat-denied">0</span> denied</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>
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,6 +69,10 @@
|
|||||||
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
|
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-1"></div>
|
<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
|
||||||
|
</button>
|
||||||
<div class="relative">
|
<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>
|
<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>
|
||||||
<input type="text" id="search-input" placeholder="Search host, user, IP..."
|
<input type="text" id="search-input" placeholder="Search host, user, IP..."
|
||||||
@@ -199,6 +212,39 @@
|
|||||||
</div>
|
</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>
|
||||||
|
<div class="relative bg-slate-900 border border-amber-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-amber-500/20 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-amber-400 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-slate-100">Disable Approval Requirement?</h3>
|
||||||
|
<p class="text-sm text-amber-400">This disables all enrollment security.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 text-sm text-slate-300 space-y-2">
|
||||||
|
<p>With <strong class="text-amber-300">Always Allow</strong> enabled, <strong>any agent that connects will be automatically approved</strong> without manual review.</p>
|
||||||
|
<p class="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 text-amber-200">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||||
|
This effectively disables all enrollment security. Any machine running your agent binary will gain immediate access to the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button id="auto-accept-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="auto-accept-modal-confirm"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-amber-600 hover:bg-amber-700 text-white transition-colors">
|
||||||
|
<i class="fa-solid fa-door-open mr-1"></i>Enable Always Allow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/assets/nav.js"></script>
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
<script type="module" src="/assets/purgatory-ui.js"></script>
|
<script type="module" src="/assets/purgatory-ui.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig, updateEnrollmentConfig } from "../../config";
|
||||||
import {
|
import {
|
||||||
getPendingClients,
|
getPendingClients,
|
||||||
getEnrollmentStats,
|
getEnrollmentStats,
|
||||||
@@ -114,6 +114,33 @@ export async function handleEnrollmentRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/enrollment/settings
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/enrollment/settings") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||||
|
|
||||||
|
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 updated = await updateEnrollmentConfig({ requireApproval: body.requireApproval });
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: "server",
|
||||||
|
action: AuditAction.ENROLLMENT_SETTINGS,
|
||||||
|
success: true,
|
||||||
|
details: JSON.stringify({ requireApproval: body.requireApproval }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true, requireApproval: updated.requireApproval });
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/enrollment/:id/approve
|
// POST /api/enrollment/:id/approve
|
||||||
const approveMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/approve$/);
|
const approveMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/approve$/);
|
||||||
if (req.method === "POST" && approveMatch) {
|
if (req.method === "POST" && approveMatch) {
|
||||||
|
|||||||
@@ -14,17 +14,41 @@ async function getSharp(): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920));
|
const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 400));
|
||||||
const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080));
|
const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 225));
|
||||||
const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88)));
|
const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88)));
|
||||||
const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
|
const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
|
||||||
256 * 1024,
|
256 * 1024,
|
||||||
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
|
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 8 * 1024 * 1024),
|
||||||
);
|
);
|
||||||
const THUMBNAIL_CACHE_MAX = Math.max(
|
const THUMBNAIL_CACHE_MAX = Math.max(
|
||||||
64,
|
64,
|
||||||
Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
|
Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
|
||||||
);
|
);
|
||||||
|
const MAX_CONCURRENT_THUMBNAIL_GEN = Math.max(
|
||||||
|
1,
|
||||||
|
Number(process.env.OVERLORD_THUMBNAIL_CONCURRENCY || 4),
|
||||||
|
);
|
||||||
|
let _activeThumbnailGen = 0;
|
||||||
|
const _thumbnailGenQueue: Array<() => void> = [];
|
||||||
|
|
||||||
|
function withThumbnailSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const run = () => {
|
||||||
|
_activeThumbnailGen++;
|
||||||
|
fn().then(resolve, reject).finally(() => {
|
||||||
|
_activeThumbnailGen--;
|
||||||
|
const next = _thumbnailGenQueue.shift();
|
||||||
|
if (next) next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (_activeThumbnailGen < MAX_CONCURRENT_THUMBNAIL_GEN) {
|
||||||
|
run();
|
||||||
|
} else {
|
||||||
|
_thumbnailGenQueue.push(run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type ThumbnailRecord = {
|
type ThumbnailRecord = {
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
@@ -157,12 +181,14 @@ export async function requestThumbnailRegen(id: string): Promise<boolean> {
|
|||||||
state.inFlight = true;
|
state.inFlight = true;
|
||||||
let didGenerate = false;
|
let didGenerate = false;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
await withThumbnailSlot(async () => {
|
||||||
state.pending = false;
|
while (true) {
|
||||||
const ok = await generateThumbnail(id);
|
state!.pending = false;
|
||||||
if (ok) didGenerate = true;
|
const ok = await generateThumbnail(id);
|
||||||
if (!state.pending) break;
|
if (ok) didGenerate = true;
|
||||||
}
|
if (!state!.pending) break;
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
state.inFlight = false;
|
state.inFlight = false;
|
||||||
if (thumbnailGenState.get(id) === state && !state.pending) {
|
if (thumbnailGenState.get(id) === state && !state.pending) {
|
||||||
|
|||||||
Reference in New Issue
Block a user