Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Actions Bot
e5edfbfa6d 🔱: [acme] sync upgrade with 6 commits [trident-sync]
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
2024-07-16 19:24:08 +00:00
GitHub Actions Bot
86e64af35c 🔱: [acme] sync upgrade with 5 commits [trident-sync]
Temp remove Node v22 from matrix, broke CNAME tests
Invalidate ACME directory cache after 24 hours
Directory URLs for Google ACME provider
Bump Pebble v2.6.0
2024-07-15 19:24:17 +00:00
16 changed files with 425 additions and 68 deletions

View File

@@ -5,8 +5,10 @@
set -euo pipefail set -euo pipefail
# Download and install # Download and install
wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLECTS_VERSION}/pebble-challtestsrv_linux-amd64" -O /usr/local/bin/pebble-challtestsrv wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLECTS_VERSION}/pebble-challtestsrv-linux-amd64.tar.gz" -O /tmp/pebble-challtestsrv.tar.gz
tar zxvf /tmp/pebble-challtestsrv.tar.gz -C /tmp
mv /tmp/pebble-challtestsrv-linux-amd64/linux/amd64/pebble-challtestsrv /usr/local/bin/pebble-challtestsrv
chown root:root /usr/local/bin/pebble-challtestsrv chown root:root /usr/local/bin/pebble-challtestsrv
chmod 0755 /usr/local/bin/pebble-challtestsrv chmod 0755 /usr/local/bin/pebble-challtestsrv

View File

@@ -22,8 +22,10 @@ wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION
wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/config/${CONFIG_NAME}" -O /etc/pebble/pebble.json wget -nv "https://raw.githubusercontent.com/letsencrypt/pebble/v${PEBBLE_VERSION}/test/config/${CONFIG_NAME}" -O /etc/pebble/pebble.json
# Download and install Pebble # Download and install Pebble
wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLE_VERSION}/pebble_linux-amd64" -O /usr/local/bin/pebble wget -nv "https://github.com/letsencrypt/pebble/releases/download/v${PEBBLE_VERSION}/pebble-linux-amd64.tar.gz" -O /tmp/pebble.tar.gz
tar zxvf /tmp/pebble.tar.gz -C /tmp
mv /tmp/pebble-linux-amd64/linux/amd64/pebble /usr/local/bin/pebble
chown root:root /usr/local/bin/pebble chown root:root /usr/local/bin/pebble
chmod 0755 /usr/local/bin/pebble chmod 0755 /usr/local/bin/pebble

View File

