From b975907b2037a2e50ecd030e31a968fd225d06c9 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Fri, 29 May 2026 15:41:08 -0500 Subject: [PATCH] let mac re-prompt + modularize more code. --- Overlord-Client/cmd/agent/handlers/command.go | 3 + Overlord-Client/cmd/agent/handlers/files.go | 66 +- Overlord-Client/cmd/agent/wire/protocol.go | 17 +- Overlord-Server/public/assets/filebrowser.js | 57 +- Overlord-Server/src/client-db-sync.ts | 48 + Overlord-Server/src/db.ts | 2 +- Overlord-Server/src/main-server.ts | 94 +- Overlord-Server/src/protocol.ts | 4 + Overlord-Server/src/server/build-tag.ts | 30 + .../server/routes/client-command-routes.ts | 225 +++++ .../src/server/routes/client-group-routes.ts | 181 ++++ .../src/server/routes/client-routes.ts | 837 ++---------------- Overlord-Server/src/server/ws-chat.ts | 60 ++ .../server/ws-file-process-proxy-keylogger.ts | 4 + Overlord-Server/src/thumbnails.ts | 6 +- Overlord-Server/src/wsHandlers.ts | 52 +- 16 files changed, 770 insertions(+), 916 deletions(-) create mode 100644 Overlord-Server/src/client-db-sync.ts create mode 100644 Overlord-Server/src/server/build-tag.ts create mode 100644 Overlord-Server/src/server/routes/client-command-routes.ts create mode 100644 Overlord-Server/src/server/routes/client-group-routes.ts create mode 100644 Overlord-Server/src/server/ws-chat.ts diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 3b25e97..c1c1177 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2011,6 +2011,9 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in case "file_list": path, _ := envelopePayloadString(envelope, "path") return HandleFileList(ctx, env, cmdID, path) + case "file_request_access": + path, _ := envelopePayloadString(envelope, "path") + return HandleFileRequestAccess(ctx, env, cmdID, path) case "file_download": path, _ := envelopePayloadString(envelope, "path") return HandleFileDownload(ctx, env, cmdID, path) diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index 1dfe7bb..237b639 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -11,6 +11,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/hex" + "errors" "fmt" "hash" "hash/crc32" @@ -19,6 +20,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -124,16 +126,70 @@ func HandleFileList(ctx context.Context, env *agentRuntime.Env, cmdID string, pa } result := wire.FileListResult{ - Type: "file_list_result", - CommandID: cmdID, - Path: path, - Entries: entries, - Error: errMsg, + Type: "file_list_result", + CommandID: cmdID, + Path: path, + Entries: entries, + Error: errMsg, + AccessDenied: isFileAccessDenied(err), + CanRequestAccess: canRequestFolderAccess(err), + AccessHelp: folderAccessHelp(err), } return wire.WriteMsg(ctx, env.Conn, result) } +func isFileAccessDenied(err error) bool { + return err != nil && errors.Is(err, os.ErrPermission) +} + +func canRequestFolderAccess(err error) bool { + return runtime.GOOS == "darwin" && isFileAccessDenied(err) +} + +func folderAccessHelp(err error) string { + if !canRequestFolderAccess(err) { + return "" + } + return "macOS blocked this folder. Ask the user to allow access in Privacy & Security, then refresh this folder." +} + +func HandleFileRequestAccess(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error { + if runtime.GOOS != "darwin" { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: false, + Message: "folder access requests are only available on macOS", + }) + } + + if strings.TrimSpace(path) == "" { + path = "." + } + if absPath, err := filepath.Abs(path); err == nil { + path = absPath + } + + settingsURL := "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders" + if err := exec.CommandContext(ctx, "open", settingsURL).Run(); err != nil { + _ = exec.CommandContext(ctx, "open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Run() + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: false, + Message: fmt.Sprintf("could not open macOS Privacy settings: %v", err), + }) + } + + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: true, + Message: fmt.Sprintf("opened macOS Privacy settings for folder access; ask the user to allow access for this app, then refresh %s", path), + }) +} + func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string) error { entries := []wire.FileEntry{} diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 9c7ecc8..64581c3 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -113,17 +113,20 @@ type FileEntry struct { // windows file attributes Attrs uint32 `msgpack:"attrs,omitempty"` // Drive / volume disk usage (populated only at root listings for drive entries). - FreeBytes int64 `msgpack:"freeBytes,omitempty"` - TotalBytes int64 `msgpack:"totalBytes,omitempty"` + FreeBytes int64 `msgpack:"freeBytes,omitempty"` + TotalBytes int64 `msgpack:"totalBytes,omitempty"` FSType string `msgpack:"fsType,omitempty"` } type FileListResult struct { - Type string `msgpack:"type"` - CommandID string `msgpack:"commandId,omitempty"` - Path string `msgpack:"path"` - Entries []FileEntry `msgpack:"entries"` - Error string `msgpack:"error,omitempty"` + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Path string `msgpack:"path"` + Entries []FileEntry `msgpack:"entries"` + Error string `msgpack:"error,omitempty"` + AccessDenied bool `msgpack:"accessDenied,omitempty"` + CanRequestAccess bool `msgpack:"canRequestAccess,omitempty"` + AccessHelp string `msgpack:"accessHelp,omitempty"` } type FileDownload struct { diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index e35228b..44df093 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -1217,7 +1217,8 @@ function renderCurrentDirectory() { function handleFileList(msg) { if (msg.error) { clearVirtualizedListMode(); - fileListEl.innerHTML = `
${escapeHtml(msg.error)}
`; + currentPath = msg.path || currentPath; + renderFileListError(msg); updateDirectorySummaryAndPaging(0, 0); return; } @@ -1261,6 +1262,60 @@ function handleFileList(msg) { renderCurrentDirectory(); } +function isMacFolderAccessError(msg) { + if (!msg || !msg.error) return false; + if (msg.canRequestAccess || msg.accessDenied) return true; + if (detectedOS !== "mac") return false; + return /permission denied|operation not permitted|not permitted|access denied/i.test(msg.error); +} + +function renderFileListError(msg) { + const canRequestAccess = isMacFolderAccessError(msg); + const helpText = msg.accessHelp || "macOS blocked this folder. Ask the user to allow access in Privacy & Security, then refresh this folder."; + const actionHtml = canRequestAccess + ? `
+ + +
+
${escapeHtml(helpText)}
` + : ""; + + fileListEl.innerHTML = ` +
+ ${escapeHtml(msg.error)} + ${actionHtml} +
`; + + const requestBtn = document.getElementById("request-folder-access-btn"); + if (requestBtn) { + requestBtn.addEventListener("click", () => requestFolderAccess(msg.path || currentPath || ".")); + } + + const retryBtn = document.getElementById("retry-folder-access-btn"); + if (retryBtn) { + retryBtn.addEventListener("click", () => listFiles(msg.path || currentPath || ".")); + } +} + +function requestFolderAccess(path) { + const commandId = `file-access-${Date.now()}`; + send({ + type: "command", + commandType: "file_request_access", + id: commandId, + payload: { path: path || currentPath || "." }, + }); + trackCommandResult(commandId, { + successMessage: "Opened macOS Privacy settings. Ask the user to allow access, then retry.", + errorPrefix: "Access request failed", + }); + notifyToast("Asking the Mac to open Privacy settings...", "info", 3500); +} + function shouldShowParentDirectory(path) { if (!path || path === ".") { return false; diff --git a/Overlord-Server/src/client-db-sync.ts b/Overlord-Server/src/client-db-sync.ts new file mode 100644 index 0000000..446222f --- /dev/null +++ b/Overlord-Server/src/client-db-sync.ts @@ -0,0 +1,48 @@ +import { upsertClientRow, type ClientDbRow } from "./db"; + +export const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000); + +const lastClientDbSync = new Map(); +const pendingClientDbUpdates = new Map(); + +export function queueClientDbUpdate(partial: ClientDbRow): void { + const existing = pendingClientDbUpdates.get(partial.id); + if (!existing) { + pendingClientDbUpdates.set(partial.id, { ...partial }); + return; + } + pendingClientDbUpdates.set(partial.id, { + ...existing, + ...partial, + id: partial.id, + lastSeen: partial.lastSeen ?? existing.lastSeen, + online: partial.online ?? existing.online, + pingMs: partial.pingMs ?? existing.pingMs, + }); +} + +export function flushQueuedClientDbUpdates(): void { + if (pendingClientDbUpdates.size === 0) return; + for (const update of pendingClientDbUpdates.values()) { + upsertClientRow(update); + } + pendingClientDbUpdates.clear(); +} + +setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS); + +export function shouldSyncClientToDb(clientId: string, now: number): boolean { + const last = lastClientDbSync.get(clientId) || 0; + if (now - last < CLIENT_DB_SYNC_INTERVAL_MS) return false; + lastClientDbSync.set(clientId, now); + return true; +} + +export function markClientDbSynced(clientId: string, now: number): void { + lastClientDbSync.set(clientId, now); +} + +export function clearClientSyncState(clientId: string): void { + lastClientDbSync.delete(clientId); + pendingClientDbUpdates.delete(clientId); +} diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 35e1a81..39542db 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -1792,7 +1792,7 @@ export function saveSavedScript( scriptType: string, ): SavedScriptRecord | null { const owner = db - .query<{ user_id: number }, [string]>( + .query<{ user_id: number }>( `SELECT user_id FROM saved_scripts WHERE id = ?`, ) .get(id); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index f569529..41e4337 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, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications, isClientNotificationsMuted } from "./db"; +import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, deleteExpiredChatMessages, getClientMetricsSummary, pruneOldNotifications, isClientNotificationsMuted } from "./db"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -13,8 +13,7 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "./auth"; import { loadConfig, getConfig } from "./config"; import { flushAuditLogsSync } from "./auditLog"; -import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; -import { hasPermission } from "./rbac"; +import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient } from "./users"; import { requireAuth, requirePermission } from "./rbac"; import { metrics } from "./metrics"; import { ensureDataDir } from "./paths"; @@ -149,6 +148,8 @@ import * as clientManager from "./clientManager"; import * as sessionManager from "./sessions/sessionManager"; import type { SocketData } from "./sessions/types"; import { SERVER_VERSION } from "./version"; +import { handleChatViewerOpen, handleChatViewerMessage } from "./server/ws-chat"; +import { handleBuildTagConnection } from "./server/build-tag"; metrics.setSnapshotEnricher((snapshot) => { @@ -527,33 +528,6 @@ async function startServer() { registration: {}, }; - function handleBuildTagConnection( - clientId: string, - buildId: string | null, - builtByUserId: number | undefined, - keyFingerprint: string, - ) { - if (!buildId || !builtByUserId) return; - - const isFirstClaim = recordBuildClaim(buildId, keyFingerprint); - if (!isFirstClaim) return; - - const user = getUserById(builtByUserId); - if (!user) return; - - if (user.role === "admin") return; - - const currentScope = getUserClientAccessScope(builtByUserId); - if (currentScope === "none") { - setUserClientAccessScope(builtByUserId, "allowlist"); - } - - if (currentScope === "none" || currentScope === "allowlist") { - setUserClientAccessRule(builtByUserId, clientId, "allow"); - logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${buildId.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`); - } - } - const lifecycleDeps = { maxClientPayloadBytes: MAX_WS_MESSAGE_BYTES_CLIENT, maxViewerPayloadBytes: MAX_WS_MESSAGE_BYTES_VIEWER, @@ -584,54 +558,8 @@ async function startServer() { }); }, handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen, - handleChatViewerOpen: (ws: import("bun").ServerWebSocket) => { - const id = crypto.randomUUID(); - ws.data.sessionId = id; - const userId = ws.data.userId; - const userRole = ws.data.userRole || "viewer"; - const user = userId ? getUserById(userId) : null; - const username = user?.username || "Unknown"; - sessionManager.addChatSession({ - id, - viewer: ws, - createdAt: Date.now(), - userId: userId || 0, - username, - userRole, - }); - const retDays = getConfig().chat?.retentionDays ?? 30; - const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined; - const history = getChatHistory(undefined, 50, retMs); - const enrichedHistory = history.map((m) => ({ - ...m, - onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0, - })); - const canWrite = userId ? hasPermission(userRole as any, "chat:write", userId) : false; - ws.send(JSON.stringify({ type: "chat_ready", history: enrichedHistory, canWrite, userId: userId || 0 })); - }, - handleChatViewerMessage: (ws: import("bun").ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => { - try { - const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw as ArrayBuffer); - const parsed = JSON.parse(text); - if (parsed.type !== "chat_send" || typeof parsed.message !== "string") return; - const userId = ws.data.userId; - const userRole = ws.data.userRole || "viewer"; - if (!userId || !hasPermission(userRole as any, "chat:write", userId)) { - ws.send(JSON.stringify({ type: "chat_error", error: "Permission denied" })); - return; - } - const msg = parsed.message.trim(); - if (!msg || msg.length > 2000) { - ws.send(JSON.stringify({ type: "chat_error", error: msg ? "Message too long (max 2000 chars)" : "Message is empty" })); - return; - } - const user = getUserById(userId); - const username = user?.username || "Unknown"; - const record = insertChatMessage(userId, username, userRole, msg); - const onlineClients = getOnlineClientCountForUser(userId); - sessionManager.broadcastChatMessage(JSON.stringify({ type: "chat_message", ...record, onlineClients })); - } catch {} - }, + handleChatViewerOpen, + handleChatViewerMessage, handleConsoleViewerMessage, handleRemoteDesktopViewerMessage, handleWebcamViewerMessage, @@ -718,7 +646,10 @@ async function startServer() { metrics, CORS_HEADERS, routes: [ - (req, url) => handleRegistrationRoutes(req, url, routeDeps.registration), + (req, url, srv) => handleRegistrationRoutes(req, url, { + ...routeDeps.registration, + requestIP: (srv as any).requestIP, + }), (req, url, srv) => handleAuthRoutes(req, url, srv as any), (req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig), (req, url) => handleAutoScriptsRoutes(req, url), @@ -735,7 +666,10 @@ async function startServer() { (req, url, srv) => handleFileDownloadRoutes(req, url, srv as any, routeDeps.fileDownload), (req, url) => handlePluginRoutes(req, url, routeDeps.plugin), (req, url) => handleFileShareRoutes(req, url, routeDeps.fileShare), - (req, url) => handleMiscRoutes(req, url, routeDeps.misc), + (req, url, srv) => handleMiscRoutes(req, url, { + ...routeDeps.misc, + requestIP: (srv as any).requestIP, + }), (req, url) => handleAssetsRoutes(req, url, routeDeps.assets), (req, url) => handlePageRoutes(req, url, routeDeps.page), (req, url) => handleWebrtcRoutes(req, url), diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 574ecbb..0882567 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -75,6 +75,7 @@ export type CommandType = | "console_stop" | "console_resize" | "file_list" + | "file_request_access" | "file_download" | "file_upload" | "file_delete" @@ -238,6 +239,9 @@ export type FileListResult = { path: string; entries: FileEntry[]; error?: string; + accessDenied?: boolean; + canRequestAccess?: boolean; + accessHelp?: string; }; export type FileDownload = { diff --git a/Overlord-Server/src/server/build-tag.ts b/Overlord-Server/src/server/build-tag.ts new file mode 100644 index 0000000..b50e0b1 --- /dev/null +++ b/Overlord-Server/src/server/build-tag.ts @@ -0,0 +1,30 @@ +import { recordBuildClaim } from "../db"; +import { getUserById, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "../users"; +import { logger } from "../logger"; + +export function handleBuildTagConnection( + clientId: string, + buildId: string | null, + builtByUserId: number | undefined, + keyFingerprint: string, +): void { + if (!buildId || !builtByUserId) return; + + const isFirstClaim = recordBuildClaim(buildId, keyFingerprint); + if (!isFirstClaim) return; + + const user = getUserById(builtByUserId); + if (!user) return; + + if (user.role === "admin") return; + + const currentScope = getUserClientAccessScope(builtByUserId); + if (currentScope === "none") { + setUserClientAccessScope(builtByUserId, "allowlist"); + } + + if (currentScope === "none" || currentScope === "allowlist") { + setUserClientAccessRule(builtByUserId, clientId, "allow"); + logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${buildId.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`); + } +} diff --git a/Overlord-Server/src/server/routes/client-command-routes.ts b/Overlord-Server/src/server/routes/client-command-routes.ts new file mode 100644 index 0000000..5926f2f --- /dev/null +++ b/Overlord-Server/src/server/routes/client-command-routes.ts @@ -0,0 +1,225 @@ +import { v4 as uuidv4 } from "uuid"; +import { authenticateRequest } from "../../auth"; +import { AuditAction, logAudit } from "../../auditLog"; +import * as clientManager from "../../clientManager"; +import { deleteClientRow } from "../../db"; +import { metrics } from "../../metrics"; +import { encodeMessage } from "../../protocol"; +import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac"; +import { clearThumbnail } from "../../thumbnails"; + +type RequestIpProvider = { + requestIP: (req: Request) => { address?: string } | null | undefined; +}; + +type PendingScript = { + resolve: (result: any) => void; + reject: (error: any) => void; + timeout: NodeJS.Timeout; + clientId: string; +}; + +type PendingCommandReply = { + resolve: (result: { ok: boolean; message?: string }) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + clientId: string; +}; + +type ClientCommandDeps = { + CORS_HEADERS: Record; + pendingScripts: Map; + pendingCommandReplies: Map; +}; + +export async function handleClientCommandRoute( + req: Request, + url: URL, + server: RequestIpProvider, + deps: ClientCommandDeps, +): Promise { + if (req.method !== "POST") return null; + const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/); + if (!cmdMatch) return null; + + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + try { + requirePermission(user, "clients:control"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + const targetId = cmdMatch[1]; + try { + requireClientAccess(user, targetId); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + const target = clientManager.getClient(targetId); + const ip = server.requestIP(req)?.address || "unknown"; + + if (!target) return new Response("Not found", { status: 404 }); + + try { + const body = await req.json(); + const action = body?.action; + let success = true; + + if (action === "ping") { + const nonce = Date.now() + Math.floor(Math.random() * 1000); + target.lastPingSent = Date.now(); + target.lastPingNonce = nonce; + target.ws.send(encodeMessage({ type: "ping", ts: nonce })); + } else if (action === "ping_bulk") { + Math.max(1, Math.min(1000, Number(body?.count || 1))); + } else if (action === "disconnect") { + try { + requirePermission(user, "clients:disconnect"); + requireFeatureAccess(user, "disconnect"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() })); + metrics.recordCommand("disconnect"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.DISCONNECT, targetClientId: targetId, success: true }); + } else if (action === "reconnect") { + try { + requirePermission(user, "clients:reconnect"); + requireFeatureAccess(user, "reconnect"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() })); + metrics.recordCommand("reconnect"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.RECONNECT, targetClientId: targetId, success: true }); + } else if (action === "screenshot") { + target.ws.send(encodeMessage({ type: "command", commandType: "screenshot", id: uuidv4(), payload: { mode: "notification", allDisplays: true } })); + metrics.recordCommand("screenshot"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SCREENSHOT, targetClientId: targetId, success: true }); + } else if (action === "desktop_start") { + target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() })); + metrics.recordCommand("desktop_start"); + } else if (action === "script_exec") { + const scriptContent = body?.script || ""; + const scriptType = body?.scriptType || "powershell"; + const cmdId = uuidv4(); + + const resultPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingScripts.delete(cmdId); + reject(new Error("Script execution timed out after 5 minutes")); + }, 5 * 60 * 1000); + deps.pendingScripts.set(cmdId, { resolve, reject, timeout, clientId: targetId }); + }); + + target.ws.send(encodeMessage({ type: "command", commandType: "script_exec", id: cmdId, payload: { script: scriptContent, type: scriptType } })); + metrics.recordCommand("script_exec"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SCRIPT_EXECUTE, targetClientId: targetId, success: true, details: `script_exec (${scriptType})` }); + + try { + const result = await resultPromise; + return Response.json(result); + } catch (error: any) { + return Response.json({ ok: false, error: error.message }, { status: 500 }); + } + } else if (action === "voice_capabilities") { + const cmdId = uuidv4(); + const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + reject(new Error("Voice capability probe timed out")); + }, 30_000); + deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId }); + }); + + target.ws.send(encodeMessage({ type: "command", commandType: "voice_capabilities", id: cmdId })); + + try { + const result = await replyPromise; + let caps: any = null; + if (result.message) { + try { caps = JSON.parse(result.message); } catch { caps = null; } + } + return Response.json({ ok: result.ok, capabilities: caps, response: result.message || "" }, { headers: deps.CORS_HEADERS }); + } catch (error: any) { + return Response.json({ ok: false, error: error.message || "Voice capability probe failed" }, { status: 504 }); + } + } else if (action === "silent_exec") { + try { + requirePermission(user, "clients:silent-exec"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + const command = typeof body?.command === "string" ? body.command.trim() : ""; + const args = typeof body?.args === "string" ? body.args : ""; + const cwd = typeof body?.cwd === "string" ? body.cwd : ""; + + if (!command) return new Response("Bad request", { status: 400 }); + + const cmdId = uuidv4(); + target.ws.send(encodeMessage({ type: "command", commandType: "silent_exec", id: cmdId, payload: { command, args, cwd } })); + metrics.recordCommand("silent_exec"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SILENT_EXECUTE, targetClientId: targetId, success: true, details: JSON.stringify({ command, args, cwd }) }); + } else if (action === "uninstall") { + try { + requirePermission(user, "clients:uninstall"); + requireFeatureAccess(user, "uninstall"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() })); + metrics.recordCommand("uninstall"); + clientManager.deleteClient(targetId); + deleteClientRow(targetId); + clearThumbnail(targetId); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.UNINSTALL, targetClientId: targetId, details: "Agent uninstall requested - persistence will be removed", success: true }); + } else if (action === "elevate") { + try { + requirePermission(user, "clients:elevate"); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + const password = typeof body?.password === "string" ? body.password : ""; + const cmdId = uuidv4(); + const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + reject(new Error("Elevation timed out")); + }, 30_000); + deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId }); + }); + + target.ws.send(encodeMessage({ type: "command", commandType: "elevate", id: cmdId, payload: { password } })); + metrics.recordCommand("elevate"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, success: true, details: "elevate" }); + + try { + const result = await replyPromise; + return Response.json({ ok: result.ok, message: result.message || "" }, { headers: deps.CORS_HEADERS }); + } catch (error: any) { + return Response.json({ ok: false, error: error.message || "Elevation failed" }, { status: 504 }); + } + } else { + success = false; + return new Response("Bad request", { status: 400 }); + } + + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: action, success }); + return Response.json({ ok: true }); + } catch (error) { + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, success: false, errorMessage: String(error) }); + return new Response("Bad request", { status: 400 }); + } +} diff --git a/Overlord-Server/src/server/routes/client-group-routes.ts b/Overlord-Server/src/server/routes/client-group-routes.ts new file mode 100644 index 0000000..7d6d19f --- /dev/null +++ b/Overlord-Server/src/server/routes/client-group-routes.ts @@ -0,0 +1,181 @@ +import { authenticateRequest } from "../../auth"; +import { AuditAction, logAudit } from "../../auditLog"; +import { + clientExists, + listGroups, + getGroup, + createGroup, + updateGroup, + deleteGroup, + setClientGroup, +} from "../../db"; +import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac"; +import { canUserAccessClient } from "../../users"; +import { notifyDashboardViewers } from "../../sessions/sessionManager"; + +type RequestIpProvider = { + requestIP: (req: Request) => { address?: string } | null | undefined; +}; + +type ClientGroupDeps = { + CORS_HEADERS: Record; +}; + +export async function handleClientGroupRoutes( + req: Request, + url: URL, + server: RequestIpProvider, + deps: ClientGroupDeps, +): Promise { + if (!url.pathname.startsWith("/api/groups") && + url.pathname !== "/api/clients/bulk-group" && + !url.pathname.match(/^\/api\/clients\/[^/]+\/group$/)) { + return null; + } + + if (url.pathname === "/api/groups") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + if (req.method === "GET") { + return Response.json({ groups: listGroups() }, { headers: deps.CORS_HEADERS }); + } + + if (req.method === "POST") { + try { requirePermission(user, "clients:control"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const color = typeof body?.color === "string" ? body.color.trim() : "#3b82f6"; + if (!name || name.length > 64) { + return Response.json({ error: "Name is required and must be 64 characters or fewer" }, { status: 400 }); + } + if (!/^#[0-9a-fA-F]{6}$/.test(color)) { + return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 }); + } + const group = createGroup(name, color); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `create_group:${name}`, success: true }); + return Response.json(group, { status: 201, headers: deps.CORS_HEADERS }); + } + } + + const groupIdMatch = url.pathname.match(/^\/api\/groups\/(\d+)$/); + if (groupIdMatch) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { requirePermission(user, "clients:control"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + const groupId = Number(groupIdMatch[1]); + + if (req.method === "PATCH") { + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const existing = getGroup(groupId); + if (!existing) return Response.json({ error: "Group not found" }, { status: 404 }); + const name = typeof body?.name === "string" ? body.name.trim() : existing.name; + const color = typeof body?.color === "string" ? body.color.trim() : existing.color; + if (name.length === 0 || name.length > 64) { + return Response.json({ error: "Name must be 1-64 characters" }, { status: 400 }); + } + if (!/^#[0-9a-fA-F]{6}$/.test(color)) { + return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 }); + } + const updated = updateGroup(groupId, name, color); + if (!updated) return Response.json({ error: "Group not found" }, { status: 404 }); + notifyDashboardViewers(); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `update_group:${groupId}`, success: true }); + return Response.json({ ok: true, id: groupId, name, color }, { headers: deps.CORS_HEADERS }); + } + + if (req.method === "DELETE") { + const deleted = deleteGroup(groupId); + if (!deleted) return Response.json({ error: "Group not found" }, { status: 404 }); + notifyDashboardViewers(); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `delete_group:${groupId}`, success: true }); + return Response.json({ ok: true }, { headers: deps.CORS_HEADERS }); + } + } + + if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const clientIds = body?.clientIds; + if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) { + return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 }); + } + if (clientIds.length > 500) { + return Response.json({ error: "Too many clients (max 500)" }, { status: 400 }); + } + const groupId = body?.groupId === null ? null : Number(body?.groupId); + if (groupId !== null && (isNaN(groupId) || groupId < 1)) { + return Response.json({ error: "Invalid groupId" }, { status: 400 }); + } + let updated = 0; + for (const cid of clientIds) { + if (!canUserAccessClient(user.userId, user.role, cid)) continue; + if (setClientGroup(cid, groupId)) updated++; + } + notifyDashboardViewers(); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_group:${groupId ?? "none"}:${updated}/${clientIds.length}`, success: true }); + return Response.json({ ok: true, updated, total: clientIds.length, groupId }, { headers: deps.CORS_HEADERS }); + } + + const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/); + if (req.method === "PATCH" && clientGroupMatch) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + const targetId = clientGroupMatch[1]; + try { + requireClientAccess(user, targetId); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + if (!clientExists(targetId)) { + return Response.json({ error: "Client not found" }, { status: 404 }); + } + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const groupId = body?.groupId === null ? null : Number(body?.groupId); + if (groupId !== null && (isNaN(groupId) || groupId < 1)) { + return Response.json({ error: "Invalid groupId" }, { status: 400 }); + } + const updated = setClientGroup(targetId, groupId); + if (!updated) { + return Response.json({ error: "Client not found or group does not exist" }, { status: 404 }); + } + notifyDashboardViewers(); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: groupId ? `set_group:${groupId}` : "clear_group", success: true }); + return Response.json({ ok: true, groupId }, { headers: deps.CORS_HEADERS }); + } + + return null; +} diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 86a86f4..b248ec1 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -19,12 +19,6 @@ import { setClientTag, setOnlineState, unbanIp, - listGroups, - getGroup, - createGroup, - updateGroup, - deleteGroup, - setClientGroup, deleteNotificationsForClient, } from "../../db"; import { metrics } from "../../metrics"; @@ -37,6 +31,8 @@ import { } from "../../users"; import { notifyDashboardViewers } from "../../sessions/sessionManager"; import { clearThumbnail } from "../../thumbnails"; +import { handleClientCommandRoute } from "./client-command-routes"; +import { handleClientGroupRoutes } from "./client-group-routes"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -69,11 +65,7 @@ export async function handleClientRoutes( server: RequestIpProvider, deps: ClientRouteDeps, ): Promise { - if ( - !url.pathname.startsWith("/api/clients") && - !url.pathname.startsWith("/api/groups") && - !url.pathname.match(/^\/api\/clients\/.+\/command$/) - ) { + if (!url.pathname.startsWith("/api/clients") && !url.pathname.startsWith("/api/groups")) { return null; } @@ -92,72 +84,38 @@ export async function handleClientRoutes( const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved"; const groupFilter = url.searchParams.get("group") || "all"; const isEnrollmentRequest = url.searchParams.has("enrollmentFilter"); + if (user.role === "admin") { const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter }); return Response.json(result, { headers: deps.CORS_HEADERS }); } if (user.role === "operator" && isEnrollmentRequest) { - const result = listClients({ - page, - pageSize, - search, - sort, - statusFilter, - osFilter, - countryFilter, - enrollmentFilter, - groupFilter, - builtByUserId: user.userId, - requireBuildOwner: true, - }); + const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, builtByUserId: user.userId, requireBuildOwner: true }); return Response.json(result, { headers: deps.CORS_HEADERS }); } const scope = getUserClientAccessScope(user.userId); if (scope === "none") { - return Response.json( - { page, pageSize, total: 0, online: 0, items: [] }, - { headers: deps.CORS_HEADERS }, - ); + return Response.json({ page, pageSize, total: 0, online: 0, items: [] }, { headers: deps.CORS_HEADERS }); } - const allowedClientIds = - scope === "allowlist" - ? listUserClientRuleIdsByAccess(user.userId, "allow") - : undefined; - const deniedClientIds = - scope === "denylist" - ? listUserClientRuleIdsByAccess(user.userId, "deny") - : undefined; + const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : undefined; + const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined; - const result = listClients({ - page, - pageSize, - search, - sort, - statusFilter, - osFilter, - countryFilter, - enrollmentFilter, - groupFilter, - allowedClientIds, - deniedClientIds, - }); + const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, allowedClientIds, deniedClientIds }); return Response.json(result, { headers: deps.CORS_HEADERS }); } if (url.pathname === "/api/clients/countries") { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - const countries = listDistinctCountries(); - return Response.json({ countries }, { headers: deps.CORS_HEADERS }); + return Response.json({ countries: listDistinctCountries() }, { headers: deps.CORS_HEADERS }); } if (url.pathname === "/api/clients/banned-ips") { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - try { requirePermission(user, "network:manage-bans"); } catch (error) { @@ -171,30 +129,13 @@ export async function handleClientRoutes( if (req.method === "DELETE") { const ipToUnban = (url.searchParams.get("ip") || "").trim(); - if (!ipToUnban) { - return Response.json({ error: "Missing ip query parameter" }, { status: 400 }); - } - - if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) { - return Response.json({ error: "Invalid IP format" }, { status: 400 }); - } - - if (!isIpBanned(ipToUnban)) { - return Response.json({ error: "IP is not banned" }, { status: 404 }); - } + if (!ipToUnban) return Response.json({ error: "Missing ip query parameter" }, { status: 400 }); + if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) return Response.json({ error: "Invalid IP format" }, { status: 400 }); + if (!isIpBanned(ipToUnban)) return Response.json({ error: "IP is not banned" }, { status: 404 }); unbanIp(ipToUnban); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - details: `Unbanned IP ${ipToUnban}`, - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `Unbanned IP ${ipToUnban}`, success: true }); return Response.json({ ok: true }, { headers: deps.CORS_HEADERS }); } } @@ -203,59 +144,35 @@ export async function handleClientRoutes( if (req.method === "POST" && banMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - try { - requirePermission(user, "clients:control"); - } catch (error) { + try { requirePermission(user, "clients:control"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const targetId = banMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } const target = clientManager.getClient(targetId); const targetIp = target?.ip || getClientIp(targetId); - if (!targetIp) { - return Response.json({ error: "Client IP not found" }, { status: 404 }); - } + if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 }); banIp(targetIp, `Banned by ${user.username} for client ${targetId}`); - if (target) { - try { - target.ws.close(4003, "banned"); - } catch {} + try { target.ws.close(4003, "banned"); } catch { } setOnlineState(targetId, false); } - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: `Banned IP ${targetIp}`, - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: `Banned IP ${targetIp}`, success: true }); return Response.json({ ok: true, ip: targetIp }); } const thumbnailMatch = url.pathname.match(/^\/api\/clients\/(.+)\/thumbnail$/); if (req.method === "POST" && thumbnailMatch) { const user = await authenticateRequest(req); - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } + if (!user) return new Response("Unauthorized", { status: 401 }); const clientId = thumbnailMatch[1]; - try { - requireClientAccess(user, clientId); - } catch (error) { + try { requireClientAccess(user, clientId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } @@ -265,14 +182,7 @@ export async function handleClientRoutes( const beforeVersion = getThumbnailVersion(clientId); if (target?.online) { const commandId = uuidv4(); - target.ws.send( - encodeMessage({ - type: "command", - commandType: "screenshot", - id: commandId, - payload: { mode: "notification", allDisplays: true }, - }), - ); + target.ws.send(encodeMessage({ type: "command", commandType: "screenshot", id: commandId, payload: { mode: "notification", allDisplays: true } })); metrics.recordCommand("screenshot"); } const fresh = target?.online ? await waitForThumbnail(clientId, 2500) : false; @@ -283,63 +193,37 @@ export async function handleClientRoutes( } clearThumbnailRequest(clientId); const version = getThumbnailVersion(clientId); - return Response.json( - { ok: true, updated: version > beforeVersion, version }, - { headers: deps.CORS_HEADERS }, - ); + return Response.json({ ok: true, updated: version > beforeVersion, version }, { headers: deps.CORS_HEADERS }); } if (req.method === "GET" && thumbnailMatch) { const user = await authenticateRequest(req); - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } + if (!user) return new Response("Unauthorized", { status: 401 }); const clientId = thumbnailMatch[1]; - try { - requireClientAccess(user, clientId); - } catch (error) { + try { requireClientAccess(user, clientId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } const { getThumbnailRecord } = await import("../../thumbnails"); const record = getThumbnailRecord(clientId); - if (!record) { - return new Response("Not Found", { status: 404 }); - } + if (!record) return new Response("Not Found", { status: 404 }); return new Response(record.bytes as unknown as BodyInit, { status: 200, - headers: { - "Content-Type": record.contentType, - "Content-Length": String(record.bytes.byteLength), - "Cache-Control": "no-store", - }, + headers: { "Content-Type": record.contentType, "Content-Length": String(record.bytes.byteLength), "Cache-Control": "no-store" }, }); } if (req.method === "DELETE" && url.pathname === "/api/clients/offline") { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - - try { - requirePermission(user, "clients:control"); - } catch (error) { + try { requirePermission(user, "clients:control"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const count = deleteOfflineClientRows(); notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - details: `wipe_offline_clients: removed ${count}`, - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `wipe_offline_clients: removed ${count}`, success: true }); return Response.json({ ok: true, count }, { headers: deps.CORS_HEADERS }); } @@ -347,59 +231,33 @@ export async function handleClientRoutes( if (req.method === "DELETE" && clientDeleteMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - - try { - requirePermission(user, "clients:control"); - } catch (error) { + try { requirePermission(user, "clients:control"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const targetId = clientDeleteMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } const target = clientManager.getClient(targetId); const isOnlineInDb = getClientOnlineState(targetId); if (target?.online || isOnlineInDb === true) { - return Response.json( - { error: "Client is online. Remove from dashboard is only allowed for offline clients." }, - { status: 409 }, - ); + return Response.json({ error: "Client is online. Remove from dashboard is only allowed for offline clients." }, { status: 409 }); } const existsInDb = clientExists(targetId); - if (!target && !existsInDb) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } + if (!target && !existsInDb) return Response.json({ error: "Client not found" }, { status: 404 }); if (target) { - try { - target.ws.close(4000, "removed"); - } catch { - // Connection may already be closed. - } + try { target.ws.close(4000, "removed"); } catch { } clientManager.deleteClient(targetId); setOnlineState(targetId, false); } - deleteClientRow(targetId); clearThumbnail(targetId); notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: "remove_dashboard", - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: "remove_dashboard", success: true }); return Response.json({ ok: true }, { headers: deps.CORS_HEADERS }); } @@ -407,60 +265,27 @@ export async function handleClientRoutes( if (req.method === "PATCH" && clientNicknameMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - - try { - requirePermission(user, "clients:metadata"); - requireFeatureAccess(user, "client_metadata"); - } catch (error) { + try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const targetId = clientNicknameMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - if (!clientExists(targetId)) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - + if (!clientExists(targetId)) return Response.json({ error: "Client not found" }, { status: 404 }); let body: any = {}; - try { - body = await req.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const rawNickname = typeof body?.nickname === "string" ? body.nickname : ""; const trimmed = rawNickname.trim(); - if (trimmed.length > 64) { - return Response.json( - { error: "Nickname must be 64 characters or fewer" }, - { status: 400 }, - ); - } - + if (trimmed.length > 64) return Response.json({ error: "Nickname must be 64 characters or fewer" }, { status: 400 }); const nickname = trimmed.length ? trimmed : null; const updated = setClientNickname(targetId, nickname); - if (!updated) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } + if (!updated) return Response.json({ error: "Client not found" }, { status: 404 }); notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: nickname ? `set_nickname:${nickname}` : "clear_nickname", - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: nickname ? `set_nickname:${nickname}` : "clear_nickname", success: true }); return Response.json({ ok: true, nickname }, { headers: deps.CORS_HEADERS }); } @@ -468,68 +293,30 @@ export async function handleClientRoutes( if (req.method === "PATCH" && clientTagMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - - try { - requirePermission(user, "clients:metadata"); - requireFeatureAccess(user, "client_metadata"); - } catch (error) { + try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const targetId = clientTagMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - if (!clientExists(targetId)) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - + if (!clientExists(targetId)) return Response.json({ error: "Client not found" }, { status: 404 }); let body: any = {}; - try { - body = await req.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const rawTag = typeof body?.tag === "string" ? body.tag : ""; const rawNote = typeof body?.note === "string" ? body.note : ""; const tag = rawTag.trim(); - if (tag.length > 64) { - return Response.json( - { error: "Tag must be 64 characters or fewer" }, - { status: 400 }, - ); - } - + if (tag.length > 64) return Response.json({ error: "Tag must be 64 characters or fewer" }, { status: 400 }); const normalizedTag = tag.length ? tag : null; const note = normalizedTag ? rawNote : null; const updated = setClientTag(targetId, normalizedTag, note); - if (!updated) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } + if (!updated) return Response.json({ error: "Client not found" }, { status: 404 }); notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: normalizedTag - ? `set_custom_tag:${normalizedTag} (note_len=${note?.length || 0})` - : "clear_custom_tag", - success: true, - }); - - return Response.json( - { ok: true, tag: normalizedTag, note: note ?? null }, - { headers: deps.CORS_HEADERS }, - ); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: normalizedTag ? `set_custom_tag:${normalizedTag} (note_len=${note?.length || 0})` : "clear_custom_tag", success: true }); + return Response.json({ ok: true, tag: normalizedTag, note: note ?? null }, { headers: deps.CORS_HEADERS }); } if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-notifications-muted") { @@ -539,21 +326,14 @@ export async function handleClientRoutes( if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const clientIds = body?.clientIds; if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) { return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 }); } - if (clientIds.length > 500) { - return Response.json({ error: "Too many clients (max 500)" }, { status: 400 }); - } + if (clientIds.length > 500) return Response.json({ error: "Too many clients (max 500)" }, { status: 400 }); const muted = !!body?.muted; - let updated = 0; let cleared = 0; for (const cid of clientIds) { @@ -562,7 +342,6 @@ export async function handleClientRoutes( cleared += deleteNotificationsForClient(cid); deps.broadcastNotificationsCleared(cid); } - notifyDashboardViewers(); const ip = server.requestIP(req)?.address || "unknown"; logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_notifications_muted:${muted}:${updated}/${clientIds.length} (cleared ${cleared})`, success: true }); @@ -577,43 +356,22 @@ export async function handleClientRoutes( if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - const targetId = muteMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - if (!clientExists(targetId)) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - + if (!clientExists(targetId)) return Response.json({ error: "Client not found" }, { status: 404 }); let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const muted = !!body?.muted; const updated = setClientNotificationsMuted(targetId, muted); - if (!updated) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } + if (!updated) return Response.json({ error: "Client not found" }, { status: 404 }); const cleared = deleteNotificationsForClient(targetId); deps.broadcastNotificationsCleared(targetId); notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: `${muted ? "mute_notifications" : "unmute_notifications"} (cleared ${cleared})`, - success: true, - }); - + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: `${muted ? "mute_notifications" : "unmute_notifications"} (cleared ${cleared})`, success: true }); return Response.json({ ok: true, muted, cleared }, { headers: deps.CORS_HEADERS }); } @@ -621,490 +379,25 @@ export async function handleClientRoutes( if (req.method === "PATCH" && bookmarkMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - const targetId = bookmarkMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { + try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - if (!clientExists(targetId)) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - + if (!clientExists(targetId)) return Response.json({ error: "Client not found" }, { status: 404 }); let body: any = {}; - try { - body = await req.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const bookmarked = !!body?.bookmarked; const updated = setClientBookmark(targetId, bookmarked); - if (!updated) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - + if (!updated) return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ ok: true, bookmarked }, { headers: deps.CORS_HEADERS }); } - if (req.method === "POST") { - const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/); - if (cmdMatch) { - const user = await authenticateRequest(req); - if (!user) return new Response("Unauthorized", { status: 401 }); + const cmdResult = await handleClientCommandRoute(req, url, server, deps); + if (cmdResult !== null) return cmdResult; - try { - requirePermission(user, "clients:control"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const targetId = cmdMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - const target = clientManager.getClient(targetId); - const ip = server.requestIP(req)?.address || "unknown"; - - if (!target) return new Response("Not found", { status: 404 }); - try { - const body = await req.json(); - const action = body?.action; - - let success = true; - if (action === "ping") { - const nonce = Date.now() + Math.floor(Math.random() * 1000); - target.lastPingSent = Date.now(); - target.lastPingNonce = nonce; - target.ws.send(encodeMessage({ type: "ping", ts: nonce })); - } else if (action === "ping_bulk") { - const count = Math.max(1, Math.min(1000, Number(body?.count || 1))); - for (let i = 0; i < count; i++) { - } - } else if (action === "disconnect") { - try { - requirePermission(user, "clients:disconnect"); - requireFeatureAccess(user, "disconnect"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() })); - metrics.recordCommand("disconnect"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.DISCONNECT, - targetClientId: targetId, - success: true, - }); - } else if (action === "reconnect") { - try { - requirePermission(user, "clients:reconnect"); - requireFeatureAccess(user, "reconnect"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() })); - metrics.recordCommand("reconnect"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.RECONNECT, - targetClientId: targetId, - success: true, - }); - } else if (action === "screenshot") { - target.ws.send( - encodeMessage({ - type: "command", - commandType: "screenshot", - id: uuidv4(), - payload: { mode: "notification", allDisplays: true }, - }), - ); - metrics.recordCommand("screenshot"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.SCREENSHOT, - targetClientId: targetId, - success: true, - }); - } else if (action === "desktop_start") { - target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() })); - metrics.recordCommand("desktop_start"); - } else if (action === "script_exec") { - const scriptContent = body?.script || ""; - const scriptType = body?.scriptType || "powershell"; - const cmdId = uuidv4(); - - const resultPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - deps.pendingScripts.delete(cmdId); - reject(new Error("Script execution timed out after 5 minutes")); - }, 5 * 60 * 1000); - - deps.pendingScripts.set(cmdId, { resolve, reject, timeout, clientId: targetId }); - }); - - target.ws.send(encodeMessage({ - type: "command", - commandType: "script_exec", - id: cmdId, - payload: { script: scriptContent, type: scriptType }, - })); - - metrics.recordCommand("script_exec"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.SCRIPT_EXECUTE, - targetClientId: targetId, - success: true, - details: `script_exec (${scriptType})`, - }); - - try { - const result = await resultPromise; - return Response.json(result); - } catch (error: any) { - return Response.json({ ok: false, error: error.message }, { status: 500 }); - } - } else if (action === "voice_capabilities") { - const cmdId = uuidv4(); - const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - deps.pendingCommandReplies.delete(cmdId); - reject(new Error("Voice capability probe timed out")); - }, 30_000); - deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId }); - }); - - target.ws.send( - encodeMessage({ - type: "command", - commandType: "voice_capabilities", - id: cmdId, - }), - ); - - try { - const result = await replyPromise; - let caps: any = null; - if (result.message) { - try { - caps = JSON.parse(result.message); - } catch { - caps = null; - } - } - return Response.json({ ok: result.ok, capabilities: caps, response: result.message || "" }, { headers: deps.CORS_HEADERS }); - } catch (error: any) { - return Response.json({ ok: false, error: error.message || "Voice capability probe failed" }, { status: 504 }); - } - } else if (action === "silent_exec") { - try { - requirePermission(user, "clients:silent-exec"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const command = typeof body?.command === "string" ? body.command.trim() : ""; - const args = typeof body?.args === "string" ? body.args : ""; - const cwd = typeof body?.cwd === "string" ? body.cwd : ""; - - if (!command) { - return new Response("Bad request", { status: 400 }); - } - - const cmdId = uuidv4(); - target.ws.send( - encodeMessage({ - type: "command", - commandType: "silent_exec", - id: cmdId, - payload: { command, args, cwd }, - }), - ); - metrics.recordCommand("silent_exec"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.SILENT_EXECUTE, - targetClientId: targetId, - success: true, - details: JSON.stringify({ command, args, cwd }), - }); - } else if (action === "uninstall") { - try { - requirePermission(user, "clients:uninstall"); - requireFeatureAccess(user, "uninstall"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() })); - metrics.recordCommand("uninstall"); - clientManager.deleteClient(targetId); - deleteClientRow(targetId); - clearThumbnail(targetId); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.UNINSTALL, - targetClientId: targetId, - details: "Agent uninstall requested - persistence will be removed", - success: true, - }); - } else if (action === "elevate") { - try { - requirePermission(user, "clients:elevate"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const password = typeof body?.password === "string" ? body.password : ""; - - const cmdId = uuidv4(); - const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - deps.pendingCommandReplies.delete(cmdId); - reject(new Error("Elevation timed out")); - }, 30_000); - deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId }); - }); - - target.ws.send( - encodeMessage({ - type: "command", - commandType: "elevate", - id: cmdId, - payload: { password }, - }), - ); - metrics.recordCommand("elevate"); - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - success: true, - details: "elevate", - }); - - try { - const result = await replyPromise; - return Response.json({ ok: result.ok, message: result.message || "" }, { headers: deps.CORS_HEADERS }); - } catch (error: any) { - return Response.json({ ok: false, error: error.message || "Elevation failed" }, { status: 504 }); - } - } else { - success = false; - return new Response("Bad request", { status: 400 }); - } - - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - details: action, - success, - }); - - return Response.json({ ok: true }); - } catch (error) { - logAudit({ - timestamp: Date.now(), - username: user.username, - ip, - action: AuditAction.COMMAND, - targetClientId: targetId, - success: false, - errorMessage: String(error), - }); - return new Response("Bad request", { status: 400 }); - } - } - } - - if (url.pathname === "/api/groups") { - const user = await authenticateRequest(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - - if (req.method === "GET") { - return Response.json({ groups: listGroups() }, { headers: deps.CORS_HEADERS }); - } - - if (req.method === "POST") { - try { requirePermission(user, "clients:control"); } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const name = typeof body?.name === "string" ? body.name.trim() : ""; - const color = typeof body?.color === "string" ? body.color.trim() : "#3b82f6"; - if (!name || name.length > 64) { - return Response.json({ error: "Name is required and must be 64 characters or fewer" }, { status: 400 }); - } - if (!/^#[0-9a-fA-F]{6}$/.test(color)) { - return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 }); - } - - const group = createGroup(name, color); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `create_group:${name}`, success: true }); - return Response.json(group, { status: 201, headers: deps.CORS_HEADERS }); - } - } - - const groupIdMatch = url.pathname.match(/^\/api\/groups\/(\d+)$/); - if (groupIdMatch) { - const user = await authenticateRequest(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - try { requirePermission(user, "clients:control"); } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const groupId = Number(groupIdMatch[1]); - - if (req.method === "PATCH") { - let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const existing = getGroup(groupId); - if (!existing) return Response.json({ error: "Group not found" }, { status: 404 }); - - const name = typeof body?.name === "string" ? body.name.trim() : existing.name; - const color = typeof body?.color === "string" ? body.color.trim() : existing.color; - if (name.length === 0 || name.length > 64) { - return Response.json({ error: "Name must be 1-64 characters" }, { status: 400 }); - } - if (!/^#[0-9a-fA-F]{6}$/.test(color)) { - return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 }); - } - - const updated = updateGroup(groupId, name, color); - if (!updated) return Response.json({ error: "Group not found" }, { status: 404 }); - notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `update_group:${groupId}`, success: true }); - return Response.json({ ok: true, id: groupId, name, color }, { headers: deps.CORS_HEADERS }); - } - - if (req.method === "DELETE") { - const deleted = deleteGroup(groupId); - if (!deleted) return Response.json({ error: "Group not found" }, { status: 404 }); - notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `delete_group:${groupId}`, success: true }); - return Response.json({ ok: true }, { headers: deps.CORS_HEADERS }); - } - } - - if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") { - const user = await authenticateRequest(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const clientIds = body?.clientIds; - if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) { - return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 }); - } - if (clientIds.length > 500) { - return Response.json({ error: "Too many clients (max 500)" }, { status: 400 }); - } - - const groupId = body?.groupId === null ? null : Number(body?.groupId); - if (groupId !== null && (isNaN(groupId) || groupId < 1)) { - return Response.json({ error: "Invalid groupId" }, { status: 400 }); - } - - let updated = 0; - for (const cid of clientIds) { - if (!canUserAccessClient(user.userId, user.role, cid)) continue; - if (setClientGroup(cid, groupId)) updated++; - } - - notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_group:${groupId ?? "none"}:${updated}/${clientIds.length}`, success: true }); - return Response.json({ ok: true, updated, total: clientIds.length, groupId }, { headers: deps.CORS_HEADERS }); - } - - const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/); - if (req.method === "PATCH" && clientGroupMatch) { - const user = await authenticateRequest(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - - const targetId = clientGroupMatch[1]; - try { - requireClientAccess(user, targetId); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); - } - if (!clientExists(targetId)) { - return Response.json({ error: "Client not found" }, { status: 404 }); - } - - let body: any = {}; - try { body = await req.json(); } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const groupId = body?.groupId === null ? null : Number(body?.groupId); - if (groupId !== null && (isNaN(groupId) || groupId < 1)) { - return Response.json({ error: "Invalid groupId" }, { status: 400 }); - } - - const updated = setClientGroup(targetId, groupId); - if (!updated) { - return Response.json({ error: "Client not found or group does not exist" }, { status: 404 }); - } - notifyDashboardViewers(); - const ip = server.requestIP(req)?.address || "unknown"; - logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: groupId ? `set_group:${groupId}` : "clear_group", success: true }); - return Response.json({ ok: true, groupId }, { headers: deps.CORS_HEADERS }); - } + const groupResult = await handleClientGroupRoutes(req, url, server, deps); + if (groupResult !== null) return groupResult; return null; } diff --git a/Overlord-Server/src/server/ws-chat.ts b/Overlord-Server/src/server/ws-chat.ts new file mode 100644 index 0000000..b3abecc --- /dev/null +++ b/Overlord-Server/src/server/ws-chat.ts @@ -0,0 +1,60 @@ +import type { ServerWebSocket } from "bun"; +import { getChatHistory, insertChatMessage, getOnlineClientCountForUser } from "../db"; +import { getUserById } from "../users"; +import { hasPermission } from "../rbac"; +import { getConfig } from "../config"; +import * as sessionManager from "../sessions/sessionManager"; +import type { SocketData } from "../sessions/types"; + +export function handleChatViewerOpen(ws: ServerWebSocket): void { + const id = crypto.randomUUID(); + ws.data.sessionId = id; + const userId = ws.data.userId; + const userRole = ws.data.userRole || "viewer"; + const user = userId ? getUserById(userId) : null; + const username = user?.username || "Unknown"; + sessionManager.addChatSession({ + id, + viewer: ws, + createdAt: Date.now(), + userId: userId || 0, + username, + userRole, + }); + const retDays = getConfig().chat?.retentionDays ?? 30; + const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined; + const history = getChatHistory(undefined, 50, retMs); + const enrichedHistory = history.map((m) => ({ + ...m, + onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0, + })); + const canWrite = userId ? hasPermission(userRole as any, "chat:write", userId) : false; + ws.send(JSON.stringify({ type: "chat_ready", history: enrichedHistory, canWrite, userId: userId || 0 })); +} + +export function handleChatViewerMessage( + ws: ServerWebSocket, + raw: string | ArrayBuffer | Uint8Array, +): void { + try { + const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw as ArrayBuffer); + const parsed = JSON.parse(text); + if (parsed.type !== "chat_send" || typeof parsed.message !== "string") return; + const userId = ws.data.userId; + const userRole = ws.data.userRole || "viewer"; + if (!userId || !hasPermission(userRole as any, "chat:write", userId)) { + ws.send(JSON.stringify({ type: "chat_error", error: "Permission denied" })); + return; + } + const msg = parsed.message.trim(); + if (!msg || msg.length > 2000) { + ws.send(JSON.stringify({ type: "chat_error", error: msg ? "Message too long (max 2000 chars)" : "Message is empty" })); + return; + } + const user = getUserById(userId); + const username = user?.username || "Unknown"; + const record = insertChatMessage(userId, username, userRole, msg); + const onlineClients = getOnlineClientCountForUser(userId); + sessionManager.broadcastChatMessage(JSON.stringify({ type: "chat_message", ...record, onlineClients })); + } catch {} +} diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 8454854..cf7bc2d 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -111,6 +111,10 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket, target.ws.send(encodeMessage({ type: "command", commandType: "file_write", id: routedId, payload: actualPayload } as any)); metrics.recordCommand("file_write"); break; + case "file_request_access": + target.ws.send(encodeMessage({ type: "command", commandType: "file_request_access", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_request_access"); + break; case "file_search": target.ws.send(encodeMessage({ type: "command", commandType: "file_search", id: routedId, payload: actualPayload } as any)); metrics.recordCommand("file_search"); diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts index 910c82a..b8a23dc 100644 --- a/Overlord-Server/src/thumbnails.ts +++ b/Overlord-Server/src/thumbnails.ts @@ -14,12 +14,12 @@ async function getSharp(): Promise { } } -const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 400)); -const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 225)); +const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920)); +const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080)); const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88))); const MAX_THUMBNAIL_SOURCE_BYTES = Math.max( 256 * 1024, - Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 8 * 1024 * 1024), + Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024), ); const THUMBNAIL_CACHE_MAX = Math.max( 64, diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 0393fbd..d5f30aa 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -15,8 +15,11 @@ import { notifyThumbnailGenerated, requestThumbnailRegen, } from "./thumbnails"; -import { upsertClientRow, type ClientDbRow } from "./db"; +import { upsertClientRow } from "./db"; import { metrics } from "./metrics"; +import { markClientDbSynced, queueClientDbUpdate, shouldSyncClientToDb } from "./client-db-sync"; + +export { clearClientSyncState } from "./client-db-sync"; /** Strip control chars and clamp length on client-supplied info strings. */ function sanitizeInfoString(val: unknown, maxLen = 256): string | undefined { @@ -56,51 +59,6 @@ function sanitizeJsonField( } 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(); -const pendingClientDbUpdates = new Map(); - -function queueClientDbUpdate(partial: ClientDbRow): void { - const existing = pendingClientDbUpdates.get(partial.id); - if (!existing) { - pendingClientDbUpdates.set(partial.id, { ...partial }); - return; - } - pendingClientDbUpdates.set(partial.id, { - ...existing, - ...partial, - id: partial.id, - lastSeen: partial.lastSeen ?? existing.lastSeen, - online: partial.online ?? existing.online, - pingMs: partial.pingMs ?? existing.pingMs, - }); -} - -function flushQueuedClientDbUpdates(): void { - if (pendingClientDbUpdates.size === 0) { - return; - } - for (const update of pendingClientDbUpdates.values()) { - upsertClientRow(update); - } - pendingClientDbUpdates.clear(); -} - -setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS); - -function shouldSyncClientToDb(clientId: string, now: number): boolean { - const last = lastClientDbSync.get(clientId) || 0; - if (now - last < CLIENT_DB_SYNC_INTERVAL_MS) { - return false; - } - lastClientDbSync.set(clientId, now); - return true; -} - -export function clearClientSyncState(clientId: string): void { - lastClientDbSync.delete(clientId); - pendingClientDbUpdates.delete(clientId); -} export async function handleHello( info: ClientInfo, @@ -246,7 +204,7 @@ export function handlePong(info: ClientInfo, payload: WireMessage) { online: 1, isAdmin: info.isAdmin, }); - lastClientDbSync.set(info.id, nowTs); + markClientDbSynced(info.id, nowTs); metrics.recordPing(rtt); } else {