mirror of
https://github.com/certd/certd.git
synced 2026-06-17 23:17:35 +08:00
e5edfbfa6d
Bump v5.4.0 Bump dependencies Retry HTTP requests on server errors or when rate limited Forgot to refresh directory timestamp after successful get Add utility method tests
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,
|
|
};
|