Files
certd/packages/ui/certd-server/src/plugins/plugin-cert/access/acme-account-access.ts
T
2026-06-14 20:59:38 +08:00

255 lines
7.1 KiB
TypeScript

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账号已生成,请保存授权配置",
type: "textarea",
rows: 4,
},
col: { span: 24 },
required: true,
helper: "请生成ACME账号,账号一旦生成不允许修改",
encrypt: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
}
}
`,
})
account = "";
@AccessInput({
title: "修改ACME账号",
component: {
name: "a-switch",
vModel: "checked",
},
required: false,
helper: "是否开启修改ACME账号,注意,开启后,会影响DNS持久验证记录",
encrypt: false,
})
editAccount = false;
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, null, 2);
}
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();