From 67b05e2d75e96b9f855e1ca0b3d0d8d03b92d8e6 Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Sun, 24 May 2026 05:42:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81dns-persist-01?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E9=AA=8C=E8=AF=81=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E8=AF=81=E4=B9=A6=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?Acme=E8=B4=A6=E5=8F=B7=E7=9A=84=E5=AD=98=E5=82=A8=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/acme-client/src/client.ts | 4 + packages/core/acme-client/src/rfc8555.ts | 6 +- packages/core/acme-client/src/verify.ts | 21 +- packages/core/acme-client/types/index.d.ts | 2 +- packages/core/acme-client/types/rfc8555.d.ts | 6 +- packages/core/pipeline/src/access/api.ts | 1 + .../src/user/access/entity/access.ts | 3 + .../src/user/access/service/access-getter.ts | 4 +- .../access/service/access-service.test.ts | 88 +++- .../src/user/access/service/access-service.ts | 86 ++-- .../cname-record-info.vue | 23 +- .../cname-verify-plan.vue | 25 +- .../dns-persist-record-info.vue | 144 ++++++ .../dns-persist-verify-plan.vue | 94 ++++ .../cert/domains-verify-plan-editor/index.vue | 49 +- .../cert/domains-verify-plan-editor/type.ts | 19 +- .../domains-verify-plan-editor/validator.ts | 8 + .../plugins/common/refresh-input.vue | 8 +- .../src/components/plugins/lib/dicts.ts | 6 + .../locales/langs/en-US/certd/navigation.ts | 1 + .../locales/langs/zh-CN/certd/navigation.ts | 1 + .../src/router/source/modules/certd.ts | 11 + .../access/access-selector/access/crud.tsx | 8 +- .../access/access-selector/access/index.vue | 23 +- .../certd/access/access-selector/index.vue | 6 +- .../src/views/certd/access/common.tsx | 11 +- .../src/views/certd/cert/dns-persist/api.ts | 83 ++++ .../src/views/certd/cert/dns-persist/crud.tsx | 349 ++++++++++++++ .../views/certd/cert/dns-persist/index.vue | 33 ++ .../cert/dns-persist/use-setting-dialog.tsx | 133 ++++++ .../src/views/certd/cert/domain/crud.tsx | 14 + .../pipeline/component/step-form/index.vue | 4 + .../src/views/sys/plugin/config-common.vue | 21 +- .../v10047__access_subtype_dns_persist.sql | 31 ++ .../cert/dns-persist-record-controller.ts | 91 ++++ .../src/modules/auto/fix/auto-fix.test.ts | 18 +- .../src/modules/auto/fix/auto-fix.ts | 16 + .../common-eab-to-acme-account-fix.test.ts | 135 ++++++ .../fix/common-eab-to-acme-account-fix.ts | 188 ++++++++ .../legacy-acme-account-access-fix.test.ts | 48 ++ .../fix/legacy-acme-account-access-fix.ts | 131 ++++++ .../modules/cert/entity/dns-persist-record.ts | 61 +++ .../dns-persist-record-service.test.ts | 315 +++++++++++++ .../service/dns-persist-record-service.ts | 438 ++++++++++++++++++ .../access/acme-account-access.test.ts | 59 +++ .../plugin-cert/access/acme-account-access.ts | 239 ++++++++++ .../src/plugins/plugin-cert/access/index.ts | 1 + .../plugin/cert-plugin/acme.test.ts | 25 + .../plugin-cert/plugin/cert-plugin/acme.ts | 96 +++- .../plugin/cert-plugin/apply.test.ts | 47 ++ .../plugin-cert/plugin/cert-plugin/apply.ts | 228 ++++++++- 51 files changed, 3352 insertions(+), 110 deletions(-) create mode 100644 packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/dns-persist-record-info.vue create mode 100644 packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/dns-persist-verify-plan.vue create mode 100644 packages/ui/certd-client/src/views/certd/cert/dns-persist/api.ts create mode 100644 packages/ui/certd-client/src/views/certd/cert/dns-persist/crud.tsx create mode 100644 packages/ui/certd-client/src/views/certd/cert/dns-persist/index.vue create mode 100644 packages/ui/certd-client/src/views/certd/cert/dns-persist/use-setting-dialog.tsx create mode 100644 packages/ui/certd-server/db/migration/v10047__access_subtype_dns_persist.sql create mode 100644 packages/ui/certd-server/src/controller/user/cert/dns-persist-record-controller.ts create mode 100644 packages/ui/certd-server/src/modules/auto/fix/common-eab-to-acme-account-fix.test.ts create mode 100644 packages/ui/certd-server/src/modules/auto/fix/common-eab-to-acme-account-fix.ts create mode 100644 packages/ui/certd-server/src/modules/auto/fix/legacy-acme-account-access-fix.test.ts create mode 100644 packages/ui/certd-server/src/modules/auto/fix/legacy-acme-account-access-fix.ts create mode 100644 packages/ui/certd-server/src/modules/cert/entity/dns-persist-record.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/dns-persist-record-service.test.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/dns-persist-record-service.ts create mode 100644 packages/ui/certd-server/src/plugins/plugin-cert/access/acme-account-access.test.ts create mode 100644 packages/ui/certd-server/src/plugins/plugin-cert/access/acme-account-access.ts create mode 100644 packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/apply.test.ts diff --git a/packages/core/acme-client/src/client.ts b/packages/core/acme-client/src/client.ts index 76eadbf97..6fa1684ca 100644 --- a/packages/core/acme-client/src/client.ts +++ b/packages/core/acme-client/src/client.ts @@ -467,6 +467,10 @@ class AcmeClient { return createHash('sha256').update(result).digest('base64url'); } + if (challenge.type === 'dns-persist-01') { + return ''; + } + /* https://datatracker.ietf.org/doc/html/rfc8737 */ if (challenge.type === 'tls-alpn-01') { return result; diff --git a/packages/core/acme-client/src/rfc8555.ts b/packages/core/acme-client/src/rfc8555.ts index 675163445..559fbf37e 100644 --- a/packages/core/acme-client/src/rfc8555.ts +++ b/packages/core/acme-client/src/rfc8555.ts @@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract { token: string; } -export type Challenge = HttpChallenge | DnsChallenge; +export interface DnsPersistChallenge extends ChallengeAbstract { + type: "dns-persist-01"; +} + +export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge; /** * Certificate diff --git a/packages/core/acme-client/src/verify.ts b/packages/core/acme-client/src/verify.ts index efcff1f4b..ecdc53d9b 100644 --- a/packages/core/acme-client/src/verify.ts +++ b/packages/core/acme-client/src/verify.ts @@ -170,7 +170,7 @@ export function createChallengeFn(opts = {}) { if (txtRecords.length === 0) { - throw new Error(`没有找到TXT解析记录(${recordName})`); + throw new Error(`没有找到TXT解析记录(${recordName}),请稍后重试`); } return txtRecords; } @@ -203,6 +203,24 @@ export function createChallengeFn(opts = {}) { return true; } + async function verifyDnsPersistChallenge(authz, challenge, keyAuthorization, prefix = '_validation-persist.') { + const recordName = `${prefix}${authz.identifier.value.replace(/^\*\./, '')}`; + log(`本地校验DNS持久验证TXT记录: ${recordName}`); + let recordValues = await walkTxtRecord(recordName, 0, walkFromAuthoritative); + recordValues = [...new Set(recordValues)]; + const expected = challenge.expectedRecordValue; + if (!expected) { + log(`未提供dns-persist-01本地校验期望值,跳过精确匹配,仅确认TXT记录存在`); + return true; + } + log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`); + if (!recordValues.length || !recordValues.includes(expected)) { + throw new Error(`没有找到需要的DNS持久验证TXT记录: ${recordName},请稍后重试,期望:${expected},结果:${recordValues}`); + } + log(`DNS持久验证记录匹配成功(${challenge.type}/${recordName}):${expected}`); + return true; + } + /** * Verify ACME TLS ALPN challenge * @@ -234,6 +252,7 @@ export function createChallengeFn(opts = {}) { challenges: { 'http-01': verifyHttpChallenge, 'dns-01': verifyDnsChallenge, + 'dns-persist-01': verifyDnsPersistChallenge, 'tls-alpn-01': verifyTlsAlpnChallenge, }, walkTxtRecord, diff --git a/packages/core/acme-client/types/index.d.ts b/packages/core/acme-client/types/index.d.ts index 7bb1f620f..5e082f93d 100644 --- a/packages/core/acme-client/types/index.d.ts +++ b/packages/core/acme-client/types/index.d.ts @@ -57,7 +57,7 @@ export interface ClientExternalAccountBindingOptions { export interface ClientAutoOptions { csr: CsrBuffer | CsrString; - challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string}>; + challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string,httpUploader?:any}>; challengeRemoveFn: (authz: Authorization, challenge: rfc8555.Challenge, keyAuthorization: string,recordReq:any, recordRes:any,dnsProvider:any,httpUploader:any) => Promise; email?: string; termsOfServiceAgreed?: boolean; diff --git a/packages/core/acme-client/types/rfc8555.d.ts b/packages/core/acme-client/types/rfc8555.d.ts index 26b759bbd..0e1fa7fcc 100644 --- a/packages/core/acme-client/types/rfc8555.d.ts +++ b/packages/core/acme-client/types/rfc8555.d.ts @@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract { token: string; } -export type Challenge = HttpChallenge | DnsChallenge; +export interface DnsPersistChallenge extends ChallengeAbstract { + type: 'dns-persist-01'; +} + +export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge; /** * Certificate diff --git a/packages/core/pipeline/src/access/api.ts b/packages/core/pipeline/src/access/api.ts index 9a4a69be8..b2d2bf55f 100644 --- a/packages/core/pipeline/src/access/api.ts +++ b/packages/core/pipeline/src/access/api.ts @@ -19,6 +19,7 @@ export type AccessInputDefine = FormItemProps & { }; export type AccessDefine = Registrable & { icon?: string; + subtype?: string; input?: { [key: string]: AccessInputDefine; }; diff --git a/packages/libs/lib-server/src/user/access/entity/access.ts b/packages/libs/lib-server/src/user/access/entity/access.ts index 3bebf7bfa..10fc2c6aa 100644 --- a/packages/libs/lib-server/src/user/access/entity/access.ts +++ b/packages/libs/lib-server/src/user/access/entity/access.ts @@ -19,6 +19,9 @@ export class AccessEntity { @Column({ comment: '类型', length: 100 }) type: string; + @Column({ name: 'subtype', comment: '子类型', length: 100, nullable: true }) + subtype: string; + @Column({ name: 'setting', comment: '设置', length: 10240, nullable: true }) setting: string; diff --git a/packages/libs/lib-server/src/user/access/service/access-getter.ts b/packages/libs/lib-server/src/user/access/service/access-getter.ts index 2333fc600..8a5505efb 100644 --- a/packages/libs/lib-server/src/user/access/service/access-getter.ts +++ b/packages/libs/lib-server/src/user/access/service/access-getter.ts @@ -1,4 +1,4 @@ -import { IAccessService } from '@certd/pipeline'; +import { IAccessService } from "@certd/pipeline"; export class AccessGetter implements IAccessService { userId: number; @@ -15,6 +15,6 @@ export class AccessGetter implements IAccessService { } async getCommonById(id: any) { - return await this.getter(id, 0,null); + return await this.getter(id, 0, null); } } diff --git a/packages/libs/lib-server/src/user/access/service/access-service.test.ts b/packages/libs/lib-server/src/user/access/service/access-service.test.ts index 4e9758eb4..3fc50a764 100644 --- a/packages/libs/lib-server/src/user/access/service/access-service.test.ts +++ b/packages/libs/lib-server/src/user/access/service/access-service.test.ts @@ -1,14 +1,16 @@ import assert from "assert"; +import esmock from "esmock"; import { AccessService } from "./access-service.js"; describe("AccessService", () => { it("does not write id into access setting when updating selected fields", async () => { let updateParam: any; const service = new AccessService(); - service.info = async () => ({ - id: 12, - type: "eab", - } as any); + service.info = async () => + ({ + id: 12, + type: "eab", + }) as any; service.decryptAccessEntity = () => ({ kid: "kid-1", }); @@ -27,4 +29,82 @@ describe("AccessService", () => { accountKey: "account-key", }); }); + + it("writes subtype from access define field", async () => { + const { AccessService: MockedAccessService } = await esmock("./access-service.js", { + "@certd/pipeline": { + accessRegistry: { + getDefine(type: string) { + assert.equal(type, "acmeAccount"); + return { + name: "acmeAccount", + subtype: "caType", + input: { + caType: {}, + account: { + encrypt: true, + }, + }, + }; + }, + }, + }, + }); + const service = new MockedAccessService(); + service.encryptService = { + encrypt(value: string) { + return `encrypted:${value}`; + }, + }; + const param: any = { + type: "acmeAccount", + setting: JSON.stringify({ + caType: "letsencrypt", + account: JSON.stringify({ + accountKey: "key", + accountUri: "https://example.com/acct/1", + caType: "letsencrypt", + }), + }), + }; + + service.encryptSetting(param); + + assert.equal(param.subtype, "letsencrypt"); + }); + + it("allows acme account access to be saved before account generation", async () => { + const { AccessService: MockedAccessService } = await esmock("./access-service.js", { + "@certd/pipeline": { + accessRegistry: { + getDefine() { + return { + name: "acmeAccount", + subtype: "caType", + input: { + caType: {}, + account: { + encrypt: true, + }, + }, + }; + }, + }, + }, + }); + const service = new MockedAccessService(); + const param: any = { + type: "acmeAccount", + setting: JSON.stringify({ + caType: "letsencrypt", + }), + }; + + service.encryptSetting(param); + + assert.equal(param.subtype, "letsencrypt"); + assert.deepEqual(JSON.parse(param.setting), { + caType: "letsencrypt", + }); + }); }); diff --git a/packages/libs/lib-server/src/user/access/service/access-service.ts b/packages/libs/lib-server/src/user/access/service/access-service.ts index 8bbb81e90..7539dc9f4 100644 --- a/packages/libs/lib-server/src/user/access/service/access-service.ts +++ b/packages/libs/lib-server/src/user/access/service/access-service.ts @@ -1,17 +1,17 @@ -import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core'; -import {InjectEntityModel} from '@midwayjs/typeorm'; +import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core"; +import { InjectEntityModel } from "@midwayjs/typeorm"; import { In, Repository } from "typeorm"; -import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js'; -import {AccessEntity} from '../entity/access.js'; -import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline'; -import {EncryptService} from './encrypt-service.js'; -import { logger, utils } from '@certd/basic'; +import { AccessGetter, BaseService, PageReq, PermissionException, ValidateException } from "../../../index.js"; +import { AccessEntity } from "../entity/access.js"; +import { AccessDefine, accessRegistry, newAccess } from "@certd/pipeline"; +import { EncryptService } from "./encrypt-service.js"; +import { logger, utils } from "@certd/basic"; /** * 授权 */ @Provide() -@Scope(ScopeEnum.Request, {allowDowngrade: true}) +@Scope(ScopeEnum.Request, { allowDowngrade: true }) export class AccessService extends BaseService { @InjectEntityModel(AccessEntity) repository: Repository; @@ -36,16 +36,16 @@ export class AccessService extends BaseService { async add(param) { let oldEntity = null; - if (param._copyFrom){ + if (param._copyFrom) { oldEntity = await this.info(param._copyFrom); if (oldEntity == null) { - throw new ValidateException('该授权配置不存在,请确认是否已被删除'); + throw new ValidateException("该授权配置不存在,请确认是否已被删除"); } - if (oldEntity.userId !== param.userId) { - throw new ValidateException('您无权查看该授权配置'); + if (oldEntity.userId !== param.userId) { + throw new ValidateException("您无权查看该授权配置"); } } - delete param._copyFrom + delete param._copyFrom; this.encryptSetting(param, oldEntity); param.keyId = "ac_" + utils.id.simpleNanoId(); return await super.add(param); @@ -62,17 +62,20 @@ export class AccessService extends BaseService { return; } const json = JSON.parse(setting); + if (accessDefine.subtype) { + param.subtype = json[accessDefine.subtype] || null; + } let oldSetting = {}; let encryptSetting = {}; - const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === '{}'; + const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === "{}"; if (oldSettingEntity) { - oldSetting = JSON.parse(oldSettingEntity.setting || '{}'); - encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || '{}'); + oldSetting = JSON.parse(oldSettingEntity.setting || "{}"); + encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || "{}"); } for (const key in json) { //加密 let value = json[key]; - if (value && typeof value === 'string') { + if (value && typeof value === "string") { //去除前后空格 value = value.trim(); json[key] = value; @@ -81,7 +84,7 @@ export class AccessService extends BaseService { if (!accessInputDefine) { continue; } - if (!accessInputDefine.encrypt || !value || typeof value !== 'string') { + if (!accessInputDefine.encrypt || !value || typeof value !== "string") { //定义无需加密、value为空、不是字符串 这些不需要加密 encryptSetting[key] = { value: value, @@ -96,7 +99,7 @@ export class AccessService extends BaseService { const subIndex = Math.min(2, length); let starLength = length - subIndex * 2; starLength = Math.max(2, starLength); - const starString = '*'.repeat(starLength); + const starString = "*".repeat(starLength); json[key] = value.substring(0, subIndex) + starString + value.substring(value.length - subIndex); encryptSetting[key] = { value: this.encryptService.encrypt(value), @@ -116,21 +119,21 @@ export class AccessService extends BaseService { async update(param) { const oldEntity = await this.info(param.id); if (oldEntity == null) { - throw new ValidateException('该授权配置不存在,请确认是否已被删除'); + throw new ValidateException("该授权配置不存在,请确认是否已被删除"); } this.encryptSetting(param, oldEntity); - delete param.keyId + delete param.keyId; return await super.update(param); } async updateAccess(access: any) { const oldEntity = await this.info(access.id); if (oldEntity == null) { - throw new ValidateException('该授权配置不存在,请确认是否已被删除'); + throw new ValidateException("该授权配置不存在,请确认是否已被删除"); } const setting = this.decryptAccessEntity(oldEntity); for (const key of Object.keys(access)) { - if (key === 'id') { + if (key === "id") { continue; } setting[key] = access[key]; @@ -145,11 +148,13 @@ export class AccessService extends BaseService { async getSimpleInfo(id: number) { const entity = await this.info(id); if (entity == null) { - throw new ValidateException('该授权配置不存在,请确认是否已被删除'); + throw new ValidateException("该授权配置不存在,请确认是否已被删除"); } return { id: entity.id, name: entity.name, + type: entity.type, + subtype: entity.subtype, userId: entity.userId, projectId: entity.projectId, }; @@ -162,14 +167,14 @@ export class AccessService extends BaseService { } if (checkUserId) { if (userId == null) { - throw new ValidateException('userId不能为空'); + throw new ValidateException("userId不能为空"); } if (userId !== entity.userId) { - throw new PermissionException('您对该Access授权无访问权限'); + throw new PermissionException("您对该Access授权无访问权限"); } } if (projectId != null && projectId !== entity.projectId) { - throw new PermissionException('您对该Access授权无访问权限'); + throw new PermissionException("您对该Access授权无访问权限"); } // const access = accessRegistry.get(entity.type); @@ -178,8 +183,8 @@ export class AccessService extends BaseService { id: entity.id, ...setting, }; - const accessGetter = new AccessGetter(userId,projectId, this.getById.bind(this)); - return await newAccess(entity.type, input,accessGetter); + const accessGetter = new AccessGetter(userId, projectId, this.getById.bind(this)); + return await newAccess(entity.type, input, accessGetter); } async getById(id: any, userId: number, projectId?: number): Promise { @@ -188,7 +193,7 @@ export class AccessService extends BaseService { decryptAccessEntity(entity: AccessEntity): any { let setting = {}; - if (entity.encryptSetting && entity.encryptSetting !== '{}') { + if (entity.encryptSetting && entity.encryptSetting !== "{}") { setting = JSON.parse(entity.encryptSetting); for (const key in setting) { //解密 @@ -213,12 +218,11 @@ export class AccessService extends BaseService { return accessRegistry.getDefine(type); } - async getSimpleByIds(ids: number[], userId: any, projectId?: number) { if (ids.length === 0) { return []; } - if (userId==null) { + if (userId == null) { return []; } return await this.repository.find({ @@ -231,24 +235,24 @@ export class AccessService extends BaseService { id: true, name: true, type: true, - userId:true, - projectId:true, + subtype: true, + userId: true, + projectId: true, }, }); - } /** * 复制授权到其他项目 - * @param accessId - * @param projectId + * @param accessId + * @param projectId */ - async copyTo(accessId: number,projectId?: number) { + async copyTo(accessId: number, projectId?: number) { const access = await this.info(accessId); if (access == null) { throw new Error(`该授权配置不存在,请确认是否已被删除:id=${accessId}`); } - + const keyId = access.keyId; //检查目标项目里是否已经有相同keyId的配置 const existAccess = await this.repository.findOne({ @@ -263,10 +267,10 @@ export class AccessService extends BaseService { } const newAccess = { ...access, - userId:-1, + userId: -1, id: undefined, projectId, - } + }; await this.repository.save(newAccess); return newAccess.id; } diff --git a/packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/cname-record-info.vue b/packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/cname-record-info.vue index dd5f3f405..afce27926 100644 --- a/packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/cname-record-info.vue +++ b/packages/ui/certd-client/src/components/plugins/cert/domains-verify-plan-editor/cname-record-info.vue @@ -11,14 +11,16 @@ - - - - - - - - + + + + + + + + + +