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 e25856a..e1f2802 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,29 +1,293 @@ 'use strict'; +var v4 = require('aws-signature-v4'); +var xml2js = require('xml2js'); +var parseString = xml2js.parseString; +parseString = require('util').promisify(parseString); +var builder = new xml2js.Builder(); var request; -var defaults = {}; + +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; + var secret = config.secret; + + function api(method, path, body, query) { + var body = body || ''; + var query = query || {}; + var options = { + key: key, + secret: secret, + query: query + }; + var parameters = { + method: method + }; + + if (method === 'POST') { + options.headers = { 'Content-Type': 'application/xml' }; + parameters.headers = { 'Content-Type': 'application/xml' }; + } + + if (body) { + parameters.body = body; + } + + 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) { + console.error(response.statusCode, options.url); + console.error(); + console.error('Request:'); + console.error(options); + console.error(); + console.error('Response:'); + console.error(response.body); + console.error(); + throw new Error( + 'Error response. Check token, baseUri, domains, etc.' + ); + } + return response; + }); + } + return { - init: function(opts) { - request = opts.request; + init: function(options) { + request = options.request; return null; }, zones: function(data) { - //console.info('List Zones', data); - throw Error('listing zones not implemented'); + 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); - throw Error('setting TXT not implemented'); - }, - remove: function(data) { - // console.info('Remove TXT', data); - throw Error('removing TXT not implemented'); + 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 = 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 + + // 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); + } + + var xml = builder.buildObject(parameters); + return api('POST', baseUri + zoneID + '/rrset/', xml); + }); }, get: function(data) { - // console.info('List TXT', data); - throw Error('listing TXTs not implemented'); + 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]; + 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 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-lock.json b/package-lock.json new file mode 100644 index 0000000..c465eba --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "acme-dns-01-route53", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@root/request": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz", + "integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw==" + }, + "acme-challenge-test": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/acme-challenge-test/-/acme-challenge-test-3.3.2.tgz", + "integrity": "sha512-0AbMcaON20wpI5vzFDAqwcv2VerY4xIlNCqX0w1xEJUIu/EQtQNmkje+rKNuy2TUl2KBMdIaR6YBbJUdaEiC4w==", + "requires": { + "@root/request": "^1.3.11" + } + }, + "aws-signature-v4": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/aws-signature-v4/-/aws-signature-v4-1.4.0.tgz", + "integrity": "sha512-OpL4svs8b7ENRoC0DJzwXW7JTDSp+PkYD1sOWT47CsDxHFtmDaydK9zkLwl4LXNHKXnlzbNCvbOwP7lqgFEE2Q==" + }, + "dotenv": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.1.0.tgz", + "integrity": "sha512-GUE3gqcDCaMltj2++g6bRQ5rBJWtkWTmqmD0fo1RnnMuUqHNCt2oTPeDnS9n6fKYvlhn7AeBkb38lymBtWBQdA==", + "dev": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + } + } +} diff --git a/package.json b/package.json index 1e9b7e1..97c7965 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,17 @@ "acme", "greenlock" ], - "author": "AJ ONeal (https://coolaj86.com/)", + "author": "Hitesh Walia (https://coolaj86.com/)" + ], "license": "MPL-2.0", "devDependencies": { - "dotenv": "^8.0.0" + "dotenv": "^8.1.0" + }, + "dependencies": { + "acme-challenge-test": "^3.3.2", + "aws-signature-v4": "^1.4.0", + "xml2js": "^0.4.19" } } diff --git a/test.js b/test.js index b794bdf..5192918 100755 --- a/test.js +++ b/test.js @@ -3,11 +3,13 @@ // See https://git.coolaj86.com/coolaj86/acme-challenge-test.js var tester = require('acme-challenge-test'); +require('dotenv').config(); -// Usage: node ./test.js example.com xxxxxxxxx +// Usage: node ./test.js example.com key secret var zone = process.argv[2] || process.env.ZONE; var challenger = require('./index.js').create({ - token: process.argv[3] || process.env.TOKEN + key: process.argv[3] || process.env.KEY, + secret: process.argv[4] || process.env.SECRET }); // The dry-run tests can pass on, literally, 'example.com'