feat(remote): add WebSocket server with auth, signaling relay, and rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c2932a1577
commit
9fa047b399
171
lib/remote-server.js
Normal file
171
lib/remote-server.js
Normal file
@ -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;
|
||||||
41
tests/remote-server.test.js
Normal file
41
tests/remote-server.test.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user