├── .npmignore ├── .gitignore ├── .github └── workflows │ └── node.js.yml ├── package.json ├── README.md ├── lib └── index.js └── test └── agent.js /.npmignore: -------------------------------------------------------------------------------- 1 | ** 2 | !/lib/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12, 14, '*'] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service-agent", 3 | "version": "4.0.2", 4 | "description": "HTTP agent that connects to services defined in SRV records'", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "lab -v -L -c -t 90" 8 | }, 9 | "author": "Gil Pedersen ", 10 | "license": "BSD-2-Clause", 11 | "engines": { 12 | "node": ">=12.13.0" 13 | }, 14 | "devDependencies": { 15 | "@hapi/code": "^8.0.1", 16 | "@hapi/lab": "^24.1.0", 17 | "request": "^2.88.0" 18 | }, 19 | "directories": { 20 | "test": "test" 21 | }, 22 | "dependencies": {}, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/kanongil/node-service-agent.git" 26 | }, 27 | "keywords": [ 28 | "dns", 29 | "agent", 30 | "service", 31 | "discovery", 32 | "srv", 33 | "record", 34 | "lookup" 35 | ], 36 | "bugs": { 37 | "url": "https://github.com/kanongil/node-service-agent/issues" 38 | }, 39 | "homepage": "https://github.com/kanongil/node-service-agent" 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # service-agent for node.js 2 | 3 | ![Node.js CI](https://github.com/kanongil/node-service-agent/workflows/Node.js%20CI/badge.svg) 4 | 5 | HTTP agent that connects to services defined in DNS SRV records, enabling transparent service discovery for any HTTP-based protocol. 6 | 7 | Just add the agent to your request, and it will connect to the service. 8 | 9 | ## Usage 10 | 11 | Using the popular `request` module: 12 | 13 | ```javascript 14 | const ServiceAgent = require('service-agent'); 15 | const Request = require('request'); 16 | 17 | const request = Request.defaults({ 18 | agentClass: ServiceAgent, 19 | agentOptions: { service:'_http._tcp.' }, 20 | pool: {} 21 | }); 22 | 23 | request('http://pkg.freebsd.org/', function(error, result, body) { 24 | … 25 | }); 26 | ``` 27 | 28 | Note that you need to set the `pool` option whenever you specify the `service` when creating the agent. Otherwise, `request` will mix multiple services together. 29 | 30 | ### Options 31 | 32 | * `service`: Service designator to look for, prepended to the hostname. Defaults to `_http._tcp.`. 33 | 34 | ### Limitations 35 | 36 | * Services are not checked for connectivity. Currently, it will only select a weighted random service at the highest priority. 37 | * SSL is not supported. 38 | 39 | Pull request to fix these issues are welcome. 40 | 41 | ## Installation 42 | 43 | ```sh 44 | $ npm install service-agent 45 | ``` 46 | 47 | # License 48 | 49 | (BSD 2-Clause License) 50 | 51 | Copyright (c) 2015-2020, Gil Pedersen <gpdev@gpost.dk> 52 | All rights reserved. 53 | 54 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 55 | 56 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 57 | 2. 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. 58 | 59 | 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. -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Dns = require('dns'); 4 | const HttpAgent = require('http').Agent; 5 | 6 | const internals = {}; 7 | 8 | internals.compareNumbers = function compareNumbers(a, b) { 9 | 10 | a = parseInt(a, 10); 11 | b = parseInt(b, 10); 12 | return (a < b ? -1 : (a > b ? 1 : 0)); 13 | }; 14 | 15 | // Sorts the SRV lookup results first by priority, then randomising the server 16 | // order for a given priority. For discussion of handling of priority and 17 | // weighting, see https://github.com/dhruvbird/dns-srv/pull/4 18 | internals.groupSrvRecords = function groupSrvRecords(addrs) { 19 | 20 | const groups = {}; // by priority 21 | addrs.forEach((addr) => { 22 | 23 | if (!groups.hasOwnProperty(addr.priority)) { 24 | groups[addr.priority] = []; 25 | } 26 | 27 | groups[addr.priority].push(addr); 28 | }); 29 | 30 | const result = []; 31 | Object.keys(groups).sort(internals.compareNumbers).forEach((priority) => { 32 | 33 | const group = groups[priority]; 34 | 35 | // Calculate the total weight for this priority group 36 | 37 | let totalWeight = 0; 38 | for (let i = 0; i < group.length; ++i) { 39 | totalWeight += group[i].weight; 40 | } 41 | 42 | // Find a weighted address 43 | 44 | while (group.length > 1) { 45 | // Select the next address (based on the relative weights) 46 | let w = Math.floor(Math.random() * totalWeight); 47 | let index = -1; 48 | while (++index < group.length && w > 0) { 49 | w -= group[index].weight; 50 | } 51 | 52 | if (index < group.length) { 53 | // Remove selected address from the group and add it to the 54 | // result list. 55 | const addr = group.splice(index, 1)[0]; 56 | result.push(addr); 57 | // Adjust the total group weight accordingly 58 | totalWeight -= addr.weight; 59 | } 60 | } 61 | 62 | // Add the final address from this group 63 | 64 | result.push(group[0]); 65 | }); 66 | 67 | return result; 68 | }; 69 | 70 | 71 | internals.rewriteOutputHeader = function (req, header) { 72 | 73 | const endOfHeader = header.indexOf('\r\n\r\n') + 4; 74 | return req._header + header.substring(endOfHeader); 75 | }; 76 | 77 | 78 | const ServiceAgent = class extends HttpAgent { 79 | 80 | service = '_http._tcp.'; 81 | 82 | constructor(options) { 83 | 84 | super(options); 85 | 86 | if (options && options.hasOwnProperty('service')) { 87 | if (typeof options.service !== 'string') { 88 | throw new TypeError('Service option must be a string'); 89 | } 90 | 91 | this.service = options.service; 92 | if (this.service.length && this.service[this.service.length - 1] !== '.') { 93 | this.service = this.service + '.'; 94 | } 95 | } 96 | } 97 | 98 | /* override */ 99 | addRequest(req, options, ...extra) { 100 | 101 | Dns.resolveSrv(this.service + options.host, (err, addrs) => { 102 | 103 | if (err || addrs.length === 0) { 104 | // use passed in values 105 | return super.addRequest(req, options, ...extra); 106 | } 107 | 108 | const addr = internals.groupSrvRecords(addrs).shift(); 109 | 110 | // regenerating stored HTTP header string for request 111 | // note: blatantly ripped from http-proxy-agent 112 | req._header = null; 113 | req.setHeader('host', addr.name + ':' + (addr.port || options.port)); 114 | req._implicitHeader(); 115 | 116 | // rewrite host name in response 117 | 118 | if (req.outputData) { // v11+ 119 | if (req.outputData.length > 0) { 120 | req.outputData[0].data = internals.rewriteOutputHeader(req, req.outputData[0].data); 121 | } 122 | } 123 | else if (req.output) { // legacy 124 | if (req.output.length > 0) { 125 | req.output[0] = internals.rewriteOutputHeader(req, req.output[0]); 126 | } 127 | } 128 | 129 | return super.addRequest(req, addr.name, addr.port || options.port, options.localAddress); 130 | }); 131 | } 132 | }; 133 | 134 | 135 | module.exports = ServiceAgent; 136 | -------------------------------------------------------------------------------- /test/agent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Dns = require('dns'); 4 | const Http = require('http'); 5 | 6 | const Code = require('@hapi/code'); 7 | const Lab = require('@hapi/lab'); 8 | const Request = require('request'); 9 | 10 | const ServiceAgent = require('..'); 11 | 12 | const lab = exports.lab = Lab.script(); 13 | const describe = lab.describe; 14 | const before = lab.before; 15 | const after = lab.after; 16 | const it = lab.it; 17 | const expect = Code.expect; 18 | 19 | const internals = { 20 | services: { 21 | '_zero._tcp.not.there': [], 22 | '_portless._tcp.localhost': [{ 23 | priority: 10, weight: 5, 24 | port: 0, 25 | name: 'localhost' 26 | }] 27 | } 28 | }; 29 | 30 | describe('ServiceAgent', () => { 31 | 32 | let origDnsResolveSrv; 33 | 34 | before(() => { 35 | 36 | origDnsResolveSrv = Dns.resolveSrv; 37 | Dns.resolveSrv = (domain, callback) => { 38 | 39 | const list = internals.services[domain]; 40 | if (list) { 41 | if (list.syncReply) { 42 | return callback(null, list); 43 | } 44 | 45 | return setImmediate(callback, null, list); 46 | } 47 | 48 | return origDnsResolveSrv.call(Dns, domain, callback); 49 | }; 50 | }); 51 | 52 | after(() => { 53 | 54 | Dns.resolveSrv = origDnsResolveSrv; 55 | }); 56 | 57 | let server; 58 | let serverPort; 59 | 60 | before(async () => { 61 | 62 | return await new Promise((resolve) => { 63 | 64 | server = Http.createServer(); 65 | 66 | server.listen(() => { 67 | 68 | serverPort = server.address().port; 69 | 70 | // 'register' services 71 | internals.services['_http._tcp.localhost'] = [{ 72 | priority: 10, weight: 5, 73 | port: serverPort, 74 | name: 'localhost' 75 | }]; 76 | internals.services['_http._tcp.localhost'].syncReply = true; 77 | 78 | internals.services.blank = [{ 79 | priority: 10, weight: 5, 80 | port: serverPort, 81 | name: 'localhost' 82 | }]; 83 | 84 | internals.services['_test._tcp.localhost'] = [{ 85 | priority: 10, weight: 5, 86 | port: serverPort, 87 | name: 'localhost' 88 | }, { 89 | priority: 10, weight: 5, 90 | port: serverPort, 91 | name: 'localhost' 92 | }, { 93 | priority: 50, weight: 5, 94 | port: 100, 95 | name: 'localhost' 96 | }]; 97 | 98 | resolve(); 99 | }); 100 | 101 | server.on('request', (req, res) => { 102 | 103 | res.end(JSON.stringify(req.headers)); 104 | }); 105 | }); 106 | }); 107 | 108 | after(async () => { 109 | 110 | const promise = new Promise((resolve) => { 111 | 112 | server.once('close', resolve); 113 | }); 114 | server.close(); 115 | 116 | await promise; 117 | }); 118 | 119 | describe('constructor', () => { 120 | 121 | it('should inherit from http.Agent', () => { 122 | 123 | const agent = new ServiceAgent(); 124 | expect(agent).to.be.an.instanceof(Http.Agent); 125 | }); 126 | 127 | it('throws on invalid service option', () => { 128 | 129 | const createBadService = () => { 130 | 131 | new ServiceAgent({ service: 10 }); 132 | }; 133 | 134 | expect(createBadService).to.throw(TypeError); 135 | }); 136 | 137 | it('respects http.Agent options', () => { 138 | 139 | const agent = new ServiceAgent({ maxSockets: 42 }); 140 | expect(agent.maxSockets).to.equal(42); 141 | }); 142 | }); 143 | 144 | describe('service', () => { 145 | 146 | it('resolves using http.get', async () => { 147 | 148 | const res = await new Promise((resolve, reject) => { 149 | 150 | const req = Http.get({ host: 'localhost', port: 100, agent: new ServiceAgent() }, resolve); 151 | req.on('error', reject); 152 | req.end(); 153 | }); 154 | 155 | res.destroy(); 156 | }); 157 | 158 | it('resolves using the request module', async () => { 159 | 160 | const request = Request.defaults({ agentClass: ServiceAgent }); 161 | 162 | const json = await new Promise((resolve, reject) => { 163 | 164 | request('http://localhost/', { json: true }, (err, res, data) => { 165 | 166 | return err ? reject(err) : resolve(data); 167 | }); 168 | }); 169 | 170 | expect(json.host).to.equal('localhost:' + serverPort); 171 | }); 172 | 173 | it('resolves a non-GET request', async () => { 174 | 175 | const request = Request.defaults({ agentClass: ServiceAgent }); 176 | 177 | const json = await new Promise((resolve, reject) => { 178 | 179 | request.post('http://localhost/', { body: {}, json: true }, (err, res, data) => { 180 | 181 | return err ? reject(err) : resolve(data); 182 | }); 183 | }); 184 | 185 | expect(json.host).to.equal('localhost:' + serverPort); 186 | }); 187 | 188 | it('handles custom services', async () => { 189 | 190 | const json = await new Promise((resolve, reject) => { 191 | 192 | Request({ url: 'http://localhost/', agentClass: ServiceAgent, agentOptions: { service: '_test._tcp.' }, pool: {}, json: true }, (err, res, data) => { 193 | 194 | return err ? reject(err) : resolve(data); 195 | }); 196 | }); 197 | 198 | expect(json.host).to.equal('localhost:' + serverPort); 199 | }); 200 | 201 | it('handles custom services with a port in url', async () => { 202 | 203 | const json = await new Promise((resolve, reject) => { 204 | 205 | Request({ url: 'http://localhost:100/', agentClass: ServiceAgent, agentOptions: { service: '_test._tcp.' }, pool: {}, json: true }, (err, res, data) => { 206 | 207 | return err ? reject(err) : resolve(data); 208 | }); 209 | }); 210 | 211 | expect(json.host).to.equal('localhost:' + serverPort); 212 | }); 213 | 214 | it('handles custom services with with missing trailing dot', async () => { 215 | 216 | const json = await new Promise((resolve, reject) => { 217 | 218 | Request({ url: 'http://localhost:100/', agentClass: ServiceAgent, agentOptions: { service: '_test._tcp' }, pool: {}, json: true }, (err, res, data) => { 219 | 220 | return err ? reject(err) : resolve(data); 221 | }); 222 | }); 223 | 224 | expect(json.host).to.equal('localhost:' + serverPort); 225 | }); 226 | 227 | it('resolves blank service option', async () => { 228 | 229 | const request = Request.defaults({ agentClass: ServiceAgent, agentOptions: { service: '' }, pool: {} }); 230 | 231 | const json = await new Promise((resolve, reject) => { 232 | 233 | request('http://blank/', { json: true }, (err, res, data) => { 234 | 235 | return err ? reject(err) : resolve(data); 236 | }); 237 | }); 238 | 239 | expect(json.host).to.equal('localhost:' + serverPort); 240 | }); 241 | 242 | it('resolves the default port when SRV record port is 0', async () => { 243 | 244 | const request = Request.defaults({ agentClass: ServiceAgent, agentOptions: { service: '_portless._tcp.' }, pool: {} }); 245 | 246 | const json = await new Promise((resolve, reject) => { 247 | 248 | request('http://localhost:' + serverPort + '/', { json: true }, (err, res, data) => { 249 | 250 | return err ? reject(err) : resolve(data); 251 | }); 252 | }); 253 | 254 | expect(json.host).to.equal('localhost:' + serverPort); 255 | }); 256 | 257 | it('resolves the default port for empty lookup results', async () => { 258 | 259 | const request = Request.defaults({ agentClass: ServiceAgent, agentOptions: { service: '_zero._tcp.' }, pool: {} }); 260 | 261 | const json = await new Promise((resolve, reject) => { 262 | 263 | request('http://localhost:' + serverPort + '/', { json: true }, (err, res, data) => { 264 | 265 | return err ? reject(err) : resolve(data); 266 | }); 267 | }); 268 | 269 | expect(json.host).to.equal('localhost:' + serverPort); 270 | }); 271 | 272 | it('resolves the default port when lookup fails', async () => { 273 | 274 | const request = Request.defaults({ agentClass: ServiceAgent }); 275 | 276 | await expect(new Promise((resolve, reject) => { 277 | 278 | request('http://the.holy.grail/', (err, res, data) => { 279 | 280 | return err ? reject(err) : resolve(data); 281 | }); 282 | })).reject(/ENOTFOUND/); 283 | }); 284 | }); 285 | }); 286 | --------------------------------------------------------------------------------