commit 157fea3a5f1ac8da1f2e800a6e5c1c3c5f89dbce Author: Clemens Vasters Date: Tue Sep 20 20:00:49 2016 +0200 baseline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b8199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.vscode +.vscode/* + +# Build and package folders +################### +build +dist +packages +typings +.tmp + +# Node +################### +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..6903f8a --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Azure Relay Hybrid Connections for Node.JS + +This repository contains Node modules and samples for the Hybrid Connections feature of the +Microsoft Azure Relay, a capability pilar of the Azure Service Bus platform. + +Azure Relay is one of the key capability pillars of the Azure Service Bus platform. Hybrid +Connections is a secure, open-protocol evolution of the existing Relay service that has been +available in Azure since the beginning. Hybrid Connections is based on HTTP and WebSockets. + +Hybrid Connections allows establishing bi-directional, binary stream communication between +two networked applications, whereby either or both of the parties can reside behind NATs or +Firewalls. + +## Functional Principles + +For Node, the code in the repository allows a **publicly discoverable and reachable** WebSocket +server to be hosted on any machine that has outbound access to the Internet, and +specifically to the Microsoft Azure Relay service in the chosen region, via HTTPS port 443. + +The WebSocket server code will look instantly familiar as it is directly based on and integrated +with the most most popular existing WebSocket modules in the Node universe: "ws" and "websocket". + +``` JS + +require('ws') ==> require('hyco-ws') +require('websocket') ==> require('hyco-websocket') + +``` + +As you create a WebSocket server using either of the alternate "hyco-ws" and "hyco-websocket" +modules from this repository, the server will not listen on a TCP port on the local network, +but rather delegate listening to a configured Hybrid Connection path the Azure Relay service +in Service Bus. That listener connection is automatically TLS/SSL protected without you having +to juggle any certificates. + +The example below shows the "ws"/"hyco-ws" variant of creating a server. The API is usage is +completely "normal" except for using the "hyco-ws" module and creating an instance of the +*RelayedServer* instead of *Server*. The default underlying *Server* class remains fully available +when using "hyco-ws" instead of "ws", meaning you can host a relayed and a local WebSocket +server side-by-side from within the same application. The "websocket"/"hyco-websocket" +experience is analogous and explained in the module's README. + +``` JS + var WebSocket = require('hyco-ws') + + var wss = WebSocket.RelayedServer( + { + // create the + server : WebSocket.createRelayListenUri(ns, path), + token: WebSocket.createRelayToken('http://'+ns, keyrule, key) + }); + + wss.on('connection', function (ws) { + console.log('connection accepted'); + ws.onmessage = function (event) { + console.log(JSON.parse(event.data)); + }; + ws.on('close', function () { + console.log('connection closed'); + }); + }); + + wss.on('error', function(err) { + console.log('error' + err); + }); +``` + +Up to 25 WebSocket listeners can listen concurrently on the same Hybrid Connection path on the +Relay; if two or more listeners are connected, the service will automatically balance incoming +connection requests across the connected listeners. which also provides an easy failover capability. +You don't have to do anything to enable this, just have multiple listeners share the same path. + +Clients connect to the server through the Relay service on the same path the listener is listening +on. The client uses the regular WebSocket protocol. +three + +## Modules + +This repository hosts two different modules for Node that integrate with the Hybrid Connections +feature. The modules are designed to act, as much as possible, as contract-compatible drop-in +replacements for two of the most popular existing WebSocket modules in the Node universe: +"ws" and "websocket". "Contract-compatible" means that you can take nearly any existing module or +app that uses either library and convert it to work through the Hybrid Connections relay with +minimal changes. + +### Functional principles + +The functional principle + + diff --git a/hyco-websocket-tunnel/.gitignore b/hyco-websocket-tunnel/.gitignore new file mode 100644 index 0000000..7ff5a9e --- /dev/null +++ b/hyco-websocket-tunnel/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +node_modules/* \ No newline at end of file diff --git a/hyco-websocket-tunnel/connect.js b/hyco-websocket-tunnel/connect.js new file mode 100644 index 0000000..19c59aa --- /dev/null +++ b/hyco-websocket-tunnel/connect.js @@ -0,0 +1,74 @@ +var https = require('https'); +var tunnel = require('./lib/tunnel'); +var WebSocket = require('../hyco-websocket'); + +var argv = require('optimist').argv; +if ( argv._.length < 4) { + console.log("connect.js [namespace] [path] [key-rule] [key]") + process.exit(1); +} + +var ns = argv._[0]; +var path = argv._[1]; +var keyrule = argv._[2]; +var key = argv._[3]; +var server = WebSocket.createRelaySendUri(ns, path); +var token = WebSocket.createRelayToken('http://'+ns, keyrule, key); + +var credentials, tunnels = []; + +var shell = global.shell = require('./lib/shell'); + +shell.on('command', function(cmd, args) { + if (cmd == 'help') { + shell.echo('Commands:'); + shell.echo('tunnel [localhost:]port [remotehost:]port'); + shell.echo('close [tunnel-id]'); + shell.echo('exit'); + shell.prompt(); + } else + if (cmd == 'tunnel') { + tunnel.createTunnel(server, token, credentials, args[0], args[1], function(err, server) { + if (err) { + shell.echo(String(err)); + } else { + var id = tunnels.push(server); + shell.echo('Tunnel created with id: ' + id); + } + shell.prompt(); + }); + } else + if (cmd == 'close') { + var id = parseInt(args[0], 10) - 1; + if (tunnels[id]) { + tunnels[id].close(); + tunnels[id] = null; + shell.echo('Tunnel ' + (id + 1) + ' closed.'); + } else { + shell.echo('Invalid tunnel id.'); + } + shell.prompt(); + } else + if (cmd == 'exit') { + shell.exit(); + } else { + shell.echo('Invalid command. Type `help` for more information.'); + shell.prompt(); + } +}); + +shell.echo('WebSocket Tunnel Console v0.1'); +shell.echo('Remote Host: ' + ns); + +authenticate(function() { + shell.prompt(); +}); + +function authenticate(callback) { + shell.prompt('Username: ', function(user) { + shell.prompt('Password: ', function(pw) { + credentials = user + ':' + pw; + callback(); + }, {passwordMode: true}); + }); +} diff --git a/hyco-websocket-tunnel/lib/shell.js b/hyco-websocket-tunnel/lib/shell.js new file mode 100644 index 0000000..08d2673 --- /dev/null +++ b/hyco-websocket-tunnel/lib/shell.js @@ -0,0 +1,106 @@ +var readline = require('readline'); +var EventEmitter = require('events').EventEmitter; + +var rl = readline.createInterface(process.stdin, process.stdout); + +var shell = module.exports = new EventEmitter(); +var paused, passwordMode; + +//Hack to hide mask characters from output +var normalWrite = process.stdout.write; +var dummyWrite = function (data) { + var args = Array.prototype.slice.call(arguments); + if (typeof data == 'string') { + //todo: deal with control characters like backspace + args[0] = new Array(data.length + 1).join('*'); + } + normalWrite.apply(process.stdout, args); +}; + +rl.on('SIGINT', function () { + shell.exit(); +}); + +shell.pause = function (s) { + process.stdin.pause(); + rl.pause(); + paused = true; +}; + +shell.resume = function (s) { + process.stdin.resume(); + rl.resume(); + paused = false; +}; + +shell.echo = function (s) { + var args = Array.prototype.slice.call(arguments); + for (var i = 0; i < args.length; i++) { + shell.writeLine(args[i]); + } +}; + +shell.setPasswordMode = function (enabled) { + passwordMode = !!enabled; + process.stdout.write = (enabled) ? dummyWrite : normalWrite; +}; + +shell.writeLine = function (s) { + if (paused) { + rl.output.write(s); + rl.output.write('\n'); + } else { + try { + rl.output.cursorTo(0); + } catch (e) { + // nop + } + rl.output.write(s); + try { + rl.output.clearLine(1); + } catch (e) { + // nop + } + rl.output.write('\n'); + try { + rl._refreshLine(); + } catch (e) { + // nop + } + } +}; + +var lineHandler; +rl.on('line', function (line) { + shell.pause(); + if (passwordMode) { + shell.setPasswordMode(false); + process.stdout.write('\n'); + } + if (lineHandler) { + lineHandler(line); + } else { + var parts = line.trim().split(/\s+/); + shell.emit('command', parts[0], parts.slice(1)); + } +}); + +shell.prompt = function (query, callback, opts) { + opts = opts || {}; + lineHandler = callback; + rl.setPrompt(query || '> '); + shell.resume(); + rl.prompt(); + if (opts.passwordMode) { + shell.setPasswordMode(true); + } +}; + +shell.exit = function () { + rl.close(); + process.stdin.destroy(); + process.exit(); +}; + +//start initially paused +rl.pause(); diff --git a/hyco-websocket-tunnel/lib/tunnel.js b/hyco-websocket-tunnel/lib/tunnel.js new file mode 100644 index 0000000..6519918 --- /dev/null +++ b/hyco-websocket-tunnel/lib/tunnel.js @@ -0,0 +1,124 @@ +var net = require('net'); +var WebSocketClient = require('../../hyco-websocket').client; + +exports.createTunnel = function(wsServerAddr, token, credentials, listen, forward, callback) { + listen = parseAddr(listen, {host: '0.0.0.0', port: '8080'}); + forward = parseAddr(forward, {host: '127.0.0.1', port: listen.port}); + + var server = net.createServer(function (tcpSock) { + + var wsClient = new WebSocketClient(); + + var webSock, buffer = []; + + tcpSock.on('data', function(data) { + if (!webSock || buffer.length) { + buffer.push(data); + } else { + webSock.send(data); + } + }); + + tcpSock.on('close', function() { + log('TCP socket closed'); + if (webSock) { + webSock.close(); + } else { + webSock = null; + } + }); + + tcpSock.on('error', function(err) { + log('TCP socket closed'); + if (webSock) { + webSock.close(); + } else { + webSock = null; + } + }); + + wsClient.on('connect', function(connection) { + log('WebSocket connected'); + + //flush buffer + while (buffer.length) { + connection.send(buffer.shift()); + } + + //check if tcpSock is already closed + if (webSock === null) { + connection.close(); + return; + } + + webSock = connection; + webSock.on('message', function (msg) { + if (msg.type == 'utf8') { + //log('Received UTF8 message'); + var data = JSON.parse(msg.utf8Data); + if (data.status == 'error') { + log(data.details); + webSock.close(); + } + } else { + //log('Received binary message'); + tcpSock.write(msg.binaryData); + } + }); + + webSock.on('close', function (reasonCode, description) { + log('WebSocket closed; ' + reasonCode + '; ' + description); + tcpSock.destroy(); + }); + + }); + + wsClient.on('connectFailed', function(err) { + log('WebSocket connection failed: ' + err); + tcpSock.destroy(); + }); + + var url = wsServerAddr; //+ '&port=' + forward.port + '&host=' + forward.host; + wsClient.connect(url, null, null, + { 'ServiceBusAuthorization' : token, + 'Authorization': 'Basic ' + new Buffer(credentials).toString('base64')}); + + }); + + server.on('error', function (err) { + callback(err); + }); + + server.listen(listen.port, listen.host, function() { + var addr = server.address(); + log('listening on ' + addr.address + ':' + addr.port); + if (callback) callback(null, server); + }); + +}; + +function parseAddr(str, addr) { + if (str) { + var parts = str.split(':'); + if (parts.length == 1) { + if (parts[0] == parseInt(parts[0], 10).toString()) { + addr.port = parts[0]; + } else { + addr.host = parts[0]; + } + } else + if (parts.length == 2) { + addr = {host: parts[0], port: parts[1]}; + } + } + return addr; +} + + +function log(s) { + if (global.shell) { + global.shell.echo(s); + } else { + console.log(s); + } +} diff --git a/hyco-websocket-tunnel/license.txt b/hyco-websocket-tunnel/license.txt new file mode 100644 index 0000000..533afe9 --- /dev/null +++ b/hyco-websocket-tunnel/license.txt @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2016, Simon Sturmer +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/hyco-websocket-tunnel/package.json b/hyco-websocket-tunnel/package.json new file mode 100644 index 0000000..6afc693 --- /dev/null +++ b/hyco-websocket-tunnel/package.json @@ -0,0 +1,13 @@ +{ + "name": "node-websocket-tunnel", + "version": "1.0.0", + "repository" : { + "type" : "git", + "url" : "git://github.com/clemensv/node-websocket-tunnel.git" + }, + "dependencies": { + "optimist": "^0.3.1", + "websocket": "^1.0.4", + "wordwrap": "^0.0.2" + } +} \ No newline at end of file diff --git a/hyco-websocket-tunnel/readme.md b/hyco-websocket-tunnel/readme.md new file mode 100644 index 0000000..8523e8b --- /dev/null +++ b/hyco-websocket-tunnel/readme.md @@ -0,0 +1,33 @@ +#TCP over WebSocket + +This tool allows you to tunnel TCP connections over WebSocket protocol using SSL. It consists of a server agent and +a client console. If the server agent is running on a remote machine you can use it as a middle-man to route connections +securely to any network host even through firewalls and proxies. + +##Example use cases: + - You are on a restricted network that only allows traffic on ports 80 and 443 + - You wish to connect securely to a service from a public access point, and cannot use SSH + +Due to WebSocket connections starting out as normal HTTPS, this can be used to tunnel connections through certain +restrictive firewalls that do not even allow SSH or OpenVPN over port 443. + +##Usage + +On a server, run `server.js` specifying optional port and address to bind to (defaults to 0.0.0.0:443): + +`node server.js 74.125.227.148:443` + +On a client, run `connect.js` specifying remote host and optional port (defaults to 443): + +`node connect.js 74.125.227.148` + +You will be prompted for username/password which the server will verify against users.txt and then you are presented +with a command shell where you can create and destroy tunnels. + +`> tunnel 3306 8.12.44.238:3306` + +This will listen on port 3306 on the client (localhost) and forward connections to remote host 8.12.44.238 via the +WebSocket server. Destination port, if omitted, will default to source port. + +The server uses SSL key files present in `keys/` and users listed in `users.txt` (in which passwords are md5 hashed). + diff --git a/hyco-websocket-tunnel/server.js b/hyco-websocket-tunnel/server.js new file mode 100644 index 0000000..1e01ccc --- /dev/null +++ b/hyco-websocket-tunnel/server.js @@ -0,0 +1,149 @@ +var fs = require('fs'); +var net = require('net'); +var crypto = require('crypto'); +var urlParse = require('url').parse; +var WebSocket = require('../hyco-websocket'); +var WebSocketServer = require('../hyco-websocket').server; + +process.chdir(__dirname); + +var argv = require('optimist').argv; +var pidfile; + +//kill an already running instance +if (argv.kill) { + pidfile = argv.kill; + if (!pidfile.match(/\.pid$/i)) + pidfile += '.pid'; + try { + var pid = fs.readFileSync(pidfile, 'utf8'); + fs.unlinkSync(pidfile); + process.kill(parseInt(pid, 10)); + console.log('Killed process ' + pid); + } catch (e) { + console.log('Error killing process ' + (pid || argv.kill)); + } + process.exit(); +} + +//write pid to file so it can be killed with --kill +if (argv.pidfile) { + pidfile = argv.pidfile; + if (!pidfile.match(/\.pid$/i)) + pidfile += '.pid'; + fs.writeFileSync(pidfile, process.pid); +} + +var users = loadUsers(); + +if (argv._.length < 4) { + console.log("server.js [namespace] [path] [key-rule] [key]") + process.exit(1); +} + +var ns = argv._[0]; +var path = argv._[1]; +var keyrule = argv._[2]; +var key = argv._[3]; + + + +var wsServer = new WebSocketServer( + { + server: WebSocket.createRelayListenUri(ns, path), + token: WebSocket.createRelayToken('http://' + ns, keyrule, key), + + }); + +wsServer.on('request', function (request) { + createTunnel(request, 3389); +}); + + +function authenticate(request) { + var encoded = request.headers['authorization'] || '', credentials; + encoded = encoded.replace(/Basic /i, ''); + try { + credentials = new Buffer(encoded, 'base64').toString('utf8').split(':'); + } catch (e) { + credentials = []; + } + var user = credentials[0], hash = md5(credentials[1]); + return (users[user] == hash); +} + +function createTunnel(request, port, host) { + if (!authenticate(request.httpRequest)) { + request.reject(403); + return; + } + request.accept(null, null, null, function (webSock) { + console.log(webSock.remoteAddress + ' connected - Protocol Version ' + webSock.webSocketVersion); + + var tcpSock = new net.Socket(); + + tcpSock.on('error', function (err) { + webSock.send(JSON.stringify({ status: 'error', details: 'Upstream socket error; ' + err })); + }); + + tcpSock.on('data', function (data) { + webSock.send(data); + }); + + tcpSock.on('close', function () { + webSock.close(); + }); + + tcpSock.connect(port, host || '127.0.0.1', function () { + webSock.on('message', function (msg) { + if (msg.type === 'utf8') { + //console.log('received utf message: ' + msg.utf8Data); + } else { + //console.log('received binary message of length ' + msg.binaryData.length); + tcpSock.write(msg.binaryData); + } + }); + webSock.send(JSON.stringify({ status: 'ready', details: 'Upstream socket connected' })); + }); + + webSock.on('close', function () { + tcpSock.destroy(); + console.log(webSock.remoteAddress + ' disconnected'); + }); + }); +} + +function loadUsers() { + var lines = fs.readFileSync('./users.txt', 'utf8'); + var users = {}; + lines.split(/[\r\n]+/g).forEach(function (line) { + var parts = line.split(':'); + if (parts.length == 2) { + users[parts[0]] = parts[1]; + } + }); + return users; +} + +function md5(s) { + var hash = crypto.createHash('md5'); + hash.update(new Buffer(String(s))); + return hash.digest('hex'); +} + +function parseAddr(str, addr) { + if (str) { + var parts = str.split(':'); + if (parts.length == 1) { + if (parts[0] == parseInt(parts[0], 10).toString()) { + addr.port = parts[0]; + } else { + addr.host = parts[0]; + } + } else + if (parts.length == 2) { + addr = { host: parts[0], port: parts[1] }; + } + } + return addr; +} diff --git a/hyco-websocket-tunnel/users.txt b/hyco-websocket-tunnel/users.txt new file mode 100644 index 0000000..38357af --- /dev/null +++ b/hyco-websocket-tunnel/users.txt @@ -0,0 +1,2 @@ +simon:bf787577ff656cde5b5d1f8236a75d2a +foo:37b51d194a7513e45b56f6524f2d51f2 \ No newline at end of file diff --git a/hyco-websocket/.gitignore b/hyco-websocket/.gitignore new file mode 100644 index 0000000..9018024 --- /dev/null +++ b/hyco-websocket/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +.lock-* +build +build/* +builderror.log +npm-debug.log diff --git a/hyco-websocket/.jshintrc b/hyco-websocket/.jshintrc new file mode 100644 index 0000000..98d8766 --- /dev/null +++ b/hyco-websocket/.jshintrc @@ -0,0 +1,88 @@ +{ + // JSHint Default Configuration File (as on JSHint website) + // See http://jshint.com/docs/ for more details + + "maxerr" : 50, // {int} Maximum error before stopping + + // Enforcing + "bitwise" : false, // true: Prohibit bitwise operators (&, |, ^, etc.) + "camelcase" : false, // true: Identifiers must be in camelCase + "curly" : true, // true: Require {} for every new block or scope + "eqeqeq" : true, // true: Require triple equals (===) for comparison + "freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc. + "forin" : false, // true: Require filtering for..in loops with obj.hasOwnProperty() + "immed" : true, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` + "latedef" : "nofunc", // true: Require variables/functions to be defined before being used + "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()` + "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` + "noempty" : true, // true: Prohibit use of empty blocks + "nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters. + "nonew" : true, // true: Prohibit use of constructors for side-effects (without assignment) + "plusplus" : false, // true: Prohibit use of `++` & `--` + "quotmark" : "single", // Quotation mark consistency: + // false : do nothing (default) + // true : ensure whatever is used is consistent + // "single" : require single quotes + // "double" : require double quotes + "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) + "unused" : "vars", // vars: Require all defined variables be used, ignore function params + "strict" : false, // true: Requires all functions run in ES5 Strict Mode + "maxparams" : false, // {int} Max number of formal params allowed per function + "maxdepth" : false, // {int} Max depth of nested blocks (within functions) + "maxstatements" : false, // {int} Max number statements per function + "maxcomplexity" : false, // {int} Max cyclomatic complexity per function + "maxlen" : false, // {int} Max number of characters per line + + // Relaxing + "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) + "boss" : false, // true: Tolerate assignments where comparisons would be expected + "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. + "eqnull" : false, // true: Tolerate use of `== null` + "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) + "esnext" : true, // true: Allow ES.next (ES6) syntax (ex: `const`) + "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) + // (ex: `for each`, multiple try/catch, function expression…) + "evil" : false, // true: Tolerate use of `eval` and `new Function()` + "expr" : false, // true: Tolerate `ExpressionStatement` as Programs + "funcscope" : false, // true: Tolerate defining variables inside control statements + "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') + "iterator" : false, // true: Tolerate using the `__iterator__` property + "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block + "laxbreak" : false, // true: Tolerate possibly unsafe line breakings + "laxcomma" : false, // true: Tolerate comma-first style coding + "loopfunc" : false, // true: Tolerate functions being defined in loops + "multistr" : false, // true: Tolerate multi-line strings + "noyield" : false, // true: Tolerate generator functions with no yield statement in them. + "notypeof" : false, // true: Tolerate invalid typeof operator values + "proto" : false, // true: Tolerate using the `__proto__` property + "scripturl" : false, // true: Tolerate script-targeted URLs + "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` + "sub" : true, // true: Tolerate using `[]` notation when it can still be expressed in dot notation + "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` + "validthis" : false, // true: Tolerate using this in a non-constructor function + + // Environments + "browser" : true, // Web Browser (window, document, etc) + "browserify" : true, // Browserify (node.js code in the browser) + "couch" : false, // CouchDB + "devel" : true, // Development/debugging (alert, confirm, etc) + "dojo" : false, // Dojo Toolkit + "jasmine" : false, // Jasmine + "jquery" : false, // jQuery + "mocha" : false, // Mocha + "mootools" : false, // MooTools + "node" : true, // Node.js + "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) + "prototypejs" : false, // Prototype and Scriptaculous + "qunit" : false, // QUnit + "rhino" : false, // Rhino + "shelljs" : false, // ShellJS + "worker" : false, // Web Workers + "wsh" : false, // Windows Scripting Host + "yui" : false, // Yahoo User Interface + + // Custom Globals + "globals" : { // additional predefined global variables + "WebSocket": true + } +} diff --git a/hyco-websocket/.npmignore b/hyco-websocket/.npmignore new file mode 100644 index 0000000..1ff9305 --- /dev/null +++ b/hyco-websocket/.npmignore @@ -0,0 +1,7 @@ +.npmignore +.gitignore + +example/ +build/ +test/ +node_modules/ diff --git a/hyco-websocket/CHANGELOG.md b/hyco-websocket/CHANGELOG.md new file mode 100644 index 0000000..38ff556 --- /dev/null +++ b/hyco-websocket/CHANGELOG.md @@ -0,0 +1,3 @@ +Changelog +========= + diff --git a/hyco-websocket/README.md b/hyco-websocket/README.md new file mode 100644 index 0000000..0eb0bb4 --- /dev/null +++ b/hyco-websocket/README.md @@ -0,0 +1,14 @@ +WebSocket Client & Server Implementation for Node +================================================= + +Overview +-------- + + +Documentation +============= + + + +Changelog +--------- diff --git a/hyco-websocket/examples/simple/listener.js b/hyco-websocket/examples/simple/listener.js new file mode 100644 index 0000000..b46c5d7 --- /dev/null +++ b/hyco-websocket/examples/simple/listener.js @@ -0,0 +1,41 @@ + +if ( process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('../../'); + var WebSocketServer = require('../../').server; + + var wss = new WebSocketServer( + { + server : WebSocket.createRelayListenUri(ns, path), + token: WebSocket.createRelayToken('http://'+ns, keyrule, key), + autoAcceptConnections : true + }); + wss.on('connect', + function (ws) { + console.log('connection accepted'); + ws.on('message', function (message) { + if (message.type === 'utf8') { + try { + console.log(JSON.parse(message.utf8Data)); + } + catch(e) { + // do nothing if there's an error. + } + } + }); + ws.on('close', function () { + console.log('connection closed'); + }); + }); + + wss.on('error', function(err) { + console.log('error' + err); + }); +} \ No newline at end of file diff --git a/hyco-websocket/examples/simple/package.json b/hyco-websocket/examples/simple/package.json new file mode 100644 index 0000000..750c422 --- /dev/null +++ b/hyco-websocket/examples/simple/package.json @@ -0,0 +1,11 @@ +{ + "author": "", + "name": "simple", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "git://github.com/clemensv/azure-hybridconnections.git" + }, + "devDependencies": {}, + "optionalDependencies": {} +} diff --git a/hyco-websocket/examples/simple/sender.js b/hyco-websocket/examples/simple/sender.js new file mode 100644 index 0000000..0fd2547 --- /dev/null +++ b/hyco-websocket/examples/simple/sender.js @@ -0,0 +1,38 @@ +if (process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('../../') + var WebSocketClient = WebSocket.client + + + var address = WebSocket.createRelaySendUri(ns, path); + var token = WebSocket.createRelayToken('http://'+ns, keyrule, key); + + var client = new WebSocketClient({tlsOptions: { rejectUnauthorized: false }}); + client.connect(address, null, null, { 'ServiceBusAuthorization' : token}); + + client.on('connect', function(connection){ + var id = setInterval(function () { + connection.send(JSON.stringify(process.memoryUsage()), function () { /* ignore errors */ }); + }, 100); + + console.log('Started client interval. Press any key to stop.'); + connection.on('close', function () { + console.log('stopping client interval'); + clearInterval(id); + process.exit(); + }); + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', function () { + connection.close(); + }); + }); +} \ No newline at end of file diff --git a/hyco-websocket/gulpfile.js b/hyco-websocket/gulpfile.js new file mode 100644 index 0000000..b515b92 --- /dev/null +++ b/hyco-websocket/gulpfile.js @@ -0,0 +1,14 @@ +/** + * Dependencies. + */ +var gulp = require('gulp'); +var jshint = require('gulp-jshint'); + +gulp.task('lint', function() { + return gulp.src(['gulpfile.js', 'lib/**/*.js', 'test/**/*.js']) + .pipe(jshint('.jshintrc')) + .pipe(jshint.reporter('jshint-stylish', {verbose: true})) + .pipe(jshint.reporter('fail')); +}); + +gulp.task('default', gulp.series('lint')); diff --git a/hyco-websocket/index.js b/hyco-websocket/index.js new file mode 100644 index 0000000..359aac8 --- /dev/null +++ b/hyco-websocket/index.js @@ -0,0 +1,38 @@ +'use strict'; + +var crypto = require('crypto') +var moment = require('moment') + +var WS = module.exports = require('./lib/hybridconnectionswebsocket'); + +WS.createRelayToken = function createRelayToken(uri, key_name, key) { + + // Token expires in one hour + var expiry = moment().add(1, 'hours').unix(); + + var string_to_sign = encodeURIComponent(uri) + '\n' + expiry; + var hmac = crypto.createHmac('sha256', key); + hmac.update(string_to_sign); + var signature = hmac.digest('base64'); + var token = 'SharedAccessSignature sr=' + encodeURIComponent(uri) + '&sig=' + encodeURIComponent(signature) + '&se=' + expiry + '&skn=' + key_name; + + return token; +}; + +WS.createRelaySendUri = function createRelaySendUri(serviceBusNamespace, path, token) +{ + var uri = 'wss://' + serviceBusNamespace + ':443/$servicebus/hybridconnection?action=connect&path=' + path; + if ( token != null ) { + uri = uri + '&token=' + encodeURIComponent(token); + } + return uri; +} + +WS.createRelayListenUri = function createRelayListenUri(serviceBusNamespace, path, token) +{ + var uri = 'wss://' + serviceBusNamespace + ':443/$servicebus/hybridconnection?action=listen&path=' + path; + if ( token != null ) { + uri = uri + '&token=' + encodeURIComponent(token); + } + return uri; +} \ No newline at end of file diff --git a/hyco-websocket/lib/HybridConnectionsWebSocketRequest.js b/hyco-websocket/lib/HybridConnectionsWebSocketRequest.js new file mode 100644 index 0000000..28c7ae5 --- /dev/null +++ b/hyco-websocket/lib/HybridConnectionsWebSocketRequest.js @@ -0,0 +1,240 @@ +/************************************************************************ + * Copyright 2010-2015 Brian McKelvey. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***********************************************************************/ + +var crypto = require('crypto'); +var util = require('util'); +var url = require('url'); +var EventEmitter = require('events').EventEmitter; +var WebSocketClient = require('websocket').client; +var WebSocketConnection = require('websocket').connection; +var wait = require('wait.for'); + +var headerValueSplitRegExp = /,\s*/; +var headerParamSplitRegExp = /;\s*/; +var headerSanitizeRegExp = /[\r\n]/g; +var xForwardedForSeparatorRegExp = /,\s*/; +var separators = [ + '(', ')', '<', '>', '@', + ',', ';', ':', '\\', '\"', + '/', '[', ']', '?', '=', + '{', '}', ' ', String.fromCharCode(9) +]; +var controlChars = [String.fromCharCode(127) /* DEL */]; +for (var i=0; i < 31; i ++) { + /* US-ASCII Control Characters */ + controlChars.push(String.fromCharCode(i)); +} + +var cookieNameValidateRegEx = /([\x00-\x20\x22\x28\x29\x2c\x2f\x3a-\x3f\x40\x5b-\x5e\x7b\x7d\x7f])/; +var cookieValueValidateRegEx = /[^\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]/; +var cookieValueDQuoteValidateRegEx = /^"[^"]*"$/; +var controlCharsAndSemicolonRegEx = /[\x00-\x20\x3b]/g; + +var cookieSeparatorRegEx = /[;,] */; + + + +function HybridConnectionsWebSocketRequest(resource, address, id, httpHeaders, serverConfig) { + // Superclass Constructor + EventEmitter.call(this); + + this.resource = resource; + this.address = address; + this.id = id; + this.httpRequest = { + headers : {} + }; + + for(var keys = Object.keys(httpHeaders), l = keys.length; l; --l) + { + this.httpRequest.headers[ keys[l-1].toLowerCase() ] = httpHeaders[ keys[l-1] ]; + } + + this.serverConfig = serverConfig; + this._resolved = false; +} + +util.inherits(HybridConnectionsWebSocketRequest, EventEmitter); + +HybridConnectionsWebSocketRequest.prototype.readHandshake = function() { + var self = this; + var request = this.httpRequest; + + // Decode URL + this.resourceURL = url.parse(this.resource, true); + + this.host = request.headers['host']; + if (!this.host) { + throw new Error('Client must provide a Host header.'); + } + + this.key = request.headers['sec-websocket-key']; + if (!this.key) { + throw new Error('Client must provide a value for Sec-WebSocket-Key.'); + } + + this.webSocketVersion = parseInt(request.headers['sec-websocket-version'], 10); + + if (!this.webSocketVersion || isNaN(this.webSocketVersion)) { + throw new Error('Client must provide a value for Sec-WebSocket-Version.'); + } + + + // Protocol is optional. + var protocolString = request.headers['sec-websocket-protocol']; + this.protocolFullCaseMap = {}; + this.requestedProtocols = []; + if (protocolString) { + var requestedProtocolsFullCase = protocolString.split(headerValueSplitRegExp); + requestedProtocolsFullCase.forEach(function(protocol) { + var lcProtocol = protocol.toLocaleLowerCase(); + self.requestedProtocols.push(lcProtocol); + self.protocolFullCaseMap[lcProtocol] = protocol; + }); + } + + if (!this.serverConfig.ignoreXForwardedFor && + request.headers['x-forwarded-for']) { + var immediatePeerIP = this.remoteAddress; + this.remoteAddresses = request.headers['x-forwarded-for'] + .split(xForwardedForSeparatorRegExp); + this.remoteAddresses.push(immediatePeerIP); + this.remoteAddress = this.remoteAddresses[0]; + } + + // Extensions are optional. + var extensionsString = request.headers['sec-websocket-extensions']; + this.requestedExtensions = this.parseExtensions(extensionsString); + + // Cookies are optional + var cookieString = request.headers['cookie']; + this.cookies = this.parseCookies(cookieString); +}; + +HybridConnectionsWebSocketRequest.prototype.parseExtensions = function(extensionsString) { + if (!extensionsString || extensionsString.length === 0) { + return []; + } + var extensions = extensionsString.toLocaleLowerCase().split(headerValueSplitRegExp); + extensions.forEach(function(extension, index, array) { + var params = extension.split(headerParamSplitRegExp); + var extensionName = params[0]; + var extensionParams = params.slice(1); + extensionParams.forEach(function(rawParam, index, array) { + var arr = rawParam.split('='); + var obj = { + name: arr[0], + value: arr[1] + }; + array.splice(index, 1, obj); + }); + var obj = { + name: extensionName, + params: extensionParams + }; + array.splice(index, 1, obj); + }); + return extensions; +}; + +// This function adapted from node-cookie +// https://github.com/shtylman/node-cookie +HybridConnectionsWebSocketRequest.prototype.parseCookies = function(str) { + // Sanity Check + if (!str || typeof(str) !== 'string') { + return []; + } + + var cookies = []; + var pairs = str.split(cookieSeparatorRegEx); + + pairs.forEach(function(pair) { + var eq_idx = pair.indexOf('='); + if (eq_idx === -1) { + cookies.push({ + name: pair, + value: null + }); + return; + } + + var key = pair.substr(0, eq_idx).trim(); + var val = pair.substr(++eq_idx, pair.length).trim(); + + // quoted values + if ('"' === val[0]) { + val = val.slice(1, -1); + } + + cookies.push({ + name: key, + value: decodeURIComponent(val) + }); + }); + + return cookies; +}; + +HybridConnectionsWebSocketRequest.prototype.accept = function(acceptedProtocol, allowedOrigin, cookies, callback) { + var req = this; + var client = new WebSocketClient({tlsOptions: { rejectUnauthorized: false }}); + client.connect(this.address, null, null); + client.on('connect', function(connection) { + req.emit('requestAccepted', connection); + callback(connection); + }); +}; + +HybridConnectionsWebSocketRequest.prototype.reject = function(status, reason, extraHeaders, callback) { + var req = this; + var client = new WebSocketClient({tlsOptions: { rejectUnauthorized: false }}); + var rejectUri = this.address + "&statusCode=" + status + "&statusDescription" + encodeURIComponent(reason); + + client.connect(rejectUri, null, null); + client.on('error', function(event){ + this.emit('requestRejected', this); + callback(event); + }); +}; + +HybridConnectionsWebSocketRequest.prototype._handleSocketCloseBeforeAccept = function() { + this._socketIsClosing = true; + this._removeSocketCloseListeners(); +}; + +HybridConnectionsWebSocketRequest.prototype._removeSocketCloseListeners = function() { + this.socket.removeListener('end', this._socketCloseHandler); + this.socket.removeListener('close', this._socketCloseHandler); +}; + +HybridConnectionsWebSocketRequest.prototype._verifyResolution = function() { + if (this._resolved) { + throw new Error('HybridConnectionsWebSocketRequest may only be accepted or rejected one time.'); + } +}; + +function cleanupFailedConnection(connection) { + // Since we have to return a connection object even if the socket is + // already dead in order not to break the API, we schedule a 'close' + // event on the connection object to occur immediately. + process.nextTick(function() { + // WebSocketConnection.CLOSE_REASON_ABNORMAL = 1006 + // Third param: Skip sending the close frame to a dead socket + connection.drop(1006, 'TCP connection lost before handshake completed.', true); + }); +} + +module.exports = HybridConnectionsWebSocketRequest; diff --git a/hyco-websocket/lib/HybridConnectionsWebSocketServer.js b/hyco-websocket/lib/HybridConnectionsWebSocketServer.js new file mode 100644 index 0000000..988a109 --- /dev/null +++ b/hyco-websocket/lib/HybridConnectionsWebSocketServer.js @@ -0,0 +1,269 @@ +/************************************************************************ + * Copyright 2010-2015 Brian McKelvey. + * Derivative Copyright Microsoft Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***********************************************************************/ + +var extend = require('./utils').extend; +var utils = require('./utils'); +var util = require('util'); +var EventEmitter = require('events').EventEmitter; +var WebSocketClient = require('websocket').client; +var WebSocketRequest = require('./HybridConnectionsWebSocketRequest'); + +var isDefinedAndNonNull = function (options, key) { + return typeof options[key] != 'undefined' && options[key] !== null; +}; + +var HybridConnectionsWebSocketServer = function HybridConnectionsWebSocketServer(config) { + // Superclass Constructor + EventEmitter.call(this); + + this._handlers = { + requestAccepted: this.handleRequestAccepted.bind(this), + requestResolved: this.handleRequestResolved.bind(this) + }; + this.pendingRequests = []; + this.connections = []; + if (config) { + this.open(config); + } +}; + +util.inherits(HybridConnectionsWebSocketServer, EventEmitter); + +HybridConnectionsWebSocketServer.prototype.open = function(config) { + this.config = { + // hybrid connection endpoint address + server: null, + // listen token + token: null, + // identifier + id: null, + + // If true, the server will automatically send a ping to all + // connections every 'keepaliveInterval' milliseconds. The timer is + // reset on any received data from the client. + keepalive: true, + + // The interval to send keepalive pings to connected clients if the + // connection is idle. Any received data will reset the counter. + keepaliveInterval: 20000, + + // If true, the server will consider any connection that has not + // received any data within the amount of time specified by + // 'keepaliveGracePeriod' after a keepalive ping has been sent to + // be dead, and will drop the connection. + // Ignored if keepalive is false. + dropConnectionOnKeepaliveTimeout: true, + + // The amount of time to wait after sending a keepalive ping before + // closing the connection if the connected peer does not respond. + // Ignored if keepalive is false. + keepaliveGracePeriod: 10000, + + // Whether to use native TCP keep-alive instead of WebSockets ping + // and pong packets. Native TCP keep-alive sends smaller packets + // on the wire and so uses bandwidth more efficiently. This may + // be more important when talking to mobile devices. + // If this value is set to true, then these values will be ignored: + // keepaliveGracePeriod + // dropConnectionOnKeepaliveTimeout + useNativeKeepalive: false, + + // If true, fragmented messages will be automatically assembled + // and the full message will be emitted via a 'message' event. + // If false, each frame will be emitted via a 'frame' event and + // the application will be responsible for aggregating multiple + // fragmented frames. Single-frame messages will emit a 'message' + // event in addition to the 'frame' event. + // Most users will want to leave this set to 'true' + assembleFragments: true, + + // If this is true, websocket connections will be accepted + // regardless of the path and protocol specified by the client. + // The protocol accepted will be the first that was requested + // by the client. Clients from any origin will be accepted. + // This should only be used in the simplest of cases. You should + // probably leave this set to 'false' and inspect the request + // object to make sure it's acceptable before accepting it. + autoAcceptConnections: false, + + // Whether or not the X-Forwarded-For header should be respected. + // It's important to set this to 'true' when accepting connections + // from untrusted connections, as a malicious client could spoof its + // IP address by simply setting this header. It's meant to be added + // by a trusted proxy or other intermediary within your own + // infrastructure. + // See: http://en.wikipedia.org/wiki/X-Forwarded-For + ignoreXForwardedFor: false, + + // The Nagle Algorithm makes more efficient use of network resources + // by introducing a small delay before sending small packets so that + // multiple messages can be batched together before going onto the + // wire. This however comes at the cost of latency, so the default + // is to disable it. If you don't need low latency and are streaming + // lots of small messages, you can change this to 'false' + disableNagleAlgorithm: true, + + // The number of milliseconds to wait after sending a close frame + // for an acknowledgement to come back before giving up and just + // closing the socket. + closeTimeout: 5000 + }; + extend(this.config, config); + + if (this.config.server) { + // connect + this.listenUri = config.server; + if (isDefinedAndNonNull(config, 'id')) { + this.listenUri = listenUri + '&id=' + config.id; + } + + connectControlChannel(this); + } + else { + throw new Error('You must specify an hybrid connections server address on which to open the WebSocket server.'); + } +}; + +HybridConnectionsWebSocketServer.prototype.closeAllConnections = function() { + this.connections.forEach(function(connection) { + connection.close(); + }); +}; + +HybridConnectionsWebSocketServer.prototype.broadcast = function(data) { + if (Buffer.isBuffer(data)) { + this.broadcastBytes(data); + } + else if (typeof(data.toString) === 'function') { + this.broadcastUTF(data); + } +}; + +HybridConnectionsWebSocketServer.prototype.broadcastUTF = function(utfData) { + this.connections.forEach(function(connection) { + connection.sendUTF(utfData); + }); +}; + +HybridConnectionsWebSocketServer.prototype.broadcastBytes = function(binaryData) { + this.connections.forEach(function(connection) { + connection.sendBytes(binaryData); + }); +}; + +HybridConnectionsWebSocketServer.prototype.shutDown = function() { + this.closeAllConnections(); +}; + +HybridConnectionsWebSocketServer.prototype.handleRequestAccepted = function(connection) { + var self = this; + connection.once('close', function(closeReason, description) { + self.handleConnectionClose(connection, closeReason, description); + }); + this.connections.push(connection); + this.emit('connect', connection); +}; + +HybridConnectionsWebSocketServer.prototype.handleRequestResolved = function(request) { + var index = this.pendingRequests.indexOf(request); + if (index !== -1) { this.pendingRequests.splice(index, 1); } +}; + +HybridConnectionsWebSocketServer.prototype.handleConnectionClose = function(closeReason, description) { + console.log(description); +} + +function connectControlChannel(server) { + /* create the control connection */ + + var headers = null; + if ( server.config.token != null) { + headers = { 'ServiceBusAuthorization' : server.config.token}; + }; + + + var client = new WebSocketClient(); + client.connect(server.listenUri, null, null, headers); + client.on('connect', function(connection) { + server.controlChannel = connection; + server.controlChannel.on('error', function (event) { + server.emit('error', event); + if (!closeRequested) { + connectControlChannel(server); + } + }); + + server.controlChannel.on('close', function(event) { + // reconnect + if (!closeRequested) + { + connectControlChannel(server); + } else { + server.emit('close', server); + } + + }); + server.controlChannel.on('message', function (message) { + if (message.type === 'utf8') { + try { + handleControl(server, JSON.parse(message.utf8Data)); + } + catch(e) { + // do nothing if there's an error. + } + } + }); + }); + client.on('connectFailed', function(event){}); +} + +function handleControl(server,message) { + if ( isDefinedAndNonNull(message, 'accept') ) { + var address = message.accept.address; + + var wsRequest = new WebSocketRequest(server.listenUri, message.accept.address, message.accept.id, message.accept.connectHeaders, server.config); + try { + wsRequest.readHandshake(); + } + catch(e) { + wsRequest.reject( + e.httpCode ? e.httpCode : 400, + e.message, + e.headers + ); + debug('Invalid handshake: %s', e.message); + return; + } + + server.pendingRequests.push(wsRequest); + + wsRequest.once('requestAccepted', server._handlers.requestAccepted); + wsRequest.once('requestResolved', server._handlers.requestResolved); + + if (!server.config.autoAcceptConnections && utils.eventEmitterListenerCount(server, 'request') > 0) { + server.emit('request', wsRequest); + } + else if (server.config.autoAcceptConnections) { + wsRequest.accept(wsRequest.requestedProtocols[0], wsRequest.origin); + } + else { + wsRequest.reject(404, 'No handler is configured to accept the connection.'); + } + } +} + +module.exports = HybridConnectionsWebSocketServer; diff --git a/hyco-websocket/lib/hybridconnectionswebsocket.js b/hyco-websocket/lib/hybridconnectionswebsocket.js new file mode 100644 index 0000000..f2ae4ba --- /dev/null +++ b/hyco-websocket/lib/hybridconnectionswebsocket.js @@ -0,0 +1,13 @@ +var websocket = require('websocket') + +module.exports = { + 'server' : require('./HybridConnectionsWebSocketServer'), + 'client' : websocket.client, + 'router' : websocket.router, + 'frame' : websocket.frame, + 'request' : require('./HybridConnectionsWebSocketRequest'), + 'connection' : websocket.connection, + 'w3cwebsocket' : websocket.w3cwebsocket, + 'deprecation' : websocket.deprecation, + 'version' : require('./version') +}; diff --git a/hyco-websocket/lib/utils.js b/hyco-websocket/lib/utils.js new file mode 100644 index 0000000..6506dc9 --- /dev/null +++ b/hyco-websocket/lib/utils.js @@ -0,0 +1,60 @@ +var noop = exports.noop = function(){}; + +exports.extend = function extend(dest, source) { + for (var prop in source) { + dest[prop] = source[prop]; + } +}; + +exports.eventEmitterListenerCount = + require('events').EventEmitter.listenerCount || + function(emitter, type) { return emitter.listeners(type).length; }; + + + + + +exports.BufferingLogger = function createBufferingLogger(identifier, uniqueID) { + var logFunction = require('debug')(identifier); + if (logFunction.enabled) { + var logger = new BufferingLogger(identifier, uniqueID, logFunction); + var debug = logger.log.bind(logger); + debug.printOutput = logger.printOutput.bind(logger); + debug.enabled = logFunction.enabled; + return debug; + } + logFunction.printOutput = noop; + return logFunction; +}; + +function BufferingLogger(identifier, uniqueID, logFunction) { + this.logFunction = logFunction; + this.identifier = identifier; + this.uniqueID = uniqueID; + this.buffer = []; +} + +BufferingLogger.prototype.log = function() { + this.buffer.push([ new Date(), Array.prototype.slice.call(arguments) ]); + return this; +}; + +BufferingLogger.prototype.clear = function() { + this.buffer = []; + return this; +}; + +BufferingLogger.prototype.printOutput = function(logFunction) { + if (!logFunction) { logFunction = this.logFunction; } + var uniqueID = this.uniqueID; + this.buffer.forEach(function(entry) { + var date = entry[0].toLocaleString(); + var args = entry[1].slice(); + var formatString = args[0]; + if (formatString !== (void 0) && formatString !== null) { + formatString = '%s - %s - ' + formatString.toString(); + args.splice(0, 1, formatString, date, uniqueID); + logFunction.apply(global, args); + } + }); +}; diff --git a/hyco-websocket/lib/version.js b/hyco-websocket/lib/version.js new file mode 100644 index 0000000..81f6e78 --- /dev/null +++ b/hyco-websocket/lib/version.js @@ -0,0 +1 @@ +module.exports = require('../package.json').version; diff --git a/hyco-websocket/package.json b/hyco-websocket/package.json new file mode 100644 index 0000000..fbde90a --- /dev/null +++ b/hyco-websocket/package.json @@ -0,0 +1,45 @@ +{ + "name": "hyco-websocket", + "description": "", + "keywords": [ + "websocket", + "websockets", + "socket", + "networking", + "comet", + "push", + "RFC-6455", + "realtime", + "server", + "client" + ], + "author": "", + "version": "1.0.0", + "repository": { + "type": "git", + "url": "https://github.com/clemensv/azure-relay-hybridconnections.git" + }, + "homepage": "https://azure.com", + "engines": { + "node": ">=0.8.0" + }, + "dependencies": { + "wait.for" : "latest", + "websocket": "latest", + "crypto": "latest", + "moment": "latest" + }, + "devDependencies": { + }, + "config": { + "verbose": false + }, + "scripts": { + + }, + "main": "index", + "directories": { + "lib": "./lib" + }, + "license": "Apache-2.0" +} diff --git a/hyco-ws/.gitignore b/hyco-ws/.gitignore new file mode 100644 index 0000000..182c7b9 --- /dev/null +++ b/hyco-ws/.gitignore @@ -0,0 +1,8 @@ +npm-debug.log +node_modules +.*.swp +.lock-* +build +coverage + +builderror.log diff --git a/hyco-ws/.npmignore b/hyco-ws/.npmignore new file mode 100644 index 0000000..1ff9305 --- /dev/null +++ b/hyco-ws/.npmignore @@ -0,0 +1,7 @@ +.npmignore +.gitignore + +example/ +build/ +test/ +node_modules/ diff --git a/hyco-ws/examples/serverstats/package.json b/hyco-ws/examples/serverstats/package.json new file mode 100644 index 0000000..b0e283f --- /dev/null +++ b/hyco-ws/examples/serverstats/package.json @@ -0,0 +1,19 @@ +{ + "author": "", + "name": "serverstats", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "git://github.com/clemensv/azure-hybridconnections.git" + }, + "engines": { + "node": ">0.4.0" + }, + "dependencies": { + "hyco-ws": "latest", + "express": "4.x", + "mustache-express" : "latest" + }, + "devDependencies": {}, + "optionalDependencies": {} +} diff --git a/hyco-ws/examples/serverstats/public/index.html b/hyco-ws/examples/serverstats/public/index.html new file mode 100644 index 0000000..eb3e062 --- /dev/null +++ b/hyco-ws/examples/serverstats/public/index.html @@ -0,0 +1,33 @@ + + + + + + + + Server Stats
+ RSS:

