diff --git a/package-lock.json b/package-lock.json index f4fc74e0d..fd41f1dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1137,6 +1137,11 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" }, + "delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3919,9 +3924,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "safetydance": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/safetydance/-/safetydance-1.2.1.tgz", - "integrity": "sha512-Y0hli/NfjYsmpLMHr7dBhBFQ9Isn7cWZPRnL5/FyEifq5ZwYdu6Xgur//vRyRkyd3M7Jq1cE0QhgCa9EyQm79g==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/safetydance/-/safetydance-2.0.0.tgz", + "integrity": "sha512-DFuUlE/gRK1AsYtX7hSB2wYcBEoRgPt3uNF2okG34iLWWkfqaFrmTr0YT37XPtygWeC+gkpM9Zi/M92NYFPrcQ==" }, "sass-graph": { "version": "2.2.5", diff --git a/package.json b/package.json index 92732d634..9c78eb942 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "db-migrate": "^0.11.12", "db-migrate-mysql": "^2.1.2", "debug": "^4.3.1", + "delay": "^5.0.0", "dockerode": "^3.3.0", "ejs": "^3.1.6", "ejs-cli": "^2.2.1", @@ -57,7 +58,7 @@ "request": "^2.88.2", "rimraf": "^3.0.2", "s3-block-read-stream": "^0.5.0", - "safetydance": "^1.2.1", + "safetydance": "^2.0.0", "semver": "^7.3.5", "showdown": "^1.9.1", "speakeasy": "^2.0.0", diff --git a/src/cert/acme2.js b/src/cert/acme2.js index 5a598862d..b53d1a902 100644 --- a/src/cert/acme2.js +++ b/src/cert/acme2.js @@ -1,6 +1,6 @@ 'use strict'; -var assert = require('assert'), +const assert = require('assert'), async = require('async'), BoxError = require('../boxerror.js'), crypto = require('crypto'), @@ -9,7 +9,8 @@ var assert = require('assert'), fs = require('fs'), path = require('path'), paths = require('../paths.js'), - request = require('request'), + promiseRetry = require('../promise-retry.js'), + superagent = require('superagent'), safe = require('safetydance'), _ = require('underscore'); @@ -60,10 +61,9 @@ function getModulus(pem) { return Buffer.from(match[1], 'hex'); } -Acme2.prototype.sendSignedRequest = function (url, payload, callback) { +Acme2.prototype.sendSignedRequest = async function (url, payload) { assert.strictEqual(typeof url, 'string'); assert.strictEqual(typeof payload, 'string'); - assert.strictEqual(typeof callback, 'function'); assert(Buffer.isBuffer(this.accountKeyPem)); @@ -84,49 +84,42 @@ Acme2.prototype.sendSignedRequest = function (url, payload, callback) { }; } - var payload64 = b64(payload); + const payload64 = b64(payload); - request.get(this.directory.newNonce, { json: true, timeout: 30000 }, function (error, response) { - if (error) return callback(new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`)); - if (response.statusCode !== 204) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response code when fetching nonce : ' + response.statusCode)); + const response = await safe(superagent.get(this.directory.newNonce).timeout(30000).ok(() => true)); + if (!response) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${safe.error.message}`); + if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code when fetching nonce : ${response.status}`); - const nonce = response.headers['Replay-Nonce'.toLowerCase()]; - if (!nonce) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'No nonce in response')); + const nonce = response.headers['Replay-Nonce'.toLowerCase()]; + if (!nonce) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No nonce in response'); - debug('sendSignedRequest: using nonce %s for url %s', nonce, url); + debug('sendSignedRequest: using nonce %s for url %s', nonce, url); - var protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce }))); + const protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce }))); - var signer = crypto.createSign('RSA-SHA256'); - signer.update(protected64 + '.' + payload64, 'utf8'); - var signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64')); + const signer = crypto.createSign('RSA-SHA256'); + signer.update(protected64 + '.' + payload64, 'utf8'); + const signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64')); - var data = { - protected: protected64, - payload: payload64, - signature: signature64 - }; + const data = { + protected: protected64, + payload: payload64, + signature: signature64 + }; - request.post(url, { headers: { 'Content-Type': 'application/jose+json', 'User-Agent': 'acme-cloudron' }, body: JSON.stringify(data), timeout: 30000 }, function (error, response) { - if (error) return callback(new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`)); // network error + const response2 = await safe(superagent.post(url).send(data).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').timeout(30000).ok(() => true)); + if (!response2) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${safe.error.message}`); - // we don't set json: true in request because it ends up mangling the content-type - // we don't set json: true in request because it ends up mangling the content-type - if (response.headers['content-type'] === 'application/json') response.body = safe.JSON.parse(response.body); - - callback(null, response); - }); - }); + return response2; }; // https://tools.ietf.org/html/rfc8555#section-6.3 -Acme2.prototype.postAsGet = function (url, callback) { - this.sendSignedRequest(url, '', callback); +Acme2.prototype.postAsGet = async function (url) { + return await this.sendSignedRequest(url, ''); }; -Acme2.prototype.updateContact = function (registrationUri, callback) { +Acme2.prototype.updateContact = async function (registrationUri) { assert.strictEqual(typeof registrationUri, 'string'); - assert.strictEqual(typeof callback, 'function'); debug(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`); @@ -135,97 +128,81 @@ Acme2.prototype.updateContact = function (registrationUri, callback) { contact: [ 'mailto:' + this.email ] }; - const that = this; - this.sendSignedRequest(registrationUri, JSON.stringify(payload), function (error, result) { - if (error) return callback(error); - if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to update contact. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`)); + const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload)); + if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); - debug(`updateContact: contact of user updated to ${that.email}`); - - callback(); - }); + debug(`updateContact: contact of user updated to ${this.email}`); }; -Acme2.prototype.registerUser = function (callback) { - assert.strictEqual(typeof callback, 'function'); - - var payload = { +Acme2.prototype.registerUser = async function () { + const payload = { termsOfServiceAgreed: true }; debug('registerUser: registering user'); - var that = this; - this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload), function (error, result) { - if (error) return callback(error); - // 200 if already exists. 201 for new accounts - if (result.statusCode !== 200 && result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.statusCode} ${JSON.stringify(result.body)}`)); + const result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload)); + // 200 if already exists. 201 for new accounts + if (result.status !== 200 && result.status !== 201) return new BoxError(BoxError.EXTERNAL_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.status} ${JSON.stringify(result.body)}`); - debug(`registerUser: user registered keyid: ${result.headers.location}`); + debug(`registerUser: user registered keyid: ${result.headers.location}`); - that.keyId = result.headers.location; + this.keyId = result.headers.location; - that.updateContact(result.headers.location, callback); - }); + await this.updateContact(result.headers.location); }; -Acme2.prototype.newOrder = function (domain, callback) { +Acme2.prototype.newOrder = async function (domain) { assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof callback, 'function'); - var payload = { + const payload = { identifiers: [{ type: 'dns', value: domain }] }; - debug('newOrder: %s', domain); + debug(`newOrder: ${domain}`); - this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload), function (error, result) { - if (error) return callback(error); - if (result.statusCode === 403) return callback(new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`)); - if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`)); + const result = await this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload)); + if (result.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`); + if (result.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`); - debug('newOrder: created order %s %j', domain, result.body); + debug('newOrder: created order %s %j', domain, result.body); - const order = result.body, orderUrl = result.headers.location; + const order = result.body, orderUrl = result.headers.location; - if (!Array.isArray(order.authorizations)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'invalid authorizations in order')); - if (typeof order.finalize !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'invalid finalize in order')); - if (typeof orderUrl !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'invalid order location in order header')); + if (!Array.isArray(order.authorizations)) throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid authorizations in order'); + if (typeof order.finalize !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid finalize in order'); + if (typeof orderUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid order location in order header'); - callback(null, order, orderUrl); - }); + return { order, orderUrl }; }; -Acme2.prototype.waitForOrder = function (orderUrl, callback) { +Acme2.prototype.waitForOrder = async function (orderUrl) { assert.strictEqual(typeof orderUrl, 'string'); - assert.strictEqual(typeof callback, 'function'); debug(`waitForOrder: ${orderUrl}`); - const that = this; - async.retry({ times: 15, interval: 20000 }, function (retryCallback) { + return await promiseRetry({ times: 15, interval: 20000 }, async () => { debug('waitForOrder: getting status'); - that.postAsGet(orderUrl, function (error, result) { - if (error) { - debug('waitForOrder: network error getting uri %s', orderUrl); - return retryCallback(error); - } - if (result.statusCode !== 200) { - debug('waitForOrder: invalid response code getting uri %s', result.statusCode); - return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode)); - } + const result = await safe(this.postAsGet(orderUrl)); + if (!result) { + debug(`waitForOrder: network error getting uri ${orderUrl}`); + throw new BoxError(BoxError.EXTERNAL_ERROR, `Network error: ${safe.error.message}`); + } + if (result.status !== 200) { + debug(`waitForOrder: invalid response code getting uri ${result.status}`); + throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response code: ${result.status}`); + } - debug('waitForOrder: status is "%s %j', result.body.status, result.body); + debug('waitForOrder: status is "%s %j', result.body.status, result.body); - if (result.body.status === 'pending' || result.body.status === 'processing') return retryCallback(new BoxError(BoxError.TRY_AGAIN, `Request is in ${result.body.status} state`)); - else if (result.body.status === 'valid' && result.body.certificate) return retryCallback(null, result.body.certificate); - else return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unexpected status or invalid response: ' + result.body)); - }); - }, callback); + if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.TRY_AGAIN, `Request is in ${result.body.status} state`); + else if (result.body.status === 'valid' && result.body.certificate) return result.body.certificate; + else throw new BoxError(BoxError.EXTERNAL_ERROR, `Unexpected status or invalid response: ${JSON.stringify(result.body)}`); + }); }; Acme2.prototype.getKeyAuthorization = function (token) { @@ -243,65 +220,54 @@ Acme2.prototype.getKeyAuthorization = function (token) { return token + '.' + thumbprint; }; -Acme2.prototype.notifyChallengeReady = function (challenge, callback) { +Acme2.prototype.notifyChallengeReady = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token } - assert.strictEqual(typeof callback, 'function'); debug('notifyChallengeReady: %s was met', challenge.url); const keyAuthorization = this.getKeyAuthorization(challenge.token); - var payload = { + const payload = { resource: 'challenge', keyAuthorization: keyAuthorization }; - this.sendSignedRequest(challenge.url, JSON.stringify(payload), function (error, result) { - if (error) return callback(error); - if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`)); - - callback(); - }); + const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload)); + if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); }; -Acme2.prototype.waitForChallenge = function (challenge, callback) { +Acme2.prototype.waitForChallenge = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); - assert.strictEqual(typeof callback, 'function'); debug('waitingForChallenge: %j', challenge); - const that = this; - async.retry({ times: 15, interval: 20000 }, function (retryCallback) { + await promiseRetry({ times: 15, interval: 20000 }, async () => { debug('waitingForChallenge: getting status'); - that.postAsGet(challenge.url, function (error, result) { - if (error) { - debug('waitForChallenge: network error getting uri %s', challenge.url); - return retryCallback(error); - } - if (result.statusCode !== 200) { - debug('waitForChallenge: invalid response code getting uri %s', result.statusCode); - return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode)); - } + const result = await this.postAsGet(challenge.url); + if (!result) { + debug(`waitForChallenge: network error getting uri ${challenge.url}`); + throw new BoxError(BoxError.EXTERNAL_ERROR, `network error: ${safe.error.message}`); + } - debug('waitForChallenge: status is "%s" %j', result.body.status, result.body); + if (result.status !== 200) { + debug(`waitForChallenge: invalid response code getting uri ${result.status}`); + throw new BoxError(BoxError.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode); + } - if (result.body.status === 'pending') return retryCallback(new BoxError(BoxError.TRY_AGAIN)); - else if (result.body.status === 'valid') return retryCallback(); - else return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, 'Unexpected status: ' + result.body.status)); - }); - }, function retryFinished(error) { - // async.retry will pass 'undefined' as second arg making it unusable with async.waterfall() - callback(error); + debug(`waitForChallenge: status is "${result.body.status}" "${JSON.stringify(result.body)}"`); + + if (result.body.status === 'pending') throw new BoxError(BoxError.TRY_AGAIN); + else if (result.body.status === 'valid') return; + else throw new BoxError(BoxError.EXTERNAL_ERROR, `Unexpected status: ${result.body.status}`); }); }; // https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits -Acme2.prototype.signCertificate = function (domain, finalizationUrl, csrDer, callback) { +Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDer) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof finalizationUrl, 'string'); assert(Buffer.isBuffer(csrDer)); - assert.strictEqual(typeof callback, 'function'); const payload = { csr: b64(csrDer) @@ -309,18 +275,13 @@ Acme2.prototype.signCertificate = function (domain, finalizationUrl, csrDer, cal debug('signCertificate: sending sign request'); - this.sendSignedRequest(finalizationUrl, JSON.stringify(payload), function (error, result) { - if (error) return callback(error); - // 429 means we reached the cert limit for this domain - if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to sign certificate. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`)); - - return callback(null); - }); + const result = await this.sendSignedRequest(finalizationUrl, JSON.stringify(payload)); + // 429 means we reached the cert limit for this domain + if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); }; -Acme2.prototype.createKeyAndCsr = function (hostname, callback) { +Acme2.prototype.createKeyAndCsr = async function (hostname) { assert.strictEqual(typeof hostname, 'string'); - assert.strictEqual(typeof callback, 'function'); const outdir = paths.APP_CERTS_DIR; const certName = hostname.replace('*.', '_.'); @@ -332,8 +293,8 @@ Acme2.prototype.createKeyAndCsr = function (hostname, callback) { debug('createKeyAndCsr: reuse the key for renewal at %s', privateKeyFile); } else { let key = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1'); // openssl ecparam -list_curves - if (!key) return callback(new BoxError(BoxError.OPENSSL_ERROR, safe.error)); - if (!safe.fs.writeFileSync(privateKeyFile, key)) return callback(new BoxError(BoxError.FS_ERROR, safe.error)); + if (!key) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); + if (!safe.fs.writeFileSync(privateKeyFile, key)) throw new BoxError(BoxError.FS_ERROR, safe.error); debug('createKeyAndCsr: key file saved at %s', privateKeyFile); } @@ -345,52 +306,46 @@ Acme2.prototype.createKeyAndCsr = function (hostname, callback) { + ' -addext "keyUsage = nonRepudiation, digitalSignature, keyEncipherment"'; const csrDer = safe.child_process.execSync(`openssl req -new -key ${privateKeyFile} -outform DER -subj /CN=${hostname} ${extensionArgs}`); - if (!csrDer) return callback(new BoxError(BoxError.OPENSSL_ERROR, safe.error)); - if (!safe.fs.writeFileSync(csrFile, csrDer)) return callback(new BoxError(BoxError.FS_ERROR, safe.error)); // bookkeeping. inspect with openssl req -text -noout -in hostname.csr -inform der + if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); + if (!safe.fs.writeFileSync(csrFile, csrDer)) throw new BoxError(BoxError.FS_ERROR, safe.error); // bookkeeping. inspect with openssl req -text -noout -in hostname.csr -inform der debug('createKeyAndCsr: csr file (DER) saved at %s', csrFile); - callback(null, csrDer); + return csrDer; }; -Acme2.prototype.downloadCertificate = function (hostname, certUrl, callback) { +Acme2.prototype.downloadCertificate = async function (hostname, certUrl) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof certUrl, 'string'); - assert.strictEqual(typeof callback, 'function'); - var outdir = paths.APP_CERTS_DIR; - const that = this; + const outdir = paths.APP_CERTS_DIR; - async.retry({ times: 5, interval: 20000 }, function (retryCallback) { + await promiseRetry({ times: 5, interval: 20000 }, async () => { debug('downloadCertificate: downloading certificate'); - that.postAsGet(certUrl, function (error, result) { - if (error) return retryCallback(new BoxError(BoxError.NETWORK_ERROR, `Network error when downloading certificate: ${error.message}`)); - if (result.statusCode === 202) return retryCallback(new BoxError(BoxError.TRY_AGAIN, 'Retry downloading certificate')); - if (result.statusCode !== 200) return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`)); + const result = await safe(this.postAsGet(certUrl)); + if (!result) throw new BoxError(BoxError.NETWORK_ERROR, `Network error when downloading certificate: ${safe.error.message}`); + if (result.statusCode === 202) throw new BoxError(BoxError.TRY_AGAIN, 'Retry downloading certificate'); + if (result.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); - const fullChainPem = result.body; // buffer + const fullChainPem = result.body; // buffer - const certName = hostname.replace('*.', '_.'); - var certificateFile = path.join(outdir, `${certName}.cert`); - if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) return retryCallback(new BoxError(BoxError.FS_ERROR, safe.error)); + const certName = hostname.replace('*.', '_.'); + const certificateFile = path.join(outdir, `${certName}.cert`); + if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) throw new BoxError(BoxError.FS_ERROR, safe.error); - debug('downloadCertificate: cert file for %s saved at %s', hostname, certificateFile); - - retryCallback(null); - }); - }, callback); + debug(`downloadCertificate: cert file for ${hostname} saved at ${certificateFile}`); + }); }; -Acme2.prototype.prepareHttpChallenge = function (hostname, domain, authorization, callback) { +Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorization, 'object'); - assert.strictEqual(typeof callback, 'function'); debug('prepareHttpChallenge: challenges: %j', authorization); let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; }); - if (httpChallenges.length === 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'no http challenges')); + if (httpChallenges.length === 0) throw new BoxError(BoxError.EXTERNAL_ERROR, 'no http challenges'); let challenge = httpChallenges[0]; debug('prepareHttpChallenge: preparing for challenge %j', challenge); @@ -399,22 +354,19 @@ Acme2.prototype.prepareHttpChallenge = function (hostname, domain, authorization debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); - fs.writeFile(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization, function (error) { - if (error) return callback(new BoxError(BoxError.FS_ERROR, error)); - - callback(null, challenge); - }); + await safe(fs.promises.writeFile(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization)); + if (safe.error) throw new BoxError(BoxError.FS_ERROR, safe.error); + return challenge; }; -Acme2.prototype.cleanupHttpChallenge = function (hostname, domain, challenge, callback) { +Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); - assert.strictEqual(typeof callback, 'function'); debug('cleanupHttpChallenge: unlinking %s', path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); - fs.unlink(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), callback); + await fs.promises.unlink(path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); }; function getChallengeSubdomain(hostname, domain) { @@ -434,42 +386,42 @@ function getChallengeSubdomain(hostname, domain) { return challengeSubdomain; } -Acme2.prototype.prepareDnsChallenge = function (hostname, domain, authorization, callback) { +Acme2.prototype.prepareDnsChallenge = async function (hostname, domain, authorization) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorization, 'object'); - assert.strictEqual(typeof callback, 'function'); debug('prepareDnsChallenge: challenges: %j', authorization); - let dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; }); - if (dnsChallenges.length === 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'no dns challenges')); - let challenge = dnsChallenges[0]; + const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; }); + if (dnsChallenges.length === 0) throw new BoxError(BoxError.EXTERNAL_ERROR, 'no dns challenges'); + const challenge = dnsChallenges[0]; const keyAuthorization = this.getKeyAuthorization(challenge.token); - let shasum = crypto.createHash('sha256'); + const shasum = crypto.createHash('sha256'); shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); - let challengeSubdomain = getChallengeSubdomain(hostname, domain); + const challengeSubdomain = getChallengeSubdomain(hostname, domain); debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`); - domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { - if (error) return callback(error); + return new Promise((resolve, reject) => { + domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { + if (error) return reject(error); - domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 }, function (error) { - if (error) return callback(error); + domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 }, function (error) { + if (error) return reject(error); - callback(null, challenge); + resolve(challenge); + }); }); }); }; -Acme2.prototype.cleanupDnsChallenge = function (hostname, domain, challenge, callback) { +Acme2.prototype.cleanupDnsChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); - assert.strictEqual(typeof callback, 'function'); const keyAuthorization = this.getKeyAuthorization(challenge.token); let shasum = crypto.createHash('sha256'); @@ -480,112 +432,94 @@ Acme2.prototype.cleanupDnsChallenge = function (hostname, domain, challenge, cal debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`); - domains.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { - if (error) return callback(error); + return new Promise((resolve, reject) => { + domains.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { + if (error) return reject(error); - callback(null); + resolve(null); + }); }); }; -Acme2.prototype.prepareChallenge = function (hostname, domain, authorizationUrl, callback) { +Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorizationUrl, 'string'); - assert.strictEqual(typeof callback, 'function'); debug(`prepareChallenge: http: ${this.performHttpAuthorization}`); - const that = this; - this.postAsGet(authorizationUrl, function (error, response) { - if (error) return callback(error); - if (response.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response code getting authorization : ' + response.statusCode)); + const response = await this.postAsGet(authorizationUrl); + if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code getting authorization : ${response.status}`); - const authorization = response.body; + const authorization = response.body; - if (that.performHttpAuthorization) { - that.prepareHttpChallenge(hostname, domain, authorization, callback); - } else { - that.prepareDnsChallenge(hostname, domain, authorization, callback); - } - }); + if (this.performHttpAuthorization) { + return await this.prepareHttpChallenge(hostname, domain, authorization); + } else { + return await this.prepareDnsChallenge(hostname, domain, authorization); + } }; -Acme2.prototype.cleanupChallenge = function (hostname, domain, challenge, callback) { +Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); - assert.strictEqual(typeof callback, 'function'); debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`); if (this.performHttpAuthorization) { - this.cleanupHttpChallenge(hostname, domain, challenge, callback); + await this.cleanupHttpChallenge(hostname, domain, challenge); } else { - this.cleanupDnsChallenge(hostname, domain, challenge, callback); + await this.cleanupDnsChallenge(hostname, domain, challenge); } }; -Acme2.prototype.acmeFlow = function (hostname, domain, callback) { +Acme2.prototype.acmeFlow = async function (hostname, domain) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof callback, 'function'); - var that = this; - this.registerUser(function (error) { - if (error) return callback(error); + await this.registerUser(); + const { order, orderUrl } = await this.newOrder(hostname); - that.newOrder(hostname, function (error, order, orderUrl) { - if (error) return callback(error); + for (let i = 0; i < order.authorizations.length; i++) { + const authorizationUrl = order.authorizations[i]; + debug(`acmeFlow: authorizing ${authorizationUrl}`); - async.eachSeries(order.authorizations, function (authorizationUrl, iteratorCallback) { - debug(`acmeFlow: authorizing ${authorizationUrl}`); + const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl); + await this.notifyChallengeReady(challenge); + await this.waitForChallenge(challenge); + const csrDer = await this.createKeyAndCsr(hostname); + await this.signCertificate(hostname, order.finalize, csrDer); + const certUrl = await this.waitForOrder(orderUrl); + await this.downloadCertificate(hostname, certUrl); - that.prepareChallenge(hostname, domain, authorizationUrl, function (error, challenge) { - if (error) return iteratorCallback(error); + try { + await this.cleanupChallenge(hostname, domain, challenge); + } catch (cleanupError) { + debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError); + } + } +}; - async.waterfall([ - that.notifyChallengeReady.bind(that, challenge), - that.waitForChallenge.bind(that, challenge), - that.createKeyAndCsr.bind(that, hostname), - that.signCertificate.bind(that, hostname, order.finalize), - that.waitForOrder.bind(that, orderUrl), - that.downloadCertificate.bind(that, hostname) - ], function (error) { - that.cleanupChallenge(hostname, domain, challenge, function (cleanupError) { - if (cleanupError) debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError); +Acme2.prototype.loadDirectory = async function () { + await promiseRetry({ times: 3, interval: 20000 }, async () => { + const response = await safe(superagent.get(this.caDirectory).timeout(30000).ok(() => true)); - iteratorCallback(error); - }); - }); - }); - }, callback); - }); + if (!response) throw new BoxError(BoxError.NETWORK_ERROR, `Network error getting directory: ${safe.error.message}`); + + if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code when fetching directory : ${response.status}`); + + if (typeof response.body.newNonce !== 'string' || + typeof response.body.newOrder !== 'string' || + typeof response.body.newAccount !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response body : ${response.body}`); + + this.directory = response.body; }); }; -Acme2.prototype.getDirectory = function (callback) { - const that = this; - - async.retry({ times: 3, interval: 20000 }, function (retryCallback) { - request.get(that.caDirectory, { json: true, timeout: 30000 }, function (error, response) { - if (error) return retryCallback(new BoxError(BoxError.NETWORK_ERROR, `Network error getting directory: ${error.message}`)); - if (response.statusCode !== 200) return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response code when fetching directory : ' + response.statusCode)); - - if (typeof response.body.newNonce !== 'string' || - typeof response.body.newOrder !== 'string' || - typeof response.body.newAccount !== 'string') return retryCallback(new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response body : ${response.body}`)); - - that.directory = response.body; - - retryCallback(null); - }); - }, callback); -}; - -Acme2.prototype.getCertificate = function (vhost, domain, callback) { +Acme2.prototype.getCertificate = async function (vhost, domain) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof callback, 'function'); debug(`getCertificate: start acme flow for ${vhost} from ${this.caDirectory}`); @@ -594,18 +528,13 @@ Acme2.prototype.getCertificate = function (vhost, domain, callback) { debug(`getCertificate: will get wildcard cert for ${vhost}`); } - const that = this; - this.getDirectory(function (error) { - if (error) return callback(error); + await this.loadDirectory(); + await this.acmeFlow(vhost, domain); - that.acmeFlow(vhost, domain, function (error) { - if (error) return callback(error); + const outdir = paths.APP_CERTS_DIR; + const certName = vhost.replace('*.', '_.'); - var outdir = paths.APP_CERTS_DIR; - const certName = vhost.replace('*.', '_.'); - callback(null, path.join(outdir, `${certName}.cert`), path.join(outdir, `${certName}.key`)); - }); - }); + return { certFilePath: path.join(outdir, `${certName}.cert`), keyFilePath: path.join(outdir, `${certName}.key`) }; }; function getCertificate(vhost, domain, options, callback) { @@ -619,6 +548,8 @@ function getCertificate(vhost, domain, options, callback) { debug(`getCertificate: attempt ${attempt++}`); let acme = new Acme2(options || { }); - acme.getCertificate(vhost, domain, retryCallback); + acme.getCertificate(vhost, domain).then((result) => { + callback(null, result.certFilePath, result.keyFilePath); + }).catch(retryCallback); }, callback); } diff --git a/src/promise-retry.js b/src/promise-retry.js new file mode 100644 index 000000000..c2a47772f --- /dev/null +++ b/src/promise-retry.js @@ -0,0 +1,23 @@ +'use strict'; + +exports = module.exports = promiseRetry; + +const assert = require('assert'), + delay = require('delay'), + util = require('util'); + +async function promiseRetry(options, asyncFunction) { + assert.strictEqual(typeof options, 'object'); + assert(util.types.isAsyncFunction(asyncFunction)); + + const { times, interval } = options; + + for (let i = 0; i < times; i++) { + try { + return await asyncFunction(); + } catch (error) { + if (i === times - 1) throw error; + await delay(interval); + } + } +} diff --git a/src/test/promise-retry-test.js b/src/test/promise-retry-test.js new file mode 100644 index 000000000..c0560990f --- /dev/null +++ b/src/test/promise-retry-test.js @@ -0,0 +1,38 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ + +'use strict'; + +const expect = require('expect.js'), + promiseRetry = require('../promise-retry.js'), + safe = require('safetydance'); + +describe('promiseRetry', function () { + this.timeout(0); + + it('normal return', async function () { + const result = await promiseRetry({ times: 5, interval: 1000 }, async () => { + return 42; + }); + + expect(result).to.be(42); + }); + + it('throws error', async function () { + await safe(promiseRetry({ times: 5, interval: 1000 }, async () => { + throw new Error('42'); + })); + + expect(safe.error.message).to.be('42'); + }); + + it('3 tries', async function () { + let tryCount = 0; + const result = await promiseRetry({ times: 5, interval: 1000 }, async () => { + if (++tryCount == 3) return 42; else throw new Error('42'); + }); + + expect(result).to.be(42); + }); +});