perf: 重构自动加载模块并优化EAB授权处理

refactor(ui): 将分散的auto-*模块整合为统一命名的auto-register模块
perf(plugin-cert): 增强EAB授权功能,支持账号私钥刷新和类型选择
test: 添加EAB授权服务和ACME账号配置的单元测试
docs: 更新AGENTS.md补充ACME/EAB使用注意事项
chore: 统一各package.json中的测试脚本配置
This commit is contained in:
xiaojunnuo
2026-05-10 16:57:12 +08:00
parent 37d03c10f9
commit 4755216505
32 changed files with 911 additions and 105 deletions
@@ -0,0 +1,20 @@
import assert from "assert";
import { EabAccess } from "./eab-access.js";
describe("EabAccess", () => {
it("generates an account key payload for the current kid", async () => {
const access = new EabAccess();
access.kid = "kid-1";
const payload = JSON.parse(await access.onGenerateAccountKey());
assert.equal(payload.kid, "kid-1");
assert.match(payload.privateKey, /BEGIN (RSA )?PRIVATE KEY/);
});
it("requires kid before generating the account key payload", async () => {
const access = new EabAccess();
await assert.rejects(() => access.onGenerateAccountKey(), /请先填写KID/);
});
});
@@ -1,4 +1,5 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
import * as acme from "@certd/acme-client";
@IsAccess({
name: "eab",
@@ -7,6 +8,23 @@ import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
icon: "ic:outline-lock",
})
export class EabAccess extends BaseAccess {
@AccessInput({
title: "EAB类型",
component: {
name: "a-select",
options: [
{ value: "google", label: "Google(免费)", icon: "flat-color-icons:google" },
{ value: "zerossl", label: "ZeroSSL(免费)", icon: "emojione:digit-zero" },
{ value: "litessl", label: "litessl(免费)", icon: "roentgen:free" },
{ value: "sslcom", label: "SSL.com(仅主域名和www免费)", icon: "la:expeditedssl" },
],
},
helper: "请选择EAB类型",
required: true,
encrypt: false,
})
eabType = "";
@AccessInput({
title: "KID",
component: {
@@ -34,10 +52,35 @@ export class EabAccess extends BaseAccess {
placeholder: "绑定一个邮箱",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
helper: "Google的EAB申请证书,更换邮箱会导致EAB失效,可以在此处绑定一个邮箱避免此问题",
helper: "绑定一个邮箱避免失效",
required: true,
})
email = "";
@AccessInput({
title: "ACME账号私钥",
component: {
name: "refresh-input",
action: "GenerateAccountKey",
buttonText: "刷新账号私钥",
successMessage: "账号私钥已刷新,请保存授权配置",
},
required: true,
helper: "如果修改了KID,请点击刷新重新生成账号私钥",
encrypt: true,
})
accountKey = "";
async onGenerateAccountKey() {
if (!this.kid) {
throw new Error("请先填写KID");
}
const key = await acme.crypto.createPrivateKey(2048);
return JSON.stringify({
kid: this.kid,
privateKey: key.toString(),
});
}
}
new EabAccess();
@@ -0,0 +1,114 @@
import assert from "assert";
import { AcmeService } from "./acme.js";
const logger = {
info() {},
error() {},
warn() {},
debug() {},
};
describe("AcmeService account config", () => {
it("keeps legacy email-based account config when EAB has no saved account key", async () => {
const userContext = {
async getObj(key: string) {
if (key === "acme.config.google.user@example.com") {
return {
key: "legacy-email-key",
accountUrl: "https://dv.acme-v02.api.pki.goog/acme/acct/legacy",
};
}
return null;
},
async setObj() {},
};
const service = new AcmeService({
userId: 1,
userContext: userContext as any,
logger: logger as any,
sslProvider: "google",
eab: {
id: 12,
kid: "kid-1",
hmacKey: "hmac",
} as any,
domainParser: {} as any,
});
const conf = await service.getAccountConfig("user@example.com", { enabled: false, mappings: {} });
assert.equal(conf.key, "legacy-email-key");
assert.equal(conf.accountUrl, "https://dv.acme-v02.api.pki.goog/acme/acct/legacy");
});
it("uses the account key saved on the EAB access before legacy email config", async () => {
const userContext = {
async getObj(key: string) {
if (key === "acme.config.google.access.12") {
return { accountUrl: "https://dv.acme-v02.api.pki.goog/acme/acct/1" };
}
if (key === "acme.config.google.user@example.com") {
return { key: "legacy-email-key" };
}
return null;
},
async setObj() {},
};
const service = new AcmeService({
userId: 1,
userContext: userContext as any,
logger: logger as any,
sslProvider: "google",
eab: {
id: 12,
kid: "kid-1",
hmacKey: "hmac",
accountKey: JSON.stringify({ kid: "kid-1", privateKey: "eab-account-key" }),
} as any,
domainParser: {} as any,
});
const conf = await service.getAccountConfig("user@example.com", { enabled: false, mappings: {} });
assert.equal(conf.key, "eab-account-key");
assert.equal(conf.accountUrl, "https://dv.acme-v02.api.pki.goog/acme/acct/1");
});
it("rejects an EAB account key generated for another kid", async () => {
const service = new AcmeService({
userId: 1,
userContext: {} as any,
logger: logger as any,
sslProvider: "google",
eab: {
id: 12,
kid: "kid-2",
hmacKey: "hmac",
accountKey: JSON.stringify({ kid: "kid-1", privateKey: "eab-account-key" }),
} as any,
domainParser: {} as any,
});
assert.throws(() => service.getEabAccountPrivateKey(), /请点击刷新重新生成ACME账号私钥/);
});
it("formats expired EAB errors with a Chinese recovery hint", () => {
const service = new AcmeService({
userId: 1,
userContext: {} as any,
logger: logger as any,
sslProvider: "google",
eab: {
id: 12,
kid: "kid-1",
hmacKey: "hmac",
} as any,
domainParser: {} as any,
});
const error = service.formatCreateAccountError(new Error("Unknown external account binding (EAB) key. This may be due to the EAB key expiring"));
assert.match(error.message, /EAB授权已失效或已过期/);
assert.match(error.message, /请重新获取EAB授权并刷新ACME账号私钥后重试/);
});
});
@@ -49,11 +49,15 @@ export type CertInfo = {
};
export type SSLProvider = "letsencrypt" | "google" | "zerossl" | "sslcom" | "letsencrypt_staging";
export type PrivateKeyType = "rsa_1024" | "rsa_2048" | "rsa_3072" | "rsa_4096" | "ec_256" | "ec_384" | "ec_521";
type AcmeEabOptions = ClientExternalAccountBindingOptions & {
id?: number;
accountKey?: string;
};
type AcmeServiceOptions = {
userContext: IContext;
logger: ILogger;
sslProvider: SSLProvider;
eab?: ClientExternalAccountBindingOptions;
eab?: AcmeEabOptions;
skipLocalVerify?: boolean;
useMappingProxy?: boolean;
reverseProxy?: string;
@@ -71,7 +75,7 @@ export class AcmeService {
logger: ILogger;
sslProvider: SSLProvider;
skipLocalVerify = true;
eab?: ClientExternalAccountBindingOptions;
eab?: AcmeEabOptions;
constructor(options: AcmeServiceOptions) {
this.options = options;
this.userContext = options.userContext;
@@ -85,7 +89,14 @@ export class AcmeService {
}
async getAccountConfig(email: string, urlMapping: UrlMapping): Promise<any> {
const conf = (await this.userContext.getObj(this.buildAccountKey(email))) || {};
let conf = (await this.userContext.getObj(this.buildAccountKey(email))) || {};
const eabAccountKey = this.getEabAccountPrivateKey();
if (eabAccountKey) {
conf = {
...((await this.userContext.getObj(this.buildAccessAccountKey())) || {}),
key: eabAccountKey,
};
}
if (urlMapping && urlMapping.mappings) {
for (const key in urlMapping.mappings) {
if (Object.prototype.hasOwnProperty.call(urlMapping.mappings, key)) {
@@ -104,16 +115,49 @@ export class AcmeService {
return `acme.config.${this.sslProvider}.${email}`;
}
buildAccessAccountKey() {
return `acme.config.${this.sslProvider}.access.${this.eab.id}`;
}
getEabAccountPrivateKey() {
if (!this.eab?.accountKey) {
return null;
}
let accountKey;
try {
accountKey = JSON.parse(this.eab.accountKey);
} catch {
return this.eab.accountKey;
}
if (accountKey.kid !== this.eab.kid) {
throw new Error("EAB的KID已变化,请点击刷新重新生成ACME账号私钥");
}
return accountKey.privateKey;
}
formatCreateAccountError(e: any) {
const message = e?.message || "";
if (message.includes("Unknown external account binding (EAB) key")) {
return new Error(`EAB授权已失效或已过期,请重新获取EAB授权并刷新ACME账号私钥后重试。原始错误:${message}`);
}
return e;
}
async saveAccountConfig(email: string, conf: any) {
if (this.getEabAccountPrivateKey()) {
// userContext 跟用户走。公共 EAB 场景下这里仅作为当前用户缓存;
// 其他用户会通过 onlyReturnExisting 用同一个账号私钥取回 accountUrl。
await this.userContext.setObj(this.buildAccessAccountKey(), { accountUrl: conf.accountUrl });
return;
}
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 });
let targetUrl = directoryUrl.replace("https://", "");
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
const mappings = {
"acme-v02.api.letsencrypt.org": "le.px.certd.handfree.work",
"dv.acme-v02.api.pki.goog": "gg.px.certd.handfree.work",
@@ -171,7 +215,23 @@ export class AcmeService {
contact: [`mailto:${email}`],
externalAccountBinding: this.eab,
};
await client.createAccount(accountPayload);
if (this.getEabAccountPrivateKey()) {
try {
// RFC 8555 的 newAccount 支持 onlyReturnExisting。
// 使用同一个账号私钥时,CA 会返回已存在账号的 URL,不会再次消费 EAB。
await client.createAccount({ onlyReturnExisting: true });
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
return client;
} catch (e: any) {
this.logger.info(`未找到已存在的ACME账号,准备创建新账号:${e.message}`);
}
}
try {
await client.createAccount(accountPayload);
} catch (e: any) {
throw this.formatCreateAccountError(e);
}
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
}