├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── example.js ├── index.js ├── lib └── consul.js ├── package.json └── test └── consul.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | charset = utf-8 13 | indent_style = tabs 14 | indent_size = 2 15 | end_of_line = lf 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.sh] 20 | insert_final_newline = false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | test/.tmp 5 | *.nar 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/.tmp 2 | *.nar 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "iojs" 5 | - "iojs-v1.6.0" 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Tomas Aparicio and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rocky-consul [![Build Status](https://api.travis-ci.org/h2non/rocky-consul.svg?branch=master&style=flat)](https://travis-ci.org/h2non/rocky-consul) [![NPM](https://img.shields.io/npm/v/rocky-consul.svg)](https://www.npmjs.org/package/rocky-consul) 2 | 3 | [rocky](https://github.com/h2non/rocky) middleware to easily setup a reverse HTTP proxy with service discovery and load balancer using [Consul](https://consul.io). 4 | 5 | Essentially, this middleware will ask to Consul on every interval (configurable) to retrieve a list of URLs of a specific service (e.g: API, CDN, storage), and then them will be provided to `rocky` in order to balance the incoming HTTP traffic between those URLs. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
Nameconsul
Rocky+0.2
Scopeglobal, route
Typeforward / balance
21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install rocky-consul --save 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```js 31 | var rocky = require('rocky') 32 | var consul = require('rocky-consul') 33 | 34 | var proxy = rocky() 35 | ``` 36 | 37 | Plug in as global middleware 38 | ```js 39 | proxy.use(consul({ 40 | // Servers refresh interval (default to 60000) 41 | interval: 60 * 5 * 1000, 42 | // App service name (required) 43 | service: 'web', 44 | // Use a custom datacenter (optional) 45 | datacenter: 'ams2', 46 | // Consul servers pool 47 | servers: [ 48 | 'http://demo.consul.io', 49 | 'http://demo.consul.io' 50 | ] 51 | })) 52 | 53 | // Handle all the traffic 54 | proxy.all('/*') 55 | 56 | proxy.listen(3000) 57 | console.log('Rocky server started') 58 | ``` 59 | 60 | Plug in as route level middleware 61 | ```js 62 | proxy 63 | .get('/download/:id') 64 | .use(consul({ 65 | // Servers refresh interval (default to 60000) 66 | interval: 60 * 5 * 1000, 67 | // App service name (required) 68 | service: 'web', 69 | // Use a custom datacenter (optional) 70 | datacenter: 'ams2', 71 | // Consul servers pool 72 | servers: [ 73 | 'http://demo.consul.io', 74 | 'http://demo.consul.io' 75 | ] 76 | })) 77 | 78 | // Handle the rest of the traffic without using Consul 79 | proxy.all('/*') 80 | .forward('http://my.server') 81 | .replay('http://old.server') 82 | 83 | proxy.listen(3000) 84 | console.log('Rocky server started') 85 | ``` 86 | 87 | ## API 88 | 89 | ### consul(options) `=>` Function(req, res, next) 90 | 91 | Return a middleware `function` with the Consul client as static property `function.consul`. 92 | 93 | #### Options 94 | 95 | - **service** `string` - Consul service. Required 96 | - **servers** `array` - List of Consul servers URLs. Required 97 | - **datacenter** `string` - Custom datacenter to use. If not defined the default one will be used 98 | - **tag** `string` - Use a specific tag for the service 99 | - **defaultServers** `array` - Optional list of default target servers to balance. This avoid asking Consul the first time. 100 | - **protocol** `string` - Transport URI protocol. Default to `http` 101 | - **timeout** `number` - Consul server timeout in miliseconds. Default to `5000` = 5 seconds 102 | - **interval** `number` - Consul servers update interval in miliseconds. Default to `120000` = 2 minutes 103 | - **headers** `object` - Map of key-value headers to send to Consul 104 | - **auth** `string` - Basic authentication for Consul. E.g: `user:p@s$` 105 | - **onRequest** `function` - Executes this function before sending a request to Consul server. Passed arguments are: `httpOpts` 106 | - **onUpdate** `function` - Executes this function on every servers update. Passed arguments are: `err, servers` 107 | - **onResponse** `function` - Executes this function on every Consul server response. Passed arguments are: `err, servers, res` 108 | 109 | ### Consul(options) 110 | 111 | Internally used micro Consul client interface. 112 | 113 | #### consul#servers(cb) 114 | 115 | Returns the Consul servers for the given service. 116 | Passed arguments to the callback are: `cb(err, servers)`. 117 | 118 | #### consul#update(cb) 119 | 120 | Perform the servers update asking to Consul 121 | Passed arguments to the callback are: `cb(err, servers)`. 122 | 123 | #### consul#startInterval() 124 | 125 | Start the servers update interval as recurrent job for the given miliseconds defined at `options.interval`. 126 | You should not call this method unless you already called `stopInterval()`. 127 | 128 | #### consul#stopInterval() 129 | 130 | Stop server update interval process. 131 | 132 | ## License 133 | 134 | MIT - Tomas Aparicio 135 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var rocky = require('rocky') 2 | var consul = require('./') 3 | 4 | var proxy = rocky() 5 | 6 | // Plug in the middleware 7 | proxy.use(consul({ 8 | // Servers refresh interval 9 | interval: 10000, 10 | // App service name (required) 11 | service: 'web', 12 | // Use a custom datacenter (optional) 13 | datacenter: 'ams2', 14 | // Consul servers pool 15 | servers: [ 16 | 'http://demo.consul.io', 17 | 'http://demo.consul.io' 18 | ] 19 | })) 20 | 21 | // Plugin another middleware at route level only 22 | var route = proxy.get('/download') 23 | 24 | route.use(consul({ 25 | // Servers refresh interval 26 | interval: 10000, 27 | // App service name (required) 28 | service: 'web', 29 | // Use a custom datacenter (optional) 30 | datacenter: 'ams2', 31 | // Consul servers pool 32 | servers: [ 33 | 'http://demo.consul.io', 34 | 'http://demo.consul.io' 35 | ] 36 | })) 37 | 38 | // Handle all the traffic 39 | proxy.get('/*') 40 | 41 | proxy.listen(3000) 42 | 43 | console.log('Rocky server listening on port: ' + 3000) 44 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const assign = require('object-assign') 2 | const Consul = require('./lib/consul') 3 | 4 | const interval = 60 * 2 * 1000 // 2 minutes 5 | 6 | module.exports = exports = function (params) { 7 | var defaults = { interval: interval } 8 | var opts = assign(defaults, params) 9 | var consul = new Consul(opts) 10 | 11 | function middleware(req, res, next) { 12 | consul.servers(function (err, urls) { 13 | if (err || !urls || !urls.length) { 14 | return proxyError(res) 15 | } 16 | 17 | // Expose to rocky the URLs to balance 18 | req.rocky.options.balance = urls 19 | 20 | // Continue with next middleware 21 | next() 22 | }) 23 | } 24 | 25 | // Expose the Consul client instance 26 | middleware.consul = consul 27 | 28 | return middleware 29 | } 30 | 31 | // Expose Consul client 32 | exports.Consul = Consul 33 | 34 | function proxyError(res) { 35 | if (res.headersSent) return 36 | var message = 'Proxy error: cannot retrieve servers from Consul' 37 | res.writeHead(502, {'Content-Type': 'application/json'}) 38 | res.end(JSON.stringify({ message: message })) 39 | } 40 | -------------------------------------------------------------------------------- /lib/consul.js: -------------------------------------------------------------------------------- 1 | const request = require('got') 2 | const parseUrl = require('url').parse 3 | 4 | const consulBasePath = '/v1/catalog/service/' 5 | const requiredParams = ['servers', 'service'] 6 | 7 | module.exports = Consul 8 | 9 | function Consul(opts) { 10 | this.opts = opts 11 | this.urls = opts.defaultServers || [] 12 | 13 | requiredParams.forEach(function (param) { 14 | if (!opts[param]) throw new TypeError('Missing required param: ' + param) 15 | }) 16 | 17 | this.updating = false 18 | this.startInterval() 19 | } 20 | 21 | Consul.prototype.servers = function (cb) { 22 | if (this.urls.length) { 23 | return cb(null, this.urls) 24 | } 25 | this.update(cb) 26 | } 27 | 28 | Consul.prototype.update = function (cb) { 29 | var url = permute(this.opts.servers) 30 | cb = cb || function () {} 31 | 32 | this.updating = true 33 | this.request(url, function (err, servers) { 34 | this.updating = false 35 | 36 | if (err || !Array.isArray(servers)) { 37 | return cb(err) 38 | } 39 | 40 | var urls = mapServers(servers, this.opts) 41 | if (!urls || !urls.length) { 42 | return cb(null, this.urls) 43 | } 44 | 45 | this.urls = urls 46 | if (this.opts.onUpdate) { 47 | this.opts.onUpdate(err, urls) 48 | } 49 | 50 | cb(null, urls) 51 | }.bind(this)) 52 | } 53 | 54 | Consul.prototype.startInterval = function () { 55 | this.interval = setInterval(function () { 56 | if (!this.updating) this.update() 57 | }.bind(this), this.opts.interval) 58 | } 59 | 60 | Consul.prototype.stopInterval = function () { 61 | if (this.interval) { 62 | clearInterval(this.interval) 63 | } 64 | this.interval = null 65 | } 66 | 67 | Consul.prototype.request = function (url, done) { 68 | var opts = this.opts 69 | var timeout = +opts.timeout || 5000 70 | var path = consulBasePath + opts.service 71 | var targetUrl = url + path 72 | 73 | var query = {} 74 | if (opts.tag) { 75 | query.tag = opts.tag 76 | } 77 | if (opts.datacenter) { 78 | query.datacenter = opts.datacenter 79 | } 80 | 81 | var httpOpts = { 82 | url: targetUrl, 83 | query: query, 84 | timeout: timeout, 85 | auth: opts.auth, 86 | headers: opts.headers 87 | } 88 | 89 | if (this.opts.onRequest) { 90 | this.opts.onRequest(httpOpts) 91 | } 92 | 93 | var handler = responseHandler(done).bind(this) 94 | request(httpOpts.url, httpOpts, handler) 95 | } 96 | 97 | function responseHandler(done) { 98 | return function (err, data, res) { 99 | if (this.opts.onResponse) { 100 | this.opts.onResponse(err, data, res) 101 | } 102 | 103 | if (err || res.statusCode >= 400 || !data) { 104 | return done(err || 'Invalid response') 105 | } 106 | 107 | done(null, JSON.parse(data)) 108 | } 109 | } 110 | 111 | function mapServers(list, opts) { 112 | var protocol = opts.protocol || 'http' 113 | var port = protocol === 'https' ? 443 : 80 114 | 115 | return list 116 | .filter(function (s) { 117 | return s && s.Address 118 | }) 119 | .map(function (s) { 120 | if (s.ServiceAddress) { 121 | return protocol + '://' + s.ServiceAddress + ':' + (+s.ServicePort || port) 122 | } 123 | return protocol + '://' + s.Address + ':' + (+s.ServicePort || port) 124 | }) 125 | } 126 | 127 | function permute(arr) { 128 | var item = arr.shift() 129 | arr.push(item) 130 | return item 131 | } 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rocky-consul", 3 | "version": "0.1.1", 4 | "description": "Rocky HTTP proxy middleware for service discovery and balancing using Consul", 5 | "repository": "h2non/rocky-consul", 6 | "author": "Tomas Aparicio", 7 | "license": "MIT", 8 | "keywords": [ 9 | "http", 10 | "proxy", 11 | "http-proxy", 12 | "replay", 13 | "rocky", 14 | "consul", 15 | "balacing", 16 | "balancer", 17 | "reactive", 18 | "discovery", 19 | "service" 20 | ], 21 | "engines": { 22 | "node": ">= 0.12" 23 | }, 24 | "scripts": { 25 | "test": "./node_modules/.bin/mocha --timeout 2000 --reporter spec --ui tdd test/*" 26 | }, 27 | "devDependencies": { 28 | "chai": "^3.0.0", 29 | "mocha": "^2.2.5", 30 | "nock": "^2.7.0", 31 | "rocky": "^0.3.0" 32 | }, 33 | "dependencies": { 34 | "got": "^3.3.0", 35 | "object-assign": "^3.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/consul.js: -------------------------------------------------------------------------------- 1 | const nock = require('nock') 2 | const expect = require('chai').expect 3 | const consul = require('..') 4 | 5 | const noop = function () {} 6 | 7 | suite('consul', function () { 8 | var consulResponse = [ 9 | { 10 | "Node": "nyc3-worker-1", 11 | "Address": "127.0.0.1", 12 | "ServiceID": "web", 13 | "ServiceName": "web", 14 | "ServiceAddress": "", 15 | "ServicePort": 80 16 | } 17 | ] 18 | 19 | test('valid', function (done) { 20 | nock('http://consul') 21 | .get('/v1/catalog/service/web?') 22 | .reply(200, consulResponse) 23 | 24 | var md = consul({ 25 | service: 'web', 26 | servers: ['http://consul'] 27 | }) 28 | 29 | var req = { rocky: { options: {} } } 30 | var res = { end: noop } 31 | 32 | md(req, res, assert) 33 | 34 | function assert(err) { 35 | expect(err).to.be.undefined 36 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80']) 37 | done() 38 | } 39 | }) 40 | 41 | test('invalid params', function (done) { 42 | function missingService() { 43 | consul({ servers: [] }) 44 | } 45 | 46 | function missingServers() { 47 | consul({ servers: 'web' }) 48 | } 49 | 50 | expect(missingService).to.throw(TypeError) 51 | expect(missingServers).to.throw(TypeError) 52 | done() 53 | }) 54 | 55 | test('invalid response', function (done) { 56 | nock('http://consul') 57 | .get('/v1/catalog/service/web?') 58 | .reply(404) 59 | 60 | var req = {} 61 | var res = { end: assertEnd, writeHead: assertHead } 62 | 63 | var md = consul({ 64 | service: 'web', 65 | servers: ['http://consul'] 66 | }) 67 | 68 | md(req, res) 69 | 70 | function assertHead(code, headers) { 71 | expect(code).to.be.equal(502) 72 | expect(headers).to.be.deep.equal({'Content-Type': 'application/json'}) 73 | } 74 | 75 | function assertEnd(data) { 76 | expect(data).to.be.match(/Proxy error: cannot retrieve/) 77 | done() 78 | } 79 | }) 80 | 81 | test('timeout', function (done) { 82 | nock('http://consul') 83 | .get('/v1/catalog/service/web?') 84 | .delay(2000) 85 | .reply(404) 86 | 87 | var req = {} 88 | var res = { end: assertEnd, writeHead: assertHead } 89 | 90 | var md = consul({ 91 | timeout: 100, 92 | service: 'web', 93 | servers: ['http://consul'] 94 | }) 95 | 96 | md(req, res) 97 | 98 | function assertHead(code, headers) { 99 | expect(code).to.be.equal(502) 100 | expect(headers).to.be.deep.equal({'Content-Type': 'application/json'}) 101 | } 102 | 103 | function assertEnd(data) { 104 | expect(data).to.be.match(/Proxy error: cannot retrieve/) 105 | done() 106 | } 107 | }) 108 | 109 | test('headers', function (done) { 110 | nock('http://consul') 111 | .get('/v1/catalog/service/web?') 112 | .matchHeader('User-Agent', 'rocky') 113 | .reply(200, consulResponse) 114 | 115 | var req = { rocky: { options: {} } } 116 | var res = {} 117 | 118 | var md = consul({ 119 | headers: { 120 | 'User-Agent': 'rocky' 121 | }, 122 | service: 'web', 123 | servers: ['http://consul'] 124 | }) 125 | 126 | md(req, res, assert) 127 | 128 | function assert(err) { 129 | expect(err).to.be.undefined 130 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80']) 131 | done() 132 | } 133 | }) 134 | 135 | test('default servers', function (done) { 136 | nock('http://consul') 137 | .get('/v1/catalog/service/web?') 138 | .reply(200, consulResponse) 139 | 140 | var req = { rocky: { options: {} } } 141 | var res = {} 142 | 143 | var md = consul({ 144 | service: 'web', 145 | servers: ['http://consul'], 146 | defaultServers: ['http://default'], 147 | interval: 100 148 | }) 149 | 150 | md(req, res, assert) 151 | 152 | function assert(err) { 153 | expect(err).to.be.undefined 154 | expect(req.rocky.options.balance).to.be.deep.equal(['http://default']) 155 | 156 | setTimeout(assertInterval, 150) 157 | } 158 | 159 | function assertInterval() { 160 | md(req, res, function () { 161 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80']) 162 | done() 163 | }) 164 | } 165 | }) 166 | 167 | test('events', function (done) { 168 | nock('http://consul') 169 | .get('/v1/catalog/service/web?') 170 | .reply(200, consulResponse) 171 | 172 | var req = { rocky: { options: {} } } 173 | var res = {} 174 | 175 | var md = consul({ 176 | service: 'web', 177 | servers: ['http://consul'], 178 | onRequest: onRequest, 179 | onUpdate: onUpdate, 180 | onResponse: onResponse 181 | }) 182 | 183 | md(req, res, assert) 184 | 185 | function onUpdate(err, servers) { 186 | expect(err).to.be.null 187 | expect(servers).to.be.an('array') 188 | expect(servers).to.have.length(1) 189 | expect(servers[0]).to.be.equal('http://127.0.0.1:80') 190 | } 191 | 192 | function onResponse(err, data, res) { 193 | expect(err).to.be.null 194 | expect(data).to.be.a('string') 195 | expect(data).to.match(/Node/) 196 | expect(data).to.match(/Address/) 197 | expect(res.statusCode).to.be.equal(200) 198 | expect(res.headers).to.have.property('content-type').to.match(/application\/json/) 199 | } 200 | 201 | function onRequest(httpOpts) { 202 | expect(httpOpts).to.be.an('object') 203 | expect(httpOpts.url).to.be.equal('http://consul/v1/catalog/service/web') 204 | } 205 | 206 | function assert(err) { 207 | expect(err).to.be.undefined 208 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80']) 209 | done() 210 | } 211 | }) 212 | }) 213 | --------------------------------------------------------------------------------