From 6a363a62f2edd7abfa2cf7412176bf6c54fd8d0d Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 29 Apr 2026 00:22:37 -0500 Subject: [PATCH] 1.9.0 --- Overlord-Server/public/assets/settings.js | 316 +++++++++ Overlord-Server/public/settings.html | 644 +++++++++++------- Overlord-Server/src/db.ts | 120 +++- Overlord-Server/src/main-server.ts | 11 +- Overlord-Server/src/server/build-process.ts | 14 +- .../src/server/plugin-state-bundle.ts | 15 + .../src/server/routes/auto-scripts-routes.ts | 21 +- .../src/server/routes/build-routes.ts | 92 ++- .../src/server/routes/misc-routes.ts | 36 +- .../routes/notifications-config-routes.ts | 4 +- .../src/server/routes/plugin-routes.ts | 117 +++- .../src/server/routes/registration-routes.ts | 6 +- .../src/server/routes/users-routes.ts | 31 +- .../routes/websocket-lifecycle-routes.ts | 20 +- Overlord-Server/src/users.ts | 25 +- Overlord-Server/src/wsHandlers.ts | 49 +- 16 files changed, 1182 insertions(+), 339 deletions(-) diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index bf73127..b7f25dc 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -650,6 +650,285 @@ async function handleUnbanClick(event) { 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 = ` + Loading... + `; + + let res; + try { + res = await fetch(url, { credentials: "include" }); + } catch { + buildsTableBody.innerHTML = ` + Network error + `; + return; + } + + if (!res.ok) { + buildsTableBody.innerHTML = ` + Failed to load builds (HTTP ${res.status}) + `; + return; + } + + const data = await res.json().catch(() => ({ builds: [] })); + const builds = Array.isArray(data.builds) ? data.builds : []; + + if (builds.length === 0) { + buildsTableBody.innerHTML = ` + No builds + `; + 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 + ? `Blocked` + : `Active`; + const actionBtn = b.blocked + ? `` + : ``; + const tagCell = tag + ? `${escapeHtml(tagShort)}` + : `—`; + return ` + + ${escapeHtml(idShort)} + ${tagCell} + ${statusBadge} + ${formatDate(b.startTime)} + ${Number(b.claimCount) || 0} + ${actionBtn} + + `; + }).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() { if (!currentUser) return; @@ -1340,6 +1619,15 @@ async function init() { 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 loadTlsSettings(); await loadAppearanceSettings(); @@ -1387,6 +1675,34 @@ async function init() { bansTableBody.addEventListener("click", handleUnbanClick); 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(); if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions); if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 530fcf9..c02c569 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -9,236 +9,164 @@ +
-
-
+
+

Settings

- Manage your account preferences and review banned IP addresses. + Manage your account preferences, operational tools, and admin configuration.

-
+
+ + + + + +
+ + + + +
- +
-

News & Updates

-

Stay up to date with the newest features, releases, and announcements.

-
-
-
-
-

- Follow the official Overlord Telegram channel for the latest updates, changelogs, and feature announcements. -

-

Channel: @kunta

-
- - - Open on Telegram - -
-
- - - - - - -
- -
-
-
- -
-
-

Account

-

View your account info and update your password.

-
-
- -
-
-
Username
-
-
-
-
-
Role
-
-
-
-
- -
-

Change Password

- - - -
-

-
- -
- - -
- - -
-
-
-
- -
-
-

Banned IPs

-

Unban blocked agent source IPs.

-
-
- -
- - - -
- - - - - - - - - - - - - - -
IPReasonBanned AtAction
Loading...
-
-
+
-
+
@@ -289,7 +217,7 @@
-
+
@@ -367,7 +295,7 @@
-
+
@@ -386,8 +314,169 @@
+ +
+
+
+ +
+
+

News & Updates

+

Stay up to date with the newest features, releases, and announcements.

+
+
+
+
+

+ Follow the official Overlord Telegram channel for the latest updates, changelogs, and feature announcements. +

+

Channel: @kunta

+
+ + + Open on Telegram + +
+
+ + + + +
+
+
+
+ +
+
+

Banned IPs

+

Unban blocked agent source IPs.

+
+
+ +
+ + + +
+ + + + + + + + + + + + + + +
IPReasonBanned AtAction
Loading...
+
+
+ + + + + + + + + -
+
@@ -489,7 +578,7 @@
-
+
@@ -591,7 +680,7 @@
-
+ +
+ + diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 76f247e..3cb38e4 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -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 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 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(` CREATE TABLE IF NOT EXISTS build_profiles ( user_id INTEGER NOT NULL, @@ -1537,6 +1548,7 @@ export interface BuildRecord { }>; buildTag?: string; builtByUserId?: number; + blocked?: boolean; } export function saveBuild(build: BuildRecord) { @@ -1553,10 +1565,7 @@ export function saveBuild(build: BuildRecord) { ); } -export function getBuild(id: string): BuildRecord | null { - const row = db.query(`SELECT * FROM builds WHERE id = ?`).get(id); - if (!row) return null; - +function mapBuildRow(row: any): BuildRecord { return { id: row.id, status: row.status, @@ -1565,22 +1574,87 @@ export function getBuild(id: string): BuildRecord | null { files: JSON.parse(row.files), buildTag: row.build_tag || undefined, builtByUserId: row.built_by_user_id || undefined, + blocked: row.blocked === 1, }; } +export function getBuild(id: string): BuildRecord | null { + const row = db.query(`SELECT * FROM builds WHERE id = ?`).get(id); + if (!row) return null; + return mapBuildRow(row); +} + export function getBuildByTag(buildTag: string): BuildRecord | null { const row = db.query(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag); if (!row) return null; + return mapBuildRow(row); +} - return { - 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 isBuildTagBlocked(buildTag: string): boolean { + if (!buildTag) return false; + const row = db.query<{ blocked: number }>( + `SELECT blocked FROM builds WHERE build_tag = ?`, + ).get(buildTag); + return !!row && row.blocked === 1; +} + +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 for the given build IDs. */ +export function countBuildClaimsBatch(buildIds: string[]): Map { + const out = new Map(); + 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[] { @@ -1591,28 +1665,12 @@ export function getAllBuilds(userId?: number, role?: string): BuildRecord[] { const rows = db .query(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`) .all(userId); - return rows.map((row) => ({ - 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, - })); + return rows.map(mapBuildRow); } const rows = db .query("SELECT * FROM builds ORDER BY start_time DESC") .all(); - return rows.map((row) => ({ - 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, - })); + return rows.map(mapBuildRow); } export function deleteExpiredBuilds() { diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index ab15013..f2800de 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs/promises"; 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 { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -482,11 +482,16 @@ async function startServer() { registration: {}, }; - function handleBuildTagConnection(clientId: string, buildTagValue: string) { + function handleBuildTagConnection(clientId: string, buildTagValue: string, keyFingerprint: string) { if (!buildTagValue) return; const build = getBuildByTag(buildTagValue); if (!build || !build.builtByUserId) return; + if (build.blocked) return; + + const isFirstClaim = recordBuildClaim(build.id, keyFingerprint); + if (!isFirstClaim) return; + const userId = build.builtByUserId; const user = getUserById(userId); if (!user) return; @@ -500,7 +505,7 @@ async function startServer() { if (currentScope === "none" || currentScope === "allowlist") { 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)}...)`); } } diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index aedfc09..d947c8c 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -46,10 +46,16 @@ function resolveClientBuildCacheRoot(): string { } 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 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 = { @@ -291,8 +297,8 @@ export async function startBuildProcess( }); } - const ndkBin = resolveAndroidNdkToolchainBin(); - if (!ndkBin && platformsToBuild.some((p) => p.startsWith("android-"))) { + const ndkBin = hasAndroidTargets ? resolveAndroidNdkToolchainBin() : null; + if (hasAndroidTargets && !ndkBin) { sendToStream({ 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", diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index 08a5701..0826c3a 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -77,6 +77,21 @@ export async function ensurePluginExtracted( const zip = new AdmZip(zipPath); 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 cssEntry: Buffer | null = null; let jsEntry: Buffer | null = null; diff --git a/Overlord-Server/src/server/routes/auto-scripts-routes.ts b/Overlord-Server/src/server/routes/auto-scripts-routes.ts index cfe266f..7c0e0f7 100644 --- a/Overlord-Server/src/server/routes/auto-scripts-routes.ts +++ b/Overlord-Server/src/server/routes/auto-scripts-routes.ts @@ -40,11 +40,8 @@ export async function handleAutoScriptsRoutes( headers: { "Content-Type": "application/json" }, }); } - try { - requirePermission(user, "clients:control"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); } let body: any = {}; @@ -98,11 +95,8 @@ export async function handleAutoScriptsRoutes( headers: { "Content-Type": "application/json" }, }); } - try { - requirePermission(user, "clients:control"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); } let body: any = {}; @@ -153,11 +147,8 @@ export async function handleAutoScriptsRoutes( headers: { "Content-Type": "application/json" }, }); } - try { - requirePermission(user, "clients:control"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); } deleteAutoScript(autoScriptMatch[1]); diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index 286b18c..0824bab 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -13,6 +13,9 @@ import { listBuildProfilesForUser, saveBuildProfileForUser, deleteBuildProfileForUser, + setBuildBlocked, + listClientIdsByBuildTag, + countBuildClaimsBatch, } from "../../db"; import { encodeMessage } from "../../protocol"; import { metrics } from "../../metrics"; @@ -386,7 +389,13 @@ export async function handleBuildRoutes( const builds = showAll ? getAllBuilds(undefined, "admin") : 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") { @@ -518,6 +527,66 @@ export async function handleBuildRoutes( 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$/)) { requirePermission(user, "clients:build"); @@ -625,11 +694,30 @@ export async function handleBuildRoutes( !fileName || fileName.includes("\u0000") || 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 }); } + 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 distRoot = path.resolve(rootDir, "dist-clients"); const filePath = path.resolve(distRoot, fileName); diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 90fc156..70bb0cd 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; import { metrics } from "../../metrics"; 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 { getActiveProxies, @@ -351,7 +351,10 @@ export async function handleMiscRoutes( if (user.role === "viewer") { 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") { @@ -373,10 +376,22 @@ export async function handleMiscRoutes( if (!clientId) { 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) { return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 }); } 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, { status: result.ok ? 200 : 400, headers: deps.CORS_HEADERS, @@ -401,7 +416,20 @@ export async function handleMiscRoutes( if (port < 1 || port > 65535) { 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); + 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, { status: result.ok ? 200 : 400, headers: deps.CORS_HEADERS, @@ -577,6 +605,10 @@ export async function handleMiscRoutes( } 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 { const file = Bun.file(deps.tlsCertPath); if (await file.exists()) { diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts index 6de815e..90a57bf 100644 --- a/Overlord-Server/src/server/routes/notifications-config-routes.ts +++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts @@ -81,8 +81,8 @@ export async function handleNotificationsConfigRoutes( headers: { "Content-Type": "application/json" }, }); } - if (user.role !== "admin" && user.role !== "operator") { - return new Response("Forbidden: Admin or operator access required", { + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403, }); } diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 71db1c2..cb44e26 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -1,5 +1,7 @@ import fs from "fs/promises"; +import { constants as fsConstants } from "fs"; import path from "path"; +import AdmZip from "adm-zip"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { requirePermission } from "../../rbac"; @@ -189,6 +191,12 @@ export async function handlePluginRoutes( 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)); let pluginId = ""; try { @@ -197,9 +205,30 @@ export async function handlePluginRoutes( 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 }); const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`); - const data = new Uint8Array(await file.arrayBuffer()); await fs.writeFile(zipPath, data); try { @@ -262,8 +291,8 @@ export async function handlePluginRoutes( if (!user) { return new Response("Unauthorized", { status: 401 }); } - if (user.role !== "admin" && user.role !== "operator") { - return new Response("Forbidden: Admin or operator access required", { status: 403 }); + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); } let pluginId = ""; try { @@ -325,6 +354,28 @@ export async function handlePluginRoutes( return target; } + async function isPathSafe(target: string, dataDir: string): Promise { + 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$/); if (req.method === "GET" && pluginDataListMatch) { const user = await authenticateRequest(req); @@ -366,10 +417,13 @@ export async function handlePluginRoutes( if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 }); const target = resolveDataPath(pluginId, relPath); 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); 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.isSymbolicLink()) return new Response("Forbidden", { status: 403 }); return new Response(file, { headers: deps.secureHeaders(deps.mimeType(relPath)) }); } @@ -387,9 +441,27 @@ export async function handlePluginRoutes( } const target = resolveDataPath(pluginId, relPath); 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 }); + 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(); - 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 }); } @@ -405,9 +477,14 @@ export async function handlePluginRoutes( if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 }); const target = resolveDataPath(pluginId, relPath); 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 { - const st = await fs.stat(target); - if (st.isDirectory()) { + const st = await fs.lstat(target); + 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 }); } else { await fs.unlink(target); @@ -437,19 +514,35 @@ export async function handlePluginRoutes( if (decodedFile.includes("\u0000")) return new Response("Bad request", { status: 400 }); const target = resolveDataPath(pluginId, decodedFile); 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 { - 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 }); } catch { 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 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; - // Ensure the binary is executable - try { await fs.chmod(target, 0o755); } catch {} - const proc = Bun.spawn([target, ...args], { - cwd: path.dirname(target), + // Ensure the binary is executable. chmod follows symlinks, but we've verified + // the realpath stays inside dataDir, so this is safe. + try { await fs.chmod(realTarget, 0o755); } catch {} + const proc = Bun.spawn([realTarget, ...args], { + cwd: path.dirname(realTarget), stdin: stdinData ? Buffer.from(stdinData) : "ignore", stdout: "pipe", stderr: "pipe", diff --git a/Overlord-Server/src/server/routes/registration-routes.ts b/Overlord-Server/src/server/routes/registration-routes.ts index d19f6b0..13986ba 100644 --- a/Overlord-Server/src/server/routes/registration-routes.ts +++ b/Overlord-Server/src/server/routes/registration-routes.ts @@ -65,11 +65,7 @@ type RegistrationRouteDeps = { }; function getClientIp(req: Request, deps: RegistrationRouteDeps): string { - const fromDeps = deps.requestIP?.(req)?.address; - if (fromDeps) return fromDeps; - return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() - || req.headers.get("x-real-ip") - || "unknown"; + return deps.requestIP?.(req)?.address || "unknown"; } export async function handleRegistrationRoutes( diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index 06a8ff1..58b05ab 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -2,6 +2,11 @@ import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../ import { AuditAction, logAudit } from "../../auditLog"; import { logger } from "../../logger"; import { requirePermission } from "../../rbac"; +import { + listUserSessions, + persistRevokedTokenHash, + revokeAllUserSessions, +} from "../../db"; import { type ClientAccessRuleKind, type ClientAccessScope, @@ -80,6 +85,9 @@ export async function handleUsersRoutes( } 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 body = await req.json(); const { password, newPassword, currentPassword } = body; @@ -90,12 +98,19 @@ export async function handleUsersRoutes( return Response.json({ error: "Permission denied" }, { status: 403 }); } - if (user.userId === userId && currentPassword) { + if (user.userId === userId) { const targetUser = getUserById(userId); if (!targetUser) { 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( currentPassword, targetUser.password_hash, @@ -117,6 +132,13 @@ export async function handleUsersRoutes( if (result.success) { 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"; logAudit({ timestamp: Date.now(), @@ -168,6 +190,13 @@ export async function handleUsersRoutes( if (result.success) { 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"; logAudit({ timestamp: Date.now(), diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 5f54f31..8d3fd26 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -103,7 +103,7 @@ type WsLifecycleDeps = { clearPendingNotificationScreenshots: (clientId: string) => void; clearClientPluginState: (clientId: 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; notifyDashboardClientEvent: ( event: "client_online" | "client_offline" | "client_purgatory", @@ -302,6 +302,22 @@ export async function handleWebSocketMessage( const build = buildTag ? getBuildByTag(buildTag) : null; 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") { logger.info(`[purgatory] denied client ${resolvedId} tried to connect`); ws.send(encodeMessage({ type: "enrollment_status", status: "denied" })); @@ -478,7 +494,7 @@ export async function handleWebSocketMessage( (ws as any).data.wasKnown = true; if (buildTag) { - deps.handleBuildTagConnection(infoObj.id, buildTag); + deps.handleBuildTagConnection(infoObj.id, buildTag, keyFingerprint); } } break; diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index 5d6c9b6..66ddc1e 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -280,10 +280,7 @@ if (userCount.count === 0) { logger.info(`[users] Initial admin account created (username: ${initialUsername})`); logger.warn( - `[users] Bootstrap login credentials -> username: ${initialUsername} | password: ${initialPassword}`, - ); - logger.warn( - "[users] SECURITY WARNING: Rotate the bootstrap password after first login. Default bootstrap credentials are admin/admin unless overridden by configuration.", + "[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.", ); } @@ -742,12 +739,30 @@ export function updateLastLogin(userId: number): void { ); } +let _dummyHashPromise: Promise | null = null; +function getDummyPasswordHash(): Promise { + if (!_dummyHashPromise) { + _dummyHashPromise = Bun.password.hash("__overlord_dummy_hash_for_timing__", { + algorithm: "bcrypt", + cost: 10, + }); + } + return _dummyHashPromise; +} + export async function verifyPassword( username: string, password: string, ): Promise { 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); if (!isValid) return null; diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 86f7a92..d222508 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -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); } +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); + 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 CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000); const lastClientDbSync = new Map(); @@ -99,13 +130,23 @@ export async function handleHello( info.arch = sanitizeInfoString(payload.arch, 32); info.version = sanitizeInfoString(payload.version, 64); info.user = sanitizeInfoString(payload.user); - info.monitors = payload.monitors; - info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo; + info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors; + 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.isAdmin = !!(payload as any).isAdmin; info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation; - if ((payload as any).permissions && typeof (payload as any).permissions === "object") { - info.permissions = (payload as any).permissions; + const cleanPermissions = sanitizeJsonField((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.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;