feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式

This commit is contained in:
xiaojunnuo
2026-05-24 05:42:51 +08:00
parent 8edb6f8727
commit 67b05e2d75
51 changed files with 3352 additions and 110 deletions
@@ -0,0 +1,59 @@
import assert from "assert";
import { AcmeAccountAccess } from "./acme-account-access.js";
import { AcmeService } from "../plugin/cert-plugin/acme.js";
describe("AcmeAccountAccess", () => {
it("requires generated account payload before use", () => {
const access = new AcmeAccountAccess();
assert.throws(() => access.getAccount(), /ACME账号信息无效/);
});
it("parses generated account payload", () => {
const access = new AcmeAccountAccess();
access.account = JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
email: "user@example.com",
directoryUrl: "https://example.com/directory",
});
const account = access.getAccount();
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/1");
});
it("generates account payload through acme service", async () => {
const original = AcmeService.prototype.getAcmeClient;
const calls: string[] = [];
AcmeService.prototype.getAcmeClient = async function (email: string) {
calls.push(email);
await this.userContext.setObj(this.buildAccountKey(email), { key: "generated-key" });
return {
getAccountUrl() {
return "https://example.com/acct/2";
},
} as any;
};
try {
const access = new AcmeAccountAccess();
access.caType = "google";
access.email = "user@example.com";
access.eabKid = "kid-1";
access.eabHmacKey = "hmac-1";
const account = JSON.parse(await access.onGenerateAccount());
assert.equal(calls[0], "user@example.com");
assert.equal(account.accountKey, "generated-key");
assert.equal(account.accountUri, "https://example.com/acct/2");
assert.equal(account.caType, "google");
assert.equal(account.email, "user@example.com");
} finally {
AcmeService.prototype.getAcmeClient = original;
}
});
});
@@ -0,0 +1,239 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import * as acme from "@certd/acme-client";
import { AcmeService } from "../plugin/cert-plugin/acme.js";
export type AcmeAccountInfo = {
accountKey: string;
accountUri: string;
caType: string;
email: string;
directoryUrl: string;
eab?: {
kid?: string;
hmacKey?: string;
usedAt: number;
};
};
function parseAccount(account?: string | AcmeAccountInfo): AcmeAccountInfo | null {
if (!account) {
return null;
}
if (typeof account !== "string") {
return account;
}
return JSON.parse(account);
}
@IsAccess({
name: "acmeAccount",
title: "ACME账号",
desc: "用于复用ACME账号私钥和账号地址,证书申请时不再临时创建账号",
icon: "ph:certificate",
subtype: "caType",
} as any)
export class AcmeAccountAccess extends BaseAccess {
@AccessInput({
title: "颁发机构",
component: {
name: "a-select",
options: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
{ value: "litessl", label: "litessl" },
{ value: "sslcom", label: "SSL.com" },
],
},
required: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
caType = "letsencrypt";
@AccessInput({
title: "邮箱",
component: {
placeholder: "user@example.com",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
required: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
email = "";
@AccessInput({
title: "ACME Directory URL",
component: {
placeholder: "自定义ACME服务端点",
},
helper: "自定义ACME时必填,其他颁发机构默认自动使用内置端点",
required: false,
mergeScript: `
return {
show: false,
}
`,
})
directoryUrl = "";
@AccessInput({
title: "EAB KID",
component: {
placeholder: "需要EAB的颁发机构生成账号时填写",
},
helper:
"需要提供EAB授权" +
"\nZeroSSL:请前往[zerossl开发者中心](https://app.zerossl.com/developer),生成 'EAB Credentials'" +
"\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" +
"\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" +
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
required: false,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
return ['google','zerossl','sslcom','litessl'].includes(caType);
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
eabKid = "";
@AccessInput({
title: "EAB HMAC Key",
component: {
placeholder: "需要EAB的颁发机构生成账号时填写",
},
required: false,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
return ['google','zerossl','sslcom','litessl'].includes(caType);
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
eabHmacKey = "";
@AccessInput({
title: "ACME账号信息",
component: {
name: "refresh-input",
action: "GenerateAccount",
buttonText: "生成ACME账号",
successMessage: "ACME账号已生成,请保存授权配置",
},
required: true,
helper: "请生成ACME账号,账号一旦生成不允许修改",
encrypt: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
account = "";
getDirectoryUrl() {
if (this.caType === "custom") {
if (!this.directoryUrl) {
throw new Error("自定义ACME需要填写Directory URL");
}
return this.directoryUrl;
}
return acme.getDirectoryUrl({ sslProvider: this.caType, pkType: "rsa_2048" });
}
async onGenerateAccount() {
if (!this.caType) {
throw new Error("请先选择颁发机构");
}
if (!this.email) {
throw new Error("请先填写邮箱");
}
const needEab = ["google", "zerossl", "sslcom", "litessl"].includes(this.caType);
if (needEab && (!this.eabKid || !this.eabHmacKey)) {
throw new Error("该颁发机构需要填写EAB KID和EAB HMAC Key后才能生成账号");
}
const account = await this.createAccountInfo();
return JSON.stringify(account);
}
private async createAccountInfo(): Promise<AcmeAccountInfo> {
const directoryUrl = this.getDirectoryUrl();
const externalAccountBinding = this.eabKid && this.eabHmacKey ? { kid: this.eabKid, hmacKey: this.eabHmacKey } : undefined;
const memoryStore = new Map<string, any>();
const userContext = {
async getObj(key: string) {
return memoryStore.get(key);
},
async setObj(key: string, value: any) {
memoryStore.set(key, value);
},
};
const acmeService = new AcmeService({
userId: 0,
userContext: userContext as any,
logger: (this.ctx?.logger || console) as any,
sslProvider: this.caType as any,
eab: externalAccountBinding ? ({ ...externalAccountBinding, id: 0 } as any) : undefined,
privateKeyType: "rsa_2048",
signal: (this.ctx as any)?.signal,
maxCheckRetryCount: 20,
domainParser: {} as any,
});
const client = await acmeService.getAcmeClient(this.email);
const conf = await userContext.getObj(acmeService.buildAccountKey(this.email));
if (!conf?.key || !client.getAccountUrl()) {
throw new Error("ACME账号生成失败,请稍后重试");
}
const account: AcmeAccountInfo = {
accountKey: conf.key,
accountUri: client.getAccountUrl(),
caType: this.caType,
email: this.email,
directoryUrl,
};
if (externalAccountBinding) {
account.eab = {
...externalAccountBinding,
usedAt: Date.now(),
};
}
return account;
}
getAccount(): AcmeAccountInfo {
const account = parseAccount(this.account);
if (!account?.accountKey || !account?.accountUri) {
throw new Error("ACME账号信息无效,请重新生成ACME账号");
}
return account;
}
}
new AcmeAccountAccess();
@@ -1,2 +1,3 @@
export * from "./eab-access.js";
export * from "./google-access.js";
export * from "./acme-account-access.js";
@@ -1,4 +1,5 @@
import assert from "assert";
import { utils } from "@certd/basic";
import { AcmeService } from "./acme.js";
const logger = {
@@ -173,4 +174,28 @@ describe("AcmeService challenge", () => {
assert.deepEqual(parseCalls, ["www.example.com", "certd-key.cname.sub.example.com"]);
});
it("enables proxy mapping when acme directory request fails", async () => {
const originalRequest = utils.http.request;
utils.http.request = async () => {
throw new Error("timeout");
};
try {
const service = new AcmeService({
userId: 1,
userContext: {} as any,
logger: logger as any,
sslProvider: "google",
domainParser: {} as any,
});
const urlMapping = await service.resolveUrlMapping("https://dv.acme-v02.api.pki.goog/directory");
assert.equal(urlMapping.enabled, true);
assert.equal(urlMapping.mappings["dv.acme-v02.api.pki.goog"], "gg.px.certd.handfree.work");
} finally {
utils.http.request = originalRequest;
}
});
});
@@ -21,15 +21,30 @@ export type HttpVerifyPlan = {
export type DomainVerifyPlan = {
domain: string;
mainDomain: string;
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProvider?: IDnsProvider;
cnameVerifyPlan?: CnameVerifyPlan;
httpVerifyPlan?: HttpVerifyPlan;
dnsPersistVerifyPlan?: DnsPersistVerifyPlan;
};
export type DomainsVerifyPlan = {
[key: string]: DomainVerifyPlan;
};
export type AcmeAccountInfo = {
accountKey: string;
accountUri: string;
caType: SSLProvider | string;
email: string;
directoryUrl: string;
};
export type DnsPersistVerifyPlan = {
hostRecord: string;
recordValue: string;
accountUri: string;
};
export type Providers = {
dnsProvider?: IDnsProvider;
domainsVerifyPlan?: DomainsVerifyPlan;
@@ -153,8 +168,7 @@ export class AcmeService {
await this.userContext.setObj(this.buildAccountKey(email), conf);
}
async getAcmeClient(email: string): Promise<acme.Client> {
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
buildUrlMapping(directoryUrl: string): UrlMapping {
let targetUrl = directoryUrl.replace("https://", "");
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
@@ -174,10 +188,29 @@ export class AcmeService {
if (this.options.reverseProxy && targetUrl) {
mappings[targetUrl] = this.options.reverseProxy;
}
const urlMapping: UrlMapping = {
return {
enabled: false,
mappings,
};
}
async resolveUrlMapping(directoryUrl: string) {
const urlMapping = this.buildUrlMapping(directoryUrl);
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
return urlMapping;
}
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
return urlMapping;
}
async getAcmeClient(email: string): Promise<acme.Client> {
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
const urlMapping = await this.resolveUrlMapping(directoryUrl);
const conf = await this.getAccountConfig(email, urlMapping);
if (conf.key == null) {
conf.key = await this.createNewKey();
@@ -185,16 +218,6 @@ export class AcmeService {
this.logger.info(`创建新的Accountkey:${email}`);
}
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
const client = new acme.Client({
sslProvider: this.sslProvider,
directoryUrl: directoryUrl,
@@ -238,6 +261,26 @@ export class AcmeService {
return client;
}
async getAcmeClientByAccount(account: AcmeAccountInfo): Promise<acme.Client> {
if (!account?.accountKey || !account?.accountUri) {
throw new Error("ACME账号信息无效,请重新生成ACME账号");
}
const directoryUrl = account.directoryUrl || acme.getDirectoryUrl({ sslProvider: account.caType, pkType: this.options.privateKeyType });
const urlMapping = await this.resolveUrlMapping(directoryUrl);
return new acme.Client({
sslProvider: account.caType,
directoryUrl,
accountKey: account.accountKey,
accountUrl: account.accountUri,
backoffAttempts: this.options.maxCheckRetryCount || 20,
backoffMin: 5000,
backoffMax: 30 * 1000,
urlMapping,
signal: this.options.signal,
logger: this.logger,
});
}
async createNewKey() {
const key = await acme.crypto.createPrivateKey(2048);
return key.toString();
@@ -300,6 +343,18 @@ export class AcmeService {
};
};
const doDnsPersistVerify = async (challenge: any, plan: DnsPersistVerifyPlan) => {
if (challenge == null) {
throw new Error("该域名不支持dns-persist-01方式校验,请确认当前CA是否已开放该能力");
}
this.logger.info("DNS持久验证");
challenge.expectedRecordValue = plan.recordValue;
return {
challenge,
keyAuthorization: "",
};
};
let dnsProvider = providers.dnsProvider;
let fullRecord = `_acme-challenge.${fullDomain}`;
@@ -343,6 +398,9 @@ export class AcmeService {
} else {
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
}
} else if (domainVerifyPlan.type === "dns-persist") {
checkIpChallenge("dns-persist");
return await doDnsPersistVerify(getChallenge("dns-persist-01"), domainVerifyPlan.dnsPersistVerifyPlan);
} else {
throw new Error("不支持的校验类型", domainVerifyPlan.type);
}
@@ -394,6 +452,8 @@ export class AcmeService {
this.logger.error("删除解析记录出错:", e);
throw e;
}
} else if (challenge.type === "dns-persist-01") {
this.logger.info(`DNS持久验证无需清理:${fullDomain}`);
}
}
@@ -407,9 +467,10 @@ export class AcmeService {
privateKeyType?: string;
profile?: string;
preferredChain?: string;
acmeAccount?: AcmeAccountInfo;
}): Promise<CertInfo> {
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain } = options;
const client: acme.Client = await this.getAcmeClient(email);
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain, acmeAccount } = options;
const client: acme.Client = acmeAccount ? await this.getAcmeClientByAccount(acmeAccount) : await this.getAcmeClient(email);
let domains = options.domains;
const encodingDomains = [];
@@ -463,12 +524,13 @@ export class AcmeService {
domainsVerifyPlan,
};
/* 自动申请证书 */
const challengePriority = domainsVerifyPlan && Object.values(domainsVerifyPlan).some((item: any) => item?.type === "dns-persist") ? ["dns-persist-01"] : ["dns-01", "http-01"];
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
challengePriority: ["dns-01", "http-01"],
challengePriority,
challengeCreateFn: async (
authz: acme.Authorization,
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
@@ -0,0 +1,47 @@
import assert from "assert";
import { CertApplyPlugin } from "./apply.js";
describe("CertApplyPlugin dns-persist verify plan", () => {
it("keeps dns-persist entries when building mixed domain verify plans", async () => {
const plugin: any = new CertApplyPlugin();
plugin.acme = {
options: {
domainParser: {
async parse(domain: string) {
return domain;
},
},
},
};
const plan = await plugin.createDomainsVerifyPlan(
["*.handfree.work"],
{
"handfree.work": {
domain: "handfree.work",
type: "dns-persist",
dnsPersistVerifyPlan: {
"handfree.work": {
domain: "handfree.work",
status: "valid",
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard",
accountUri: "https://acme.example/acct/1",
},
},
},
},
{
accountKey: "private-key",
accountUri: "https://acme.example/acct/1",
caType: "letsencrypt_staging",
email: "user@example.com",
directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory",
}
);
assert.equal(plan["handfree.work"].type, "dns-persist");
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.hostRecord, "_validation-persist");
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.recordValue, "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard");
});
});
@@ -1,7 +1,7 @@
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import { AcmeAccountInfo, AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
import { CertReader } from "@certd/plugin-lib";
import { CertApplyBasePlugin } from "./base.js";
@@ -22,14 +22,22 @@ export type HttpRecordInput = {
httpUploaderAccess: number;
httpUploadRootDir: string;
};
export type DnsPersistRecordInput = {
domain: string;
status?: string;
hostRecord?: string;
recordValue?: string;
accountUri?: string;
};
export type DomainVerifyPlanInput = {
domain: string;
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecordInput>;
httpVerifyPlan?: Record<string, HttpRecordInput>;
dnsPersistVerifyPlan?: Record<string, DnsPersistRecordInput>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
@@ -99,6 +107,19 @@ const preferredChainMergeScript = (() => {
},
})
export class CertApplyPlugin extends CertApplyBasePlugin {
constructor() {
super();
this.version = 1;
}
@TaskInput({
title: "版本",
value: 2,
isSys: true,
show: false,
})
version?: number;
@TaskInput({
title: "域名验证方式",
value: "dns",
@@ -107,6 +128,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
vModel: "value",
options: [
{ value: "dns", label: "DNS直接验证" },
{ value: "dns-persist", label: "DNS持久验证" },
{ value: "cname", label: "CNAME代理验证" },
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
{ value: "dnses", label: "多DNS提供商" },
@@ -119,12 +141,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
6. <b>DNS持久验证</b>:需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请
`,
})
challengeType!: string;
@TaskInput({
title: "DNS解析服务商",
component: {
@@ -145,7 +166,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}
`,
required: true,
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证校验方式",
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证",
})
dnsProviderType!: string;
@@ -190,18 +211,30 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}),
defaultType: ctx.compute(({form})=>{
return form.challengeType || 'cname'
}),
caType: ctx.compute(({form})=>{
return form.sslProvider
}),
acmeAccountAccessId: ctx.compute(({form})=>{
return form.acmeAccountAccessId
}),
commonAcmeAccountAccessId: ctx.compute(({form})=>{
const key = form.sslProvider + 'CommonAcmeAccountAccessId';
return form[key]
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses'
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses' || form.challengeType === 'dns-persist'
}),
helper: ctx.compute(({form})=>{
if(form.challengeType === 'cname' ){
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
}else if (form.challengeType === 'http'){
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
}else if (form.challengeType === 'http'){
}else if (form.challengeType === 'dnses'){
return '给每个域名单独配置dns提供商'
}else if (form.challengeType === 'dns-persist'){
return '请先创建并校验_validation-persist TXT持久记录,校验成功后才能提交流水线;当前仅 Let\\'s Encrypt 测试环境可以申请'
}
})
}
@@ -209,7 +242,6 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
domainsVerifyPlan!: DomainsVerifyPlanInput;
@TaskInput({
title: "证书颁发机构",
value: "letsencrypt",
@@ -237,6 +269,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
googleCommonEabAccessId!: number;
@TaskInput({
title: "Google公共ACME账号授权",
isSys: true,
show: false,
})
googleCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "ZeroSSL公共EAB授权",
isSys: true,
@@ -244,6 +283,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
zerosslCommonEabAccessId!: number;
@TaskInput({
title: "ZeroSSL公共ACME账号授权",
isSys: true,
show: false,
})
zerosslCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "SSL.com公共EAB授权",
isSys: true,
@@ -251,6 +297,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
sslcomCommonEabAccessId!: number;
@TaskInput({
title: "SSL.com公共ACME账号授权",
isSys: true,
show: false,
})
sslcomCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "litessl公共EAB授权",
isSys: true,
@@ -258,6 +311,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
litesslCommonEabAccessId!: number;
@TaskInput({
title: "litessl公共ACME账号授权",
isSys: true,
show: false,
})
litesslCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "EAB授权",
component: {
@@ -275,7 +335,16 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
mergeScript: `
return {
show: ctx.compute(({form})=>{
console.log("show",form)
if (form.version === 2) {
return false
}
if(form.acmeAccountAccessId){
return false
}
const commonAcmeKey = form.sslProvider + 'CommonAcmeAccountAccessId';
if (form[commonAcmeKey]) {
return false
}
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
@@ -286,6 +355,31 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
eabAccessId!: number;
@TaskInput({
title: "ACME账号授权",
component: {
name: "access-selector",
type: "acmeAccount",
},
required: false,
helper: "请选择颁发机构对应的ACME账号",
mergeScript: `
return {
show: ctx.compute(({form})=>{
const commonKey = form.sslProvider + 'CommonAcmeAccountAccessId';
if (form[commonKey]) {
return false
}
return !!form.sslProvider
}),
component:{
subtype: ctx.compute(({form})=> form.sslProvider)
}
}
`,
})
acmeAccountAccessId!: number;
@TaskInput({
title: "服务账号授权",
component: {
@@ -298,6 +392,15 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
mergeScript: `
return {
show: ctx.compute(({form})=>{
if (form.version === 2) {
return false
}
if(form.acmeAccountAccessId){
return false
}
if(form.googleCommonAcmeAccountAccessId){
return false
}
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
})
}
@@ -432,7 +535,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
async onInit() {
let eab: EabAccess = null;
if (this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
const isNewVersion = this.version === 2;
if (!isNewVersion && this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
if (this.sslProvider === "google" && this.googleAccessId) {
this.logger.info("当前正在使用 google服务账号授权获取EAB");
const googleAccess = await this.getAccess(this.googleAccessId);
@@ -499,8 +603,23 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
let dnsProvider: IDnsProvider = null;
let domainsVerifyPlan: DomainsVerifyPlan = null;
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan);
let acmeAccount: AcmeAccountInfo = null;
if (this.acmeAccountAccessId) {
const access: any = await this.getAccess(this.acmeAccountAccessId);
acmeAccount = this.parseAcmeAccount(access.account);
} else {
acmeAccount = await this.getCommonAcmeAccount();
}
if (this.version === 2 && !this.sslProvider.startsWith("letsencrypt") && !acmeAccount) {
throw new Error("请选择颁发机构对应的ACME账号");
}
if (this.challengeType === "dns-persist") {
if (!acmeAccount) {
throw new Error("DNS持久验证需要先选择ACME账号授权");
}
domainsVerifyPlan = await this.createDnsPersistDomainsVerifyPlan(domains, acmeAccount);
} else if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan, acmeAccount);
} else if (this.challengeType === "auto") {
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
} else {
@@ -519,6 +638,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
privateKeyType: this.privateKeyType,
profile: this.certProfile,
preferredChain: this.preferredChain,
acmeAccount,
});
const certInfo = this.formatCerts(cert);
@@ -552,7 +672,80 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
});
}
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
parseAcmeAccount(account: string | AcmeAccountInfo): AcmeAccountInfo {
if (!account) {
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
}
const parsed = typeof account === "string" ? JSON.parse(account) : account;
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号授权无效,请重新生成ACME账号");
}
return parsed;
}
async getCommonAcmeAccount(): Promise<AcmeAccountInfo | null> {
if (!this.sslProvider || this.sslProvider === "letsencrypt" || this.sslProvider === "letsencrypt_staging") {
return null;
}
const commonAccessId = this[`${this.sslProvider}CommonAcmeAccountAccessId`];
if (!commonAccessId) {
return null;
}
const accessService: any = this.ctx.accessService;
if (!accessService?.getCommonById) {
return null;
}
const access = await accessService.getCommonById(commonAccessId);
if (!access?.account) {
return null;
}
this.logger.info(`使用系统公共${this.sslProvider} ACME账号`);
return this.parseAcmeAccount(access.account);
}
private async createDnsPersistDomainsVerifyPlan(domains: string[], acmeAccount: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
const domainParser = this.acme.options.domainParser;
for (const fullDomain of domains) {
const domain = fullDomain.replaceAll("*.", "");
const mainDomain = await domainParser.parse(domain);
const persistRecord = this.domainsVerifyPlan?.[mainDomain]?.dnsPersistVerifyPlan?.[domain];
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, persistRecord);
}
return plan;
}
private createDnsPersistDomainVerifyPlan(domain: string, mainDomain: string, acmeAccount: AcmeAccountInfo, persistRecord?: DnsPersistRecordInput): DomainVerifyPlan {
if (!persistRecord) {
throw new Error(`DNS持久验证记录${domain}不存在,请先创建并校验`);
}
if (persistRecord.status !== "valid") {
throw new Error(`DNS持久验证记录${domain}还未校验成功`);
}
return {
type: "dns-persist",
mainDomain,
domain,
dnsPersistVerifyPlan: {
hostRecord: persistRecord.hostRecord || `_validation-persist.${domain}`,
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true),
accountUri: persistRecord.accountUri || acmeAccount.accountUri,
},
};
}
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number) {
const parts = [`letsencrypt.org`, `accounturi=${accountUri}`];
if (wildcard !== false) {
parts.push("policy=wildcard");
}
if (persistUntil) {
parts.push(`persistUntil=${persistUntil}`);
}
return parts.join("; ");
}
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput, acmeAccount?: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
const domainParser = this.acme.options.domainParser;
@@ -569,6 +762,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
} else if (planSetting.type === "http") {
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
} else if (planSetting.type === "dns-persist") {
if (!acmeAccount) {
throw new Error("DNS持久验证需要先选择ACME账号授权");
}
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, planSetting.dnsPersistVerifyPlan?.[domain]);
}
}
return plan;
@@ -677,9 +875,9 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}
async onGetReverseProxyList() {
const sysSettingsService:any = await this.ctx.serviceGetter.get("sysSettingsService");
const sysSettingsService: any = await this.ctx.serviceGetter.get("sysSettingsService");
const sysSettings = await sysSettingsService.getPrivateSettings();
return sysSettings.reverseProxyList || []
return sysSettings.reverseProxyList || [];
}
}