// 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 REC_DIR = path.join(__dirname, 'recordings'); try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {} const TRANS_DIR = path.join(__dirname, 'transcripts'); try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {} 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); }); // ---------- session recording: upload (agent) + download (team) ---------- const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap route('POST', '/api/recording', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId'); if (!sid) return json(res, 400, { error: 'sessionId required' }); const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'recording too large' }); const fname = sid + '.webm'; try { fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks)); db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, sid); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid }); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save recording' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); route('POST', '/api/transcript', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId'); if (!sid) return json(res, 400, { error: 'sessionId required' }); const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'transcript too large' }); const fname = sid + '.txt'; try { fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks)); db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, sid); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save transcript' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); // ---------- 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' && req.url.split('?')[0].startsWith('/transcripts/')) { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = path.basename(decodeURIComponent(req.url.split('?')[0])); const sid = name.replace(/\.txt$/i, ''); const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id); if (!row || !row.transcript) return json(res, 404, { error: 'not found' }); const fp = path.join(TRANS_DIR, row.transcript); if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' }); return fs.stat(fp, (err, st) => { if (err) return json(res, 404, { error: 'not found' }); res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' }); const rs = fs.createReadStream(fp); rs.on('error', () => { try { res.destroy(); } catch (e) {} }); rs.pipe(res); }); } if (req.method === 'GET' && req.url.split('?')[0].startsWith('/recordings/')) { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = path.basename(decodeURIComponent(req.url.split('?')[0])); const sid = name.replace(/\.webm$/i, ''); const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id); if (!row || !row.recording) return json(res, 404, { error: 'not found' }); const fp = path.join(REC_DIR, row.recording); if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' }); return fs.stat(fp, (err, st) => { if (err) return json(res, 404, { error: 'not found' }); res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.webm"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' }); const rs = fs.createReadStream(fp); rs.on('error', () => { try { res.destroy(); } catch (e) {} }); rs.pipe(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 '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 = 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://:${HTTPS_PORT}/share`); console.log(` Technician connects: https://:${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 };