diff --git a/.gitignore b/.gitignore index 6704566..5d81abd 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ dist # TernJS port file .tern-port +package-lock.json diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..00beba9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,40 @@ +language: node_js +cache: npm +stages: + - check + - test + - cov + +node_js: + - 'lts/*' + - 'stable' + +os: + - linux + - osx + +script: npx nyc -s npm run test:node -- --bail +after_success: npx nyc report --reporter=text-lcov > coverage.lcov && npx codecov + +jobs: + include: + - stage: check + script: + - npm run lint + + - stage: test + name: chrome + addons: + chrome: stable + script: + - npx aegir test -t browser + + - stage: test + name: firefox + addons: + firefox: latest + script: + - npx aegir test -t browser -- --browsers FirefoxHeadless + +notifications: + email: false \ No newline at end of file diff --git a/README.md b/README.md index 4a8f769..5efe0ed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,186 @@ -# dns-over-http-client -DNS over HTTP client +# dns-over-http-resolver + +[![Build Status](https://travis-ci.org/vasco-santos/dns-over-http-resolver.svg?branch=master)](https://travis-ci.org/vasco-santos/dns-over-http-resolver) +[![dependencies Status](https://david-dm.org/vasco-santos/dns-over-http-resolver/status.svg)](https://david-dm.org/vasco-santos/dns-over-http-resolver) +[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) + +> DNS over HTTP resolver + +Isomorphic DNS over HTTP resolver using fetch. + +API based on [Node.js' dns promises API](https://nodejs.org/dist/latest-v14.x/docs/api/dns.html#dns_dns_promises_api), allowing the native `dns` module to be used if available when relying on this API. + +## Install + +```sh +npm i dns-over-http-resolver +``` + +## Usage + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') +``` + +You can also use `require('dns').promises` in Node.js in lieu of this module. + +[Cloudflare](https://cloudflare-dns.com/dns-query) and [Google](https://dns.google/resolve) DNS servers are used by default. They can be replaced via the API. + +## API + +### resolve(hostname, rrType) + +Uses the DNS protocol to resolve the given host name into a DNS record. + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| hostname | `string` | host name to resolve | +| [rrType] | `string` | resource record type (default: 'A') | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise>` | returns a Promise resolving a DNS record according to its type | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') +const resolver = new DnsOverHttpResolver() + +const hostname = 'google.com' +const recordType = 'TXT' + +const dnsRecord = await resolver.resolve(hostname, recordType) +``` + +### resolve4(hostname) + +Uses the DNS protocol to resolve the given host name into IPv4 addresses. + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| hostname | `string` | host name to resolve | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise>` | returns a Promise resolving IPv4 addresses | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') +const resolver = new DnsOverHttpResolver() + +const hostname = 'google.com' + +const address = await resolver.resolve4(hostname) // ['216.58.212.142'] +``` + +### resolve6(hostname) + +Uses the DNS protocol to resolve the given host name into IPv6 addresses. + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| hostname | `string` | host name to resolve | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise>` | returns a Promise resolving IPv6 addresses | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') +const resolver = new DnsOverHttpResolver() + +const hostname = 'google.com' + +const address = await resolver.resolve6(hostname) // ['2a00:1450:4001:801::200e'] +``` + +### resolveTxt(hostname) + +Uses the DNS protocol to resolve the given host name into a Text Record. + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| hostname | `string` | host name to resolve | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise>>` | returns a Promise resolving a Text Record | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') +const resolver = new DnsOverHttpResolver() + +const hostname = 'google.com' + +const address = await resolver.resolveTxt(hostname) // [['v=spf1 -all']] +``` + +### getServers() + +Get an array of the IP addresses currently configured for DNS resolution. +These addresses are formatted according to RFC 5952. It can include a custom port. + +#### Returns + +| Type | Description | +|------|-------------| +| `Array` | returns array of DNS servers used | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') + +const resolver = new DnsOverHttpResolver() +const servers = resolver.getServers() +``` + +### setServers(servers) + +Sets the IP address and port of servers to be used when performing DNS resolution. + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| servers | `Array` | Array of RFC 5952 formatted addresses. | + +#### Example + +```js +const DnsOverHttpResolver = require('dns-over-http-resolver') + +const resolver = new DnsOverHttpResolver() +resolver.setServers(['https://cloudflare-dns.com/dns-query']) +``` + +## Contribute + +Feel free to dive in! [Open an issue](https://github.com/vasco-santos/dns-over-http-resolver/issues/new) or submit PRs. + +## License + +[MIT](LICENSE) © Vasco Santos diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c43103 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "dns-over-http-resolver", + "version": "0.0.0", + "description": "DNS over HTTP resolver", + "main": "src/index.js", + "author": "Vasco Santos", + "scripts": { + "test": "aegir test -t node -t browser", + "test:browser": "aegir test -t browser", + "test:node": "aegir test -t node", + "lint": "aegir lint", + "release": "aegir release --docs", + "release-minor": "aegir release --type minor --docs", + "release-major": "aegir release -t node -t browser --type major --docs", + "build": "aegir build" + }, + "files": [ + "src", + "dist" + ], + "devDependencies": { + "aegir": "^27.0.0", + "ipfs-utils": "^4.0.0", + "sinon": "^9.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vasco-santos/dns-over-http-resolver.git" + }, + "keywords": [ + "doh", + "dns", + "http" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/vasco-santos/dns-over-http-resolver/issues" + }, + "homepage": "https://github.com/vasco-santos/dns-over-http-resolver#readme", + "dependencies": { + "debug": "^4.2.0", + "native-fetch": "^2.0.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..3cc0cf8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,140 @@ +'use strict' +const debug = require('debug') +const log = debug('dns-over-http-resolver') +log.error = debug('dns-over-http-resolver:error') + +const { buildResource, fetch } = require('./utils') + +/** + * DNS over HTTP resolver. + * Uses a list of servers to resolve DNS records with HTTP requests. + */ +class Resolver { + /** + * @class + */ + constructor () { + this._servers = [ + 'https://cloudflare-dns.com/dns-query', + 'https://dns.google/resolve' + ] + } + + /** + * Get an array of the IP addresses currently configured for DNS resolution. + * These addresses are formatted according to RFC 5952. It can include a custom port. + * + * @returns {Array} + */ + getServers () { + return this._servers + } + + /** + * Sets the IP address and port of servers to be used when performing DNS resolution. + * + * @param {Array} servers - array of RFC 5952 formatted addresses. + */ + setServers (servers) { + this._servers = servers + } + + /** + * Uses the DNS protocol to resolve the given host name into the appropriate DNS record. + * + * @param {string} hostname - host name to resolve. + * @param {string} [rrType = 'A'] - resource record type. + * @returns {Promise<*>} + */ + resolve (hostname, rrType = 'A') { + switch (rrType) { + case 'A': + return this.resolve4(hostname) + case 'AAAA': + return this.resolve6(hostname) + case 'TXT': + return this.resolveTxt(hostname) + default: + throw new Error(`${rrType} is not supported`) + } + } + + /** + * Uses the DNS protocol to resolve the given host name into IPv4 addresses. + * + * @param {string} hostname - host name to resolve. + * @returns {Promise>} + */ + async resolve4 (hostname) { + for (const server of this._servers) { + try { + const response = await fetch(buildResource({ + serverResolver: server, + hostname, + recordType: 'A' + })) + + const d = await response.json() + return d.Answer.map(a => a.data) + } catch (err) { + log.error(`${server} could not resolve ${hostname} record A`) + } + } + + throw new Error(`Could not resolve ${hostname} record A`) + } + + /** + * Uses the DNS protocol to resolve the given host name into IPv6 addresses. + * + * @param {string} hostname - host name to resolve. + * @returns {Promise>} + */ + async resolve6 (hostname) { + for (const server of this._servers) { + try { + const response = await fetch(buildResource({ + serverResolver: server, + hostname, + recordType: 'AAAA' + })) + + const d = await response.json() + return d.Answer.map(a => a.data) + } catch (err) { + log.error(`${server} could not resolve ${hostname} record AAAA`) + } + } + + throw new Error(`Could not resolve ${hostname} record AAAA`) + } + + /** + * Uses the DNS protocol to resolve the given host name into a Text record. + * + * @param {string} hostname - host name to resolve. + * @returns {Promise>>} + */ + async resolveTxt (hostname) { + for (const server of this._servers) { + try { + const response = await fetch(buildResource({ + serverResolver: server, + hostname, + recordType: 'TXT' + })) + + const d = await response.json() + + return d.Answer.map(a => [a.data.replace(/['"]+/g, '')]) + } catch (err) { + log.error(`${server} could not resolve ${hostname} record TXT`) + } + } + + throw new Error(`Could not resolve ${hostname} record TXT`) + } +} + +Resolver.Resolver = Resolver +module.exports = Resolver diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..e13e07c --- /dev/null +++ b/src/utils.js @@ -0,0 +1,28 @@ +'use strict' + +const { default: nativeFetch, Headers } = require('native-fetch') + +/** + * Build fetch resource for request. + * + * @param {object} properties + * @param {string} properties.serverResolver + * @param {string} properties.hostname + * @param {string} properties.recordType + * @returns {string} + */ +function buildResource ({ serverResolver, hostname, recordType }) { + return `${serverResolver}?name=${hostname}&type=${recordType}` +} + +module.exports.buildResource = buildResource + +function fetch (resource) { + return nativeFetch(resource, { + headers: new Headers({ + accept: 'application/dns-json' + }) + }) +} + +module.exports.fetch = fetch diff --git a/test/index.spec.js b/test/index.spec.js new file mode 100644 index 0000000..26064f8 --- /dev/null +++ b/test/index.spec.js @@ -0,0 +1,203 @@ +'use strict' + +/* eslint-env mocha */ +const { expect } = require('aegir/utils/chai') +const sinon = require('sinon') +const { default: nativeFetch } = require('native-fetch') +const { isBrowser } = require('ipfs-utils/src/env') + +const DnsOverHttpResolver = require('../') + +describe('dns-over-http-resolver', () => { + let resolver + + beforeEach(() => { + resolver = new DnsOverHttpResolver() + }) + + afterEach(() => { + sinon.restore() + }) + + it('can get and set http servers', () => { + const servers1 = resolver.getServers() + expect(servers1).to.exist() + expect(servers1).to.have.lengthOf(2) + + const newServer = 'https://dns.google/resolve' + resolver.setServers([newServer]) + + const servers2 = resolver.getServers() + expect(servers2).to.exist() + expect(servers2).to.have.lengthOf(1) + expect(servers2[0]).to.eql(newServer) + }) + + it('resolves a dns record of type A', async () => { + const hostname = 'google.com' + const recordType = 'A' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [{ name: 'google.com', type: 1, TTL: 285, data: '216.58.212.142' }] + }) + })) + + const response = await resolver.resolve(hostname, recordType) + expect(response).to.exist() + expect(response).to.eql(['216.58.212.142']) + }) + + it('resolves a dns record using IPv4', async () => { + const hostname = 'google.com' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [{ name: 'google.com', type: 1, TTL: 285, data: '216.58.212.142' }] + }) + })) + + const response = await resolver.resolve4(hostname) + expect(response).to.exist() + expect(response).to.eql(['216.58.212.142']) + }) + + it('resolves a dns record of type AAAA', async () => { + const hostname = 'google.com' + const recordType = 'AAAA' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [ + { + name: 'google.com', + type: 28, + TTL: 148, + data: '2a00:1450:4001:801::200e' + } + ] + }) + })) + + const response = await resolver.resolve(hostname, recordType) + expect(response).to.exist() + expect(response).to.eql(['2a00:1450:4001:801::200e']) + }) + + it('resolves a dns record using IPv6', async () => { + const hostname = 'google.com' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [ + { + name: 'google.com', + type: 28, + TTL: 148, + data: '2a00:1450:4001:801::200e' + } + ] + }) + })) + + const response = await resolver.resolve6(hostname) + expect(response).to.exist() + expect(response).to.eql(['2a00:1450:4001:801::200e']) + }) + + it('resolves a dns record of type TXT', async () => { + const hostname = 'google.com' + const recordType = 'TXT' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'example.com', type: 1 }], + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"v=spf1 -all"' + }, + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"' + } + ] + }) + })) + + const response = await resolver.resolve(hostname, recordType) + expect(response).to.exist() + expect(response).to.have.length(2) + expect(response).to.eql([['v=spf1 -all'], ['docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e']]) + }) + + it('resolves a dns record using TXT', async () => { + const hostname = 'example.com' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'example.com', type: 1 }], + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"v=spf1 -all"' + }, + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"' + } + ] + }) + })) + + const response = await resolver.resolveTxt(hostname) + expect(response).to.exist() + expect(response).to.have.length(2) + expect(response).to.eql([['v=spf1 -all'], ['docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e']]) + }) + + it('should fail if cannot resolve', async () => { + const hostname = 'example.com' + const recordType = 'TXT' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.returns(Promise.reject(new Error())) + + await expect(resolver.resolve(hostname, recordType)).to.eventually.be.rejected() + }) + + it('resolved a dns record from the second server if the first fails', async () => { + const hostname = 'example.com' + + const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + stub.onCall(0).returns(Promise.reject(new Error())) + stub.onCall(1).returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [{ name: 'google.com', type: 1, TTL: 285, data: '216.58.212.142' }] + }) + })) + + const response = await resolver.resolve(hostname) + expect(response).to.exist() + expect(response).to.eql(['216.58.212.142']) + }) +})