UNPKG

boomcatch

Version:

A standalone, node.js-based beacon receiver for boomerang.

708 lines (569 loc) 21.2 kB
// Copyright © 2014, 2015, 2016 Springer Nature // // This file is part of boomcatch. // // Boomcatch is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Boomcatch is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with boomcatch. If not, see <http://www.gnu.org/licenses/>. /*globals require, exports, process, setTimeout */ 'use strict'; var check = require('check-types'), http = require('http'), https = require('https'), url = require('url'), qs = require('qs'), fs = require('fs'), toobusy = require('toobusy-js'), cluster = require('cluster'), defaults = { host: '0.0.0.0', port: 80, https: false, path: '/beacon', referer: /.*/, origin: '*', limit: 0, maxSize: -1, log: { info: function () {}, warn: function () {}, error: function () {} }, validator: 'permissive', filter: 'unfiltered', mapper: 'statsd', forwarder: 'udp', workers: 0, delayRespawn: 0, maxRespawn: -1 }, urlRegex = /^https?:\/\/.+/, tlsCiphers = [ 'ECDHE-RSA-AES256-SHA384', 'DHE-RSA-AES256-SHA384', 'ECDHE-RSA-AES256-SHA256', 'DHE-RSA-AES256-SHA256', 'ECDHE-RSA-AES128-SHA256', 'DHE-RSA-AES128-SHA256', 'HIGH', '!aNULL', '!eNULL', '!EXPORT', '!DES', '!RC4', '!MD5', '!PSK', '!SRP', '!CAMELLIA' ].join(':'), signals; /** * Public function `listen`. * * Forwards performance metrics calculated from Boomerang beacon requests. * * @option host {string} Host name to accept connections on. Defaults to * '0.0.0.0' (INADDR_ANY). * @option port {number} Port to accept connections on. Defaults to 80 for * HTTP or 443 for HTTPS. * @option https {boolean} Start the server in HTTPS mode. Defaults to false. * @option httpsPfx {string} PFX/PKCX12 string containing private key, cert and * CA certs (HTTPS only). * @option httpsKey {string} Path to private key file, ignored if `httpsPfx` is * set (HTTPS only). * @option httpsCert {string} Path to certificate file, ignored if `httpsPfx` is * set (HTTPS only). * @option httpsPass {string} Passphrase for `httpsPfx` or `httpsKey` options * (HTTPS only). * @option path {string} URL path to accept requests to. Defaults to '/beacon'. * @option referer {regexp} Referers to accept requests from. Defaults to `.*`. * @option origin {string|array} URL(s) for the Access-Control-Allow-Origin header. * @option limit {number} Minimum elapsed time between requests from the same * IP address. Defaults to 0. * @option maxSize {number} Maximum body size for POST requests. * @option log {object} Object with `info` and `error` log functions. * @option validator {string} Validator used to accept or reject beacon requests, * loaded with `require`. Defaults to 'permissive'. * @option filter {string} Filter used to purge unwanted data, loaded with `require`. * Defaults to `unfiltered`. * @option mapper {string} Data mapper used to transform data before forwarding, * loaded with `require`. Defaults to 'statsd'. * @option prefix {string} Prefix to use for mapped metric names. Defaults to ''. * @option svgTemplate {string} Path to alternative SVG handlebars template file (SVG * mapper only). * @option svgSettings {string} Path to alternative SVG settings JSON file (SVG mapper * only). * @option forwarder {string} Forwarder used to send data, loaded with `require`. * Defaults to 'udp'. * @option fwdHost {string} Host name to forward mapped data to (UDP forwarder only). * @option fwdPort {number} Port to forward mapped data on (UDP forwarder only). * @option fwdSize {bytes} Maximum allowable packet size for data forwarding (UDP * forwarder only). * @option fwdUrl {string} URL to forward mapped data to (HTTP forwarder only). * @option fwdMethod {string} Method to forward mapped data with (HTTP forwarder only). * @option fwdDir {string} Directory to write mapped data to (file forwarder only). * @option workers {number} Number of child worker processes to fork. Defaults to 0. * @option delayRespawn {number} Number of milliseconds to delay respawning. Defaults to 0. * @option maxRespawn {number} Maximum number of respawn attempts. Defaults to -1. */ exports.listen = function (options) { var workers, log; if (options) { verifyOptions(options); } else { options = {}; } workers = getWorkers(options); log = getLog(options); createExceptionHandler(log); if (cluster.isMaster) { log.info('starting boomcatch in process ' + process.pid + ' with options:\n' + JSON.stringify(options, null, ' ')); createSignalHandlers(log); if (workers > 0) { return createWorkers(workers, options, log); } } createServer(options, log); }; function verifyOptions (options) { check.assert.maybe.unemptyString(options.host, 'Invalid host'); check.assert.maybe.positive(options.port, 'Invalid port'); check.assert.maybe.unemptyString(options.path, 'Invalid path'); check.assert.maybe.instance(options.referer, RegExp, 'Invalid referer'); check.assert.maybe.positive(options.limit, 'Invalid limit'); check.assert.maybe.positive(options.maxSize, 'Invalid max size'); check.assert.maybe.unemptyString(options.validator, 'Invalid validator'); check.assert.maybe.unemptyString(options.filter, 'Invalid filter'); check.assert.maybe.number(options.workers, 'Invalid workers'); check.assert.not.negative(options.workers, 'Invalid workers'); check.assert.maybe.number(options.delayRespawn, 'Invalid worker respawn delay'); check.assert.not.negative(options.delayRespawn, 'Invalid worker respawn delay'); check.assert.maybe.number(options.maxRespawn, 'Invalid worker respawn limit'); verifyOrigin(options.origin); verifyLog(options.log); verifyHttpsOptions(options); verifyMapperOptions(options); verifyForwarderOptions(options); } function verifyOrigin (origin) { if (check.string(origin)) { if (origin !== '*' && origin !== 'null') { check.assert.match(origin, urlRegex); } } else if (check.array(origin)) { origin.forEach(function (o) { check.assert.match(o, urlRegex); }); } else if (origin) { throw new Error('Invalid access control origin'); } } function verifyLog (log) { check.assert.maybe.object(log, 'Invalid log object'); if (check.object(log)) { check.assert.function(log.info, 'Invalid log.info function'); check.assert.function(log.warn, 'Invalid log.warn function'); check.assert.function(log.error, 'Invalid log.error function'); } } function verifyHttpsOptions (options) { if (options.https) { if (options.httpsPfx) { check.assert.unemptyString(options.httpsPfx); } else { verifyFile(false, options.httpsKey, 'Invalid private key path'); verifyFile(false, options.httpsCert, 'Invalid certificate path'); } check.assert.maybe.unemptyString(options.httpsPass); } } function verifyFile (isOptional, path, message) { verifyFs(isOptional, path, 'isFile', message); } function verifyFs (isOptional, path, method, message) { var stat; if (isOptional && !path) { return; } check.assert.unemptyString(path, message); if (fs.existsSync(path)) { stat = fs.statSync(path); if (stat[method]()) { return; } } throw new Error(message); } function verifyMapperOptions (options) { check.assert.maybe.unemptyString(options.mapper, 'Invalid data mapper'); check.assert.maybe.unemptyString(options.prefix, 'Invalid metric prefix'); } function verifyForwarderOptions (options) { check.assert.maybe.unemptyString(options.forwarder, 'Invalid forwarder'); switch (options.forwarder) { case 'waterfall-svg': verifyFile(true, options.svgTemplate, 'Invalid SVG template path'); verifyFile(true, options.svgSettings, 'Invalid SVG settings path'); break; case 'file': verifyDirectory(options.fwdDir, 'Invalid forwarding directory'); break; case 'http': check.assert.match(options.fwdUrl, urlRegex); check.assert.maybe.unemptyString(options.fwdMethod, 'Invalid forwarding method'); break; default: check.assert.maybe.unemptyString(options.fwdHost, 'Invalid forwarding host'); check.assert.maybe.positive(options.fwdPort, 'Invalid forwarding port'); check.assert.maybe.positive(options.fwdSize, 'Invalid forwarding packet size'); } } function verifyDirectory (path, message) { verifyFs(false, path, 'isDirectory', message); } function getWorkers (options) { return getOption('workers', options); } function getOption (name, options) { return options[name] || defaults[name]; } function getLog (options) { return getOption('log', options); } function createExceptionHandler (log) { process.on('uncaughtException', handleException.bind(null, log)); } function handleException (log, error) { log.error('unhandled exception\n' + error.stack); process.exit(1); } function createSignalHandlers (log) { signals.forEach(function (signal) { process.on(signal.name, handleTerminalSignal.bind(null, log, signal.name, signal.value)); }); } signals = [ { name: 'SIGHUP', value: 1 }, { name: 'SIGINT', value: 2 }, { name: 'SIGTERM', value: 15 } ]; function handleTerminalSignal (log, signal, value) { log.info(signal + ' received, terminating process ' + process.pid); process.exit(128 + value); } function createWorkers (count, options, log) { var respawnCount, respawnLimit, respawnDelay, i; respawnCount = 0; respawnLimit = getOption('maxRespawn', options); respawnDelay = getOption('delayRespawn', options); cluster.on('online', function (worker) { log.info('worker ' + worker.process.pid + ' started'); }); cluster.on('exit', function (worker, code, signal) { var exitStatus = getExitStatus(code, signal); if (worker.exitedAfterDisconnect) { return log.info('worker ' + worker.process.pid + ' exited (' + exitStatus + ')'); } respawnCount += 1; if (respawnLimit > 0 && respawnCount > respawnLimit) { return log.error('exceeded respawn limit, worker ' + worker.process.pid + ' died (' + exitStatus + ')'); } setTimeout(function () { log.warn('worker ' + worker.process.pid + ' died (' + exitStatus + '), respawning'); cluster.fork(); }, respawnDelay); }); for (i = 0; i < count; i += 1) { cluster.fork(); } } function getExitStatus (code, signal) { if (check.assigned(signal)) { return 'signal ' + signal; } return 'code ' + code; } function createServer (options, log) { var host, port, path, handler, server; host = getHost(options); port = getPort(options); path = getPath(options); log.info('listening for ' + host + ':' + port + path); handler = handleRequest.bind( null, log, path, getReferer(options), getLimit(options), getOrigin(options), getMaxSize(options), getValidator(options), getFilter(options), getMapper(options), getForwarder(options) ); if (options.https) { server = https.createServer(getHttpsOptions(options), handler); } else { server = http.createServer(handler); } server.listen(port, host); } function getHost (options) { return getOption('host', options); } function getPort (options) { if (options.port) { return options.port; } if (options.https) { return 443; } return 80; } function getPath (options) { return getOption('path', options); } function getReferer (options) { return getOption('referer', options); } function getLimit (options) { var limit = getOption('limit', options); if (limit === 0) { return null; } return { time: limit, requests: {} }; } function getOrigin (options) { return getOption('origin', options); } function getMaxSize (options) { return getOption('maxSize', options); } function getValidator (options) { return getExtension('validator', options); } function getExtension (type, options, properties) { var name, extension, result; name = getOption(type, options); try { extension = require('./' + type + 's/' + name); } catch (e) { extension = require(name); } result = extension.initialise(options); if (check.array(properties)) { properties.forEach(function (property) { result[property] = extension[property]; }); } return result; } function getFilter (options) { return getExtension('filter', options); } function getMapper (options) { return getExtension('mapper', options, ['type','separator']); } function getForwarder (options) { return getExtension('forwarder', options); } function getHttpsOptions (options) { if (options.httpsPfx) { return { pfx: options.httpsPfx, passphrase: options.httpsPass, ciphers: tlsCiphers, honorCipherOrder: true }; } return { key: fs.readFileSync(options.httpsKey), cert: fs.readFileSync(options.httpsCert), passphrase: options.httpsPass, ciphers: tlsCiphers, honorCipherOrder: true }; } function handleRequest (log, path, referer, limit, origin, maxSize, validator, filter, mapper, forwarder, request, response) { var requestPath, remoteAddress, state; logRequest(log, request); if (toobusy()) { return fail(log, request, response, 503, 'Server too busy'); } requestPath = getRequestPath(request); remoteAddress = getRemoteAddress(request); if (!checkRequest(log, path, referer, limit, requestPath, remoteAddress, request, response)) { return; } response.setHeader('Access-Control-Allow-Origin', getAccessControlOrigin(request.headers, origin)); state = { body: '' }; request.on('data', receive.bind(null, log, state, maxSize, request, response)); request.on('end', send.bind(null, log, state, remoteAddress, validator, filter, mapper, forwarder, request, response)); } function logRequest (log, request) { log.info( 'referer=' + (request.headers.referer || '') + ' ' + 'user-agent=' + request.headers['user-agent'] + ' ' + 'address=' + request.socket.remoteAddress + '[' + (request.headers['x-forwarded-for'] || '') + ']' + ' ' + 'method=' + request.method + ' ' + 'url=' + request.url ); } function getRequestPath (request) { var queryIndex = request.url.indexOf('?'); return queryIndex === -1 ? request.url : request.url.substr(0, queryIndex); } function getRemoteAddress (request) { var proxiedAddresses = request.headers['x-forwarded-for'], filteredAddresses; if (proxiedAddresses) { filteredAddresses = proxiedAddresses.split(',').map(function (address) { return address.trim(); }).filter(check.unemptyString); if (filteredAddresses.length > 0) { return filteredAddresses[0]; } } return request.socket.remoteAddress; } function checkRequest (log, path, referer, limit, requestPath, remoteAddress, request, response) { if (request.method !== 'GET' && request.method !== 'POST') { fail(log, request, response, 405, 'Invalid method `' + request.method + '`'); return false; } if (requestPath !== path) { fail(log, request, response, 404, 'Invalid path `' + requestPath + '`'); return false; } if (check.unemptyString(request.headers.referer) && !referer.test(request.headers.referer)) { fail(log, request, response, 403, 'Invalid referer `' + request.headers.referer + '`'); return false; } if (request.method === 'POST' && !isValidContentType(request.headers['content-type'])) { fail(log, request, response, 415, 'Invalid content type `' + request.headers['content-type'] + '`'); return false; } if (!checkLimit(limit, remoteAddress)) { fail(log, request, response, 429, 'Exceeded rate `' + limit.time + '`'); return false; } return true; } function fail (log, request, response, status, message) { log.error(status + ' ' + message); response.statusCode = status; response.setHeader('Content-Type', 'application/json'); response.end('{ "error": "' + message + '" }'); request.socket.destroy(); } function isValidContentType (contentType) { if (!contentType) { return false; } if (contentType === 'application/x-www-form-urlencoded' || contentType === 'text/plain') { return true; } return isValidContentType(contentType.substr(0, contentType.indexOf(';'))); } function checkLimit (limit, remoteAddress) { var now, lastRequest; if (limit === null) { return true; } now = Date.now(); lastRequest = limit.requests[remoteAddress]; if (check.positive(lastRequest) && now <= lastRequest + limit.time) { return false; } limit.requests[remoteAddress] = now; return true; } function getAccessControlOrigin (headers, origin) { if (check.array(origin)) { if (headers.origin && contains(origin, headers.origin)) { return headers.origin; } return 'null'; } return origin; } function contains (array, value) { return array.reduce(function (match, candidate) { return match || candidate === value; }, false); } function receive (log, state, maxSize, request, response, data) { if ( (request.method === 'GET' && data.length > 0) || (request.method === 'POST' && maxSize >= 0 && state.body.length + data.length > maxSize) ) { state.failed = true; return fail(log, request, response, 413, 'Body too large'); } state.body += data; } function send (log, state, remoteAddress, validator, filter, mapper, forwarder, request, response) { var data, referer, userAgent, mappedData; if (state.failed) { return; } try { data = parseData(request, state); referer = request.headers.referer; userAgent = request.headers['user-agent']; if (!validator(data, referer, userAgent, remoteAddress)) { throw null; } mappedData = mapper(filter(data), referer, userAgent, remoteAddress); if (mappedData === '') { return pass(log, response, 204, 0); } log.info('sending ' + mappedData); forwarder(mappedData, mapper.type, mapper.separator, function (error, bytesSent) { if (error) { log.error(error.stack || error.message || error); return fail(log, request, response, 502, 'Forwarder failed'); } pass(log, response, state.successStatus, bytesSent); }); } catch (error) { fail(log, request, response, 400, 'Invalid data'); if (error) { log.error(error.stack); } } } function parseData (request, state) { if (request.method === 'GET') { state.successStatus = 204; return qs.parse(url.parse(request.url).query, { allowDots: true }); } state.successStatus = 200; if (state.body.substr(0, 5) === 'data=') { state.body = state.body.substr(5); } state.body = decodeURIComponent(state.body); if (request.headers['content-type'] === 'text/plain') { return JSON.parse(state.body); } return qs.parse(state.body, { parameterLimit: Infinity }); } function pass (log, response, status, bytes) { log.info('sent ' + bytes + ' bytes'); response.statusCode = status; response.end(); }