1.9.0
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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 & 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 & 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 & 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> — 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 — 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> — 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 & 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 */ :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 */ :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 & 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> — Downloads a JSON file with all current settings and auth tokens.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">Keep this file secure — 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> — 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 — <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 — <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
@@ -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() {
|
||||||
|
|||||||
@@ -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)}...)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user