diff --git a/lib/remote-server.js b/lib/remote-server.js new file mode 100644 index 0000000..b8797b6 --- /dev/null +++ b/lib/remote-server.js @@ -0,0 +1,171 @@ +const { WebSocketServer } = require('ws'); +const crypto = require('crypto'); + +class RemoteServer { + constructor() { + this._wss = null; + this._clients = new Map(); // ws -> { id, role, authenticated } + this._config = null; + this._failedAttempts = new Map(); // ip -> { count, blockedUntil } + } + + start(opts) { + return new Promise((resolve, reject) => { + this._config = opts; + + this._wss = new WebSocketServer({ port: opts.port }, () => { + resolve(); + }); + + this._wss.on('error', (err) => { + reject(err); + }); + + this._wss.on('connection', (ws, req) => { + this._handleConnection(ws, req); + }); + }); + } + + stop() { + if (this._wss) { + for (const [ws] of this._clients) { + ws.close(1000, 'Server shutting down'); + } + this._clients.clear(); + this._wss.close(); + this._wss = null; + } + } + + getClientCount() { + let count = 0; + for (const [, client] of this._clients) { + if (client.authenticated) count++; + } + return count; + } + + getPort() { + if (this._wss && this._wss.address()) { + return this._wss.address().port; + } + return null; + } + + _handleConnection(ws, req) { + const ip = req.socket.remoteAddress || 'unknown'; + + if (this._isBlocked(ip)) { + ws.close(4003, 'Too many failed attempts'); + return; + } + + const clientId = crypto.randomUUID(); + this._clients.set(ws, { id: clientId, role: null, authenticated: false }); + + let authReceived = false; + const authTimeout = setTimeout(() => { + if (!authReceived) { + ws.close(4001, 'Auth timeout'); + this._clients.delete(ws); + } + }, 5000); + + ws.on('message', (raw) => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + + const client = this._clients.get(ws); + if (!client) return; + + if (!client.authenticated) { + authReceived = true; + clearTimeout(authTimeout); + + if (msg.type === 'auth' && msg.token === this._config.token) { + client.authenticated = true; + client.role = msg.role || 'viewer'; + ws.send(JSON.stringify({ type: 'auth-ok', clientId })); + + if (this.getClientCount() === 1) { + this._config.onCreateCaptureWindow(); + } + } else { + this._recordFailedAttempt(ip); + ws.close(4002, 'Invalid token'); + this._clients.delete(ws); + } + return; + } + + if (msg.type === 'offer' || msg.type === 'ice-candidate') { + msg.clientId = client.id; + msg.role = client.role; + this._config.onSignalingToCapture(msg); + } + }); + + ws.on('close', () => { + clearTimeout(authTimeout); + const client = this._clients.get(ws); + const wasAuthenticated = client && client.authenticated; + this._clients.delete(ws); + + if (wasAuthenticated) { + this._config.onSignalingToCapture({ + type: 'client-disconnected', + clientId: client.id + }); + + if (this.getClientCount() === 0) { + this._config.onDestroyCaptureWindow(); + } + } + }); + + ws.on('error', () => { + this._clients.delete(ws); + }); + } + + sendToClient(clientId, data) { + for (const [ws, client] of this._clients) { + if (client.id === clientId && client.authenticated) { + ws.send(JSON.stringify(data)); + break; + } + } + } + + broadcast(data) { + const msg = JSON.stringify(data); + for (const [ws, client] of this._clients) { + if (client.authenticated && ws.readyState === 1) { + ws.send(msg); + } + } + } + + _isBlocked(ip) { + const entry = this._failedAttempts.get(ip); + if (!entry) return false; + if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true; + if (entry.blockedUntil && Date.now() >= entry.blockedUntil) { + this._failedAttempts.delete(ip); + return false; + } + return false; + } + + _recordFailedAttempt(ip) { + const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null }; + entry.count++; + if (entry.count >= 5) { + entry.blockedUntil = Date.now() + 60000; + } + this._failedAttempts.set(ip, entry); + } +} + +module.exports = RemoteServer; diff --git a/tests/remote-server.test.js b/tests/remote-server.test.js new file mode 100644 index 0000000..75a37a3 --- /dev/null +++ b/tests/remote-server.test.js @@ -0,0 +1,41 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); + +// Test the module can be required and has the expected API +describe('RemoteServer', () => { + it('should export a class with start/stop methods', () => { + const RemoteServer = require('../lib/remote-server'); + assert.strictEqual(typeof RemoteServer, 'function'); + assert.strictEqual(typeof RemoteServer.prototype.start, 'function'); + assert.strictEqual(typeof RemoteServer.prototype.stop, 'function'); + assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function'); + }); + + it('should start and stop without errors', async () => { + const RemoteServer = require('../lib/remote-server'); + const server = new RemoteServer(); + + // Mock mainWindow + const mockMainWindow = { + isDestroyed: () => false, + getTitle: () => 'Test Window', + getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }), + webContents: { + sendInputEvent: () => {} + } + }; + + await server.start({ + port: 0, // random available port + token: 'test-token-123', + allowInput: true, + mainWindow: mockMainWindow, + onSignalingToCapture: () => {}, + onCreateCaptureWindow: () => {}, + onDestroyCaptureWindow: () => {} + }); + + assert.strictEqual(server.getClientCount(), 0); + server.stop(); + }); +});