mirror of https://github.com/certd/certd
341 lines
9.8 KiB
JavaScript
341 lines
9.8 KiB
JavaScript
/**
|
|
* Utility methods
|
|
*/
|
|
|
|
const tls = require('tls');
|
|
const dns = require('dns').promises;
|
|
const { readCertificateInfo, splitPemChain } = require('./crypto');
|
|
const { log } = require('./logger');
|
|
|
|
/**
|
|
* Exponential backoff
|
|
*
|
|
* https://github.com/mokesmokes/backo
|
|
*
|
|
* @class
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.min] Minimum backoff duration in ms
|
|
* @param {number} [opts.max] Maximum backoff duration in ms
|
|
*/
|
|
|
|
class Backoff {
|
|
constructor({ min = 100, max = 10000 } = {}) {
|
|
this.min = min;
|
|
this.max = max;
|
|
this.attempts = 0;
|
|
}
|
|
|
|
/**
|
|
* Get backoff duration
|
|
*
|
|
* @returns {number} Backoff duration in ms
|
|
*/
|
|
|
|
duration() {
|
|
const ms = this.min * (2 ** this.attempts);
|
|
this.attempts += 1;
|
|
return Math.min(ms, this.max);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry promise
|
|
*
|
|
* @param {function} fn Function returning promise that should be retried
|
|
* @param {number} attempts Maximum number of attempts
|
|
* @param {Backoff} backoff Backoff instance
|
|
* @returns {Promise}
|
|
*/
|
|
|
|
async function retryPromise(fn, attempts, backoff) {
|
|
let aborted = false;
|
|
|
|
try {
|
|
const data = await fn(() => { aborted = true; });
|
|
return data;
|
|
}
|
|
catch (e) {
|
|
if (aborted || ((backoff.attempts + 1) >= attempts)) {
|
|
throw e;
|
|
}
|
|
|
|
const duration = backoff.duration();
|
|
log(`Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`);
|
|
|
|
await new Promise((resolve) => { setTimeout(resolve, duration); });
|
|
return retryPromise(fn, attempts, backoff);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry promise
|
|
*
|
|
* @param {function} fn Function returning promise that should be retried
|
|
* @param {object} [backoffOpts] Backoff options
|
|
* @param {number} [backoffOpts.attempts] Maximum number of attempts, default: `5`
|
|
* @param {number} [backoffOpts.min] Minimum attempt delay in milliseconds, default: `5000`
|
|
* @param {number} [backoffOpts.max] Maximum attempt delay in milliseconds, default: `30000`
|
|
* @returns {Promise}
|
|
*/
|
|
|
|
function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) {
|
|
const backoff = new Backoff({ min, max });
|
|
return retryPromise(fn, attempts, backoff);
|
|
}
|
|
|
|
/**
|
|
* Parse URLs from Link header
|
|
*
|
|
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2
|
|
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
|
|
*
|
|
* @param {string} header Header contents
|
|
* @param {string} rel Link relation, default: `alternate`
|
|
* @returns {string[]} Array of URLs
|
|
*/
|
|
|
|
function parseLinkHeader(header, rel = 'alternate') {
|
|
const relRe = new RegExp(`\\s*rel\\s*=\\s*"?${rel}"?`, 'i');
|
|
|
|
const results = (header || '').split(/,\s*</).map((link) => {
|
|
const [, linkUrl, linkParts] = link.match(/<?([^>]*)>;(.*)/) || [];
|
|
return (linkUrl && linkParts && linkParts.match(relRe)) ? linkUrl : null;
|
|
});
|
|
|
|
return results.filter((r) => r);
|
|
}
|
|
|
|
/**
|
|
* Parse date or duration from Retry-After header
|
|
*
|
|
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
*
|
|
* @param {string} header Header contents
|
|
* @returns {number} Retry duration in seconds
|
|
*/
|
|
|
|
function parseRetryAfterHeader(header) {
|
|
const sec = parseInt(header, 10);
|
|
const date = new Date(header);
|
|
|
|
/* Seconds into the future */
|
|
if (Number.isSafeInteger(sec) && (sec > 0)) {
|
|
return sec;
|
|
}
|
|
|
|
/* Future date string */
|
|
if (date instanceof Date && !Number.isNaN(date)) {
|
|
const now = new Date();
|
|
const diff = Math.ceil((date.getTime() - now.getTime()) / 1000);
|
|
|
|
if (diff > 0) {
|
|
return diff;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Find certificate chain with preferred issuer common name
|
|
* - If issuer is found in multiple chains, the closest to root wins
|
|
* - If issuer can not be located, the first chain will be returned
|
|
*
|
|
* @param {string[]} certificates Array of PEM encoded certificate chains
|
|
* @param {string} issuer Preferred certificate issuer
|
|
* @returns {string} PEM encoded certificate chain
|
|
*/
|
|
|
|
function findCertificateChainForIssuer(chains, issuer) {
|
|
log(`Attempting to find match for issuer="${issuer}" in ${chains.length} certificate chains`);
|
|
let bestMatch = null;
|
|
let bestDistance = null;
|
|
|
|
chains.forEach((chain) => {
|
|
/* Look up all issuers */
|
|
const certs = splitPemChain(chain);
|
|
const infoCollection = certs.map((c) => readCertificateInfo(c));
|
|
const issuerCollection = infoCollection.map((i) => i.issuer.commonName);
|
|
|
|
/* Found issuer match, get distance from root - lower is better */
|
|
if (issuerCollection.includes(issuer)) {
|
|
const distance = (issuerCollection.length - issuerCollection.indexOf(issuer));
|
|
log(`Found matching chain for preferred issuer="${issuer}" distance=${distance} issuers=${JSON.stringify(issuerCollection)}`);
|
|
|
|
/* Chain wins, use it */
|
|
if (!bestDistance || (distance < bestDistance)) {
|
|
log(`Issuer is closer to root than previous match, using it (${distance} < ${bestDistance || 'undefined'})`);
|
|
bestMatch = chain;
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
else {
|
|
/* No match */
|
|
log(`Unable to match certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`);
|
|
}
|
|
});
|
|
|
|
/* Return found match */
|
|
if (bestMatch) {
|
|
return bestMatch;
|
|
}
|
|
|
|
/* No chains matched, return default */
|
|
log(`Found no match in ${chains.length} certificate chains for preferred issuer="${issuer}", returning default certificate chain`);
|
|
return chains[0];
|
|
}
|
|
|
|
/**
|
|
* Find and format error in response object
|
|
*
|
|
* @param {object} resp HTTP response
|
|
* @returns {string} Error message
|
|
*/
|
|
|
|
function formatResponseError(resp) {
|
|
let result;
|
|
|
|
if (resp.data) {
|
|
if (resp.data.error) {
|
|
result = resp.data.error.detail || resp.data.error;
|
|
}
|
|
else {
|
|
result = resp.data.detail || JSON.stringify(resp.data);
|
|
}
|
|
}
|
|
|
|
return (result || '').replace(/\n/g, '');
|
|
}
|
|
|
|
/**
|
|
* Resolve root domain name by looking for SOA record
|
|
*
|
|
* @param {string} recordName DNS record name
|
|
* @returns {Promise<string>} Root domain name
|
|
*/
|
|
|
|
async function resolveDomainBySoaRecord(recordName) {
|
|
try {
|
|
await dns.resolveSoa(recordName);
|
|
log(`Found SOA record, considering domain to be: ${recordName}`);
|
|
return recordName;
|
|
}
|
|
catch (e) {
|
|
log(`Unable to locate SOA record for name: ${recordName}`);
|
|
const parentRecordName = recordName.split('.').slice(1).join('.');
|
|
|
|
if (!parentRecordName.includes('.')) {
|
|
throw new Error('Unable to resolve domain by SOA record');
|
|
}
|
|
|
|
return resolveDomainBySoaRecord(parentRecordName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get DNS resolver using domains authoritative NS records
|
|
*
|
|
* @param {string} recordName DNS record name
|
|
* @returns {Promise<dns.Resolver>} DNS resolver
|
|
*/
|
|
|
|
async function getAuthoritativeDnsResolver(recordName) {
|
|
log(`Locating authoritative NS records for name: ${recordName}`);
|
|
const resolver = new dns.Resolver();
|
|
|
|
try {
|
|
/* Resolve root domain by SOA */
|
|
const domain = await resolveDomainBySoaRecord(recordName);
|
|
|
|
/* Resolve authoritative NS addresses */
|
|
log(`Looking up authoritative NS records for domain: ${domain}`);
|
|
const nsRecords = await dns.resolveNs(domain);
|
|
const nsAddrArray = await Promise.all(nsRecords.map(async (r) => dns.resolve4(r)));
|
|
const nsAddresses = [].concat(...nsAddrArray).filter((a) => a);
|
|
|
|
if (!nsAddresses.length) {
|
|
throw new Error(`Unable to locate any valid authoritative NS addresses for domain: ${domain}`);
|
|
}
|
|
|
|
/* Authoritative NS success */
|
|
log(`Found ${nsAddresses.length} authoritative NS addresses for domain: ${domain}`);
|
|
resolver.setServers(nsAddresses);
|
|
}
|
|
catch (e) {
|
|
log(`Authoritative NS lookup error: ${e.message}`);
|
|
}
|
|
|
|
/* Return resolver */
|
|
const addresses = resolver.getServers();
|
|
log(`DNS resolver addresses: ${addresses.join(', ')}`);
|
|
|
|
return resolver;
|
|
}
|
|
|
|
/**
|
|
* Attempt to retrieve TLS ALPN certificate from peer
|
|
*
|
|
* https://nodejs.org/api/tls.html#tlsconnectoptions-callback
|
|
*
|
|
* @param {string} host Host the TLS client should connect to
|
|
* @param {number} port Port the client should connect to
|
|
* @param {string} servername Server name for the SNI (Server Name Indication)
|
|
* @returns {Promise<string>} PEM encoded certificate
|
|
*/
|
|
|
|
async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) {
|
|
return new Promise((resolve, reject) => {
|
|
let result;
|
|
|
|
/* TLS connection */
|
|
const socket = tls.connect({
|
|
host,
|
|
port,
|
|
servername: host,
|
|
rejectUnauthorized: false,
|
|
ALPNProtocols: ['acme-tls/1'],
|
|
});
|
|
|
|
socket.setTimeout(timeout);
|
|
socket.setEncoding('utf-8');
|
|
|
|
/* Grab certificate once connected and close */
|
|
socket.on('secureConnect', () => {
|
|
result = socket.getPeerX509Certificate();
|
|
socket.end();
|
|
});
|
|
|
|
/* Errors */
|
|
socket.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy(new Error('TLS ALPN certificate lookup request timed out'));
|
|
});
|
|
|
|
/* Done, return cert as PEM if found */
|
|
socket.on('end', () => {
|
|
if (result) {
|
|
return resolve(result.toString());
|
|
}
|
|
|
|
return reject(new Error('TLS ALPN lookup failed to retrieve certificate'));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Export utils
|
|
*/
|
|
|
|
module.exports = {
|
|
retry,
|
|
parseLinkHeader,
|
|
parseRetryAfterHeader,
|
|
findCertificateChainForIssuer,
|
|
formatResponseError,
|
|
getAuthoritativeDnsResolver,
|
|
retrieveTlsAlpnCertificate,
|
|
};
|