+ Heap total:

+ Heap used:

+ + diff --git a/hyco-ws/examples/serverstats/server.js b/hyco-ws/examples/serverstats/server.js new file mode 100644 index 0000000..7cd9d5f --- /dev/null +++ b/hyco-ws/examples/serverstats/server.js @@ -0,0 +1,37 @@ +if ( process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + var WebSocket = require('hyco-ws') + , http = require('http') + , express = require('express') + , mustache = require('mustache-express') + , app = express(); + + var _ns = process.argv[2]; + var _path = process.argv[3]; + var _keyrule = process.argv[4]; + var _key = process.argv[5]; + + app.engine('html', mustache()); + app.set('view engine', 'mustache'); + app.set('views', __dirname + '/public'); + app.get('/', function (req, res) { + res.render('index.html', { ns : _ns, path : _path, token : encodeURIComponent(WebSocket.createRelayToken('http://'+_ns, _keyrule, _key)) }); + }); + app.listen(8080); + + WebSocket.createRelayedServer( + { + server : WebSocket.createRelayListenUri(_ns, _path), + token: WebSocket.createRelayToken('http://'+_ns, _keyrule, _key) + }, function(ws) { + var id = setInterval(function() { + ws.send(JSON.stringify(process.memoryUsage()), function() { /* ignore errors */ }); + }, 100); + console.log('started client interval'); + ws.on('close', function() { + console.log('stopping client interval'); + clearInterval(id); + }); + }); +} \ No newline at end of file diff --git a/hyco-ws/examples/simple-externalpkg/listener.js b/hyco-ws/examples/simple-externalpkg/listener.js new file mode 100644 index 0000000..4ecd34f --- /dev/null +++ b/hyco-ws/examples/simple-externalpkg/listener.js @@ -0,0 +1,31 @@ + +if ( process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('hyco-ws') + + var wss = WebSocket.createRelayedServer( + { + server : WebSocket.createRelayListenUri(ns, path), + token: WebSocket.createRelayToken('http://'+ns, keyrule, key) + }, + function (ws) { + console.log('connection accepted'); + ws.onmessage = function (event) { + console.log(JSON.parse(event.data)); + }; + ws.on('close', function () { + console.log('connection closed'); + }); + }); + + wss.on('error', function(err) { + console.log('error' + err); + }); +} \ No newline at end of file diff --git a/hyco-ws/examples/simple-externalpkg/package.json b/hyco-ws/examples/simple-externalpkg/package.json new file mode 100644 index 0000000..ff40e14 --- /dev/null +++ b/hyco-ws/examples/simple-externalpkg/package.json @@ -0,0 +1,14 @@ +{ + "author": "", + "name": "simple-externalpkg", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "git://github.com/clemensv/azure-hybridconnections.git" + }, + "dependencies": { + "hyco-ws": "latest" + }, + "devDependencies": {}, + "optionalDependencies": {} +} diff --git a/hyco-ws/examples/simple-externalpkg/sender.js b/hyco-ws/examples/simple-externalpkg/sender.js new file mode 100644 index 0000000..6012dd7 --- /dev/null +++ b/hyco-ws/examples/simple-externalpkg/sender.js @@ -0,0 +1,34 @@ +if (process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('hyco-ws') + + WebSocket.relayedConnect( + WebSocket.createRelaySendUri(ns, path), + WebSocket.createRelayToken('http://'+ns, keyrule, key), + function (wss) { + var id = setInterval(function () { + wss.send(JSON.stringify(process.memoryUsage()), function () { /* ignore errors */ }); + }, 100); + + console.log('Started client interval. Press any key to stop.'); + wss.on('close', function () { + console.log('stopping client interval'); + clearInterval(id); + process.exit(); + }); + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', function () { + wss.close(); + }); + } + ); +} \ No newline at end of file diff --git a/hyco-ws/examples/simple/listener.js b/hyco-ws/examples/simple/listener.js new file mode 100644 index 0000000..3cb1dd3 --- /dev/null +++ b/hyco-ws/examples/simple/listener.js @@ -0,0 +1,31 @@ + +if ( process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('../../') + + var wss = WebSocket.createRelayedServer( + { + server : WebSocket.createRelayListenUri(ns, path), + token: WebSocket.createRelayToken('http://'+ns, keyrule, key) + }, + function (ws) { + console.log('connection accepted'); + ws.onmessage = function (event) { + console.log(JSON.parse(event.data)); + }; + ws.on('close', function () { + console.log('connection closed'); + }); + }); + + wss.on('error', function(err) { + console.log('error' + err); + }); +} \ No newline at end of file diff --git a/hyco-ws/examples/simple/package.json b/hyco-ws/examples/simple/package.json new file mode 100644 index 0000000..750c422 --- /dev/null +++ b/hyco-ws/examples/simple/package.json @@ -0,0 +1,11 @@ +{ + "author": "", + "name": "simple", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "git://github.com/clemensv/azure-hybridconnections.git" + }, + "devDependencies": {}, + "optionalDependencies": {} +} diff --git a/hyco-ws/examples/simple/sender.js b/hyco-ws/examples/simple/sender.js new file mode 100644 index 0000000..763d1f9 --- /dev/null +++ b/hyco-ws/examples/simple/sender.js @@ -0,0 +1,34 @@ +if (process.argv.length < 6) { + console.log("listener.js [namespace] [path] [key-rule] [key]"); +} else { + + var ns = process.argv[2]; + var path = process.argv[3]; + var keyrule = process.argv[4]; + var key = process.argv[5]; + + var WebSocket = require('../../') + + WebSocket.relayedConnect( + WebSocket.createRelaySendUri(ns, path), + WebSocket.createRelayToken('http://'+ns, keyrule, key), + function (wss) { + var id = setInterval(function () { + wss.send(JSON.stringify(process.memoryUsage()), function () { /* ignore errors */ }); + }, 100); + + console.log('Started client interval. Press any key to stop.'); + wss.on('close', function () { + console.log('stopping client interval'); + clearInterval(id); + process.exit(); + }); + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', function () { + wss.close(); + }); + } + ); +} \ No newline at end of file diff --git a/hyco-ws/index.js b/hyco-ws/index.js new file mode 100644 index 0000000..69aa75e --- /dev/null +++ b/hyco-ws/index.js @@ -0,0 +1,88 @@ +'use strict'; + +var crypto = require('crypto') +var moment = require('moment') + +/*! + * Adapted from + * ws: a node.js websocket client + * Copyright(c) 2011 Einar Otto Stangvik + * MIT Licensed + * + */ + +var WS = module.exports = require('ws'); + +WS.RelayedServer = require('./lib/HybridConnectionWebSocketServer'); + +/** + * Create a new WebSocket server. + * + * @param {Object} options Server options + * @param {Function} fn Optional connection listener. + * @returns {WS.Server} + * @api public + */ +WS.createRelayedServer = function createRelayedServer(options, fn) { + var server = new WS.RelayedServer(options); + + if (typeof fn === 'function') { + server.on('connection', fn); + } + + return server; +}; + +/** + * Create a new WebSocket connection. + * + * @param {String} address The URL/address we need to connect to. + * @param {Function} fn Open listener. + * @returns {WS} + * @api public + */ +WS.relayedConnect = WS.createRelayedConnection = function relayedConnect(address, token, fn) { + var opt = null; + if ( token != null) { + opt = { headers : { 'ServiceBusAuthorization' : token}}; + }; + var client = new WS(address, null, opt); + + if (typeof fn === 'function') { + client.on('open', function() { fn(client) }); + } + + return client; +}; + +WS.createRelayToken = function createRelayToken(uri, key_name, key) { + + // Token expires in one hour + var expiry = moment().add(1, 'hours').unix(); + + var string_to_sign = encodeURIComponent(uri) + '\n' + expiry; + var hmac = crypto.createHmac('sha256', key); + hmac.update(string_to_sign); + var signature = hmac.digest('base64'); + var token = 'SharedAccessSignature sr=' + encodeURIComponent(uri) + '&sig=' + encodeURIComponent(signature) + '&se=' + expiry + '&skn=' + key_name; + + return token; +}; + +WS.createRelaySendUri = function createRelaySendUri(serviceBusNamespace, path, token) +{ + var uri = 'wss://' + serviceBusNamespace + ':443/$servicebus/hybridconnection?action=connect&path=' + path; + if ( token != null ) { + uri = uri + '&token=' + encodeURIComponent(token); + } + return uri; +} + +WS.createRelayListenUri = function createRelayListenUri(serviceBusNamespace, path, token) +{ + var uri = 'wss://' + serviceBusNamespace + ':443/$servicebus/hybridconnection?action=listen&path=' + path; + if ( token != null ) { + uri = uri + '&token=' + encodeURIComponent(token); + } + return uri; +} \ No newline at end of file diff --git a/hyco-ws/lib/HybridConnectionWebSocketServer.js b/hyco-ws/lib/HybridConnectionWebSocketServer.js new file mode 100644 index 0000000..27f0211 --- /dev/null +++ b/hyco-ws/lib/HybridConnectionWebSocketServer.js @@ -0,0 +1,267 @@ +'use strict'; + +const util = require('util'); +const EventEmitter = require('events'); +const http = require('http'); +const crypto = require('crypto'); +const WebSocket = require('ws'); +const url = require('url'); + +// slightly awful workaround to pull submodules +var wsc = require.cache[require.resolve('ws')] +const Extensions = wsc.require('./lib/Extensions'); +const PerMessageDeflate = wsc.require('./lib/PerMessageDeflate'); + + +var isDefinedAndNonNull = function (options, key) { + return typeof options[key] != 'undefined' && options[key] !== null; +}; + +/** + * WebSocket Server implementation + */ + +function HybridConnectionsWebSocketServer(options, callback) { + if (this instanceof HybridConnectionsWebSocketServer === false) { + return new HybridConnectionsWebSocketServer(options, callback); + } + + EventEmitter.call(this); + + options = Object.assign({ + server: null, + token: null, + id: null, + verifyClient: null, + handleProtocols: null, + disableHixie: false, + clientTracking: true, + perMessageDeflate: true, + maxPayload: 100 * 1024 * 1024, + backlog: null // use default (511 as implemented in net.js) + }, options); + + if (!isDefinedAndNonNull(options, 'server')) { + throw new TypeError('\'server\' must be provided'); + } + + var self = this; + + this.listenUri = options.server; + if (isDefinedAndNonNull(options, 'id')) { + this.listenUri = listenUri + '&id=' + options.id; + } + + this.closeRequested = false; + this.options = options; + this.path = options.path; + this.clients = []; + + connectControlChannel(this); +} + + + +/** + * Inherits from EventEmitter. + */ + +util.inherits(HybridConnectionsWebSocketServer, EventEmitter); + + + +/** + * Immediately shuts down the connection. + * + * @api public + */ + +HybridConnectionsWebSocketServer.prototype.close = function (callback) { + this.closeRequested = true; + // terminate all associated clients + var error = null; + try { + for (var i = 0, l = this.clients.length; i < l; ++i) { + this.clients[i].close(); + } + this.controlChannel.close(); + } + catch (e) { + error = e; + } + + if (callback) + callback(error); + else if (error) + throw error; +} + + +function connectControlChannel(server) { + /* create the control connection */ + + var opt = null; + if (server.options.token != null) { + opt = { headers: { 'ServiceBusAuthorization': server.options.token } }; + }; + server.controlChannel = new WebSocket(server.listenUri, null, opt); + + server.controlChannel.onerror = function (event) { + server.emit('error', event); + if (!server.closeRequested) { + connectControlChannel(server); + } + } + + server.controlChannel.onopen = function (event) { + server.emit('listening'); + } + + server.controlChannel.onclose = function (event) { + // reconnect + if (!server.closeRequested) { + connectControlChannel(server); + } else { + server.emit('close', server); + } + + } + server.controlChannel.onmessage = function (event) { + var message = JSON.parse(event.data); + if (isDefinedAndNonNull(message, 'accept')) { + accept(server, message); + } + }; +} + +function accept(server, message) { + var address = message.accept.address; + var req = { headers: {} }; + var headers = []; + + for (var keys = Object.keys(message.accept.connectHeaders), l = keys.length; l; --l) { + req.headers[keys[l - 1].toLowerCase()] = message.accept.connectHeaders[keys[l - 1]]; + } + // verify key presence + if (!req.headers['sec-websocket-key']) { + abortConnection(message, 400, 'Bad Request'); + return; + } + + // verify version + var version = parseInt(req.headers['sec-websocket-version']); + // verify protocol + var protocols = req.headers['sec-websocket-protocol']; + + // verify client + var origin = version < 13 ? + req.headers['sec-websocket-origin'] : + req.headers['origin']; + + // handle extensions offer + var extensionsOffer = Extensions.parse(req.headers['sec-websocket-extensions']); + + // handler to call when the connection sequence completes + var self = server; + var completeHybiUpgrade2 = function (protocol) { + + if (typeof protocol != 'undefined') { + headers.push('Sec-WebSocket-Protocol: ' + protocol); + } + + var extensions = {}; + try { + extensions = acceptExtensions.call(self, extensionsOffer); + } catch (err) { + abortConnection(message, 400, 'Bad Request'); + return; + } + + if (Object.keys(extensions).length) { + var serverExtensions = {}; + Object.keys(extensions).forEach(function (token) { + serverExtensions[token] = [extensions[token].params] + }); + headers.push('Sec-WebSocket-Extensions: ' + Extensions.format(serverExtensions)); + } + + // allows external modification/inspection of handshake headers + self.emit('headers', headers); + + var client = new WebSocket(address, null, { rejectUnauthorized: false, headers: headers }); + server.clients.push(client); + + client.on('close', function () { + var index = server.clients.indexOf(client); + if (index != -1) { + server.clients.splice(index, 1); + } + }); + + server.emit('connection', client); + if (self.options.clientTracking) { + self.clients.push(client); + client.on('close', function () { + var index = self.clients.indexOf(client); + if (index != -1) { + self.clients.splice(index, 1); + } + }); + } + } + + // optionally call external protocol selection handler before + // calling completeHybiUpgrade2 + var completeHybiUpgrade1 = function () { + // choose from the sub-protocols + if (typeof self.options.handleProtocols == 'function') { + var protList = (protocols || '').split(/, */); + var callbackCalled = false; + self.options.handleProtocols(protList, function (result, protocol) { + callbackCalled = true; + if (!result) abortConnection(socket, 401, 'Unauthorized'); + else completeHybiUpgrade2(protocol); + }); + if (!callbackCalled) { + // the handleProtocols handler never called our callback + abortConnection(socket, 501, 'Could not process protocols'); + } + return; + } else { + if (typeof protocols !== 'undefined') { + completeHybiUpgrade2(protocols.split(/, */)[0]); + } + else { + completeHybiUpgrade2(); + } + } + } + + completeHybiUpgrade1(); +} + +function acceptExtensions(offer) { + var extensions = {}; + var options = this.options.perMessageDeflate; + var maxPayload = this.options.maxPayload; + if (options && offer[PerMessageDeflate.extensionName]) { + var perMessageDeflate = new PerMessageDeflate(options !== true ? options : {}, true, maxPayload); + perMessageDeflate.accept(offer[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + return extensions; +} + +function abortConnection(message, status, reason) { + + var client = new WebSocketClient({ tlsOptions: { rejectUnauthorized: false } }); + var rejectUri = message.address + "&statusCode=" + status + "&statusDescription" + encodeURIComponent(reason); + + client.connect(rejectUri, null, null); + client.on('error', function (connection) { + this.emit('requestRejected', this); + }); +} + + +module.exports = HybridConnectionsWebSocketServer \ No newline at end of file diff --git a/hyco-ws/package.json b/hyco-ws/package.json new file mode 100644 index 0000000..388b15d --- /dev/null +++ b/hyco-ws/package.json @@ -0,0 +1,29 @@ +{ + "author": "xyz", + "name": "hyco-ws", + "description": "xyz", + "version": "1.0.0", + "license": "MIT", + "main": "index.js", + "keywords": [ + "Hixie", + "HyBi", + "Push", + "RFC-6455", + "WebSocket", + "WebSockets", + "real-time" + ], + "repository" : + { + "type" : "git", + "url" : "https://github.com/clemensv/azure-relay-hybridconnections.git" + }, + "dependencies": { + "ws": "1.1.x", + "crypto": "latest", + "moment": "latest" + }, + "gypfile": true, + "private" : false +}