This commit is contained in:
KingKDot
2026-04-29 00:22:37 -05:00
parent 4808a0fbd3
commit 6a363a62f2
16 changed files with 1182 additions and 339 deletions
+316
View File
@@ -650,6 +650,285 @@ async function handleUnbanClick(event) {
await loadBannedIps(); await loadBannedIps();
} }
// ---- Build IDs section ----------------------------------------------------
const buildsSection = document.getElementById("section-builds");
const buildsTableBody = document.getElementById("builds-table-body");
const refreshBuildsBtn = document.getElementById("refresh-builds-btn");
const buildsShowAllInput = document.getElementById("builds-show-all");
const buildsShowAllWrap = document.getElementById("builds-show-all-wrap");
const buildsMessageEl = document.getElementById("builds-message");
function showBuildsMessage(text, type = "ok") {
if (!buildsMessageEl) return;
buildsMessageEl.textContent = text;
buildsMessageEl.classList.remove(
"hidden",
"text-emerald-200", "border-emerald-700", "bg-emerald-900/30",
"text-rose-200", "border-rose-700", "bg-rose-900/30",
);
if (type === "error") {
buildsMessageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
} else {
buildsMessageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
}
setTimeout(() => buildsMessageEl.classList.add("hidden"), 4000);
}
function canManageBuilds(role) {
return role === "admin" || role === "operator";
}
async function loadBuilds() {
if (!buildsTableBody) return;
if (!canManageBuilds(currentUser?.role)) return;
const showAll = !!buildsShowAllInput?.checked && isAdmin(currentUser?.role);
const url = showAll ? "/api/build/list?all=true" : "/api/build/list";
buildsTableBody.innerHTML = `
<tr><td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading...</td></tr>
`;
let res;
try {
res = await fetch(url, { credentials: "include" });
} catch {
buildsTableBody.innerHTML = `
<tr><td colspan="6" class="px-3 py-6 text-center text-rose-300">Network error</td></tr>
`;
return;
}
if (!res.ok) {
buildsTableBody.innerHTML = `
<tr><td colspan="6" class="px-3 py-6 text-center text-rose-300">Failed to load builds (HTTP ${res.status})</td></tr>
`;
return;
}
const data = await res.json().catch(() => ({ builds: [] }));
const builds = Array.isArray(data.builds) ? data.builds : [];
if (builds.length === 0) {
buildsTableBody.innerHTML = `
<tr><td colspan="6" class="px-3 py-6 text-center text-slate-400">No builds</td></tr>
`;
return;
}
buildsTableBody.innerHTML = builds.map((b) => {
const tag = b.buildTag ? String(b.buildTag) : "";
const tagShort = tag ? `${tag.substring(0, 8)}...` : "—";
const idShort = `${String(b.id).substring(0, 8)}...`;
const statusBadge = b.blocked
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/40 border border-red-700 text-red-200 text-xs"><i class="fa-solid fa-ban"></i>Blocked</span>`
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/30 border border-emerald-700 text-emerald-200 text-xs"><i class="fa-solid fa-circle-check"></i>Active</span>`;
const actionBtn = b.blocked
? `<button type="button" class="builds-action-btn px-2.5 py-1.5 rounded bg-emerald-700/80 hover:bg-emerald-600 text-white text-xs" data-build-id="${escapeHtml(b.id)}" data-block="false"><i class="fa-solid fa-unlock mr-1"></i>Unblock</button>`
: `<button type="button" class="builds-action-btn px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs" data-build-id="${escapeHtml(b.id)}" data-block="true"><i class="fa-solid fa-ban mr-1"></i>Block</button>`;
const tagCell = tag
? `<span class="font-mono text-xs text-slate-300" title="${escapeHtml(tag)}">${escapeHtml(tagShort)}</span>`
: `<span class="text-slate-500 text-xs">—</span>`;
return `
<tr>
<td class="px-3 py-2 font-mono text-xs text-slate-100" title="${escapeHtml(b.id)}">${escapeHtml(idShort)}</td>
<td class="px-3 py-2">${tagCell}</td>
<td class="px-3 py-2">${statusBadge}</td>
<td class="px-3 py-2 text-slate-400">${formatDate(b.startTime)}</td>
<td class="px-3 py-2 text-right font-mono text-slate-200">${Number(b.claimCount) || 0}</td>
<td class="px-3 py-2 text-right">${actionBtn}</td>
</tr>
`;
}).join("");
}
async function handleBuildBlockClick(event) {
const btn = event.target.closest(".builds-action-btn");
if (!btn) return;
const buildId = btn.dataset.buildId;
const block = btn.dataset.block === "true";
if (!buildId) return;
const verb = block ? "Block" : "Unblock";
if (block) {
if (!confirm(`Block build ${buildId.substring(0, 8)}...?\n\nAny agent currently connected from this build will be disconnected, and future connection attempts with this build's tag will be rejected.`)) return;
}
btn.disabled = true;
let res;
try {
res = await fetch(`/api/build/${encodeURIComponent(buildId)}/block`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocked: block }),
});
} catch {
showBuildsMessage(`${verb} failed: network error`, "error");
btn.disabled = false;
return;
}
const data = await res.json().catch(() => ({}));
if (!res.ok || data.success === false) {
showBuildsMessage(data.error || `${verb} failed`, "error");
btn.disabled = false;
return;
}
if (block && typeof data.disconnected === "number" && data.disconnected > 0) {
showBuildsMessage(`Build blocked. Disconnected ${data.disconnected} live agent${data.disconnected === 1 ? "" : "s"}.`);
} else {
showBuildsMessage(`Build ${block ? "blocked" : "unblocked"}.`);
}
await loadBuilds();
}
// ---- Sidebar nav ---------------------------------------------------------
/**
* Update the --settings-scroll-mt CSS variable to match whatever the top nav
* is currently consuming at the top of the viewport. In topbar mode this is
* the topbar's rendered height + an 8px buffer so the section title isn't
* flush against the nav. In sidebar mode the nav doesn't obstruct the top, so
* we use a small visual margin. Called on init and on viewport changes.
*/
function updateSettingsScrollOffset() {
const root = document.documentElement;
const nav = document.getElementById("top-nav");
// Sidebar mode (left column): no top obstruction. Body gets `sb-ready` from
// the adaptive nav layout.
const isSidebarMode = document.body.classList.contains("sb-ready");
// Nav hidden via Ctrl+\: also no obstruction.
const isHidden = document.body.classList.contains("nav-hidden");
let offsetPx = 16; // matches the old scroll-mt-4 default
if (!isSidebarMode && !isHidden && nav) {
const rect = nav.getBoundingClientRect();
// Only treat as obstructing if the nav is actually pinned at the top and
// spans most of the width. (Defensive — covers mobile/dropdown variations.)
if (rect.top <= 0 && rect.height > 0 && rect.width >= window.innerWidth * 0.5) {
offsetPx = Math.round(rect.height) + 8;
}
}
root.style.setProperty("--settings-scroll-mt", `${offsetPx}px`);
}
function initSettingsSidebar() {
const sidebar = document.getElementById("settings-sidebar");
if (!sidebar) return;
const links = Array.from(sidebar.querySelectorAll(".settings-nav-link"));
// Hide nav links and group labels whose target sections are admin-only /
// admin-or-operator when the current user isn't authorized. We hide rather
// than disable so the sidebar stays uncluttered for non-admins.
const gated = sidebar.querySelectorAll("[data-admin-only], [data-admin-or-operator]");
for (const el of gated) {
if (el.dataset.adminOnly !== undefined && !isAdmin(currentUser?.role)) {
el.classList.add("hidden");
}
if (el.dataset.adminOrOperator !== undefined && !canManageBuilds(currentUser?.role)) {
el.classList.add("hidden");
}
}
// Build the section→link map up front so click + observer share it.
const sectionMap = new Map();
// sectionsInOrder preserves document order for "last visible at page bottom"
// tie-breaking (see updateActive below).
const sectionsInOrder = [];
for (const link of links) {
const id = link.dataset.target;
const section = id ? document.getElementById(id) : null;
if (section) {
sectionMap.set(section, link);
sectionsInOrder.push(section);
}
}
if (sectionMap.size === 0) return;
function setActive(link) {
links.forEach((l) => l.classList.remove("active"));
if (link) link.classList.add("active");
}
function isAtPageBottom() {
return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4;
}
// Click → smooth scroll to section. Mark the link active immediately so the
// bottom links (where scroll can't reach the section's top) still highlight.
sidebar.addEventListener("click", (event) => {
const link = event.target.closest(".settings-nav-link");
if (!link) return;
event.preventDefault();
const id = link.dataset.target;
if (!id) return;
const target = document.getElementById(id);
if (!target) return;
// Re-measure the topbar offset right before scrolling, so a layout shift
// (nav-hidden toggle, viewport resize, dropdown opened) doesn't leave the
// section partly hidden under the nav.
updateSettingsScrollOffset();
setActive(link);
target.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
});
// Active-section highlight via IntersectionObserver + page-bottom override.
const visible = new Set();
function updateActive() {
// Special case: if the user has scrolled as far as the page allows, the
// last visible section becomes active (otherwise the topmost-visible
// heuristic would leave shorter trailing sections — like Custom CSS or
// Export / Import — unable to ever become active because the page can't
// scroll their headings to the top of the viewport).
if (isAtPageBottom() && visible.size > 0) {
let lastEl = null;
for (const section of sectionsInOrder) {
if (visible.has(section)) lastEl = section;
}
if (lastEl) {
setActive(sectionMap.get(lastEl));
return;
}
}
// Default: topmost visible section.
let topEl = null;
let topY = Infinity;
for (const el of visible) {
const y = el.getBoundingClientRect().top;
if (y < topY) { topY = y; topEl = el; }
}
if (topEl) setActive(sectionMap.get(topEl));
}
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visible.add(e.target);
else visible.delete(e.target);
}
updateActive();
},
{ rootMargin: "-10% 0px -70% 0px", threshold: 0 },
);
for (const section of sectionMap.keys()) observer.observe(section);
// Re-run the picker on raw scroll too, so the page-bottom override fires
// even when no IntersectionObserver entry has changed (last sections may
// already be inside `visible` and the rootMargin won't re-trigger).
let scrollRaf = 0;
window.addEventListener("scroll", () => {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
updateActive();
});
}, { passive: true });
}
async function loadAppearanceSettings() { async function loadAppearanceSettings() {
if (!currentUser) return; if (!currentUser) return;
@@ -1340,6 +1619,15 @@ async function init() {
wipeOfflineSection.classList.remove("hidden"); wipeOfflineSection.classList.remove("hidden");
} }
if (canManageBuilds(currentUser?.role)) {
if (buildsSection) buildsSection.classList.remove("hidden");
// Hide the "show all" toggle for non-admins
if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
buildsShowAllWrap.classList.add("hidden");
}
await loadBuilds();
}
await loadSecurityPolicy(); await loadSecurityPolicy();
await loadTlsSettings(); await loadTlsSettings();
await loadAppearanceSettings(); await loadAppearanceSettings();
@@ -1387,6 +1675,34 @@ async function init() {
bansTableBody.addEventListener("click", handleUnbanClick); bansTableBody.addEventListener("click", handleUnbanClick);
if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients); if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients);
if (refreshBuildsBtn) refreshBuildsBtn.addEventListener("click", loadBuilds);
if (buildsTableBody) buildsTableBody.addEventListener("click", handleBuildBlockClick);
if (buildsShowAllInput) buildsShowAllInput.addEventListener("change", loadBuilds);
initSettingsSidebar();
// Compute the scroll offset now, then again after the nav has had a moment
// to settle (the adaptive nav controller adjusts mode async based on
// viewport width and content overflow), and on every resize.
updateSettingsScrollOffset();
setTimeout(updateSettingsScrollOffset, 0);
setTimeout(updateSettingsScrollOffset, 200);
let resizeTimer = 0;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateSettingsScrollOffset, 100);
});
// Honor a ?#section-id deep-link on first load so we land on that section.
// Run AFTER the offset is set so the section lands under the topbar.
if (window.location.hash) {
const target = document.getElementById(window.location.hash.slice(1));
if (target) {
// Defer to the next frame so scroll-margin-top has been applied.
requestAnimationFrame(() => target.scrollIntoView({ behavior: "auto", block: "start" }));
}
}
await loadSessions(); await loadSessions();
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions); if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions); if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions);
+393 -251
View File
@@ -9,236 +9,164 @@
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" /> <link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/main.css" /> <link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" /> <link rel="stylesheet" href="/assets/custom.css" />
<style>
.settings-nav-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: rgb(203 213 225); /* slate-300 */
cursor: pointer;
transition: background-color 120ms, color 120ms;
}
.settings-nav-link:hover {
background-color: rgb(30 41 59 / 0.7); /* slate-800/70 */
color: rgb(241 245 249); /* slate-100 */
}
/* Active state pulls its color from --accent (set inline per link to
match the link's icon). Falls back to sky-400 if not set. */
.settings-nav-link.active {
background-color: rgb(var(--accent, 56 189 248) / 0.18);
color: rgb(241 245 249);
box-shadow:
inset 2px 0 0 0 rgb(var(--accent, 56 189 248)),
0 0 14px -2px rgb(var(--accent, 56 189 248) / 0.45);
}
.settings-nav-group-label {
display: block;
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgb(100 116 139); /* slate-500 */
}
/* Scroll offset for #section anchors. In topbar nav mode this is set to
the topbar's actual rendered height + a small buffer at runtime; in
sidebar mode it's just a small visual margin. The settings-section
class applies scroll-margin-top, which both `scrollIntoView` and
browser hash navigation honor. */
:root { --settings-scroll-mt: 1rem; }
.settings-section { scroll-margin-top: var(--settings-scroll-mt); }
/* Also pin the sticky sidebar below the topbar so it doesn't slide
under the nav when the page is scrolled. */
#settings-sidebar { top: var(--settings-scroll-mt); }
</style>
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-100"> <body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header> <header id="top-nav"></header>
<main class="max-w-6xl w-full mx-auto px-4 py-8 space-y-6"> <main class="max-w-7xl w-full mx-auto px-4 py-8">
<div> <div class="mb-6">
<h1 class="text-2xl font-bold text-slate-100">Settings</h1> <h1 class="text-2xl font-bold text-slate-100">Settings</h1>
<p class="text-slate-400 text-sm"> <p class="text-slate-400 text-sm">
Manage your account preferences and review banned IP addresses. Manage your account preferences, operational tools, and admin configuration.
</p> </p>
</div> </div>
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <div class="flex flex-col lg:flex-row gap-6">
<!-- Sidebar nav -->
<aside class="lg:w-56 lg:flex-shrink-0">
<nav id="settings-sidebar"
class="lg:sticky bg-slate-900/60 border border-slate-800 rounded-xl p-2 space-y-0.5 text-sm">
<span class="settings-nav-group-label">Personal</span>
<a data-target="section-account" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-solid fa-user w-4 text-sky-400"></i><span>Account</span></a>
<a data-target="section-sessions" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-desktop w-4 text-emerald-400"></i><span>Active Sessions</span></a>
<a data-target="section-prefs" class="settings-nav-link" style="--accent: 167 139 250"><i class="fa-solid fa-sliders w-4 text-violet-400"></i><span>Dashboard Preferences</span></a>
<a data-target="telegram-settings" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-bell w-4 text-emerald-400"></i><span>Notification Delivery</span></a>
<a data-target="section-news" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-brands fa-telegram w-4 text-sky-400"></i><span>News &amp; Updates</span></a>
<span class="settings-nav-group-label" data-admin-or-operator>Operations</span>
<a data-target="section-bans" class="settings-nav-link" style="--accent: 248 113 113"><i class="fa-solid fa-ban w-4 text-red-400"></i><span>Banned IPs</span></a>
<a data-target="section-builds" class="settings-nav-link" data-admin-or-operator style="--accent: 251 146 60"><i class="fa-solid fa-hammer w-4 text-orange-400"></i><span>Build IDs</span></a>
<a data-target="wipe-offline-section" class="settings-nav-link" data-admin-or-operator style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
<span class="settings-nav-group-label" data-admin-only>Admin</span>
<a data-target="section-security" class="settings-nav-link" data-admin-only style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
<a data-target="section-tls" class="settings-nav-link" data-admin-only style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
<a data-target="chat-settings-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
<a data-target="registration-section" class="settings-nav-link" data-admin-only style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
<a data-target="build-rate-limit-section" class="settings-nav-link" data-admin-only style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-admin-only style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
<a data-target="export-import-section" class="settings-nav-link" data-admin-only style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
</nav>
</aside>
<!-- Sections -->
<div class="flex-1 min-w-0 space-y-6">
<!-- ========== PERSONAL ========== -->
<!-- Account -->
<section id="section-account" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-brands fa-telegram text-sky-400"></i> <i class="fa-solid fa-user text-sky-400"></i>
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold">News &amp; Updates</h2> <h2 class="text-lg font-semibold">Account</h2>
<p class="text-sm text-slate-400">Stay up to date with the newest features, releases, and announcements.</p> <p class="text-sm text-slate-400">View your account info and update your password.</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1">
<p class="text-sm text-slate-300">
Follow the official Overlord Telegram channel for the latest updates, changelogs, and feature announcements.
</p>
<p class="text-xs text-slate-500 mt-1">Channel: <span class="text-slate-400 font-mono">@kunta</span></p>
</div>
<a
href="https://t.me/kunta"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-brands fa-telegram"></i>
Open on Telegram
</a>
</div>
</section>
<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
<i class="fa-solid fa-file-export text-amber-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Export &amp; Import Settings</h2>
<p class="text-sm text-slate-400">Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).</p>
</div> </div>
</div> </div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div class="flex-1 space-y-1"> <div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<p class="text-sm text-slate-300"> <div class="text-slate-400">Username</div>
<strong>Export</strong> &mdash; Downloads a JSON file with all current settings and auth tokens. <div id="settings-username" class="font-medium">-</div>
</p> </div>
<p class="text-xs text-slate-500">Keep this file secure &mdash; it contains sensitive tokens.</p> <div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<div class="text-slate-400">Role</div>
<div id="settings-role" class="font-medium">-</div>
</div>
</div>
<form id="password-form" class="space-y-3 mt-2 pt-4 border-t border-slate-800">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 pb-1">Change Password</p>
<input
id="current-password"
type="password"
autocomplete="current-password"
placeholder="Current password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="new-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="New password (min 6 chars)"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="confirm-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="Confirm new password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<div class="pt-1">
<p id="password-policy-hint" class="text-xs text-slate-400 mb-3"></p>
</div> </div>
<button <button
id="export-settings-btn" type="submit"
type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 transition-colors text-white text-sm font-medium"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
> >
<i class="fa-solid fa-download"></i> <i class="fa-solid fa-key"></i>Update Password
Export Settings
</button> </button>
</div> </form>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4"> <p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Import</strong> &mdash; Restore settings from a previously exported JSON file.
</p>
<p class="text-xs text-slate-500">Auth token changes require a server restart. Environment variables will still take priority.</p>
</div>
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium whitespace-nowrap cursor-pointer"
>
<i class="fa-solid fa-upload"></i>
Import Settings
<input id="import-settings-file" type="file" accept=".json,application/json" class="hidden" />
</label>
</div>
<p id="export-import-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<section id="wipe-offline-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
<i class="fa-solid fa-circle-minus text-red-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Wipe Offline Clients</h2>
<p class="text-sm text-slate-400">Permanently remove all offline clients from the dashboard in one click.</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1">
<p class="text-sm text-slate-300">
Deletes every client currently marked as offline. If any of them reconnect later they will reappear automatically.
</p>
</div>
<button
id="wipe-offline-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-800/70 hover:bg-red-700 transition-colors text-red-100 border border-red-700 text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-circle-minus"></i>
Wipe Offline
</button>
</div>
<p id="wipe-offline-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<!-- Account + Banned IPs -->
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- Account -->
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-user text-sky-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Account</h2>
<p class="text-sm text-slate-400">View your account info and update your password.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<div class="text-slate-400">Username</div>
<div id="settings-username" class="font-medium">-</div>
</div>
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<div class="text-slate-400">Role</div>
<div id="settings-role" class="font-medium">-</div>
</div>
</div>
<form id="password-form" class="space-y-3 mt-2 pt-4 border-t border-slate-800">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 pb-1">Change Password</p>
<input
id="current-password"
type="password"
autocomplete="current-password"
placeholder="Current password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="new-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="New password (min 6 chars)"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="confirm-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="Confirm new password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<div class="pt-1">
<p id="password-policy-hint" class="text-xs text-slate-400 mb-3"></p>
</div>
<button
type="submit"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-key"></i>Update Password
</button>
</form>
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</div>
<!-- Banned IPs -->
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
<i class="fa-solid fa-ban text-red-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Banned IPs</h2>
<p class="text-sm text-slate-400">Unban blocked agent source IPs.</p>
</div>
</div>
<button
id="refresh-bans-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
type="button"
>
<i class="fa-solid fa-rotate"></i>Refresh
</button>
</div>
<div id="bans-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Your role does not allow IP unban actions.
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">IP</th>
<th class="text-left px-3 py-2">Reason</th>
<th class="text-left px-3 py-2">Banned At</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody id="bans-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-400">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section> </section>
<!-- Active Sessions --> <!-- Active Sessions -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="section-sessions" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
@@ -289,7 +217,7 @@
</section> </section>
<!-- Dashboard Preferences --> <!-- Dashboard Preferences -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="section-prefs" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30">
<i class="fa-solid fa-sliders text-violet-400"></i> <i class="fa-solid fa-sliders text-violet-400"></i>
@@ -367,7 +295,7 @@
</section> </section>
<!-- Notification Delivery --> <!-- Notification Delivery -->
<section id="telegram-settings" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="telegram-settings" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-bell text-emerald-400"></i> <i class="fa-solid fa-bell text-emerald-400"></i>
@@ -386,8 +314,169 @@
</div> </div>
</section> </section>
<!-- News & Updates -->
<section id="section-news" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-brands fa-telegram text-sky-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">News &amp; Updates</h2>
<p class="text-sm text-slate-400">Stay up to date with the newest features, releases, and announcements.</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1">
<p class="text-sm text-slate-300">
Follow the official Overlord Telegram channel for the latest updates, changelogs, and feature announcements.
</p>
<p class="text-xs text-slate-500 mt-1">Channel: <span class="text-slate-400 font-mono">@kunta</span></p>
</div>
<a
href="https://t.me/kunta"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-brands fa-telegram"></i>
Open on Telegram
</a>
</div>
</section>
<!-- ========== OPERATIONS ========== -->
<!-- Banned IPs -->
<section id="section-bans" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
<i class="fa-solid fa-ban text-red-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Banned IPs</h2>
<p class="text-sm text-slate-400">Unban blocked agent source IPs.</p>
</div>
</div>
<button
id="refresh-bans-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
type="button"
>
<i class="fa-solid fa-rotate"></i>Refresh
</button>
</div>
<div id="bans-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Your role does not allow IP unban actions.
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">IP</th>
<th class="text-left px-3 py-2">Reason</th>
<th class="text-left px-3 py-2">Banned At</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody id="bans-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-400">Loading...</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Build IDs (operator/admin) -->
<section id="section-builds" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
<i class="fa-solid fa-hammer text-orange-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Build IDs</h2>
<p class="text-sm text-slate-400">Review your builds, see how many agents have claimed each, and block a build to immediately disconnect any agent that connects with its tag.</p>
</div>
</div>
<div class="flex items-center gap-2">
<label class="hidden lg:flex items-center gap-2 text-xs text-slate-400" id="builds-show-all-wrap">
<input id="builds-show-all" type="checkbox" class="h-3.5 w-3.5" />
<span>Show all users' builds</span>
</label>
<button
id="refresh-builds-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
type="button"
>
<i class="fa-solid fa-rotate"></i>Refresh
</button>
</div>
</div>
<p class="text-xs text-slate-500">
<strong>Claim count</strong> is the number of unique agent fingerprints that have ever connected using a build's tag. Each unique host produces one claim. A claim count higher than the number of machines you actually deployed to suggests the build tag may have leaked.
</p>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">Build ID</th>
<th class="text-left px-3 py-2">Tag</th>
<th class="text-left px-3 py-2">Status</th>
<th class="text-left px-3 py-2">Created</th>
<th class="text-right px-3 py-2">Claims</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody id="builds-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading...</td>
</tr>
</tbody>
</table>
</div>
<p id="builds-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<!-- Wipe Offline Clients -->
<section id="wipe-offline-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
<i class="fa-solid fa-circle-minus text-red-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Wipe Offline Clients</h2>
<p class="text-sm text-slate-400">Permanently remove all offline clients from the dashboard in one click.</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1">
<p class="text-sm text-slate-300">
Deletes every client currently marked as offline. If any of them reconnect later they will reappear automatically.
</p>
</div>
<button
id="wipe-offline-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-800/70 hover:bg-red-700 transition-colors text-red-100 border border-red-700 text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-circle-minus"></i>
Wipe Offline
</button>
</div>
<p id="wipe-offline-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<!-- ========== ADMIN ========== -->
<!-- Security Policy --> <!-- Security Policy -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="section-security" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
<i class="fa-solid fa-shield-halved text-indigo-400"></i> <i class="fa-solid fa-shield-halved text-indigo-400"></i>
@@ -489,7 +578,7 @@
</section> </section>
<!-- TLS Certificates --> <!-- TLS Certificates -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="section-tls" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
<i class="fa-solid fa-certificate text-cyan-400"></i> <i class="fa-solid fa-certificate text-cyan-400"></i>
@@ -591,7 +680,7 @@
</section> </section>
<!-- Team Chat --> <!-- Team Chat -->
<section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-comments text-sky-400"></i> <i class="fa-solid fa-comments text-sky-400"></i>
@@ -626,46 +715,8 @@
</form> </form>
</section> </section>
<!-- Custom CSS -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Custom CSS</h2>
<p class="text-sm text-slate-400">Admin-only. Paste custom CSS to override the default theme across all pages.</p>
</div>
</div>
<form id="appearance-form" class="space-y-4">
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update custom CSS.
</div>
<label class="block">
<textarea
id="appearance-custom-css"
rows="12"
spellcheck="false"
placeholder="/* Paste your custom CSS here */&#10;:root { --color-primary: #f59e0b; }"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500 font-mono text-xs resize-y"
></textarea>
<p class="text-xs text-slate-500 mt-1">Max 50 KB. Reload any open page after saving to see the new styles.</p>
</label>
<button
type="submit"
id="appearance-save-btn"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-fuchsia-700 hover:bg-fuchsia-600 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-paintbrush"></i>Save Custom CSS
</button>
</form>
</section>
<!-- Registration Settings (admin only) --> <!-- Registration Settings (admin only) -->
<section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-user-plus text-emerald-400"></i> <i class="fa-solid fa-user-plus text-emerald-400"></i>
@@ -766,7 +817,7 @@
</section> </section>
<!-- Build Rate Limit (admin only) --> <!-- Build Rate Limit (admin only) -->
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
<i class="fa-solid fa-gauge-high text-orange-400"></i> <i class="fa-solid fa-gauge-high text-orange-400"></i>
@@ -799,6 +850,97 @@
<p id="brl-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p> <p id="brl-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section> </section>
<!-- Custom CSS -->
<section id="section-appearance" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Custom CSS</h2>
<p class="text-sm text-slate-400">Admin-only. Paste custom CSS to override the default theme across all pages.</p>
</div>
</div>
<form id="appearance-form" class="space-y-4">
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update custom CSS.
</div>
<label class="block">
<textarea
id="appearance-custom-css"
rows="12"
spellcheck="false"
placeholder="/* Paste your custom CSS here */&#10;:root { --color-primary: #f59e0b; }"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500 font-mono text-xs resize-y"
></textarea>
<p class="text-xs text-slate-500 mt-1">Max 50 KB. Reload any open page after saving to see the new styles.</p>
</label>
<button
type="submit"
id="appearance-save-btn"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-fuchsia-700 hover:bg-fuchsia-600 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-paintbrush"></i>Save Custom CSS
</button>
</form>
</section>
<!-- Export & Import -->
<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
<i class="fa-solid fa-file-export text-amber-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Export &amp; Import Settings</h2>
<p class="text-sm text-slate-400">Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Export</strong> &mdash; Downloads a JSON file with all current settings and auth tokens.
</p>
<p class="text-xs text-slate-500">Keep this file secure &mdash; it contains sensitive tokens.</p>
</div>
<button
id="export-settings-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-download"></i>
Export Settings
</button>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Import</strong> &mdash; Restore settings from a previously exported JSON file.
</p>
<p class="text-xs text-slate-500">Auth token changes require a server restart. Environment variables will still take priority.</p>
</div>
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium whitespace-nowrap cursor-pointer"
>
<i class="fa-solid fa-upload"></i>
Import Settings
<input id="import-settings-file" type="file" accept=".json,application/json" class="hidden" />
</label>
</div>
<p id="export-import-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
</div>
<!-- /Sections -->
</div>
<!-- /flex wrapper -->
<footer class="text-center text-sm text-slate-500 py-4 border-t border-slate-800 mt-4"> <footer class="text-center text-sm text-slate-500 py-4 border-t border-slate-800 mt-4">
<p>Created by KDot &mdash; <a href="https://t.me/kunta" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/kunta</a></p> <p>Created by KDot &mdash; <a href="https://t.me/kunta" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/kunta</a></p>
</footer> </footer>
+89 -31
View File
@@ -191,8 +191,19 @@ db.run(`
try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {} try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {} try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
try { db.run(`ALTER TABLE builds ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`); } catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`); db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
db.run(`
CREATE TABLE IF NOT EXISTS build_claims (
build_id TEXT NOT NULL,
key_fingerprint TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
PRIMARY KEY (build_id, key_fingerprint)
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_build_claims_build ON build_claims(build_id);`);
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS build_profiles ( CREATE TABLE IF NOT EXISTS build_profiles (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -1537,6 +1548,7 @@ export interface BuildRecord {
}>; }>;
buildTag?: string; buildTag?: string;
builtByUserId?: number; builtByUserId?: number;
blocked?: boolean;
} }
export function saveBuild(build: BuildRecord) { export function saveBuild(build: BuildRecord) {
@@ -1553,10 +1565,7 @@ export function saveBuild(build: BuildRecord) {
); );
} }
export function getBuild(id: string): BuildRecord | null { function mapBuildRow(row: any): BuildRecord {
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
if (!row) return null;
return { return {
id: row.id, id: row.id,
status: row.status, status: row.status,
@@ -1565,22 +1574,87 @@ export function getBuild(id: string): BuildRecord | null {
files: JSON.parse(row.files), files: JSON.parse(row.files),
buildTag: row.build_tag || undefined, buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined, builtByUserId: row.built_by_user_id || undefined,
blocked: row.blocked === 1,
}; };
} }
export function getBuild(id: string): BuildRecord | null {
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
if (!row) return null;
return mapBuildRow(row);
}
export function getBuildByTag(buildTag: string): BuildRecord | null { export function getBuildByTag(buildTag: string): BuildRecord | null {
const row = db.query<any>(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag); const row = db.query<any>(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag);
if (!row) return null; if (!row) return null;
return mapBuildRow(row);
}
return { export function isBuildTagBlocked(buildTag: string): boolean {
id: row.id, if (!buildTag) return false;
status: row.status, const row = db.query<{ blocked: number }>(
startTime: row.start_time, `SELECT blocked FROM builds WHERE build_tag = ?`,
expiresAt: row.expires_at, ).get(buildTag);
files: JSON.parse(row.files), return !!row && row.blocked === 1;
buildTag: row.build_tag || undefined, }
builtByUserId: row.built_by_user_id || undefined,
}; export function setBuildBlocked(buildId: string, blocked: boolean): boolean {
const result = db.run(
`UPDATE builds SET blocked = ? WHERE id = ?`,
blocked ? 1 : 0,
buildId,
);
return ((result as any)?.changes || 0) > 0;
}
export function listClientIdsByBuildTag(buildTag: string): string[] {
if (!buildTag) return [];
const rows = db.query<{ id: string }>(
`SELECT id FROM clients WHERE build_tag = ?`,
).all(buildTag);
return rows.map((r) => r.id);
}
export function recordBuildClaim(buildId: string, keyFingerprint: string): boolean {
if (!buildId || !keyFingerprint) return false;
try {
const result = db.run(
`INSERT OR IGNORE INTO build_claims (build_id, key_fingerprint, claimed_at) VALUES (?, ?, ?)`,
buildId,
keyFingerprint,
Date.now(),
);
return ((result as any)?.changes || 0) > 0;
} catch {
return false;
}
}
/** Number of distinct agent fingerprints that have ever claimed this build's tag. */
export function countBuildClaims(buildId: string): number {
if (!buildId) return 0;
const row = db.query<{ count: number }>(
`SELECT COUNT(*) as count FROM build_claims WHERE build_id = ?`,
).get(buildId);
return row?.count ?? 0;
}
/** Batch helper — returns Map<buildId, claimCount> for the given build IDs. */
export function countBuildClaimsBatch(buildIds: string[]): Map<string, number> {
const out = new Map<string, number>();
if (buildIds.length === 0) return out;
// SQLite has a host-parameter limit (~999 by default); chunk to be safe.
for (let i = 0; i < buildIds.length; i += 500) {
const chunk = buildIds.slice(i, i + 500);
const placeholders = chunk.map(() => "?").join(",");
const rows = db.query<{ build_id: string; count: number }>(
`SELECT build_id, COUNT(*) as count FROM build_claims WHERE build_id IN (${placeholders}) GROUP BY build_id`,
).all(...chunk);
for (const r of rows) out.set(r.build_id, r.count);
}
// Ensure every requested ID is in the map (default 0).
for (const id of buildIds) if (!out.has(id)) out.set(id, 0);
return out;
} }
export function getAllBuilds(userId?: number, role?: string): BuildRecord[] { export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
@@ -1591,28 +1665,12 @@ export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
const rows = db const rows = db
.query<any>(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`) .query<any>(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`)
.all(userId); .all(userId);
return rows.map((row) => ({ return rows.map(mapBuildRow);
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
}));
} }
const rows = db const rows = db
.query<any>("SELECT * FROM builds ORDER BY start_time DESC") .query<any>("SELECT * FROM builds ORDER BY start_time DESC")
.all(); .all();
return rows.map((row) => ({ return rows.map(mapBuildRow);
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
}));
} }
export function deleteExpiredBuilds() { export function deleteExpiredBuilds() {
+8 -3
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages } from "./db"; import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim } from "./db";
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
import { ClientInfo, ClientRole } from "./types"; import { ClientInfo, ClientRole } from "./types";
@@ -482,11 +482,16 @@ async function startServer() {
registration: {}, registration: {},
}; };
function handleBuildTagConnection(clientId: string, buildTagValue: string) { function handleBuildTagConnection(clientId: string, buildTagValue: string, keyFingerprint: string) {
if (!buildTagValue) return; if (!buildTagValue) return;
const build = getBuildByTag(buildTagValue); const build = getBuildByTag(buildTagValue);
if (!build || !build.builtByUserId) return; if (!build || !build.builtByUserId) return;
if (build.blocked) return;
const isFirstClaim = recordBuildClaim(build.id, keyFingerprint);
if (!isFirstClaim) return;
const userId = build.builtByUserId; const userId = build.builtByUserId;
const user = getUserById(userId); const user = getUserById(userId);
if (!user) return; if (!user) return;
@@ -500,7 +505,7 @@ async function startServer() {
if (currentScope === "none" || currentScope === "allowlist") { if (currentScope === "none" || currentScope === "allowlist") {
setUserClientAccessRule(userId, clientId, "allow"); setUserClientAccessRule(userId, clientId, "allow");
logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)})`); logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`);
} }
} }
+10 -4
View File
@@ -46,10 +46,16 @@ function resolveClientBuildCacheRoot(): string {
} }
function resolveAndroidNdkToolchainBin(): string | null { function resolveAndroidNdkToolchainBin(): string | null {
const ndkHome = (process.env.ANDROID_NDK_HOME || "/opt/android-ndk").trim(); const explicit = process.env.ANDROID_NDK_HOME?.trim();
const ndkHome = explicit || (process.platform === "win32" ? "" : "/opt/android-ndk");
if (!ndkHome) return null;
const hostArch = process.arch === "arm64" ? "linux-aarch64" : "linux-x86_64"; const hostArch = process.arch === "arm64" ? "linux-aarch64" : "linux-x86_64";
const toolchainBin = path.join(ndkHome, "toolchains", "llvm", "prebuilt", hostArch, "bin"); const toolchainBin = path.join(ndkHome, "toolchains", "llvm", "prebuilt", hostArch, "bin");
return fs.existsSync(toolchainBin) ? toolchainBin : null; try {
return fs.existsSync(toolchainBin) ? toolchainBin : null;
} catch {
return null;
}
} }
type BoundFile = { type BoundFile = {
@@ -291,8 +297,8 @@ export async function startBuildProcess(
}); });
} }
const ndkBin = resolveAndroidNdkToolchainBin(); const ndkBin = hasAndroidTargets ? resolveAndroidNdkToolchainBin() : null;
if (!ndkBin && platformsToBuild.some((p) => p.startsWith("android-"))) { if (hasAndroidTargets && !ndkBin) {
sendToStream({ sendToStream({
type: "output", type: "output",
text: "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n", text: "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n",
@@ -77,6 +77,21 @@ export async function ensurePluginExtracted(
const zip = new AdmZip(zipPath); const zip = new AdmZip(zipPath);
const entries = zip.getEntries(); const entries = zip.getEntries();
const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
let totalUncompressed = 0;
for (const entry of entries as any[]) {
if (entry?.isDirectory) continue;
const sz = Number(entry?.header?.size ?? 0);
if (!Number.isFinite(sz) || sz < 0) {
throw new Error(`Invalid plugin bundle: ${safeId} (malformed zip)`);
}
totalUncompressed += sz;
if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
throw new Error(`Plugin bundle too large: ${safeId} (uncompressed > 200 MB)`);
}
}
let htmlEntry: Buffer | null = null; let htmlEntry: Buffer | null = null;
let cssEntry: Buffer | null = null; let cssEntry: Buffer | null = null;
let jsEntry: Buffer | null = null; let jsEntry: Buffer | null = null;
@@ -40,11 +40,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
try { if (user.role !== "admin") {
requirePermission(user, "clients:control"); return new Response("Forbidden: Admin access required", { status: 403 });
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -98,11 +95,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
try { if (user.role !== "admin") {
requirePermission(user, "clients:control"); return new Response("Forbidden: Admin access required", { status: 403 });
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -153,11 +147,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
try { if (user.role !== "admin") {
requirePermission(user, "clients:control"); return new Response("Forbidden: Admin access required", { status: 403 });
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
deleteAutoScript(autoScriptMatch[1]); deleteAutoScript(autoScriptMatch[1]);
@@ -13,6 +13,9 @@ import {
listBuildProfilesForUser, listBuildProfilesForUser,
saveBuildProfileForUser, saveBuildProfileForUser,
deleteBuildProfileForUser, deleteBuildProfileForUser,
setBuildBlocked,
listClientIdsByBuildTag,
countBuildClaimsBatch,
} from "../../db"; } from "../../db";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
@@ -386,7 +389,13 @@ export async function handleBuildRoutes(
const builds = showAll const builds = showAll
? getAllBuilds(undefined, "admin") ? getAllBuilds(undefined, "admin")
: getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role); : getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role);
return Response.json({ builds });
const claimCounts = countBuildClaimsBatch(builds.map((b) => b.id));
const enriched = builds.map((b) => ({
...b,
claimCount: claimCounts.get(b.id) ?? 0,
}));
return Response.json({ builds: enriched });
} }
if (req.method === "GET" && url.pathname === "/api/build/profiles") { if (req.method === "GET" && url.pathname === "/api/build/profiles") {
@@ -518,6 +527,66 @@ export async function handleBuildRoutes(
return Response.json({ success: true }); return Response.json({ success: true });
} }
if (req.method === "POST" && url.pathname.match(/^\/api\/build\/(.+)\/block$/)) {
requirePermission(user, "clients:build");
const buildId = decodeURIComponent(url.pathname.split("/")[3]);
const build = getBuild(buildId);
if (!build) {
return new Response("Not Found", { status: 404 });
}
if (build.builtByUserId && build.builtByUserId !== user.userId && user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try {
body = await req.json();
} catch {
// empty body = default to block (true)
}
const blocked = body && typeof body.blocked === "boolean" ? body.blocked : true;
const ok = setBuildBlocked(buildId, blocked);
if (!ok) {
return Response.json({ error: "Build not found" }, { status: 404 });
}
let disconnected = 0;
if (blocked && build.buildTag) {
const clientIds = listClientIdsByBuildTag(build.buildTag);
for (const cid of clientIds) {
const target = clientManager.getClient(cid);
if (!target?.ws) continue;
try {
target.ws.send(
encodeMessage({
type: "command",
commandType: "disconnect",
id: uuidv4(),
payload: { reason: "build_blocked" },
}),
);
} catch {}
try { target.ws.close(4007, "build_blocked"); } catch {}
disconnected++;
}
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `${blocked ? "Blocked" : "Unblocked"} build ${buildId}${blocked && disconnected > 0 ? ` (kicked ${disconnected} live agents)` : ""}`,
success: true,
});
logger.info(`[build:block] Build ${buildId.substring(0, 8)} ${blocked ? "blocked" : "unblocked"} by ${user.username}${blocked && disconnected > 0 ? ` (${disconnected} live agents kicked)` : ""}`);
return Response.json({ success: true, blocked, disconnected });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) { if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
requirePermission(user, "clients:build"); requirePermission(user, "clients:build");
@@ -625,11 +694,30 @@ export async function handleBuildRoutes(
!fileName || !fileName ||
fileName.includes("\u0000") || fileName.includes("\u0000") ||
fileName.includes("/") || fileName.includes("/") ||
fileName.includes("\\") fileName.includes("\\") ||
fileName.includes("\"") ||
fileName.includes("\r") ||
fileName.includes("\n") ||
fileName.includes(";")
) { ) {
return Response.json({ error: "File not found" }, { status: 404 }); return Response.json({ error: "File not found" }, { status: 404 });
} }
const allBuilds = getAllBuilds(undefined, "admin");
const owningBuild = allBuilds.find((b) =>
Array.isArray(b.files) && b.files.some((f: any) => f && f.filename === fileName),
);
if (!owningBuild) {
return Response.json({ error: "File not found" }, { status: 404 });
}
if (
owningBuild.builtByUserId &&
owningBuild.builtByUserId !== user.userId &&
user.role !== "admin"
) {
return new Response("Forbidden", { status: 403 });
}
const rootDir = resolveRuntimeRoot(); const rootDir = resolveRuntimeRoot();
const distRoot = path.resolve(rootDir, "dist-clients"); const distRoot = path.resolve(rootDir, "dist-clients");
const filePath = path.resolve(distRoot, fileName); const filePath = path.resolve(distRoot, fileName);
@@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac"; import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users"; import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users";
import { runCertbotSetup } from "../certbot-setup"; import { runCertbotSetup } from "../certbot-setup";
import { import {
getActiveProxies, getActiveProxies,
@@ -351,7 +351,10 @@ export async function handleMiscRoutes(
if (user.role === "viewer") { if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
return Response.json({ proxies: getActiveProxies() }, { headers: deps.CORS_HEADERS }); const proxies = getActiveProxies().filter((p) =>
canUserAccessClient(user.userId, user.role, p.clientId),
);
return Response.json({ proxies }, { headers: deps.CORS_HEADERS });
} }
if (req.method === "POST" && url.pathname === "/api/proxy/start") { if (req.method === "POST" && url.pathname === "/api/proxy/start") {
@@ -373,10 +376,22 @@ export async function handleMiscRoutes(
if (!clientId) { if (!clientId) {
return Response.json({ ok: false, message: "clientId is required" }, { status: 400 }); return Response.json({ ok: false, message: "clientId is required" }, { status: 400 });
} }
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
}
if (port < 1 || port > 65535) { if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 }); return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
} }
const result = startProxy(clientId, port); const result = startProxy(clientId, port);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Started SOCKS5 proxy on port ${port} for client ${clientId}`,
success: result.ok,
errorMessage: result.ok ? undefined : result.message,
});
return Response.json(result, { return Response.json(result, {
status: result.ok ? 200 : 400, status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS, headers: deps.CORS_HEADERS,
@@ -401,7 +416,20 @@ export async function handleMiscRoutes(
if (port < 1 || port > 65535) { if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 }); return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
} }
const owner = getActiveProxies().find((p) => p.port === port);
if (owner && !canUserAccessClient(user.userId, user.role, owner.clientId)) {
return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
}
const result = stopProxy(port); const result = stopProxy(port);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Stopped SOCKS5 proxy on port ${port}${owner ? ` (client ${owner.clientId})` : ""}`,
success: result.ok,
errorMessage: result.ok ? undefined : result.message,
});
return Response.json(result, { return Response.json(result, {
status: result.ok ? 200 : 400, status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS, headers: deps.CORS_HEADERS,
@@ -577,6 +605,10 @@ export async function handleMiscRoutes(
} }
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) { if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try { try {
const file = Bun.file(deps.tlsCertPath); const file = Bun.file(deps.tlsCertPath);
if (await file.exists()) { if (await file.exists()) {
@@ -81,8 +81,8 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin" && user.role !== "operator") { if (user.role !== "admin") {
return new Response("Forbidden: Admin or operator access required", { return new Response("Forbidden: Admin access required", {
status: 403, status: 403,
}); });
} }
@@ -1,5 +1,7 @@
import fs from "fs/promises"; import fs from "fs/promises";
import { constants as fsConstants } from "fs";
import path from "path"; import path from "path";
import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac"; import { requirePermission } from "../../rbac";
@@ -189,6 +191,12 @@ export async function handlePluginRoutes(
return new Response("Only .zip files are supported", { status: 400 }); return new Response("Only .zip files are supported", { status: 400 });
} }
const MAX_PLUGIN_ZIP_BYTES = 50 * 1024 * 1024; // 50 MB compressed
const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; // 200 MB total uncompressed
if (typeof file.size === "number" && file.size > MAX_PLUGIN_ZIP_BYTES) {
return new Response("Plugin zip exceeds size limit", { status: 413 });
}
const base = path.basename(filename, path.extname(filename)); const base = path.basename(filename, path.extname(filename));
let pluginId = ""; let pluginId = "";
try { try {
@@ -197,9 +205,30 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin name", { status: 400 }); return new Response("Invalid plugin name", { status: 400 });
} }
const data = new Uint8Array(await file.arrayBuffer());
if (data.byteLength > MAX_PLUGIN_ZIP_BYTES) {
return new Response("Plugin zip exceeds size limit", { status: 413 });
}
try {
const probe = new AdmZip(Buffer.from(data));
let totalUncompressed = 0;
for (const entry of probe.getEntries() as any[]) {
const sz = Number(entry?.header?.size ?? 0);
if (!Number.isFinite(sz) || sz < 0) {
return new Response("Invalid plugin zip", { status: 400 });
}
totalUncompressed += sz;
if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
return new Response("Plugin uncompressed size exceeds limit", { status: 413 });
}
}
} catch {
return new Response("Invalid plugin zip", { status: 400 });
}
await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true }); await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true });
const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`); const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
const data = new Uint8Array(await file.arrayBuffer());
await fs.writeFile(zipPath, data); await fs.writeFile(zipPath, data);
try { try {
@@ -262,8 +291,8 @@ export async function handlePluginRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { if (user.role !== "admin") {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); return new Response("Forbidden: Admin access required", { status: 403 });
} }
let pluginId = ""; let pluginId = "";
try { try {
@@ -325,6 +354,28 @@ export async function handlePluginRoutes(
return target; return target;
} }
async function isPathSafe(target: string, dataDir: string): Promise<boolean> {
const root = path.resolve(dataDir);
let current = path.resolve(target);
while (current !== root) {
try {
const st = await fs.lstat(current);
if (st.isSymbolicLink()) return false;
} catch {
}
const parent = path.dirname(current);
if (parent === current) return false;
current = parent;
}
try {
const st = await fs.lstat(root);
if (st.isSymbolicLink() || !st.isDirectory()) return false;
} catch {
return false;
}
return true;
}
const pluginDataListMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/data$/); const pluginDataListMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/data$/);
if (req.method === "GET" && pluginDataListMatch) { if (req.method === "GET" && pluginDataListMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
@@ -366,10 +417,13 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 }); if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath); const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 }); if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
const file = Bun.file(target); const file = Bun.file(target);
if (!(await file.exists())) return new Response("Not found", { status: 404 }); if (!(await file.exists())) return new Response("Not found", { status: 404 });
const st = await fs.stat(target); const st = await fs.lstat(target);
if (st.isDirectory()) return new Response("Is a directory", { status: 400 }); if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(relPath)) }); return new Response(file, { headers: deps.secureHeaders(deps.mimeType(relPath)) });
} }
@@ -387,9 +441,27 @@ export async function handlePluginRoutes(
} }
const target = resolveDataPath(pluginId, relPath); const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 }); if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
await fs.mkdir(dataDir, { recursive: true });
await fs.mkdir(path.dirname(target), { recursive: true }); await fs.mkdir(path.dirname(target), { recursive: true });
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
const existingSt = await fs.lstat(target).catch(() => null);
if (existingSt && existingSt.isSymbolicLink()) {
return new Response("Forbidden", { status: 403 });
}
const body = await req.arrayBuffer(); const body = await req.arrayBuffer();
await fs.writeFile(target, new Uint8Array(body)); let fh: import("fs/promises").FileHandle | null = null;
try {
fh = await fs.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | (fsConstants.O_NOFOLLOW || 0));
await fh.writeFile(new Uint8Array(body));
} catch (err: any) {
if (err && (err.code === "ELOOP" || err.code === "EISDIR")) {
return new Response("Forbidden", { status: 403 });
}
throw err;
} finally {
if (fh) await fh.close().catch(() => {});
}
return Response.json({ ok: true, path: relPath, size: body.byteLength }); return Response.json({ ok: true, path: relPath, size: body.byteLength });
} }
@@ -405,9 +477,14 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 }); if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath); const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 }); if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try { try {
const st = await fs.stat(target); const st = await fs.lstat(target);
if (st.isDirectory()) { if (st.isSymbolicLink()) {
// Just unlink the link itself; never traverse it.
await fs.unlink(target);
} else if (st.isDirectory()) {
await fs.rm(target, { recursive: true, force: true }); await fs.rm(target, { recursive: true, force: true });
} else { } else {
await fs.unlink(target); await fs.unlink(target);
@@ -437,19 +514,35 @@ export async function handlePluginRoutes(
if (decodedFile.includes("\u0000")) return new Response("Bad request", { status: 400 }); if (decodedFile.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, decodedFile); const target = resolveDataPath(pluginId, decodedFile);
if (!target) return new Response("Forbidden", { status: 403 }); if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try { try {
const st = await fs.stat(target); const st = await fs.lstat(target);
if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
if (st.isDirectory()) return new Response("Is a directory", { status: 400 }); if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
} catch { } catch {
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} }
// Resolve via realpath to defeat any symlink raced into place after the lstat
// above; re-verify the resolved path is still under the data dir.
let realTarget: string;
try {
realTarget = await fs.realpath(target);
} catch {
return new Response("Forbidden", { status: 403 });
}
const dataDirPrefix = dataDir.endsWith(path.sep) ? dataDir : `${dataDir}${path.sep}`;
if (realTarget !== dataDir && !realTarget.startsWith(dataDirPrefix)) {
return new Response("Forbidden", { status: 403 });
}
const args: string[] = Array.isArray(body.args) ? body.args.filter((a: any) => typeof a === "string") : []; const args: string[] = Array.isArray(body.args) ? body.args.filter((a: any) => typeof a === "string") : [];
const stdinData: string = typeof body.stdin === "string" ? body.stdin : ""; const stdinData: string = typeof body.stdin === "string" ? body.stdin : "";
const timeoutMs: number = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? Math.min(body.timeoutMs, 60_000) : 30_000; const timeoutMs: number = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? Math.min(body.timeoutMs, 60_000) : 30_000;
// Ensure the binary is executable // Ensure the binary is executable. chmod follows symlinks, but we've verified
try { await fs.chmod(target, 0o755); } catch {} // the realpath stays inside dataDir, so this is safe.
const proc = Bun.spawn([target, ...args], { try { await fs.chmod(realTarget, 0o755); } catch {}
cwd: path.dirname(target), const proc = Bun.spawn([realTarget, ...args], {
cwd: path.dirname(realTarget),
stdin: stdinData ? Buffer.from(stdinData) : "ignore", stdin: stdinData ? Buffer.from(stdinData) : "ignore",
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -65,11 +65,7 @@ type RegistrationRouteDeps = {
}; };
function getClientIp(req: Request, deps: RegistrationRouteDeps): string { function getClientIp(req: Request, deps: RegistrationRouteDeps): string {
const fromDeps = deps.requestIP?.(req)?.address; return deps.requestIP?.(req)?.address || "unknown";
if (fromDeps) return fromDeps;
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")
|| "unknown";
} }
export async function handleRegistrationRoutes( export async function handleRegistrationRoutes(
@@ -2,6 +2,11 @@ import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../
import { AuditAction, logAudit } from "../../auditLog"; import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { requirePermission } from "../../rbac"; import { requirePermission } from "../../rbac";
import {
listUserSessions,
persistRevokedTokenHash,
revokeAllUserSessions,
} from "../../db";
import { import {
type ClientAccessRuleKind, type ClientAccessRuleKind,
type ClientAccessScope, type ClientAccessScope,
@@ -80,6 +85,9 @@ export async function handleUsersRoutes(
} }
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) { if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) {
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const userId = parseInt(url.pathname.split("/")[3]); const userId = parseInt(url.pathname.split("/")[3]);
const body = await req.json(); const body = await req.json();
const { password, newPassword, currentPassword } = body; const { password, newPassword, currentPassword } = body;
@@ -90,12 +98,19 @@ export async function handleUsersRoutes(
return Response.json({ error: "Permission denied" }, { status: 403 }); return Response.json({ error: "Permission denied" }, { status: 403 });
} }
if (user.userId === userId && currentPassword) { if (user.userId === userId) {
const targetUser = getUserById(userId); const targetUser = getUserById(userId);
if (!targetUser) { if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
if (typeof currentPassword !== "string" || currentPassword.length === 0) {
return Response.json(
{ error: "Current password is required" },
{ status: 400 },
);
}
const isValid = await Bun.password.verify( const isValid = await Bun.password.verify(
currentPassword, currentPassword,
targetUser.password_hash, targetUser.password_hash,
@@ -117,6 +132,13 @@ export async function handleUsersRoutes(
if (result.success) { if (result.success) {
const targetUser = getUserById(userId); const targetUser = getUserById(userId);
const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
for (const s of existingSessions) {
persistRevokedTokenHash(s.tokenHash, s.expiresAt);
}
revokeAllUserSessions(userId);
const ip = server.requestIP(req)?.address || "unknown"; const ip = server.requestIP(req)?.address || "unknown";
logAudit({ logAudit({
timestamp: Date.now(), timestamp: Date.now(),
@@ -168,6 +190,13 @@ export async function handleUsersRoutes(
if (result.success) { if (result.success) {
const targetUser = getUserById(userId); const targetUser = getUserById(userId);
const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
for (const s of existingSessions) {
persistRevokedTokenHash(s.tokenHash, s.expiresAt);
}
revokeAllUserSessions(userId);
const ip = server.requestIP(req)?.address || "unknown"; const ip = server.requestIP(req)?.address || "unknown";
logAudit({ logAudit({
timestamp: Date.now(), timestamp: Date.now(),
@@ -103,7 +103,7 @@ type WsLifecycleDeps = {
clearPendingNotificationScreenshots: (clientId: string) => void; clearPendingNotificationScreenshots: (clientId: string) => void;
clearClientPluginState: (clientId: string) => void; clearClientPluginState: (clientId: string) => void;
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
handleBuildTagConnection: (clientId: string, buildTag: string) => void; handleBuildTagConnection: (clientId: string, buildTag: string, keyFingerprint: string) => void;
notifyDashboard: () => void; notifyDashboard: () => void;
notifyDashboardClientEvent: ( notifyDashboardClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory", event: "client_online" | "client_offline" | "client_purgatory",
@@ -302,6 +302,22 @@ export async function handleWebSocketMessage(
const build = buildTag ? getBuildByTag(buildTag) : null; const build = buildTag ? getBuildByTag(buildTag) : null;
const builtByUserId = build?.builtByUserId; const builtByUserId = build?.builtByUserId;
if (buildTag && build?.blocked) {
logger.info(`[build-block] rejecting agent ${resolvedId} from blocked build ${build.id.substring(0, 8)}`);
try {
ws.send(
encodeMessage({
type: "command",
commandType: "disconnect",
id: crypto.randomUUID(),
payload: { reason: "build_blocked" },
}),
);
} catch {}
try { ws.close(4007, "build_blocked"); } catch {}
return;
}
if (enrollmentStatus === "denied") { if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`); logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" })); ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
@@ -478,7 +494,7 @@ export async function handleWebSocketMessage(
(ws as any).data.wasKnown = true; (ws as any).data.wasKnown = true;
if (buildTag) { if (buildTag) {
deps.handleBuildTagConnection(infoObj.id, buildTag); deps.handleBuildTagConnection(infoObj.id, buildTag, keyFingerprint);
} }
} }
break; break;
+20 -5
View File
@@ -280,10 +280,7 @@ if (userCount.count === 0) {
logger.info(`[users] Initial admin account created (username: ${initialUsername})`); logger.info(`[users] Initial admin account created (username: ${initialUsername})`);
logger.warn( logger.warn(
`[users] Bootstrap login credentials -> username: ${initialUsername} | password: ${initialPassword}`, "[users] SECURITY WARNING: A default admin account has been created. Sign in and rotate the password immediately. Bootstrap credentials default to admin/admin unless overridden by configuration; the password is not logged.",
);
logger.warn(
"[users] SECURITY WARNING: Rotate the bootstrap password after first login. Default bootstrap credentials are admin/admin unless overridden by configuration.",
); );
} }
@@ -742,12 +739,30 @@ export function updateLastLogin(userId: number): void {
); );
} }
let _dummyHashPromise: Promise<string> | null = null;
function getDummyPasswordHash(): Promise<string> {
if (!_dummyHashPromise) {
_dummyHashPromise = Bun.password.hash("__overlord_dummy_hash_for_timing__", {
algorithm: "bcrypt",
cost: 10,
});
}
return _dummyHashPromise;
}
export async function verifyPassword( export async function verifyPassword(
username: string, username: string,
password: string, password: string,
): Promise<User | null> { ): Promise<User | null> {
const user = getUserByUsername(username); const user = getUserByUsername(username);
if (!user) return null; if (!user) {
try {
const dummy = await getDummyPasswordHash();
await Bun.password.verify(password, dummy);
} catch {
}
return null;
}
const isValid = await Bun.password.verify(password, user.password_hash); const isValid = await Bun.password.verify(password, user.password_hash);
if (!isValid) return null; if (!isValid) return null;
+45 -4
View File
@@ -24,6 +24,37 @@ function sanitizeInfoString(val: unknown, maxLen = 256): string | undefined {
return val.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, maxLen); return val.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, maxLen);
} }
function sanitizeMonitorCount(val: unknown): number | undefined {
if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
const n = Math.floor(val);
if (n < 0) return 0;
if (n > 32) return 32;
return n;
}
function sanitizeJsonField(
val: unknown,
opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
): unknown | undefined {
if (val === null || val === undefined) return undefined;
if (typeof val !== "object") return undefined;
if (Array.isArray(val) && opts.maxArrayLen !== undefined && val.length > opts.maxArrayLen) {
return undefined;
}
if (!Array.isArray(val) && opts.maxKeys !== undefined) {
const keys = Object.keys(val as Record<string, unknown>);
if (keys.length > opts.maxKeys) return undefined;
}
let serialized: string;
try {
serialized = JSON.stringify(val);
} catch {
return undefined;
}
if (serialized.length > opts.maxJsonBytes) return undefined;
return val;
}
const MAX_PING_RTT_MS = 15_000; const MAX_PING_RTT_MS = 15_000;
const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000); const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
const lastClientDbSync = new Map<string, number>(); const lastClientDbSync = new Map<string, number>();
@@ -99,13 +130,23 @@ export async function handleHello(
info.arch = sanitizeInfoString(payload.arch, 32); info.arch = sanitizeInfoString(payload.arch, 32);
info.version = sanitizeInfoString(payload.version, 64); info.version = sanitizeInfoString(payload.version, 64);
info.user = sanitizeInfoString(payload.user); info.user = sanitizeInfoString(payload.user);
info.monitors = payload.monitors; info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors;
info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo; const cleanMonitorInfo = sanitizeJsonField((payload as any).monitorInfo, {
maxJsonBytes: 8 * 1024,
maxArrayLen: 32,
});
if (cleanMonitorInfo !== undefined) {
info.monitorInfo = cleanMonitorInfo as any;
}
info.inMemory = !!(payload as any).inMemory; info.inMemory = !!(payload as any).inMemory;
info.isAdmin = !!(payload as any).isAdmin; info.isAdmin = !!(payload as any).isAdmin;
info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation; info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation;
if ((payload as any).permissions && typeof (payload as any).permissions === "object") { const cleanPermissions = sanitizeJsonField((payload as any).permissions, {
info.permissions = (payload as any).permissions; maxJsonBytes: 4 * 1024,
maxKeys: 64,
});
if (cleanPermissions !== undefined && !Array.isArray(cleanPermissions)) {
info.permissions = cleanPermissions as any;
} }
info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu; info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu; info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;