| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540 |
- // Remote Access Platform — backend server
- // HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
- const http = require('http');
- const https = require('https');
- const fs = require('fs');
- const path = require('path');
- const { WebSocketServer } = require('ws');
- const db = require('./db');
- const A = require('./auth');
-
- const PORT = process.env.PORT || 8090;
- const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
- const PUBLIC_DIR = path.join(__dirname, 'public');
- const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
-
- // ---------- helpers ----------
- const now = () => Date.now();
- const json = (res, code, body) => {
- res.writeHead(code, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify(body));
- };
- function readBody(req) {
- return new Promise((resolve) => {
- let data = '';
- req.on('data', (c) => (data += c));
- req.on('end', () => {
- try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
- });
- });
- }
- function parseCookies(req) {
- const out = {};
- (req.headers.cookie || '').split(';').forEach((c) => {
- const [k, ...v] = c.trim().split('=');
- if (k) out[k] = decodeURIComponent(v.join('='));
- });
- return out;
- }
- function audit(entry) {
- db.prepare(
- `INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
- VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
- ).run({
- team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
- machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
- action: entry.action, detail: entry.detail || null, at: now(),
- });
- }
-
- // Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
- function currentUser(req, { requireMfa = true } = {}) {
- const tok = parseCookies(req).sid;
- if (!tok) return null;
- const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
- if (!s || s.expires_at < now()) return null;
- if (requireMfa && !s.mfa_passed) return null;
- const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
- if (!u || u.active === 0) return null;
- return { ...u, _session: s };
- }
-
- // ---------- HTTP API ----------
- const routes = {};
- const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
-
- // Register: creates a team + admin user. MFA must be set up before full access.
- route('POST', '/api/register', async (req, res) => {
- const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
- if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
- return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
- const { email, password, teamName } = await readBody(req);
- if (!email || !password) return json(res, 400, { error: 'email and password required' });
- if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
- return json(res, 409, { error: 'email already registered' });
- const teamId = A.id(), userId = A.id();
- const { hash, salt } = A.hashPassword(password);
- const mfaSecret = A.newMfaSecret();
- db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
- .run(teamId, teamName || `${email}'s team`, now());
- db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
- VALUES (?,?,?,?,?,?,?,0,?)`)
- .run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
- audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
- json(res, 200, { ok: true });
- });
-
- // Verify MFA enrollment (confirm the user scanned the QR / entered code)
- route('POST', '/api/mfa/enable', async (req, res) => {
- const { email, code } = await readBody(req);
- const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
- if (!u) return json(res, 404, { error: 'no such user' });
- if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
- db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
- json(res, 200, { ok: true });
- });
-
- // Login step 1: email + password -> sets a session cookie (mfa not yet passed)
- route('POST', '/api/login', async (req, res) => {
- const { email, password, remember } = await readBody(req);
- const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
- if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
- return json(res, 401, { error: 'invalid credentials' });
- if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
- const tok = A.token();
- const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
- db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
- .run(tok, u.id, now(), now() + ttl);
- res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
- json(res, 200, { ok: true, mfaRequired: false });
- });
-
- // Login step 2: TOTP code -> marks session mfa_passed
- route('POST', '/api/login/mfa', async (req, res) => {
- const { code } = await readBody(req);
- const tok = parseCookies(req).sid;
- const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
- if (!s) return json(res, 401, { error: 'no session' });
- const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
- if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
- db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
- json(res, 200, { ok: true });
- });
-
- route('POST', '/api/logout', async (req, res) => {
- const tok = parseCookies(req).sid;
- if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
- res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
- json(res, 200, { ok: true });
- });
-
- route('GET', '/api/setup-state', async (req, res) => {
- const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
- json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
- });
-
- // ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
- // configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
- // Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
- // three env vars — nothing to install or run on your side.
- route('GET', '/api/ice', async (req, res) => {
- const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
- if (process.env.TURN_URLS) {
- iceServers.push({
- urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
- username: process.env.TURN_USERNAME || '',
- credential: process.env.TURN_CREDENTIAL || '',
- });
- }
- json(res, 200, { iceServers });
- });
-
- route('GET', '/api/me', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
- });
-
- // ---------- BizGaze SSO: agent arrives already logged in ----------
- route('GET', '/sso', async (req, res) => {
- if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
- const q = new URLSearchParams(req.url.split('?')[1] || '');
- const token = q.get('token') || '';
- const [payloadB64, sig] = token.split('.');
- const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
- if (!payloadB64 || !sig) return fail('Invalid SSO token');
- const crypto = require('crypto');
- const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
- const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
- if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
- let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
- if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
- let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
- if (!u) {
- const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
- if (!team) return fail('No team configured');
- const userId = A.id();
- const { hash, salt } = A.hashPassword(A.token());
- const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
- db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
- VALUES (?,?,?,?,?,?,?,?,0,?)`)
- .run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
- u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
- audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
- } else if (p.name && p.name !== u.name) {
- db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
- }
- if (u.active === 0) return fail('Account deactivated');
- const tok = A.token();
- db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
- .run(tok, u.id, now(), now() + SESSION_TTL);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
- const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
- res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
- res.end();
- });
-
- // Admin adds an agent login to their team
- route('POST', '/api/users', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
- const { email, password, name, role } = await readBody(req);
- if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
- if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
- return json(res, 409, { error: 'email already registered' });
- const userId = A.id();
- const { hash, salt } = A.hashPassword(password);
- const mfaSecret = A.newMfaSecret();
- const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
- db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
- VALUES (?,?,?,?,?,?,?,?,0,?)`)
- .run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
- json(res, 200, { ok: true, id: userId, email, role: r });
- });
-
- // List the team's agents
- route('GET', '/api/users', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
- json(res, 200, rows);
- });
-
- // First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
- route('GET', '/api/mfa/setup', async (req, res) => {
- const u = currentUser(req, { requireMfa: false });
- if (!u) return json(res, 401, { error: 'unauthorized' });
- if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
- json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
- });
-
- // Admin manages an agent: reset password, rename, deactivate/activate, delete.
- // (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
- route('POST', '/api/users/manage', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
- const { id, action, password, name } = await readBody(req);
- const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
- if (!target) return json(res, 404, { error: 'no such agent' });
- switch (action) {
- case 'reset-password': {
- if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
- const { hash, salt } = A.hashPassword(password);
- db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
- db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
- return json(res, 200, { ok: true });
- }
- case 'rename': {
- const clean = String(name || '').trim().slice(0, 60);
- if (!clean) return json(res, 400, { error: 'name required' });
- db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
- return json(res, 200, { ok: true, name: clean });
- }
- case 'deactivate': {
- if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
- db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
- db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
- return json(res, 200, { ok: true });
- }
- case 'activate': {
- db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
- return json(res, 200, { ok: true });
- }
- case 'delete': {
- if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
- db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
- db.prepare('DELETE FROM users WHERE id=?').run(target.id);
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
- return json(res, 200, { ok: true });
- }
- default: return json(res, 400, { error: 'unknown action' });
- }
- });
-
- // Session report: one row per session, filterable by agent and date period
- route('GET', '/api/report', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- const q = new URLSearchParams(req.url.split('?')[1] || '');
- let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
- const args = [u.team_id];
- if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
- if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
- if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
- sql += ' ORDER BY started_at DESC LIMIT 500';
- json(res, 200, db.prepare(sql).all(...args));
- });
-
- // List machines for the team (with live online status from signaling layer)
- route('GET', '/api/machines', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
- json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
- });
-
- // Create a machine enrollment token (admin/technician). Agent uses it to come online.
- route('POST', '/api/machines', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
- const { name, unattended } = await readBody(req);
- const mId = A.id(), enroll = A.token();
- db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
- .run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
- audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
- json(res, 200, { id: mId, enrollToken: enroll });
- });
-
- route('GET', '/api/audit', async (req, res) => {
- const u = currentUser(req);
- if (!u) return json(res, 401, { error: 'unauthorized' });
- const rows = db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(u.team_id);
- json(res, 200, rows);
- });
-
- // ---------- static + router ----------
- const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
- function serveStatic(req, res) {
- let p = req.url.split('?')[0];
- if (p === '/') p = '/index.html';
- if (p === '/console') p = '/console.html';
- if (p === '/share') p = '/share.html';
- if (p === '/connect') p = '/connect.html';
- const fp = path.join(PUBLIC_DIR, path.normalize(p));
- if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
- fs.readFile(fp, (err, data) => {
- if (err) return json(res, 404, { error: 'not found' });
- const ct = MIME[path.extname(fp)] || 'application/octet-stream';
- res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
- res.end(data);
- });
- }
-
- const server = http.createServer(async (req, res) => {
- const key = `${req.method} ${req.url.split('?')[0]}`;
- if (routes[key]) return routes[key](req, res);
- if (req.method === 'GET') return serveStatic(req, res);
- json(res, 404, { error: 'not found' });
- });
-
- // ---------- 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 onlineAgents = new Map(); // machineId -> { ws, machine }
- const liveSessions = new Map(); // sessionId -> { agentWs, viewerWs, machine, user }
- const pendingShares = new Map(); // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
-
- 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); });
- }
-
- const wss = new WebSocketServer({ server, path: '/ws' });
- wss.on('connection', onConnection);
-
- function handle(ws, m, req) {
- switch (m.type) {
- // --- Agent comes online ---
- case 'agent-hello': {
- const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(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 });
- db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), 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 = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(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 {
- db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
- .run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
- } 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 '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 = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(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 { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), 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);
- }
- }
- }
-
- server.listen(PORT, () => {
- console.log(`HTTP on http://localhost:${PORT}`);
- });
-
- // HTTPS — required so other devices can share their screen (browsers block
- // screen capture on non-secure origins). Uses cert.pem/key.pem if present.
- let httpsServer = null;
- try {
- const certPath = path.join(__dirname, 'cert.pem');
- const keyPath = path.join(__dirname, 'key.pem');
- if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
- httpsServer = https.createServer(
- { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) },
- (req, res) => server.emit('request', req, res)
- );
- const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' });
- wssSecure.on('connection', onConnection);
- httpsServer.listen(HTTPS_PORT, () => {
- console.log(`HTTPS on https://localhost:${HTTPS_PORT} (use this address from other devices)`);
- console.log(` End user shares screen: https://<this-pc-ip>:${HTTPS_PORT}/share`);
- console.log(` Technician connects: https://<this-pc-ip>:${HTTPS_PORT}/connect`);
- });
- } else {
- console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)');
- }
- } catch (e) {
- console.log('HTTPS failed to start:', e.message);
- }
-
- module.exports = { server };
|