9c8f78a059
An issue was that mail container was not getting refreshed with the up to date certs. The root cause is that it is refreshed only in the renewCerts() cron job. If cert renewal was caused by an app task, then the cron job will skip the restart (since cert is fresh). The other issue is that we keep hitting 0 length certs when we run out of disk space. The root cause is that when out of disk space, a cert renewal will cause cert to be written but since it has no space it is 0 length. Then, when the user tries to restart the server, the box code does not write the cert again. This change fixes the above two including: * To simplify, we use the fallback cert only if we failed to get a LE cert. Expired LE certs will continue to be used. nginx is fine with this. * restart directory as well on renewal
555 lines
24 KiB
JavaScript
555 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
getCertificate,
|
|
|
|
// testing
|
|
_name: 'acme',
|
|
_getChallengeSubdomain: getChallengeSubdomain
|
|
};
|
|
|
|
const assert = require('assert'),
|
|
blobs = require('./blobs.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
crypto = require('crypto'),
|
|
debug = require('debug')('box:cert/acme2'),
|
|
dns = require('./dns.js'),
|
|
fs = require('fs'),
|
|
os = require('os'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
promiseRetry = require('./promise-retry.js'),
|
|
superagent = require('superagent'),
|
|
safe = require('safetydance'),
|
|
_ = require('underscore');
|
|
|
|
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
|
|
CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory';
|
|
|
|
// http://jose.readthedocs.org/en/latest/
|
|
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
|
|
// https://community.letsencrypt.org/t/list-of-client-implementations/2103
|
|
|
|
function Acme2(options) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
this.accountKeyPem = null; // Buffer .
|
|
this.email = options.email;
|
|
this.keyId = null;
|
|
this.caDirectory = options.prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
|
|
this.directory = {};
|
|
this.performHttpAuthorization = !!options.performHttpAuthorization;
|
|
this.wildcard = !!options.wildcard;
|
|
}
|
|
|
|
// urlsafe base64 encoding (jose)
|
|
function urlBase64Encode(string) {
|
|
return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
}
|
|
|
|
function b64(str) {
|
|
const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
|
|
return urlBase64Encode(buf.toString('base64'));
|
|
}
|
|
|
|
function getModulus(pem) {
|
|
assert(Buffer.isBuffer(pem));
|
|
|
|
const stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
|
|
if (!stdout) return null;
|
|
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
|
|
if (!match) return null;
|
|
return Buffer.from(match[1], 'hex');
|
|
}
|
|
|
|
Acme2.prototype.sendSignedRequest = async function (url, payload) {
|
|
assert.strictEqual(typeof url, 'string');
|
|
assert.strictEqual(typeof payload, 'string');
|
|
|
|
assert(Buffer.isBuffer(this.accountKeyPem));
|
|
|
|
const that = this;
|
|
let header = {
|
|
url: url,
|
|
alg: 'RS256'
|
|
};
|
|
|
|
// keyId is null when registering account
|
|
if (this.keyId) {
|
|
header.kid = this.keyId;
|
|
} else {
|
|
header.jwk = {
|
|
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
|
|
kty: 'RSA',
|
|
n: b64(getModulus(this.accountKeyPem))
|
|
};
|
|
}
|
|
|
|
const payload64 = b64(payload);
|
|
|
|
let [error, response] = await safe(superagent.get(this.directory.newNonce).timeout(30000).ok(() => true));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`);
|
|
if (response.status !== 204) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching nonce : ${response.status}`);
|
|
|
|
const nonce = response.headers['Replay-Nonce'.toLowerCase()];
|
|
if (!nonce) throw new BoxError(BoxError.ACME_ERROR, 'No nonce in response');
|
|
|
|
debug('sendSignedRequest: using nonce %s for url %s', nonce, url);
|
|
|
|
const protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce })));
|
|
|
|
const signer = crypto.createSign('RSA-SHA256');
|
|
signer.update(protected64 + '.' + payload64, 'utf8');
|
|
const signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64'));
|
|
|
|
const data = {
|
|
protected: protected64,
|
|
payload: payload64,
|
|
signature: signature64
|
|
};
|
|
|
|
[error, response] = await safe(superagent.post(url).send(data).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').timeout(30000).ok(() => true));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`);
|
|
|
|
return response;
|
|
};
|
|
|
|
// https://tools.ietf.org/html/rfc8555#section-6.3
|
|
Acme2.prototype.postAsGet = async function (url) {
|
|
return await this.sendSignedRequest(url, '');
|
|
};
|
|
|
|
Acme2.prototype.updateContact = async function (registrationUri) {
|
|
assert.strictEqual(typeof registrationUri, 'string');
|
|
|
|
debug(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`);
|
|
|
|
// https://github.com/ietf-wg-acme/acme/issues/30
|
|
const payload = {
|
|
contact: [ 'mailto:' + this.email ]
|
|
};
|
|
|
|
const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload));
|
|
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`);
|
|
|
|
debug(`updateContact: contact of user updated to ${this.email}`);
|
|
};
|
|
|
|
async function generateAccountKey() {
|
|
const acmeAccountKey = safe.child_process.execSync('openssl genrsa 4096');
|
|
if (!acmeAccountKey) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate acme account key: ${safe.error.message}`);
|
|
return acmeAccountKey;
|
|
}
|
|
|
|
Acme2.prototype.ensureAccount = async function () {
|
|
const payload = {
|
|
termsOfServiceAgreed: true
|
|
};
|
|
|
|
debug('ensureAccount: registering user');
|
|
|
|
this.accountKeyPem = await blobs.get(blobs.ACME_ACCOUNT_KEY);
|
|
if (!this.accountKeyPem) {
|
|
debug('ensureAccount: generating new account keys');
|
|
this.accountKeyPem = await generateAccountKey();
|
|
await blobs.set(blobs.ACME_ACCOUNT_KEY, this.accountKeyPem);
|
|
}
|
|
|
|
let result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload));
|
|
if (result.status === 403 && result.body.type === 'urn:ietf:params:acme:error:unauthorized') {
|
|
debug(`ensureAccount: key was revoked. ${result.status} ${JSON.stringify(result.body)}. generating new account key`);
|
|
this.accountKeyPem = await generateAccountKey();
|
|
await blobs.set(blobs.ACME_ACCOUNT_KEY, this.accountKeyPem);
|
|
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) throw new BoxError(BoxError.ACME_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.status} ${JSON.stringify(result.body)}`);
|
|
|
|
debug(`ensureAccount: user registered keyid: ${result.headers.location}`);
|
|
|
|
this.keyId = result.headers.location;
|
|
|
|
await this.updateContact(result.headers.location);
|
|
};
|
|
|
|
Acme2.prototype.newOrder = async function (domain) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
const payload = {
|
|
identifiers: [{
|
|
type: 'dns',
|
|
value: domain
|
|
}]
|
|
};
|
|
|
|
debug(`newOrder: ${domain}`);
|
|
|
|
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.ACME_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`);
|
|
|
|
debug('newOrder: created order %s %j', domain, result.body);
|
|
|
|
const order = result.body, orderUrl = result.headers.location;
|
|
|
|
if (!Array.isArray(order.authorizations)) throw new BoxError(BoxError.ACME_ERROR, 'invalid authorizations in order');
|
|
if (typeof order.finalize !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid finalize in order');
|
|
if (typeof orderUrl !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid order location in order header');
|
|
|
|
return { order, orderUrl };
|
|
};
|
|
|
|
Acme2.prototype.waitForOrder = async function (orderUrl) {
|
|
assert.strictEqual(typeof orderUrl, 'string');
|
|
|
|
debug(`waitForOrder: ${orderUrl}`);
|
|
|
|
return await promiseRetry({ times: 15, interval: 20000, debug }, async () => {
|
|
debug('waitForOrder: getting status');
|
|
|
|
const result = await this.postAsGet(orderUrl);
|
|
if (result.status !== 200) {
|
|
debug(`waitForOrder: invalid response code getting uri ${result.status}`);
|
|
throw new BoxError(BoxError.ACME_ERROR, `Bad response when waiting for order. code: ${result.status}`);
|
|
}
|
|
|
|
debug('waitForOrder: status is "%s %j', result.body.status, result.body);
|
|
|
|
if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.ACME_ERROR, `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.ACME_ERROR, `Unexpected status or invalid response when waiting for order: ${JSON.stringify(result.body)}`);
|
|
});
|
|
};
|
|
|
|
Acme2.prototype.getKeyAuthorization = function (token) {
|
|
assert(Buffer.isBuffer(this.accountKeyPem));
|
|
|
|
let jwk = {
|
|
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
|
|
kty: 'RSA',
|
|
n: b64(getModulus(this.accountKeyPem))
|
|
};
|
|
|
|
let shasum = crypto.createHash('sha256');
|
|
shasum.update(JSON.stringify(jwk));
|
|
let thumbprint = urlBase64Encode(shasum.digest('base64'));
|
|
return token + '.' + thumbprint;
|
|
};
|
|
|
|
Acme2.prototype.notifyChallengeReady = async function (challenge) {
|
|
assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token }
|
|
|
|
debug('notifyChallengeReady: %s was met', challenge.url);
|
|
|
|
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
|
|
|
const payload = {
|
|
resource: 'challenge',
|
|
keyAuthorization: keyAuthorization
|
|
};
|
|
|
|
const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload));
|
|
if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`);
|
|
};
|
|
|
|
Acme2.prototype.waitForChallenge = async function (challenge) {
|
|
assert.strictEqual(typeof challenge, 'object');
|
|
|
|
debug('waitingForChallenge: %j', challenge);
|
|
|
|
await promiseRetry({ times: 15, interval: 20000, debug }, async () => {
|
|
debug('waitingForChallenge: getting status');
|
|
|
|
const result = await this.postAsGet(challenge.url);
|
|
if (result.status !== 200) {
|
|
debug(`waitForChallenge: invalid response code getting uri ${result.status}`);
|
|
throw new BoxError(BoxError.ACME_ERROR, `Bad response code when waiting for challenge : ${result.status}`);
|
|
}
|
|
|
|
debug(`waitForChallenge: status is "${result.body.status}" "${JSON.stringify(result.body)}"`);
|
|
|
|
if (result.body.status === 'pending') throw new BoxError(BoxError.ACME_ERROR, 'Challenge is in pending state');
|
|
else if (result.body.status === 'valid') return;
|
|
else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status when waiting for challenge: ${result.body.status}`);
|
|
});
|
|
};
|
|
|
|
// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
|
|
Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDer) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof finalizationUrl, 'string');
|
|
assert(Buffer.isBuffer(csrDer));
|
|
|
|
const payload = {
|
|
csr: b64(csrDer)
|
|
};
|
|
|
|
debug('signCertificate: sending sign request');
|
|
|
|
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.ACME_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`);
|
|
};
|
|
|
|
Acme2.prototype.createKeyAndCsr = async function (hostname, keyFilePath, csrFilePath) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
|
|
if (safe.fs.existsSync(keyFilePath)) {
|
|
debug('createKeyAndCsr: reuse the key for renewal at %s', keyFilePath);
|
|
} else {
|
|
let key = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1'); // openssl ecparam -list_curves
|
|
if (!key) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
|
if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, safe.error);
|
|
|
|
debug('createKeyAndCsr: key file saved at %s', keyFilePath);
|
|
}
|
|
|
|
const [error, tmpdir] = await safe(fs.promises.mkdtemp(path.join(os.tmpdir(), 'acme-')));
|
|
if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating temporary directory for openssl config: ${error.message}`);
|
|
|
|
// OCSP must-staple is currently disabled because nginx does not provide staple on the first request (https://forum.cloudron.io/topic/4917/ocsp-stapling-for-tls-ssl/)
|
|
// ' -addext "tlsfeature = status_request"'; // this adds OCSP must-staple
|
|
// we used to use -addext to the CLI to add these but that arg doesn't work on Ubuntu 16.04
|
|
// empty distinguished_name section is required for Ubuntu 16 openssl
|
|
const conf = '[req]\nreq_extensions = v3_req\ndistinguished_name = req_distinguished_name\n'
|
|
+ '[req_distinguished_name]\n\n'
|
|
+ '[v3_req]\nbasicConstraints = CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment\nsubjectAltName = @alt_names\n'
|
|
+ `[alt_names]\nDNS.1 = ${hostname}\n`;
|
|
|
|
const opensslConfigFile = path.join(tmpdir, 'openssl.conf');
|
|
if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);
|
|
|
|
// while we pass the CN anyways, subjectAltName takes precedence
|
|
const csrDer = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform DER -subj /CN=${hostname} -config ${opensslConfigFile}`);
|
|
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
|
if (!safe.fs.writeFileSync(csrFilePath, csrDer)) throw new BoxError(BoxError.FS_ERROR, safe.error); // bookkeeping. inspect with openssl req -text -noout -in hostname.csr -inform der
|
|
|
|
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
|
|
|
|
debug('createKeyAndCsr: csr file (DER) saved at %s', csrFilePath);
|
|
|
|
return csrDer;
|
|
};
|
|
|
|
Acme2.prototype.downloadCertificate = async function (hostname, certUrl, certFilePath) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof certUrl, 'string');
|
|
|
|
await promiseRetry({ times: 5, interval: 20000, debug }, async () => {
|
|
debug(`downloadCertificate: downloading certificate of ${hostname}`);
|
|
|
|
const result = await this.postAsGet(certUrl);
|
|
if (result.statusCode === 202) throw new BoxError(BoxError.ACME_ERROR, 'Retry downloading certificate');
|
|
if (result.statusCode !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`);
|
|
|
|
const fullChainPem = result.body; // buffer
|
|
|
|
if (!safe.fs.writeFileSync(certFilePath, fullChainPem)) throw new BoxError(BoxError.FS_ERROR, safe.error);
|
|
|
|
debug(`downloadCertificate: cert file for ${hostname} saved at ${certFilePath}`);
|
|
});
|
|
};
|
|
|
|
Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization, acmeChallengesDir) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof authorization, 'object');
|
|
assert.strictEqual(typeof acmeChallengesDir, 'string');
|
|
|
|
debug('prepareHttpChallenge: challenges: %j', authorization);
|
|
let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
|
|
if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges');
|
|
let challenge = httpChallenges[0];
|
|
|
|
debug('prepareHttpChallenge: preparing for challenge %j', challenge);
|
|
|
|
let keyAuthorization = this.getKeyAuthorization(challenge.token);
|
|
|
|
debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(acmeChallengesDir, challenge.token));
|
|
|
|
if (!safe.fs.writeFileSync(path.join(acmeChallengesDir, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
|
|
|
|
return challenge;
|
|
};
|
|
|
|
Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge, acmeChallengesDir) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof challenge, 'object');
|
|
assert.strictEqual(typeof acmeChallengesDir, 'string');
|
|
|
|
debug('cleanupHttpChallenge: unlinking %s', path.join(acmeChallengesDir, challenge.token));
|
|
|
|
if (!safe.fs.unlinkSync(path.join(acmeChallengesDir, challenge.token))) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`);
|
|
};
|
|
|
|
function getChallengeSubdomain(hostname, domain) {
|
|
let challengeSubdomain;
|
|
|
|
if (hostname === domain) {
|
|
challengeSubdomain = '_acme-challenge';
|
|
} else if (hostname.includes('*')) { // wildcard
|
|
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;
|
|
}
|
|
|
|
Acme2.prototype.prepareDnsChallenge = async function (hostname, domain, authorization) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof authorization, 'object');
|
|
|
|
debug('prepareDnsChallenge: challenges: %j', authorization);
|
|
const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
|
|
if (dnsChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no dns challenges');
|
|
const challenge = dnsChallenges[0];
|
|
|
|
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
|
const shasum = crypto.createHash('sha256');
|
|
shasum.update(keyAuthorization);
|
|
|
|
const txtValue = urlBase64Encode(shasum.digest('base64'));
|
|
const challengeSubdomain = getChallengeSubdomain(hostname, domain);
|
|
|
|
debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`);
|
|
|
|
await dns.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ]);
|
|
|
|
await dns.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 });
|
|
|
|
return challenge;
|
|
};
|
|
|
|
Acme2.prototype.cleanupDnsChallenge = async function (hostname, domain, challenge) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof challenge, 'object');
|
|
|
|
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
|
let shasum = crypto.createHash('sha256');
|
|
shasum.update(keyAuthorization);
|
|
|
|
const txtValue = urlBase64Encode(shasum.digest('base64'));
|
|
let challengeSubdomain = getChallengeSubdomain(hostname, domain);
|
|
|
|
debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`);
|
|
|
|
await dns.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ]);
|
|
};
|
|
|
|
Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl, acmeChallengesDir) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof authorizationUrl, 'string');
|
|
assert.strictEqual(typeof acmeChallengesDir, 'string');
|
|
|
|
debug(`prepareChallenge: http: ${this.performHttpAuthorization}`);
|
|
|
|
const response = await this.postAsGet(authorizationUrl);
|
|
if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code getting authorization : ${response.status}`);
|
|
|
|
const authorization = response.body;
|
|
|
|
if (this.performHttpAuthorization) {
|
|
return await this.prepareHttpChallenge(hostname, domain, authorization, acmeChallengesDir);
|
|
} else {
|
|
return await this.prepareDnsChallenge(hostname, domain, authorization);
|
|
}
|
|
};
|
|
|
|
Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge, acmeChallengesDir) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof challenge, 'object');
|
|
assert.strictEqual(typeof acmeChallengesDir, 'string');
|
|
|
|
debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`);
|
|
|
|
if (this.performHttpAuthorization) {
|
|
await this.cleanupHttpChallenge(hostname, domain, challenge, acmeChallengesDir);
|
|
} else {
|
|
await this.cleanupDnsChallenge(hostname, domain, challenge);
|
|
}
|
|
};
|
|
|
|
Acme2.prototype.acmeFlow = async function (hostname, domain, acmeCertificatePaths) {
|
|
assert.strictEqual(typeof hostname, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof acmeCertificatePaths, 'object');
|
|
|
|
const { certFilePath, keyFilePath, csrFilePath } = acmeCertificatePaths;
|
|
|
|
await this.ensureAccount();
|
|
const { order, orderUrl } = await this.newOrder(hostname);
|
|
|
|
for (let i = 0; i < order.authorizations.length; i++) {
|
|
const authorizationUrl = order.authorizations[i];
|
|
debug(`acmeFlow: authorizing ${authorizationUrl}`);
|
|
|
|
const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl, paths.ACME_CHALLENGES_DIR);
|
|
await this.notifyChallengeReady(challenge);
|
|
await this.waitForChallenge(challenge);
|
|
const csrDer = await this.createKeyAndCsr(hostname, keyFilePath, csrFilePath);
|
|
await this.signCertificate(hostname, order.finalize, csrDer);
|
|
const certUrl = await this.waitForOrder(orderUrl);
|
|
await this.downloadCertificate(hostname, certUrl, certFilePath);
|
|
|
|
try {
|
|
await this.cleanupChallenge(hostname, domain, challenge, paths.ACME_CHALLENGES_DIR);
|
|
} catch (cleanupError) {
|
|
debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError);
|
|
}
|
|
}
|
|
};
|
|
|
|
Acme2.prototype.loadDirectory = async function () {
|
|
await promiseRetry({ times: 3, interval: 20000, debug }, async () => {
|
|
const response = await superagent.get(this.caDirectory).timeout(30000).ok(() => true);
|
|
|
|
if (response.status !== 200) throw new BoxError(BoxError.ACME_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.ACME_ERROR, `Invalid response body : ${response.body}`);
|
|
|
|
this.directory = response.body;
|
|
});
|
|
};
|
|
|
|
Acme2.prototype.getCertificate = async function (fqdn, domain, acmeCertificatePaths) {
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof acmeCertificatePaths, 'object');
|
|
|
|
debug(`getCertificate: start acme flow for ${fqdn} from ${this.caDirectory}`);
|
|
|
|
if (fqdn !== domain && this.wildcard) { // bare domain is not part of wildcard SAN
|
|
fqdn = dns.makeWildcard(fqdn);
|
|
debug(`getCertificate: will get wildcard cert for ${fqdn}`);
|
|
}
|
|
|
|
await this.loadDirectory();
|
|
await this.acmeFlow(fqdn, domain, acmeCertificatePaths);
|
|
};
|
|
|
|
async function getCertificate(fqdn, domain, acmeCertificatePaths, options) {
|
|
assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains)
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof acmeCertificatePaths, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
await promiseRetry({ times: 3, interval: 0, debug }, async function () {
|
|
debug(`getCertificate: for fqdn ${fqdn} and domain ${domain}`);
|
|
|
|
const acme = new Acme2(options || { });
|
|
return await acme.getCertificate(fqdn, domain, acmeCertificatePaths);
|
|
});
|
|
}
|