diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..8c2e19e --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +xtest/* diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..d561f83 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,126 @@ +{ + "env": { + "node": true, + "es6": true + }, + "parserOptions": { + "ecmaFeatures": { + "jsx": false, + "modules": false + }, + "ecmaVersion": 2017 + }, + "plugins": ["promise"], + "rules": { + "promise/always-return": "error", + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/catch-or-return": "error", + "promise/no-native": "off", + "promise/no-nesting": "warn", + "promise/no-promise-in-callback": "warn", + "promise/no-callback-in-promise": "warn", + "promise/no-return-in-finally": "warn", + + // Possible Errors + // http://eslint.org/docs/rules/#possible-errors + "comma-dangle": [2, "only-multiline"], + "no-control-regex": 2, + "no-debugger": 2, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-empty-character-class": 2, + "no-ex-assign": 2, + "no-extra-boolean-cast" : 2, + "no-extra-parens": [2, "functions"], + "no-extra-semi": 2, + "no-func-assign": 2, + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-negated-in-lhs": 2, + "no-obj-calls": 2, + "no-proto": 2, + "no-unexpected-multiline": 2, + "no-unreachable": 2, + "use-isnan": 2, + "valid-typeof": 2, + + // Best Practices + // http://eslint.org/docs/rules/#best-practices + "no-fallthrough": 2, + "no-octal": 2, + "no-redeclare": 2, + "no-self-assign": 2, + "no-unused-labels": 2, + + // Strict Mode + // http://eslint.org/docs/rules/#strict-mode + "strict": [2, "never"], + + // Variables + // http://eslint.org/docs/rules/#variables + "no-delete-var": 2, + "no-undef": 2, + "no-unused-vars": [2, {"args": "none"}], + + // Node.js and CommonJS + // http://eslint.org/docs/rules/#nodejs-and-commonjs + "no-mixed-requires": 2, + "no-new-require": 2, + "no-path-concat": 2, + "no-restricted-modules": [2, "sys", "_linklist"], + + // Stylistic Issues + // http://eslint.org/docs/rules/#stylistic-issues + "comma-spacing": 2, + "eol-last": 2, + "indent": [2, 2, {"SwitchCase": 1}], + "keyword-spacing": 2, + "max-len": [2, 120, 2], + "new-parens": 2, + "no-mixed-spaces-and-tabs": 2, + "no-multiple-empty-lines": [2, {"max": 2}], + "no-trailing-spaces": [2, {"skipBlankLines": false }], + "quotes": [2, "single", "avoid-escape"], + "semi": 2, + "space-before-blocks": [2, "always"], + "space-before-function-paren": [2, "never"], + "space-in-parens": [2, "never"], + "space-infix-ops": 2, + "space-unary-ops": 2, + + // ECMAScript 6 + // http://eslint.org/docs/rules/#ecmascript-6 + "arrow-parens": [2, "always"], + "arrow-spacing": [2, {"before": true, "after": true}], + "constructor-super": 2, + "no-class-assign": 2, + "no-confusing-arrow": 2, + "no-const-assign": 2, + "no-dupe-class-members": 2, + "no-new-symbol": 2, + "no-this-before-super": 2, + "prefer-const": 2 + }, + "globals": { + "DTRACE_HTTP_CLIENT_REQUEST" : false, + "LTTNG_HTTP_CLIENT_REQUEST" : false, + "COUNTER_HTTP_CLIENT_REQUEST" : false, + "DTRACE_HTTP_CLIENT_RESPONSE" : false, + "LTTNG_HTTP_CLIENT_RESPONSE" : false, + "COUNTER_HTTP_CLIENT_RESPONSE" : false, + "DTRACE_HTTP_SERVER_REQUEST" : false, + "LTTNG_HTTP_SERVER_REQUEST" : false, + "COUNTER_HTTP_SERVER_REQUEST" : false, + "DTRACE_HTTP_SERVER_RESPONSE" : false, + "LTTNG_HTTP_SERVER_RESPONSE" : false, + "COUNTER_HTTP_SERVER_RESPONSE" : false, + "DTRACE_NET_STREAM_END" : false, + "LTTNG_NET_STREAM_END" : false, + "COUNTER_NET_SERVER_CONNECTION_CLOSE" : false, + "DTRACE_NET_SERVER_CONNECTION" : false, + "LTTNG_NET_SERVER_CONNECTION" : false, + "COUNTER_NET_SERVER_CONNECTION" : false + } +} diff --git a/.gitignore b/.gitignore index 92299c1..2ecb6bc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ node_modules config.js .DS_Store +.vscode +.eslint* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..bd8184c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +sudo: required + +dist: xenial + +language: node_js + +node_js: + - "lts/*" + +services: + - docker + +before_install: + - docker pull drachtio/drachtio-server:latest + - docker pull drachtio/sipp:latest + - docker pull davehorton/freeswitch-hairpin:latest + - docker images + +script: + - npm test diff --git a/app.js b/app.js index c8a4f83..63fae17 100644 --- a/app.js +++ b/app.js @@ -1,49 +1,38 @@ -'use strict'; - -var drachtio = require('drachtio') ; -var app = drachtio() ; -var config = app.config = require('./config'); -var fs = require('fs') ; -var rangeCheck = require('range_check'); -var debug = app.debug = require('debug')('simple-sip-proxy') ; - -// Expose app -exports = module.exports = app; - -//connect to drachtio server -app.connect({ - host: config.drachtioServer.address, - port: config.drachtioServer.port, - secret: config.drachtioServer.secret, -}) ; - -app.on('connect', function(err, hostport) { - if( err ) throw err ; - console.log('connected to drachtio server at %s', hostport) ; -}) -.on('error', function(err){ - console.warn(err.message ) ; -}) ; - -app.use(function checkSender(req, res, next) { - if( !rangeCheck.inRange( req.source_address, app.config.authorizedSources) ) { return res.send(403) ; } - next() ; -}) ; -app.use(function onlyInvites( req, res, next ) { - if( -1 === ['invite','cancel','prack','ack'].indexOf( req.method.toLowerCase() ) ) { return res.send(405); } - next() ; -}); - -require('./lib/proxy.js')(app); - -//handle any errors thrown while executing middleware stack -app.use(function myErrorHandler(err, req, res, next) { - debug('caught error generated from middleware: ', err); - res.send(500, { - headers: { - 'X-Error-Info': err.message || 'unknown application error' - } - }) ; -}) ; - - +const Srf = require('drachtio-srf') ; +const srf = new Srf(); +const config = require('config'); +const pino = require('pino'); +const level = config.has('log.level') ? config.get('log.level') : 'info'; +const logger = srf.locals.logger = pino({level}); +const RouteMgr = require('./lib/routing/route-manager'); +const routeMgr = new RouteMgr({logger, srf}); +const validate = require('./lib/validation-middleware')({logger, routeMgr}); +const inviteHandler = require('./lib/invite')({logger, srf, routeMgr}); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +srf.connect(config.get('drachtio')) + .on('connect', (err, hp) => { + if (err) return logger.error(err, 'error connecting'); + logger.info(`connected to drachtio server at ${hp}`); + }); + +if (process.env.NODE_ENV !== 'test') { + srf.on('error', (err) => { + logger.error(err, 'Error connecting to drachtio server'); + }); +} + +srf.use(validate); +srf.invite(inviteHandler); +srf.info((req, res) => res.send(200)); + + +// for test purposes only +if (process.env.NODE_ENV === 'test') { + srf.routeMgr = () => routeMgr; + + module.exports = srf; +} diff --git a/config/default.json.example b/config/default.json.example new file mode 100644 index 0000000..91d2b11 --- /dev/null +++ b/config/default.json.example @@ -0,0 +1,80 @@ +{ + "drachtio": { + "host": "127.0.0.1", + "port": 9022, + "secret": "cymru" + }, + "external-trunks": { + "carrier-1": { + "type": "in", + "signaling-address": ["10.10.100.1:5060", "10.10.100.2:5060"], + "enabled": false + }, + "carrier-2": { + "type": "out", + "signaling-address": "10.10.100.5", + "groups": "cheap" + }, + "carrier-3": { + "type": "both", + "signaling-address": "10.10.100.6", + "groups": ["cheap", "international"] + } + }, + "internal-trunks": { + "appserver1": { + "signaling-address": "10.10.200.1", + "options-ping": true, + "enabled": true + }, + "appserver2": { + "signaling-address": "10.10.200.2", + "options-ping": true, + "enabled": false + } + }, + "options-ping": { + "interval": "6000" + }, + "proxy-request-types": ["INVITE"], + "routing": { + "outbound": { + "default": [ + { + "target": "carrier-2", + "ratio": 9 + }, + { + "target": "carrier-1", + "ratio": 1 + } + ] + }, + "international": { + "regex": "+?1\\d{10}", + "match": ["international"] + }, + "toll-free": { + "regex": "+?1\\d{10}", + "match": ["carrier-2"], + "no-match": ["carrier-3"] + }, + "test": { + "regex": "+15083084809", + "against": "from", + "match": ["carrier-2"] + } + }, + "inbound": { + "default": [ + { + "target": "appserver1", + "ratio": 2 + }, + { + "target": "appserver2", + "ratio": 1 + } + ] + } +} \ No newline at end of file diff --git a/config/test-1.json b/config/test-1.json new file mode 100644 index 0000000..219649b --- /dev/null +++ b/config/test-1.json @@ -0,0 +1,91 @@ +{ + "drachtio": { + "host": "127.0.0.1", + "port": 9022, + "secret": "cymru" + }, + "trunks": { + "outside": { + "carrier-1": { + "direction": "in", + "address": "10.10.100.1:5060", + "enabled": false + }, + "carrier-2": { + "direction": "out", + "address": "10.10.100.5", + "groups": "cheap" + }, + "carrier-3": { + "direction": "both", + "address": ["10.10.100.6","10.10.100.7"], + "groups": ["cheap", "international"] + }, + "carrier-4": { + "direction": "out", + "address": "10.10.100.8", + "enabled": false, + "groups": "cheap" + } + }, + "inside": { + "appserver1": { + "address": "10.10.200.1", + "options-ping": true, + "groups": "standby", + "enabled": true + }, + "voicemail": { + "address": "10.10.200.1", + "options-ping": true, + "groups": ["media-processing", "storage"], + "enabled": true + }, + "appserver2": { + "address": ["10.10.200.2", "10.10.200.6"], + "options-ping": true, + "enabled": false + } + } + }, + "routes": { + "outside": { + "international": { + "regex": "\\+?1\\d{10}", + "match": ["international"] + }, + "toll-free": { + "regex": "\\+?1\\d{10}", + "match": ["carrier-2"], + "no-match": "carrier-3" + }, + "test": { + "regex": "^4083084809$", + "against": "from", + "match": ["carrier-2"] + }, + "default": [ + { + "dest": "carrier-2", + "weight": 9 + }, + { + "dest": "carrier-1", + "weight": 1 + } + ] + }, + "inside": { + "default": [ + { + "dest": "appserver1", + "weight": 2 + }, + { + "dest": "appserver2", + "weight": 1 + } + ] + } + } +} \ No newline at end of file diff --git a/config/test-100.json b/config/test-100.json new file mode 100644 index 0000000..3fb4fe7 --- /dev/null +++ b/config/test-100.json @@ -0,0 +1,67 @@ +{ + "drachtio": { + "host": "127.0.0.1", + "port": 9061, + "secret": "cymru" + }, + "log": { + "level": "info" + }, + "trunks": { + "outside": { + "carrier-1": { + "direction": "in", + "address": "172.19.0.100" + }, + "carrier-2": { + "direction": "out", + "address": "172.19.0.112" + } + }, + "inside": { + "freeswitch-1": { + "address": "172.19.0.21", + "options-ping": false + }, + "freeswitch-2": { + "address": "172.19.0.22", + "options-ping": false + }, + "freeswitch-3": { + "address": "172.19.0.23", + "options-ping": false + }, + "freeswitch-4": { + "address": "172.19.0.24", + "options-ping": false + }, + "sipp-online": { + "address": "172.19.0.110", + "options-ping": true, + "options-timeout": 1000, + "options-interval": 3000 + }, + "sipp-offline": { + "address": "172.19.0.111", + "options-ping": true, + "options-timeout": 1000, + "options-interval": 3000 + }, + "sipp-noexist": { + "address": "172.19.0.121", + "options-ping": true, + "options-timeout": 1000, + "options-interval": 3000 + } + } + }, + "routes": { + "inside": { + "default": [ + "172.19.0.21", + "172.19.0.22", + "172.19.0.23" + ] + } + } +} \ No newline at end of file diff --git a/config/test-2.json b/config/test-2.json new file mode 100644 index 0000000..a880da7 --- /dev/null +++ b/config/test-2.json @@ -0,0 +1,79 @@ +{ + "drachtio": { + "host": "127.0.0.1", + "port": 9022, + "secret": "cymru" + }, + "trunks": { + "outside": { + "carrier-1": { + "direction": "in", + "address": "10.10.100.1:5060", + "enabled": false + }, + "carrier-2": { + "direction": "out", + "address": "10.10.100.5", + "groups": "cheap" + }, + "carrier-3": { + "direction": "both", + "address": ["10.10.100.6","10.10.100.7"], + "groups": ["cheap", "international"] + }, + "carrier-4": { + "direction": "out", + "address": "10.10.100.8", + "enabled": false, + "groups": "cheap" + } + }, + "inside": { + "appserver1": { + "address": "10.10.200.1", + "options-ping": true, + "groups": "standby", + "enabled": true + }, + "voicemail": { + "address": "10.10.200.99", + "options-ping": true, + "groups": ["media-processing", "storage"], + "enabled": true + }, + "appserver2": { + "address": ["10.10.200.2", "10.10.200.6"], + "options-ping": true, + "enabled": false + } + } + }, + "routes": { + "outside": { + "international": { + "regex": "\\+?1\\d{10}", + "match": ["international"] + }, + "toll-free": { + "regex": "\\+?1\\d{10}", + "match": ["carrier-2"], + "no-match": "carrier-3" + }, + "test": { + "regex": "^4083084809$", + "against": "from", + "match": ["carrier-2"] + }, + "default": [ + { + "dest": "carrier-2", + "weight": 9 + }, + { + "dest": "carrier-1", + "weight": 1 + } + ] + } + } +} \ No newline at end of file diff --git a/lib/invite.js b/lib/invite.js new file mode 100644 index 0000000..d6cb26a --- /dev/null +++ b/lib/invite.js @@ -0,0 +1,20 @@ +module.exports = function({logger, srf, routeMgr}) { + const parentLogger = logger; + + return function(req, res) { + const logger = parentLogger.child({'callId': req.get('Call-Id')}); + const sourceSide = req.locals.source.side; + const destSide = sourceSide === 'inside' ? 'outside' : 'inside'; + const uri = routeMgr.chooseRoute(destSide, req); + + if (!uri) { + logger.error(req.locals.source, 'No available routes for this call, returning 480'); + return res.send(480); + } + logger.info(req.locals.source, `received call from ${sourceSide}, proxying to uri: ${uri}`); + + srf.proxyRequest(req, uri, { + recordRoute: true + }); + }; +}; diff --git a/lib/proxy.js b/lib/proxy.js deleted file mode 100644 index 9ffa91e..0000000 --- a/lib/proxy.js +++ /dev/null @@ -1,168 +0,0 @@ -'use strict'; - -var fs = require('fs') ; -var path = require('path') ; -var _ = require('lodash') ; -var allPossibleTargets = [] ; -var dest = [] ; -var debug ; -var config ; - -function createTargets( config ) { - allPossibleTargets = _.filter( config.targets, function(t) { return t.enabled === true; }) ; -} -function isValidTarget( target ) { - return _.find( allPossibleTargets, function(t) {return t.address === target.address && t.port === target.port; }) ; -} - -function pingTarget( app, target ) { - var dest = 'sip:' + target.address + ':' + (target.port || 5060) ; - if( target.markedForDeath ) { - console.log('removing target %s due to re-loading config file: ', dest) ; - return; - } - - //start a timer - if we don't get a response within a second we'll mark it down - var timeoutHandler = setTimeout( function() { - //if we get here we timed out - if( !target.offline ) { - target.offline = true ; - console.error('taking target %s offline because it did not respond to ping: ', dest) ; - } - }, 1500 ) ; - - //send the OPTIONS ping - console.log('OPTIONS %s', dest) ; - app.request({ - uri: dest, - method: 'OPTIONS', - headers: { - 'User-Agent': 'phone.com internal proxy' - } - }, function( err, req ) { - if( err ) { - setTimeout( function() { - if( isValidTarget( target ) && !target.markedForDeath ) { - pingTarget( app, target); - } - else { - console.log('stop sending OPTIONS ping to %s; it has been removed from the configuration file', dest) ; - } - }, config.pingIntervalWhenOffline || config.optionsPingInterval) ; - return console.error('Error pinging %s', target) ; - } - - req.on('response', function(res){ - - console.log('%s: %d %s', dest, res.status, res.reason) ; - clearTimeout( timeoutHandler ) ; - if( 200 === res.status ) { - if( target.offline ) { - target.offline = false ; - console.log('bringing target back online: %s', dest) ; - } - } - else if( 503 === res.status ) { - if( !target.offline) { - target.offline = true ; - console.log('taking target offline due to 503 response: ', dest) ; - } - } - setTimeout( function() { - if( isValidTarget( target ) && !target.markedForDeath ) { - pingTarget( app, target); - } - else { - console.log('stop sending OPTIONS ping to %s; it has been removed from the configuration file', dest) ; - } - }, target.offline === true ? (config.pingIntervalWhenOffline || config.optionsPingInterval) : config.optionsPingInterval) ; - }) ; - }) ; -} - -function stopPinging() { - console.log('stopping OPTIONS pings..') ; - allPossibleTargets.forEach(function(t) { t.markedForDeath = true; }) ; -} - -function startPinging( app, config ) { - var pingTargets = _.filter( allPossibleTargets, function(t) {return t.optionsPing === true; }) ; - console.log('start OPTIONS pings to targets at %d ms interval: ', config.optionsPingInterval, JSON.stringify(pingTargets)) ; - pingTargets.forEach( function(t) {pingTarget(app,t); }) ; -} - -module.exports = function(app) { - debug = app.debug ; - config = app.config ; - - createTargets(config) ; - app.on('connect', function(err) { - startPinging(app,config); - }); - - //min allowed ping interval is 2 seconds - config.optionsPingInterval = Math.max(config.optionsPingInterval || 5000, 2000) ; - console.log('options ping interval will be %d milliseconds', config.optionsPingInterval) ; - - createTargets(config) ; - startPinging(app, config) ; - - //watch config file for changes - we allow the user to dynamically add or remove targets - var configPath = path.resolve(__dirname) + '/../config.js' ; - fs.watchFile(configPath, function () { - try { - console.log('config.js was just modified...') ; - - delete require.cache[require.resolve(configPath)] ; - config = require(configPath) ; - stopPinging() ; - createTargets( config ) ; - - console.log('proxy targets are now: ', - _.map( allPossibleTargets, function(t) { return _.pick(t, ['address','port','enabled','optionsPing']);})); - - startPinging(app, config) ; - - } catch( err ) { - console.error('Error re-reading config.js after modification; check to ensure there are no syntax errors: ', err) ; - } - }) ; - - app.invite( function(req,res) { - - //filter out offline servers - var onlineServers = _.filter( allPossibleTargets, function(t) { return !t.offline; }) ; - - if( 0 === onlineServers.length ) { - if( config.proxyWhenAllTargetsOffline === true ) { - onlineServers = allPossibleTargets ; - console.log('all servers seem down, but attempting to proxy anyways') ; - } - else { - console.error('there are no servers online currently') ; - return res.send(480) ; - } - } - - var dest = _.map( onlineServers, function(t) { return t.address + ':' + (t.port || 5060); }) ; - - debug('proxy destinations are: ', dest) ; - - //finally....here's the magic: proxy the request, attempting destinations in order until we connect or all fail - req.proxy({ - remainInDialog: false, - destination: dest - }, function(err, results) { - if( err ) console.error('Error proxying: ', err) ; - - //this is voluminous, but if you want to know what happened all the detail is here.. - debug('proxy result: ', JSON.stringify(results)) ; - }) ; - - //round-robin the next starting server in the list - var item = allPossibleTargets.shift() ; - allPossibleTargets.push( item ) ; - }) ; -} ; - - diff --git a/lib/routing/route-manager.js b/lib/routing/route-manager.js new file mode 100644 index 0000000..0de349e --- /dev/null +++ b/lib/routing/route-manager.js @@ -0,0 +1,145 @@ +const Emitter = require('events'); +const fs = require('fs'); +const clearModule = require('clear-module'); +const assert = require('assert'); +const RouteSet = require('./route-set'); +const nullLogger = require('../utils/null-logger'); +const PingTester = require('../utils/ping-tester'); +const SIDES = ['inside', 'outside']; + +class RouteManager extends Emitter { + constructor({logger, srf}) { + super(); + + this.config = require('config'); + this.logger = logger || nullLogger; + this.srf = srf; + this.routeSet = {}; + this.outboundExternalTrunks = []; + this.inboundExternalTrunks = []; + this.internalTrunks = []; + this.pingers = []; + this._isPinging = false; + + this._init(); + + if (process.env.NODE_ENV !== 'test') { + fs.watch(`${__dirname}/../../config`, (eventType, filename) => { + if (filename.endsWith('.json')) { + this.logger.info(`RouteManager: configuration file change ${filename}: ${eventType}`); + clearModule('config'); + this.config = require('config'); + this._init(); + this.emit('changed'); + } + }); + } + } + + get lastTarget() { + return this._lastTarget; + } + + get lastUri() { + return this._lastUri; + } + + _init() { + this._initSipTrunks(); + this._initRouteSets(); + } + + _initSipTrunks() { + assert.ok(this.config.has('trunks.outside'), 'trunks.outside configuration is missing'); + const extTrunks = this.config.get('trunks.outside'); + + let arr = Object.keys(extTrunks).filter((k) => + ['out', 'both'].includes(extTrunks[k].direction) && extTrunks[k].enabled !== false); + this.outboundExternalTrunks = arr.map((k) => Object.assign(extTrunks[k], {name: k})); + + arr = Object.keys(extTrunks).filter((k) => + ['in', 'both'].includes(extTrunks[k].direction) && extTrunks[k].enabled !== false); + this.inboundExternalTrunks = arr.map((k) => Object.assign(extTrunks[k], {name: k})); + + assert.ok(this.config.has('trunks.inside'), 'routes.inside configuration is missing'); + const intTrunks = this.config.get('trunks.inside'); + + arr = Object.keys(intTrunks).filter((k) => intTrunks[k].enabled !== false); + this.internalTrunks = arr.map((k) => Object.assign(intTrunks[k], {name: k})); + + assert.ok(this.outboundExternalTrunks.length, 'must have at least one enabled outbound external trunk'); + assert.ok(this.inboundExternalTrunks.length, 'must have at least one enabled inbound external trunk'); + assert.ok(this.internalTrunks.length, 'must have at least one internal trunk'); + + /* start OPTIONS ping testing internal trunks */ + this.pingers.forEach((p) => p.stopPinging()); + this.pingers = []; + if (this.srf) { + this.internalTrunks.filter((t) => t['options-ping'] === true).forEach((t) => { + (typeof t.address === 'string' ? [t.address] : t.address).forEach((uri) => { + this.pingers.push(new PingTester({ + logger: this.logger, + srf: this.srf, + uri, + timeout: t['options-timeout'], + interval: t['options-interval'] + })); + }); + }); + this.logger.info(`started pinging ${this.pingers.length} servers ${this.pingers}`); + } + } + + _initRouteSets() { + SIDES.forEach((type) => { + delete this.routeSet[type]; + this.routeSet[type] = new RouteSet(type, this.config, this.logger); + }); + + if (!this.routeSet.outside.hasDefaultRoute) { + this.routeSet.outside.setDefaultRoute( + this.outboundExternalTrunks.map((t) => Object.assign({}, {dest: t['address'], weight: 1})) + ); + } + if (!this.routeSet.inside.hasDefaultRoute) { + this.routeSet.inside.setDefaultRoute( + Object.keys(this.internalTrunks).map((t) => { + return { + dest: this.internalTrunks[t]['address'], + weight: 1 + }; + }) + ); + } + } + + chooseRoute(type, req) { + assert(SIDES.includes(type)); + const uri = this.routeSet[type].evaluate(req); + if (uri) this.logger.info(`selected uri: ${uri}`); + + return uri; + } + + isValidSendingAddress(source) { + let ip = source; + let trunk; + const arr = /^(.*):(\d+)$/.exec(source); + if (arr) ip = arr[1]; + + if (trunk = this.inboundExternalTrunks.find((t) => + (typeof t.address === 'string' && source === t.address) || + (Array.isArray(t.address) && t.address.includes(source)) || + (typeof t.address === 'string' && source.endsWith(':5060') && ip === t.address) || + (Array.isArray(t.address) && source.endsWith(':5060') && t.address.includes(ip)) + )) { + return Object.assign(trunk, {side: 'outside'}); + } + + if (trunk = this.internalTrunks.find((t) => ip === t['address'])) { + return Object.assign(trunk, {side: 'inside'}); + } + } +} + +module.exports = RouteManager; diff --git a/lib/routing/route-set.js b/lib/routing/route-set.js new file mode 100644 index 0000000..7b4cffc --- /dev/null +++ b/lib/routing/route-set.js @@ -0,0 +1,53 @@ +const nullLogger = require('../utils/null-logger'); +const Route = require('./route'); +const assert = require('assert'); + +class RouteSet { + constructor(type, config, logger) { + this.type = type; + this.config = config; + + const opts = config.has(`routes.${type}`) ? config.get(`routes.${type}`) : {}; + + const conditionalRoutes = Object.keys(opts).filter((k) => k !== 'default'); + this.routes = conditionalRoutes.map((key) => new Route(key, opts[key], config, logger)); + if (opts.default) this.default = new Route('default', opts.default, config, logger); + this.logger = logger || nullLogger; + } + + get hasDefaultRoute() { + return typeof this.default !== 'undefined'; + } + + setDefaultRoute(def) { + assert(!this.hasDefault); + this.default = new Route('default', def, this.config, this.logger); + } + + toJSON() { + return {routes: this.routes, default: this.default}; + } + + evaluate(req) { + const logger = this.logger.child({'Call-ID': req.get('Call-ID')}); + logger.debug(`RouteSet ${this.type}: evaluating..`); + + // try conditional routes first + for (const route of this.routes) { + const target = route.evaluate(req); + if (target) { + logger.debug(`RouteSet ${this.type}: matched ${route.type}`); + return target; + } + } + + if (this.default) { + logger.debug(`RouteSet ${this.type}: no matches, returning default route`); + return this.default.evaluate(req); + } + + logger.info(`RouteSet ${this.type}: no matches and no default`); + } +} + +module.exports = RouteSet; diff --git a/lib/routing/route.js b/lib/routing/route.js new file mode 100644 index 0000000..55d30c1 --- /dev/null +++ b/lib/routing/route.js @@ -0,0 +1,69 @@ +const nullLogger = require('../utils/null-logger'); +const Target = require('./target'); + +class Route { + constructor(name, opts, config, logger) { + this.name = name; + + if (Array.isArray(opts)) { + this.alwaysTargets = new Target(opts, config, logger); + } + else if (typeof opts === 'string') { + this.alwaysTargets = new Target([opts], config, logger); + } + else { + this.regex = opts.regex; + if (opts.match) this.match = new Target(opts.match, config, logger); + if (opts['no-match']) this.noMatch = new Target(opts['no-match'], config, logger); + this.against = (opts.against || 'to').toLowerCase(); + } + this.logger = logger || nullLogger; + } + + toJSON() { + return { + name: this.name, + alwaysTargets: this.alwaysTargets, + regex: this.regex, + against: this.against, + match: this.match, + noMatch: this.noMatch + }; + } + + evaluate(req) { + const logger = this.logger.child({'Call-ID': req.get('Call-ID')}); + let inspected; + + if (this.alwaysTargets) { + return this.alwaysTargets.evaluate(); + } + + if (this.against === 'from') { + inspected = req.callingNumber; + } + else { + inspected = req.calledNumber; + } + + let matched = false; + try { + matched = inspected && inspected.match(new RegExp(this.regex)); + } catch (err) { + logger.error(`error evaluating regex: ${err}`); + return; + } + + if (this.match && matched) { + logger.debug(`Route ${this.name}: routing based on ${this.against} match ${inspected}: ${this.match}`); + return this.match.evaluate(); + } + + if (this.noMatch && !matched) { + logger.debug(`Route: ${this.name} routing based on ${this.against} no-match ${inspected}: ${this.noMatch}`); + return this.noMatch.evaluate(); + } + } +} + +module.exports = Route; diff --git a/lib/routing/target.js b/lib/routing/target.js new file mode 100644 index 0000000..5b376e3 --- /dev/null +++ b/lib/routing/target.js @@ -0,0 +1,144 @@ +const assert = require('assert'); +const nullLogger = require('../utils/null-logger'); +const PingTester = require('../utils/ping-tester'); + +/** + * A Target may be any of the following: + * + * a single sip address[:port] + * '10.10.100.1' or '10.10.100.2:5061' + * + * an array of sip addresses + * ['10.10.100.1', '10.10.100.2:5061'] + * + * a single group name, or array of group names + * 'international', or ['cheapest', 'moderate'] + * + * an array of weighted entries (sip addresses or groups) + * [{dest: '10.10.100.1', weight: 1}, {dest: '10.10.100.2:5061', weight: 2}] + * + * or + * + * [{dest: 'cheapest', weight: 5}, {dest: 'moderate', weight: 1}] + */ +class Target { + constructor(opts, config, logger) { + + this.entries = (Array.isArray(opts) ? opts : [opts]).map((t) => { + assert.ok(typeof t === 'string' || (t.dest && t.weight), + `invalid target configuration ${JSON.stringify(t)}: must have dest and weight properties`); + return typeof t === 'string' ? {dest: t, weight: 1} : t; + }); + + // map trunk and group names to ip addresses + const disabledTrunks = new Set(); + const outside = new Map(); + if (config.get('trunks.outside')) { + const obj = config.get('trunks.outside'); + Object.keys(obj).forEach((k) => { + const trunk = obj[k]; + if (['out', 'both'].includes(trunk.direction)) { + const address = Array.isArray(trunk.address) ? trunk.address : [trunk.address]; + if (trunk.enabled === false) disabledTrunks.add(k); + const groups = Array.isArray(trunk.groups) ? trunk.groups : + (typeof trunk.groups === 'string' ? [trunk.groups] : []); + outside.set(k, (outside.get(k) || []).concat(address)); + + if (trunk.enabled !== false) { + groups.forEach((g) => { + outside.set(g, (outside.get(g) || []).concat(address)); + }); + } + } + }); + } + + const inside = new Map(); + if (config.get('trunks.inside')) { + const obj = config.get('trunks.inside'); + Object.keys(obj).forEach((k) => { + const trunk = obj[k]; + if (trunk.enabled === false) disabledTrunks.add(k); + const address = Array.isArray(trunk.address) ? trunk.address : [trunk.address]; + const groups = Array.isArray(trunk.groups) ? trunk.groups : + (typeof trunk.groups === 'string' ? [trunk.groups] : []); + inside.set(k, (inside.get(k) || []).concat(address)); + + if (trunk.enabled !== false) { + groups.forEach((g) => { + inside.set(g, (inside.get(g) || []).concat(address)); + }); + } + }); + } + + // expand entries that reference group or trunk names + for (let i = 0; i < this.entries.length; i++) { + const e = this.entries[i]; + if (outside.has(e.dest) || inside.has(e.dest)) { + const addresses = outside.get(e.dest) || inside.get(e.dest); + const replacement = disabledTrunks.has(e.dest) ? + [] : + addresses.map((a) => Object.assign({}, {dest: a, weight: e.weight})); + this.entries[i] = replacement; + for (let j = 0; j < this.entries.length; j++) { + if (j !== i) this.entries[j].weight *= replacement.length; + } + } + } + this.entries = [].concat(...this.entries); //flatten + + /** + * if only 1 target: just select it + * if N equally-weighted entries: round robin + * otherwise, percentage-based using random number generation + */ + if (this.entries.length > 1) { + const total = this.entries.reduce((accumulator, currentValue) => accumulator + currentValue.weight, 0); + if (total === this.entries.length) { + this.rrSelected = 0; + } + else { + let floor = 0; + this.entries.forEach((t) => { + const delta = Math.floor(t.weight / total * 100.0); + t.floor = floor; + t.ceiling = floor + delta; + floor = t.ceiling; + }); + this.entries[this.entries.length - 1].ceiling = 100; + } + } + this.logger = logger || nullLogger; + } + + toJSON() { + return { + entries: this.entries, + rrSelected: this.rrSelected + }; + } + + evaluate() { + /* remove offline servers */ + const entries = this.entries.filter((e) => !PingTester.isOffline(e.dest)); + + if (entries.length === 1) return entries[0].dest; + + if (this.rrSelected >= 0) { + const sel = this.rrSelected++; + if (this.rrSelected > entries.length - 1) this.rrSelected = 0; + return entries[sel].dest; + } + + const rand = Math.floor(Math.random() * 100); + for (const t of entries) { + if (rand >= t.floor && rand < t.ceiling) return t.dest; + } + + this.logger.error(`Target#evaluate all targets offline or not responding: ${JSON.stringify(this.entries)}`); + } + +} + +module.exports = Target; diff --git a/lib/utils/null-logger.js b/lib/utils/null-logger.js new file mode 100644 index 0000000..af890ce --- /dev/null +++ b/lib/utils/null-logger.js @@ -0,0 +1,5 @@ +const noLogger = {}; +['error', 'info', 'debug'].forEach((m) => noLogger[m] = () => {}); +noLogger.child = function() { return noLogger; }; + +module.exports = noLogger; diff --git a/lib/utils/ping-tester.js b/lib/utils/ping-tester.js new file mode 100644 index 0000000..7191bd4 --- /dev/null +++ b/lib/utils/ping-tester.js @@ -0,0 +1,79 @@ +const Emitter = require('events'); +const nullLogger = require('./null-logger'); +const assert = require('assert'); + +const offlineUris = new Set(); + +class PingTester extends Emitter { + constructor(opts) { + super(); + + assert(opts.uri); + assert(opts.srf); + this._logger = opts.logger || nullLogger; + this._timeout = opts.timeout || 2000; + this._interval = opts.interval || 10000; + this._srf = opts.srf; + this.online = true; + + this._interval = setInterval(this._ping.bind(this, `sip:${opts.uri}`), this._interval); + + this + .on('offline', (uri) => offlineUris.add(uri)) + .on('online', (uri) => offlineUris.delete(uri)); + + this._srf.on('disconnect', () => { + this.stopPinging(); + }); + } + + static isOffline(uri) { + return offlineUris.has(`sip:${uri}`); + } + + get logger() { + return this._logger; + } + + stopPinging() { + clearInterval(this._interval); + + } + + _ping(uri) { + + // set a timeout to wait for a response + const timeout = setTimeout(() => { + if (this.online) { + this.online = false; + this.emit('offline', uri); + } + }, this._timeout); + + // send an OPTIONS ping + this._srf.request({ + uri: uri, + method: 'OPTIONS' + }) + .then((req) => { + return req.on('response', (res) => { + clearTimeout(timeout); + const status = res.status; + if (200 === status && !this.online) { + this.online = true; + this.emit('online', uri); + } + else if (200 !== status && this.online) { + this.online = false; + this.emit('offline', uri); + } + }); + }) + .catch((err) => { + this.logger.error(err, `Error sending OPTIONS ping tp ${uri}`); + }); + } + +} + +module.exports = PingTester; diff --git a/lib/validation-middleware.js b/lib/validation-middleware.js new file mode 100644 index 0000000..7eaea52 --- /dev/null +++ b/lib/validation-middleware.js @@ -0,0 +1,12 @@ +module.exports = ({logger, routeMgr}) => { + return (req, res, next) => { + req.locals = {}; + req.locals.source = routeMgr.isValidSendingAddress(`${req.source_address}:${req.source_port}`); + if (!req.locals.source) { + logger.info(`rejecting INVITE from invalid source address ${req.source_address}`); + return res.send(403); + } + + next(); + }; +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3925ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1207 @@ +{ + "name": "simple-sip-proxy", + "version": "1.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "async": { + "version": "1.5.2", + "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "blue-tape": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/blue-tape/-/blue-tape-1.0.0.tgz", + "integrity": "sha1-dYHQTAc5XJXEJrLtbR7bRUp2uSs=", + "dev": true, + "requires": { + "tape": ">=2.0.0 <5.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=", + "dev": true + }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "requires": { + "callsites": "^2.0.0" + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "requires": { + "caller-callsite": "^2.0.0" + } + }, + "callsites": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=" + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "clear-module": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-3.0.0.tgz", + "integrity": "sha512-T8RArhIvOMPJ0w2OenM2W4ua6bsJ7BM6V7jbpfr+Ev5nhCGxKZ2cNE2BZylDz+0Pc1BtT7wiGBGR1N0t3MlGCg==", + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + } + } + }, + "colors": { + "version": "1.0.3", + "resolved": "http://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + }, + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "config": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/config/-/config-2.0.1.tgz", + "integrity": "sha512-aTaviJnC8ZjQYx8kQf4u6tWqIxWolyQQ3LqXgnCLAsIb78JrUshHG0YuzIarzTaVVe1Pazms3TXImfYra8UsyQ==", + "requires": { + "json5": "^1.0.1" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, + "debug": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz", + "integrity": "sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg==", + "requires": { + "ms": "^2.1.1" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, + "delegates": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-0.1.0.tgz", + "integrity": "sha1-tLV74RoWU1F6BLJ/CUm9wyff45A=" + }, + "deprecate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/deprecate/-/deprecate-1.1.0.tgz", + "integrity": "sha512-b5dDNQYdy2vW9WXUD8+RQlfoxvqztLLhDE+T7Gd37I5E8My7nJkKu6FmhdDeRWJ8B+yjZKuwjCta8pgi8kgSqA==" + }, + "drachtio-mw-registration-parser": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/drachtio-mw-registration-parser/-/drachtio-mw-registration-parser-0.0.2.tgz", + "integrity": "sha1-Bmlv43Wvt/68AI8oaCJWukEHWAE=" + }, + "drachtio-sip": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/drachtio-sip/-/drachtio-sip-0.3.6.tgz", + "integrity": "sha512-KqlpGr9RTUB2uR1+7jAFul1+84Jd11GlEUgtB89pushWVEGpO+ejqwACnjyk+OIT/98rctgRoo2QP2xyQVRnzw==", + "requires": { + "debug": "*", + "merge": "^1.2.1", + "only": "0.0.2", + "sip-status": "~0.1.0" + } + }, + "drachtio-srf": { + "version": "github:davehorton/drachtio-srf#52788f9f25a77dd5a4b314b171c391606f51d1e7", + "from": "github:davehorton/drachtio-srf#v4.4.1-rc2", + "requires": { + "async": "^1.4.2", + "debug": "^3.1.0", + "delegates": "^0.1.0", + "deprecate": "^1.0.0", + "drachtio-mw-registration-parser": "0.0.2", + "drachtio-sip": "^0.3.6", + "lodash": "^4.17.10", + "node-noop": "0.0.1", + "only": "0.0.2", + "sip-methods": "^0.3.0", + "utils-merge": "1.0.0", + "uuid": "^3.0.0", + "winston": "^2.4.2" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "duplexer": { + "version": "0.1.1", + "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", + "dev": true + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "requires": { + "once": "^1.4.0" + } + }, + "es-abstract": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz", + "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.1.1", + "function-bind": "^1.1.1", + "has": "^1.0.1", + "is-callable": "^1.1.3", + "is-regex": "^1.0.4" + } + }, + "es-to-primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", + "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" + }, + "fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-redact": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-1.3.0.tgz", + "integrity": "sha512-ci4qKDR8nDzQCQTPw4hviyDFaSwTgSYiqddRh/EslkUQa0otpzM8IGnuG+LwiUE54t4OjU2T7bYKmjtd7632Wg==" + }, + "fast-safe-stringify": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz", + "integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==" + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "flatstr": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.8.tgz", + "integrity": "sha512-YXblbv/vc1zuVVUtnKl1hPqqk7TalZCppnKE7Pr8FI/Rp48vzckS/4SJ4Y9O9RNiI82Vcw/FydmtqdQOg1Dpqw==" + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "handlebars": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.12.tgz", + "integrity": "sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA==", + "dev": true, + "requires": { + "async": "^2.5.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "dev": true, + "requires": { + "lodash": "^4.17.10" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "dev": true + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-symbol": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", + "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + } + } + }, + "json5": { + "version": "1.0.1", + "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "requires": { + "minimist": "^1.2.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "merge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz", + "integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "node-noop": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/node-noop/-/node-noop-0.0.1.tgz", + "integrity": "sha1-CFzLsA3AY6eb5EwpDcqLpcXVQVM=" + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-inspect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", + "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", + "dev": true + }, + "object-keys": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", + "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=" + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "parse-ms": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-1.0.1.tgz", + "integrity": "sha1-VjRtR0nXjyNDDKDHE4UK75GqNh0=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "pino": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-5.8.1.tgz", + "integrity": "sha512-7bVFzUw3ffIfOM3t7MuQ9KsH+wX5bdGdQhGfccKgleoY7qG4FO3CmVSjywlFmmYGyMOISi1LDGC6JMEH7XkZJg==", + "requires": { + "fast-json-parse": "^1.0.3", + "fast-redact": "^1.2.0", + "fast-safe-stringify": "^2.0.6", + "flatstr": "^1.0.5", + "pino-std-serializers": "^2.3.0", + "pump": "^3.0.0", + "quick-format-unescaped": "^3.0.0", + "sonic-boom": "^0.6.1" + } + }, + "pino-std-serializers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-2.3.0.tgz", + "integrity": "sha512-klfGoOsP6sJH7ON796G4xoUSx2fkpFgKHO4YVVO2zmz31jR+etzc/QzGJILaOIiCD6HTCFgkPx+XN8nk+ruqPw==" + }, + "plur": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-1.0.0.tgz", + "integrity": "sha1-24XGgU9eXlo7Se/CjWBP7GKXUVY=", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "pretty-ms": { + "version": "2.1.0", + "resolved": "http://registry.npmjs.org/pretty-ms/-/pretty-ms-2.1.0.tgz", + "integrity": "sha1-QlfCVt8/sLRR1q/6qwIYhBJpgdw=", + "dev": true, + "requires": { + "is-finite": "^1.0.1", + "parse-ms": "^1.0.0", + "plur": "^1.0.0" + } + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "quick-format-unescaped": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-3.0.1.tgz", + "integrity": "sha512-Tnk4iJQ8x3V8ml3x9sLIf4tSDaVB9OJY/5gOrnxgK63CXKphhn8oYOPI4tqnXPQcZ3tCv7GFjeoYY5h6UAvuzg==" + }, + "re-emitter": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/re-emitter/-/re-emitter-1.1.3.tgz", + "integrity": "sha1-+p4xn/3u6zWycpbvDz03TawvUqc=", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "dev": true, + "requires": { + "path-parse": "^1.0.5" + } + }, + "resumer": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz", + "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=", + "dev": true, + "requires": { + "through": "~2.3.4" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "sip-message-examples": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/sip-message-examples/-/sip-message-examples-0.0.4.tgz", + "integrity": "sha1-5Pi6yfrDjl7xt9gkbYCJNumqNx8=", + "dev": true + }, + "sip-methods": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sip-methods/-/sip-methods-0.3.0.tgz", + "integrity": "sha1-6KKiO7EEKPuem1hCv2Q9TrPrnPM=" + }, + "sip-status": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sip-status/-/sip-status-0.1.0.tgz", + "integrity": "sha1-fHvT+cvfLQ09g6AGwxyAlVJdH5c=" + }, + "sonic-boom": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-0.6.2.tgz", + "integrity": "sha512-JVftM6ZJanmU/akt+bfiHUKQq0MtRe0ayXyEXjB1yiZYRH6ettF4gu7Dbei4HbzTmBVNshHpPJLZ9R9lY2FjWA==", + "requires": { + "flatstr": "^1.0.8" + } + }, + "source-map": { + "version": "0.2.0", + "resolved": "http://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "requires": { + "through": "2" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "string.prototype.trim": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz", + "integrity": "sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.0", + "function-bind": "^1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "tap-dot": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tap-dot/-/tap-dot-2.0.0.tgz", + "integrity": "sha512-7N1yPcRDgdfHCUbG6lZ0hXo53NyXhKIjJNhqKBixl9HVEG4QasG16Nlvr8wRnqr2ZRYVWmbmxwF3NOBbTLtQLQ==", + "dev": true, + "requires": { + "chalk": "^1.1.1", + "tap-out": "^1.3.2", + "through2": "^2.0.0" + } + }, + "tap-out": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tap-out/-/tap-out-1.4.2.tgz", + "integrity": "sha1-yQfsG/lAURHQiCY+kvVgi4jLs3o=", + "dev": true, + "requires": { + "re-emitter": "^1.0.0", + "readable-stream": "^2.0.0", + "split": "^1.0.0", + "trim": "0.0.1" + } + }, + "tap-spec": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tap-spec/-/tap-spec-5.0.0.tgz", + "integrity": "sha512-zMDVJiE5I6Y4XGjlueGXJIX2YIkbDN44broZlnypT38Hj/czfOXrszHNNJBF/DXR8n+x6gbfSx68x04kIEHdrw==", + "dev": true, + "requires": { + "chalk": "^1.0.0", + "duplexer": "^0.1.1", + "figures": "^1.4.0", + "lodash": "^4.17.10", + "pretty-ms": "^2.1.0", + "repeat-string": "^1.5.2", + "tap-out": "^2.1.0", + "through2": "^2.0.0" + }, + "dependencies": { + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", + "dev": true + }, + "readable-stream": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz", + "integrity": "sha1-z3jsb0ptHrQ9JkiMrJfwQudLf8g=", + "dev": true, + "requires": { + "buffer-shims": "~1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~1.0.0", + "util-deprecate": "~1.0.1" + } + }, + "split": { + "version": "1.0.0", + "resolved": "http://registry.npmjs.org/split/-/split-1.0.0.tgz", + "integrity": "sha1-xDlc5oOrzSVLwo/h2rtuXCfc/64=", + "dev": true, + "requires": { + "through": "2" + } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "tap-out": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tap-out/-/tap-out-2.1.0.tgz", + "integrity": "sha512-LJE+TBoVbOWhwdz4+FQk40nmbIuxJLqaGvj3WauQw3NYYU5TdjoV3C0x/yq37YAvVyi+oeBXmWnxWSjJ7IEyUw==", + "dev": true, + "requires": { + "re-emitter": "1.1.3", + "readable-stream": "2.2.9", + "split": "1.0.0", + "trim": "0.0.1" + } + } + } + }, + "tape": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/tape/-/tape-4.9.1.tgz", + "integrity": "sha512-6fKIXknLpoe/Jp4rzHKFPpJUHDHDqn8jus99IfPnHIjyz78HYlefTGD3b5EkbQzuLfaEvmfPK3IolLgq2xT3kw==", + "dev": true, + "requires": { + "deep-equal": "~1.0.1", + "defined": "~1.0.0", + "for-each": "~0.3.3", + "function-bind": "~1.1.1", + "glob": "~7.1.2", + "has": "~1.0.3", + "inherits": "~2.0.3", + "minimist": "~1.2.0", + "object-inspect": "~1.6.0", + "resolve": "~1.7.1", + "resumer": "~0.0.0", + "string.prototype.trim": "~1.1.2", + "through": "~2.3.8" + } + }, + "through": { + "version": "2.3.8", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "uglify-js": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", + "integrity": "sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q==", + "dev": true, + "optional": true, + "requires": { + "commander": "~2.17.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "utils-merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "integrity": "sha1-ApT7kiu5N1FTVBxPcJYjHyh8ivg=" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "winston": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.4.tgz", + "integrity": "sha512-NBo2Pepn4hK4V01UfcWcDlmiVTs7VTB1h7bgnB0rgP146bYhMxX0ypCz3lBOfNxCO4Zuek7yeT+y/zM1OfMw4Q==", + "requires": { + "async": "~1.0.0", + "colors": "1.0.x", + "cycle": "1.0.x", + "eyes": "0.1.x", + "isstream": "0.1.x", + "stack-trace": "0.0.x" + }, + "dependencies": { + "async": { + "version": "1.0.0", + "resolved": "http://registry.npmjs.org/async/-/async-1.0.0.tgz", + "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=" + } + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + } + } +} diff --git a/package.json b/package.json index 127c0cf..5f721ef 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "simple-sip-proxy", - "version": "1.0.0", + "version": "1.1.0", "description": "Simple load balancing sip proxy", "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "NODE_ENV=test node test/ | ./node_modules/.bin/tap-spec", + "coverage": "NODE_ENV=test ./node_modules/.bin/istanbul cover test/ --report html", + "debug-test": "NODE_ENV=test node --nolazy --inspect-brk=9229 test/", + "jslint": "eslint lib" }, "repository": { "type": "git", @@ -22,9 +25,18 @@ }, "homepage": "https://github.com/davehorton/simple-sip-proxy#readme", "dependencies": { - "debug": "*", - "drachtio": "davehorton/drachtio.git", - "lodash": "~2.4.1", - "range_check": "0.0.5" - } + "clear-module": "^3.0.0", + "config": "^2.0.1", + "drachtio-srf": "davehorton/drachtio-srf#v4.4.1-rc2", + "pino": "^5.8.1" + }, + "devDependencies": { + "blue-tape": "^1.0.0", + "debug": "^4.1.0", + "drachtio-sip": "^0.3.6", + "istanbul": "^0.4.5", + "sip-message-examples": "0.0.4", + "tap-dot": "^2.0.0", + "tap-spec": "^5.0.0" + } } diff --git a/test-1.json b/test-1.json new file mode 100644 index 0000000..e69de29 diff --git a/test/data/invite.js b/test/data/invite.js new file mode 100644 index 0000000..9d85fa6 --- /dev/null +++ b/test/data/invite.js @@ -0,0 +1,19 @@ +const examples = require('sip-message-examples') ; +const SipMessage = require('drachtio-srf').SipMessage; +const SipRequest = require('drachtio-srf').SipRequest; + +module.exports = function({from, to, source_address, source_port}) { + const m = examples('invite'); + const msg = new SipMessage(m); + const req = new SipRequest(msg, { + source: 'network', + source_address: source_address || '127.0.0.1', + source_port: source_port || 5060, + protocol: 'udp', + stackTime: 'na', + stackTxnId: '0xdeadbeef', + stackDialogId: '0xdeadbeef' + }); + + return req; +}; diff --git a/test/docker-compose-testbed.yaml b/test/docker-compose-testbed.yaml new file mode 100644 index 0000000..d604f7d --- /dev/null +++ b/test/docker-compose-testbed.yaml @@ -0,0 +1,74 @@ +version: '2' + +networks: + testbed: + driver: bridge + ipam: + config: + - subnet: 172.19.0.0/16 + +services: + drachtio: + image: drachtio/drachtio-server:latest + command: drachtio --contact "sip:*;transport=udp" --loglevel debug --sofia-loglevel 9 + container_name: drachtio + ports: + - "9061:9022/tcp" + networks: + testbed: + ipv4_address: 172.19.0.10 + + as1: + image: davehorton/freeswitch-hairpin:latest + container_name: freeswitch-1 + command: freeswitch --sip-port 5060 + networks: + testbed: + ipv4_address: 172.19.0.21 + + as2: + image: davehorton/freeswitch-hairpin:latest + command: freeswitch --sip-port 5060 + container_name: freeswitch-2 + networks: + testbed: + ipv4_address: 172.19.0.22 + + as3: + image: davehorton/freeswitch-hairpin:latest + command: freeswitch --sip-port 5060 + container_name: freeswitch-3 + networks: + testbed: + ipv4_address: 172.19.0.23 + + sipp-uas-options-ok: + image: drachtio/sipp:latest + command: sipp -sf /tmp/uas-options-200.xml + volumes: + - ./scenarios:/tmp + tty: true + networks: + testbed: + ipv4_address: 172.19.0.110 + + sipp-uas-options-fail: + image: drachtio/sipp:latest + command: sipp -sf /tmp/uas-options-503.xml + volumes: + - ./scenarios:/tmp + tty: true + networks: + testbed: + ipv4_address: 172.19.0.111 + + sipp-uas: + image: drachtio/sipp:latest + command: sipp -sf /tmp/uas.xml + volumes: + - ./scenarios:/tmp + tty: true + networks: + testbed: + ipv4_address: 172.19.0.112 + diff --git a/test/docker-start.js b/test/docker-start.js new file mode 100644 index 0000000..3c20c58 --- /dev/null +++ b/test/docker-start.js @@ -0,0 +1,42 @@ +const test = require('blue-tape'); +const exec = require('child_process').exec ; +const async = require('async'); + +test('starting docker network..', (t) => { + exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => { + if (-1 != stderr.indexOf('is up-to-date')) return t.end() ; + console.log('docker network started, giving extra time for freeswitch to initialize...'); + testFreeswitches(['freeswitch-1', 'freeswitch-2', 'freeswitch-3'], 40000, (err) => { + t.end(err); + }); + }); +}); + +function testFreeswitches(arr, timeout, callback) { + let timeup = false; + const timer = setTimeout(() => { + timeup = true; + }, timeout); + + async.whilst( + () => !timeup && arr.length, + (callback) => setTimeout(() => async.each(arr, testOneFsw.bind(null, arr), () => callback()), 1000), + () => { + if (arr.length > 0) { + clearTimeout(timer); + return callback(new Error('some freeswitches did not initialize')); + } + callback(null); + } + ); +} + +function testOneFsw(arr, fsw, callback) { + exec(`docker exec ${fsw} fs_cli -x "console loglevel debug"`, (err, stdout, stderr) => { + if (!err) { + const idx = arr.indexOf(fsw); + arr.splice(idx, 1); + } + callback(null); + }); +} diff --git a/test/docker-stop.js b/test/docker-stop.js new file mode 100644 index 0000000..44f8892 --- /dev/null +++ b/test/docker-stop.js @@ -0,0 +1,14 @@ +const test = require('blue-tape'); +const exec = require('child_process').exec ; + +test('stopping docker network..', (t) => { + t.timeoutAfter(20000); + exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml down`, (err, stdout, stderr) => { + //console.log(`stdout: ${stdout}`); + //if (stderr.length) console.log(`stderr: ${stderr}`); + t.pass('docker network stopped'); + t.end() ; + process.exit(0); + }); +}); + diff --git a/test/e2e.js b/test/e2e.js new file mode 100644 index 0000000..317cdc0 --- /dev/null +++ b/test/e2e.js @@ -0,0 +1,64 @@ +const test = require('blue-tape'); +const clearModule = require('clear-module'); +const PingTester = require('../lib/utils/ping-tester'); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('end-to-end tests', (t) => { + t.timeoutAfter(30000); + + clearModule('config'); + clearModule('../lib/routing/route-manager'); + process.env.NODE_APP_INSTANCE = 100; + + let srf, obj; + + Promise.resolve() + .then(() => { + srf = require('../app'); + return new Promise((resolve, reject) => { + srf.on('connect', (err) => { + if (err) return reject(err); + resolve(); + }); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + setTimeout(() => { + t.ok(!PingTester.isOffline('172.19.0.110'), 'server marked online by responding 200 to OPTIONS'); + t.ok(PingTester.isOffline('172.19.0.111'), 'server marked offline by responding non-200 to OPTIONS'); + t.ok(PingTester.isOffline('172.19.0.121'), 'server marked offline by not responding to OPTIONS'); + resolve(); + }, 4000); + }); + }) + .then(() => { + obj = require('./sipp')('test_testbed', '172.19.0.50'); + return obj.sippUac('uac-expect-403.xml'); + }) + .then(() => { + return t.pass('INVITE from unknown carrier / trunk rejected with 403'); + }) + .then(() => { + obj = require('./sipp')('test_testbed', '172.19.0.100'); + return obj.sippUac('uac.xml'); + }) + .then(() => { + return t.pass('INVITE successfully proxied with defaults when no routing specified'); + }) + .then(() => { + srf.emit('disconnect'); + srf.disconnect(); + t.end(); + return new Promise((resolve) => setTimeout(() => resolve(), 1000)); + }) + .catch((err) => { + if (srf) srf.disconnect(); + console.log(`error received: ${err}`); + //console.log(obj.output()); + t.error(err); + }); +}); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..46714c7 --- /dev/null +++ b/test/index.js @@ -0,0 +1,9 @@ +require('./target'); +require('./route'); +require('./route-set'); +require('./route-manager'); + +// end-to-end tests require docker +require('./docker-start'); +require('./e2e'); +require('./docker-stop'); diff --git a/test/route-manager.js b/test/route-manager.js new file mode 100644 index 0000000..489825b --- /dev/null +++ b/test/route-manager.js @@ -0,0 +1,31 @@ +const test = require('blue-tape'); +const makeInviteRequest = require('./data/invite'); +const clearModule = require('clear-module'); + +test('RouteManager', (t) => { + process.env.NODE_APP_INSTANCE = 1; + + clearModule('config'); + + const RouteManager = require('../lib/routing/route-manager'); + const mgr = new RouteManager({}); + t.equal(mgr.inboundExternalTrunks.length, 1, '1 enabled inbound outside trunks'); + t.equal(mgr.outboundExternalTrunks.length, 2, '2 enabled outbound outside trunks'); + t.equal(mgr.internalTrunks.length, 2, '2 enabled inside trunks'); + + const req = makeInviteRequest({ + from: '+16173333456', + to: '+16173333456', + source_address: '10.10.100.3', + source_port: 5060 + }); + let uri = mgr.chooseRoute('inside', req); + t.equal(uri, '10.10.200.1', 'selects correct target first time'); + uri = mgr.chooseRoute('inside', req); + t.equal(uri, '10.10.200.1', 'selects correct target second time'); + + t.ok(mgr.isValidSendingAddress('10.10.100.7'), 'allows valid sending source'); + t.notOk(mgr.isValidSendingAddress('10.10.100.3'), 'detects invalid sending source'); + + t.end(); +}); diff --git a/test/route-set.js b/test/route-set.js new file mode 100644 index 0000000..969e2bb --- /dev/null +++ b/test/route-set.js @@ -0,0 +1,44 @@ +const test = require('blue-tape'); +const makeInviteRequest = require('./data/invite'); +const clearModule = require('clear-module'); + +test('RouteSet', (t) => { + process.env.NODE_APP_INSTANCE = 1; + let config = require('config'); + + let RouteSet = require('../lib/routing/route-set'); + let rsInside = new RouteSet('inside', config); + t.ok(rsInside.hasDefaultRoute, 'default route is loaded from config'); + + const req = makeInviteRequest({ + from: '+16173333456', + to: '+16173333456', + source_address: '10.10.100.3', + source_port: 5060 + }); + + let uri = rsInside.evaluate(req); + t.equal(uri, '10.10.200.1', 'default route selected'); + + let rsOutside = new RouteSet('outside', config); + uri = rsOutside.evaluate(req); + t.equal(uri, '10.10.100.6', 'selected conditional route'); + + clearModule('config'); + clearModule('../lib/routing/route-set'); + process.env.NODE_APP_INSTANCE = 2; + + config = require('config'); + RouteSet = require('../lib/routing/route-set'); + + rsInside = new RouteSet('inside', config); + t.ok(!rsInside.hasDefaultRoute, 'route.inside is optional'); + uri = rsInside.evaluate(req); + t.notOk(uri, 'empty route set can not return a route'); + + rsInside.setDefaultRoute('10.10.200.1'); + uri = rsInside.evaluate(req); + t.equal(uri, '10.10.200.1', 'default route can be set'); + + t.end(); +}); diff --git a/test/route.js b/test/route.js new file mode 100644 index 0000000..50f94c3 --- /dev/null +++ b/test/route.js @@ -0,0 +1,42 @@ +const test = require('blue-tape'); +const makeInviteRequest = require('./data/invite'); +const Route = require('../lib/routing/route'); + +test('Route', (t) => { + process.env.NODE_APP_INSTANCE = 1; + const config = require('config'); + + let route = new Route('default', '10.10.100.1', config); + const req = makeInviteRequest({ + from: '+16173333456', + to: '+16173333456', + source_address: '10.10.100.3', + source_port: 5060 + }); + + t.equal(route.evaluate(req), '10.10.100.1', 'default route with single entry works'); + + route = new Route('default', ['10.10.100.1'], config); + t.equal(route.evaluate(req), '10.10.100.1', 'default route with single element array works'); + + route = new Route('work', { + regex: '^5753606$', + match: '10.10.100.2' + }, config); + t.equal(route.evaluate(req), '10.10.100.2', 'regex exact match on calling number works'); + + route = new Route('work', { + regex: '^4083084809$', + match: '10.10.100.2', + against: 'from' + }, config); + t.equal(route.evaluate(req), '10.10.100.2', 'regex exact match on calling number works'); + + route = new Route('work', { + regex: '^57566', + 'no-match': '10.10.100.2' + }, config); + t.equal(route.evaluate(req), '10.10.100.2', 'regex no-match on calling number works'); + + t.end(); +}); diff --git a/test/scenarios/uac-expect-403.xml b/test/scenarios/uac-expect-403.xml new file mode 100644 index 0000000..8e2e048 --- /dev/null +++ b/test/scenarios/uac-expect-403.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: [service] + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Subject: Performance Test + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 0 + a=rtpmap:0 PCMU/8000 + + ]]> + + + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: [service] [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Subject: Performance Test + Content-Length: 0 + + ]]> + + + + + + + + + + diff --git a/test/scenarios/uac.xml b/test/scenarios/uac.xml new file mode 100644 index 0000000..1c60555 --- /dev/null +++ b/test/scenarios/uac.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: [service] + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + X-Return-Token: foobar + Subject: Performance Test + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 0 + a=rtpmap:0 PCMU/8000 + + ]]> + + + + + + + + + + + + + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: [service] [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + [routes] + Max-Forwards: 70 + Subject: Performance Test + Content-Length: 0 + + ]]> + + + + + + + + + ;tag=[pid]SIPpTag00[call_number] + To: [service] [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 BYE + Max-Forwards: 70 + [routes] + Subject: Performance Test + Content-Length: 0 + + ]]> + + + + + + + + + + + + + diff --git a/test/scenarios/uas-options-200.xml b/test/scenarios/uas-options-200.xml new file mode 100644 index 0000000..f04a3e1 --- /dev/null +++ b/test/scenarios/uas-options-200.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/scenarios/uas-options-503.xml b/test/scenarios/uas-options-503.xml new file mode 100644 index 0000000..4c36047 --- /dev/null +++ b/test/scenarios/uas-options-503.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/scenarios/uas.xml b/test/scenarios/uas.xml new file mode 100644 index 0000000..144ba25 --- /dev/null +++ b/test/scenarios/uas.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] + s=- + c=IN IP[media_ip_type] [media_ip] + t=0 0 + m=audio [media_port] RTP/AVP 0 + a=rtpmap:0 PCMU/8000 + + ]]> + + + + + + + + + + + Content-Length: 0 + + ]]> + + + + + + + + + + + + + + diff --git a/test/sipp.js b/test/sipp.js new file mode 100644 index 0000000..51ba1c5 --- /dev/null +++ b/test/sipp.js @@ -0,0 +1,67 @@ +const { spawn } = require('child_process'); +const debug = require('debug')('drachtio:test'); +let network, ip; +const obj = {}; +let output = ''; +let idx = 1; + +function clearOutput() { + output = ''; +} + +function addOutput(str) { + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) < 128) output += str.charAt(i); + } +} + +module.exports = (networkName, ipAddress) => { + network = networkName ; + ip = ipAddress; + return obj; +}; + +obj.output = () => { + return output; +}; + +obj.sippUac = (file) => { + const cmd = 'docker'; + const args = [ + 'run', '-ti', '--rm', '--net', network, + '--ip', ip, + '-v', `${__dirname}/scenarios:/tmp/scenarios`, + 'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`, + '-m', '1', + '-sleep', '250ms', + '-nostdin', + '-cid_str', `%u-%p@%s-${idx++}`, + 'drachtio' + ]; + + clearOutput(); + + return new Promise((resolve, reject) => { + const child_process = spawn(cmd, args, {stdio: ['inherit', 'pipe', 'pipe']}); + + child_process.on('exit', (code, signal) => { + if (code === 0) { + return resolve(); + } + console.log(`sipp exited with non-zero code ${code} signal ${signal}`); + reject(code); + }); + child_process.on('error', (error) => { + console.log(`error spawing child process for docker: ${args}`); + }); + + child_process.stdout.on('data', (data) => { + //debug(`stdout: ${data}`); + addOutput(data.toString()); + }); + child_process.stdout.on('data', (data) => { + //debug(`stdout: ${data}`); + addOutput(data.toString()); + }); + }); +}; diff --git a/test/target.js b/test/target.js new file mode 100644 index 0000000..a95331b --- /dev/null +++ b/test/target.js @@ -0,0 +1,62 @@ +const test = require('blue-tape'); +const Target = require('../lib/routing/target'); + +test('Target', (t) => { + process.env.NODE_APP_INSTANCE = 1; + const config = require('config'); + + let target = new Target('10.10.100.1', config); + t.deepEqual(target.entries, [{dest: '10.10.100.1', weight: 1}], 'supplying single IP works'); + t.equal(target.evaluate(), '10.10.100.1', 'single IP evaluates to itself') + + target = new Target(['10.10.100.1', '10.10.100.2'], config); + t.deepEqual(target.entries, [{dest: '10.10.100.1', weight: 1}, {dest: '10.10.100.2', weight: 1}], + 'supplying array of ips work'); + const mySet = new Set(); + mySet.add(target.evaluate()); + mySet.add(target.evaluate()); + t.equal(mySet.size, 2, 'round robin choices when equally-weighted trunks provided'); + + target = new Target(['10.10.100.1', {dest: '10.10.100.2', weight: 2}], config); + t.deepEqual(target.entries, [ + {dest: '10.10.100.1', weight: 1, floor: 0, ceiling: 33}, + {dest: '10.10.100.2', weight: 2, floor: 33, ceiling: 100}], + 'supplying explicit weights works'); + + // to test weighted average we need to generate lots of "calls" and check the resulting ratios + const results = {}; + for (let i = 0; i < 1000; i++) { + const uri = target.evaluate(); + if (!results[uri]) results[uri] = 1; + else results[uri]++; + } + const score1 = Math.floor(results['10.10.100.1'] / 1000 * 100); + const score2 = Math.floor(results['10.10.100.2'] / 1000 * 100); + t.ok(score1 > 27 && score1 < 38, 'using weighted averages, appserver1 gets ~33%'); + t.ok(score2 > 62 && score2 < 73, 'using weighted averages, appserver2 gets ~66%'); + + target = new Target('appserver1', config); + t.deepEqual(target.entries, [{dest: '10.10.200.1', weight: 1}], 'supplying inside trunk name works'); + + target = new Target('carrier-2', config); + t.deepEqual(target.entries, [{dest: '10.10.100.5', weight: 1}], 'supplying outside trunk name works'); + + target = new Target('carrier-4', config); + t.deepEqual(target.entries, [], 'supplying disabled outside trunk name results in empty set'); + + target = new Target('cheap', config); + t.deepEqual(target.entries, [ + { dest: '10.10.100.5', weight: 1 }, + { dest: '10.10.100.6', weight: 1 }, + { dest: '10.10.100.7', weight: 1 }], + 'supplying group name works'); + + target = new Target([{dest: 'carrier-3', weight: 1}, {dest:'carrier-2', weight: 2}], config); + t.deepEqual(target.entries, [ + {dest: '10.10.100.6', weight: 1, floor: 0, ceiling: 16}, + {dest: '10.10.100.7', weight: 1, floor: 16, ceiling: 32}, + {dest: '10.10.100.5', weight: 4, floor: 32, ceiling: 100}], + 'weights are properly recalculated when expanding trunk name destinations'); + + t.end(); +});