2023-01-29 15:27:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ACME challenge verification
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2024-11-12 12:15:06 +08:00
|
|
|
|
import dnsSdk from "dns"
|
|
|
|
|
|
import https from 'https'
|
|
|
|
|
|
import {log} from './logger.js'
|
|
|
|
|
|
import axios from './axios.js'
|
|
|
|
|
|
import * as util from './util.js'
|
|
|
|
|
|
import {isAlpnCertificateAuthorizationValid} from './crypto/index.js'
|
2023-01-29 15:27:11 +08:00
|
|
|
|
|
2024-11-12 12:15:06 +08:00
|
|
|
|
|
|
|
|
|
|
const dns = dnsSdk.promises
|
2023-01-29 15:27:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Verify ACME HTTP challenge
|
|
|
|
|
|
*
|
2024-02-03 19:24:11 +00:00
|
|
|
|
* https://datatracker.ietf.org/doc/html/rfc8555#section-8.3
|
2023-01-29 15:27:11 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param {object} authz Identifier authorization
|
|
|
|
|
|
* @param {object} challenge Authorization challenge
|
|
|
|
|
|
* @param {string} keyAuthorization Challenge key authorization
|
|
|
|
|
|
* @param {string} [suffix] URL suffix
|
|
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyHttpChallenge(authz, challenge, keyAuthorization, suffix = `/.well-known/acme-challenge/${challenge.token}`) {
|
|
|
|
|
|
|
2025-05-06 17:01:20 +08:00
|
|
|
|
async function doQuery(challengeUrl){
|
|
|
|
|
|
log(`正在测试请求 ${challengeUrl} `)
|
|
|
|
|
|
// const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443;
|
|
|
|
|
|
// const challengeUrl = `https://${authz.identifier.value}:${httpsPort}${suffix}`;
|
|
|
|
|
|
|
|
|
|
|
|
/* May redirect to HTTPS with invalid/self-signed cert - https://letsencrypt.org/docs/challenge-types/#http-01-challenge */
|
|
|
|
|
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
2024-01-22 19:24:37 +00:00
|
|
|
|
|
2025-05-06 17:01:20 +08:00
|
|
|
|
log(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`);
|
|
|
|
|
|
let data = ""
|
|
|
|
|
|
try{
|
|
|
|
|
|
const resp = await axios.get(challengeUrl, { httpsAgent });
|
|
|
|
|
|
data = (resp.data || '').replace(/\s+$/, '');
|
|
|
|
|
|
}catch (e) {
|
|
|
|
|
|
log(`[error] HTTP request error from ${authz.identifier.value}`,e);
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2023-01-29 15:27:11 +08:00
|
|
|
|
|
2025-05-06 17:01:20 +08:00
|
|
|
|
if (!data || (data !== keyAuthorization)) {
|
|
|
|
|
|
log(`[error] Authorization not found in HTTPS response from ${authz.identifier.value}`);
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
2023-01-29 15:27:11 +08:00
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-06 17:01:20 +08:00
|
|
|
|
const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80;
|
|
|
|
|
|
const challengeUrl = `http://${authz.identifier.value}:${httpPort}${suffix}`;
|
|
|
|
|
|
|
|
|
|
|
|
if (!await doQuery(challengeUrl)) {
|
|
|
|
|
|
const httpsPort = axios.defaults.acmeSettings.httpsChallengePort || 443;
|
|
|
|
|
|
const httpsChallengeUrl = `https://${authz.identifier.value}:${httpsPort}${suffix}`;
|
|
|
|
|
|
const res = await doQuery(httpsChallengeUrl)
|
|
|
|
|
|
if (!res) {
|
|
|
|
|
|
throw new Error(`[error] 验证失败,请检查以上测试url是否可以正常访问`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-01-29 15:27:11 +08:00
|
|
|
|
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Walk DNS until TXT records are found
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-03-29 23:10:59 +08:00
|
|
|
|
async function walkDnsChallengeRecord(recordName, resolver = dns,deep = 0) {
|
|
|
|
|
|
|
|
|
|
|
|
let records = [];
|
2023-01-29 15:27:11 +08:00
|
|
|
|
|
|
|
|
|
|
/* Resolve TXT records */
|
|
|
|
|
|
try {
|
2025-03-29 23:10:59 +08:00
|
|
|
|
log(`检查域名 ${recordName} 的TXT记录`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
const txtRecords = await resolver.resolveTxt(recordName);
|
|
|
|
|
|
|
2024-10-10 02:15:05 +08:00
|
|
|
|
if (txtRecords && txtRecords.length) {
|
2025-03-29 23:10:59 +08:00
|
|
|
|
log(`找到 ${txtRecords.length} 条 TXT记录( ${recordName})`);
|
2024-10-07 03:21:16 +08:00
|
|
|
|
log(`TXT records: ${JSON.stringify(txtRecords)}`);
|
2025-03-29 23:10:59 +08:00
|
|
|
|
records = records.concat(...txtRecords);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
}
|
2025-03-29 23:10:59 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log(`解析 TXT 记录出错, ${recordName} :${e.message}`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
}
|
2025-03-29 23:10:59 +08:00
|
|
|
|
|
|
|
|
|
|
/* Resolve CNAME record first */
|
|
|
|
|
|
try {
|
|
|
|
|
|
log(`检查是否存在CNAME映射: ${recordName}`);
|
|
|
|
|
|
const cnameRecords = await resolver.resolveCname(recordName);
|
|
|
|
|
|
|
|
|
|
|
|
if (cnameRecords.length) {
|
|
|
|
|
|
const cnameRecord = cnameRecords[0];
|
|
|
|
|
|
log(`已找到${recordName}的CNAME记录,将检查: ${cnameRecord}`);
|
|
|
|
|
|
let res= await walkTxtRecord(cnameRecord,deep+1);
|
|
|
|
|
|
if (res && res.length) {
|
|
|
|
|
|
log(`从CNAME中找到TXT记录: ${JSON.stringify(res)}`);
|
|
|
|
|
|
records = records.concat(...res);
|
|
|
|
|
|
}
|
|
|
|
|
|
}else{
|
|
|
|
|
|
log(`没有CNAME映射(${recordName})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log(`检查CNAME出错(${recordName}) :${e.message}`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
}
|
2025-03-29 23:10:59 +08:00
|
|
|
|
return records
|
2024-10-10 02:15:05 +08:00
|
|
|
|
}
|
2023-01-29 15:27:11 +08:00
|
|
|
|
|
2025-03-29 23:10:59 +08:00
|
|
|
|
export async function walkTxtRecord(recordName,deep = 0) {
|
|
|
|
|
|
if(deep >5){
|
|
|
|
|
|
log(`walkTxtRecord too deep (#${deep}) , skip walk`)
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
2025-03-25 11:08:25 +08:00
|
|
|
|
|
|
|
|
|
|
const txtRecords = []
|
2024-10-10 02:15:05 +08:00
|
|
|
|
try {
|
|
|
|
|
|
/* Default DNS resolver first */
|
2025-03-25 11:08:25 +08:00
|
|
|
|
log('从本地DNS服务器获取TXT解析记录');
|
2025-04-04 20:46:48 +08:00
|
|
|
|
const res = await walkDnsChallengeRecord(recordName,dns,deep);
|
2024-10-10 02:15:05 +08:00
|
|
|
|
if (res && res.length > 0) {
|
2025-03-25 11:08:25 +08:00
|
|
|
|
for (const item of res) {
|
|
|
|
|
|
txtRecords.push(item)
|
|
|
|
|
|
}
|
2024-10-10 02:15:05 +08:00
|
|
|
|
}
|
2025-03-25 11:08:25 +08:00
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log(`本地获取TXT解析记录失败:${e.message}`)
|
2024-10-10 02:15:05 +08:00
|
|
|
|
}
|
2025-03-25 11:08:25 +08:00
|
|
|
|
|
|
|
|
|
|
try{
|
2024-10-10 02:15:05 +08:00
|
|
|
|
/* Authoritative DNS resolver */
|
2025-03-25 11:08:25 +08:00
|
|
|
|
log(`从域名权威服务器获取TXT解析记录`);
|
2024-10-10 02:15:05 +08:00
|
|
|
|
const authoritativeResolver = await util.getAuthoritativeDnsResolver(recordName);
|
2025-03-29 23:10:59 +08:00
|
|
|
|
const res = await walkDnsChallengeRecord(recordName, authoritativeResolver,deep);
|
2025-03-25 11:08:25 +08:00
|
|
|
|
if (res && res.length > 0) {
|
|
|
|
|
|
for (const item of res) {
|
|
|
|
|
|
txtRecords.push(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}catch (e) {
|
|
|
|
|
|
log(`权威服务器获取TXT解析记录失败:${e.message}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (txtRecords.length === 0) {
|
|
|
|
|
|
throw new Error(`没有找到TXT解析记录(${recordName})`);
|
2024-10-10 02:15:05 +08:00
|
|
|
|
}
|
2025-03-25 11:08:25 +08:00
|
|
|
|
return txtRecords;
|
2023-01-29 15:27:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Verify ACME DNS challenge
|
|
|
|
|
|
*
|
2024-02-03 19:24:11 +00:00
|
|
|
|
* https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
|
2023-01-29 15:27:11 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param {object} authz Identifier authorization
|
|
|
|
|
|
* @param {object} challenge Authorization challenge
|
|
|
|
|
|
* @param {string} keyAuthorization Challenge key authorization
|
|
|
|
|
|
* @param {string} [prefix] DNS prefix
|
|
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = '_acme-challenge.') {
|
|
|
|
|
|
const recordName = `${prefix}${authz.identifier.value}`;
|
2025-03-25 11:12:24 +08:00
|
|
|
|
log(`本地校验TXT记录): ${recordName}`);
|
2025-03-28 23:27:24 +08:00
|
|
|
|
let recordValues = await walkTxtRecord(recordName);
|
|
|
|
|
|
//去重
|
|
|
|
|
|
recordValues = [...new Set(recordValues)];
|
2025-04-05 00:24:57 +08:00
|
|
|
|
log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
if (!recordValues.length || !recordValues.includes(keyAuthorization)) {
|
2025-03-25 11:12:24 +08:00
|
|
|
|
throw new Error(`没有找到需要的DNS TXT记录: ${recordName},期望:${keyAuthorization},结果:${recordValues}`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-05 00:24:57 +08:00
|
|
|
|
log(`关键授权匹配成功(${challenge.type}/${recordName}):${keyAuthorization},校验成功, ACME challenge verified`);
|
2023-01-29 15:27:11 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-01-30 19:24:20 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Verify ACME TLS ALPN challenge
|
|
|
|
|
|
*
|
2024-02-03 19:24:11 +00:00
|
|
|
|
* https://datatracker.ietf.org/doc/html/rfc8737
|
2024-01-30 19:24:20 +00:00
|
|
|
|
*
|
|
|
|
|
|
* @param {object} authz Identifier authorization
|
|
|
|
|
|
* @param {object} challenge Authorization challenge
|
|
|
|
|
|
* @param {string} keyAuthorization Challenge key authorization
|
|
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyTlsAlpnChallenge(authz, challenge, keyAuthorization) {
|
|
|
|
|
|
const tlsAlpnPort = axios.defaults.acmeSettings.tlsAlpnChallengePort || 443;
|
|
|
|
|
|
const host = authz.identifier.value;
|
|
|
|
|
|
log(`Establishing TLS connection with host: ${host}:${tlsAlpnPort}`);
|
|
|
|
|
|
|
|
|
|
|
|
const certificate = await util.retrieveTlsAlpnCertificate(host, tlsAlpnPort);
|
|
|
|
|
|
log('Certificate received from server successfully, matching key authorization in ALPN');
|
|
|
|
|
|
|
|
|
|
|
|
if (!isAlpnCertificateAuthorizationValid(certificate, keyAuthorization)) {
|
|
|
|
|
|
throw new Error(`Authorization not found in certificate from ${authz.identifier.value}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-01-29 15:27:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Export API
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2024-11-12 12:15:06 +08:00
|
|
|
|
export default {
|
2023-01-29 15:27:11 +08:00
|
|
|
|
'http-01': verifyHttpChallenge,
|
2024-01-30 19:24:20 +00:00
|
|
|
|
'dns-01': verifyDnsChallenge,
|
2024-05-22 19:24:07 +00:00
|
|
|
|
'tls-alpn-01': verifyTlsAlpnChallenge,
|
2023-01-29 15:27:11 +08:00
|
|
|
|
};
|