├── .eslintignore ├── .gitignore ├── lib ├── user.js ├── methods │ ├── refresh.js │ ├── createPermission.js │ ├── send.js │ ├── channelBind.js │ └── allocate.js ├── transport.js ├── channelMessage.js ├── address.js ├── data.js ├── network.js ├── constants.js ├── allocation.js ├── server.js ├── authentification.js ├── message.js └── attribute.js ├── .npmignore ├── .travis.yml ├── .eslintrc ├── karma.conf.js ├── package.json ├── LICENSE.md ├── start.js ├── sample-config.conf ├── README.md └── test └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | test/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .nyc_output/ -------------------------------------------------------------------------------- /lib/user.js: -------------------------------------------------------------------------------- 1 | var user = function(username, password) { 2 | this.username = username; 3 | this.password = password; 4 | }; 5 | 6 | module.exports = user; 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | package-lock.json 4 | node_modules 5 | test 6 | karma.conf.js 7 | .eslintignore 8 | .eslintrc 9 | .nyc_output/ 10 | .travis.yml 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | - '8' 5 | - '10' 6 | addons: 7 | chrome: stable 8 | firefox: latest 9 | before_script: 10 | - export DISPLAY=:99.0 11 | - sh -e /etc/init.d/xvfb start 12 | sudo: required 13 | after_success: npm run coveralls 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module" 5 | }, 6 | "extends": "eslint:recommended", 7 | "env": { 8 | "node": true, 9 | "es6": true 10 | }, 11 | "rules": { 12 | "indent": [1, 2, { "SwitchCase": 1 }], 13 | "no-undef": 1, 14 | "no-console": 1, 15 | "keyword-spacing": 1, 16 | "no-trailing-spaces": 1, 17 | 18 | "no-unused-vars": 1, 19 | "no-redeclare": 1, 20 | "no-unused-labels": 1, 21 | "no-unreachable": 1, 22 | "semi": [1, "always"], 23 | "no-extra-semi": 1, 24 | "no-fallthrough": 1, 25 | "no-constant-condition": 1, 26 | "no-mixed-spaces-and-tabs": 1, 27 | "no-case-declarations": 1 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const Server = require('./lib/server'); 2 | 3 | module.exports = function (config) { 4 | const server = new Server({ 5 | debugLevel: 'NONE', 6 | authMech: 'long-term', 7 | credentials: { 8 | username: "password" 9 | } 10 | }); 11 | 12 | server.start(); 13 | 14 | config.set({ 15 | basePath: '', 16 | frameworks: ['mocha'], 17 | logLevel: config.LOG_INFO, 18 | files: [ 19 | './test/test.js' 20 | ], 21 | 22 | browsers : ['ChromeHeadlessNoSandbox'], 23 | 24 | customLaunchers: { 25 | ChromeHeadlessNoSandbox: { 26 | base: 'ChromeHeadless', 27 | flags: ['--no-sandbox'] 28 | } 29 | }, 30 | 31 | singleRun: true, 32 | 33 | autoWatch: true, 34 | autoWatchBatchDelay: 5000, 35 | 36 | reporters: ['mocha'], 37 | port: 9876, 38 | colors: true, 39 | concurrency: 1 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /lib/methods/refresh.js: -------------------------------------------------------------------------------- 1 | var refresh = function(server) { 2 | var self = this; 3 | this.server = server; 4 | 5 | this.server.on('refresh', function(msg, reply) { 6 | self.refresh(msg, reply); 7 | }); 8 | }; 9 | 10 | refresh.prototype.refresh = function(msg, reply) { 11 | var desiredLifetime = this.server.defaultLifetime; 12 | var lifetime = msg.getAttribute('lifetime'); 13 | if (lifetime !== void 0) { 14 | if (lifetime === 0) { 15 | desiredLifetime = 0; 16 | } else { 17 | desiredLifetime = Math.min(lifetime, this.server.maxAllocateTimeout); 18 | } 19 | } 20 | 21 | if (desiredLifetime === 0) { 22 | delete this.server.allocations[msg.transport.get5Tuple()]; 23 | } else { 24 | msg.allocation.update(desiredLifetime); 25 | } 26 | reply.addAttribute('lifetime', desiredLifetime); 27 | reply.addAttribute('software', this.server.software); 28 | reply.addAttribute('message-integrity'); 29 | reply.resolve(); 30 | }; 31 | 32 | module.exports = refresh; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-turn", 3 | "version": "0.0.6", 4 | "description": "STUN/TURN Server", 5 | "main": "./lib/server.js", 6 | "scripts": { 7 | "start": "node start", 8 | "test": "nyc karma start", 9 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 10 | "lint": "eslint ./" 11 | }, 12 | "author": "Atlantis Software", 13 | "license": "MIT", 14 | "dependencies": { 15 | "crc": "~3.8.0", 16 | "js-yaml": "~3.14.0", 17 | "log4js": "~6.3.0" 18 | }, 19 | "devDependencies": { 20 | "coveralls": "~3.1.0", 21 | "eslint": "~7.9.0", 22 | "karma": "~5.2.2", 23 | "karma-chrome-launcher": "~3.1.0", 24 | "karma-mocha": "~2.0.1", 25 | "karma-mocha-reporter": "~2.2.5", 26 | "mocha": "~8.1.3", 27 | "nyc": "~15.1.0" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/Atlantis-Software/node-turn.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/Atlantis-Software/node-turn/issues" 35 | }, 36 | "homepage": "https://github.com/Atlantis-Software/node-turn#readme" 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | -- 3 | 4 | Copyright © 2018 Atlantis Software 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /lib/methods/createPermission.js: -------------------------------------------------------------------------------- 1 | var createPermission = function(server) { 2 | var self = this; 3 | this.server = server; 4 | 5 | this.server.on('create_permission', function(msg, reply) { 6 | self.createPermission(msg, reply); 7 | }); 8 | 9 | }; 10 | 11 | createPermission.prototype.createPermission = function(msg, reply) { 12 | var xorPeerAddresses = msg.getAttributes('xor-peer-address'); 13 | if (xorPeerAddresses.length === 0) { 14 | return reply.reject(400, 'Bad Request'); 15 | } 16 | var badRequest = false; 17 | var permissions = []; 18 | xorPeerAddresses.forEach(function(xorPeerAddress) { 19 | if (!xorPeerAddress.address) { 20 | badRequest = true; 21 | } 22 | permissions.push(xorPeerAddress.address); 23 | }); 24 | 25 | if (badRequest) { 26 | return reply.reject(400, 'Bad Request'); 27 | } 28 | 29 | permissions.forEach(function(address) { 30 | msg.allocation.permit(address); 31 | }); 32 | reply.addAttribute('software', this.server.software); 33 | reply.addAttribute('message-integrity'); 34 | reply.resolve(); 35 | }; 36 | 37 | module.exports = createPermission; 38 | -------------------------------------------------------------------------------- /lib/methods/send.js: -------------------------------------------------------------------------------- 1 | var send = function(server) { 2 | var self = this; 3 | this.server = server; 4 | 5 | this.server.on('send', function(msg, reply) { 6 | self.send(msg, reply); 7 | }); 8 | }; 9 | 10 | send.prototype.send = function(msg) { 11 | var self = this; 12 | // the destination transport address is taken from the XOR-PEER-ADDRESS attribute 13 | var dst = msg.getAttribute('xor-peer-address'); 14 | var data = msg.getAttribute('data'); 15 | // var dontFragment = msg.getAttribute('dont-fragment'); 16 | 17 | if (!dst || !data) { 18 | msg.debug('TRACE', 'Invalid attribute for ' + msg); 19 | return; 20 | } 21 | 22 | var permission = msg.allocation.permissions[dst.address]; 23 | 24 | if (!permission || permission < Date.now()) { 25 | msg.debug('TRACE', 'No permission for ' + msg); 26 | return; 27 | } 28 | 29 | msg.allocation.sockets[0].send(data, dst.port, dst.address, function(err) { 30 | if (err) { 31 | return msg.debug('ERROR', err); 32 | } 33 | msg.debug('TRACE', 'relaying data from transactionID ' + msg.transactionID + ' to ' + dst); 34 | }); 35 | }; 36 | 37 | module.exports = send; 38 | -------------------------------------------------------------------------------- /lib/transport.js: -------------------------------------------------------------------------------- 1 | const CONSTANTS = require('./constants'); 2 | 3 | var transport = function(protocol, src, dst, socket) { 4 | this.protocol = protocol; 5 | this.src = src; 6 | this.dst = dst; 7 | this.socket = socket; 8 | }; 9 | 10 | transport.prototype.get5Tuple = function() { 11 | var fiveTuple = ''; 12 | 13 | switch (this.protocol) { 14 | case CONSTANTS.TRANSPORT.PROTOCOL.UDP: 15 | fiveTuple += 'UDP'; 16 | break; 17 | } 18 | 19 | switch (this.family) { 20 | case CONSTANTS.TRANSPORT.FAMILY.IPV4: 21 | fiveTuple += '4'; 22 | break; 23 | case CONSTANTS.TRANSPORT.FAMILY.IPV6: 24 | fiveTuple += '6'; 25 | break; 26 | } 27 | 28 | fiveTuple += '://' + this.src.address + ':' + this.src.port + '>' + this.dst.address + ':' + this.dst.port; 29 | 30 | return fiveTuple; 31 | }; 32 | 33 | transport.prototype.toString = function() { 34 | var str = ''; 35 | switch (this.protocol) { 36 | case CONSTANTS.TRANSPORT.PROTOCOL.UDP: 37 | str += 'UDP'; 38 | break; 39 | default: 40 | str += 'UNKNOWN PROTOCOL'; 41 | } 42 | str += ': from ' + this.src + ' to ' + this.dst; 43 | return str; 44 | }, 45 | 46 | transport.prototype.revert = function() { 47 | return new transport(this.protocol, this.dst, this.src, this.socket); 48 | }; 49 | 50 | module.exports = transport; 51 | -------------------------------------------------------------------------------- /lib/channelMessage.js: -------------------------------------------------------------------------------- 1 | var channelMsg = function(channelNumber, data) { 2 | this.channelNumber = channelNumber; 3 | this.length = 0; 4 | this.data = null; 5 | this.padding = 0; 6 | if (data) { 7 | this.data = data; 8 | this.length = data.length; 9 | this.padding = this.length % 4 ? 4 - (this.length % 4) : 0; 10 | } 11 | }; 12 | 13 | channelMsg.prototype.read = function(data) { 14 | this.channelNumber = data.readUInt16BE(0); 15 | if (this.channelNumber < 0x4000 || this.channelNumber > 0x7FFE) { 16 | return false; 17 | } 18 | this.length = data.readUInt16BE(2); 19 | if (this.length > (data.length - 4)) { 20 | return false; 21 | } 22 | this.padding = data.length - this.length - 4; 23 | if (this.padding > 3) { 24 | return false; 25 | } 26 | this.data = data.slice(4, this.length + 4); 27 | return true; 28 | }; 29 | 30 | channelMsg.prototype.write = function() { 31 | if (!this.channelNumber) { 32 | throw new Error('Channel Message require a channelNumber'); 33 | } 34 | if (!this.data || !Buffer.isBuffer(this.data)) { 35 | throw new Error('Channel Message require a data buffer'); 36 | } 37 | var header = Buffer.alloc(4); 38 | header.writeUInt16BE(this.channelNumber, 0); 39 | header.writeUInt16BE(this.length, 2); 40 | this.padding = this.length % 4 ? 4 - (this.length % 4) : 0; 41 | return Buffer.concat([header, this.data, Buffer.alloc(this.padding)]); 42 | }; 43 | 44 | module.exports = channelMsg; -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | const Server = require('./lib/server'); 2 | const yaml = require('js-yaml'); 3 | const fs = require('fs'); 4 | const log4js = require('log4js'); 5 | const os = require('os'); 6 | 7 | 8 | try { 9 | var logFile; 10 | var configFile; 11 | 12 | switch (os.platform()) { 13 | case 'win32': 14 | logFile = 'c:\\Windows\\Logs\\node-turn.log'; 15 | configFile = 'C:\\ProgramData\\node-turn.conf'; 16 | break; 17 | default: 18 | logFile = '/var/log/node-turn.log'; 19 | configFile = '/etc/node-turn/node-turn.conf'; 20 | break; 21 | } 22 | 23 | var appenders = {}; 24 | var hostname = os.hostname(); 25 | appenders[hostname] = { type: 'file', filename: logFile }; 26 | 27 | /* eslint-disable no-console */ 28 | if (!fs.existsSync(logFile)) { 29 | appenders[hostname] = { type: 'console'}; 30 | console.log('Using STDOUT for logging'); 31 | console.log('Please create a the log file ' + logFile + ' with correct permission'); 32 | } 33 | 34 | if (!fs.existsSync(configFile)) { 35 | console.log('Using sample-config.conf'); 36 | console.log('Please copy with correct permission and modify sample-config.conf to ' + configFile); 37 | configFile = 'sample-config.conf'; 38 | } 39 | /* eslint-enable no-console */ 40 | 41 | var config = yaml.safeLoad(fs.readFileSync(configFile, 'utf8')); 42 | 43 | log4js.configure({ 44 | appenders: appenders, 45 | categories: { default: { appenders: [hostname], level: config['debug-level'] || 'ERROR' } } 46 | }); 47 | 48 | const logger = log4js.getLogger(hostname); 49 | var debug = function(level, message) { 50 | level = level.toLowerCase(); 51 | logger[level](message); 52 | }; 53 | 54 | config.debug = debug; 55 | 56 | var server = new Server(config); 57 | server.start(); 58 | } catch (e) { 59 | console.log(e); // eslint-disable-line no-console 60 | } 61 | -------------------------------------------------------------------------------- /lib/address.js: -------------------------------------------------------------------------------- 1 | const CONSTANTS = require('./constants'); 2 | 3 | var ipv4Regex = /^(\d{1,3}\.){3,3}\d{1,3}$/; 4 | var ipv6Regex = 5 | /^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i; 6 | 7 | var address = function(address, port) { 8 | if (typeof address !== 'string') { 9 | throw new Error('address must be a string instead of ' + typeof address + ' ' + JSON.stringify(address)); 10 | } 11 | 12 | // remove interface name for ipv6 13 | address = address.split('%')[0]; 14 | 15 | if (ipv4Regex.test(address)) { 16 | this.family = CONSTANTS.TRANSPORT.FAMILY.IPV4; 17 | this.address = address; 18 | } else if (ipv6Regex.test(address)) { 19 | this.family = CONSTANTS.TRANSPORT.FAMILY.IPV6; 20 | if (address.indexOf("::") == -1) { 21 | this.address = address; 22 | } else { 23 | var sides = address.split('::'); 24 | var left = sides[0].split(':'); 25 | if (left[0] === '') { 26 | left[0] = '0'; 27 | } 28 | var right = sides[1].split(':'); 29 | var digits = [].concat(left); 30 | for (var i = left.length; i <= (8 - left.length - right.length); i++) { 31 | digits.push('0'); 32 | } 33 | digits = digits.concat(right); 34 | this.address = digits.join(':'); 35 | } 36 | } else { 37 | throw new Error('invalid ip address'); 38 | } 39 | this.port = port; 40 | }; 41 | 42 | address.prototype.UintAddress = function() { 43 | var ip = this.address.split('.'); 44 | var address = parseInt(ip[0]) * Math.pow(2, 24); 45 | address += parseInt(ip[1]) * Math.pow(2, 16); 46 | address += parseInt(ip[2]) * Math.pow(2, 8); 47 | address += parseInt(ip[3]); 48 | return address; 49 | }; 50 | 51 | address.prototype.toString = function() { 52 | var str = ''; 53 | switch (this.family) { 54 | case CONSTANTS.TRANSPORT.FAMILY.IPV4: 55 | str += 'IPV4://'; 56 | break; 57 | case CONSTANTS.TRANSPORT.FAMILY.IPV6: 58 | str += 'IPV6://'; 59 | } 60 | str += this.address + ':' + this.port; 61 | return str; 62 | }; 63 | 64 | module.exports = address; -------------------------------------------------------------------------------- /lib/data.js: -------------------------------------------------------------------------------- 1 | var data = function(buffer) { 2 | Object.setPrototypeOf(buffer, data.prototype); 3 | return buffer; 4 | }; 5 | 6 | Object.setPrototypeOf(data.prototype, Buffer.prototype); 7 | Object.setPrototypeOf(data, Buffer); 8 | 9 | data.alloc = function(size) { 10 | return new data(new Buffer.alloc(size)); 11 | }; 12 | 13 | data.prototype.readBit = function(index) { 14 | var byte = index >> 3; 15 | var bit = index & 7; 16 | return !!(this[byte] & (128 >> bit)); 17 | }; 18 | 19 | data.prototype.writeBit = function(value, index) { 20 | var byte = index >> 3; 21 | var bit = index & 7; 22 | var mask = 128 >> bit; 23 | 24 | var currentByte = this[byte]; 25 | var newByte = value ? currentByte | mask : currentByte & ~mask; 26 | 27 | if (currentByte === newByte) { 28 | return false; 29 | } 30 | 31 | this[byte] = newByte; 32 | return true; 33 | }; 34 | 35 | data.prototype.readUncontiguous = function(indexArray) { 36 | var self = this; 37 | var value = 0; 38 | indexArray.forEach(function(bitIndex, i) { 39 | let weight = Math.pow(2, indexArray.length -1 - i); 40 | if (self.readBit(bitIndex)) { 41 | value += weight; 42 | } 43 | }); 44 | return value; 45 | }; 46 | 47 | data.prototype.writeUncontiguous = function(value, indexArray) { 48 | var bits = Array.from(value.toString(2)); 49 | if (bits.length > indexArray.length) { 50 | throw new Error('value is larger than specified data size'); 51 | } 52 | for (var i = 0; i < indexArray.length; i++) { 53 | var pos = indexArray[indexArray.length - 1 - i]; 54 | var bit = 0; 55 | if (i < bits.length) { 56 | bit = bits[bits.length -1 - i] === "1" ? 1 : 0; 57 | } 58 | this.writeBit(bit, pos); 59 | } 60 | }; 61 | 62 | data.prototype.writeWord = function(value, offset, length) { 63 | var bits = Array.from(value.toString(2)); 64 | if (bits.length > length) { 65 | throw new Error('value is larger than specified data size'); 66 | } 67 | for (var i = 0; i < length; i++) { 68 | var pos = offset + length - i - 1; 69 | var bit = 0; 70 | if (i < bits.length) { 71 | bit = bits[bits.length -1 - i] === "1" ? 1 : 0; 72 | } 73 | this.writeBit(bit, pos); 74 | } 75 | }; 76 | 77 | module.exports = data; 78 | -------------------------------------------------------------------------------- /lib/network.js: -------------------------------------------------------------------------------- 1 | const dgram = require('dgram'); 2 | const Message = require('./message'); 3 | const Transport = require('./transport'); 4 | const Address = require('./address'); 5 | const CONSTANTS = require('./constants'); 6 | 7 | var network = function(server) { 8 | this.sockets = []; 9 | this.server = server; 10 | this.listeningIps = server.listeningIps; 11 | this.listeningPort = server.listeningPort; 12 | this.debug = server.debug.bind(server); 13 | this.debugLevel = server.debugLevel; 14 | }; 15 | 16 | network.prototype.start = function() { 17 | var self = this; 18 | this.listeningIps.forEach(function(ip) { 19 | const dst = new Address(ip, self.listeningPort); 20 | var family = 'udp4'; 21 | if (dst.family === CONSTANTS.TRANSPORT.FAMILY.IPV6) { 22 | if (ip.startsWith('fe80:') && !ip.indexOf('%')) { 23 | self.debug('FATAL', 'local-link ipv6 address require a specified interface ex: fe80::0000:0000:0000:0000%eth0'); 24 | return; 25 | } 26 | family = 'udp6'; 27 | } 28 | const udpSocket = dgram.createSocket(family); 29 | 30 | udpSocket.on('error', function(err) { 31 | self.debug('FATAL', err); 32 | }); 33 | 34 | udpSocket.on('message', function(udpMessage, rinfo) { 35 | // shoud detect destination address for dst but https://github.com/nodejs/node/issues/1649 36 | const src = new Address(rinfo.address, rinfo.port); 37 | const transport = new Transport(CONSTANTS.TRANSPORT.PROTOCOL.UDP, src, dst, udpSocket); 38 | var msg = new Message(self.server, transport); 39 | try { 40 | if (msg.read(udpMessage)) { 41 | self.server.emit('message', msg); 42 | } 43 | } catch(err) { 44 | self.debug('ERROR', err); 45 | } 46 | }); 47 | 48 | udpSocket.on('listening', function() { 49 | self.debug('INFO', 'Server is listening on ' + ip + ':' + self.listeningPort); 50 | }); 51 | 52 | udpSocket.on('close', function() { 53 | self.debug('INFO', 'Server is no more listening on ' + ip + ':' + self.listeningPort); 54 | }); 55 | 56 | udpSocket.bind({ 57 | address: ip, 58 | port: self.listeningPort, 59 | exclusive: true 60 | }); 61 | 62 | self.sockets.push(udpSocket); 63 | 64 | }); 65 | }; 66 | 67 | network.prototype.stop = function() { 68 | this.sockets.forEach(function(socket) { 69 | socket.close(); 70 | }); 71 | }; 72 | 73 | module.exports = network; 74 | -------------------------------------------------------------------------------- /sample-config.conf: -------------------------------------------------------------------------------- 1 | # node-turn Server configuration file 2 | 3 | # TURN listener port for UDP (Default: 3478). 4 | # 5 | #listeningPort: 3478 6 | 7 | # Listener IP address of relay server. Multiple listeners can be specified. 8 | # If no IP(s) specified in the config file or in the command line options, 9 | # then all system IPs will be used for listening. 10 | # 11 | #listeningIps: [172.17.19.101, 10.207.21.238] 12 | #listeningIps: [172.17.19.101] 13 | 14 | # Relay address (the local IP address that will be used to relay the 15 | # packets to the peer). 16 | # Multiple relay addresses may be used. 17 | # The same IP(s) can be used as both listening IP(s) and relay IP(s). 18 | # 19 | # If no relay IP(s) specified, then the turnserver will apply the default 20 | # policy: it will decide itself which relay addresses to be used, and it 21 | # will always be using the client socket IP address as the relay IP address 22 | # of the TURN session (if the requested relay address family is the same 23 | # as the family of the client socket). 24 | # 25 | #relayIps: [172.17.19.101, 10.207.21.238] 26 | #relayIps: [172.17.19.101] 27 | 28 | # Lower and upper bounds of the UDP relay endpoints: 29 | # (default values are 49152 and 65535) 30 | # 31 | #minPort: 49152 32 | #maxPort: 65535 33 | 34 | # TURN credential mechanism. 35 | # By default no credentials mechanism is used (any user allowed). 36 | # possible value: 37 | # - none to disable authentification 38 | # - short-term to use short-term mechanism 39 | # - long-term to use long-term mechanism 40 | # 41 | authMech: long-term 42 | 43 | # user accounts for credentials mechanisms. 44 | # 45 | credentials: 46 | username: password 47 | username2: password2 48 | 49 | # The realm to be used for the users with long-term credentials mechanism. 50 | # 51 | #realm: atlantis-software.net 52 | 53 | # Server log verbose level (Default: ERROR). 54 | # possible value: 55 | # - OFF nothing is logged 56 | # - FATAL fatal errors are logged 57 | # - ERROR errors are logged 58 | # - WARN warnings are logged 59 | # - INFO infos are logged 60 | # - DEBUG debug infos are logged 61 | # - TRACE traces are logged 62 | # - ALL everything is logged 63 | # 64 | debugLevel: ALL 65 | 66 | # Option to set the max lifetime, in seconds, allowed for allocations. 67 | # Default is 3600 seconds. 68 | # 69 | #maxAllocateLifetime: 3600 70 | 71 | # Option to set the default allocation lifetime, in seconds. 72 | # Default is 600 seconds. 73 | # 74 | #defaultAllocatetLifetime: 600 75 | -------------------------------------------------------------------------------- /lib/methods/channelBind.js: -------------------------------------------------------------------------------- 1 | var channelBind = function(server) { 2 | var self = this; 3 | this.server = server; 4 | 5 | this.server.on('channel_bind', function(msg, reply) { 6 | self.channelBind(msg, reply); 7 | }); 8 | }; 9 | 10 | channelBind.prototype.channelBind = function(msg, reply) { 11 | var channelNumber = msg.getAttribute('channel-number'); 12 | var peer = msg.getAttribute('xor-peer-address'); 13 | 14 | // The request contains both a CHANNEL-NUMBER and an XOR-PEER-ADDRESS attribute 15 | if (!channelNumber || !peer) { 16 | msg.debug('TRACE', 'transactionID' + msg.transactionID + ' The request MUST contains both a CHANNEL-NUMBER and an XOR-PEER-ADDRESS attribute'); 17 | return reply.reject(400, 'Bad Request'); 18 | } 19 | 20 | // The channel number is in the range 0x4000 through 0x7FFE (inclusive) 21 | if (channelNumber < 0x4000 || channelNumber > 0x7FFE) { 22 | msg.debug('TRACE', 'transactionID' + msg.transactionID + ' The channel number MUST be in the range 0x4000 through 0x7FFE (inclusive)'); 23 | return reply.reject(400, 'Bad Request'); 24 | } 25 | 26 | var boundChannelNumber = msg.allocation.getPeerChannelNumber(peer); 27 | 28 | // The channel number is not currently bound to a different transport address (same transport address is OK) 29 | var channel = msg.allocation.channelBindings[channelNumber]; 30 | if (channel && boundChannelNumber !== channelNumber) { 31 | msg.debug('TRACE', 'transactionID' + msg.transactionID + ' The channel number is currently bound to a different transport address'); 32 | return reply.reject(400, 'Bad Request'); 33 | } 34 | 35 | // The transport address is not currently bound to a different channel number 36 | if (boundChannelNumber && boundChannelNumber !== channelNumber) { 37 | msg.debug('TRACE', 'transactionID' + msg.transactionID + ' The transport address is currently bound to a different channel number'); 38 | return reply.reject(400, 'Bad Request'); 39 | } 40 | 41 | // If a value in the XOR-PEER-ADDRESS attribute is not allowed, the server rejects the request with a 403 (Forbidden) error. 42 | // TODO 43 | if (0) { // eslint-disable-line no-constant-condition 44 | return reply.reject(403, 'Forbidden'); 45 | } 46 | 47 | // If the server is unable to fulfill the request, the server replies with a 508 (Insufficient Capacity) error. 48 | // TODO 49 | if (0) { // eslint-disable-line no-constant-condition 50 | return reply.reject(508, 'Insufficient Capacity'); 51 | } 52 | 53 | msg.allocation.permit(peer.address); 54 | msg.allocation.channelBindings[channelNumber] = peer; 55 | 56 | reply.resolve(); 57 | }; 58 | 59 | module.exports = channelBind; 60 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | const constants = { 2 | // STUN Attributes 3 | ATTR: { 4 | // https://tools.ietf.org/html/rfc5389#section-18.2 5 | // Comprehension-required range (0x0000-0x7FFF): 6 | // 0x0000: (Reserved) 7 | MAPPED_ADDRESS: 0x0001, 8 | // 0x0002: (Reserved; was RESPONSE-ADDRESS) 9 | // 0x0003: (Reserved; was CHANGE-ADDRESS) 10 | // 0x0004: (Reserved; was SOURCE-ADDRESS) 11 | // 0x0005: (Reserved; was CHANGED-ADDRESS) 12 | USERNAME: 0x0006, 13 | MESSAGE_INTEGRITY: 0x0008, 14 | ERROR_CODE: 0x0009, 15 | UNKNOWN_ATTRIBUTES: 0x000A, 16 | // 0x000B: (Reserved; was REFLECTED-FROM) 17 | REALM: 0x0014, 18 | NONCE: 0x0015, 19 | XOR_MAPPED_ADDRESS: 0x0020, 20 | // Comprehension-optional range (0x8000-0xFFFF) 21 | SOFTWARE: 0x8022, 22 | ALTERNATE_SERVER: 0x8023, 23 | FINGERPRINT: 0x8028, 24 | // https://tools.ietf.org/html/rfc5766#section-6.2 25 | CHANNEL_NUMBER: 0x000C, 26 | LIFETIME: 0x000D, 27 | // 0x0010: Reserved (was BANDWIDTH) 28 | XOR_PEER_ADDRESS: 0x0012, 29 | DATA: 0x0013, 30 | XOR_RELAYED_ADDRESS: 0x0016, 31 | EVEN_PORT: 0x0018, 32 | REQUESTED_TRANSPORT: 0x0019, 33 | DONT_FRAGMENT: 0x001A, 34 | // 0x0021: Reserved (was TIMER-VAL) 35 | RESERVATION_TOKEN: 0x0022 36 | }, 37 | // STUN Methods 38 | METHOD: { 39 | // https://tools.ietf.org/html/rfc5389#section-18.1 40 | // 0x000: (Reserved) 41 | BINDING: 0x001, 42 | // 0x002: (Reserved; was SharedSecret) 43 | // https://tools.ietf.org/html/rfc5766#section-6.2 44 | ALLOCATE: 0x003, // (only request/response semantics defined) 45 | REFRESH: 0x004, // (only request/response semantics defined) 46 | SEND: 0x006, // (only indication semantics defined) 47 | DATA: 0x007, // (only indication semantics defined) 48 | CREATE_PERMISSION: 0x008, // (only request/response semantics defined) 49 | CHANNEL_BIND: 0x009 // (only request/response semantics defined) 50 | }, 51 | CLASS: { 52 | REQUEST: 0x00, 53 | INDICATION: 0x01, 54 | SUCCESS: 0x02, 55 | ERROR: 0x03 56 | }, 57 | TRANSPORT: { 58 | FAMILY: { 59 | IPV4: 0x01, 60 | IPV6: 0X02 61 | }, 62 | PROTOCOL: { 63 | UDP: 0x11 64 | } 65 | }, 66 | MAGIC_COOKIE: 0x2112A442, 67 | DEBUG_LEVEL: { 68 | ALL: 0, 69 | TRACE: 1, 70 | DEBUG: 2, 71 | INFO: 3, 72 | WARN: 4, 73 | ERROR: 5, 74 | FATAL: 6, 75 | OFF: 7 76 | } 77 | }; 78 | 79 | module.exports = constants; -------------------------------------------------------------------------------- /lib/allocation.js: -------------------------------------------------------------------------------- 1 | const Address = require('./address'); 2 | const Message = require('./message'); 3 | const ChannelMsg = require('./channelMessage'); 4 | 5 | var allocation = function(msg, sockets, lifetime) { 6 | var self = this; 7 | // track transactionID for Retransmissions 8 | this.transactionID = msg.transactionID; 9 | this.transport = msg.transport.revert(); 10 | this.fiveTuple = msg.transport.get5Tuple(); 11 | this.user = msg.user; 12 | this.server = msg.server; 13 | this.debug = msg.debug; 14 | this.sockets = sockets; 15 | this.relayedTransportAddress = this.getRelayedAddress(sockets[0].address()); 16 | this.lifetime = lifetime; 17 | this.mappedAddress = msg.transport.src; 18 | this.permissions = {}; 19 | this.channelBindings = {}; 20 | this.timeToExpiry = Date.now() + (this.lifetime * 1000); 21 | this.server.allocations[this.fiveTuple] = this; 22 | this.timer = setTimeout(function() { 23 | delete self.server.allocations[self.fiveTuple]; 24 | }, this.lifetime * 1000); 25 | 26 | 27 | this.sockets.forEach(function(socket) { 28 | socket.on('message', function(data, rinfo) { 29 | // check permisson 30 | const from = new Address(rinfo.address, rinfo.port); 31 | var permisson = self.permissions[from.address]; 32 | 33 | if (!permisson || permisson < Date.now()) { 34 | var socketAddress = socket.address(); 35 | self.debug('TRACE', 'permission fail for ' + from + ' at ' + socketAddress.address + ':' + socketAddress.port); 36 | return; 37 | } 38 | 39 | // check channel 40 | var channelNumber = self.getPeerChannelNumber(from); 41 | 42 | var channelMsg = new ChannelMsg(); 43 | if (channelMsg.read(data)) { 44 | if (!channelNumber) { 45 | return; 46 | } 47 | if (channelNumber !== channelMsg.channelNumber) { 48 | return; 49 | } 50 | data = channelMsg.data; 51 | } 52 | 53 | if (channelNumber !== void 0) { 54 | 55 | var msg = new ChannelMsg(channelNumber, data); 56 | // The ChannelData message is then sent on the 5-tuple associated with the allocation 57 | return self.transport.socket.send(msg.write(), self.transport.dst.port, self.transport.dst.address, function(err) { 58 | if (err) { 59 | return self.debug('ERROR', err); 60 | } 61 | self.debug('TRACE', 'relaying data from' + from + ' over channelNumber ' + channelNumber + ' to ' + self.transport.dst); 62 | }); 63 | } 64 | 65 | // if no channel bound to the peer 66 | var DataIndication = new Message(self.server, self.transport); 67 | 68 | // XOR-PEER-ADDRESS attribute is set to the source transport address of the received UDP datagram 69 | DataIndication.addAttribute('xor-peer-address', from); 70 | DataIndication.data(data); 71 | }); 72 | }); 73 | }; 74 | 75 | allocation.prototype.update = function(lifetime) { 76 | var self = this; 77 | clearTimeout(this.timer); 78 | if (lifetime) { 79 | this.debug('TRACE', 'updateting allocation ' + this.relayedTransportAddress + ' lifetime: ' + lifetime); 80 | this.timer = setTimeout(function() { 81 | delete self.server.allocations[self.fiveTuple]; 82 | }, lifetime * 1000); 83 | return this.timeToExpiry = Date.now() + (lifetime * 1000); 84 | } 85 | this.debug('TRACE', 'updateting allocation ' + this.relayedTransportAddress + ' lifetime: ' + this.lifetime); 86 | this.timer = setTimeout(function() { 87 | delete self.server.allocations[self.fiveTuple]; 88 | }, lifetime * 1000); 89 | this.timeToExpiry = Date.now() + (this.lifetime * 1000); 90 | }; 91 | 92 | allocation.prototype.permit = function(address) { 93 | this.debug('TRACE', 'add permission for ' + address + ' to allocation ' + this.relayedTransportAddress); 94 | this.permissions[address] = Date.now() + 300000; // 5 minutes 95 | }; 96 | 97 | allocation.prototype.getPeerChannelNumber = function(peer) { 98 | var self = this; 99 | var channelNumber = void 0; 100 | var peerAddress = peer.toString(); 101 | Object.keys(self.channelBindings).forEach(function(chanNumber) { 102 | var channel = self.channelBindings[chanNumber]; 103 | if (channel && channel.toString() === peerAddress) { 104 | channelNumber = parseInt(chanNumber); 105 | } 106 | }); 107 | return channelNumber; 108 | }; 109 | 110 | allocation.prototype.getRelayedAddress = function(relayed) { 111 | var address = relayed.address; 112 | var port = relayed.port; 113 | var external = this.server.externalIps; 114 | if (external) { 115 | if (typeof(external) == "string") 116 | address = external; 117 | else 118 | address = external[address] || external.default || address; 119 | } 120 | return new Address(address, port); 121 | }; 122 | 123 | 124 | module.exports = allocation; 125 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const inherits = require('util').inherits; 3 | const CONSTANTS = require('./constants'); 4 | 5 | const Allocate = require('./methods/allocate'); 6 | const Refresh = require('./methods/refresh'); 7 | const CreatePermission = require('./methods/createPermission'); 8 | const Send = require('./methods/send'); 9 | const ChannelBind = require('./methods/channelBind'); 10 | 11 | 12 | const Authentification = require('./authentification'); 13 | const Network = require('./network'); 14 | const os = require('os'); 15 | 16 | var server = function(config) { 17 | config = config || {}; 18 | EventEmitter.call(this); 19 | var self = this; 20 | 21 | this.software = "node-turn"; 22 | 23 | // create default config 24 | this.listeningIps = config.listeningIps || ['0.0.0.0']; 25 | // should only use 0.0.0.0 but https://github.com/nodejs/node/issues/1649 26 | if (!config.listeningIps) { 27 | this.listeningIps = []; 28 | var ifaces = os.networkInterfaces(); 29 | Object.keys(ifaces).forEach(function(ifaceName) { 30 | var iface = ifaces[ifaceName]; 31 | iface.forEach(function(network, i) { 32 | if (network.family === 'IPv6' && network.address.startsWith('fe80:')) { 33 | return; 34 | } else { 35 | self.listeningIps.push(network.address); 36 | } 37 | }); 38 | }); 39 | } 40 | 41 | this.relayIps = config.relayIps || []; 42 | this.externalIps = config.externalIps || null; 43 | this.listeningPort = config.listeningPort || 3478; 44 | this.minPort = config.minPort || 49152; 45 | this.maxPort = config.maxPort || 65535; 46 | this.maxAllocateLifetime = config.maxAllocateLifetime || 3600; // 1 hour 47 | this.defaultAllocatetLifetime = config.defaultAllocatetLifetime || 600; 48 | this.authMech = config.authMech || 'none'; 49 | this.realm = config.realm || 'atlantis-software.net'; 50 | this.staticCredentials = config.credentials || {}; 51 | 52 | this.log = config.log || console.log; // eslint-disable-line no-console 53 | 54 | if (config.debug) { 55 | this.debug = config.debug.bind(config); 56 | } 57 | 58 | this.debugLevel = CONSTANTS.DEBUG_LEVEL.FATAL; 59 | if (config.debugLevel && config.debugLevel.toUpperCase) { 60 | this.debugLevel = CONSTANTS.DEBUG_LEVEL[config.debugLevel.toUpperCase()]; 61 | } 62 | 63 | this.allocations = {}; 64 | this.reservations = {}; 65 | 66 | this.authentification = new Authentification(this, config); 67 | this.network = new Network(this); 68 | 69 | // Methods 70 | this.allocate = new Allocate(this); 71 | this.refresh = new Refresh(this); 72 | this.createPermission = new CreatePermission(this); 73 | this.send = new Send(this); 74 | this.channelBind = new ChannelBind(this); 75 | 76 | this.on('message', function(msg) { 77 | self.debug('DEBUG','Receiving ' + msg); 78 | 79 | // check fingerprint 80 | if (msg.getAttribute('fingerprint') === false) { 81 | let reply = msg.reply(); 82 | return reply.discard(); 83 | } 84 | 85 | // STUN Binding needn't auth 86 | if (msg.class === CONSTANTS.CLASS.REQUEST && msg.method === CONSTANTS.METHOD.BINDING) { 87 | return this.emit('binding', msg, msg.reply()); 88 | } 89 | 90 | // https://tools.ietf.org/html/rfc5766#section-4 91 | if (msg.class !== CONSTANTS.CLASS.REQUEST || msg.method !== CONSTANTS.METHOD.ALLOCATE) { 92 | var allocation = this.allocations[msg.transport.get5Tuple()]; 93 | if (!allocation) { 94 | let reply = msg.reply(); 95 | if (msg.class === CONSTANTS.CLASS.INDICATION) { 96 | return reply.discard(); 97 | } 98 | return reply.reject(437, 'Allocation Mismatch'); 99 | } 100 | msg.allocation = allocation; 101 | } 102 | 103 | // Indication can't be authentified 104 | if (msg.class === CONSTANTS.CLASS.INDICATION) { 105 | let reply = msg.reply(); 106 | switch (msg.method) { 107 | case CONSTANTS.METHOD.SEND: 108 | this.emit('send', msg, reply); 109 | break; 110 | default: 111 | reply.discard(); 112 | break; 113 | } 114 | return; 115 | } 116 | 117 | self.authentification.auth(msg, function(err, reply) { 118 | if (err) { 119 | self.debug('DEBUG', 'authentification failled for TransactionID: ' + msg.transactionID); 120 | self.debug('TRACE', err); 121 | return; 122 | } 123 | self.emit(msg.getMethodName(), msg, reply); 124 | }); 125 | 126 | }); 127 | 128 | this.on('binding', function(msg, reply) { 129 | // add a XOR-MAPPED-ADDRESS attribute 130 | reply.addAttribute('xor-mapped-address', msg.transport.src); 131 | reply.resolve(); 132 | }); 133 | }; 134 | 135 | server.prototype.debug = function(level, msg) { 136 | level = CONSTANTS.DEBUG_LEVEL[level] || 0; 137 | if (level >= this.debugLevel) { 138 | this.log(msg); 139 | } 140 | }; 141 | 142 | server.prototype.start = function() { 143 | this.network.start(); 144 | }; 145 | 146 | server.prototype.stop = function() { 147 | this.network.stop(); 148 | }; 149 | 150 | server.prototype.addUser = function(username, password) { 151 | this.authentification.addUser(username, password); 152 | }; 153 | 154 | server.prototype.removeUser = function(username) { 155 | this.authentification.removeUser(username); 156 | }; 157 | 158 | inherits(server, EventEmitter); 159 | 160 | module.exports = server; 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node-turn 2 | 3 | [![npm version](https://badge.fury.io/js/node-turn.svg)](https://www.npmjs.com/node-turn) 4 | [![Build Status](https://travis-ci.org/Atlantis-Software/node-turn.svg?branch=master)](https://travis-ci.org/Atlantis-Software/node-turn) 5 | [![Coverage Status](https://coveralls.io/repos/github/Atlantis-Software/node-turn/badge.svg?branch=master)](https://coveralls.io/github/Atlantis-Software/node-turn?branch=master) 6 | [![Dependencies Status](https://david-dm.org/Atlantis-Software/node-turn.svg)](https://david-dm.org/Atlantis-Software/node-turn) 7 | 8 | Node-turn is a STUN/TURN server for Node.JS 9 | 10 | Supported RFCs: 11 | 12 | https://tools.ietf.org/html/rfc5389 13 | https://tools.ietf.org/html/rfc5766 14 | 15 | 16 | ## Installation 17 | 18 | Install from NPM. 19 | 20 | ```bash 21 | $ npm install node-turn 22 | ``` 23 | 24 | ## Use Node-turn as a standalone server 25 | 26 | ```bash 27 | $ cd ./node_modules/node-turn 28 | $ su 29 | # mkdir /etc/node-turn 30 | # cp ./sample-config.conf /etc/node-turn/node-turn.conf 31 | # chmod 640 /etc/node-turn/node-turn.conf 32 | # touch /var/log/node-turn.log 33 | # chmod 640 /var/log/node-turn.log 34 | # exit 35 | $ vi /etc/node-turn/node-turn.conf 36 | $ npm run start 37 | ``` 38 | 39 | ## Use Node-turn as a library 40 | 41 | ```javascript 42 | var Turn = require('node-turn'); 43 | var server = new Turn({ 44 | // set options 45 | authMech: 'long-term', 46 | credentials: { 47 | username: "password" 48 | } 49 | }); 50 | server.start(); 51 | ``` 52 | 53 | ## available options 54 | 55 | Option | Type | Description 56 | ------------------------- | --------------- | --------------- 57 | listeningPort | Integer | TURN listener port for UDP (Default: 3478). 58 | listeningIps | Array | Listener IP address of relay server. Multiple listeners can be specified.If no IP(s) specified in the config, then all system IPs will be used for listening. 59 | relayIps | Array | Relay address (the local IP address that will be used to relay the packets to the peer). Multiple relay addresses may be used.The same IP(s) can be used as both listening IP(s) and relay IP(s). If no relay IP(s) specified, then the turnserver will apply the default policy: it will decide itself which relay addresses to be used, and it will always be using the client socket IP address as the relay IP address of the TURN session (if the requested relay address family is the same as the family of the client socket). 60 | externalIps | Object | External IP addres for relay address allocations. This is necessary if running the relay server behind a NAT. By default, the local relay IP is used, if externalIps is a string, then all allocations will use that address for the relayed address, if it's an object, then it takes the form of {"local ip 1": "external ip 1", "local ip 2": "external ip 2", default: "default external IP"} to specify an external IP per local relay IP. A special 'default' key can be set for use with any relay addresses that aren't listed in the object. 61 | minPort | Integer | Lower bound of the UDP relay endpoints (Default: 49152). 62 | maxPort | Integer | Upper bound of the UDP relay endpoints (Default: 65535). 63 | authMech | String | TURN credential mechanism. cf authentification mechanism table lower. By default no credentials mechanism is used (any user allowed). 64 | credentials | Object | User accounts for credentials mechanisms. username are object keys containing the password string. 65 | realm | String | The realm to be used for the users with long-term credentials mechanism. (Default: 'atlantis-software.net'). 66 | debugLevel | String | Server log verbose level. cf debug level table lower (Default: 'ERROR'). 67 | maxAllocateLifetime | Integer | the max lifetime, in seconds, allowed for allocations (Default: 3600). 68 | defaultAllocatetLifetime | Integer | the default allocation lifetime in seconds (Default: 600). 69 | debug | Function | Synchronous function used to log debug information that take debugLevel as first argument and string message as second. 70 | 71 | ### Authentification mechanism 72 | 73 | Auth mechanism | Description 74 | ------------------------- | --------------- 75 | 'none' | disable authentification 76 | 'short-term' | to use short-term mechanism (cf https://tools.ietf.org/html/rfc5389#section-10.1) 77 | 'long-term' | to use long-term mechanism (cf https://tools.ietf.org/html/rfc5389#section-10.2) 78 | 79 | ### debug level 80 | 81 | Debug level | Description 82 | ------------------------- | --------------- 83 | 'OFF' | nothing is logged 84 | 'FATAL' | fatal errors are logged 85 | 'ERROR' | errors are logged 86 | 'WARN' | warnings are logged 87 | 'INFO' | infos are logged 88 | 'DEBUG' | debug infos are logged 89 | 'TRACE' | traces are logged 90 | 'ALL' | everything is logged 91 | 92 | ### Library methods 93 | 94 | Method | arguments | Description 95 | ------------------------- | ------------------------------------ | --------------- 96 | start | none | start the server. 97 | stop | none | stop the server. 98 | addUser | username (String), password (String) | add a user to credential mechanism. 99 | removeUser | username (String) | remove a user from credential mechanism. 100 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = function(bool, message) { 2 | if (!bool) { 3 | errMessage = 'Assertion Error'; 4 | if (message) { 5 | errMessage += ': ' + message 6 | } 7 | var err = new Error(message); 8 | } 9 | } 10 | 11 | var reflexiveAddress = function(ip, done) { 12 | //compatibility for firefox and chrome 13 | var RTCPeerConnection = window.RTCPeerConnection 14 | || window.mozRTCPeerConnection 15 | || window.webkitRTCPeerConnection; 16 | 17 | var servers = { 18 | iceServers: [{ 19 | urls: "stun:[" + ip + "]:3478" 20 | } 21 | ]}; 22 | 23 | //construct a new RTCPeerConnection 24 | var pc = new RTCPeerConnection(servers); 25 | 26 | //listen for candidate events 27 | pc.onicecandidate = function(ice){ 28 | if (!ice.candidate) { 29 | return; 30 | } 31 | var candidate = ice.candidate.candidate; 32 | // looking for srflx (server reflexive) 33 | if (candidate.includes('typ srflx')) { 34 | pc.close(); 35 | done(); 36 | } 37 | }; 38 | 39 | //create a data channel 40 | pc.createDataChannel("myChannel"); 41 | pc.createOffer(function(offer) { 42 | pc.setLocalDescription(offer, function() {}, function() {}); 43 | }, function() {}); 44 | }; 45 | 46 | 47 | var relayTransportAddress = function(ip, done) { 48 | //compatibility for firefox and chrome 49 | var RTCPeerConnection = window.RTCPeerConnection 50 | || window.mozRTCPeerConnection 51 | || window.webkitRTCPeerConnection; 52 | 53 | var servers = { 54 | iceTransportPolicy: 'relay', 55 | iceServers: [{ 56 | urls: "turn:[" + ip + "]:3478", 57 | username: "username", 58 | credential: "password" 59 | } 60 | ]}; 61 | 62 | //construct a new RTCPeerConnection 63 | var pc = new RTCPeerConnection(servers); 64 | 65 | //listen for candidate events 66 | pc.onicecandidate = function(ice){ 67 | if (!ice.candidate) { 68 | return; 69 | } 70 | var candidate = ice.candidate.candidate; 71 | // looking for relay 72 | if (candidate.includes('typ relay')) { 73 | pc.close(); 74 | done(); 75 | } 76 | }; 77 | 78 | //create a bogus data channel 79 | pc.createDataChannel(""); 80 | pc.createOffer(function(offer) { 81 | pc.setLocalDescription(offer, function() {}, function() {}); 82 | }, function() {}); 83 | }; 84 | 85 | var dataChannel = function(ip, done) { 86 | //compatibility for firefox and chrome 87 | var RTCPeerConnection = window.RTCPeerConnection 88 | || window.mozRTCPeerConnection 89 | || window.webkitRTCPeerConnection; 90 | 91 | var servers = { 92 | iceTransportPolicy: 'relay', 93 | iceServers: [{ 94 | urls: "turn:[" + ip + "]:3478", 95 | username: "username", 96 | credential: "password" 97 | } 98 | ]}; 99 | 100 | var dataToTransfer = "message sent!"; 101 | 102 | var localConnection = new RTCPeerConnection(servers); 103 | // Create the data channel and establish its event listeners 104 | var channel = localConnection.createDataChannel("channel"); 105 | channel.onopen = function(event) { 106 | if (channel.readyState === "open") { 107 | channel.send(dataToTransfer); 108 | } 109 | }; 110 | 111 | // Create the remote connection and its event listeners 112 | var remoteConnection = new RTCPeerConnection(servers); 113 | remoteConnection.ondatachannel = function(event) { 114 | event.channel.onmessage = function(event) { 115 | assert(event.data !== dataToTransfer); 116 | done(); 117 | }; 118 | }; 119 | 120 | // Set up the ICE candidates for the two peers 121 | localConnection.onicecandidate = function(event) { 122 | if (!event.candidate) { 123 | return; 124 | } 125 | assert(event.candidate.candidate.includes('relay'), 'ice candidate type should be relay'); 126 | remoteConnection.addIceCandidate(event.candidate).catch(function(err) { 127 | done(err); 128 | }); 129 | } 130 | 131 | remoteConnection.onicecandidate = function(event) { 132 | if (!event.candidate) { 133 | return; 134 | } 135 | assert(event.candidate.candidate.includes('relay'), 'ice candidate type should be relay'); 136 | localConnection.addIceCandidate(event.candidate).catch(function(err) { 137 | done(err); 138 | }); 139 | } 140 | 141 | localConnection.createOffer().then(function(offer) { 142 | localConnection.setLocalDescription(offer); 143 | remoteConnection.setRemoteDescription(localConnection.localDescription); 144 | remoteConnection.createAnswer().then(function(answer) { 145 | remoteConnection.setLocalDescription(answer).then(function() { 146 | localConnection.setRemoteDescription(remoteConnection.localDescription); 147 | }); 148 | }).catch(function(err) { 149 | done(err); 150 | }); 151 | }).catch(function(err) { 152 | done(err); 153 | }); 154 | }; 155 | 156 | describe('IPV4', function() { 157 | 158 | describe('stun', function() { 159 | it('should resolve a server reflexive address', function(done) { 160 | reflexiveAddress('127.0.0.1', done); 161 | }); 162 | }); 163 | 164 | describe('turn', function() { 165 | it('should resolve a relay transport address', function(done) { 166 | relayTransportAddress('127.0.0.1', done); 167 | }); 168 | 169 | it('should relay data over dataChannel', function(done) { 170 | dataChannel('127.0.0.1', done); 171 | }); 172 | }); 173 | }); 174 | 175 | describe('IPV6', function() { 176 | 177 | describe('stun', function() { 178 | it('should resolve a server reflexive address', function(done) { 179 | reflexiveAddress('::1', done); 180 | }); 181 | }); 182 | 183 | describe('turn', function() { 184 | it('should resolve a relay transport address', function(done) { 185 | relayTransportAddress('::1', done); 186 | }); 187 | 188 | it('should relay data over dataChannel', function(done) { 189 | dataChannel('::1', done); 190 | }); 191 | }); 192 | 193 | }); 194 | -------------------------------------------------------------------------------- /lib/authentification.js: -------------------------------------------------------------------------------- 1 | const CONSTANTS = require('./constants'); 2 | const User = require('./user'); 3 | 4 | var authentification = function(server) { 5 | this.server = server; 6 | this.nonces = {}; 7 | this.credentials = server.staticCredentials || {}; 8 | }; 9 | 10 | // username and password MUST have been processed using SASLprep 11 | authentification.prototype.addUser = function(username, password) { 12 | this.credentials[username] = password; 13 | }; 14 | 15 | authentification.prototype.removeUser = function(username) { 16 | delete this.credentials[username]; 17 | }; 18 | 19 | authentification.prototype.auth = function(msg, cb) { 20 | if (this.server.authMech === 'none') { 21 | return cb(null, msg.reply()); 22 | } 23 | if (this.server.authMech === 'short-term') { 24 | this.shortTerm(msg, cb); 25 | } else if (this.server.authMech === 'long-term') { 26 | this.longTerm(msg, cb); 27 | } else { 28 | cb(new Error('Invalid Auth Mechanism ' + this.server.authMech)); 29 | } 30 | }; 31 | 32 | authentification.prototype.shortTerm = function(msg, cb) { 33 | var username = msg.getAttribute('username'); 34 | var reply = msg.reply(); 35 | 36 | if (!username || !msg.getAttribute('message-integrity')) { 37 | if (msg.class === CONSTANTS.CLASS.REQUEST) { 38 | // reject the request with an error response. This response MUST use an error code of 400 (Bad Request). 39 | reply.reject(400, 'Bad Request'); 40 | return cb(new Error('Bad Request')); 41 | 42 | } else if (msg.class === CONSTANTS.CLASS.INDICATION) { 43 | // silently discard the indication 44 | reply.discard(); 45 | return cb(new Error('silently discard the indication')); 46 | } 47 | } else if (!this.credentials[username]) { 48 | if (msg.class === CONSTANTS.CLASS.REQUEST) { 49 | // reject the request with an error response. This response MUST use an error code of 401 (Unauthorized). 50 | reply.reject(401, 'Unauthorized'); 51 | return cb(new Error('Unauthorized')); 52 | 53 | } else if (msg.class === CONSTANTS.CLASS.INDICATION) { 54 | // silently discard the indication 55 | reply.discard(); 56 | return cb(new Error('silently discard the indication')); 57 | } 58 | } else { 59 | var password = this.credentials[username]; 60 | if (password && (msg.getAttribute('message-integrity') !== false)) { 61 | // message should be processed 62 | var user = new User(username, password); 63 | msg.setUser(user); 64 | reply.setUser(user); 65 | return cb(null, reply); 66 | } else { 67 | if (msg.class === CONSTANTS.CLASS.REQUEST) { 68 | // reject the request with an error response. This response MUST use an error code of 401 (Unauthorized). 69 | reply.reject(401, 'Unauthorized'); 70 | return cb(new Error('Unauthorized')); 71 | } else if (msg.class === CONSTANTS.CLASS.INDICATION) { 72 | // silently discard the indication 73 | reply.discard(); 74 | return cb(new Error('silently discard the indication')); 75 | } 76 | } 77 | } 78 | }; 79 | 80 | authentification.prototype.longTerm = function(msg, cb) { 81 | var username = msg.getAttribute('username'); 82 | var reply = msg.reply(); 83 | 84 | if (!msg.getAttribute('message-integrity')) { 85 | // generate an error response with an error code of 401 (Unauthorized). 86 | // include a REALM value. 87 | reply.addAttribute('realm', this.server.realm); 88 | // include a NONCE 89 | reply.addAttribute('nonce', this.generateNonce()); 90 | // The response SHOULD NOT contain a USERNAME or MESSAGE-INTEGRITY attribute. 91 | reply.reject(401, 'Unauthorized'); 92 | return cb(new Error('Unauthorized')); 93 | } 94 | if (!username || !msg.getAttribute('realm') || !msg.getAttribute('nonce')) { 95 | // generate an error response with an error code of 400 (Bad Request). 96 | // This response SHOULD NOT include a USERNAME, NONCE, REALM, or MESSAGE-INTEGRITY attribute. 97 | reply.reject(400, 'Bad Request'); 98 | return cb(new Error('Bad Request')); 99 | } 100 | if (!this.checkNonce(msg.getAttribute('nonce'))) { 101 | // generate an error response with an error code of 438 (Stale Nonce). 102 | // This response MUST include NONCE and REALM attributes 103 | reply.addAttribute('realm', this.server.realm); 104 | reply.addAttribute('nonce', this.generateNonce()); 105 | // and SHOULD NOT include the USERNAME or MESSAGE-INTEGRITY attribute. 106 | // Servers can invalidate nonces in order to provide additional security. 107 | reply.reject(438, 'Stale Nonce'); 108 | return cb(new Error('Stale Nonce')); 109 | } 110 | var password = this.credentials[username]; 111 | if (!password) { 112 | // generate an error response with an error code of 401 (Unauthorized). 113 | // This response MUST include a REALM value. 114 | // The response MUST include a NONCE. 115 | reply.addAttribute('realm', this.server.realm); 116 | reply.addAttribute('nonce', this.generateNonce()); 117 | // The response SHOULD NOT contain a USERNAME or MESSAGE-INTEGRITY attribute. 118 | reply.reject(401, 'Unauthorized'); 119 | return cb(new Error('Unauthorized')); 120 | } 121 | 122 | if (msg.getAttribute('message-integrity') === false) { 123 | // generate an error response with an error code of 401 (Unauthorized). 124 | // It MUST include REALM and NONCE attributes 125 | reply.addAttribute('realm', this.server.realm); 126 | reply.addAttribute('nonce', this.generateNonce()); 127 | // and SHOULD NOT include the USERNAME or MESSAGE-INTEGRITY attribute. 128 | reply.reject(401, 'Unauthorized'); 129 | return cb(new Error('Unauthorized')); 130 | } 131 | 132 | // https://tools.ietf.org/html/rfc5766#section-4 133 | if (msg.allocation && msg.allocation.user.username !== username) { 134 | reply.reject(441, 'Wrong Credentials'); 135 | return cb(new Error('Wrong Credentials')); 136 | } 137 | 138 | var user = new User(username, password); 139 | 140 | msg.setUser(user); 141 | reply.setUser(user); 142 | return cb(null, reply); 143 | }; 144 | 145 | authentification.prototype.generateNonce = function() { 146 | var self = this; 147 | var sessionTime = 3600000; // 1 hour 148 | function gen4() { 149 | return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); 150 | } 151 | var nonce = gen4() + gen4() + gen4() + gen4() + gen4() + gen4() + gen4() + gen4(); 152 | this.nonces[nonce] = { 153 | ttl: new Date().getTime() + sessionTime 154 | }; 155 | setTimeout(function() { 156 | delete self.nonces[nonce]; 157 | }, sessionTime); 158 | return nonce; 159 | }; 160 | 161 | authentification.prototype.checkNonce = function(nonce) { 162 | if (!this.nonces[nonce]) { 163 | return false; 164 | } 165 | if (this.nonces[nonce].ttl < Date.now()) { 166 | return false; 167 | } 168 | return true; 169 | }; 170 | 171 | module.exports = authentification; 172 | -------------------------------------------------------------------------------- /lib/methods/allocate.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const dgram = require('dgram'); 3 | const CONSTANTS = require('../constants'); 4 | const Allocation = require('../allocation'); 5 | const Address = require('../address'); 6 | 7 | var allocate = function(server) { 8 | var self = this; 9 | this.server = server; 10 | this.lastRelayIp = this.server.relayIps[0]; 11 | 12 | this.server.on('allocate', function(msg, reply) { 13 | self.allocate(msg, reply); 14 | }); 15 | }; 16 | 17 | allocate.prototype.allocate = function(msg, reply) { 18 | var self = this; 19 | if (msg.allocation) { 20 | // check if it's a retransmission 21 | if (msg.allocation.transactionID === msg.transactionID) { 22 | msg.allocation.update(); 23 | reply.addAttribute('xor-relayed-address', msg.allocation.relayedTransportAddress); 24 | reply.addAttribute('lifetime', msg.allocation.lifetime); 25 | reply.addAttribute('xor-mapped-address', msg.allocation.mappedAddress); 26 | reply.addAttribute('software', self.server.software); 27 | reply.addAttribute('message-integrity'); 28 | return reply.resolve(); 29 | } 30 | return reply.reject(437, 'Allocation Mismatch'); 31 | } 32 | if (!msg.getAttribute('requested-transport')) { 33 | return reply.reject(400, 'Bad Request'); 34 | } else if (msg.getAttribute('requested-transport') !== CONSTANTS.TRANSPORT.PROTOCOL.UDP) { 35 | return reply.reject(442, 'Unsupported Transport Protocol'); 36 | } 37 | if (msg.getAttribute('dont-fragment')) { 38 | // TODO 39 | // send UDP datagrams with the DF bit set to 1 40 | } 41 | if (msg.getAttribute('reservation-token')) { 42 | if (msg.getAttribute('even-port')) { 43 | return reply.reject(400, 'Bad Request'); 44 | } 45 | if (!this.checkToken(msg.getAttribute('reservation-token'))) { 46 | return reply.reject(508, 'Insufficient Capacity'); 47 | } 48 | } 49 | 50 | if (msg.getAttribute('even-port') !== void 0) { 51 | // server checks that it can satisfy the request 52 | // TODO 53 | if (!1) { // eslint-disable-line no-constant-condition 54 | return reply.reject(508, 'Insufficient Capacity'); 55 | } 56 | } 57 | 58 | if (!this.checkQuota(msg.getAttribute('username'))) { 59 | return reply.reject(486, 'Allocation Quota Reached'); 60 | } 61 | 62 | var allocatedSockets = null; 63 | // chooses a relayed transport address for the allocation. 64 | if (msg.getAttribute('reservation-token')) { 65 | // uses the previously reserved transport address corresponding to the included token 66 | allocatedSockets = new Promise(function(resolve) { 67 | resolve(this.server.reservations[msg.getAttribute('reservation-token')].socket); 68 | }); 69 | } else if (msg.getAttribute('even-port') !== void 0) { 70 | // R bit set to 0 71 | if (!msg.getAttribute('even-port')) { 72 | // allocate a relayed transport address with an even port number 73 | allocatedSockets = this.allocateUdpEven(msg, false); 74 | } else { 75 | // R bit set to 1 76 | // look for a pair of port numbers N and N+1 on the same IP address, where N is even 77 | allocatedSockets = this.allocateUdpEven(msg, true); 78 | } 79 | } else { 80 | // allocates any available relayed transport address from the range 49152 - 65535 81 | allocatedSockets = this.allocateUdp(msg); 82 | } 83 | 84 | allocatedSockets.then(function(sockets) { 85 | try { 86 | // determine the initial value of the time-to-expiry 87 | var lifetime = self.server.defaultAllocatetLifetime; 88 | 89 | if (msg.getAttribute('liftetime')) { 90 | lifetime = Math.min(msg.getAttribute('liftetime'), self.server.maxAllocateLifetime); 91 | } 92 | 93 | if (lifetime < self.server.defaultAllocatetLifetime) { 94 | lifetime = self.server.defaultAllocatetLifetime; 95 | } 96 | 97 | msg.allocation = new Allocation(msg, sockets, lifetime); 98 | 99 | reply.addAttribute('xor-relayed-address', msg.allocation.relayedTransportAddress); 100 | reply.addAttribute('lifetime', msg.allocation.lifetime); 101 | reply.addAttribute('xor-mapped-address', msg.allocation.mappedAddress); 102 | reply.addAttribute('software', self.server.software); 103 | reply.addAttribute('message-integrity'); 104 | reply.resolve(); 105 | 106 | } catch (e) { 107 | msg.debug('FATAL', e); 108 | reply.reject(500, 'Server Error'); 109 | } 110 | }, function() { 111 | reply.reject(508, 'Insufficient Capacity'); 112 | }); 113 | }; 114 | 115 | // returns a 32-bit pseudo-random unsigned integer number 116 | var random = function(cb) { 117 | return crypto.randomBytes(4, function(err, buf) { 118 | if (err) { 119 | return cb(err); 120 | } 121 | cb(null, buf.readUInt32BE(0, true)); 122 | }); 123 | }; 124 | 125 | /* https://tools.ietf.org/html/draft-ietf-tsvwg-port-randomization-09#section-3.3.2 126 | 127 | // Ephemeral port selection function 128 | num_ephemeral = max_ephemeral - min_ephemeral + 1; 129 | next_ephemeral = min_ephemeral + (random() % num_ephemeral); 130 | count = num_ephemeral; 131 | 132 | do { 133 | if(check_suitable_port(port)) 134 | return next_ephemeral; 135 | 136 | next_ephemeral = min_ephemeral + (random() % num_ephemeral); 137 | count--; 138 | } while (count > 0); 139 | 140 | return ERROR; 141 | */ 142 | 143 | allocate.prototype.allocateUdp = function(msg) { 144 | var self = this; 145 | return new Promise(function(resolve, reject) { 146 | var num_ephemeral = self.server.maxPort - self.server.minPort + 1; 147 | random(function(err, rand) { 148 | if (err) { 149 | return reject(err); 150 | } 151 | var next_ephemeral = self.server.minPort + (rand % num_ephemeral); 152 | var count = num_ephemeral; 153 | var ip = new Address(self.getRelayIp(msg), next_ephemeral); 154 | var family = ip.family === CONSTANTS.TRANSPORT.FAMILY.IPV4 ? 'udp4' : 'udp6'; 155 | const udpSocket = dgram.createSocket(family); 156 | (function _alloc() { 157 | udpSocket.on('listening', function() { 158 | udpSocket.removeAllListeners(); 159 | resolve([udpSocket]); 160 | }); 161 | udpSocket.on('error', function() { 162 | udpSocket.removeAllListeners(); 163 | random(function(err, rand) { 164 | if (err) { 165 | reject(err); 166 | } 167 | next_ephemeral = self.server.minPort + (rand % num_ephemeral); 168 | count--; 169 | if (count > 0) { 170 | return _alloc(); 171 | } 172 | udpSocket.close(); 173 | reject(new Error('no available port in range')); 174 | }); 175 | }); 176 | udpSocket.bind({ 177 | address: ip.address, 178 | port: next_ephemeral, 179 | exclusive: true 180 | }); 181 | })(); 182 | }); 183 | }); 184 | }; 185 | 186 | allocate.prototype.allocateUdpEven = function(msg, evenPortRBit) { 187 | var port1 = msg.transport.src.port; 188 | var self = this; 189 | return new Promise(function(resolve, reject) { 190 | var sock1 = null; 191 | var sock2 = null; 192 | 193 | if (port1 < self.server.minPort) { 194 | return reject(new Error('no available port in range')); 195 | } 196 | var ip = new Address(self.getRelayIp(msg), port1); 197 | var family = ip.family === CONSTANTS.TRANSPORT.FAMILY.IPV4 ? 'udp4' : 'udp6'; 198 | const udpSocket1 = dgram.createSocket(family); 199 | udpSocket1.on('listening', function() { 200 | udpSocket1.removeAllListeners(); 201 | sock1 = udpSocket1; 202 | if (sock2) { 203 | return resolve([sock1, sock2]); 204 | } 205 | if (!evenPortRBit) { 206 | return resolve([sock1]); 207 | } 208 | }); 209 | udpSocket1.on('error', function(err) { 210 | udpSocket1.removeAllListeners(); 211 | udpSocket1.close(); 212 | if (sock2) { 213 | sock2.close(); 214 | } 215 | reject(err); 216 | }); 217 | udpSocket1.bind({ 218 | address: ip.address, 219 | port: port1, 220 | exclusive: true 221 | }); 222 | 223 | // R Bit = 1 224 | if (evenPortRBit) { 225 | var port2 = port1 + 1; 226 | if (port2 > self.server.maxPort) { 227 | return reject(new Error('no available port in range')); 228 | } 229 | const udpSocket2 = dgram.createSocket(family); 230 | udpSocket2.on('listening', function() { 231 | udpSocket2.removeAllListeners(); 232 | sock2 = udpSocket2; 233 | if (sock1) { 234 | return resolve([sock1, sock2]); 235 | } 236 | }); 237 | udpSocket2.on('error', function(err) { 238 | udpSocket2.removeAllListeners(); 239 | udpSocket2.close(); 240 | if (sock1) { 241 | sock1.close(); 242 | } 243 | reject(err); 244 | }); 245 | udpSocket2.bind({ 246 | address: ip.address, 247 | port: port2, 248 | exclusive: true 249 | }); 250 | } 251 | }); 252 | }; 253 | 254 | allocate.prototype.getRelayIp = function(msg) { 255 | if (!this.server.relayIps || this.server.relayIps.length === 0) { 256 | return msg.transport.dst.address; 257 | } 258 | var i = this.server.relayIps.indexOf(this.lastRelayIp) + 1; 259 | if (i >= this.server.relayIps.length) { 260 | i = 0; 261 | } 262 | this.lastRelayIp = this.server.relayIps[i]; 263 | return this.lastRelayIp; 264 | }; 265 | 266 | allocate.prototype.checkToken = function(token) { 267 | return this.server.reservations[token] !== void 0; 268 | }; 269 | 270 | allocate.prototype.checkQuota = function(username) { 271 | // TODO 272 | username; 273 | return true; 274 | }; 275 | 276 | module.exports = allocate; 277 | -------------------------------------------------------------------------------- /lib/message.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const Data = require('./data'); 3 | const Attribute = require('./attribute'); 4 | const CONSTANTS = require('./constants'); 5 | const crc = require('crc'); 6 | 7 | const STATE_WAITING = 0; 8 | const STATE_RESOLVED = 1; 9 | const STATE_REJECTED = 2; 10 | const STATE_DISCARDED = 3; 11 | const STATE_INCOMMING = 4; 12 | 13 | /* 14 | * The most significant 2 bits of every STUN message MUST be zeroes 15 | * 16 | * 17 | * 0 1 2 3 18 | * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 19 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 20 | * |0 0| STUN Message Type | Message Length | 21 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 22 | * | Magic Cookie | 23 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 24 | * | | 25 | * | Transaction ID (96 bits) | 26 | * | | 27 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 28 | * 29 | * STUN Message Type: 30 | * 31 | * 0 1 32 | * 2 3 4 5 6 7 8 9 0 1 2 3 4 5 33 | * 34 | * +--+--+-+-+-+-+-+-+-+-+-+-+-+-+ 35 | * |M |M |M|M|M|C|M|M|M|C|M|M|M|M| 36 | * |11|10|9|8|7|1|6|5|4|0|3|2|1|0| 37 | * +--+--+-+-+-+-+-+-+-+-+-+-+-+-+ 38 | */ 39 | 40 | 41 | var message = function(server, transport) { 42 | this.server = server; 43 | this.transport = transport; 44 | this.allocation = null; 45 | this.class = null; 46 | this.method = null; 47 | this.useFingerprint = false; 48 | this.length = 0; 49 | this.raw = Buffer.alloc(0); 50 | this.attributes = []; 51 | this.magicCookie = CONSTANTS.MAGIC_COOKIE; 52 | this.transactionID = null; 53 | this.user = null; 54 | this.hmacInput = null; 55 | this._state = STATE_WAITING; 56 | this.debugLevel = server.debugLevel; 57 | this.debug = server.debug.bind(server); 58 | }; 59 | 60 | message.prototype.read = function(udpMessage) { 61 | 62 | this._state = STATE_INCOMMING; 63 | 64 | if (!udpMessage) { 65 | return false; 66 | } 67 | 68 | udpMessage = new Data(udpMessage); 69 | 70 | var firstBit = udpMessage.readBit(0); 71 | var secondBit = udpMessage.readBit(1); 72 | 73 | // The most significant 2nd bits of every TURN message MUST be zeroes. 74 | if (firstBit || secondBit) { 75 | return false; 76 | } 77 | 78 | // read class C1 7th bit and C0 11th bit 79 | this.class = udpMessage.readUncontiguous([7, 11]); 80 | 81 | // read method from M11 to M0 82 | this.method = udpMessage.readUncontiguous([2, 3, 4, 5, 6, 8, 9, 10, 12, 13, 14, 15]); 83 | 84 | var messageLength = udpMessage.readInt16BE(2); 85 | 86 | // check message length 87 | if (messageLength + 20 > udpMessage.length) { 88 | throw new Error('invalid STUN message length'); 89 | } 90 | 91 | this.magicCookie = udpMessage.readUInt32BE(4); 92 | 93 | // check magic cookie 94 | if (this.magicCookie != CONSTANTS.MAGIC_COOKIE) { 95 | return false; 96 | } 97 | 98 | this.transactionID = udpMessage.toString('hex', 8, 20); 99 | 100 | // read attributes 101 | var attributes = udpMessage.slice(20); 102 | 103 | 104 | while (attributes.length >= 4) { 105 | var attribute = new Attribute(this); 106 | attribute.read(attributes); 107 | 108 | 109 | if (attribute.type === CONSTANTS.ATTR.MESSAGE_INTEGRITY) { 110 | let hmacInput = udpMessage.slice(0, 20 + this.length); 111 | let username = this.getAttribute('username'); 112 | if (!username) { 113 | this.debug('DEBUG', 'no username sent'); 114 | attribute.value = false; 115 | } else { 116 | let password = this.server.authentification.credentials[username]; 117 | if (!password) { 118 | this.debug('DEBUG', 'invalid user'); 119 | attribute.value = false; 120 | } else { 121 | let hmacKey = crypto.createHash('md5').update(username + ':' + this.server.realm + ':' + password).digest(); 122 | // update message length to compute hmac 123 | let previousLength = hmacInput.readInt16BE(2); 124 | hmacInput.writeInt16BE(this.length + 24, 2); 125 | let hmac = crypto.createHmac('sha1', hmacKey).update(hmacInput).digest('hex'); 126 | // reset message length for fingerprint 127 | hmacInput.writeInt16BE(previousLength, 2); 128 | if (hmac !== attribute.value) { 129 | this.debug('DEBUG', 'invalid message-integrity, should be ' + hmac + ' instead of ' + attribute.value); 130 | attribute.value = false; 131 | } 132 | } 133 | } 134 | } 135 | if (attribute.type === CONSTANTS.ATTR.FINGERPRINT) { 136 | this.useFingerprint = true; 137 | let toHash = udpMessage.slice(0, 20 + this.length); 138 | let fingerprint = Buffer.alloc(4); 139 | fingerprint.writeUIntBE(crc.crc32(toHash), 0, 4); 140 | let xor = Buffer.alloc(4); 141 | xor.writeUIntBE(0x5354554e, 0, 4); 142 | fingerprint.forEach(function(byte, i) { 143 | fingerprint[i] = (parseInt(byte, 10) & 0xff) ^ xor[i]; 144 | }); 145 | if (fingerprint.readUIntBE(0,4) !== attribute.value) { 146 | // fingerprint is invalid 147 | attribute.value = false; 148 | } 149 | } 150 | this.attributes.push(attribute); 151 | this.length += 4 + attribute.length + attribute.padding; 152 | attributes = attributes.slice(4 + attribute.length + attribute.padding); 153 | } 154 | 155 | // all tests passed so this is a valid STUN message 156 | return true; 157 | }; 158 | 159 | message.prototype.reply = function() { 160 | var replyMsg = new message(this.server, this.transport.revert()); 161 | replyMsg.class = this.class; 162 | replyMsg.method = this.method; 163 | replyMsg.magicCookie = this.magicCookie; 164 | replyMsg.transactionID = this.transactionID; 165 | replyMsg.useFingerprint = this.useFingerprint; 166 | return replyMsg; 167 | }; 168 | 169 | message.prototype.getMethodName = function() { 170 | var self = this; 171 | var method = "unknown-method"; 172 | Object.keys(CONSTANTS.METHOD).forEach(function(methodName) { 173 | if (self.method === CONSTANTS.METHOD[methodName]) { 174 | method = methodName.toLowerCase(); 175 | } 176 | }); 177 | return method; 178 | }; 179 | 180 | message.prototype.getClassName = function() { 181 | var self = this; 182 | var _class = "unknown-class"; 183 | Object.keys(CONSTANTS.CLASS).forEach(function(className) { 184 | if (self.class === CONSTANTS.CLASS[className]) { 185 | _class = className.toLowerCase(); 186 | } 187 | }); 188 | return _class; 189 | }; 190 | 191 | message.prototype.setUser = function(user) { 192 | this.user = user; 193 | }; 194 | 195 | message.prototype.addAttribute = function(name, value) { 196 | if (!name) { 197 | throw new Error('addAttribute require a name as first argument'); 198 | } 199 | // skip message-integrity when no user is registered 200 | if (name === 'message-integrity' && !this.user) { 201 | return; 202 | } 203 | var attribute = new Attribute(this, name, value); 204 | this.attributes.push(attribute); 205 | // update length 206 | this.length += 4 + attribute.length + attribute.padding; 207 | }; 208 | 209 | message.prototype.getAttribute = function(name) { 210 | var attributeName = name.replace(/-/g, '_').toUpperCase(); 211 | var type = CONSTANTS.ATTR[attributeName]; 212 | var attribute = null; 213 | this.attributes.forEach(function(attr) { 214 | if (attr.type === type) { 215 | attribute = attribute || attr; 216 | } 217 | }); 218 | if (attribute && attribute.value !== void 0) { 219 | return attribute.value; 220 | } 221 | return void 0; 222 | }; 223 | 224 | message.prototype.getAttributes = function(name) { 225 | var attributeName = name.replace(/-/g, '_').toUpperCase(); 226 | var type = CONSTANTS.ATTR[attributeName]; 227 | var attributes = []; 228 | this.attributes.forEach(function(attr) { 229 | if (attr.type === type) { 230 | attributes.push(attr.value); 231 | } 232 | }); 233 | return attributes; 234 | }; 235 | 236 | message.prototype.toBuffer = function() { 237 | var self = this; 238 | 239 | // add fingerprint if it was used 240 | if (this.useFingerprint) { 241 | this.addAttribute('fingerprint'); 242 | } 243 | 244 | var header = new Data(Buffer.alloc(20)); 245 | // write two first bits to 0 246 | header.writeBit(0, 0); 247 | header.writeBit(0, 1); 248 | // write method in header 249 | header.writeUncontiguous(this.method, [2, 3, 4, 5, 6, 8, 9, 10, 12, 13, 14, 15]); 250 | // write success class in header 251 | header.writeUncontiguous(this.class, [7, 11]); 252 | 253 | // write message length 254 | header.writeUInt16BE(this.length, 2); 255 | 256 | // write Magic Cookie 257 | header.writeUInt32BE(this.magicCookie, 4); 258 | 259 | // write transactionID 260 | header.write(this.transactionID, 8, 20, 'hex'); 261 | 262 | this.raw = header; 263 | 264 | this.attributes.forEach(function(attribute) { 265 | self.raw = Buffer.concat([self.raw, attribute.toBuffer()]); 266 | }); 267 | 268 | return this.raw; 269 | 270 | }; 271 | 272 | message.prototype.resolve = function() { 273 | var self = this; 274 | 275 | if (this._state !== STATE_WAITING) { 276 | return; 277 | } 278 | 279 | this.class = CONSTANTS.CLASS.SUCCESS; 280 | 281 | var msg = this.toBuffer(); 282 | 283 | this._state = STATE_RESOLVED; 284 | 285 | this.transport.socket.send(msg, this.transport.dst.port, this.transport.dst.address, function(err) { 286 | if (err) { 287 | self.debug('FATAL', 'Fatal error while responding to ' + self.transport.dst + ' TransactionID: ' + self.transactionID + '\n' + self); 288 | self.debug('FATAL', err); 289 | return; 290 | } 291 | self.debug('DEBUG', 'Sending ' + self); 292 | }); 293 | }; 294 | 295 | message.prototype.reject = function(code, reason) { 296 | var self = this; 297 | 298 | if (this._state !== STATE_WAITING) { 299 | return; 300 | } 301 | 302 | this.class = CONSTANTS.CLASS.ERROR; 303 | this.addAttribute('error-code', { 304 | code: code, 305 | reason: reason 306 | }); 307 | this._state = STATE_REJECTED; 308 | var msg = this.toBuffer(); 309 | this.transport.socket.send(msg, this.transport.dst.port, this.transport.dst.address, function(err) { 310 | if (err) { 311 | self.debug('FATAL', 'Fatal error while responding to ' + self.transport.dst + ' TransactionID: ' + self.transactionID + '\n' + self); 312 | self.debug('FATAL', err); 313 | return; 314 | } 315 | self.debug('DEBUG', 'Sending ' + self); 316 | }); 317 | }; 318 | 319 | message.prototype.discard = function() { 320 | if (this._state !== STATE_WAITING) { 321 | return; 322 | } 323 | this._state = STATE_DISCARDED; 324 | this.debug('DEBUG', 'Discarding ' + this); 325 | }; 326 | 327 | message.prototype.data = function(data) { 328 | if (this._state !== STATE_WAITING) { 329 | return; 330 | } 331 | var self = this; 332 | this._state = STATE_RESOLVED; 333 | this.class = CONSTANTS.CLASS.INDICATION; 334 | this.method = CONSTANTS.METHOD.DATA; 335 | this.addAttribute('data', data); 336 | // generate a transactionID 337 | crypto.randomBytes(12, function(err, buf) { 338 | if (err) { 339 | self.debug('FATAL', 'Fatal error while generating TransactionID, indicating to ' + self.transport.dst + '\n' + self); 340 | self.debug('FATAL', err); 341 | return; 342 | } 343 | self.transactionID = buf.toString('hex'); 344 | var msg = self.toBuffer(); 345 | self.transport.socket.send(msg, self.transport.dst.port, self.transport.dst.address, function(err) { 346 | if (err) { 347 | self.debug('FATAL', 'Fatal error while indicating to ' + self.transport.dst + ' TransactionID: ' + self.transactionID + '\n' + self); 348 | self.debug('FATAL', err); 349 | return; 350 | } 351 | self.debug('DEBUG', 'Sending ' + self); 352 | }); 353 | }); 354 | }; 355 | 356 | message.prototype.toString = function() { 357 | var self = this; 358 | var str = ''; 359 | 360 | if (this.debugLevel <= CONSTANTS.DEBUG_LEVEL.DEBUG) { 361 | str = this.transport + ' ' + this.getMethodName() + ' ' + this.getClassName() + ' TransactionID: ' + this.transactionID; 362 | var indent = ' '; 363 | this.attributes.forEach(function(attribute) { 364 | if (Buffer.isBuffer(attribute.value)) { 365 | if (self.debugLevel <= CONSTANTS.DEBUG_LEVEL.TRACE) { 366 | str += '\n' + indent + attribute.name + ': '; 367 | attribute.value.toString('hex').match(/.{1,32}/g).forEach(function(bin) { 368 | str += '\n' + indent + indent + bin; 369 | }); 370 | return; 371 | } 372 | str += '\n' + indent + attribute.name + ': [ Binary data length: ' + attribute.value.length + ' ]'; 373 | return; 374 | } else if (attribute.name === 'error-code') { 375 | str += '\n' + indent + attribute.name + ": " + attribute.value.code + ' ' + attribute.value.reason; 376 | return; 377 | } 378 | str += '\n' + indent + attribute.name + ": " + attribute.value; 379 | }); 380 | } 381 | return str; 382 | }; 383 | 384 | module.exports = message; 385 | -------------------------------------------------------------------------------- /lib/attribute.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const CONSTANTS = require('./constants'); 3 | const Data = require('./data'); 4 | const Address = require('./address'); 5 | const crc = require('crc'); 6 | 7 | /* 8 | * STUN Attributes https://tools.ietf.org/html/rfc5389#section-15 9 | * 10 | * 0 1 2 3 11 | * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 12 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 13 | * | Type | Length | 14 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 15 | * | Value (variable) .... 16 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 17 | */ 18 | 19 | var attribute = function(msg, name, value) { 20 | if (!msg) { 21 | throw new Error('Attribute constructor need a parent Message Object as first argument'); 22 | } 23 | this.msg = msg; 24 | this.name = name || null; 25 | this.type = null; 26 | this.length = 0; 27 | this.value = null; 28 | this.padding = 0; 29 | 30 | if (name) { 31 | 32 | var attributeName = name.replace(/-/g, '_').toUpperCase(); 33 | this.type = CONSTANTS.ATTR[attributeName]; 34 | 35 | if (!this.type) { 36 | throw new Error('invalid attribute name: ' + name); 37 | } 38 | 39 | this.value = value; 40 | 41 | switch (this.type) { 42 | case CONSTANTS.ATTR.MAPPED_ADDRESS: 43 | this.length = 8; 44 | if (this.value.family === CONSTANTS.TRANSPORT.FAMILY.IPV6) { 45 | this.length = 20; 46 | } 47 | break; 48 | 49 | case CONSTANTS.ATTR.USERNAME: 50 | this.length = this.value.length; 51 | break; 52 | 53 | case CONSTANTS.ATTR.MESSAGE_INTEGRITY: 54 | this.length = 20; 55 | break; 56 | 57 | case CONSTANTS.ATTR.ERROR_CODE: 58 | this.length = 4 + this.value.reason.length; 59 | break; 60 | 61 | case CONSTANTS.ATTR.UNKNOWN_ATTRIBUTES: 62 | this.length = 2; 63 | break; 64 | 65 | case CONSTANTS.ATTR.REALM: 66 | this.length = this.value.length; 67 | break; 68 | 69 | case CONSTANTS.ATTR.NONCE: 70 | this.length = this.value.length; 71 | break; 72 | 73 | case CONSTANTS.ATTR.XOR_MAPPED_ADDRESS: 74 | case CONSTANTS.ATTR.XOR_PEER_ADDRESS: 75 | case CONSTANTS.ATTR.XOR_RELAYED_ADDRESS: 76 | this.length = 8; 77 | if (this.value.family === CONSTANTS.TRANSPORT.FAMILY.IPV6) { 78 | this.length = 20; 79 | } 80 | break; 81 | 82 | // Comprehension-optional range (0x8000-0xFFFF) 83 | case CONSTANTS.ATTR.SOFTWARE: 84 | this.length = this.value.length; 85 | break; 86 | 87 | case CONSTANTS.ATTR.ALTERNATE_SERVER: 88 | break; 89 | 90 | case CONSTANTS.ATTR.FINGERPRINT: 91 | this.length = 4; 92 | break; 93 | 94 | // https://tools.ietf.org/html/rfc5766#section-6.2 95 | case CONSTANTS.ATTR.CHANNEL_NUMBER: 96 | this.length = 4; 97 | break; 98 | 99 | case CONSTANTS.ATTR.LIFETIME: 100 | this.length = 4; 101 | break; 102 | 103 | case CONSTANTS.ATTR.DATA: 104 | this.length = this.value.length; 105 | break; 106 | 107 | case CONSTANTS.ATTR.EVEN_PORT: 108 | this.length = 1; 109 | break; 110 | 111 | case CONSTANTS.ATTR.REQUESTED_TRANSPORT: 112 | this.length = 4; 113 | break; 114 | 115 | case CONSTANTS.ATTR.DONT_FRAGMENT: 116 | this.length = 0; 117 | break; 118 | 119 | case CONSTANTS.ATTR.RESERVATION_TOKEN: 120 | this.length = 8; 121 | break; 122 | 123 | default: 124 | throw new Error('invalid type ' + name); 125 | } 126 | } 127 | 128 | this.padding = this.length % 4 ? 4 - (this.length % 4) : 0; 129 | }; 130 | 131 | attribute.prototype.read = function(data) { 132 | var self = this; 133 | this.type = data.readUIntBE(0, 2); 134 | this.length = data.readUIntBE(2, 2); 135 | this.padding = this.length % 4 ? 4 - (this.length % 4) : 0; 136 | switch (this.type) { 137 | case CONSTANTS.ATTR.ALTERNATE_SERVER: 138 | this.value = data.readUIntBE(4, this.length); 139 | break; 140 | case CONSTANTS.ATTR.CHANNEL_NUMBER: 141 | this.value = data.readUIntBE(4, 2); 142 | break; 143 | case CONSTANTS.ATTR.DATA: 144 | this.value = data.slice(4, this.length + 4); 145 | break; 146 | case CONSTANTS.ATTR.DONT_FRAGMENT: 147 | this.value = true; 148 | break; 149 | case CONSTANTS.ATTR.ERROR_CODE: 150 | this.value = data.readUIntBE(4, this.length); 151 | break; 152 | case CONSTANTS.ATTR.EVEN_PORT: 153 | /* 154 | * 0 155 | * 0 1 2 3 4 5 6 7 156 | * +-+-+-+-+-+-+-+-+ 157 | * |R| RFFU | 158 | * +-+-+-+-+-+-+-+-+ 159 | */ 160 | this.value = data.readBit(32); 161 | break; 162 | case CONSTANTS.ATTR.FINGERPRINT: 163 | this.value = data.readUIntBE(4, this.length); 164 | break; 165 | case CONSTANTS.ATTR.LIFETIME: 166 | this.value = data.readUIntBE(4, this.length); 167 | break; 168 | case CONSTANTS.ATTR.MAPPED_ADDRESS: 169 | this.value = data.readUIntBE(4, this.length); 170 | break; 171 | case CONSTANTS.ATTR.MESSAGE_INTEGRITY: 172 | this.value = data.toString('hex', 4, 4 + this.length); 173 | break; 174 | case CONSTANTS.ATTR.NONCE: 175 | this.value = data.toString('utf8', 4, 4 + this.length); 176 | break; 177 | case CONSTANTS.ATTR.REALM: 178 | this.value = data.toString('utf8', 4, 4 + this.length); 179 | break; 180 | case CONSTANTS.ATTR.REQUESTED_TRANSPORT: 181 | this.value = data.readUIntBE(4, 1); 182 | break; 183 | case CONSTANTS.ATTR.RESERVATION_TOKEN: 184 | this.value = data.readUIntBE(4, this.length); 185 | break; 186 | case CONSTANTS.ATTR.SOFTWARE: 187 | this.value = data.toString('utf8', 4, 4 + this.length); 188 | break; 189 | case CONSTANTS.ATTR.UNKNOWN_ATTRIBUTES: 190 | this.value = data.readUIntBE(4, this.length); 191 | break; 192 | case CONSTANTS.ATTR.USERNAME: 193 | this.value = data.toString('utf8', 4, 4 + this.length); 194 | break; 195 | case CONSTANTS.ATTR.XOR_MAPPED_ADDRESS: 196 | case CONSTANTS.ATTR.XOR_PEER_ADDRESS: 197 | case CONSTANTS.ATTR.XOR_RELAYED_ADDRESS: 198 | var family = data.readUIntBE(5, 1); 199 | var xport = data.readUIntBE(6, 2); 200 | var port = xport ^ (this.msg.magicCookie >> 16); 201 | if (family === CONSTANTS.TRANSPORT.FAMILY.IPV4) { 202 | var magicCookieBuffer = Buffer.alloc(4); 203 | magicCookieBuffer.writeUInt32BE(this.msg.magicCookie); 204 | var address = []; 205 | address.push((data[8] & 0xff) ^ magicCookieBuffer[0]); 206 | address.push((data[9] & 0xff) ^ magicCookieBuffer[1]); 207 | address.push((data[10] & 0xff) ^ magicCookieBuffer[2]); 208 | address.push((data[11] & 0xff) ^ magicCookieBuffer[3]); 209 | address = address.join('.'); 210 | this.value = new Address(address, port); 211 | } else { 212 | var key = Buffer.alloc(16); 213 | key.writeUInt32BE(this.msg.magicCookie); 214 | key.write(this.msg.transactionID, 4, 12, 'hex'); 215 | var address = []; 216 | for (var i=0; i < 8; i++) { 217 | address.push((data.readUInt16BE(8 + i * 2) ^ key.readUInt16BE(i * 2)).toString(16)); 218 | } 219 | this.value = new Address(address.join(':'), port); 220 | } 221 | break; 222 | default: 223 | throw new Error('Invalid Attribute type ' + this.type.toString(16)); 224 | } 225 | 226 | Object.keys(CONSTANTS.ATTR).forEach(function(name) { 227 | if (CONSTANTS.ATTR[name] === self.type) { 228 | self.name = name.replace(/_/g, '-').toLowerCase(); 229 | } 230 | }); 231 | }; 232 | 233 | attribute.prototype.toBuffer = function() { 234 | var attrValue = Data.alloc(this.length); 235 | switch (this.type) { 236 | case CONSTANTS.ATTR.MAPPED_ADDRESS: 237 | /* 238 | * MAPPED-ADDRESS https://tools.ietf.org/html/rfc5389#section-15.1 239 | * 0 1 2 3 240 | * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 241 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 242 | * |0 0 0 0 0 0 0 0| Family | Port | 243 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 244 | * | | 245 | * | Address (32 bits or 128 bits) | 246 | * | | 247 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 248 | */ 249 | 250 | // value: Address Object 251 | 252 | // write family 253 | attrValue.writeUIntBE(this.value.family, 1, 1); 254 | // write port 255 | attrValue.writeUIntBE(this.value.port, 2, 2); 256 | // write address 257 | if (this.value.family === CONSTANTS.TRANSPORT.FAMILY.IPV4) { 258 | var ip = this.value.address.split('.'); 259 | attrValue.writeIntBE(parseInt(ip[0]), 4, 1); 260 | attrValue.writeIntBE(parseInt(ip[1]), 5, 1); 261 | attrValue.writeIntBE(parseInt(ip[2]), 6, 1); 262 | attrValue.writeIntBE(parseInt(ip[3]), 7, 1); 263 | } else { 264 | var ip = this.value.address.split(':'); 265 | for (var i in ip) { 266 | attrValue.writeIntBE(parseInt(ip[i]), i + 4, 1); 267 | } 268 | } 269 | break; 270 | 271 | case CONSTANTS.ATTR.USERNAME: 272 | // value: string (username) 273 | attrValue.write(this.value); 274 | break; 275 | 276 | case CONSTANTS.ATTR.MESSAGE_INTEGRITY: 277 | /* 278 | * value: void 0 279 | */ 280 | if (!this.msg.raw || !this.msg.raw.length) { 281 | throw new Error('Invalid Message for MESSAGE_INTEGRITY attribute'); 282 | } 283 | if (!this.msg.user) { 284 | throw new Error('MESSAGE_INTEGRITY attribute require an Auth User'); 285 | } 286 | var hmacKey = crypto.createHash('md5').update(this.msg.user.username + ':' + this.msg.server.realm + ':' + this.msg.user.password).digest(); 287 | if (!hmacKey || !hmacKey.length) { 288 | throw new Error('Invalid Message User for MESSAGE_INTEGRITY attribute'); 289 | } 290 | 291 | if (this.msg.useFingerprint) { 292 | // update message length to compute hmac 293 | let previousLength = this.msg.raw.readInt16BE(2); 294 | this.msg.raw.writeInt16BE(previousLength - 8, 2); 295 | attrValue = crypto.createHmac('sha1', hmacKey).update(this.msg.raw).digest(); 296 | this.msg.raw.writeInt16BE(previousLength, 2); 297 | } else { 298 | attrValue = crypto.createHmac('sha1', hmacKey).update(this.msg.raw).digest(); 299 | } 300 | this.value = attrValue.toString('hex'); 301 | break; 302 | 303 | case CONSTANTS.ATTR.ERROR_CODE: 304 | /* 305 | * ERROR-CODE https://tools.ietf.org/html/rfc5389#section-15.6 306 | * 307 | * 0 1 2 3 308 | * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 309 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 310 | * | Reserved, should be 0 |Class| Number | 311 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 312 | * | Reason Phrase (variable) .. 313 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 314 | */ 315 | 316 | /* 317 | * value: Object 318 | * code: integer (in range of 300 to 699) 319 | * reason: string (length < 128) 320 | */ 321 | 322 | // ensure code is in the range of 300 to 699 323 | if (this.value.code < 300 || this.value.code > 699) { 324 | throw new Error('invalid code argument for error-code attribute, code MUST be in range of 300 to 699'); 325 | } 326 | // ensure reason length is lower than 128 327 | if (this.value.reason.length > 128) { 328 | throw new Error('invalid reason argument for error-code attribute, reason MUST be shorter than 128 characters'); 329 | } 330 | var errorClass = parseInt(this.value.code / 100); 331 | var errorNumber = this.value.code % 100; 332 | 333 | attrValue.writeUncontiguous(errorClass, [21, 22, 23]); 334 | attrValue.writeIntBE(errorNumber, 3, 1); 335 | attrValue.write(this.value.reason, 4); 336 | break; 337 | 338 | case CONSTANTS.ATTR.UNKNOWN_ATTRIBUTES: 339 | attrValue.writeUIntBE(this.value, 0, 2); 340 | break; 341 | 342 | case CONSTANTS.ATTR.REALM: 343 | attrValue.write(this.value); 344 | break; 345 | 346 | case CONSTANTS.ATTR.NONCE: 347 | attrValue.write(this.value); 348 | break; 349 | 350 | case CONSTANTS.ATTR.XOR_MAPPED_ADDRESS: 351 | case CONSTANTS.ATTR.XOR_PEER_ADDRESS: 352 | case CONSTANTS.ATTR.XOR_RELAYED_ADDRESS: 353 | /* 354 | * XOR-MAPPED-ADDRESS https://tools.ietf.org/html/rfc5389#section-15.2 355 | * 356 | * 0 1 2 3 357 | * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 358 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 359 | * |x x x x x x x x| Family | X-Port | 360 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 361 | * | X-Address (Variable) 362 | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 363 | */ 364 | 365 | // value: Transport Object 366 | 367 | // write family 368 | attrValue.writeUIntBE(this.value.family, 1, 1); 369 | // write X-Port (the mapped port XOR'ing it with the most significant 16 bits of the magic cookie) 370 | attrValue.writeUIntBE((this.value.port ^ (this.msg.magicCookie >> 16)), 2, 2); 371 | // write X-Address 372 | if (this.value.family === CONSTANTS.TRANSPORT.FAMILY.IPV4) { 373 | var magicCookieBuffer = Buffer.alloc(4); 374 | magicCookieBuffer.writeUInt32BE(this.msg.magicCookie); 375 | var ipaddr = this.value.address.split('.'); 376 | ipaddr.forEach(function(byte, i) { 377 | attrValue[i + 4] = (parseInt(byte, 10) & 0xff) ^ magicCookieBuffer[i]; 378 | }); 379 | } else { 380 | var key = Buffer.alloc(16); 381 | key.writeUInt32BE(this.msg.magicCookie); 382 | key.write(this.msg.transactionID, 4, 12, 'hex'); 383 | ip = this.value.address.split(':'); 384 | for (var i in ip) { 385 | attrValue.writeUInt16BE(parseInt(ip[i], 16) ^ key.readUInt16BE(i * 2), i * 2 + 4); 386 | } 387 | } 388 | break; 389 | 390 | // Comprehension-optional range (0x8000-0xFFFF) 391 | case CONSTANTS.ATTR.SOFTWARE: 392 | attrValue.write(this.value); 393 | break; 394 | 395 | case CONSTANTS.ATTR.ALTERNATE_SERVER: 396 | break; 397 | 398 | case CONSTANTS.ATTR.FINGERPRINT: 399 | attrValue.writeUIntBE(crc.crc32(this.msg.raw), 0, 4); 400 | var xor = Buffer.alloc(4); 401 | xor.writeUIntBE(0x5354554e, 0, 4); 402 | attrValue.forEach(function(byte, i) { 403 | attrValue[i] = (parseInt(byte, 10) & 0xff) ^ xor[i]; 404 | }); 405 | this.value = attrValue.readUIntBE(0, 4); 406 | break; 407 | 408 | // https://tools.ietf.org/html/rfc5766#section-6.2 409 | case CONSTANTS.ATTR.CHANNEL_NUMBER: 410 | break; 411 | 412 | case CONSTANTS.ATTR.LIFETIME: 413 | // value: integer (number of seconds remaining until expiration.) 414 | attrValue.writeUIntBE(this.value, 0, 4); 415 | break; 416 | 417 | case CONSTANTS.ATTR.DATA: 418 | // value: Buffer 419 | attrValue = this.value; 420 | break; 421 | 422 | case CONSTANTS.ATTR.EVEN_PORT: 423 | // value: Boolean 424 | if (this.value) { 425 | attrValue.writeIntBE(0x80, 0, 1); 426 | } 427 | break; 428 | 429 | case CONSTANTS.ATTR.REQUESTED_TRANSPORT: 430 | // value: CONSTANTS.TRANSPORT.PROTOCOL 431 | attrValue.writeIntBE(this.value, 0, 1); 432 | break; 433 | 434 | case CONSTANTS.ATTR.DONT_FRAGMENT: 435 | // nothing to do 436 | break; 437 | 438 | case CONSTANTS.ATTR.RESERVATION_TOKEN: 439 | // 0 - String: token value 440 | attrValue.write(this.value); 441 | break; 442 | 443 | default: 444 | throw new Error('invalid type ' + this.type); 445 | } 446 | 447 | var attrHeader = Buffer.alloc(4); 448 | attrHeader.writeUIntBE(this.type, 0, 2); 449 | attrHeader.writeUIntBE(this.length, 2, 2); 450 | return Buffer.concat([attrHeader, attrValue, Buffer.alloc(this.padding)]); 451 | }; 452 | 453 | module.exports = attribute; 454 | --------------------------------------------------------------------------------