diff --git a/lib/index.js b/lib/index.js index e25856a..56182d4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,29 +1,329 @@ 'use strict'; -var request; -var defaults = {}; +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 = require('@root/request'); +//request.debug = false; + +var defaults = { + baseUri: '/2013-04-01/' +}; 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 + }; + + 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 + }; + + if(method === 'POST') { + parameters.headers = { 'Content-Type': 'application/xml' } + } + + if(body) { + parameters.body = body; + } + + // console.log(method, path, query); + + 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; + }); + } + + /* + api('GET', baseUri + '/' + 'hostedzone').then(function(response){ + console.log('######## RESPONSE #########'); + + }); + */ + return { - init: function(opts) { - request = opts.request; + init: function(options) { + request = options.request; return null; }, - zones: function(data) { + 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'); + // 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 + ] + } + ] + + } + ] + } + ] + } + ] + } + }; + + // 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 + ]; + + 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' + ]; + } + } + + var xml = builder.buildObject(parameters); + //console.log('xml: ' + xml); + + 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]; + // 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' + ]; + } + } + + var xml = builder.buildObject(parameters); + // console.log('\n', xml, '\n'); + + 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..a92e017 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "author": "AJ ONeal (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'