Files
BizGaze_Remote/server/signaling.js
T

174 строки
8.8 KiB
JavaScript
Исходник Обычный вид История

// 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 };