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 @@ - - - - - - - - + + + + + + + + + +