diff --git a/AUTHORS b/AUTHORS index f2496e6..c8a5c36 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1,2 @@ AJ ONeal (https://coolaj86.com/) +Hitesh Walia diff --git a/README.md b/README.md index bfd0dcf..fd9bdfb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,111 @@ # acme-dns-01-route53 -Amazon AWS Route53 DNS + Let's Encrypt for Node.js - ACME dns-01 challenges w/ ACME.js and Greenlock.js \ No newline at end of file +Amazon AWS Route53 DNS + Let's Encrypt for Node.js - ACME dns-01 challenges w/ ACME.js and Greenlock.js + + +# Features + +- Compatible + - Let’s Encrypt v2.1 / ACME draft 18 (2019) + - DNSimple v2 API + - ACME.js, Greenlock.js, and others +- Quality + - node v6 compatible VanillaJS + +# Install + +```js +npm install --save acme-dns-01-route53 +``` + +AWS API keys: + +- Login to your account at: https://console.aws.amazon.com +- Go to Services > IAM > Users, create a new user and assign AmazonRoute53FullAccess policy. +- Click *Securty credentials* tab and then click *Create access key*. + +# Usage + +First you create an instance with your credentials: + +```js +var dns01 = require('acme-dns-01-route53').create({ + key: 'your_key', + secret: 'your_secret' +}); +``` + +Then you can use it with any compatible ACME library, such as Greenlock.js or ACME.js. + +## Greenlock.js + +```js +var Greenlock = require('greenlock-express'); +var greenlock = Greenlock.create({ + challenges: { + 'dns-01': dns01 + // ... + } +}); +``` + +See [Greenlock Express](https://git.rootprojects.org/root/greenlock-express.js) and/or [Greenlock.js](https://git.rootprojects.org/root/greenlock.js) documentation for more details. + +## ACME.js + +```js +// TODO +``` + +See the [ACME.js](https://git.rootprojects.org/root/acme-v2.js) for more details. + +## Build your own + +There are only 5 methods: + +- `init(config)` +- `zones(opts)` +- `set(opts)` +- `get(opts)` +- `remove(opts)` + +```js +dns01 + .set({ + identifier: { value: 'foo.example.co.uk' }, + wildcard: false, + dnsZone: 'example.co.uk', + dnsPrefix: '_acme-challenge.foo', + dnsAuthorization: 'xxx_secret_xxx' + }) + .then(function() { + console.log('TXT record set'); + }) + .catch(function() { + console.log('Failed to set TXT record'); + }); +``` + +See acme-dns-01-test for more implementation details. + +# Tests + +```bash +# node ./test.js domain-zone key secret +node ./test.js example.com xxxxx yyyyy +``` + +# Authors + +- AJ ONeal +- Hitesh Walia + +See AUTHORS for contact info. + +# Legal + +[acme-dns-01-dnsimple.js](https://git.coolaj86.com/coolaj86/acme-dns-01-dnsimple.js) | MPL-2.0 | [Terms of Use](https://therootcompany.com/legal/#terms) | [Privacy Policy](https://therootcompany.com/legal/#privacy) + +Copyright 2019 Hitesh Walia +Copyright 2019 AJ ONeal +Copyright 2019 The Root Group LLC diff --git a/lib/index.js b/lib/index.js index 56182d4..e1f2802 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,14 +5,66 @@ var xml2js = require('xml2js'); var parseString = xml2js.parseString; parseString = require('util').promisify(parseString); var builder = new xml2js.Builder(); - -var request = require('@root/request'); -//request.debug = false; +var request; var defaults = { baseUri: '/2013-04-01/' }; +function setWeightedPolicy(XmlObject, isWildcard) { + if (isWildcard) { + XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ + 'wildcard' + ]; + } else { + XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ + 'subdomain' + ]; + } + + XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [ + 1 + ]; +} + +// used to build XML payload +var XmlParameters = { + ChangeResourceRecordSetsRequest: { + $: { + xmlns: '' + }, + ChangeBatch: [ + { + Changes: [ + { + Change: [ + { + Action: [''], + ResourceRecordSet: [ + { + Type: ['TXT'], + Name: [''], + ResourceRecords: [ + { + ResourceRecord: [ + { + Value: [''] + } + ] + } + ], + TTL: [300] + } + ] + } + ] + } + ] + } + ] + } +}; + module.exports.create = function(config) { var baseUri = defaults.baseUri.replace(/\/$/, ''); var key = config.key; @@ -21,33 +73,34 @@ module.exports.create = function(config) { function api(method, path, body, query) { var body = body || ''; var query = query || {}; - var options = { - key: key, - secret: secret, - query: query + var options = { + key: key, + secret: secret, + query: query }; - - if(method === 'POST') { - options.headers = { 'Content-Type': 'application/xml' } - } - - var url = v4.createPresignedURL(method, 'route53.amazonaws.com', path, 'route53', body, options); - //console.log('url: ' + url + '\n'); - var parameters = { - method: method, - url: url + method: method }; - if(method === 'POST') { - parameters.headers = { 'Content-Type': 'application/xml' } + if (method === 'POST') { + options.headers = { 'Content-Type': 'application/xml' }; + parameters.headers = { 'Content-Type': 'application/xml' }; } - if(body) { + if (body) { parameters.body = body; } - // console.log(method, path, query); + var url = v4.createPresignedURL( + method, + 'route53.amazonaws.com', + path, + 'route53', + body, + options + ); + + parameters.url = url; return request(parameters).then(function(response) { if (response.statusCode < 200 || response.statusCode >= 300) { @@ -67,263 +120,174 @@ module.exports.create = function(config) { }); } - /* - api('GET', baseUri + '/' + 'hostedzone').then(function(response){ - console.log('######## RESPONSE #########'); - - }); - */ - return { init: function(options) { request = options.request; return null; }, - zones: function(data) { - //console.info('List Zones', data); - return api('GET', baseUri + '/' + 'hostedzone').then(function(response){ - return parseString(response.body).then(function(body){ - var zones = body.ListHostedZonesResponse.HostedZones[0].HostedZone.map(function(element) { - return element.Name[0].replace(/\.$/, ''); // all route 53 domains terminate with a dot - }); + zones: function(data) { + return api('GET', baseUri + '/' + 'hostedzone').then(function( + response + ) { + return parseString(response.body).then(function(body) { + var zones = body.ListHostedZonesResponse.HostedZones[0].HostedZone.map( + function(element) { + return element.Name[0].replace(/\.$/, ''); // all route 53 domains terminate with a dot + } + ); return zones; }); }); - }, set: function(data) { - // console.info('Add TXT', data); - // console.log('data: ' + JSON.stringify(data, null, 2)); var challenge = data.challenge; if (!challenge.dnsZone) { throw new Error('No matching zone for ' + challenge.dnsHost); } - return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1') - .then(function(response){ - return parseString(response.body).then(function(body){ - return body; - }); - }) - .then(function(body){ - var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0]; - var xmlns = body.ListHostedZonesByNameResponse.$.xmlns; - - var parameters = { - 'ChangeResourceRecordSetsRequest': { - '$': { - 'xmlns': xmlns - }, - 'ChangeBatch': [ - { - 'Changes': [ - { - 'Change': [ - { - 'Action': [ - 'CREATE' - ], - 'ResourceRecordSet': [ - { - 'Type': [ - 'TXT' - ], - 'Name': [ - challenge.dnsHost // AWS requires FQDN - ], - 'ResourceRecords': [ - { - 'ResourceRecord': [ - { - 'Value': [ - '"' + challenge.dnsAuthorization + '"' // value must be surrounded by double quotes - ] - } - ] - } - ], - 'TTL': [ - 300 - ] - } - ] - - } - ] - } - ] - } - ] - } - }; + return api( + 'GET', + baseUri + '/' + 'hostedzonesbyname', + null, + 'dnsname=' + challenge.dnsZone + '&maxitems=1' + ) + .then(function(response) { + return parseString(response.body).then(function(body) { + return body; + }); + }) + .then(function(body) { + var zoneID = + body.ListHostedZonesByNameResponse.HostedZones[0] + .HostedZone[0].Id[0]; + var xmlns = body.ListHostedZonesByNameResponse.$.xmlns; - // By default AWS creates record sets with simple routing policy, so duplicates are not allowed. - // A workaround is put the record sets in different weighted policies. - if(challenge.identifier.value.startsWith('foo')) { - if(challenge.wildcard) { - - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [ - 1 - ]; + var parameters = JSON.parse(JSON.stringify(XmlParameters)); + parameters.ChangeResourceRecordSetsRequest.$.xmlns = xmlns; + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].Action[0] = + 'CREATE'; + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Name[0] = + challenge.dnsHost; // AWS requires FQDN + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].ResourceRecords[0].ResourceRecord[0].Value[0] = + '"' + challenge.dnsAuthorization + '"'; // value must be surrounded by double quotes - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ - 'wildcard' - ]; - } else { - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [ - 1 - ]; + // By default AWS creates record sets with simple routing policy, so duplicates are not allowed. + // A workaround is put the record sets in different weighted policies. + if (challenge.identifier.value.startsWith('foo')) { + setWeightedPolicy(parameters, challenge.wildcard); + } - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ - 'subdomain' - ]; - } - } - - var xml = builder.buildObject(parameters); - //console.log('xml: ' + xml); - - return api('POST', baseUri + zoneID + '/rrset/', xml); - - }); - - + var xml = builder.buildObject(parameters); + return api('POST', baseUri + zoneID + '/rrset/', xml); + }); }, get: function(data) { - // console.info('List TXT', data); var challenge = data.challenge; - return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1') - .then(function(response){ - return parseString(response.body).then(function(body){ - return body; - }); - }) - .then(function(body){ - var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0]; - // console.log('zoneID: ' + zoneID); - return zoneID; - }) - .then(function(zoneID){ - // GET /2013-04-01/hostedzone/Id/rrset?identifier=StartRecordIdentifier&maxitems=MaxItems&name=StartRecordName&type=StartRecordType HTTP/1.1 - - return api('GET', baseUri + '/' + zoneID + '/rrset', null, 'name=' + challenge.dnsPrefix + '.' + challenge.dnsZone + '.' + '&type=TXT') - .then(function(response){ - return parseString(response.body).then(function(body){ - if (body.ListResourceRecordSetsResponse.ResourceRecordSets[0] === '') return null; - - var record = body.ListResourceRecordSetsResponse.ResourceRecordSets[0].ResourceRecordSet.filter(function(element){ - return ( - element.ResourceRecords[0].ResourceRecord[0].Value[0].replace(/\"/g, '') === challenge.dnsAuthorization && - element.Name[0].includes(challenge.dnsPrefix) - ); - })[0]; - - if (record) { - return { dnsAuthorization: record.ResourceRecords[0].ResourceRecord[0].Value[0].replace(/\"/g, '') }; - } - - return null; - }); - }); - - }); - - }, - remove: function(data) { - // console.info('Remove TXT', data); - - var challenge = data.challenge; - - return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1') - .then(function(response){ - return parseString(response.body).then(function(body){ - return body; - }); - }) - .then(function(body){ - var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0]; - var xmlns = body.ListHostedZonesByNameResponse.$.xmlns; - - var parameters = { - 'ChangeResourceRecordSetsRequest': { - '$': { - 'xmlns': xmlns - }, - 'ChangeBatch': [ - { - 'Changes': [ - { - 'Change': [ - { - 'Action': [ - 'DELETE' - ], - 'ResourceRecordSet': [ - { - 'Type': [ - 'TXT' - ], - 'Name': [ - challenge.dnsHost // AWS requires FQDN - ], - 'ResourceRecords': [ - { - 'ResourceRecord': [ - { - 'Value': [ - '"' + challenge.dnsAuthorization + '"' // value must be surrounded by double quotes - ] - } - ] - } - ], - 'TTL': [ - 300 - ] - } - ] - - } - ] - } - ] - } - ] - } - }; - - if(challenge.identifier.value.startsWith('foo')) { - if(challenge.wildcard) { - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [ - 1 - ]; - - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ - 'wildcard' - ]; - } else { - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [ - 1 - ]; - - parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [ - 'subdomain' - ]; - } + return api( + 'GET', + baseUri + '/' + 'hostedzonesbyname', + null, + 'dnsname=' + challenge.dnsZone + '&maxitems=1' + ) + .then(function(response) { + return parseString(response.body).then(function(body) { + return body; + }); + }) + .then(function(body) { + var zoneID = + body.ListHostedZonesByNameResponse.HostedZones[0] + .HostedZone[0].Id[0]; + return zoneID; + }) + .then(function(zoneID) { + return api( + 'GET', + baseUri + '/' + zoneID + '/rrset', + null, + 'name=' + + challenge.dnsPrefix + + '.' + + challenge.dnsZone + + '.' + + '&type=TXT' + ).then(function(response) { + return parseString(response.body).then(function(body) { + if ( + body.ListResourceRecordSetsResponse + .ResourceRecordSets[0] === '' + ) { + return null; } - var xml = builder.buildObject(parameters); - // console.log('\n', xml, '\n'); - - return api('POST', baseUri + zoneID + '/rrset/', xml); - - }); + var record = body.ListResourceRecordSetsResponse.ResourceRecordSets[0].ResourceRecordSet.filter( + function(element) { + return ( + element.ResourceRecords[0].ResourceRecord[0].Value[0].replace( + /\"/g, + '' + ) === challenge.dnsAuthorization && + element.Name[0].includes( + challenge.dnsPrefix + ) + ); + } + )[0]; + if (record) { + return { + dnsAuthorization: record.ResourceRecords[0].ResourceRecord[0].Value[0].replace( + /\"/g, + '' + ) + }; + } + + return null; + }); + }); + }); + }, + remove: function(data) { + var challenge = data.challenge; + + return api( + 'GET', + baseUri + '/' + 'hostedzonesbyname', + null, + 'dnsname=' + challenge.dnsZone + '&maxitems=1' + ) + .then(function(response) { + return parseString(response.body).then(function(body) { + return body; + }); + }) + .then(function(body) { + var zoneID = + body.ListHostedZonesByNameResponse.HostedZones[0] + .HostedZone[0].Id[0]; + var xmlns = body.ListHostedZonesByNameResponse.$.xmlns; + + var parameters = JSON.parse(JSON.stringify(XmlParameters)); + parameters.ChangeResourceRecordSetsRequest.$.xmlns = xmlns; + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].Action[0] = + 'DELETE'; + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Name[0] = + challenge.dnsHost; // AWS requires FQDN + parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].ResourceRecords[0].ResourceRecord[0].Value[0] = + '"' + challenge.dnsAuthorization + '"'; // value must be surrounded by double quotes + + // need to add weight and setidentifier to delete record set + if (challenge.identifier.value.startsWith('foo')) { + setWeightedPolicy(parameters, challenge.wildcard); + } + + var xml = builder.buildObject(parameters); + return api('POST', baseUri + zoneID + '/rrset/', xml); + }); } - - } - + }; }; diff --git a/package.json b/package.json index a92e017..97c7965 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,10 @@ "acme", "greenlock" ], - "author": "AJ ONeal (https://coolaj86.com/)", + "author": "Hitesh Walia (https://coolaj86.com/)" + ], "license": "MPL-2.0", "devDependencies": { "dotenv": "^8.1.0"