const process = require('process') const crypto = require('crypto') const {spawn} = require('child_process') const WebSocket = require('ws'); const fs = require('fs'); const parser = require('ua-parser-js'); const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator'); const express = require('express'); const RateLimit = require('express-rate-limit'); const http = require('http'); // Handle SIGINT process.on('SIGINT', () => { console.info("SIGINT Received, exiting...") process.exit(0) }) // Handle SIGTERM process.on('SIGTERM', () => { console.info("SIGTERM Received, exiting...") process.exit(0) }) // Handle APP ERRORS process.on('uncaughtException', (error, origin) => { console.log('----- Uncaught exception -----') console.log(error) console.log('----- Exception origin -----') console.log(origin) }) process.on('unhandledRejection', (reason, promise) => { console.log('----- Unhandled Rejection at -----') console.log(promise) console.log('----- Reason -----') console.log(reason) }) if (process.argv.includes('--auto-restart')) { process.on( 'uncaughtException', () => { process.once( 'exit', () => spawn( process.argv.shift(), process.argv, { cwd: process.cwd(), detached: true, stdio: 'inherit' } ) ); process.exit(); } ); } const rtcConfig = process.env.RTC_CONFIG ? JSON.parse(fs.readFileSync(process.env.RTC_CONFIG, 'utf8')) : { "sdpSemantics": "unified-plan", "iceServers": [ { "urls": "stun:stun.l.google.com:19302" } ] }; const app = express(); if (process.argv.includes('--rate-limit')) { const limiter = RateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 1000, // Limit each IP to 1000 requests per `window` (here, per 5 minutes) message: 'Too many requests from this IP Address, please try again after 5 minutes.', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }) app.use(limiter); // ensure correct client ip and not the ip of the reverse proxy is used for rate limiting on render.com // see https://github.com/express-rate-limit/express-rate-limit#troubleshooting-proxy-issues app.set('trust proxy', 5); } if (process.argv.includes('--include-ws-fallback')) { app.use(express.static('public_included_ws_fallback')); } else { app.use(express.static('public')); } const debugMode = process.env.DEBUG_MODE === "true"; if (debugMode) { console.log("DEBUG_MODE is active. To protect privacy, do not use in production.") } let ipv6_lcl; if (process.env.IPV6_LOCALIZE) { ipv6_lcl = parseInt(process.env.IPV6_LOCALIZE); if (!ipv6_lcl || !(0 < ipv6_lcl && ipv6_lcl < 8)) { console.error("IPV6_LOCALIZE must be an integer between 1 and 7"); return; } console.log("IPv6 client IPs will be localized to", ipv6_lcl, ipv6_lcl === 1 ? "segment" : "segments"); } app.use(function(req, res) { res.redirect('/'); }); app.get('/', (req, res) => { res.sendFile('index.html'); }); const server = http.createServer(app); const port = process.env.PORT || 3000; if (process.argv.includes('--localhost-only')) { server.listen(port, '127.0.0.1'); } else { server.listen(port); } class MoreLoadServer { constructor() { this._wss = new WebSocket.Server({ server }); this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); this._rooms = {}; this._roomSecrets = {}; console.log('MoreLoad is running on port', port); } _onConnection(peer) { peer.socket.on('message', message => this._onMessage(peer, message)); peer.socket.onerror = e => console.error(e); this._keepAlive(peer); this._send(peer, { type: 'rtc-config', config: rtcConfig }); // send displayName this._send(peer, { type: 'display-name', message: { displayName: peer.name.displayName, deviceName: peer.name.deviceName, peerId: peer.id, peerIdHash: hasher.hashCodeSalted(peer.id) } }); } _onMessage(sender, message) { // Try to parse message try { message = JSON.parse(message); } catch (e) { return; // TODO: handle malformed JSON } switch (message.type) { case 'disconnect': this._onDisconnect(sender); break; case 'pong': sender.lastBeat = Date.now(); break; case 'join-ip-room': this._joinRoom(sender); break; case 'room-secrets': this._onRoomSecrets(sender, message); break; case 'room-secrets-deleted': this._onRoomSecretsDeleted(sender, message); break; case 'pair-device-initiate': this._onPairDeviceInitiate(sender); break; case 'pair-device-join': this._onPairDeviceJoin(sender, message); break; case 'pair-device-cancel': this._onPairDeviceCancel(sender); break; case 'regenerate-room-secret': this._onRegenerateRoomSecret(sender, message); break case 'resend-peers': this._notifyPeers(sender); break; case 'signal': default: this._signalAndRelay(sender, message); } } _signalAndRelay(sender, message) { const room = message.roomType === 'ip' ? sender.ip : message.roomSecret; // relay message to recipient if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) { const recipient = this._rooms[room][message.to]; delete message.to; // add sender message.sender = { id: sender.id, rtcSupported: sender.rtcSupported }; this._send(recipient, message); } } _onDisconnect(sender) { this._disconnect(sender); } _disconnect(sender) { this._leaveRoom(sender, 'ip', '', true); this._leaveAllSecretRooms(sender, true); this._removeRoomKey(sender.roomKey); sender.roomKey = null; sender.socket.terminate(); } _onRoomSecrets(sender, message) { if (!message.roomSecrets) return; const roomSecrets = message.roomSecrets.filter(roomSecret => { return /^[\x00-\x7F]{64,256}$/.test(roomSecret); }) if (!roomSecrets) return; this._joinSecretRooms(sender, roomSecrets); } _onRoomSecretsDeleted(sender, message) { for (let i = 0; i= 10) { this._send(sender, { type: 'pair-device-join-key-rate-limit' }); return; } sender.roomKeyRate += 1; setTimeout(_ => sender.roomKeyRate -= 1, 10000); if (!this._roomSecrets[message.roomKey] || sender.id === this._roomSecrets[message.roomKey].creator.id) { this._send(sender, { type: 'pair-device-join-key-invalid' }); return; } const roomSecret = this._roomSecrets[message.roomKey].roomSecret; const creator = this._roomSecrets[message.roomKey].creator; this._removeRoomKey(message.roomKey); this._send(sender, { type: 'pair-device-joined', roomSecret: roomSecret, peerId: creator.id }); this._send(creator, { type: 'pair-device-joined', roomSecret: roomSecret, peerId: sender.id }); this._joinRoom(sender, 'secret', roomSecret); this._removeRoomKey(sender.roomKey); } _onPairDeviceCancel(sender) { const roomKey = sender.roomKey if (!roomKey) return; this._removeRoomKey(roomKey); this._send(sender, { type: 'pair-device-canceled', roomKey: roomKey, }); } _onRegenerateRoomSecret(sender, message) { const oldRoomSecret = message.roomSecret; const newRoomSecret = randomizer.getRandomString(256); // notify all other peers for (const peerId in this._rooms[oldRoomSecret]) { const peer = this._rooms[oldRoomSecret][peerId]; this._send(peer, { type: 'room-secret-regenerated', oldRoomSecret: oldRoomSecret, newRoomSecret: newRoomSecret, }); peer.removeRoomSecret(oldRoomSecret); } delete this._rooms[oldRoomSecret]; } _createRoomKey(creator, roomSecret) { let roomKey; do { // get randomInt until keyRoom not occupied roomKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s } while (roomKey in this._roomSecrets) this._roomSecrets[roomKey] = { roomSecret: roomSecret, creator: creator } return roomKey; } _removeRoomKey(roomKey) { if (roomKey in this._roomSecrets) { this._roomSecrets[roomKey].creator.roomKey = null delete this._roomSecrets[roomKey]; } } _joinRoom(peer, roomType = 'ip', roomSecret = '') { const room = roomType === 'ip' ? peer.ip : roomSecret; if (this._rooms[room] && this._rooms[room][peer.id]) { // ensures that otherPeers never receive `peer-left` after `peer-joined` on reconnect. this._leaveRoom(peer, roomType, roomSecret); } // if room doesn't exist, create it if (!this._rooms[room]) { this._rooms[room] = {}; } this._notifyPeers(peer, roomType, roomSecret); // add peer to room this._rooms[room][peer.id] = peer; // add secret to peer if (roomType === 'secret') { peer.addRoomSecret(roomSecret); } } _leaveRoom(peer, roomType = 'ip', roomSecret = '', disconnect = false) { const room = roomType === 'ip' ? peer.ip : roomSecret; if (!this._rooms[room] || !this._rooms[room][peer.id]) return; this._cancelKeepAlive(this._rooms[room][peer.id]); // delete the peer delete this._rooms[room][peer.id]; //if room is empty, delete the room if (!Object.keys(this._rooms[room]).length) { delete this._rooms[room]; } else { // notify all other peers for (const otherPeerId in this._rooms[room]) { const otherPeer = this._rooms[room][otherPeerId]; this._send(otherPeer, { type: 'peer-left', peerId: peer.id, roomType: roomType, roomSecret: roomSecret, disconnect: disconnect }); } } //remove secret from peer if (roomType === 'secret') { peer.removeRoomSecret(roomSecret); } } _notifyPeers(peer, roomType = 'ip', roomSecret = '') { const room = roomType === 'ip' ? peer.ip : roomSecret; if (!this._rooms[room]) return; // notify all other peers for (const otherPeerId in this._rooms[room]) { if (otherPeerId === peer.id) continue; const otherPeer = this._rooms[room][otherPeerId]; this._send(otherPeer, { type: 'peer-joined', peer: peer.getInfo(), roomType: roomType, roomSecret: roomSecret }); } // notify peer about the other peers const otherPeers = []; for (const otherPeerId in this._rooms[room]) { if (otherPeerId === peer.id) continue; otherPeers.push(this._rooms[room][otherPeerId].getInfo()); } this._send(peer, { type: 'peers', peers: otherPeers, roomType: roomType, roomSecret: roomSecret }); } _joinSecretRooms(peer, roomSecrets) { for (let i=0; i 2 * timeout) { this._disconnect(peer); return; } this._send(peer, { type: 'ping' }); peer.timerId = setTimeout(() => this._keepAlive(peer), timeout); } _cancelKeepAlive(peer) { if (peer && peer.timerId) { clearTimeout(peer.timerId); } } } class Peer { constructor(socket, request) { // set socket this.socket = socket; // set remote ip this._setIP(request); // set peer id this._setPeerId(request); // is WebRTC supported ? this.rtcSupported = request.url.indexOf('webrtc') > -1; // set name this._setName(request); // for keepalive this.timerId = 0; this.lastBeat = Date.now(); this.roomSecrets = []; this.roomKey = null; this.roomKeyRate = 0; } _setIP(request) { if (request.headers['cf-connecting-ip']) { this.ip = request.headers['cf-connecting-ip'].split(/\s*,\s*/)[0]; } else if (request.headers['x-forwarded-for']) { this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0]; } else { this.ip = request.connection.remoteAddress; } // remove the prefix used for IPv4-translated addresses if (this.ip.substring(0,7) === "::ffff:") this.ip = this.ip.substring(7); let ipv6_was_localized = false; if (ipv6_lcl && this.ip.includes(':')) { this.ip = this.ip.split(':',ipv6_lcl).join(':'); ipv6_was_localized = true; } if (debugMode) { console.debug("----DEBUGGING-PEER-IP-START----"); console.debug("remoteAddress:", request.connection.remoteAddress); console.debug("x-forwarded-for:", request.headers['x-forwarded-for']); console.debug("cf-connecting-ip:", request.headers['cf-connecting-ip']); if (ipv6_was_localized) console.debug("IPv6 client IP was localized to", ipv6_lcl, ipv6_lcl > 1 ? "segments" : "segment"); console.debug("MoreLoad uses:", this.ip); console.debug("IP is private:", this.ipIsPrivate(this.ip)); console.debug("if IP is private, '127.0.0.1' is used instead"); console.debug("----DEBUGGING-PEER-IP-END----"); } // IPv4 and IPv6 use different values to refer to localhost // put all peers on the same network as the server into the same room as well if (this.ip === '::1' || this.ipIsPrivate(this.ip)) { this.ip = '127.0.0.1'; } } ipIsPrivate(ip) { // if ip is IPv4 if (!ip.includes(":")) { // 10.0.0.0 - 10.255.255.255 || 172.16.0.0 - 172.31.255.255 || 192.168.0.0 - 192.168.255.255 return /^(10)\.(.*)\.(.*)\.(.*)$/.test(ip) || /^(172)\.(1[6-9]|2[0-9]|3[0-1])\.(.*)\.(.*)$/.test(ip) || /^(192)\.(168)\.(.*)\.(.*)$/.test(ip) } // else: ip is IPv6 const firstWord = ip.split(":").find(el => !!el); //get first not empty word // The original IPv6 Site Local addresses (fec0::/10) are deprecated. Range: fec0 - feff if (/^fe[c-f][0-f]$/.test(firstWord)) return true; // These days Unique Local Addresses (ULA) are used in place of Site Local. // Range: fc00 - fcff else if (/^fc[0-f]{2}$/.test(firstWord)) return true; // Range: fd00 - fcff else if (/^fd[0-f]{2}$/.test(firstWord)) return true; // Link local addresses (prefixed with fe80) are not routable else if (firstWord === "fe80") return true; // Discard Prefix else if (firstWord === "100") return true; // Any other IP address is not Unique Local Address (ULA) return false; } _setPeerId(request) { const searchParams = new URL(request.url, "http://server").searchParams; let peerId = searchParams.get("peer_id"); let peerIdHash = searchParams.get("peer_id_hash"); if (peerId && Peer.isValidUuid(peerId) && this.isPeerIdHashValid(peerId, peerIdHash)) { this.id = peerId; } else { this.id = crypto.randomUUID(); } } toString() { return `` } _setName(req) { let ua = parser(req.headers['user-agent']); let deviceName = ''; if (ua.os && ua.os.name) { deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' '; } if (ua.device.model) { deviceName += ua.device.model; } else { deviceName += ua.browser.name; } if(!deviceName) deviceName = 'Unknown Device'; const displayName = uniqueNamesGenerator({ length: 2, separator: ' ', dictionaries: [colors, animals], style: 'capital', seed: cyrb53(this.id) }) this.name = { model: ua.device.model, os: ua.os.name, browser: ua.browser.name, type: ua.device.type, deviceName, displayName }; } getInfo() { return { id: this.id, name: this.name, rtcSupported: this.rtcSupported } } static isValidUuid(uuid) { return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid); } isPeerIdHashValid(peerId, peerIdHash) { return peerIdHash === hasher.hashCodeSalted(peerId); } addRoomSecret(roomSecret) { if (!(roomSecret in this.roomSecrets)) { this.roomSecrets.push(roomSecret); } } removeRoomSecret(roomSecret) { if (roomSecret in this.roomSecrets) { delete this.roomSecrets[roomSecret]; } } } const hasher = (() => { let password; return { hashCodeSalted(salt) { if (!password) { // password is created on first call. password = randomizer.getRandomString(128); } return crypto.createHash("sha3-512") .update(password) .update(crypto.createHash("sha3-512").update(salt, "utf8").digest("hex")) .digest("hex"); } } })() const randomizer = (() => { return { getRandomString(length) { let string = ""; while (string.length < length) { let arr = new Uint16Array(length); crypto.webcrypto.getRandomValues(arr); arr = Array.apply([], arr); /* turn into non-typed array */ arr = arr.map(function (r) { return r % 128 }) arr = arr.filter(function (r) { /* strip non-printables: if we transform into desirable range we have a probability bias, so I suppose we better skip this character */ return r === 45 || r >= 47 && r <= 57 || r >= 64 && r <= 90 || r >= 97 && r <= 122; }); string += String.fromCharCode.apply(String, arr); } return string.substring(0, length) } } })() /* cyrb53 (c) 2018 bryc (github.com/bryc) A fast and simple hash function with decent collision resistance. Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. Public domain. Attribution appreciated. */ const cyrb53 = function(str, seed = 0) { let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1>>>16), 2246822507) ^ Math.imul(h2 ^ (h2>>>13), 3266489909); h2 = Math.imul(h2 ^ (h2>>>16), 2246822507) ^ Math.imul(h1 ^ (h1>>>13), 3266489909); return 4294967296 * (2097151 & h2) + (h1>>>0); }; new MoreLoadServer();