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";