diff --git a/src/cert/acme2.js b/src/cert/acme2.js index 7ff33c857..251f4c87c 100644 --- a/src/cert/acme2.js +++ b/src/cert/acme2.js @@ -62,6 +62,7 @@ function Acme2(options) { this.caDirectory = options.prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL; this.directory = {}; this.performHttpAuthorization = !!options.performHttpAuthorization; + this.wildcard = !!options.wildcard; } Acme2.prototype.getNonce = function (callback) { @@ -335,13 +336,14 @@ Acme2.prototype.signCertificate = function (domain, finalizationUrl, csrDer, cal }); }; -Acme2.prototype.createKeyAndCsr = function (domain, callback) { - assert.strictEqual(typeof domain, 'string'); +Acme2.prototype.createKeyAndCsr = function (hostname, callback) { + assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof callback, 'function'); var outdir = paths.APP_CERTS_DIR; - var csrFile = path.join(outdir, domain + '.csr'); - var privateKeyFile = path.join(outdir, domain + '.key'); + const certName = hostname.replace('*.', '_.'); + var csrFile = path.join(outdir, `${certName}.csr`); + var privateKeyFile = path.join(outdir, `${certName}.key`); if (safe.fs.existsSync(privateKeyFile)) { // in some old releases, csr file was corrupt. so always regenerate it @@ -354,7 +356,7 @@ Acme2.prototype.createKeyAndCsr = function (domain, callback) { debug('createKeyAndCsr: key file saved at %s', privateKeyFile); } - var csrDer = execSync(util.format('openssl req -new -key %s -outform DER -subj /CN=%s', privateKeyFile, domain)); + var csrDer = execSync(`openssl req -new -key ${privateKeyFile} -outform DER -subj /CN=${hostname}`); if (!csrDer) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error)); if (!safe.fs.writeFileSync(csrFile, csrDer)) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error)); // bookkeeping @@ -363,8 +365,8 @@ Acme2.prototype.createKeyAndCsr = function (domain, callback) { callback(null, csrDer); }; -Acme2.prototype.downloadCertificate = function (domain, certUrl, callback) { - assert.strictEqual(typeof domain, 'string'); +Acme2.prototype.downloadCertificate = function (hostname, certUrl, callback) { + assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof certUrl, 'string'); assert.strictEqual(typeof callback, 'function'); @@ -381,10 +383,11 @@ Acme2.prototype.downloadCertificate = function (domain, certUrl, callback) { const fullChainPem = result.text; - var certificateFile = path.join(outdir, domain + '.cert'); + const certName = hostname.replace('*.', '_.'); + var certificateFile = path.join(outdir, `${certName}.cert`); if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) return callback(new Acme2Error(Acme2Error.INTERNAL_ERROR, safe.error)); - debug('downloadCertificate: cert file for %s saved at %s', domain, certificateFile); + debug('downloadCertificate: cert file for %s saved at %s', hostname, certificateFile); callback(); }); @@ -441,14 +444,15 @@ Acme2.prototype.prepareDnsChallenge = function (hostname, domain, authorization, shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); - const subdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1); + const subdomain = hostname.slice(0, -domain.length - 1).replace('*', ''); + const challengeSubdomain = `_acme-challenge${subdomain}`; - debug(`prepareDnsChallenge: update ${subdomain}} with ${txtValue}`); + debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`); - domains.upsertDnsRecords(subdomain, domain, 'TXT', [ txtValue ], function (error) { + domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ txtValue ], function (error) { if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); - domains.waitForDnsRecord(`${subdomain}.${domain}`, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) { + domains.waitForDnsRecord(`${challengeSubdomain}.${domain}`, domain, 'TXT', txtValue, { interval: 5000, times: 200 }, function (error) { if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error.message)); callback(null, challenge); @@ -467,11 +471,12 @@ Acme2.prototype.cleanupDnsChallenge = function (hostname, domain, challenge, cal shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); - const subdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1); + const subdomain = hostname.slice(0, -domain.length - 1).replace('*', ''); + const challengeSubdomain = `_acme-challenge${subdomain}`; - debug(`prepareDnsChallenge: remove ${subdomain} with ${txtValue}`); + debug(`cleanupDnsChallenge: remove ${subdomain} with ${txtValue}`); - domains.removeDnsRecords(subdomain, domain, 'TXT', [ txtValue ], function (error) { + domains.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ txtValue ], function (error) { if (error) return callback(new Acme2Error(Acme2Error.EXTERNAL_ERROR, error)); callback(null, challenge); @@ -585,6 +590,11 @@ Acme2.prototype.getCertificate = function (hostname, domain, callback) { debug(`getCertificate: start acme flow for ${hostname} from ${this.caDirectory}`); + if (hostname !== domain && this.wildcard) { + hostname = domains.makeWildcard(hostname); + debug(`getCertificate: will get wildcard cert for ${hostname}`); + } + const that = this; this.getDirectory(function (error) { if (error) return callback(error); @@ -593,7 +603,8 @@ Acme2.prototype.getCertificate = function (hostname, domain, callback) { if (error) return callback(error); var outdir = paths.APP_CERTS_DIR; - callback(null, path.join(outdir, hostname + '.cert'), path.join(outdir, hostname + '.key')); + const certName = hostname.replace('*.', '_.'); + callback(null, path.join(outdir, `${certName}.cert`), path.join(outdir, `${certName}.key`)); }); }); }; diff --git a/src/domains.js b/src/domains.js index 733cf6a74..60e9056da 100644 --- a/src/domains.js +++ b/src/domains.js @@ -22,6 +22,8 @@ module.exports = exports = { validateHostname: validateHostname, + makeWildcard: makeWildcard, + DomainsError: DomainsError }; @@ -462,3 +464,11 @@ function removeRestrictedFields(domain) { return result; } + +function makeWildcard(hostname) { + assert.strictEqual(typeof hostname, 'string'); + + let parts = hostname.split('.'); + parts[0] = '*'; + return parts.join('.'); +} diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 061e7f8af..451938f15 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -92,6 +92,7 @@ function getCertApi(domain, callback) { if (result.tlsConfig.provider !== 'caas') { // matches 'le-prod' or 'letsencrypt-prod' options.prod = result.tlsConfig.provider.match(/.*-prod/) !== null; options.performHttpAuthorization = result.provider.match(/noop|manual|wildcard/) !== null; + options.wildcard = !!result.tlsConfig.wildcard; } // registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197) @@ -217,6 +218,12 @@ function getCertificate(app, callback) { if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath }); + let certName = domains.makeWildcard(app.fqdn).replace('*.', '_.'); + certFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.cert`); + keyFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.key`); + + if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath }); + return getFallbackCertificate(app.domain, callback); } @@ -245,7 +252,7 @@ function ensureCertificate(appDomain, auditSource, callback) { if (!isExpiringSync(certFilePath, 24 * 30)) return callback(null, { certFilePath, keyFilePath, reason: 'existing-le' }); debug('ensureCertificate: %s cert require renewal', vhost); - } else { + } else { // FIXME: check wildcard cert debug('ensureCertificate: %s cert does not exist', vhost); } diff --git a/src/routes/domains.js b/src/routes/domains.js index 20e3130b9..c63c75d1c 100644 --- a/src/routes/domains.js +++ b/src/routes/domains.js @@ -32,6 +32,7 @@ function add(req, res, next) { if (!req.body.config || typeof req.body.config !== 'object') return next(new HttpError(400, 'config must be an object')); if ('hyphenatedSubdomains' in req.body.config && typeof req.body.config.hyphenatedSubdomains !== 'boolean') return next(new HttpError(400, 'hyphenatedSubdomains must be a boolean')); + if ('wildcard' in req.body.config && typeof req.body.config.wildcard !== 'boolean') return next(new HttpError(400, 'wildcard must be a boolean')); if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be a string')); if ('fallbackCertificate' in req.body && typeof req.body.fallbackCertificate !== 'object') return next(new HttpError(400, 'fallbackCertificate must be a object with cert and key strings')); @@ -85,6 +86,7 @@ function update(req, res, next) { if (!req.body.config || typeof req.body.config !== 'object') return next(new HttpError(400, 'config must be an object')); if ('hyphenatedSubdomains' in req.body.config && typeof req.body.config.hyphenatedSubdomains !== 'boolean') return next(new HttpError(400, 'hyphenatedSubdomains must be a boolean')); + if ('wildcard' in req.body.config && typeof req.body.config.wildcard !== 'boolean') return next(new HttpError(400, 'wildcard must be a boolean')); if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be a string')); if ('fallbackCertificate' in req.body && typeof req.body.fallbackCertificate !== 'object') return next(new HttpError(400, 'fallbackCertificate must be a object with cert and key strings'));