@@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node: [16, 18, 20, 22] node: [16, 18, 20]
eab: [0, 1] eab: [0, 1]
# #
@@ -19,9 +19,9 @@ jobs:
FORCE_COLOR: 1 FORCE_COLOR: 1
NPM_CONFIG_COLOR: always NPM_CONFIG_COLOR: always
PEBBLE_VERSION: 2.3.1 PEBBLE_VERSION: 2.6.0
PEBBLE_ALTERNATE_ROOTS: 2 PEBBLE_ALTERNATE_ROOTS: 2
PEBBLECTS_VERSION: 2.3.1 PEBBLECTS_VERSION: 2.6.0
PEBBLECTS_DNS_PORT: 8053 PEBBLECTS_DNS_PORT: 8053
COREDNS_VERSION: 1.11.1 COREDNS_VERSION: 1.11.1

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## v5.4.0 (2024-07-16)
* `added` Directory URLs for [Google](https://cloud.google.com/certificate-manager/docs/overview) ACME provider
* `fixed` Invalidate ACME provider directory cache after 24 hours
* `fixed` Retry HTTP requests on server errors or when rate limited - [#89](https://github.com/publishlab/node-acme-client/issues/89)
## v5.3.1 (2024-05-22) ## v5.3.1 (2024-05-22)
* `fixed` Allow `client.auto()` being called with an empty CSR common name * `fixed` Allow `client.auto()` being called with an empty CSR common name
@@ -8,7 +14,7 @@
## v5.3.0 (2024-02-05) ## v5.3.0 (2024-02-05)
* `added` Support and tests for satisfying `tls-alpn-01` challenges * `added` Support and tests for satisfying `tls-alpn-01` challenges
* `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR generation and parsing * `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR handling
* `changed` Method `getChallengeKeyAuthorization()` now returns `$token.$thumbprint` when called with a `tls-alpn-01` challenge * `changed` Method `getChallengeKeyAuthorization()` now returns `$token.$thumbprint` when called with a `tls-alpn-01` challenge
* Previously returned base64url encoded SHA256 digest of `$token.$thumbprint` erroneously * Previously returned base64url encoded SHA256 digest of `$token.$thumbprint` erroneously
* This change is not considered breaking since the previous behavior was incorrect * This change is not considered breaking since the previous behavior was incorrect

View File

@@ -59,6 +59,9 @@ const client = new acme.Client({
acme.directory.buypass.staging; acme.directory.buypass.staging;
acme.directory.buypass.production; acme.directory.buypass.production;
acme.directory.google.staging;
acme.directory.google.production;
acme.directory.letsencrypt.staging; acme.directory.letsencrypt.staging;
acme.directory.letsencrypt.production; acme.directory.letsencrypt.production;

View File

@@ -2,7 +2,7 @@
"name": "acme-client", "name": "acme-client",
"description": "Simple and unopinionated ACME client", "description": "Simple and unopinionated ACME client",
"author": "nmorsman", "author": "nmorsman",
"version": "5.3.1", "version": "5.4.0",
"main": "src/index.js", "main": "src/index.js",
"types": "types/index.d.ts", "types": "types/index.d.ts",
"license": "MIT", "license": "MIT",
@@ -15,23 +15,23 @@
"types" "types"
], ],
"dependencies": { "dependencies": {
"@peculiar/x509": "^1.10.0", "@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.5",
"axios": "^1.7.2", "axios": "^1.7.2",
"debug": "^4.1.1", "debug": "^4.3.5",
"node-forge": "^1.3.1" "node-forge": "^1.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.12", "@types/node": "^20.14.10",
"chai": "^4.4.1", "chai": "^4.4.1",
"chai-as-promised": "^7.1.2", "chai-as-promised": "^7.1.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"jsdoc-to-markdown": "^8.0.1", "jsdoc-to-markdown": "^8.0.1",
"mocha": "^10.4.0", "mocha": "^10.6.0",
"nock": "^13.5.4", "nock": "^13.5.4",
"tsd": "^0.31.0" "tsd": "^0.31.1"
}, },
"scripts": { "scripts": {
"build-docs": "jsdoc2md src/client.js > docs/client.md && jsdoc2md src/crypto/index.js > docs/crypto.md && jsdoc2md src/crypto/forge.js > docs/forge.md", "build-docs": "jsdoc2md src/client.js > docs/client.md && jsdoc2md src/crypto/index.js > docs/crypto.md && jsdoc2md src/crypto/forge.js > docs/forge.md",

View File

@@ -3,10 +3,14 @@
*/ */
const axios = require('axios'); const axios = require('axios');
const { parseRetryAfterHeader } = require('./util');
const { log } = require('./logger');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
const { AxiosError } = axios;
/** /**
* Instance * Defaults
*/ */
const instance = axios.create(); const instance = axios.create();
@@ -19,6 +23,9 @@ instance.defaults.acmeSettings = {
httpChallengePort: 80, httpChallengePort: 80,
httpsChallengePort: 443, httpsChallengePort: 443,
tlsAlpnChallengePort: 443, tlsAlpnChallengePort: 443,
retryMaxAttempts: 5,
retryDefaultDelay: 5,
}; };
/** /**
@@ -30,6 +37,85 @@ instance.defaults.acmeSettings = {
instance.defaults.adapter = 'http'; instance.defaults.adapter = 'http';
/**
* Retry requests on server errors or when rate limited
*
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.6
*/
function isRetryableError(error) {
return (error.code !== 'ECONNABORTED')
&& (error.code !== 'ERR_NOCK_NO_MATCH')
&& (!error.response
|| (error.response.status === 429)
|| ((error.response.status >= 500) && (error.response.status <= 599)));
}
/* https://github.com/axios/axios/blob/main/lib/core/settle.js */
function validateStatus(response) {
const validator = response.config.retryValidateStatus;
if (!response.status || !validator || validator(response.status)) {
return response;
}
throw new AxiosError(
`Request failed with status code ${response.status}`,
(Math.floor(response.status / 100) === 4) ? AxiosError.ERR_BAD_REQUEST : AxiosError.ERR_BAD_RESPONSE,
response.config,
response.request,
response,
);
}
/* Pass all responses through the error interceptor */
instance.interceptors.request.use((config) => {
if (!('retryValidateStatus' in config)) {
config.retryValidateStatus = config.validateStatus;
}
config.validateStatus = () => false;
return config;
});
/* Handle request retries if applicable */
instance.interceptors.response.use(null, async (error) => {
const { config, response } = error;
if (!config) {
return Promise.reject(error);
}
/* Pick up errors we want to retry */
if (isRetryableError(error)) {
const { retryMaxAttempts, retryDefaultDelay } = instance.defaults.acmeSettings;
config.retryAttempt = ('retryAttempt' in config) ? (config.retryAttempt + 1) : 1;
if (config.retryAttempt <= retryMaxAttempts) {
const code = response ? `HTTP ${response.status}` : error.code;
log(`Caught ${code}, retry attempt ${config.retryAttempt}/${retryMaxAttempts} to URL ${config.url}`);
/* Attempt to parse Retry-After header, fallback to default delay */
let retryAfter = response ? parseRetryAfterHeader(response.headers['retry-after']) : 0;
if (retryAfter > 0) {
log(`Found retry-after response header with value: ${response.headers['retry-after']}, waiting ${retryAfter} seconds`);
}
else {
retryAfter = (retryDefaultDelay * config.retryAttempt);
log(`Unable to locate or parse retry-after response header, waiting ${retryAfter} seconds`);
}
/* Wait and retry the request */
await new Promise((resolve) => { setTimeout(resolve, (retryAfter * 1000)); });
return instance(config);
}
}
/* Validate and return response */
return validateStatus(response);
});
/** /**
* Export instance * Export instance
*/ */

View File

@@ -25,8 +25,11 @@ class HttpClient {
this.externalAccountBinding = externalAccountBinding; this.externalAccountBinding = externalAccountBinding;
this.maxBadNonceRetries = 5; this.maxBadNonceRetries = 5;
this.directory = null;
this.jwk = null; this.jwk = null;
this.directoryCache = null;
this.directoryMaxAge = 86400;
this.directoryTimestamp = 0;
} }
/** /**
@@ -59,15 +62,19 @@ class HttpClient {
} }
/** /**
* Ensure provider directory exists * Get ACME provider directory
* *
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1 * https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1
* *
* @returns {Promise} * @returns {Promise<object>} ACME directory contents
*/ */
async getDirectory() { async getDirectory() {
if (!this.directory) { const now = Math.floor(Date.now() / 1000);
const age = (now - this.directoryTimestamp);
if (!this.directoryCache || (age > this.directoryMaxAge)) {
log(`Refreshing ACME directory, age: ${age}`);
const resp = await this.request(this.directoryUrl, 'get'); const resp = await this.request(this.directoryUrl, 'get');
if (resp.status >= 400) { if (resp.status >= 400) {
@@ -78,8 +85,11 @@ class HttpClient {
throw new Error('Attempting to read ACME directory returned no data'); throw new Error('Attempting to read ACME directory returned no data');
} }
this.directory = resp.data; this.directoryCache = resp.data;
this.directoryTimestamp = now;
} }
return this.directoryCache;
} }
/** /**
@@ -101,7 +111,7 @@ class HttpClient {
* *
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.2 * https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
* *
* @returns {Promise<string>} nonce * @returns {Promise<string>} Nonce
*/ */
async getNonce() { async getNonce() {
@@ -123,13 +133,13 @@ class HttpClient {
*/ */
async getResourceUrl(resource) { async getResourceUrl(resource) {
await this.getDirectory(); const dir = await this.getDirectory();
if (!this.directory[resource]) { if (!dir[resource]) {
throw new Error(`Unable to locate API resource URL in ACME directory: "${resource}"`); throw new Error(`Unable to locate API resource URL in ACME directory: "${resource}"`);
} }
return this.directory[resource]; return dir[resource];
} }
/** /**
@@ -140,10 +150,10 @@ class HttpClient {
*/ */
async getMetaField(field) { async getMetaField(field) {
await this.getDirectory(); const dir = await this.getDirectory();
if (('meta' in this.directory) && (field in this.directory.meta)) { if (('meta' in dir) && (field in dir.meta)) {
return this.directory.meta[field]; return dir.meta[field];
} }
return null; return null;

View File

@@ -13,6 +13,10 @@ exports.directory = {
staging: 'https://api.test4.buypass.no/acme/directory', staging: 'https://api.test4.buypass.no/acme/directory',
production: 'https://api.buypass.com/acme/directory', production: 'https://api.buypass.com/acme/directory',
}, },
google: {
staging: 'https://dv.acme-v02.test-api.pki.goog/directory',
production: 'https://dv.acme-v02.api.pki.goog/directory',
},
letsencrypt: { letsencrypt: {
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory', staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
production: 'https://acme-v02.api.letsencrypt.org/directory', production: 'https://acme-v02.api.letsencrypt.org/directory',

View File

@@ -84,9 +84,12 @@ function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) {
} }
/** /**
* Parse URLs from link header * Parse URLs from Link header
* *
* @param {string} header Link header contents * 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` * @param {string} rel Link relation, default: `alternate`
* @returns {string[]} Array of URLs * @returns {string[]} Array of URLs
*/ */
@@ -102,6 +105,37 @@ function parseLinkHeader(header, rel = 'alternate') {
return results.filter((r) => r); 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 * Find certificate chain with preferred issuer common name
* - If issuer is found in multiple chains, the closest to root wins * - If issuer is found in multiple chains, the closest to root wins
@@ -161,14 +195,16 @@ function findCertificateChainForIssuer(chains, issuer) {
function formatResponseError(resp) { function formatResponseError(resp) {
let result; let result;
if (resp.data.error) { if (resp.data) {
result = resp.data.error.detail || resp.data.error; if (resp.data.error) {
} result = resp.data.error.detail || resp.data.error;
else { }
result = resp.data.detail || JSON.stringify(resp.data); else {
result = resp.data.detail || JSON.stringify(resp.data);
}
} }
return result.replace(/\n/g, ''); return (result || '').replace(/\n/g, '');
} }
/** /**
@@ -296,6 +332,7 @@ async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) {
module.exports = { module.exports = {
retry, retry,
parseLinkHeader, parseLinkHeader,
parseRetryAfterHeader,
findCertificateChainForIssuer, findCertificateChainForIssuer,
formatResponseError, formatResponseError,
getAuthoritativeDnsResolver, getAuthoritativeDnsResolver,

View File

@@ -12,33 +12,12 @@ const pkg = require('./../package.json');
describe('http', () => { describe('http', () => {
let testClient; let testClient;
const endpoint = `http://${uuid()}.example.com`;
const defaultUserAgent = `node-${pkg.name}/${pkg.version}`; const defaultUserAgent = `node-${pkg.name}/${pkg.version}`;
const customUserAgent = 'custom-ua-123'; const customUserAgent = 'custom-ua-123';
const primaryEndpoint = `http://${uuid()}.example.com`; afterEach(() => {
const defaultUaEndpoint = `http://${uuid()}.example.com`; nock.cleanAll();
const customUaEndpoint = `http://${uuid()}.example.com`;
/**
* HTTP mocking
*/
before(() => {
const defaultUaOpts = { reqheaders: { 'User-Agent': defaultUserAgent } };
const customUaOpts = { reqheaders: { 'User-Agent': customUserAgent } };
nock(primaryEndpoint)
.persist().get('/').reply(200, 'ok');
nock(defaultUaEndpoint, defaultUaOpts)
.persist().get('/').reply(200, 'ok');
nock(customUaEndpoint, customUaOpts)
.persist().get('/').reply(200, 'ok');
});
after(() => {
axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
}); });
/** /**
@@ -54,7 +33,8 @@ describe('http', () => {
*/ */
it('should http get', async () => { it('should http get', async () => {
const resp = await testClient.request(primaryEndpoint, 'get'); nock(endpoint).get('/').reply(200, 'ok');
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp); assert.isObject(resp);
assert.strictEqual(resp.status, 200); assert.strictEqual(resp.status, 200);
@@ -66,28 +46,76 @@ describe('http', () => {
*/ */
it('should request using default user-agent', async () => { it('should request using default user-agent', async () => {
const resp = await testClient.request(defaultUaEndpoint, 'get'); nock(endpoint).matchHeader('user-agent', defaultUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp); assert.isObject(resp);
assert.strictEqual(resp.status, 200); assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok'); assert.strictEqual(resp.data, 'ok');
}); });
it('should not request using custom user-agent', async () => { it('should reject using custom user-agent', async () => {
await assert.isRejected(testClient.request(customUaEndpoint, 'get')); nock(endpoint).matchHeader('user-agent', defaultUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = customUserAgent;
await assert.isRejected(testClient.request(endpoint, 'get'));
}); });
it('should request using custom user-agent', async () => { it('should request using custom user-agent', async () => {
nock(endpoint).matchHeader('user-agent', customUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = customUserAgent; axios.defaults.headers.common['User-Agent'] = customUserAgent;
const resp = await testClient.request(customUaEndpoint, 'get'); const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp); assert.isObject(resp);
assert.strictEqual(resp.status, 200); assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok'); assert.strictEqual(resp.data, 'ok');
}); });
it('should not request using default user-agent', async () => { it('should reject using default user-agent', async () => {
axios.defaults.headers.common['User-Agent'] = customUserAgent; nock(endpoint).matchHeader('user-agent', customUserAgent).get('/').reply(200, 'ok');
await assert.isRejected(testClient.request(defaultUaEndpoint, 'get')); axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
await assert.isRejected(testClient.request(endpoint, 'get'));
});
/**
* Retry on HTTP errors
*/
it('should retry on 429 rate limit', async () => {
let rateLimitCount = 0;
nock(endpoint).persist().get('/').reply(() => {
rateLimitCount += 1;
if (rateLimitCount < 3) {
return [429, 'Rate Limit Exceeded', { 'Retry-After': 1 }];
}
return [200, 'ok'];
});
assert.strictEqual(rateLimitCount, 0);
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok');
assert.strictEqual(rateLimitCount, 3);
});
it('should retry on 5xx server error', async () => {
let serverErrorCount = 0;
nock(endpoint).persist().get('/').reply(() => {
serverErrorCount += 1;
return [500, 'Internal Server Error', { 'Retry-After': 1 }];
});
assert.strictEqual(serverErrorCount, 0);
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 500);
assert.strictEqual(serverErrorCount, 4);
}); });
}); });

View File

@@ -0,0 +1,145 @@
/**
* Utility method tests
*/
const dns = require('dns').promises;
const fs = require('fs').promises;
const path = require('path');
const { assert } = require('chai');
const util = require('./../src/util');
const { readCertificateInfo } = require('./../src/crypto');
describe('util', () => {
const testCertPath1 = path.join(__dirname, 'fixtures', 'certificate.crt');
const testCertPath2 = path.join(__dirname, 'fixtures', 'letsencrypt.crt');
it('retry()', async () => {
let attempts = 0;
const backoffOpts = {
min: 100,
max: 500,
};
await assert.isRejected(util.retry(() => {
throw new Error('oops');
}, backoffOpts));
const r = await util.retry(() => {
attempts += 1;
if (attempts < 3) {
throw new Error('oops');
}
return 'abc';
}, backoffOpts);
assert.strictEqual(r, 'abc');
assert.strictEqual(attempts, 3);
});
it('parseLinkHeader()', () => {
const r1 = util.parseLinkHeader('<https://example.com/a>;rel="alternate"');
assert.isArray(r1);
assert.strictEqual(r1.length, 1);
assert.strictEqual(r1[0], 'https://example.com/a');
const r2 = util.parseLinkHeader('<https://example.com/b>;rel="test"');
assert.isArray(r2);
assert.strictEqual(r2.length, 0);
const r3 = util.parseLinkHeader('<http://example.com/c>; rel="test"', 'test');
assert.isArray(r3);
assert.strictEqual(r3.length, 1);
assert.strictEqual(r3[0], 'http://example.com/c');
const r4 = util.parseLinkHeader(`<https://example.com/a>; rel="alternate",
<https://example.com/x>; rel="nope",
<https://example.com/b>;rel="alternate",
<https://example.com/c>; rel="alternate"`);
assert.isArray(r4);
assert.strictEqual(r4.length, 3);
assert.strictEqual(r4[0], 'https://example.com/a');
assert.strictEqual(r4[1], 'https://example.com/b');
assert.strictEqual(r4[2], 'https://example.com/c');
});
it('parseRetryAfterHeader()', () => {
const r1 = util.parseRetryAfterHeader('');
assert.strictEqual(r1, 0);
const r2 = util.parseRetryAfterHeader('abcdef');
assert.strictEqual(r2, 0);
const r3 = util.parseRetryAfterHeader('123');
assert.strictEqual(r3, 123);
const r4 = util.parseRetryAfterHeader('123.456');
assert.strictEqual(r4, 123);
const r5 = util.parseRetryAfterHeader('-555');
assert.strictEqual(r5, 0);
const r6 = util.parseRetryAfterHeader('Wed, 21 Oct 2015 07:28:00 GMT');
assert.strictEqual(r6, 0);
const now = new Date();
const future = new Date(now.getTime() + 123000);
const r7 = util.parseRetryAfterHeader(future.toUTCString());
assert.isTrue(r7 > 100);
});
it('findCertificateChainForIssuer()', async () => {
const certs = [
(await fs.readFile(testCertPath1)).toString(),
(await fs.readFile(testCertPath2)).toString(),
];
const r1 = util.findCertificateChainForIssuer(certs, 'abc123');
const r2 = util.findCertificateChainForIssuer(certs, 'example.com');
const r3 = util.findCertificateChainForIssuer(certs, 'E6');
[r1, r2, r3].forEach((r) => {
assert.isString(r);
assert.isNotEmpty(r);
});
assert.strictEqual(readCertificateInfo(r1).issuer.commonName, 'example.com');
assert.strictEqual(readCertificateInfo(r2).issuer.commonName, 'example.com');
assert.strictEqual(readCertificateInfo(r3).issuer.commonName, 'E6');
});
it('formatResponseError()', () => {
const e1 = util.formatResponseError({ data: { error: 'aaa' } });
assert.strictEqual(e1, 'aaa');
const e2 = util.formatResponseError({ data: { error: { detail: 'bbb' } } });
assert.strictEqual(e2, 'bbb');
const e3 = util.formatResponseError({ data: { detail: 'ccc' } });
assert.strictEqual(e3, 'ccc');
const e4 = util.formatResponseError({ data: { a: 123 } });
assert.strictEqual(e4, '{"a":123}');
const e5 = util.formatResponseError({});
assert.isString(e5);
assert.isEmpty(e5);
});
it('getAuthoritativeDnsResolver()', async () => {
/* valid domain - should not use global default */
const r1 = await util.getAuthoritativeDnsResolver('example.com');
assert.instanceOf(r1, dns.Resolver);
assert.isNotEmpty(r1.getServers());
assert.notDeepEqual(r1.getServers(), dns.getServers());
/* invalid domain - fallback to global default */
const r2 = await util.getAuthoritativeDnsResolver('invalid.xtldx');
assert.instanceOf(r2, dns.Resolver);
assert.deepStrictEqual(r2.getServers(), dns.getServers());
});
/* TODO: Figure out how to test this */
it('retrieveTlsAlpnCertificate()');
});

View File

@@ -414,7 +414,7 @@ describe('client.auto', () => {
const info = acme.crypto.readCertificateInfo(testCertificate); const info = acme.crypto.readCertificateInfo(testCertificate);
spec.crypto.certificateInfo(info); spec.crypto.certificateInfo(info);
assert.strictEqual(info.domains.commonName, testDomain); assert.isNull(info.domains.commonName);
assert.deepStrictEqual(info.domains.altNames, [testDomain]); assert.deepStrictEqual(info.domains.altNames, [testDomain]);
}); });
@@ -422,7 +422,7 @@ describe('client.auto', () => {
const info = acme.crypto.readCertificateInfo(testSanCertificate); const info = acme.crypto.readCertificateInfo(testSanCertificate);
spec.crypto.certificateInfo(info); spec.crypto.certificateInfo(info);
assert.strictEqual(info.domains.commonName, testSanDomains[0]); assert.isNull(info.domains.commonName);
assert.deepStrictEqual(info.domains.altNames, testSanDomains); assert.deepStrictEqual(info.domains.altNames, testSanDomains);
}); });
@@ -430,7 +430,7 @@ describe('client.auto', () => {
const info = acme.crypto.readCertificateInfo(testWildcardCertificate); const info = acme.crypto.readCertificateInfo(testWildcardCertificate);
spec.crypto.certificateInfo(info); spec.crypto.certificateInfo(info);
assert.strictEqual(info.domains.commonName, testWildcardDomain); assert.isNull(info.domains.commonName);
assert.deepStrictEqual(info.domains.altNames, [testWildcardDomain, `*.${testWildcardDomain}`]); assert.deepStrictEqual(info.domains.altNames, [testWildcardDomain, `*.${testWildcardDomain}`]);
}); });
}); });

