naked-websocket
Version:
Fastest WebSockets for node to node data exchange.
511 lines (392 loc) • 15.2 kB
JavaScript
;
//
// naked-websocket
//
// Version: 0.1.7
// Author: Mark W. B. Ashcroft (mark [at] fluidecho [dot] com)
// License: MIT or Apache 2.0.
//
// Copyright (c) 2016-2017 Mark W. B. Ashcroft.
// Copyright (c) 2016-2017 FluidEcho.
//
const preview = require('preview')('naked-websocket');
const util = require('util');
const url = require('url');
const querystring = require('querystring');
const crypto = require('crypto');
const events = require('events');
const EventEmitter = events.EventEmitter;
const os = require('os');
// TODO:
// - add a timeout for client to parse server.
// public:
exports.createServer = createServer;
exports.connect = connect;
// options, set through app (also: 'headers' and 'path' (connect)):
var Options = {
maxbuffer: 4000, // max header size, 4000 = 4 KB.
version: '0.0.1', // must be same on all peers.
protocol: 'ws', // 'wss' = secure (TLS), must be same on all peers.
slowHandshake: false, // true: if you wish to manage own auth at app level.
timedout: 15000 // how long to wait for connection, 15 seconds.
};
// private:
const LF = 10; // line feed is 10 decimal value in ascii eg: \n = 10.
const CR = 13; // carage return is 13 decimal value in ascii eg: \r = 13.
const CRLF = '\r\n';
const VERSION = "13"; // (as string) WebSocket Protocol version number.
function createServer(options, fn) {
for ( var o in Options ) if ( !options[o] ) options[o] = Options[o];
preview('createServer, options', options);
var self = this;
var transport = undefined; // socket transport, net (tcp) or tls.
if ( options.protocol === 'wss' ) {
transport = require('tls'); // WebSocket through TLS (SSL)
} else {
transport = require('net'); // WebSocket through TCP
}
var Server = transport.createServer(options, function(socket) {
preview('client connected');
socket.on('error', function(err) {
preview('error, code: ' + err.code, err.message);
Server.emit('error', err);
});
if ( options.noDelay ) {
socket.setNoDelay(true); // turn nagle batching algorithm off.
}
socket.handshaked = false;
socket.handshake = function(opts){
if ( !socket.writable || socket.handshaked ) return;
preview('handshake, opts', opts);
var headers = '';
if ( opts != undefined ) {
if ( opts.headers != undefined ) {
for ( var h in opts.headers ) {
headers += h + ': ' + opts.headers[h] + CRLF;
}
}
}
preview('headers', headers);
// key to accept (.replace(/\//g, '0') // hum maybe?):
var accept64 = crypto.createHash('sha1').update(socket.headers['Sec-WebSocket-Key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); // concat Key and, websocket protocol fixed GUID.
socket.handshaked = true;
socket.write(
'HTTP/1.1 101 Switching Protocols' + CRLF +
'Upgrade: websocket' + CRLF +
'Connection: Upgrade' + CRLF +
'Sec-WebSocket-Accept: ' + accept64 + CRLF +
'Sec-WebSocket-Version: ' + VERSION + CRLF +
'Sec-WebSocket-Protocol: nws/' + options.version + CRLF +
headers +
CRLF
, 'ascii');
return;
};
socket.goodbye = function(code, message){
if ( !socket.writable ) return;
preview('goodbye');
if ( !code ) code = 403;
socket.end(
'HTTP/1.1 ' + code + CRLF +
'Connection: closed' + CRLF +
'Content-Type: application/json; charset=utf-8' + CRLF +
CRLF +
JSON.stringify({"status": message}) + CRLF +
CRLF, 'ascii');
return;
};
socket.connected = false;
var buffer = new Buffer(0); // concat until header completed.
socket.on('data', function(chunk) {
//preview('socket.on, data, chunk', chunk.toString());
if ( !socket.connected ) {
serverParser(options, buffer, chunk, socket, function(s) {
socket.connected = true;
if ( s != false ) {
return fn(s); // return parsed socket back to app.
}
});
}
});
});
return Server; // return the server object.
}
function connect(options, fn) {
for ( var o in Options ) if ( !options[o] ) options[o] = Options[o];
preview('connect, options', options);
var self = this;
var headers = '';
if ( options.headers != undefined ) {
for ( var h in options.headers ) {
headers += h + ': ' + options.headers[h] + CRLF;
}
}
if ( options.path === undefined || options.path === '' ) {
options.path = '/';
}
// client, as EventEmitter.
var Client = function() {
EventEmitter.call(this);
};
util.inherits(Client, events.EventEmitter);
var client = new Client();
if ( options.timedout != Infinity ) {
var timedOut = setTimeout(function() {
preview('connection timedout!');
var err = new Error("connection timedout")
client.emit('error', err);
Socket.destroy();
return;
}, options.timedout);
}
var transport = undefined; // socket transport, net (tcp) or tls.
var httpx = undefined;
if ( options.protocol === 'wss' ) {
transport = require('tls'); // WebSocket through TLS (SSL)
httpx = 'https';
} else {
transport = require('net'); // WebSocket through TCP
httpx = 'http';
}
var key = crypto.randomBytes(20).toString('base64');
var Socket = transport.connect({port: options.port, host: options.hostname}, options, function() {
preview('client connected to server, send websoket upgrade request');
var auth = '';
if ( options.auth != undefined ) {
auth = 'Authorization: Basic ' + new Buffer(options.auth).toString('base64') + CRLF;
}
var origin = 'localhost';
const interfaces = os.networkInterfaces();
for (var i in interfaces) {
for (var i2 in interfaces[i]) {
var address = interfaces[i][i2];
if (address.family === 'IPv4' && !address.internal) {
origin = address.address;
break;
}
}
}
Socket.write('GET ' + options.path + ' HTTP/1.1' + CRLF +
'Host: ' + origin + CRLF +
'Upgrade: websocket' + CRLF +
'Connection: Upgrade' + CRLF +
'Sec-WebSocket-Key: ' + key + CRLF + //'Sec-WebSocket-Key: ' + crypto.randomBytes(20).toString('base64').replace(/\//g, '0') + CRLF +
'Sec-WebSocket-Version: ' + VERSION + CRLF +
'Sec-WebSocket-Protocol: nws/' + options.version + CRLF +
'Origin: ' + httpx + '://' + origin + CRLF +
'Pragma: no-cache' + CRLF +
'Cache-Control: no-cache' + CRLF +
auth +
headers +
CRLF, 'ascii');
});
if ( options.noDelay ) {
Socket.setNoDelay(true); // turn nagle batching algorithm off.
}
Socket.on('connect', function() {
client.emit('connect', true);
});
Socket.on('error', function(err) {
preview('connect', 'socket, error', err);
client.emit('error', err);
});
Socket.on('close', function() {
preview('connect', 'socket, close, status_code: ' + Socket.status_code);
client.emit('close', Socket.status_code);
});
// HTTP Parser.
Socket.connected = false;
Socket.status_code = undefined; // returned server http status code.
var buffer = new Buffer(0); // concat until header completed.
Socket.on('data', function(chunk) {
if ( !Socket.connected ) {
clientParser(key, options, buffer, chunk, Socket, function(s, code) {
Socket.connected = true;
Socket.status_code = code; // if server returns http status code.
clearTimeout(timedOut); // okay.
if ( s != false ) {
return fn(s); // return parsed socket back to app.
}
});
}
});
return client; // return the server object.
}
function serverParser(options, buffer, chunk, socket, _fn) {
preview('server, parser, chunk', chunk.toString());
// validate request headers.
var HEADS = {
'method' : 'GET',
'upgrade' : 'websocket',
'connection' : 'upgrade',
'sec-websocket-version' : VERSION,
'sec-websocket-protocol' : 'nws/'+options.version
};
buffer = Buffer.concat([buffer, chunk], buffer.length + chunk.length); // on chunk concate buffer.
if ( buffer.length > options.maxbuffer ) {
socket.goodbye(403, 'Invalid Request');
return _fn(false);
}
var timedOut = setTimeout(function() {
preview('connection timedout!');
socket.goodbye(408, 'Request Timeout');
return _fn(false);
}, options.timedout);
var bufferLoop = new Buffer(buffer.length);
bufferLoop = buffer;
// examine chunk for header.
var i = 0;
for (i = 0; i < bufferLoop.length; i++) {
if ( bufferLoop[i] === CR && bufferLoop[i + 1] == LF && bufferLoop[i + 2] == CR && bufferLoop[i + 3] == LF ) {
//preview('header here, at: ' + i);
var headers = {};
var headersString = bufferLoop.slice(0, i + 4).toString('ascii');
preview('headersString: ' + headersString);
try {
var rawHeaders = headersString.split(CRLF);
preview('rawHeaders', rawHeaders);
var line0 = rawHeaders[0].split(' ');
if ( HEADS.method === line0[0] ) {
HEADS.method = true;
headers.method = line0[0];
}
headers.url = url.parse(line0[1]);
headers.url.protocol = options.protocol;
// loop through raw header folloing line 0:
var h = 1;
for (h = 1; h < rawHeaders.length; h++) {
var headerRow = rawHeaders[h].split(': ');
//preview('headerRow', headerRow);
if ( headerRow[0] === '' || headerRow[0] === undefined || headerRow[1] === '' || headerRow[1] === undefined ) {
continue;
}
// authorization: set options.slowHandshake = true to manage own auth.
if ( headerRow[0].toLowerCase() === 'authorization' ) {
var token = headerRow[1].split(/\s+/).pop()||'', // auth token
plain = new Buffer(token, 'base64').toString(), // convert from base64
parts = plain.split(/:/); // split on colon
headers.authorization = { scheme: 'basic', username: parts[0], password: parts[1] };
}
if ( HEADS[headerRow[0].toLowerCase()] != undefined ) {
if ( HEADS[headerRow[0].toLowerCase()] === headerRow[1].toLowerCase() ) {
headers[headerRow[0]] = headerRow[1];
HEADS[headerRow[0].toLowerCase()] = true; // so can validate request.
}
} else {
headers[headerRow[0]] = headerRow[1];
}
}
} catch(e) {
// err, invaid header format, close socket!
preview('caught error in request, e', e);
socket.goodbye(403, 'Invalid Request');
clearTimeout(timedOut); // okay.
return _fn(false);
}
// make sure is valid request.
for ( var head in HEADS ) {
if ( HEADS[head] != true ) {
socket.goodbye(404, 'Invalid Request');
clearTimeout(timedOut); // okay.
return _fn(false);
}
}
clearTimeout(timedOut); // okay.
//preview('headers', headers);
socket.headers = headers;
socket.rawHeaders = rawHeaders;
socket.client = { hostname: socket.remoteAddress, port: socket.remotePort };
//preview('client', socket.client);
preview('serverParser', 'options', options);
if ( !options.slowHandshake ) {
socket.handshake();
}
return _fn(socket);
}
}
}
function clientParser(key, options, buffer, chunk, socket, _fn) {
preview('client, parser, chunk', chunk.toString());
// validate request headers.
var HEADS = {
'upgrade' : 'websocket',
'connection' : 'upgrade',
'sec-websocket-version' : VERSION,
'sec-websocket-protocol' : 'nws/'+options.version
};
buffer = Buffer.concat([buffer, chunk], buffer.length + chunk.length); // on chunk concate buffer.
if ( buffer.length > options.maxbuffer ) {
preview('maxbuffer exceeded!');
socket.end();
return _fn(false);
}
var bufferLoop = new Buffer(buffer.length);
bufferLoop = buffer;
// examine chunk for header.
var i = 0;
for (i = 0; i < bufferLoop.length; i++) {
if ( bufferLoop[i] === CR && bufferLoop[i + 1] == LF && bufferLoop[i + 2] == CR && bufferLoop[i + 3] == LF ) {
//console.log('header here, at: ' + i);
var headers = {};
var headersString = bufferLoop.slice(0, i).toString('ascii');
//preview('headersString: ' + headersString);
var body = bufferLoop.slice(i+4);
if ( body === '' || body.length === 0 ) {
body = undefined;
}
preview('body: ' + body);
try {
var rawHeaders = headersString.split(CRLF);
//preview('rawHeaders', rawHeaders);
var status_code = Number(rawHeaders[0].replace(/^\s+|\s+$/g,'').substring(9, 12)); // "HTTP/1.1 "<STATUS_CODE>
//preview('status_code', status_code);
if ( status_code != 101 ) { // 101: switching protocol.
preview('not valid response code: ' + status_code);
return _fn(false, status_code);
}
// loop through raw header folloing line 0:
var h = 1;
for (h = 1; h < rawHeaders.length; h++) {
var headerRow = rawHeaders[h].split(': ');
//preview('headerRow', headerRow);
if ( headerRow[0] === '' || headerRow[0] === undefined || headerRow[1] === '' || headerRow[1] === undefined ) {
continue;
}
if ( HEADS[headerRow[0].toLowerCase()] != undefined ) {
if ( HEADS[headerRow[0].toLowerCase()] === headerRow[1].toLowerCase() ) {
headers[headerRow[0]] = headerRow[1];
HEADS[headerRow[0].toLowerCase()] = true; // so can validate request.
}
} else {
headers[headerRow[0]] = headerRow[1];
}
}
} catch(e) {
preview('invalid header, end');
socket.end();
return _fn(false);
}
// make sure is valid request.
for ( var head in HEADS ) {
if ( HEADS[head] != true ) {
preview('invalid head.header, end');
socket.end();
return _fn(false);
}
}
// validate key to accept.
var accept64 = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); // concat Key and, websocket protocol fixed GUID.
//preview('accept64', accept64);
if ( accept64 != headers['Sec-WebSocket-Accept'] ) {
preview('invalid Sec-WebSocket-Accept, Sec-WebSocket-Key does not match!, end');
socket.end();
return _fn(false);
}
preview('headers', headers);
socket.headers = headers;
socket.rawHeaders = rawHeaders;
socket.body = body;
return _fn(socket, 200); // status code okay = 200
}
}
}