feat: 域名验证方法支持CNAME间接方式,此方式支持所有域名注册商,且无需提供Access授权,但是需要手动添加cname解析

This commit is contained in:
xiaojunnuo
2024-10-07 03:21:16 +08:00
parent 0c8e83e125
commit f3d35084ed
123 changed files with 2373 additions and 456 deletions

View File

@@ -8,14 +8,16 @@ export type DnsProviderDefine = Registrable & {
};
export type CreateRecordOptions = {
domain: string;
fullRecord: string;
hostRecord: string;
type: string;
value: any;
domain: string;
};
export type RemoveRecordOptions<T> = CreateRecordOptions & {
export type RemoveRecordOptions<T> = {
recordReq: CreateRecordOptions;
// 本次创建的dns解析记录实际上就是createRecord接口的返回值
record: T;
recordRes: T;
};
export type DnsProviderContext = {

View File

@@ -1,4 +1,5 @@
import { CreateRecordOptions, DnsProviderContext, IDnsProvider, RemoveRecordOptions } from "./api.js";
import psl from "psl";
export abstract class AbstractDnsProvider<T = any> implements IDnsProvider<T> {
ctx!: DnsProviderContext;
@@ -13,3 +14,11 @@ export abstract class AbstractDnsProvider<T = any> implements IDnsProvider<T> {
abstract removeRecord(options: RemoveRecordOptions<T>): Promise<void>;
}
export function 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;
}

View File

@@ -1,13 +1,28 @@
// @ts-ignore
import * as acme from "@certd/acme-client";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import _ from "lodash-es";
import { Challenge } from "@certd/acme-client/types/rfc8555";
import { Logger } from "log4js";
import { IContext } from "@certd/pipeline";
import { IDnsProvider } from "../../dns-provider/index.js";
import psl from "psl";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import { utils } from "@certd/pipeline";
import { IContext, utils } from "@certd/pipeline";
import { IDnsProvider, parseDomain } from "../../dns-provider/index.js";
export type CnameVerifyPlan = {
domain: string;
fullRecord: string;
dnsProvider: IDnsProvider;
};
export type DomainVerifyPlan = {
domain: string;
type: "cname" | "dns";
dnsProvider?: IDnsProvider;
cnameVerifyPlan?: Record<string, CnameVerifyPlan>;
};
export type DomainsVerifyPlan = {
[key: string]: DomainVerifyPlan;
};
export type CertInfo = {
crt: string;
key: string;
@@ -132,14 +147,7 @@ export class AcmeService {
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;
}
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider) {
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider, domainsVerifyPlan: DomainsVerifyPlan) {
this.logger.info("Triggered challengeCreateFn()");
/* http-01 */
@@ -155,21 +163,62 @@ export class AcmeService {
// await fs.writeFileAsync(filePath, fileContents);
} else if (challenge.type === "dns-01") {
/* dns-01 */
const dnsRecord = `_acme-challenge.${fullDomain}`;
let fullRecord = `_acme-challenge.${fullDomain}`;
const recordValue = keyAuthorization;
this.logger.info(`Creating TXT record for ${fullDomain}: ${dnsRecord}`);
this.logger.info(`Creating TXT record for ${fullDomain}: ${fullRecord}`);
/* Replace this */
this.logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`);
this.logger.info(`Would create TXT record "${fullRecord}" with value "${recordValue}"`);
const domain = this.parseDomain(fullDomain);
let domain = parseDomain(fullDomain);
this.logger.info("解析到域名domain=", domain);
return await dnsProvider.createRecord({
fullRecord: dnsRecord,
if (domainsVerifyPlan) {
//按照计划执行
const domainVerifyPlan = 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 = parseDomain(cname.domain);
fullRecord = cname.fullRecord;
}
} else {
this.logger.error("未找到域名Cname校验计划使用默认的dnsProvider");
}
} else {
this.logger.error("不支持的校验类型", domainVerifyPlan.type);
}
} else {
this.logger.info("未找到域名校验计划使用默认的dnsProvider");
}
}
let hostRecord = fullRecord.replace(`${domain}`, "");
if (hostRecord.endsWith(".")) {
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
}
const recordReq = {
domain,
fullRecord,
hostRecord,
type: "TXT",
value: recordValue,
domain,
});
};
this.logger.info("添加 TXT 解析记录", JSON.stringify(recordReq));
const recordRes = await dnsProvider.createRecord(recordReq);
this.logger.info("添加 TXT 解析记录成功", JSON.stringify(recordRes));
return {
recordReq,
recordRes,
dnsProvider,
};
}
}
@@ -179,12 +228,13 @@ export class AcmeService {
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordItem challengeCreateFn create record item
* @param recordReq
* @param recordRes
* @param dnsProvider dnsProvider
* @returns {Promise}
*/
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordItem: any, dnsProvider: IDnsProvider) {
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordReq: any, recordRes: any, dnsProvider: IDnsProvider) {
this.logger.info("Triggered challengeRemoveFn()");
/* http-01 */
@@ -198,24 +248,13 @@ export class AcmeService {
this.logger.info(`Would remove file on path "${filePath}"`);
// await fs.unlinkAsync(filePath);
} else if (challenge.type === "dns-01") {
const dnsRecord = `_acme-challenge.${fullDomain}`;
const recordValue = keyAuthorization;
this.logger.info(`Removing TXT record for ${fullDomain}: ${dnsRecord}`);
/* Replace this */
this.logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`);
const domain = this.parseDomain(fullDomain);
this.logger.info(`删除 TXT 解析记录:${JSON.stringify(recordReq)} ,recordRes = ${JSON.stringify(recordRes)}`);
try {
await dnsProvider.removeRecord({
fullRecord: dnsRecord,
type: "TXT",
value: keyAuthorization,
record: recordItem,
domain,
recordReq,
recordRes,
});
this.logger.info("删除解析记录成功");
} catch (e) {
this.logger.error("删除解析记录出错:", e);
throw e;
@@ -226,12 +265,13 @@ export class AcmeService {
async order(options: {
email: string;
domains: string | string[];
dnsProvider: any;
dnsProvider?: any;
domainsVerifyPlan?: DomainsVerifyPlan;
csrInfo: any;
isTest?: boolean;
privateKeyType?: string;
}): Promise<CertInfo> {
const { email, isTest, domains, csrInfo, dnsProvider } = options;
const { email, isTest, domains, csrInfo, dnsProvider, domainsVerifyPlan } = options;
const client: acme.Client = await this.getAcmeClient(email, isTest);
/* Create CSR */
@@ -271,8 +311,8 @@ export class AcmeService {
privateKey
);
if (dnsProvider == null) {
throw new Error("dnsProvider 不能为空");
if (dnsProvider == null && domainsVerifyPlan == null) {
throw new Error("dnsProvider 、 domainsVerifyPlan 不能为空");
}
/* 自动申请证书 */
const crt = await client.auto({
@@ -281,11 +321,22 @@ export class AcmeService {
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
challengePriority: ["dns-01"],
challengeCreateFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string): Promise<any> => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider);
challengeCreateFn: async (
authz: acme.Authorization,
challenge: Challenge,
keyAuthorization: string
): Promise<{ recordReq: any; recordRes: any; dnsProvider: any }> => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider, domainsVerifyPlan);
},
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
challengeRemoveFn: async (
authz: acme.Authorization,
challenge: Challenge,
keyAuthorization: string,
recordReq: any,
recordRes: any,
dnsProvider: IDnsProvider
): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordReq, recordRes, dnsProvider);
},
signal: this.options.signal,
});

View File

@@ -24,7 +24,7 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
col: {
span: 24,
},
order: -1,
order: -999,
helper:
"1、支持通配符域名例如 *.foo.com、foo.com、*.test.handsfree.work\n" +
"2、支持多个域名、多个子域名、多个通配符域名打到一个证书上域名必须是在同一个DNS提供商解析\n" +
@@ -39,6 +39,7 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
name: "a-input",
vModel: "value",
},
rules: [{ type: "email" }],
required: true,
order: -1,
helper: "请输入邮箱",

View File

@@ -1,13 +1,27 @@
import { Decorator, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, utils } from "@certd/pipeline";
import type { CertInfo, PrivateKeyType, SSLProvider } from "./acme.js";
import type { CertInfo, CnameVerifyPlan, DomainsVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import { AcmeService } from "./acme.js";
import _ from "lodash-es";
import { DnsProviderContext, DnsProviderDefine, dnsProviderRegistry } from "../../dns-provider/index.js";
import { DnsProviderContext, DnsProviderDefine, dnsProviderRegistry, IDnsProvider } from "../../dns-provider/index.js";
import { CertReader } from "./cert-reader.js";
import { CertApplyBasePlugin } from "./base.js";
export type { CertInfo };
export * from "./cert-reader.js";
export type CnameRecordInput = {
id: number;
status: string;
};
export type DomainVerifyPlanInput = {
domain: string;
type: "cname" | "dns";
dnsProviderType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecordInput>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
};
@IsTaskPlugin({
name: "CertApply",
@@ -26,6 +40,85 @@ export * from "./cert-reader.js";
},
})
export class CertApplyPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "域名验证方式",
value: "dns",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "dns", label: "DNS直接验证" },
{ value: "cname", label: "CNAME间接验证" },
],
},
required: true,
helper:
"DNS直接验证适合域名是在阿里云、腾讯云、华为云、Cloudflare、西数注册的需要提供Access授权信息。\nCNAME间接验证支持任何注册商注册的域名并且不需要提供Access授权信息但第一次需要手动添加CNAME记录",
})
challengeType!: string;
@TaskInput({
title: "DNS提供商",
component: {
name: "dns-provider-selector",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.challengeType === 'dns'
})
}
`,
required: true,
helper: "请选择dns解析提供商您的域名是在哪里注册的或者域名的dns解析服务器属于哪个平台\n如果这里没有您需要的dns解析提供商请选择CNAME间接验证校验方式",
})
dnsProviderType!: string;
@TaskInput({
title: "DNS解析授权",
component: {
name: "access-selector",
},
required: true,
helper: "请选择dns解析提供商授权",
mergeScript: `return {
component:{
type: ctx.compute(({form})=>{
return form.dnsProviderType
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'dns'
})
}
`,
})
dnsProviderAccess!: number;
@TaskInput({
title: "域名验证配置",
component: {
name: "domains-verify-plan-editor",
},
required: true,
helper: "如果选择CNAME方式请按照上面的显示给域名添加CNAME记录",
col: {
span: 24,
},
mergeScript: `return {
component:{
domains: ctx.compute(({form})=>{
return form.domains
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'cname'
})
}
`,
})
domainsVerifyPlan!: DomainsVerifyPlanInput;
@TaskInput({
title: "证书提供商",
value: "letsencrypt",
@@ -38,7 +131,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
{ value: "zerossl", label: "ZeroSSL" },
],
},
helper: "Let's Encrypt最简单如果使用ZeroSSL、google证书需要提供EAB授权",
helper: "Let's Encrypt最简单如果使用ZeroSSL、Google证书需要提供EAB授权",
required: true,
})
sslProvider!: SSLProvider;
@@ -46,7 +139,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "EAB授权",
component: {
name: "pi-access-selector",
name: "access-selector",
type: "eab",
},
maybeNeed: true,
@@ -80,39 +173,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
// { value: "ec_521", label: "EC 521" },
],
},
helper: "如无特殊需求,默认即可",
required: true,
})
privateKeyType!: PrivateKeyType;
@TaskInput({
title: "DNS提供商",
component: {
name: "pi-dns-provider-selector",
},
required: true,
helper:
"请选择dns解析提供商您的域名是在哪里注册的或者域名的dns解析服务器属于哪个平台\n如果这里没有您需要的dns解析提供商您需要将域名解析服务器设置成上面的任意一个提供商",
})
dnsProviderType!: string;
@TaskInput({
title: "DNS解析授权",
component: {
name: "pi-access-selector",
},
required: true,
helper: "请选择dns解析提供商授权",
mergeScript: `return {
component:{
type: ctx.compute(({form})=>{
return form.dnsProviderType
})
}
}
`,
})
dnsProviderAccess!: string;
@TaskInput({
title: "使用代理",
value: false,
@@ -120,7 +185,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
name: "a-switch",
vModel: "checked",
},
helper: "如果acme-v02.api.letsencrypt.org或dv.acme-v02.api.pki.goog被墙无法访问请尝试开启此选项",
helper: "如果acme-v02.api.letsencrypt.org或dv.acme-v02.api.pki.goog被墙无法访问请尝试开启此选项\n默认情况会进行测试如果无法访问将会自动使用代理",
})
useProxy = false;
@@ -131,7 +196,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
name: "a-switch",
vModel: "checked",
},
helper: "如果重试多次出现Authorization not found TXT record导致无法申请成功请尝试开启此选项",
helper: "跳过本地校验可以加快申请速度,同时也会增加失败概率。",
})
skipLocalVerify = false;
@@ -150,14 +215,15 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
skipLocalVerify: this.skipLocalVerify,
useMappingProxy: this.useProxy,
privateKeyType: this.privateKeyType,
// cnameProxyService: this.ctx.cnameProxyService,
// dnsProviderCreator: this.createDnsProvider.bind(this),
});
}
async doCertApply() {
const email = this["email"];
const domains = this["domains"];
const dnsProviderType = this["dnsProviderType"];
const dnsProviderAccessId = this["dnsProviderAccess"];
const csrInfo = _.merge(
{
country: "CN",
@@ -171,26 +237,22 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
);
this.logger.info("开始申请证书,", email, domains);
const dnsProviderPlugin = dnsProviderRegistry.get(dnsProviderType);
const DnsProviderClass = dnsProviderPlugin.target;
const dnsProviderDefine = dnsProviderPlugin.define as DnsProviderDefine;
if (dnsProviderDefine.deprecated) {
throw new Error(dnsProviderDefine.deprecated);
let dnsProvider: any = null;
let domainsVerifyPlan: DomainsVerifyPlan = null;
if (this.challengeType === "cname") {
domainsVerifyPlan = await this.createDomainsVerifyPlan();
} else {
const dnsProviderType = this.dnsProviderType;
const dnsProviderAccessId = this.dnsProviderAccess;
dnsProvider = await this.createDnsProvider(dnsProviderType, dnsProviderAccessId);
}
const access = await this.accessService.getById(dnsProviderAccessId);
// @ts-ignore
const dnsProvider: IDnsProvider = new DnsProviderClass();
const context: DnsProviderContext = { access, logger: this.logger, http: this.http, utils };
Decorator.inject(dnsProviderDefine.autowire, dnsProvider, context);
dnsProvider.setCtx(context);
await dnsProvider.onInstance();
try {
const cert = await this.acme.order({
email,
domains,
dnsProvider,
domainsVerifyPlan,
csrInfo,
isTest: false,
privateKeyType: this.privateKeyType,
@@ -207,6 +269,52 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
throw e;
}
}
async createDnsProvider(dnsProviderType: string, dnsProviderAccessId: number): Promise<IDnsProvider> {
const dnsProviderPlugin = dnsProviderRegistry.get(dnsProviderType);
const DnsProviderClass = dnsProviderPlugin.target;
const dnsProviderDefine = dnsProviderPlugin.define as DnsProviderDefine;
if (dnsProviderDefine.deprecated) {
throw new Error(dnsProviderDefine.deprecated);
}
const access = await this.accessService.getById(dnsProviderAccessId);
// @ts-ignore
const dnsProvider: IDnsProvider = new DnsProviderClass();
const context: DnsProviderContext = { access, logger: this.logger, http: this.http, utils };
Decorator.inject(dnsProviderDefine.autowire, dnsProvider, context);
dnsProvider.setCtx(context);
await dnsProvider.onInstance();
return dnsProvider;
}
async createDomainsVerifyPlan(): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
for (const domain in this.domainsVerifyPlan) {
const domainVerifyPlan = this.domainsVerifyPlan[domain];
let dnsProvider = null;
const cnameVerifyPlan: Record<string, CnameVerifyPlan> = {};
if (domainVerifyPlan.type === "dns") {
dnsProvider = await this.createDnsProvider(domainVerifyPlan.dnsProviderType, domainVerifyPlan.dnsProviderAccessId);
} else {
for (const key in domainVerifyPlan.cnameVerifyPlan) {
const cnameRecord = await this.ctx.cnameProxyService.getByDomain(key);
cnameVerifyPlan[key] = {
domain: cnameRecord.cnameProvider.domain,
fullRecord: cnameRecord.recordValue,
dnsProvider: await this.createDnsProvider(cnameRecord.cnameProvider.dnsProviderType, cnameRecord.cnameProvider.accessId),
};
}
}
plan[domain] = {
domain,
type: domainVerifyPlan.type,
dnsProvider,
cnameVerifyPlan,
};
}
return plan;
}
}
new CertApplyPlugin();

View File

@@ -69,7 +69,7 @@ export class CertApplyLegoPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "EAB授权",
component: {
name: "pi-access-selector",
name: "access-selector",
type: "eab",
},
maybeNeed: true,