Нема описа
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // WebSocket signaling. Two kinds of WS clients:
  2. // agent -> authenticates with machine enroll_token, waits for session requests
  3. // viewer -> authenticated technician, requests a session to a machine
  4. // The server brokers consent and relays SDP/ICE. Media never traverses the server.
  5. const R = require('./repos');
  6. const A = require('./auth');
  7. const { currentUser, audit } = require('./session');
  8. const { onlineAgents, liveSessions, pendingShares } = require('./presence');
  9. function onConnection(ws, req) {
  10. const hb = setInterval(() => {
  11. if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
  12. }, 25000);
  13. ws.on('message', (raw) => {
  14. let m; try { m = JSON.parse(raw); } catch { return; }
  15. handle(ws, m, req);
  16. });
  17. ws.on('close', () => { clearInterval(hb); cleanup(ws); });
  18. }
  19. function handle(ws, m, req) {
  20. switch (m.type) {
  21. // --- Agent comes online ---
  22. case 'agent-hello': {
  23. const machine = R.machines.byEnrollToken(m.enrollToken);
  24. if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
  25. ws.kind = 'agent'; ws.machineId = machine.id;
  26. onlineAgents.set(machine.id, { ws, machine });
  27. R.machines.touch(machine.id);
  28. ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
  29. break;
  30. }
  31. // --- Technician requests control of a machine ---
  32. case 'viewer-connect': {
  33. const u = currentUser(req); // cookie sent on WS upgrade
  34. if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
  35. const agent = onlineAgents.get(m.machineId);
  36. const machine = R.machines.inTenant(m.machineId, u.team_id);
  37. if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
  38. if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
  39. if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
  40. const sessionId = A.token(8);
  41. ws.kind = 'viewer'; ws.sessionId = sessionId;
  42. liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
  43. 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' });
  44. // Ask the agent for consent (or auto-grant if unattended policy is on)
  45. agent.ws.sessionId = sessionId;
  46. agent.ws.send(JSON.stringify({
  47. type: 'session-request', sessionId,
  48. technician: u.email, unattended: !!machine.unattended,
  49. }));
  50. ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
  51. break;
  52. }
  53. // --- Agent grants/denies consent ---
  54. case 'consent': {
  55. const sess = liveSessions.get(m.sessionId);
  56. if (!sess) return;
  57. if (m.granted) {
  58. 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') });
  59. try {
  60. 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 });
  61. } catch (e) { /* duplicate consent */ }
  62. sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
  63. sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
  64. } else {
  65. 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') });
  66. sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
  67. liveSessions.delete(m.sessionId);
  68. }
  69. break;
  70. }
  71. // --- No-install: end user opens /share, gets a one-time code ---
  72. case 'share-create': {
  73. let code;
  74. do { code = A.numericCode(6); } while (pendingShares.has(code));
  75. const sessionId = A.token(8);
  76. ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
  77. pendingShares.set(code, { sharerWs: ws, sessionId });
  78. ws.send(JSON.stringify({ type: 'share-code', code }));
  79. break;
  80. }
  81. // --- Logged-in agent enters the code (+ ticket) to connect ---
  82. case 'code-connect': {
  83. const agent = currentUser(req); // identity from the agent's authenticated session
  84. if (!agent) {
  85. return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
  86. }
  87. const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
  88. const pend = pendingShares.get(String(m.code || '').trim());
  89. if (!pend || pend.sharerWs.readyState !== 1) {
  90. return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
  91. }
  92. pendingShares.delete(pend.sharerWs.shareCode);
  93. const sessionId = pend.sessionId;
  94. ws.kind = 'viewer'; ws.sessionId = sessionId;
  95. const agentName = agent.name || agent.email;
  96. const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
  97. const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
  98. liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
  99. pend.sharerWs.sessionId = sessionId;
  100. 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 });
  101. pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
  102. ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
  103. break;
  104. }
  105. // --- Relay WebRTC signaling between the two peers ---
  106. case 'offer': case 'answer': case 'ice-candidate': {
  107. const sess = liveSessions.get(m.sessionId || ws.sessionId);
  108. if (!sess) return;
  109. const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
  110. if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
  111. break;
  112. }
  113. case 'transcript': {
  114. const sess = liveSessions.get(m.sessionId || ws.sessionId);
  115. if (!sess) return;
  116. const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
  117. if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
  118. break;
  119. }
  120. case 'recording': {
  121. const sess = liveSessions.get(m.sessionId || ws.sessionId);
  122. if (!sess) return;
  123. const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
  124. if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
  125. break;
  126. }
  127. case 'end-session': {
  128. endSession(ws.sessionId, m.reason || null);
  129. break;
  130. }
  131. }
  132. }
  133. function notifyBizGaze(sessionId) {
  134. const url = process.env.BIZGAZE_WEBHOOK_URL;
  135. if (!url) return;
  136. try {
  137. const row = R.sessionsLog.byId(sessionId);
  138. if (!row) return;
  139. const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
  140. agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
  141. duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
  142. const crypto = require('crypto');
  143. const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
  144. fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
  145. } catch (e) {}
  146. }
  147. function endSession(sessionId, reason) {
  148. const sess = liveSessions.get(sessionId);
  149. if (!sess) return;
  150. try { R.sessionsLog.end(sessionId); } catch (e) {}
  151. notifyBizGaze(sessionId);
  152. 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') });
  153. [sess.agentWs, sess.viewerWs].forEach((p) => {
  154. if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
  155. });
  156. liveSessions.delete(sessionId);
  157. }
  158. function cleanup(ws) {
  159. if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
  160. if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
  161. if (ws.sessionId) {
  162. for (const [sid, sess] of liveSessions) {
  163. if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
  164. }
  165. }
  166. }
  167. module.exports = { onConnection };