diff --git a/Dockerfile b/Dockerfile index 74142ab..43c97ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --quiet --tries=1 --spider http://localhost:3000 || exit 1 -ENTRYPOINT ["node", "index.js"] \ No newline at end of file +ENTRYPOINT ["npm", "start"] \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 0a66535..0000000 --- a/index.js +++ /dev/null @@ -1,897 +0,0 @@ -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) -}) - -// Arguments for deployment with Docker and Node.js -const DEBUG_MODE = process.env.DEBUG_MODE === "true"; -const PORT = process.env.PORT || 3000; -const WS_FALLBACK = process.argv.includes('--include-ws-fallback') || process.env.WS_FALLBACK === "true"; -const IPV6_LOCALIZE = parseInt(process.env.IPV6_LOCALIZE) || false; -const RTC_CONFIG = process.env.RTC_CONFIG - ? JSON.parse(fs.readFileSync(process.env.RTC_CONFIG, 'utf8')) - : { - "sdpSemantics": "unified-plan", - "iceServers": [ - { - "urls": "stun:stun.l.google.com:19302" - } - ] - }; - -let rateLimit = false; -if (process.argv.includes('--rate-limit') || process.env.RATE_LIMIT === "true") { - rateLimit = 5; -} -else { - let envRateLimit = parseInt(process.env.RATE_LIMIT); - if (!isNaN(envRateLimit)) { - rateLimit = envRateLimit; - } -} -const RATE_LIMIT = rateLimit; - -// Arguments for deployment with Node.js only -const AUTO_START = process.argv.includes('--auto-restart'); -const LOCALHOST_ONLY = process.argv.includes('--localhost-only'); - -if (DEBUG_MODE) { - console.log("DEBUG_MODE is active. To protect privacy, do not use in production."); - console.debug("\n"); - console.debug("----DEBUG ENVIRONMENT VARIABLES----") - console.debug("DEBUG_MODE", DEBUG_MODE); - console.debug("PORT", PORT); - console.debug("WS_FALLBACK", WS_FALLBACK); - console.debug("IPV6_LOCALIZE", IPV6_LOCALIZE); - console.debug("RTC_CONFIG", RTC_CONFIG); - console.debug("RATE_LIMIT", RATE_LIMIT); - console.debug("AUTO_START", AUTO_START); - console.debug("LOCALHOST_ONLY", LOCALHOST_ONLY); - console.debug("\n"); -} - -if (AUTO_START) { - process.on( - 'uncaughtException', - () => { - process.once( - 'exit', - () => spawn( - process.argv.shift(), - process.argv, - { - cwd: process.cwd(), - detached: true, - stdio: 'inherit' - } - ) - ); - process.exit(); - } - ); -} - -const app = express(); - -if (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 - // see https://express-rate-limit.mintlify.app/guides/troubleshooting-proxy-issues - app.set('trust proxy', RATE_LIMIT); - - if (!DEBUG_MODE) { - console.log("Use DEBUG_MODE=true to find correct number for RATE_LIMIT."); - } -} - -if (WS_FALLBACK) { - app.use(express.static('public_included_ws_fallback')); -} -else { - app.use(express.static('public')); -} - -if (IPV6_LOCALIZE) { - if (!(0 < IPV6_LOCALIZE && IPV6_LOCALIZE < 8)) { - console.error("IPV6_LOCALIZE must be an integer between 1 and 7"); - return; - } - - console.log("IPv6 client IPs will be localized to", IPV6_LOCALIZE, IPV6_LOCALIZE === 1 ? "segment" : "segments"); -} - -app.use(function(req, res) { - if (DEBUG_MODE && RATE_LIMIT && req.path === "/ip") { - console.debug("----DEBUG RATE_LIMIT----") - console.debug("To find out the correct value for RATE_LIMIT go to '/ip' and ensure the returned IP-address is the IP-address of your client.") - console.debug("See https://github.com/express-rate-limit/express-rate-limit#troubleshooting-proxy-issues for more info") - console.debug("\n"); - res.send(req.ip); - } - - res.redirect('/'); -}); - -app.get('/', (req, res) => { - res.sendFile('index.html'); -}); - -const server = http.createServer(app); - -if (LOCALHOST_ONLY) { - server.listen(PORT, '127.0.0.1'); -} -else { - server.listen(PORT); -} - -server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - console.error(err); - console.info("Error EADDRINUSE received, exiting process without restarting process..."); - process.exit(0) - } -}); - -class PairDropServer { - - constructor() { - this._wss = new WebSocket.Server({ server }); - this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); - - this._rooms = {}; // { roomId: peers[] } - this._roomSecrets = {}; // { pairKey: roomSecret } - - this._keepAliveTimers = {}; - - console.log('PairDrop 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: RTC_CONFIG - }); - - // send displayName - this._send(peer, { - type: 'display-name', - 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': - this._setKeepAliveTimerToNow(sender); - break; - case 'join-ip-room': - this._joinIpRoom(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 'create-public-room': - this._onCreatePublicRoom(sender); - break; - case 'join-public-room': - this._onJoinPublicRoom(sender, message); - break; - case 'leave-public-room': - this._onLeavePublicRoom(sender); - break; - case 'signal': - default: - this._signalAndRelay(sender, message); - } - } - - _signalAndRelay(sender, message) { - const room = message.roomType === 'ip' - ? sender.ip - : message.roomId; - - // 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._removePairKey(sender.pairKey); - sender.pairKey = null; - - this._cancelKeepAlive(sender); - delete this._keepAliveTimers[sender.id]; - - this._leaveIpRoom(sender, true); - this._leaveAllSecretRooms(sender, true); - this._leavePublicRoom(sender, true); - - 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 5 * timeout) { - // Disconnect peer if unresponsive for 10s - this._disconnect(peer); - return; - } - - this._send(peer, { type: 'ping' }); - - this._keepAliveTimers[peer.id].timer = setTimeout(() => this._keepAlive(peer), timeout); - } - - _cancelKeepAlive(peer) { - if (this._keepAliveTimers[peer.id]?.timer) { - clearTimeout(this._keepAliveTimers[peer.id].timer); - } - } - - _setKeepAliveTimerToNow(peer) { - if (this._keepAliveTimers[peer.id]?.lastBeat) { - this._keepAliveTimers[peer.id].lastBeat = Date.now(); - } - } -} - - - -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); - - this.requestRate = 0; - - this.roomSecrets = []; - this.roomKey = null; - - this.publicRoomId = null; - } - - rateLimitReached() { - // rate limit implementation: max 10 attempts every 10s - if (this.requestRate >= 10) { - return true; - } - this.requestRate += 1; - setTimeout(() => this.requestRate -= 1, 10000); - return false; - } - - _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_LOCALIZE && this.ip.includes(':')) { - this.ip = this.ip.split(':',IPV6_LOCALIZE).join(':'); - ipv6_was_localized = true; - } - - if (DEBUG_MODE) { - 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_LOCALIZE, IPV6_LOCALIZE > 1 ? "segments" : "segment"); - } - console.debug("PairDrop 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 = (() => { - let charCodeLettersOnly = r => 65 <= r && r <= 90; - let charCodeAllPrintableChars = r => r === 45 || 47 <= r && r <= 57 || 64 <= r && r <= 90 || 97 <= r && r <= 122; - - return { - getRandomString(length, lettersOnly = false) { - const charCodeCondition = lettersOnly - ? charCodeLettersOnly - : charCodeAllPrintableChars; - - 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 charCodeCondition(r); - }); - 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 PairDropServer(); diff --git a/package.json b/package.json index ac41c5e..01cc03d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "pairdrop", "version": "1.9.4", + "type": "module", "description": "", - "main": "index.js", + "main": "server/index.js", "scripts": { - "start": "node index.js", - "start:prod": "node index.js --rate-limit --auto-restart" + "start": "node server/index.js", + "start:prod": "node server/index.js --rate-limit --auto-restart" }, "author": "", "license": "ISC", diff --git a/server/helper.js b/server/helper.js new file mode 100644 index 0000000..c8746d4 --- /dev/null +++ b/server/helper.js @@ -0,0 +1,65 @@ +import crypto from "crypto"; + +export 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"); + } + } +})() + +export const randomizer = (() => { + let charCodeLettersOnly = r => 65 <= r && r <= 90; + let charCodeAllPrintableChars = r => r === 45 || 47 <= r && r <= 57 || 64 <= r && r <= 90 || 97 <= r && r <= 122; + + return { + getRandomString(length, lettersOnly = false) { + const charCodeCondition = lettersOnly + ? charCodeLettersOnly + : charCodeAllPrintableChars; + + 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 charCodeCondition(r); + }); + 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. +*/ +export 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); +}; \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..db6dd9b --- /dev/null +++ b/server/index.js @@ -0,0 +1,114 @@ +import {spawn} from "child_process"; +import fs from "fs"; + +import PairDropServer from "./server.js"; +import PairDropWsServer from "./ws-server.js"; + +// 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) +}) + +// Evaluate arguments for deployment with Docker and Node.js +let conf = {}; +conf.debugMode = process.env.DEBUG_MODE === "true"; +conf.port = process.env.PORT || 3000; +conf.wsFallback = process.argv.includes('--include-ws-fallback') || process.env.WS_FALLBACK === "true"; +conf.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" + } + ] + }; + +let ipv6Localize = parseInt(process.env.IPV6_LOCALIZE) || false; +if (ipv6Localize) { + if (!(0 < ipv6Localize && ipv6Localize < 8)) { + console.error("ipv6Localize must be an integer between 1 and 7"); + process.exit(1); + } + + console.log("IPv6 client IPs will be localized to", + ipv6Localize, + ipv6Localize === 1 ? "segment" : "segments"); +} +conf.ipv6Localize = ipv6Localize; + +let rateLimit = false; +if (process.argv.includes('--rate-limit') || process.env.RATE_LIMIT === "true") { + rateLimit = 5; +} +else { + let envRateLimit = parseInt(process.env.RATE_LIMIT); + if (!isNaN(envRateLimit)) { + rateLimit = envRateLimit; + } +} +conf.rateLimit = rateLimit; + +// Evaluate arguments for deployment with Node.js only +conf.autoStart = process.argv.includes('--auto-restart'); +conf.localhostOnly = process.argv.includes('--localhost-only'); + +// Logs for debugging +if (conf.debugMode) { + console.log("DEBUG_MODE is active. To protect privacy, do not use in production."); + console.debug("\n"); + console.debug("----DEBUG ENVIRONMENT VARIABLES----") + console.debug(JSON.stringify(conf, null, 4)); + console.debug("\n"); +} + +// Start a new PairDrop instance when an uncaught exception occurs +if (conf.autoStart) { + process.on( + 'uncaughtException', + () => { + process.once( + 'exit', + () => spawn( + process.argv.shift(), + process.argv, + { + cwd: process.cwd(), + detached: true, + stdio: 'inherit' + } + ) + ); + process.exit(); + } + ); +} + +// Start server to serve client files +const pairDropServer = new PairDropServer(conf); + +// Start websocket Server +const pairDropWsServer = new PairDropWsServer(pairDropServer.server, conf); + diff --git a/server/peer.js b/server/peer.js new file mode 100644 index 0000000..5b556c5 --- /dev/null +++ b/server/peer.js @@ -0,0 +1,200 @@ +import crypto from "crypto"; +import parser from "ua-parser-js"; +import {animals, colors, uniqueNamesGenerator} from "unique-names-generator"; +import {cyrb53, hasher} from "./helper.js"; + +export default class Peer { + + constructor(socket, request, conf) { + this.conf = conf + + // 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); + + this.requestRate = 0; + + this.roomSecrets = []; + this.pairKey = null; + + this.publicRoomId = null; + } + + rateLimitReached() { + // rate limit implementation: max 10 attempts every 10s + if (this.requestRate >= 10) { + return true; + } + this.requestRate += 1; + setTimeout(_ => this.requestRate -= 1, 10000); + return false; + } + + _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 (this.conf.ipv6Localize && this.ip.includes(':')) { + this.ip = this.ip.split(':',this.conf.ipv6Localize).join(':'); + ipv6_was_localized = true; + } + + if (this.conf.debugMode) { + console.debug("\n"); + 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", this.conf.ipv6Localize, this.conf.ipv6Localize > 1 ? "segments" : "segment"); + } + console.debug("PairDrop 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 + + if (/^fe[c-f][0-f]$/.test(firstWord)) { + // The original IPv6 Site Local addresses (fec0::/10) are deprecated. Range: fec0 - feff + 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(); + } + } + + _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]; + } + } +} \ No newline at end of file diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..02c5643 --- /dev/null +++ b/server/server.js @@ -0,0 +1,73 @@ +import express from "express"; +import RateLimit from "express-rate-limit"; +import {fileURLToPath} from "url"; +import path, {dirname} from "path"; +import http from "http"; + +export default class PairDropServer { + + constructor(conf) { + const app = express(); + + if (conf.rateLimit) { + 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 + // see https://express-rate-limit.mintlify.app/guides/troubleshooting-proxy-issues + + app.set('trust proxy', conf.rateLimit); + + if (!conf.debugMode) { + console.log("Use DEBUG_MODE=true to find correct number for RATE_LIMIT."); + } + } + + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + + const publicPathAbs = conf.wsFallback + ? path.join(__dirname, '../public_included_ws_fallback') + : path.join(__dirname, '../public'); + + app.use(express.static(publicPathAbs)); + + app.use((req, res) => { + if (conf.debugMode && conf.rateLimit && req.path === "/ip") { + console.debug("\n"); + console.debug("----DEBUG RATE_LIMIT----") + console.debug("To find out the correct value for RATE_LIMIT go to '/ip' and ensure the returned IP-address is the IP-address of your client.") + console.debug("See https://github.com/express-rate-limit/express-rate-limit#troubleshooting-proxy-issues for more info") + res.send(req.ip); + } + + res.redirect(301, '/'); + }); + + app.get('/', (req, res) => { + res.sendFile('index.html'); + console.log(`Serving client files from:\n${publicPathAbs}`) + }); + + const hostname = conf.localhostOnly ? '127.0.0.1' : null; + const server = http.createServer(app); + + server.listen(conf.port, hostname); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(err); + console.info("Error EADDRINUSE received, exiting process without restarting process..."); + process.exit(1) + } + }); + + this.server = server + } +} \ No newline at end of file diff --git a/server/ws-server.js b/server/ws-server.js new file mode 100644 index 0000000..3779f83 --- /dev/null +++ b/server/ws-server.js @@ -0,0 +1,466 @@ +import {WebSocketServer} from "ws"; +import crypto from "crypto" + +import Peer from "./peer.js"; +import {hasher, randomizer} from "./helper.js"; + +export default class PairDropWsServer { + + constructor(server, conf) { + this._conf = conf + + this._rooms = {}; // { roomId: peers[] } + + this._roomSecrets = {}; // { pairKey: roomSecret } + this._keepAliveTimers = {}; + + this._wss = new WebSocketServer({ server }); + this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request, conf))); + console.log('\nPairDrop is running on port', this._conf.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: this._conf.rtcConfig + }); + + // send displayName + this._send(peer, { + type: 'display-name', + 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) { + console.warn("WS: Received JSON is malformed"); + return; + } + + switch (message.type) { + case 'disconnect': + this._onDisconnect(sender); + break; + case 'pong': + this._setKeepAliveTimerToNow(sender); + break; + case 'join-ip-room': + this._joinIpRoom(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 'create-public-room': + this._onCreatePublicRoom(sender); + break; + case 'join-public-room': + this._onJoinPublicRoom(sender, message); + break; + case 'leave-public-room': + this._onLeavePublicRoom(sender); + break; + case 'signal': + default: + this._signalAndRelay(sender, message); + } + } + + _signalAndRelay(sender, message) { + const room = message.roomType === 'ip' + ? sender.ip + : message.roomId; + + // 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._removePairKey(sender.pairKey); + sender.pairKey = null; + + this._cancelKeepAlive(sender); + delete this._keepAliveTimers[sender.id]; + + this._leaveIpRoom(sender, true); + this._leaveAllSecretRooms(sender, true); + this._leavePublicRoom(sender, true); + + 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 5 * timeout) { + // Disconnect peer if unresponsive for 10s + this._disconnect(peer); + return; + } + + this._send(peer, { type: 'ping' }); + + this._keepAliveTimers[peer.id].timer = setTimeout(() => this._keepAlive(peer), timeout); + } + + _cancelKeepAlive(peer) { + if (this._keepAliveTimers[peer.id]?.timer) { + clearTimeout(this._keepAliveTimers[peer.id].timer); + } + } + + _setKeepAliveTimerToNow(peer) { + if (this._keepAliveTimers[peer.id]?.lastBeat) { + this._keepAliveTimers[peer.id].lastBeat = Date.now(); + } + } +} +