logstash-relay
Version:
A simple NodeJS service to relay JSNLogs or Log4Javascript/Log4JS(client) event messages to Logstash.
503 lines (446 loc) • 15.5 kB
JavaScript
// app/server.js
// Dependencies
const __ = require('@outofsync/lodash-ex');
const LogStub = require('logstub');
const RelayLogger = require('./lib/relayLogger');
const http = require('http');
const https = require('https');
const fs = require('fs');
const os = require('os');
const url = require('url');
const moment = require('moment');
const uuidv4 = require('uuid/v4');
const express = require('express');
const router = express.Router;
const bodyParser = require('body-parser');
const multer = require('multer');
const timeout = require('connect-timeout');
const cors = require('cors');
const expressWinston = require('express-winston');
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware');
const connectionTester = require('connection-tester');
const validLevels = ['silly', 'trace', 'debug', 'info', 'warn', 'error'];
const numericLevels = {
[0]: 'trace',
[1]: 'trace',
[2]: 'debug',
[3]: 'info',
[4]: 'warn',
[5]: 'error',
[6]: 'error'
};
/**
* @class Server
* @classdesc Server Class
*/
class Server {
constructor(config, log) {
this.app = express();
// Setup Express
this.server = null;
this.httpsServer = null;
this.config = config;
this.hostname = os.hostname();
// Set the server base configuration
this.isActive = false;
this.port = config.server.port || 8080;
// Configure the port number
this.timeout = config.server.timeout || 5;
// Configure the server timeout mechanic
this.sslEnabled = config.server.sslEnabled || false;
// Configure servers ssl enablement
this.sslPort = config.server.sslPort || 8443;
if (this.sslEnabled) {
this.sslKey = fs.readFileSync(config.server.sslKey);
// Load SSL Key
this.sslCert = fs.readFileSync(config.server.sslCert); // Load SSL Cert
}
this.log = log || new LogStub();
this.relayLogger = {};
this.relayLog = {};
}
// ****************************************************************************
// Server Shutdown Logic
// ***************************************************************************/
close() {
// Perform gracful shutdown here
if (__.hasValue(this.server)) {
this.log.debug('Shutting down HTTP listener');
this.server.close();
}
if (__.hasValue(this.httpsServer)) {
this.log.debug('Shutting down HTTPS listener');
this.httpsServer.close();
}
this.isActive = false;
}
// ****************************************************************************
// Middleware functions (Core)
// ***************************************************************************/
setError(err, req, res) {
const errorBlock = {
summary: 'General Server Error',
message: 'An unknown error occurred processed the log message.'
};
if (__.hasValue(err)) {
errorBlock.details = err.toString();
}
req.hasError = true;
req.error = errorBlock;
req.respCode = req.respCode || 500000;
req.respStatus = req.respStatus || 500;
this.setResponse(req, res);
}
setResponse(req, res) {
const code = req.respCode || 200000;
const status = req.respStatus || 200;
const respBlock = {
respCode: code,
status: status,
callID: req.callID,
time: req.time,
timestamp: req.timestamp,
ip: req.ip,
ipForwarding: req.ips,
};
if (__.hasValue(req.error)) {
respBlock.error = req.error;
}
res.locals.status = status;
res.locals.body = respBlock;
}
sendResponse(req, res, next) {
if (!res.headersSent) {
res.set('Content-Type', 'application/json');
for (const header in res.locals.headers) {
if (Object.prototype.hasOwnProperty.call(res.locals.headers, header)) {
res.header(header, res.locals.headers[header]);
}
}
res.status(res.locals.status);
res.json(res.locals.body);
const code = req.respCode || 200000;
if (code === 200000) {
const respMsg = {
status: res.locals.status,
respCode: code,
protocol: req.secure ? 'HTTPS' : 'HTTP',
method: req.method,
endpoint: req.urlpath,
actualIP: req.connection.remoteAddress,
ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress,
callID: req.callID,
server: req.oshost,
headers: req.headers,
performance: { response: req.timers.response, route: req.timers.route },
body: JSON.stringify(res.body)
};
this.log.info(JSON.stringify(respMsg));
}
}
next();
}
// Returns headers needed for proper Cross-Origin Resource Sharing
// This is needed so browsers applications can properly request data
handleCORS(req, res, next) {
if (__.isUnset(res.locals.headers)) {
res.locals.headers = {};
}
res.locals.headers['Access-Control-Allow-Origin'] = '*';
res.locals.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS';
res.locals.headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept';
next();
}
attachCallID(req, res, next) {
// Generate CallID attach to the request object
const now = moment();
req.callID = uuidv4();
req.time = `${now.format()}Z`;
req.timestamp = now.format('x');
req.oshost = this.hostname;
req.urlpath = url.parse(req.url).pathname;
req.hasData = false;
req.hasError = false;
req.timedout = false;
next();
}
safeDeserialize(data) {
try {
return JSON.parse(data);
} catch (err) {
return data;
}
}
normalizeLevel(errorLevel) {
let level = 'error';
if (__.hasValue(errorLevel)) {
if (Number.isInteger(errorLevel)) {
level = numericLevels[Math.floor(errorLevel / 1000)];
} else {
const test = errorLevel.toString().toLowerCase();
if (validLevels.includes(test)) {
level = test;
}
}
}
return level;
}
handleIncomingLog(req, res, next) {
try {
const timestamp = moment().valueOf();
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
if (__.hasValue(req.body.r) && __.hasValue(req.body.lg)) {
let lg = req.body.lg;
// Handle JSNLogs style logging
if (typeof req.body.lg === 'string') {
lg = this.safeDeserialize(req.body.lg);
}
if (!lg || !Array.isArray(lg)) {
next('Malformed JSNLogs log message');
return;
}
const requestID = req.body.r;
const count = lg.length;
for (let itr = 0; itr < count; itr++) {
const entry = lg[itr];
const logName = entry.n;
const level = this.normalizeLevel(entry.l);
const logMessage = {
type: 'client_error',
name: logName,
requestID: requestID,
/* eslint-disable camelcase */
client_error: this.safeDeserialize(entry.m),
/* eslint-enable camelcase */
actualIP: req.connection.remoteAddress,
ip: ip,
callID: req.callID,
headers: req.headers,
clientTimestamp: entry.t,
timestamp: timestamp
};
this.log.debug('Delivering JSNLogs log message to relay.');
this.log.debug(JSON.stringify(logMessage));
this.relayLog.log(level, JSON.stringify(logMessage));
}
} else if (__.hasValue(req.body.level) && __.hasValue(req.body.message)) {
// Handle Log4Javascript style logging
const level = this.normalizeLevel(req.body.level);
const logMessage = {
type: 'client_error',
level: level,
/* eslint-disable camelcase */
client_error: { msg: req.body.message },
/* eslint-enable camelcase */
actualIP: req.connection.remoteAddress,
ip: ip,
callID: req.callID,
headers: req.headers,
clientTimestamp: timestamp,
timestamp: timestamp
};
this.log.debug('Delivering Log4JS log message to relay.');
this.log.debug(JSON.stringify(logMessage));
this.relayLog.log(level, JSON.stringify(logMessage));
} else {
next('Proper logging message was not found');
return;
}
req.hasData = true;
next();
} catch (err) {
this.log.error(err);
next(err);
}
}
errorHandler(err, req, res, next) {
this.log.debug('Error Handler');
this.stopRouteTimerError(req);
this.stopResponseTimerError(req);
this.setError(err, req, res);
const errorMsg = {
status: 500,
protocol: req.secure ? 'HTTPS' : 'HTTP',
actualIP: req.connection.remoteAddress,
ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress,
callID: req.callID,
server: req.oshost,
headers: req.headers,
performance: { response: req.timers.response, route: req.timers.route },
error: err
};
this.log.error(JSON.stringify(errorMsg));
next();
}
responseHandler(req, res, next) {
this.log.debug('Response Handler');
if (req.hasData === true) {
this.setResponse(req, res);
}
next();
}
handle404Error(req, res, next) {
if (!req.hasData && !req.hasError) {
req.respCode = 404000;
req.hasError = true;
next('Endpoint Not Found.');
} else {
next();
}
}
timeoutHandler(req, res, next) {
if (req.timedout) {
req.respCode = 408000;
req.hasError = true;
next('The request data took too long to send, please attempt your request again.');
} else {
next();
}
}
// ****************************************************************************
// Middleware Functions (Metrics Tracking)
// ***************************************************************************/
setupTimers(req, res, next) {
req.timers = {
response: null,
responseStart: null,
route: null,
routeStart: null
};
next();
}
startResponseTimer(req, res, next) {
req.timers.responseStart = Date.now();
next();
}
startRouteTimer(req, res, next) {
req.timers.routeStart = Date.now();
next();
}
stopResponseTimer(req, res, next) {
if (req.timers.responseStart) {
req.timers.response = Date.now() - req.timers.responseStart;
}
next();
}
stopRouteTimer(req, res, next) {
if (req.timers.routeStart) {
req.timers.route = Date.now() - req.timers.routeStart;
}
next();
}
stopResponseTimerError(req) {
if (req.timers.responseStart) {
req.timers.response = Date.now() - req.timers.responseStart;
}
}
stopRouteTimerError(req) {
if (req.timers.routeStart) {
req.timers.route = Date.now() - req.timers.routeStart;
}
}
// ****************************************************************************
// Server Initialization Logic
// ***************************************************************************/
init(isLambda) {
this.setupRelay(this.config);
this.setupServer(this.app, isLambda);
this.isActive = true;
}
setupRelay(config) {
const relay = config.logstash.relay;
this.log.debug(`Setting up logging relay '${relay.appName}' to ` +
`${relay.host}:${relay.port}.`);
const results = connectionTester.test(config.logstash.relay.host, 22, 1000);
if (results.err) {
this.log.error(results.err);
}
this.log.debug(`Connection Active? ${results.success}`);
this.relayLogger = new RelayLogger(config);
this.relayLog = this.relayLogger.log;
}
setupServer(app, isLambda) {
this.log.debug('Starting server');
app.use(this.setupTimers.bind(this));
// Start Metrics Gathering on Response processing
app.use(this.startResponseTimer.bind(this));
app.use(timeout(`${this.timeout}s`));
// configure app to use bodyParser()
// this will let us get the data from a POST x-www-form-urlencode
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// configure app to use multer()
// this will let us get the data from multipart/form-data
app.use(multer().array());
// Create the router and routes for the API
/* eslint new-cap: "off" */
this.router = router();
/* eslint new-cap: "error" */
// Handle CORS Headers
app.use(this.handleCORS.bind(this));
app.options('*', cors({ origin: true, method: ['POST', 'OPTIONS'] }));
// Add Lambda Context when in Lambda mode
if (isLambda === true) {
app.use(awsServerlessExpressMiddleware.eventContext());
}
// bind middleware to use for all requests
// The 'bind' statements are there to preserve the scope of this class
app.use(this.attachCallID.bind(this));
// app.use(this.controllers.AuthController.authenticateRequest.bind(this.controllers.AuthController));
// Start Metrics Gathering on Route processing
app.use(this.startRouteTimer.bind(this));
// perform the logic for pushing the logging information
app.post('/api/logger', this.handleIncomingLog.bind(this));
app.post('/jsnlog.logger', this.handleIncomingLog.bind(this));
// Stop Metrics Gathering on Route processing
app.use(this.stopRouteTimer.bind(this));
// middleware for general handling of route responses
app.use(this.timeoutHandler.bind(this));
// no data from above routes -- 404 error
app.use(this.handle404Error.bind(this));
// Stop Metrics Gathering on Response processing
app.use(this.stopResponseTimer.bind(this));
app.use(this.errorHandler.bind(this));
app.use(this.responseHandler.bind(this));
expressWinston.requestWhitelist.push('body');
expressWinston.responseWhitelist.push('body', 'locals');
// Attach Winston Express logger
app.use(expressWinston.logger({
winstonInstance: this.log,
msg: '{ ' + ' "status": {{res.locals.status}}, ' + ' "respCode": {{(req.respCode || 200000)}}, ' +
' "protocol": "{{(req.secure?\'HTTPS\':\'HTTP\')}}", ' +
' "method": "{{req.method}}", ' +
' "endpoint": "{{req.urlpath}}", ' +
' "actualIP": "{{req.connection.remoteAddress}}",' +
' "ip": "{{req.headers[\'x-forwarded-for\']||req.connection.remoteAddress}}",' +
' "callID": "{{req.callID}}",' +
' "server": "{{req.oshost}}",' +
' "headers": {{JSON.stringify(req.headers)}},' +
' "performance": {' +
' "response": {{req.timers.response}},' +
' "route": {{req.timers.route}},' +
' }' +
'}',
statusLevels: { success: 'info', warn: 'info', error: 'error' },
json: true,
logstash: true,
meta: true
}));
app.disable('x-powered-by');
// the buck stops here -- all responses are sent, regardless of status
app.use(this.sendResponse.bind(this));
if (isLambda !== true) {
// Start the HTTP server
this.server = http.createServer(app).listen(this.port);
this.log.debug(`Listening for HTTP on port ${this.port}`);
// Start the HTTPS server
if (this.sslEnabled) {
this.httpsServer = https
.createServer({ key: this.sslKey, cert: this.sslCert }, app)
.listen(this.sslPort);
this.log.debug(`Listening for HTTPS on port ${this.sslPort}`);
}
}
}
}
module.exports = Server;