diff --git a/src/cert/acme2.js b/src/cert/acme2.js index 39df54305..7ad98dad7 100644 --- a/src/cert/acme2.js +++ b/src/cert/acme2.js @@ -21,7 +21,8 @@ exports = module.exports = { getCertificate: getCertificate, // testing - _name: 'acme' + _name: 'acme', + _getChallengeSubdomain: getChallengeSubdomain }; function Acme2Error(reason, errorOrMessage) { @@ -435,11 +436,14 @@ function getChallengeSubdomain(hostname, domain) { if (hostname === domain) { challengeSubdomain = '_acme-challenge'; } else if (hostname.includes('*')) { // wildcard - challengeSubdomain = hostname.replace('*', '_acme-challenge').slice(0, -domain.length - 1); + let subdomain = hostname.slice(0, -domain.length - 1); + challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge'; } else { challengeSubdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1); } + debug(`getChallengeSubdomain: challenge subdomain for hostname ${hostname} at domain ${domain} is ${challengeSubdomain}`); + return challengeSubdomain; } @@ -466,7 +470,7 @@ Acme2.prototype.prepareDnsChallenge = function (hostname, domain, authorization, domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); - domains.waitForDnsRecord(`${challengeSubdomain}`, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) { + domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) { if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); callback(null, challenge); diff --git a/src/domains.js b/src/domains.js index 4d1048c93..0fff1737f 100644 --- a/src/domains.js +++ b/src/domains.js @@ -26,7 +26,10 @@ module.exports = exports = { makeWildcard: makeWildcard, - DomainsError: DomainsError + DomainsError: DomainsError, + + // exported for testing + _getName: getName }; var assert = require('assert'), @@ -347,19 +350,25 @@ function del(domain, callback) { // returns the 'name' that needs to be inserted into zone function getName(domain, subdomain, type) { - // support special caas domains + // hack for supporting special caas domains. if we want to remove this, we have to fix the appstore domain API first if (domain.provider === 'caas') return subdomain; - if (domain.domain === domain.zoneName) return subdomain; + const part = domain.domain.slice(0, -domain.zoneName.length - 1); - var part = domain.domain.slice(0, -domain.zoneName.length - 1); + if (subdomain === '') return part; - if (subdomain === '') { - return part; - } else if (type === 'TXT') { - return `${subdomain}.${part}`; + if (!domain.config.hyphenatedSubdomains) return part ? `${subdomain}.${part}` : subdomain; + + // hyphenatedSubdomains + if (type !== 'TXT') return `${subdomain}-${part}`; + + if (subdomain.startsWith('_acme-challenge.')) { + return `${subdomain}-${part}`; + } else if (subdomain === '_acme-challenge') { + const up = part.replace(/^[^.]*\.?/, ''); // this gets the domain one level up + return up ? `${subdomain}.${up}` : subdomain; } else { - return subdomain + (domain.config.hyphenatedSubdomains ? '-' : '.') + part; + return `${subdomain}.${part}`; } } @@ -432,7 +441,8 @@ function waitForDnsRecord(subdomain, domain, type, value, options, callback) { get(domain, function (error, domainObject) { if (error) return callback(error); - const hostname = fqdn(subdomain, domainObject); + const name = getName(domainObject, subdomain, type); + const hostname = `${name}.${domainObject.zoneName}`; api(domainObject.provider).waitForDns(hostname, domainObject.zoneName, type, value, options, callback); }); diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 3c286fa99..db4a49062 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -34,7 +34,7 @@ var acme2 = require('./cert/acme2.js'), config = require('./config.js'), constants = require('./constants.js'), crypto = require('crypto'), - debug = require('debug')('box:certificates'), + debug = require('debug')('box:reverseproxy'), domains = require('./domains.js'), ejs = require('ejs'), eventlog = require('./eventlog.js'), diff --git a/src/test/acme2-test.js b/src/test/acme2-test.js new file mode 100644 index 000000000..80bac860b --- /dev/null +++ b/src/test/acme2-test.js @@ -0,0 +1,44 @@ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var async = require('async'), + config = require('../config.js'), + database = require('../database.js'), + acme2 = require('../cert/acme2.js'), + expect = require('expect.js'), + _ = require('underscore'); + +describe('Acme2', function () { + before(function (done) { + config._reset(); + + async.series([ + database.initialize, + database._clear + ], done); + }); + + after(function (done) { + async.series([ + database._clear, + database.uninitialize + ], done); + }); + + describe('getChallengeSubdomain', function () { + it('non-wildcard', function () { + expect(acme2._getChallengeSubdomain('example.com', 'example.com')).to.be('_acme-challenge'); + expect(acme2._getChallengeSubdomain('git.example.com', 'example.com')).to.be('_acme-challenge.git'); + }); + + it('wildcard', function () { + expect(acme2._getChallengeSubdomain('*.example.com', 'example.com')).to.be('_acme-challenge'); + expect(acme2._getChallengeSubdomain('*.git.example.com', 'example.com')).to.be('_acme-challenge.git'); + expect(acme2._getChallengeSubdomain('*.example.com', 'customer.example.com')).to.be('_acme-challenge'); // for hyphenatedSubdomains + }); + }); +}); diff --git a/src/test/domains-test.js b/src/test/domains-test.js index 4ca8022e8..dc096d04e 100644 --- a/src/test/domains-test.js +++ b/src/test/domains-test.js @@ -29,13 +29,13 @@ describe('Domains', function () { ], done); }); - const domain = { - domain: 'example.com', - zoneName: 'example.com', - config: {} - }; - describe('validateHostname', function () { + const domain = { + domain: 'example.com', + zoneName: 'example.com', + config: {} + }; + it('does not allow admin subdomain', function () { config.setFqdn('example.com'); config.setAdminFqdn('my.example.com'); @@ -85,4 +85,109 @@ describe('Domains', function () { expect(domains.validateHostname('a0.x', domain)).to.be.an(Error); }); }); + + describe('getName', function () { + it('works with zoneName==domain (not hyphenated)', function () { + const domain = { + domain: 'example.com', + zoneName: 'example.com', + config: {} + }; + + expect(domains._getName(domain, '', 'A')).to.be(''); + expect(domains._getName(domain, 'www', 'A')).to.be('www'); + expect(domains._getName(domain, 'www.dev', 'A')).to.be('www.dev'); + + expect(domains._getName(domain, '', 'MX')).to.be(''); + + expect(domains._getName(domain, '', 'TXT')).to.be(''); + expect(domains._getName(domain, 'www', 'TXT')).to.be('www'); + expect(domains._getName(domain, 'www.dev', 'TXT')).to.be('www.dev'); + }); + + it('works when zoneName!=domain (not hyphenated)', function () { + const domain = { + domain: 'dev.example.com', + zoneName: 'example.com', + config: {} + }; + + expect(domains._getName(domain, '', 'A')).to.be('dev'); + expect(domains._getName(domain, 'www', 'A')).to.be('www.dev'); + expect(domains._getName(domain, 'www.dev', 'A')).to.be('www.dev.dev'); + + expect(domains._getName(domain, '', 'MX')).to.be('dev'); + + expect(domains._getName(domain, '', 'TXT')).to.be('dev'); + expect(domains._getName(domain, 'www', 'TXT')).to.be('www.dev'); + expect(domains._getName(domain, 'www.dev', 'TXT')).to.be('www.dev.dev'); + }); + + it('works when hyphenated - level1', function () { + const domain = { + domain: 'customer.example.com', + zoneName: 'example.com', + config: { + hyphenatedSubdomains: true + } + }; + + expect(domains._getName(domain, '', 'A')).to.be('customer'); + expect(domains._getName(domain, 'www', 'A')).to.be('www-customer'); + expect(domains._getName(domain, 'www.dev', 'A')).to.be('www.dev-customer'); + + expect(domains._getName(domain, '', 'MX')).to.be('customer'); + + expect(domains._getName(domain, '', 'TXT')).to.be('customer'); + expect(domains._getName(domain, '_dmarc', 'TXT')).to.be('_dmarc.customer'); + expect(domains._getName(domain, 'cloudron._domainkey', 'TXT')).to.be('cloudron._domainkey.customer'); + expect(domains._getName(domain, '_acme-challenge.my', 'TXT')).to.be('_acme-challenge.my-customer'); + expect(domains._getName(domain, '_acme-challenge', 'TXT')).to.be('_acme-challenge'); + }); + + it('works when hyphenated - level2', function () { + const domain = { + domain: 'customer.dev.example.com', + zoneName: 'example.com', + config: { + hyphenatedSubdomains: true + } + }; + + expect(domains._getName(domain, '', 'A')).to.be('customer.dev'); + expect(domains._getName(domain, 'www', 'A')).to.be('www-customer.dev'); + expect(domains._getName(domain, 'www.dev', 'A')).to.be('www.dev-customer.dev'); + + expect(domains._getName(domain, '', 'MX')).to.be('customer.dev'); + + expect(domains._getName(domain, '', 'TXT')).to.be('customer.dev'); + expect(domains._getName(domain, '_dmarc', 'TXT')).to.be('_dmarc.customer.dev'); + expect(domains._getName(domain, 'cloudron._domainkey', 'TXT')).to.be('cloudron._domainkey.customer.dev'); + expect(domains._getName(domain, '_acme-challenge.my', 'TXT')).to.be('_acme-challenge.my-customer.dev'); + expect(domains._getName(domain, '_acme-challenge', 'TXT')).to.be('_acme-challenge.dev'); + }); + + it('works with caas', function () { + const domain = { + domain: 'customer.example.com', + provider: 'caas', + zoneName: 'example.com', + config: { + hyphenatedSubdomains: true + } + }; + + expect(domains._getName(domain, '', 'A')).to.be(''); + expect(domains._getName(domain, 'www', 'A')).to.be('www'); + expect(domains._getName(domain, 'www.dev', 'A')).to.be('www.dev'); + + expect(domains._getName(domain, '', 'MX')).to.be(''); + + expect(domains._getName(domain, '', 'TXT')).to.be(''); + expect(domains._getName(domain, '_dmarc', 'TXT')).to.be('_dmarc'); + expect(domains._getName(domain, 'cloudron._domainkey', 'TXT')).to.be('cloudron._domainkey'); + expect(domains._getName(domain, '_acme-challenge.my', 'TXT')).to.be('_acme-challenge.my'); + expect(domains._getName(domain, '_acme-challenge', 'TXT')).to.be('_acme-challenge'); + }); + }); });