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

321 lines
10 KiB
TypeScript
Raw Normal View History

2022-11-08 22:10:42 +08:00
// @ts-ignore
import * as acme from "@certd/acme-client";
2024-07-15 00:30:33 +08:00
import _ from "lodash-es";
2022-11-08 22:10:42 +08:00
import { Challenge } from "@certd/acme-client/types/rfc8555";
import { Logger } from "log4js";
2024-05-30 10:12:48 +08:00
import { IContext } from "@certd/pipeline";
2024-07-15 00:30:33 +08:00
import { IDnsProvider } from "../../dns-provider/index.js";
import psl from "psl";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import { utils } from "@certd/pipeline";
export type CertInfo = {
crt: string;
key: string;
csr: 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;
logger: Logger;
sslProvider: SSLProvider;
eab?: ClientExternalAccountBindingOptions;
skipLocalVerify?: boolean;
useMappingProxy?: boolean;
privateKeyType?: PrivateKeyType;
signal?: AbortSignal;
};
2022-11-08 22:10:42 +08:00
export class AcmeService {
options: AcmeServiceOptions;
2022-11-08 22:10:42 +08:00
userContext: IContext;
logger: Logger;
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;
2022-11-08 22:10:42 +08:00
acme.setLogger((text: string) => {
this.logger.info(text);
});
}
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> {
2024-08-23 13:15:06 +08:00
const urlMapping: UrlMapping = {
enabled: false,
mappings: {
"acme-v02.api.letsencrypt.org": "letsencrypt.proxy.handsfree.work",
"dv.acme-v02.api.pki.goog": "google.proxy.handsfree.work",
},
};
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);
}
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({
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,
2024-08-23 13:15:06 +08:00
backoffAttempts: 15,
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.forge.createPrivateKey();
return key.toString();
}
parseDomain(fullDomain: string) {
const parsed = psl.parse(fullDomain) as psl.ParsedDomain;
if (parsed.error) {
throw new Error(`解析${fullDomain}域名失败:` + JSON.stringify(parsed.error));
}
return parsed.domain as string;
}
2022-11-08 22:10:42 +08:00
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider) {
this.logger.info("Triggered challengeCreateFn()");
/* http-01 */
const fullDomain = authz.identifier.value;
2022-11-08 22:10:42 +08:00
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
const fileContents = keyAuthorization;
this.logger.info(`Creating challenge response for ${fullDomain} at path: ${filePath}`);
2022-11-08 22:10:42 +08:00
/* Replace this */
this.logger.info(`Would write "${fileContents}" to path "${filePath}"`);
// await fs.writeFileAsync(filePath, fileContents);
} else if (challenge.type === "dns-01") {
/* dns-01 */
const dnsRecord = `_acme-challenge.${fullDomain}`;
2022-11-08 22:10:42 +08:00
const recordValue = keyAuthorization;
this.logger.info(`Creating TXT record for ${fullDomain}: ${dnsRecord}`);
2022-11-08 22:10:42 +08:00
/* Replace this */
this.logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`);
const domain = this.parseDomain(fullDomain);
this.logger.info("解析到域名domain=", domain);
2022-11-08 22:10:42 +08:00
return await dnsProvider.createRecord({
fullRecord: dnsRecord,
type: "TXT",
value: recordValue,
domain,
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 recordItem challengeCreateFn create record item
* @param dnsProvider dnsProvider
* @returns {Promise}
*/
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordItem: any, dnsProvider: IDnsProvider) {
this.logger.info("Triggered challengeRemoveFn()");
/* http-01 */
const fullDomain = authz.identifier.value;
2022-11-08 22:10:42 +08:00
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
this.logger.info(`Removing challenge response for ${fullDomain} at path: ${filePath}`);
2022-11-08 22:10:42 +08:00
/* Replace this */
this.logger.info(`Would remove file on path "${filePath}"`);
// await fs.unlinkAsync(filePath);
} else if (challenge.type === "dns-01") {
const dnsRecord = `_acme-challenge.${fullDomain}`;
2022-11-08 22:10:42 +08:00
const recordValue = keyAuthorization;
this.logger.info(`Removing TXT record for ${fullDomain}: ${dnsRecord}`);
2022-11-08 22:10:42 +08:00
/* Replace this */
this.logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`);
const domain = this.parseDomain(fullDomain);
2023-05-09 13:52:25 +08:00
try {
await dnsProvider.removeRecord({
fullRecord: dnsRecord,
type: "TXT",
value: keyAuthorization,
record: recordItem,
domain,
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;
csrInfo: any;
isTest?: boolean;
privateKeyType?: string;
}): Promise<CertInfo> {
2022-11-08 22:10:42 +08:00
const { email, isTest, domains, csrInfo, dnsProvider } = options;
const client: acme.Client = await this.getAcmeClient(email, isTest);
/* 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];
const size = parseInt(privateKeyArr[1]);
if (type == "ec") {
const name: any = "P-" + size;
privateKey = await acme.crypto.createPrivateEcdsaKey(name);
} else {
2024-08-25 11:56:15 +08:00
privateKey = await acme.crypto.createPrivateRsaKey(size);
}
2024-08-25 11:56:15 +08:00
const [key, csr] = await acme.crypto.createCsr(
{
commonName,
...csrInfo,
altNames,
},
privateKey
);
2022-11-08 22:10:42 +08:00
if (dnsProvider == null) {
throw new Error("dnsProvider 不能为空");
}
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
2022-11-08 22:10:42 +08:00
challengePriority: ["dns-01"],
challengeCreateFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string): Promise<any> => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider);
},
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
},
signal: this.options.signal,
2022-11-08 22:10:42 +08:00
});
const cert: CertInfo = {
2022-11-08 22:10:42 +08:00
crt: crt.toString(),
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 {
await utils.http({
url: directoryUrl,
method: "GET",
timeout: 5000,
});
} catch (e) {
this.logger.error(`${directoryUrl},测试访问失败`, e);
return false;
}
this.logger.info(`${directoryUrl},测试访问成功`);
return true;
}
2022-11-08 22:10:42 +08:00
}