| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- // WebSocket signaling. Two kinds of WS clients:
- // agent -> authenticates with machine enroll_token, waits for session requests
- // viewer -> authenticated technician, requests a session to a machine
- // The server brokers consent and relays SDP/ICE. Media never traverses the server.
- const R = require('./repos');
- const A = require('./auth');
- const { currentUser, audit } = require('./session');
- const { onlineAgents, liveSessions, pendingShares } = require('./presence');
-
- function onConnection(ws, req) {
- const hb = setInterval(() => {
- if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
- }, 25000);
- ws.on('message', (raw) => {
- let m; try { m = JSON.parse(raw); } catch { return; }
- handle(ws, m, req);
- });
- ws.on('close', () => { clearInterval(hb); cleanup(ws); });
- }
-
- function handle(ws, m, req) {
- switch (m.type) {
- // --- Agent comes online ---
- case 'agent-hello': {
- const machine = R.machines.byEnrollToken(m.enrollToken);
- if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
- ws.kind = 'agent'; ws.machineId = machine.id;
- onlineAgents.set(machine.id, { ws, machine });
- R.machines.touch(machine.id);
- ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
- break;
- }
- // --- Technician requests control of a machine ---
- case 'viewer-connect': {
- const u = currentUser(req); // cookie sent on WS upgrade
- if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
- const agent = onlineAgents.get(m.machineId);
- const machine = R.machines.inTenant(m.machineId, u.team_id);
- if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
- if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
- if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
- const sessionId = A.token(8);
- ws.kind = 'viewer'; ws.sessionId = sessionId;
- liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' });
- // Ask the agent for consent (or auto-grant if unattended policy is on)
- agent.ws.sessionId = sessionId;
- agent.ws.send(JSON.stringify({
- type: 'session-request', sessionId,
- technician: u.email, unattended: !!machine.unattended,
- }));
- ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
- break;
- }
- // --- Agent grants/denies consent ---
- case 'consent': {
- const sess = liveSessions.get(m.sessionId);
- if (!sess) return;
- if (m.granted) {
- audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
- try {
- R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
- } catch (e) { /* duplicate consent */ }
- sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
- sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
- } else {
- audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
- sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
- liveSessions.delete(m.sessionId);
- }
- break;
- }
- // --- No-install: end user opens /share, gets a one-time code ---
- case 'share-create': {
- let code;
- do { code = A.numericCode(6); } while (pendingShares.has(code));
- const sessionId = A.token(8);
- ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
- pendingShares.set(code, { sharerWs: ws, sessionId });
- ws.send(JSON.stringify({ type: 'share-code', code }));
- break;
- }
- // --- Logged-in agent enters the code (+ ticket) to connect ---
- case 'code-connect': {
- const agent = currentUser(req); // identity from the agent's authenticated session
- if (!agent) {
- return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
- }
- const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
- const pend = pendingShares.get(String(m.code || '').trim());
- if (!pend || pend.sharerWs.readyState !== 1) {
- return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
- }
- pendingShares.delete(pend.sharerWs.shareCode);
- const sessionId = pend.sessionId;
- ws.kind = 'viewer'; ws.sessionId = sessionId;
- const agentName = agent.name || agent.email;
- const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
- const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
- liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
- pend.sharerWs.sessionId = sessionId;
- audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' · agent ' + agentName });
- pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
- ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
- break;
- }
- // --- Relay WebRTC signaling between the two peers ---
- case 'offer': case 'answer': case 'ice-candidate': {
- const sess = liveSessions.get(m.sessionId || ws.sessionId);
- if (!sess) return;
- const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
- if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
- break;
- }
- case 'transcript': {
- const sess = liveSessions.get(m.sessionId || ws.sessionId);
- if (!sess) return;
- const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
- if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
- break;
- }
- case 'recording': {
- const sess = liveSessions.get(m.sessionId || ws.sessionId);
- if (!sess) return;
- const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
- if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
- break;
- }
- case 'end-session': {
- endSession(ws.sessionId, m.reason || null);
- break;
- }
- }
- }
-
- function notifyBizGaze(sessionId) {
- const url = process.env.BIZGAZE_WEBHOOK_URL;
- if (!url) return;
- try {
- const row = R.sessionsLog.byId(sessionId);
- if (!row) return;
- const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
- agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
- duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
- const crypto = require('crypto');
- const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
- fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
- } catch (e) {}
- }
-
- function endSession(sessionId, reason) {
- const sess = liveSessions.get(sessionId);
- if (!sess) return;
- try { R.sessionsLog.end(sessionId); } catch (e) {}
- notifyBizGaze(sessionId);
- audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
- [sess.agentWs, sess.viewerWs].forEach((p) => {
- if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
- });
- liveSessions.delete(sessionId);
- }
-
- function cleanup(ws) {
- if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
- if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
- if (ws.sessionId) {
- for (const [sid, sess] of liveSessions) {
- if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
- }
- }
- }
-
- module.exports = { onConnection };
|