View File

@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDzzCCA1WgAwIBAgISA0ghDoSv5DpT3Pd3lqwjbVDDMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNDA2MTAxNzEyMjZaFw0yNDA5MDgxNzEyMjVaMBQxEjAQBgNVBAMTCWxl
bmNyLm9yZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEHJ3DjN7pYV3mftHzaP
V/WI0RhOJnSI5AIFEPFHDi8UowOINRGIfm9FHGIDqrb4Rmyvr9JrrqBdFGDen8BW
6OGjggJnMIICYzAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFIdCTnxqmpOELDyzPaEM
seB36lUOMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMFUGCCsGAQUF
BwEBBEkwRzAhBggrBgEFBQcwAYYVaHR0cDovL2U2Lm8ubGVuY3Iub3JnMCIGCCsG
AQUFBzAChhZodHRwOi8vZTYuaS5sZW5jci5vcmcvMG8GA1UdEQRoMGaCCWxlbmNy
Lm9yZ4IPbGV0c2VuY3J5cHQuY29tgg9sZXRzZW5jcnlwdC5vcmeCDXd3dy5sZW5j
ci5vcmeCE3d3dy5sZXRzZW5jcnlwdC5jb22CE3d3dy5sZXRzZW5jcnlwdC5vcmcw
EwYDVR0gBAwwCjAIBgZngQwBAgEwggEFBgorBgEEAdZ5AgQCBIH2BIHzAPEAdgA/
F0tP1yJHWJQdZRyEvg0S7ZA3fx+FauvBvyiF7PhkbgAAAZADWfneAAAEAwBHMEUC
IGlp+dPU2hLT2suTMYkYMlt/xbzSnKLZDA/wYSsPACP7AiEAxbAzx6mkzn0cs0hh
ti6sLf0pcbmDhxHdlJRjuo6SQZEAdwDf4VbrqgWvtZwPhnGNqMAyTq5W2W6n9aVq
AdHBO75SXAAAAZADWfqrAAAEAwBIMEYCIQCrAmDUrlX3oGhri1qCIb65Cuf8h2GR
LC1VfXBenX7dCAIhALXwbhCQ1vO1WLv4CqyihMHOwFaICYqN/N6ylaBlVAM4MAoG
CCqGSM49BAMDA2gAMGUCMFdgjOXGl+hE2ABDsAeuNq8wi34yTMUHk0KMTOjRAfy9
rOCGQqvP0myoYlyzXOH9uQIxAMdkG1ZWBZS1dHavbPf1I/MjYpzX6gy0jVHIXXu5
aYWylBi/Uf2RPj0LWFZh8tNa1Q==
-----END CERTIFICATE-----

View File

@@ -29,6 +29,13 @@ if (process.env.ACME_TLSALPN_PORT) {
axios.defaults.acmeSettings.tlsAlpnChallengePort = process.env.ACME_TLSALPN_PORT; axios.defaults.acmeSettings.tlsAlpnChallengePort = process.env.ACME_TLSALPN_PORT;
} }
/**
* Greatly reduce retry duration while testing
*/
axios.defaults.acmeSettings.retryMaxAttempts = 3;
axios.defaults.acmeSettings.retryDefaultDelay = 1;
/** /**
* External account binding * External account binding
*/ */

View File

@@ -87,6 +87,10 @@ export const directory: {
staging: string, staging: string,
production: string production: string
}, },
google: {
staging: string,
production: string
},
letsencrypt: { letsencrypt: {
staging: string, staging: string,
production: string production: string