Files
certd/packages/plugins/plugin-cert/src/plugin/cert-plugin/acme.ts

452 lines
15 KiB
TypeScript
Raw Normal View History

2022-11-08 22:10:42 +08:00
// @ts-ignore
import * as acme from "@certd/acme-client";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
2024-11-08 23:43:19 +08:00
import * as _ from "lodash-es";
2022-11-08 22:10:42 +08:00
import { Challenge } from "@certd/acme-client/types/rfc8555";
2024-11-04 15:14:56 +08:00
import { IContext } from "@certd/pipeline";
2024-11-06 01:17:36 +08:00
import { ILogger, utils } from "@certd/basic";
import { IDnsProvider, IDomainParser } from "../../dns-provider/index.js";
2025-04-24 11:55:14 +08:00
import punycode from "node:punycode";
2025-04-25 01:26:04 +08:00
import { IOssClient } from "@certd/plugin-lib";
export type CnameVerifyPlan = {
2025-01-03 00:12:15 +08:00
type?: string;
domain: string;
fullRecord: string;
dnsProvider: IDnsProvider;
};
2025-01-03 00:12:15 +08:00
export type HttpVerifyPlan = {
type: string;
domain: string;
2025-04-25 01:26:04 +08:00
httpUploader: IOssClient;
2025-01-03 00:12:15 +08:00
};
export type DomainVerifyPlan = {
domain: string;
type: "cname" | "dns" | "http";
dnsProvider?: IDnsProvider;
cnameVerifyPlan?: Record<string, CnameVerifyPlan>;
2025-01-03 00:12:15 +08:00
httpVerifyPlan?: Record<string, HttpVerifyPlan>;
};
export type DomainsVerifyPlan = {
[key: string]: DomainVerifyPlan;
};
export type Providers = {
dnsProvider?: IDnsProvider;
domainsVerifyPlan?: DomainsVerifyPlan;
};
export type CertInfo = {
2024-12-12 16:45:40 +08:00
crt: string; //fullchain证书
key: string; //私钥
csr: string; //csr
oc?: string; //仅证书非fullchain证书
ic?: string; //中间证书
2024-09-06 00:13:21 +08:00
pfx?: string;
der?: string;
2024-10-30 01:44:02 +08:00
jks?: string;
2024-12-17 22:45:14 +08:00
one?: string;
};
2024-08-23 13:15:06 +08:00
export type SSLProvider = "letsencrypt" | "google" | "zerossl";
2024-08-25 11:56:15 +08:00
export type PrivateKeyType = "rsa_1024" | "rsa_2048" | "rsa_3072" | "rsa_4096" | "ec_256" | "ec_384" | "ec_521";
type AcmeServiceOptions = {
userContext: IContext;
2024-11-06 01:17:36 +08:00
logger: ILogger;
sslProvider: SSLProvider;
eab?: ClientExternalAccountBindingOptions;
skipLocalVerify?: boolean;
useMappingProxy?: boolean;
2024-10-10 15:32:25 +08:00
reverseProxy?: string;
privateKeyType?: PrivateKeyType;
signal?: AbortSignal;
maxCheckRetryCount?: number;
userId: number;
domainParser: IDomainParser;
waitDnsDiffuseTime?: number;
};
2022-11-08 22:10:42 +08:00
export class AcmeService {
options: AcmeServiceOptions;
2022-11-08 22:10:42 +08:00
userContext: IContext;
2024-11-06 01:17:36 +08:00
logger: ILogger;
2024-07-04 01:14:09 +08:00
sslProvider: SSLProvider;
skipLocalVerify = true;
2024-07-04 01:14:09 +08:00
eab?: ClientExternalAccountBindingOptions;
constructor(options: AcmeServiceOptions) {
this.options = options;
2022-11-08 22:10:42 +08:00
this.userContext = options.userContext;
this.logger = options.logger;
2024-07-04 01:14:09 +08:00
this.sslProvider = options.sslProvider || "letsencrypt";
this.eab = options.eab;
this.skipLocalVerify = options.skipLocalVerify ?? false;
2024-11-01 02:19:00 +08:00
acme.setLogger((message: any, ...args: any[]) => {
this.logger.info(message, ...args);
2022-11-08 22:10:42 +08:00
});
}
2024-08-23 13:15:06 +08:00
async getAccountConfig(email: string, urlMapping: UrlMapping): Promise<any> {
const conf = (await this.userContext.getObj(this.buildAccountKey(email))) || {};
2024-08-23 13:15:06 +08:00
if (urlMapping && urlMapping.mappings) {
for (const key in urlMapping.mappings) {
if (Object.prototype.hasOwnProperty.call(urlMapping.mappings, key)) {
const element = urlMapping.mappings[key];
if (conf.accountUrl?.indexOf(element) > -1) {
//如果用了代理url要替换回去
conf.accountUrl = conf.accountUrl.replace(element, key);
}
}
}
}
return conf;
2022-11-08 22:10:42 +08:00
}
buildAccountKey(email: string) {
2024-07-04 01:14:09 +08:00
return `acme.config.${this.sslProvider}.${email}`;
2022-11-08 22:10:42 +08:00
}
async saveAccountConfig(email: string, conf: any) {
2023-05-09 14:11:01 +08:00
await this.userContext.setObj(this.buildAccountKey(email), conf);
2022-11-08 22:10:42 +08:00
}
async getAcmeClient(email: string, isTest = false): Promise<acme.Client> {
const mappings = {};
2024-10-23 10:34:55 +08:00
if (this.sslProvider === "letsencrypt") {
mappings["acme-v02.api.letsencrypt.org"] = this.options.reverseProxy || "le.px.certd.handfree.work";
2024-10-23 10:34:55 +08:00
} else if (this.sslProvider === "google") {
mappings["dv.acme-v02.api.pki.goog"] = this.options.reverseProxy || "gg.px.certd.handfree.work";
}
2024-08-23 13:15:06 +08:00
const urlMapping: UrlMapping = {
enabled: false,
mappings,
2024-08-23 13:15:06 +08:00
};
const conf = await this.getAccountConfig(email, urlMapping);
2022-11-08 22:10:42 +08:00
if (conf.key == null) {
conf.key = await this.createNewKey();
await this.saveAccountConfig(email, conf);
this.logger.info(`创建新的Accountkey:${email}`);
2022-11-08 22:10:42 +08:00
}
2024-07-04 01:14:09 +08:00
let directoryUrl = "";
if (isTest) {
directoryUrl = acme.directory[this.sslProvider].staging;
} else {
directoryUrl = acme.directory[this.sslProvider].production;
}
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
2022-11-08 22:10:42 +08:00
const client = new acme.Client({
sslProvider: this.sslProvider,
2024-07-04 01:14:09 +08:00
directoryUrl: directoryUrl,
2022-11-08 22:10:42 +08:00
accountKey: conf.key,
accountUrl: conf.accountUrl,
2024-07-04 01:14:09 +08:00
externalAccountBinding: this.eab,
backoffAttempts: this.options.maxCheckRetryCount || 20,
2022-11-08 22:10:42 +08:00
backoffMin: 5000,
backoffMax: 10000,
urlMapping,
signal: this.options.signal,
2022-11-08 22:10:42 +08:00
});
if (conf.accountUrl == null) {
const accountPayload = {
termsOfServiceAgreed: true,
contact: [`mailto:${email}`],
2024-07-04 01:14:09 +08:00
externalAccountBinding: this.eab,
2022-11-08 22:10:42 +08:00
};
await client.createAccount(accountPayload);
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
}
return client;
}
async createNewKey() {
const key = await acme.crypto.createPrivateKey(2048);
2022-11-08 22:10:42 +08:00
return key.toString();
}
2025-01-03 01:17:20 +08:00
async challengeCreateFn(authz: any, keyAuthorizationGetter: (challenge: Challenge) => Promise<string>, providers: Providers) {
2022-11-08 22:10:42 +08:00
this.logger.info("Triggered challengeCreateFn()");
const fullDomain = authz.identifier.value;
let domain = await this.options.domainParser.parse(fullDomain);
2025-01-03 01:17:20 +08:00
this.logger.info("主域名为:" + domain);
const getChallenge = (type: string) => {
return authz.challenges.find((c: any) => c.type === type);
};
2025-04-25 01:26:04 +08:00
const doHttpVerify = async (challenge: any, httpUploader: IOssClient) => {
2025-01-03 01:17:20 +08:00
const keyAuthorization = await keyAuthorizationGetter(challenge);
this.logger.info("http校验");
const filePath = `.well-known/acme-challenge/${challenge.token}`;
2022-11-08 22:10:42 +08:00
const fileContents = keyAuthorization;
this.logger.info(`校验 ${fullDomain} ,准备上传文件:${filePath}`);
await httpUploader.upload(filePath, Buffer.from(fileContents));
this.logger.info(`上传文件【${filePath}】成功`);
2025-01-03 01:17:20 +08:00
return {
challenge,
keyAuthorization,
httpUploader,
2025-01-03 01:17:20 +08:00
};
};
2022-11-08 22:10:42 +08:00
2025-01-03 01:17:20 +08:00
const doDnsVerify = async (challenge: any, fullRecord: string, dnsProvider: IDnsProvider) => {
this.logger.info("dns校验");
const keyAuthorization = await keyAuthorizationGetter(challenge);
2025-04-24 11:55:14 +08:00
const mainDomain = dnsProvider.usePunyCode() ? domain : punycode.toUnicode(domain);
fullRecord = dnsProvider.usePunyCode() ? fullRecord : punycode.toUnicode(fullRecord);
2025-01-03 01:17:20 +08:00
const recordValue = keyAuthorization;
2025-04-24 11:55:14 +08:00
let hostRecord = fullRecord.replace(`${mainDomain}`, "");
if (hostRecord.endsWith(".")) {
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
}
const recordReq = {
2025-04-24 11:55:14 +08:00
domain: mainDomain,
fullRecord,
hostRecord,
2022-11-08 22:10:42 +08:00
type: "TXT",
value: recordValue,
};
this.logger.info("添加 TXT 解析记录", JSON.stringify(recordReq));
const recordRes = await dnsProvider.createRecord(recordReq);
this.logger.info("添加 TXT 解析记录成功", JSON.stringify(recordRes));
return {
recordReq,
recordRes,
dnsProvider,
2025-01-03 01:17:20 +08:00
challenge,
keyAuthorization,
};
2025-01-03 01:17:20 +08:00
};
let dnsProvider = providers.dnsProvider;
let fullRecord = `_acme-challenge.${fullDomain}`;
if (providers.domainsVerifyPlan) {
//按照计划执行
const domainVerifyPlan = providers.domainsVerifyPlan[domain];
if (domainVerifyPlan) {
if (domainVerifyPlan.type === "dns") {
dnsProvider = domainVerifyPlan.dnsProvider;
} else if (domainVerifyPlan.type === "cname") {
const cnameVerifyPlan = domainVerifyPlan.cnameVerifyPlan;
if (cnameVerifyPlan) {
const cname = cnameVerifyPlan[fullDomain];
if (cname) {
dnsProvider = cname.dnsProvider;
domain = await this.options.domainParser.parse(cname.domain);
2025-01-03 01:17:20 +08:00
fullRecord = cname.fullRecord;
}
} else {
this.logger.error(`未找到域名${fullDomain}的CNAME校验计划请修改证书申请配置`);
}
if (dnsProvider == null) {
throw new Error(`未找到域名${fullDomain}CNAME校验计划的DnsProvider请修改证书申请配置`);
2025-01-03 01:17:20 +08:00
}
} else if (domainVerifyPlan.type === "http") {
const httpVerifyPlan = domainVerifyPlan.httpVerifyPlan;
if (httpVerifyPlan) {
const httpChallenge = getChallenge("http-01");
2025-01-05 01:02:41 +08:00
if (httpChallenge == null) {
throw new Error("该域名不支持http-01方式校验");
}
const plan = httpVerifyPlan[fullDomain];
return await doHttpVerify(httpChallenge, plan.httpUploader);
2025-01-03 01:17:20 +08:00
} else {
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
}
} else {
throw new Error("不支持的校验类型", domainVerifyPlan.type);
}
} else {
this.logger.info("未找到域名校验计划使用默认的dnsProvider");
}
2022-11-08 22:10:42 +08:00
}
2025-01-03 01:17:20 +08:00
const dnsChallenge = getChallenge("dns-01");
return await doDnsVerify(dnsChallenge, fullRecord, dnsProvider);
2022-11-08 22:10:42 +08:00
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordReq
* @param recordRes
2022-11-08 22:10:42 +08:00
* @param dnsProvider dnsProvider
* @param httpUploader
2022-11-08 22:10:42 +08:00
* @returns {Promise}
*/
2025-04-25 01:26:04 +08:00
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordReq: any, recordRes: any, dnsProvider?: IDnsProvider, httpUploader?: IOssClient) {
this.logger.info("执行清理");
2022-11-08 22:10:42 +08:00
/* http-01 */
const fullDomain = authz.identifier.value;
2022-11-08 22:10:42 +08:00
if (challenge.type === "http-01") {
const filePath = `.well-known/acme-challenge/${challenge.token}`;
this.logger.info(`Removing challenge response for ${fullDomain} at file: ${filePath}`);
await httpUploader.remove(filePath);
this.logger.info(`删除文件【${filePath}】成功`);
2022-11-08 22:10:42 +08:00
} else if (challenge.type === "dns-01") {
this.logger.info(`删除 TXT 解析记录:${JSON.stringify(recordReq)} ,recordRes = ${JSON.stringify(recordRes)}`);
2023-05-09 13:52:25 +08:00
try {
await dnsProvider.removeRecord({
recordReq,
recordRes,
2023-05-09 13:52:25 +08:00
});
this.logger.info("删除解析记录成功");
2023-05-09 13:52:25 +08:00
} catch (e) {
this.logger.error("删除解析记录出错:", e);
throw e;
}
2022-11-08 22:10:42 +08:00
}
}
async order(options: {
email: string;
domains: string | string[];
dnsProvider?: any;
domainsVerifyPlan?: DomainsVerifyPlan;
httpUploader?: any;
csrInfo: any;
isTest?: boolean;
privateKeyType?: string;
}): Promise<CertInfo> {
const { email, isTest, csrInfo, dnsProvider, domainsVerifyPlan } = options;
2022-11-08 22:10:42 +08:00
const client: acme.Client = await this.getAcmeClient(email, isTest);
2025-04-24 11:55:14 +08:00
let domains = options.domains;
const encodingDomains = [];
for (const domain of domains) {
encodingDomains.push(punycode.toASCII(domain));
}
domains = encodingDomains;
2022-11-08 22:10:42 +08:00
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains);
let privateKey = null;
2024-08-25 12:07:47 +08:00
const privateKeyType = options.privateKeyType || "rsa_2048";
const privateKeyArr = privateKeyType.split("_");
2024-08-25 11:56:15 +08:00
const type = privateKeyArr[0];
2024-09-06 22:32:29 +08:00
let size = 2048;
if (privateKeyArr.length > 1) {
size = parseInt(privateKeyArr[1]);
}
2024-09-23 14:32:57 +08:00
let encodingType = "pkcs8";
if (privateKeyArr.length > 2) {
encodingType = privateKeyArr[2];
}
2024-08-25 11:56:15 +08:00
if (type == "ec") {
const name: any = "P-" + size;
2024-09-23 14:32:57 +08:00
privateKey = await acme.crypto.createPrivateEcdsaKey(name, encodingType);
} else {
2024-09-23 14:32:57 +08:00
privateKey = await acme.crypto.createPrivateRsaKey(size, encodingType);
}
2024-09-23 14:32:57 +08:00
let createCsr: any = acme.crypto.createCsr;
if (encodingType === "pkcs1") {
//兼容老版本
createCsr = acme.forge.createCsr;
}
const [key, csr] = await createCsr(
{
commonName,
...csrInfo,
altNames,
},
privateKey
);
2024-09-23 14:32:57 +08:00
if (dnsProvider == null && domainsVerifyPlan == null) {
throw new Error("dnsProvider 、 domainsVerifyPlan不能都为空");
2022-11-08 22:10:42 +08:00
}
const providers: Providers = {
dnsProvider,
domainsVerifyPlan,
};
2022-11-08 22:10:42 +08:00
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
2025-01-03 00:12:15 +08:00
challengePriority: ["dns-01", "http-01"],
challengeCreateFn: async (
authz: acme.Authorization,
2025-01-03 01:17:20 +08:00
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
): Promise<{ recordReq?: any; recordRes?: any; dnsProvider?: any; challenge: Challenge; keyAuthorization: string }> => {
return await this.challengeCreateFn(authz, keyAuthorizationGetter, providers);
2022-11-08 22:10:42 +08:00
},
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordReq: any, recordRes: any, dnsProvider: IDnsProvider, httpUploader: IOssClient): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordReq, recordRes, dnsProvider, httpUploader);
2022-11-08 22:10:42 +08:00
},
signal: this.options.signal,
2022-11-08 22:10:42 +08:00
});
2024-09-22 23:19:10 +08:00
const crtString = crt.toString();
const cert: CertInfo = {
2024-09-22 23:19:10 +08:00
crt: crtString,
2022-11-08 22:10:42 +08:00
key: key.toString(),
csr: csr.toString(),
};
/* Done */
this.logger.debug(`CSR:\n${cert.csr}`);
this.logger.debug(`Certificate:\n${cert.crt}`);
this.logger.info("证书申请成功");
return cert;
}
buildCommonNameByDomains(domains: string | string[]): {
commonName: string;
altNames: string[] | undefined;
} {
if (typeof domains === "string") {
domains = domains.split(",");
}
if (domains.length === 0) {
throw new Error("domain can not be empty");
}
const commonName = domains[0];
let altNames: undefined | string[] = undefined;
if (domains.length > 1) {
altNames = _.slice(domains, 1);
}
return {
commonName,
altNames,
};
}
private async testDirectory(directoryUrl: string) {
try {
2024-09-09 17:29:09 +08:00
await utils.http.request({
url: directoryUrl,
method: "GET",
2024-09-04 11:26:56 +08:00
timeout: 10000,
});
} catch (e) {
2024-10-25 10:57:38 +08:00
this.logger.error(`${directoryUrl},测试访问失败`, e.message);
return false;
}
this.logger.info(`${directoryUrl},测试访问成功`);
return true;
}
2022-11-08 22:10:42 +08:00
}