/** * ACME auto helper */ import { readCsrDomains } from './crypto/index.js'; import { log } from './logger.js'; import { wait } from './wait.js'; import { CancelError } from './error.js'; const defaultOpts = { csr: null, email: null, preferredChain: null, termsOfServiceAgreed: false, skipChallengeVerification: false, challengePriority: ['http-01', 'dns-01'], challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); }, challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()'); }, }; /** * ACME client auto mode * * @param {AcmeClient} client ACME client * @param {object} userOpts Options * @returns {Promise} Certificate */ export default async (client, userOpts) => { const opts = { ...defaultOpts, ...userOpts }; const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed }; if (!Buffer.isBuffer(opts.csr)) { opts.csr = Buffer.from(opts.csr); } if (opts.email) { accountPayload.contact = [`mailto:${opts.email}`]; } if (opts.externalAccountBinding) { accountPayload.externalAccountBinding = opts.externalAccountBinding; } /** * Register account */ log('[auto] Checking account'); try { client.getAccountUrl(); log('[auto] Account URL already exists, skipping account registration'); } catch (e) { log('[auto] Registering account'); await client.createAccount(accountPayload); } /** * Parse domains from CSR */ log('[auto] Parsing domains from Certificate Signing Request'); const { commonName, altNames } = readCsrDomains(opts.csr); const uniqueDomains = Array.from(new Set([commonName].concat(altNames).filter((d) => d))); log(`[auto] Resolved ${uniqueDomains.length} unique domains from parsing the Certificate Signing Request`); /** * Place order */ log('[auto] Placing new certificate order with ACME provider'); const orderPayload = { identifiers: uniqueDomains.map((d) => ({ type: 'dns', value: d })) }; const order = await client.createOrder(orderPayload); const authorizations = await client.getAuthorizations(order); log(`[auto] Placed certificate order successfully, received ${authorizations.length} identity authorizations`); /** * Resolve and satisfy challenges */ log('[auto] Resolving and satisfying authorization challenges'); const clearTasks = []; const challengeFunc = async (authz) => { const d = authz.identifier.value; let challengeCompleted = false; /* Skip authz that already has valid status */ if (authz.status === 'valid') { log(`[auto] [${d}] Authorization already has valid status, no need to complete challenges`); return; } try { /* Select challenge based on priority */ const challenge = authz.challenges.sort((a, b) => { const aidx = opts.challengePriority.indexOf(a.type); const bidx = opts.challengePriority.indexOf(b.type); if (aidx === -1) return 1; if (bidx === -1) return -1; return aidx - bidx; }).slice(0, 1)[0]; if (!challenge) { throw new Error(`Unable to select challenge for ${d}, no challenge found`); } log(`[auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}`); /* Trigger challengeCreateFn() */ log(`[auto] [${d}] Trigger challengeCreateFn()`); const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); try { const { recordReq, recordRes, dnsProvider } = await opts.challengeCreateFn(authz, challenge, keyAuthorization); log(`[auto] [${d}] challengeCreateFn success`); log(`[auto] [${d}] add challengeRemoveFn()`); clearTasks.push(async () => { /* Trigger challengeRemoveFn(), suppress errors */ log(`[auto] [${d}] Trigger challengeRemoveFn()`); try { await opts.challengeRemoveFn(authz, challenge, keyAuthorization, recordReq, recordRes, dnsProvider); } catch (e) { log(`[auto] [${d}] challengeRemoveFn threw error: ${e.message}`); } }); // throw new Error('测试异常'); /* Challenge verification */ if (opts.skipChallengeVerification === true) { log(`[auto] [${d}] Skipping challenge verification since skipChallengeVerification=true,wait 60s`); await wait(60 * 1000); } else { log(`[auto] [${d}] Running challenge verification`); try { await client.verifyChallenge(authz, challenge); } catch (e) { log(`[auto] [${d}] challenge verification threw error: ${e.message}`); } } /* Complete challenge and wait for valid status */ log(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`); await client.completeChallenge(challenge); challengeCompleted = true; await client.waitForValidStatus(challenge); } catch (e) { log(`[auto] [${d}] challengeCreateFn threw error: ${e.message}`); throw e; } } catch (e) { /* Deactivate pending authz when unable to complete challenge */ if (!challengeCompleted) { log(`[auto] [${d}] Unable to complete challenge: ${e.message}`); try { log(`[auto] [${d}] Deactivating failed authorization`); await client.deactivateAuthorization(authz); } catch (f) { /* Suppress deactivateAuthorization() errors */ log(`[auto] [${d}] Authorization deactivation threw error: ${f.message}`); } } throw e; } }; const domainSets = []; authorizations.forEach((authz) => { const d = authz.identifier.value; log(`authorization:domain = ${d}, value = ${JSON.stringify(authz)}`); if (authz.status === 'valid') { log(`[auto] [${d}] Authorization already has valid status, no need to complete challenges`); return; } let setd = false; // eslint-disable-next-line no-restricted-syntax for (const group of domainSets) { if (!group[d]) { group[d] = authz; setd = true; break; } } if (!setd) { const group = {}; group[d] = authz; domainSets.push(group); } }); // log(`domainSets:${JSON.stringify(domainSets)}`); const allChallengePromises = []; // eslint-disable-next-line no-restricted-syntax for (const domainSet of domainSets) { const challengePromises = []; // eslint-disable-next-line guard-for-in,no-restricted-syntax for (const domain in domainSet) { const authz = domainSet[domain]; challengePromises.push(async () => { log(`[auto] [${domain}] Starting challenge`); await challengeFunc(authz); }); } allChallengePromises.push(challengePromises); } log(`[auto] challengeGroups:${allChallengePromises.length}`); function runAllPromise(tasks) { let promise = Promise.resolve(); tasks.forEach((task) => { promise = promise.then(task); }); return promise; } async function runPromisePa(tasks) { const results = []; // eslint-disable-next-line no-await-in-loop,no-restricted-syntax for (const task of tasks) { results.push(task()); // eslint-disable-next-line no-await-in-loop await wait(10000); } return Promise.all(results); } try { log(`开始challenge,共${allChallengePromises.length}组`); let i = 0; // eslint-disable-next-line no-restricted-syntax for (const challengePromises of allChallengePromises) { i += 1; log(`开始第${i}组`); if (opts.signal && opts.signal.aborted) { throw new CancelError('用户取消'); } try { // eslint-disable-next-line no-await-in-loop await runPromisePa(challengePromises); } catch (e) { log(`证书申请失败${e.message}`); throw e; } finally { if (client.opts.sslProvider !== 'google') { // letsencrypt 如果同时检出两个TXT记录,会以第一个为准,就会校验失败,所以需要提前删除 // zerossl 此方式测试无问题 log(`清理challenge痕迹,length:${clearTasks.length}`); try { // eslint-disable-next-line no-await-in-loop await runAllPromise(clearTasks); } catch (e) { log('清理challenge失败'); log(e); } } } } } finally { if (client.opts.sslProvider === 'google') { // google 相同的域名txt记录是一样的,不能提前删除,否则校验失败,报错如下 // Error: The TXT record retrieved from _acme-challenge.bbc.handsfree.work. // at the time the challenge was validated did not contain JshHVu7dt_DT6uYILWhokHefFVad2Q6Mw1L-fNZFcq8 // (the base64url-encoded SHA-256 digest of RlJZNBR0LWnxNK_xd2zqtYVvCiNJOKJ3J1NmCjU_9BjaUJgL3k-qSpIhQ-uF4FBS.NRyqT8fRiq6THzzrvkgzgR5Xai2LsA2SyGLAq_wT3qc). // See https://tools.ietf.org/html/rfc8555#section-8.4 for more information. log(`清理challenge痕迹,length:${clearTasks.length}`); try { // eslint-disable-next-line no-await-in-loop await runAllPromise(clearTasks); } catch (e) { log('清理challenge失败'); log(e); } } } log('challenge结束'); // log('[auto] Waiting for challenge valid status'); // await Promise.all(challengePromises); /** * Finalize order and download certificate */ log('[auto] Finalizing order and downloading certificate'); const finalized = await client.finalizeOrder(order, opts.csr); const res = await client.getCertificate(finalized, opts.preferredChain); return res; // try { // await Promise.allSettled(challengePromises); // } // finally { // log('清理challenge'); // await Promise.allSettled(clearTasks); // } };