mirror of
https://github.com/certd/certd.git
synced 2026-05-15 12:37:30 +08:00
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:
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { logger } from '@certd/basic';
|
||||
import { SysSettingsService, SysSiteInfo } from '@certd/lib-server';
|
||||
import { getPlusInfo, isPlus } from "@certd/plus-core";
|
||||
import { Autoload, Config, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { Config, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import dayjs from "dayjs";
|
||||
import { Between } from "typeorm";
|
||||
import { DomainService } from '../cert/service/domain-service.js';
|
||||
@@ -14,9 +14,9 @@ import { PipelineService } from '../pipeline/service/pipeline-service.js';
|
||||
import { UserService } from "../sys/authority/service/user-service.js";
|
||||
import { ProjectService } from '../sys/enterprise/service/project-service.js';
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoCRegisterCron {
|
||||
export class AutoCron {
|
||||
@Inject()
|
||||
pipelineService: PipelineService;
|
||||
|
||||
@@ -53,7 +53,6 @@ export class AutoCRegisterCron {
|
||||
|
||||
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
logger.info('加载定时trigger开始');
|
||||
await this.pipelineService.onStartup(this.immediateTriggerOnce, this.onlyAdminUser);
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from "assert";
|
||||
import esmock from "esmock";
|
||||
import { AutoFix, buildEabAccountKeyValue, buildLegacyGoogleAccountConfigWhere, parseStorageValue } from "./auto-fix.js";
|
||||
|
||||
function createAutoFix(options: { pluginConfigService?: any; accessService?: any; storageService?: any }) {
|
||||
const autoFix = new AutoFix();
|
||||
autoFix.pluginConfigService = options.pluginConfigService;
|
||||
autoFix.accessService = options.accessService;
|
||||
autoFix.storageService = options.storageService;
|
||||
return autoFix;
|
||||
}
|
||||
|
||||
describe("AutoFix", () => {
|
||||
it("parses legacy storage values", () => {
|
||||
const config = parseStorageValue(
|
||||
JSON.stringify({
|
||||
value: {
|
||||
key: "legacy-private-key",
|
||||
accountUrl: "https://example.com/acct/1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(config.key, "legacy-private-key");
|
||||
});
|
||||
|
||||
it("builds the EAB account key payload", () => {
|
||||
const payload = JSON.parse(buildEabAccountKeyValue("kid-1", "private-key"));
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
kid: "kid-1",
|
||||
privateKey: "private-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds legacy Google account config query by exact email key only", () => {
|
||||
assert.deepEqual(buildLegacyGoogleAccountConfigWhere("user@example.com"), {
|
||||
userId: 1,
|
||||
scope: "user",
|
||||
namespace: "1",
|
||||
key: "acme.config.google.user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds legacy Google account config by exact email key only", async () => {
|
||||
let findOneWhere: any;
|
||||
let findCalled = false;
|
||||
const autoFix = createAutoFix({
|
||||
pluginConfigService: null as any,
|
||||
accessService: null as any,
|
||||
storageService: {
|
||||
getRepository() {
|
||||
return {
|
||||
async findOne(options: any) {
|
||||
findOneWhere = options.where;
|
||||
return {
|
||||
value: JSON.stringify({
|
||||
value: {
|
||||
privateKey: "legacy-private-key",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
async find() {
|
||||
findCalled = true;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const config = await autoFix.getLegacyGoogleAccountConfig("user@example.com");
|
||||
|
||||
assert.equal(config.privateKey, "legacy-private-key");
|
||||
assert.deepEqual(findOneWhere, buildLegacyGoogleAccountConfigWhere("user@example.com"));
|
||||
assert.equal(findCalled, false);
|
||||
});
|
||||
|
||||
it("does not query legacy Google account config without email", async () => {
|
||||
let repositoryCalled = false;
|
||||
const autoFix = createAutoFix({
|
||||
pluginConfigService: null as any,
|
||||
accessService: null as any,
|
||||
storageService: {
|
||||
getRepository() {
|
||||
repositoryCalled = true;
|
||||
return {};
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const config = await autoFix.getLegacyGoogleAccountConfig();
|
||||
|
||||
assert.equal(config, null);
|
||||
assert.equal(repositoryCalled, false);
|
||||
});
|
||||
|
||||
it("skips Google common EAB account key fix outside commercial edition", async () => {
|
||||
let pluginConfigCalled = false;
|
||||
const autoFix = createAutoFix({
|
||||
pluginConfigService: {
|
||||
async getPluginConfig() {
|
||||
pluginConfigCalled = true;
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
accessService: null as any,
|
||||
storageService: null as any,
|
||||
});
|
||||
|
||||
await autoFix.init();
|
||||
|
||||
assert.equal(pluginConfigCalled, false);
|
||||
});
|
||||
|
||||
it("fixes Google common EAB account key in commercial edition", async () => {
|
||||
const { AutoFix: MockedAutoFix } = await esmock("./auto-fix.js", {
|
||||
"@certd/plus-core": {
|
||||
isComm: () => true,
|
||||
},
|
||||
});
|
||||
let getAccessByIdArgs: any[] = [];
|
||||
let findOneWhere: any;
|
||||
let updateAccessParam: any;
|
||||
const autoFix = new MockedAutoFix();
|
||||
autoFix.pluginConfigService = {
|
||||
async getPluginConfig(options: any) {
|
||||
assert.deepEqual(options, {
|
||||
name: "CertApply",
|
||||
type: "builtIn",
|
||||
});
|
||||
return {
|
||||
sysSetting: {
|
||||
input: {
|
||||
googleCommonEabAccessId: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
autoFix.accessService = {
|
||||
async getAccessById(...args: any[]) {
|
||||
getAccessByIdArgs = args;
|
||||
return {
|
||||
kid: "kid-1",
|
||||
email: "user@example.com",
|
||||
};
|
||||
},
|
||||
async updateAccess(param: any) {
|
||||
updateAccessParam = param;
|
||||
},
|
||||
};
|
||||
autoFix.storageService = {
|
||||
getRepository() {
|
||||
return {
|
||||
async findOne(options: any) {
|
||||
findOneWhere = options.where;
|
||||
return {
|
||||
value: JSON.stringify({
|
||||
value: {
|
||||
privateKey: "legacy-private-key",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await autoFix.fixGoogleCommonEabAccountKey();
|
||||
|
||||
assert.deepEqual(getAccessByIdArgs, [12, false]);
|
||||
assert.deepEqual(findOneWhere, buildLegacyGoogleAccountConfigWhere("user@example.com"));
|
||||
assert.deepEqual(updateAccessParam, {
|
||||
id: 12,
|
||||
eabType: "google",
|
||||
accountKey: buildEabAccountKeyValue("kid-1", "legacy-private-key"),
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { logger } from "@certd/basic";
|
||||
import { AccessService } from "@certd/lib-server";
|
||||
import { isComm } from "@certd/plus-core";
|
||||
import { PluginConfigService } from "../plugin/service/plugin-config-service.js";
|
||||
import { StorageService } from "../pipeline/service/storage-service.js";
|
||||
|
||||
export function parseStorageValue(value?: string) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed?.value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEabAccountKeyValue(kid: string, privateKey: string) {
|
||||
return JSON.stringify({
|
||||
kid,
|
||||
privateKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLegacyGoogleAccountConfigWhere(email: string) {
|
||||
return {
|
||||
userId: 1,
|
||||
scope: "user",
|
||||
namespace: "1",
|
||||
key: `acme.config.google.${email}`,
|
||||
};
|
||||
}
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoFix {
|
||||
@Inject()
|
||||
pluginConfigService: PluginConfigService;
|
||||
|
||||
@Inject()
|
||||
accessService: AccessService;
|
||||
|
||||
@Inject()
|
||||
storageService: StorageService;
|
||||
|
||||
async init() {
|
||||
await this.fixGoogleCommonEabAccountKey();
|
||||
}
|
||||
async fixGoogleCommonEabAccountKey() {
|
||||
if (!isComm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const certApplyConfig = await this.pluginConfigService.getPluginConfig({
|
||||
name: "CertApply",
|
||||
type: "builtIn",
|
||||
});
|
||||
const googleCommonEabAccessId = certApplyConfig?.sysSetting?.input?.googleCommonEabAccessId;
|
||||
if (!googleCommonEabAccessId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eabAccess = await this.accessService.getAccessById(googleCommonEabAccessId, false);
|
||||
if (eabAccess.accountKey) {
|
||||
return;
|
||||
}
|
||||
if (!eabAccess.kid) {
|
||||
logger.info("公共Google EAB授权缺少KID,跳过历史ACME账号私钥修复");
|
||||
return;
|
||||
}
|
||||
|
||||
const accountConfig = await this.getLegacyGoogleAccountConfig(eabAccess.email);
|
||||
const privateKey = accountConfig?.privateKey || accountConfig?.key || accountConfig?.accountKey;
|
||||
if (!privateKey) {
|
||||
logger.info("未找到可迁移到公共Google EAB授权的历史ACME账号私钥");
|
||||
return;
|
||||
}
|
||||
|
||||
const accountKey = buildEabAccountKeyValue(eabAccess.kid, privateKey);
|
||||
await this.accessService.updateAccess({ id: googleCommonEabAccessId, eabType: "google", accountKey });
|
||||
logger.info(`已修复公共Google EAB授权的ACME账号私钥,accessId=${googleCommonEabAccessId}`);
|
||||
} catch (e: any) {
|
||||
logger.error("修复公共Google EAB授权ACME账号私钥失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
async getLegacyGoogleAccountConfig(email?: string) {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
const repository = this.storageService.getRepository();
|
||||
const exact = await repository.findOne({
|
||||
where: buildLegacyGoogleAccountConfigWhere(email),
|
||||
});
|
||||
const exactValue = this.parseStorageValue(exact?.value);
|
||||
if (exactValue?.key || exactValue?.privateKey || exactValue?.accountKey) {
|
||||
return exactValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
parseStorageValue(value?: string) {
|
||||
return parseStorageValue(value);
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -1,14 +1,14 @@
|
||||
import { logger } from '@certd/basic';
|
||||
import { PlusService, SysInstallInfo, SysPrivateSettings, SysSettingsService } from '@certd/lib-server';
|
||||
import { Autoload, Config, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { Config, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import crypto from 'crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { UserService } from '../sys/authority/service/user-service.js';
|
||||
import { SafeService } from "../sys/settings/safe-service.js";
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoAInitSite {
|
||||
export class AutoInitSite {
|
||||
@Inject()
|
||||
userService: UserService;
|
||||
|
||||
@@ -22,7 +22,6 @@ export class AutoAInitSite {
|
||||
@Inject()
|
||||
safeService: SafeService;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
logger.info('初始化站点开始');
|
||||
await this.startOptimizeDb();
|
||||
+3
-4
@@ -1,16 +1,15 @@
|
||||
import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { logger } from "@certd/basic";
|
||||
import { PluginService } from "../plugin/service/plugin-service.js";
|
||||
import { registerPaymentProviders } from "../suite/payments/index.js";
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoBLoadPlugins {
|
||||
export class AutoLoadPlugins {
|
||||
@Inject()
|
||||
pluginService: PluginService;
|
||||
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
logger.info(`加载插件开始,加载模式:${process.env.certd_plugin_loadmode}`);
|
||||
if (process.env.certd_plugin_loadmode === "metadata") {
|
||||
+3
-4
@@ -1,14 +1,13 @@
|
||||
import { logger, utils } from '@certd/basic';
|
||||
import { UserSuiteService } from '@certd/commercial-core';
|
||||
import { Autoload, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoDMitterRegister {
|
||||
export class AutoMitterRegister {
|
||||
@Inject()
|
||||
userSuiteService: UserSuiteService;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await this.registerOnNewUser();
|
||||
}
|
||||
+5
-5
@@ -1,21 +1,21 @@
|
||||
import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { CertInfoService } from "../monitor/index.js";
|
||||
import { pipelineEmitter } from "@certd/pipeline";
|
||||
import { CertInfo, EVENT_CERT_APPLY_SUCCESS } from "@certd/plugin-cert";
|
||||
import { PipelineEvent } from "@certd/pipeline";
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoEPipelineEmitterRegister {
|
||||
export class AutoPipelineEmitterRegister {
|
||||
@Inject()
|
||||
certInfoService: CertInfoService;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await this.onCertApplySuccess();
|
||||
}
|
||||
|
||||
async onCertApplySuccess() {
|
||||
pipelineEmitter.on(EVENT_CERT_APPLY_SUCCESS, async (event: PipelineEvent<{cert:CertInfo,file:string}>) => {
|
||||
pipelineEmitter.on(EVENT_CERT_APPLY_SUCCESS, async (event: PipelineEvent<{ cert: CertInfo; file: string }>) => {
|
||||
await this.certInfoService.updateCertByPipelineId(event.pipeline.id, event.event.cert, event.event.file);
|
||||
});
|
||||
}
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
import { App, Autoload, Config, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { App, Config, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { getPlusInfo, isPlus } from '@certd/plus-core';
|
||||
import { isDev, logger } from '@certd/basic';
|
||||
|
||||
@@ -11,9 +11,9 @@ import { UserService } from '../sys/authority/service/user-service.js';
|
||||
import { UserSettingsService } from '../mine/service/user-settings-service.js';
|
||||
import { startProxyServer } from './proxy/server.js';
|
||||
|
||||
@Autoload()
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoZPrint {
|
||||
export class AutoPrint {
|
||||
@Inject()
|
||||
sysSettingsService: SysSettingsService;
|
||||
|
||||
@@ -34,7 +34,6 @@ export class AutoZPrint {
|
||||
@Config('system.resetAdminPasswd')
|
||||
private resetAdminPasswd: boolean;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
//监听https
|
||||
this.startHttpsServer();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { AutoInitSite } from "./auto-init-site.js";
|
||||
import { AutoLoadPlugins } from "./auto-load-plugins.js";
|
||||
import { AutoCron } from "./auto-cron.js";
|
||||
import { AutoMitterRegister } from "./auto-mitter-register.js";
|
||||
import { AutoPipelineEmitterRegister } from "./auto-pipeline-emitter-register.js";
|
||||
import { AutoFix } from "./auto-fix.js";
|
||||
import { AutoPrint } from "./auto-print.js";
|
||||
|
||||
@Autoload()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class AutoRegister {
|
||||
@Inject()
|
||||
autoInitSite: AutoInitSite;
|
||||
|
||||
@Inject()
|
||||
autoLoadPlugins: AutoLoadPlugins;
|
||||
|
||||
@Inject()
|
||||
autoCron: AutoCron;
|
||||
|
||||
@Inject()
|
||||
autoMitterRegister: AutoMitterRegister;
|
||||
|
||||
@Inject()
|
||||
autoPipelineEmitterRegister: AutoPipelineEmitterRegister;
|
||||
|
||||
@Inject()
|
||||
autoPrint: AutoPrint;
|
||||
|
||||
@Inject()
|
||||
autoFix: AutoFix;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await this.autoInitSite.init();
|
||||
await this.autoLoadPlugins.init();
|
||||
await this.autoCron.init();
|
||||
await this.autoMitterRegister.init();
|
||||
await this.autoPipelineEmitterRegister.init();
|
||||
await this.autoFix.init();
|
||||
await this.autoPrint.init();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user