mirror of
https://github.com/certd/certd.git
synced 2026-06-10 10:37:34 +08:00
feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式
This commit is contained in:
@@ -467,6 +467,10 @@ class AcmeClient {
|
|||||||
return createHash('sha256').update(result).digest('base64url');
|
return createHash('sha256').update(result).digest('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (challenge.type === 'dns-persist-01') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/* https://datatracker.ietf.org/doc/html/rfc8737 */
|
/* https://datatracker.ietf.org/doc/html/rfc8737 */
|
||||||
if (challenge.type === 'tls-alpn-01') {
|
if (challenge.type === 'tls-alpn-01') {
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Challenge = HttpChallenge | DnsChallenge;
|
export interface DnsPersistChallenge extends ChallengeAbstract {
|
||||||
|
type: "dns-persist-01";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificate
|
* Certificate
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function createChallengeFn(opts = {}) {
|
|||||||
|
|
||||||
|
|
||||||
if (txtRecords.length === 0) {
|
if (txtRecords.length === 0) {
|
||||||
throw new Error(`没有找到TXT解析记录(${recordName})`);
|
throw new Error(`没有找到TXT解析记录(${recordName}),请稍后重试`);
|
||||||
}
|
}
|
||||||
return txtRecords;
|
return txtRecords;
|
||||||
}
|
}
|
||||||
@@ -203,6 +203,24 @@ export function createChallengeFn(opts = {}) {
|
|||||||
return true;
|
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
|
* Verify ACME TLS ALPN challenge
|
||||||
*
|
*
|
||||||
@@ -234,6 +252,7 @@ export function createChallengeFn(opts = {}) {
|
|||||||
challenges: {
|
challenges: {
|
||||||
'http-01': verifyHttpChallenge,
|
'http-01': verifyHttpChallenge,
|
||||||
'dns-01': verifyDnsChallenge,
|
'dns-01': verifyDnsChallenge,
|
||||||
|
'dns-persist-01': verifyDnsPersistChallenge,
|
||||||
'tls-alpn-01': verifyTlsAlpnChallenge,
|
'tls-alpn-01': verifyTlsAlpnChallenge,
|
||||||
},
|
},
|
||||||
walkTxtRecord,
|
walkTxtRecord,
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ export interface ClientExternalAccountBindingOptions {
|
|||||||
|
|
||||||
export interface ClientAutoOptions {
|
export interface ClientAutoOptions {
|
||||||
csr: CsrBuffer | CsrString;
|
csr: CsrBuffer | CsrString;
|
||||||
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string}>;
|
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => 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<any>;
|
challengeRemoveFn: (authz: Authorization, challenge: rfc8555.Challenge, keyAuthorization: string,recordReq:any, recordRes:any,dnsProvider:any,httpUploader:any) => Promise<any>;
|
||||||
email?: string;
|
email?: string;
|
||||||
termsOfServiceAgreed?: boolean;
|
termsOfServiceAgreed?: boolean;
|
||||||
|
|||||||
+5
-1
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Challenge = HttpChallenge | DnsChallenge;
|
export interface DnsPersistChallenge extends ChallengeAbstract {
|
||||||
|
type: 'dns-persist-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificate
|
* Certificate
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type AccessInputDefine = FormItemProps & {
|
|||||||
};
|
};
|
||||||
export type AccessDefine = Registrable & {
|
export type AccessDefine = Registrable & {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
subtype?: string;
|
||||||
input?: {
|
input?: {
|
||||||
[key: string]: AccessInputDefine;
|
[key: string]: AccessInputDefine;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export class AccessEntity {
|
|||||||
@Column({ comment: '类型', length: 100 })
|
@Column({ comment: '类型', length: 100 })
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
|
@Column({ name: 'subtype', comment: '子类型', length: 100, nullable: true })
|
||||||
|
subtype: string;
|
||||||
|
|
||||||
@Column({ name: 'setting', comment: '设置', length: 10240, nullable: true })
|
@Column({ name: 'setting', comment: '设置', length: 10240, nullable: true })
|
||||||
setting: string;
|
setting: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IAccessService } from '@certd/pipeline';
|
import { IAccessService } from "@certd/pipeline";
|
||||||
|
|
||||||
export class AccessGetter implements IAccessService {
|
export class AccessGetter implements IAccessService {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -15,6 +15,6 @@ export class AccessGetter implements IAccessService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCommonById<T = any>(id: any) {
|
async getCommonById<T = any>(id: any) {
|
||||||
return await this.getter<T>(id, 0,null);
|
return await this.getter<T>(id, 0, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
|
import esmock from "esmock";
|
||||||
import { AccessService } from "./access-service.js";
|
import { AccessService } from "./access-service.js";
|
||||||
|
|
||||||
describe("AccessService", () => {
|
describe("AccessService", () => {
|
||||||
it("does not write id into access setting when updating selected fields", async () => {
|
it("does not write id into access setting when updating selected fields", async () => {
|
||||||
let updateParam: any;
|
let updateParam: any;
|
||||||
const service = new AccessService();
|
const service = new AccessService();
|
||||||
service.info = async () => ({
|
service.info = async () =>
|
||||||
id: 12,
|
({
|
||||||
type: "eab",
|
id: 12,
|
||||||
} as any);
|
type: "eab",
|
||||||
|
}) as any;
|
||||||
service.decryptAccessEntity = () => ({
|
service.decryptAccessEntity = () => ({
|
||||||
kid: "kid-1",
|
kid: "kid-1",
|
||||||
});
|
});
|
||||||
@@ -27,4 +29,82 @@ describe("AccessService", () => {
|
|||||||
accountKey: "account-key",
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core';
|
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||||
import {InjectEntityModel} from '@midwayjs/typeorm';
|
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||||
import { In, Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js';
|
import { AccessGetter, BaseService, PageReq, PermissionException, ValidateException } from "../../../index.js";
|
||||||
import {AccessEntity} from '../entity/access.js';
|
import { AccessEntity } from "../entity/access.js";
|
||||||
import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline';
|
import { AccessDefine, accessRegistry, newAccess } from "@certd/pipeline";
|
||||||
import {EncryptService} from './encrypt-service.js';
|
import { EncryptService } from "./encrypt-service.js";
|
||||||
import { logger, utils } from '@certd/basic';
|
import { logger, utils } from "@certd/basic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 授权
|
* 授权
|
||||||
*/
|
*/
|
||||||
@Provide()
|
@Provide()
|
||||||
@Scope(ScopeEnum.Request, {allowDowngrade: true})
|
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||||
export class AccessService extends BaseService<AccessEntity> {
|
export class AccessService extends BaseService<AccessEntity> {
|
||||||
@InjectEntityModel(AccessEntity)
|
@InjectEntityModel(AccessEntity)
|
||||||
repository: Repository<AccessEntity>;
|
repository: Repository<AccessEntity>;
|
||||||
@@ -36,16 +36,16 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
|
|
||||||
async add(param) {
|
async add(param) {
|
||||||
let oldEntity = null;
|
let oldEntity = null;
|
||||||
if (param._copyFrom){
|
if (param._copyFrom) {
|
||||||
oldEntity = await this.info(param._copyFrom);
|
oldEntity = await this.info(param._copyFrom);
|
||||||
if (oldEntity == null) {
|
if (oldEntity == null) {
|
||||||
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
|
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
|
||||||
}
|
}
|
||||||
if (oldEntity.userId !== param.userId) {
|
if (oldEntity.userId !== param.userId) {
|
||||||
throw new ValidateException('您无权查看该授权配置');
|
throw new ValidateException("您无权查看该授权配置");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete param._copyFrom
|
delete param._copyFrom;
|
||||||
this.encryptSetting(param, oldEntity);
|
this.encryptSetting(param, oldEntity);
|
||||||
param.keyId = "ac_" + utils.id.simpleNanoId();
|
param.keyId = "ac_" + utils.id.simpleNanoId();
|
||||||
return await super.add(param);
|
return await super.add(param);
|
||||||
@@ -62,17 +62,20 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json = JSON.parse(setting);
|
const json = JSON.parse(setting);
|
||||||
|
if (accessDefine.subtype) {
|
||||||
|
param.subtype = json[accessDefine.subtype] || null;
|
||||||
|
}
|
||||||
let oldSetting = {};
|
let oldSetting = {};
|
||||||
let encryptSetting = {};
|
let encryptSetting = {};
|
||||||
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === '{}';
|
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === "{}";
|
||||||
if (oldSettingEntity) {
|
if (oldSettingEntity) {
|
||||||
oldSetting = JSON.parse(oldSettingEntity.setting || '{}');
|
oldSetting = JSON.parse(oldSettingEntity.setting || "{}");
|
||||||
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || '{}');
|
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || "{}");
|
||||||
}
|
}
|
||||||
for (const key in json) {
|
for (const key in json) {
|
||||||
//加密
|
//加密
|
||||||
let value = json[key];
|
let value = json[key];
|
||||||
if (value && typeof value === 'string') {
|
if (value && typeof value === "string") {
|
||||||
//去除前后空格
|
//去除前后空格
|
||||||
value = value.trim();
|
value = value.trim();
|
||||||
json[key] = value;
|
json[key] = value;
|
||||||
@@ -81,7 +84,7 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
if (!accessInputDefine) {
|
if (!accessInputDefine) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!accessInputDefine.encrypt || !value || typeof value !== 'string') {
|
if (!accessInputDefine.encrypt || !value || typeof value !== "string") {
|
||||||
//定义无需加密、value为空、不是字符串 这些不需要加密
|
//定义无需加密、value为空、不是字符串 这些不需要加密
|
||||||
encryptSetting[key] = {
|
encryptSetting[key] = {
|
||||||
value: value,
|
value: value,
|
||||||
@@ -96,7 +99,7 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
const subIndex = Math.min(2, length);
|
const subIndex = Math.min(2, length);
|
||||||
let starLength = length - subIndex * 2;
|
let starLength = length - subIndex * 2;
|
||||||
starLength = Math.max(2, starLength);
|
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);
|
json[key] = value.substring(0, subIndex) + starString + value.substring(value.length - subIndex);
|
||||||
encryptSetting[key] = {
|
encryptSetting[key] = {
|
||||||
value: this.encryptService.encrypt(value),
|
value: this.encryptService.encrypt(value),
|
||||||
@@ -116,21 +119,21 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
async update(param) {
|
async update(param) {
|
||||||
const oldEntity = await this.info(param.id);
|
const oldEntity = await this.info(param.id);
|
||||||
if (oldEntity == null) {
|
if (oldEntity == null) {
|
||||||
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
|
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
|
||||||
}
|
}
|
||||||
this.encryptSetting(param, oldEntity);
|
this.encryptSetting(param, oldEntity);
|
||||||
delete param.keyId
|
delete param.keyId;
|
||||||
return await super.update(param);
|
return await super.update(param);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAccess(access: any) {
|
async updateAccess(access: any) {
|
||||||
const oldEntity = await this.info(access.id);
|
const oldEntity = await this.info(access.id);
|
||||||
if (oldEntity == null) {
|
if (oldEntity == null) {
|
||||||
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
|
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
|
||||||
}
|
}
|
||||||
const setting = this.decryptAccessEntity(oldEntity);
|
const setting = this.decryptAccessEntity(oldEntity);
|
||||||
for (const key of Object.keys(access)) {
|
for (const key of Object.keys(access)) {
|
||||||
if (key === 'id') {
|
if (key === "id") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
setting[key] = access[key];
|
setting[key] = access[key];
|
||||||
@@ -145,11 +148,13 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
async getSimpleInfo(id: number) {
|
async getSimpleInfo(id: number) {
|
||||||
const entity = await this.info(id);
|
const entity = await this.info(id);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
|
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
subtype: entity.subtype,
|
||||||
userId: entity.userId,
|
userId: entity.userId,
|
||||||
projectId: entity.projectId,
|
projectId: entity.projectId,
|
||||||
};
|
};
|
||||||
@@ -162,14 +167,14 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
}
|
}
|
||||||
if (checkUserId) {
|
if (checkUserId) {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new ValidateException('userId不能为空');
|
throw new ValidateException("userId不能为空");
|
||||||
}
|
}
|
||||||
if (userId !== entity.userId) {
|
if (userId !== entity.userId) {
|
||||||
throw new PermissionException('您对该Access授权无访问权限');
|
throw new PermissionException("您对该Access授权无访问权限");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (projectId != null && projectId !== entity.projectId) {
|
if (projectId != null && projectId !== entity.projectId) {
|
||||||
throw new PermissionException('您对该Access授权无访问权限');
|
throw new PermissionException("您对该Access授权无访问权限");
|
||||||
}
|
}
|
||||||
|
|
||||||
// const access = accessRegistry.get(entity.type);
|
// const access = accessRegistry.get(entity.type);
|
||||||
@@ -178,8 +183,8 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
id: entity.id,
|
id: entity.id,
|
||||||
...setting,
|
...setting,
|
||||||
};
|
};
|
||||||
const accessGetter = new AccessGetter(userId,projectId, this.getById.bind(this));
|
const accessGetter = new AccessGetter(userId, projectId, this.getById.bind(this));
|
||||||
return await newAccess(entity.type, input,accessGetter);
|
return await newAccess(entity.type, input, accessGetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: any, userId: number, projectId?: number): Promise<any> {
|
async getById(id: any, userId: number, projectId?: number): Promise<any> {
|
||||||
@@ -188,7 +193,7 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
|
|
||||||
decryptAccessEntity(entity: AccessEntity): any {
|
decryptAccessEntity(entity: AccessEntity): any {
|
||||||
let setting = {};
|
let setting = {};
|
||||||
if (entity.encryptSetting && entity.encryptSetting !== '{}') {
|
if (entity.encryptSetting && entity.encryptSetting !== "{}") {
|
||||||
setting = JSON.parse(entity.encryptSetting);
|
setting = JSON.parse(entity.encryptSetting);
|
||||||
for (const key in setting) {
|
for (const key in setting) {
|
||||||
//解密
|
//解密
|
||||||
@@ -213,12 +218,11 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
return accessRegistry.getDefine(type);
|
return accessRegistry.getDefine(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getSimpleByIds(ids: number[], userId: any, projectId?: number) {
|
async getSimpleByIds(ids: number[], userId: any, projectId?: number) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (userId==null) {
|
if (userId == null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await this.repository.find({
|
return await this.repository.find({
|
||||||
@@ -231,24 +235,24 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
userId:true,
|
subtype: true,
|
||||||
projectId:true,
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制授权到其他项目
|
* 复制授权到其他项目
|
||||||
* @param accessId
|
* @param accessId
|
||||||
* @param projectId
|
* @param projectId
|
||||||
*/
|
*/
|
||||||
async copyTo(accessId: number,projectId?: number) {
|
async copyTo(accessId: number, projectId?: number) {
|
||||||
const access = await this.info(accessId);
|
const access = await this.info(accessId);
|
||||||
if (access == null) {
|
if (access == null) {
|
||||||
throw new Error(`该授权配置不存在,请确认是否已被删除:id=${accessId}`);
|
throw new Error(`该授权配置不存在,请确认是否已被删除:id=${accessId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyId = access.keyId;
|
const keyId = access.keyId;
|
||||||
//检查目标项目里是否已经有相同keyId的配置
|
//检查目标项目里是否已经有相同keyId的配置
|
||||||
const existAccess = await this.repository.findOne({
|
const existAccess = await this.repository.findOne({
|
||||||
@@ -263,10 +267,10 @@ export class AccessService extends BaseService<AccessEntity> {
|
|||||||
}
|
}
|
||||||
const newAccess = {
|
const newAccess = {
|
||||||
...access,
|
...access,
|
||||||
userId:-1,
|
userId: -1,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
projectId,
|
projectId,
|
||||||
}
|
};
|
||||||
await this.repository.save(newAccess);
|
await this.repository.save(newAccess);
|
||||||
return newAccess.id;
|
return newAccess.id;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-8
@@ -11,14 +11,16 @@
|
|||||||
<td class="record-value" :title="cnameRecord.recordValue">
|
<td class="record-value" :title="cnameRecord.recordValue">
|
||||||
<fs-copyable v-model="cnameRecord.recordValue"></fs-copyable>
|
<fs-copyable v-model="cnameRecord.recordValue"></fs-copyable>
|
||||||
</td>
|
</td>
|
||||||
<td class="status center flex-center">
|
<td class="status center">
|
||||||
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
|
<span class="status-content">
|
||||||
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
|
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
|
||||||
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
|
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
|
||||||
</a-tooltip>
|
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
|
||||||
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
|
</a-tooltip>
|
||||||
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
|
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
|
||||||
</a-tooltip>
|
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="center">
|
<td class="center">
|
||||||
<template v-if="cnameRecord.status !== 'valid'">
|
<template v-if="cnameRecord.status !== 'valid'">
|
||||||
@@ -142,5 +144,10 @@ async function resetStatus() {
|
|||||||
.fs-copyable {
|
.fs-copyable {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.status-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+20
-5
@@ -2,11 +2,11 @@
|
|||||||
<table class="cname-verify-plan">
|
<table class="cname-verify-plan">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 160px">{{ t("certd.verifyPlan.hostRecord") }}</td>
|
<td class="col-host">{{ t("certd.verifyPlan.hostRecord") }}</td>
|
||||||
<td style="width: 100px; text-align: center">{{ t("certd.verifyPlan.recordType") }}</td>
|
<td class="col-type center">{{ t("certd.verifyPlan.recordType") }}</td>
|
||||||
<td style="width: 250px">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
|
<td class="col-value">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
|
||||||
<td style="width: 120px" class="center">{{ t("certd.status") }}</td>
|
<td class="col-status center">{{ t("certd.status") }}</td>
|
||||||
<td style="width: 90px" class="center">{{ t("certd.verifyPlan.operation") }}</td>
|
<td class="col-action center">{{ t("certd.verifyPlan.operation") }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<template v-for="key in domains" :key="key">
|
<template v-for="key in domains" :key="key">
|
||||||
@@ -49,6 +49,21 @@ function onRecordChange(domain: string, record: CnameRecord) {
|
|||||||
.cname-verify-plan {
|
.cname-verify-plan {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
.col-host {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.col-type {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.col-value {
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
.col-status {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.col-action {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
tbody tr td {
|
tbody tr td {
|
||||||
border-top: 1px solid #e8e8e8 !important;
|
border-top: 1px solid #e8e8e8 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<tbody class="dns-persist-record-info">
|
||||||
|
<tr v-if="dnsPersistRecord">
|
||||||
|
<td class="host-record" :title="dnsPersistRecord.hostRecord">
|
||||||
|
<fs-copyable v-model="dnsPersistRecord.hostRecord"></fs-copyable>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center">TXT</td>
|
||||||
|
<td class="record-value" :title="dnsPersistRecord.recordValue">
|
||||||
|
<fs-copyable v-model="dnsPersistRecord.recordValue"></fs-copyable>
|
||||||
|
</td>
|
||||||
|
<td class="status center">
|
||||||
|
<fs-values-format v-model="dnsPersistRecord.status" :dict="statusDict" />
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<template v-if="dnsPersistRecord.status !== 'valid'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" size="small" @click="openSettingDialog">设置TXT</a-button>
|
||||||
|
<a-button type="primary" size="small" :loading="loading" @click="doVerify">校验</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<div v-else class="helper">请勿删除TXT记录</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="5" class="color-red">{{ errorMessage || "请先选择ACME账号授权" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { dict } from "@fast-crud/fast-crud";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { GetByDomain, Verify } from "/@/views/certd/cert/dns-persist/api";
|
||||||
|
import { useDnsPersistSettingDialog } from "/@/views/certd/cert/dns-persist/use-setting-dialog";
|
||||||
|
import { DnsPersistRecord } from "./type";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DnsPersistRecordInfo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
domain: string;
|
||||||
|
caType?: string;
|
||||||
|
acmeAccountAccessId?: number;
|
||||||
|
commonAcmeAccountAccessId?: number;
|
||||||
|
wildcard?: boolean;
|
||||||
|
persistUntil?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [DnsPersistRecord];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusDict = dict({
|
||||||
|
data: [
|
||||||
|
{ value: "pending", label: "待设置", color: "warning" },
|
||||||
|
{ value: "validating", label: "校验中", color: "blue" },
|
||||||
|
{ value: "valid", label: "有效", color: "green" },
|
||||||
|
{ value: "failed", label: "请重试", color: "red" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dnsPersistRecord = ref<DnsPersistRecord | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
|
||||||
|
|
||||||
|
function onRecordChange() {
|
||||||
|
if (dnsPersistRecord.value) {
|
||||||
|
emit("change", dnsPersistRecord.value);
|
||||||
|
} else {
|
||||||
|
emit("change", {
|
||||||
|
domain: props.domain,
|
||||||
|
status: null,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecord() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
dnsPersistRecord.value = null;
|
||||||
|
if (!props.domain || (!props.acmeAccountAccessId && !props.commonAcmeAccountAccessId)) {
|
||||||
|
onRecordChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dnsPersistRecord.value = await GetByDomain({
|
||||||
|
domain: props.domain,
|
||||||
|
caType: props.caType,
|
||||||
|
acmeAccountAccessId: props.acmeAccountAccessId,
|
||||||
|
commonAcmeAccountAccessId: props.commonAcmeAccountAccessId,
|
||||||
|
wildcard: props.wildcard,
|
||||||
|
persistUntil: props.persistUntil,
|
||||||
|
createOnNotFound: true,
|
||||||
|
});
|
||||||
|
onRecordChange();
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMessage.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.domain, props.caType, props.acmeAccountAccessId, props.commonAcmeAccountAccessId, props.wildcard, props.persistUntil],
|
||||||
|
async () => {
|
||||||
|
await loadRecord();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function doVerify() {
|
||||||
|
if (!dnsPersistRecord.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const ok = await Verify(dnsPersistRecord.value.id);
|
||||||
|
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
|
||||||
|
await loadRecord();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingDialog() {
|
||||||
|
if (!dnsPersistRecord.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDnsPersistSettingDialog({
|
||||||
|
record: dnsPersistRecord.value,
|
||||||
|
onDone: loadRecord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.dns-persist-record-info {
|
||||||
|
.fs-copyable {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<table class="dns-persist-verify-plan">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td class="col-host">TXT主机名</td>
|
||||||
|
<td class="col-type center">记录类型</td>
|
||||||
|
<td class="col-value">请设置TXT记录(验证成功以后不要删除)</td>
|
||||||
|
<td class="col-status center">状态</td>
|
||||||
|
<td class="col-action center">操作</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<template v-for="key in domains" :key="key">
|
||||||
|
<dns-persist-record-info
|
||||||
|
:domain="key"
|
||||||
|
:ca-type="caType"
|
||||||
|
:acme-account-access-id="acmeAccountAccessId"
|
||||||
|
:common-acme-account-access-id="commonAcmeAccountAccessId"
|
||||||
|
:wildcard="modelValue[key]?.wildcard"
|
||||||
|
:persist-until="modelValue[key]?.persistUntil"
|
||||||
|
@change="onRecordChange(key, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import DnsPersistRecordInfo from "./dns-persist-record-info.vue";
|
||||||
|
import { DnsPersistRecord } from "./type";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DnsPersistVerifyPlan",
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "change"]);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Record<string, DnsPersistRecord>;
|
||||||
|
caType?: string;
|
||||||
|
acmeAccountAccessId?: number;
|
||||||
|
commonAcmeAccountAccessId?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const domains = computed(() => {
|
||||||
|
return Object.keys(props.modelValue || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRecordChange(domain: string, record: DnsPersistRecord) {
|
||||||
|
const value = { ...props.modelValue };
|
||||||
|
value[domain] = {
|
||||||
|
...value[domain],
|
||||||
|
...record,
|
||||||
|
};
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.dns-persist-verify-plan {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
.col-host {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.col-type {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.col-value {
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
.col-status {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.col-action {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
tbody tr td {
|
||||||
|
border-top: 1px solid #e8e8e8 !important;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
border: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+48
-1
@@ -46,13 +46,28 @@
|
|||||||
<div class="form-item">
|
<div class="form-item">
|
||||||
<span class="label">{{ t("certd.verifyPlan.dnsAccess") }}:</span>
|
<span class="label">{{ t("certd.verifyPlan.dnsAccess") }}:</span>
|
||||||
<span class="input">
|
<span class="input">
|
||||||
<access-selector v-model="item.dnsProviderAccessId" size="small" :type="item.dnsProviderAccessType || item.dnsProviderType" :placeholder="t('certd.verifyPlan.pleaseSelect')" @change="onPlanChanged"></access-selector>
|
<access-selector
|
||||||
|
v-model="item.dnsProviderAccessId"
|
||||||
|
size="small"
|
||||||
|
:type="item.dnsProviderAccessType || item.dnsProviderType"
|
||||||
|
:placeholder="t('certd.verifyPlan.pleaseSelect')"
|
||||||
|
@change="onPlanChanged"
|
||||||
|
></access-selector>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.type === 'cname'" class="plan-cname">
|
<div v-if="item.type === 'cname'" class="plan-cname">
|
||||||
<cname-verify-plan v-model="item.cnameVerifyPlan" @change="onPlanChanged" />
|
<cname-verify-plan v-model="item.cnameVerifyPlan" @change="onPlanChanged" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="item.type === 'dns-persist'" class="plan-dns-persist">
|
||||||
|
<dns-persist-verify-plan
|
||||||
|
v-model="item.dnsPersistVerifyPlan"
|
||||||
|
:ca-type="caType"
|
||||||
|
:acme-account-access-id="acmeAccountAccessId"
|
||||||
|
:common-acme-account-access-id="commonAcmeAccountAccessId"
|
||||||
|
@change="onPlanChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-if="item.type === 'http'" class="plan-http">
|
<div v-if="item.type === 'http'" class="plan-http">
|
||||||
<http-verify-plan v-model="item.httpVerifyPlan" @change="onPlanChanged" />
|
<http-verify-plan v-model="item.httpVerifyPlan" @change="onPlanChanged" />
|
||||||
<div class="helper">{{ t("certd.verifyPlan.httpHelper") }}</div>
|
<div class="helper">{{ t("certd.verifyPlan.httpHelper") }}</div>
|
||||||
@@ -76,6 +91,7 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { dict, FsDictSelect } from "@fast-crud/fast-crud";
|
import { dict, FsDictSelect } from "@fast-crud/fast-crud";
|
||||||
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
|
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
|
||||||
import CnameVerifyPlan from "./cname-verify-plan.vue";
|
import CnameVerifyPlan from "./cname-verify-plan.vue";
|
||||||
|
import DnsPersistVerifyPlan from "./dns-persist-verify-plan.vue";
|
||||||
import HttpVerifyPlan from "./http-verify-plan.vue";
|
import HttpVerifyPlan from "./http-verify-plan.vue";
|
||||||
import { Form } from "ant-design-vue";
|
import { Form } from "ant-design-vue";
|
||||||
import { DomainsVerifyPlanInput } from "./type";
|
import { DomainsVerifyPlanInput } from "./type";
|
||||||
@@ -92,6 +108,10 @@ const challengeTypeOptions = ref<any[]>([
|
|||||||
label: t("certd.verifyPlan.dnsChallenge"),
|
label: t("certd.verifyPlan.dnsChallenge"),
|
||||||
value: "dns",
|
value: "dns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "DNS持久验证",
|
||||||
|
value: "dns-persist",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("certd.verifyPlan.cnameChallenge"),
|
label: t("certd.verifyPlan.cnameChallenge"),
|
||||||
value: "cname",
|
value: "cname",
|
||||||
@@ -106,6 +126,9 @@ const props = defineProps<{
|
|||||||
modelValue?: DomainsVerifyPlanInput;
|
modelValue?: DomainsVerifyPlanInput;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
defaultType?: string;
|
defaultType?: string;
|
||||||
|
caType?: string;
|
||||||
|
acmeAccountAccessId?: number;
|
||||||
|
commonAcmeAccountAccessId?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -189,11 +212,15 @@ async function onDomainsChanged(domains: string[]) {
|
|||||||
|
|
||||||
const cnameOrigin = planItem.cnameVerifyPlan;
|
const cnameOrigin = planItem.cnameVerifyPlan;
|
||||||
const httpOrigin = planItem.httpVerifyPlan;
|
const httpOrigin = planItem.httpVerifyPlan;
|
||||||
|
const dnsPersistOrigin = planItem.dnsPersistVerifyPlan;
|
||||||
planItem.cnameVerifyPlan = {};
|
planItem.cnameVerifyPlan = {};
|
||||||
planItem.httpVerifyPlan = {};
|
planItem.httpVerifyPlan = {};
|
||||||
|
planItem.dnsPersistVerifyPlan = {};
|
||||||
const cnamePlan = planItem.cnameVerifyPlan;
|
const cnamePlan = planItem.cnameVerifyPlan;
|
||||||
const httpPlan = planItem.httpVerifyPlan;
|
const httpPlan = planItem.httpVerifyPlan;
|
||||||
|
const dnsPersistPlan = planItem.dnsPersistVerifyPlan;
|
||||||
for (const subDomain of domainGroupItem.keySubDomains) {
|
for (const subDomain of domainGroupItem.keySubDomains) {
|
||||||
|
const wildcard = true;
|
||||||
if (!cnameOrigin[subDomain]) {
|
if (!cnameOrigin[subDomain]) {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
planItem.cnameVerifyPlan[subDomain] = {
|
planItem.cnameVerifyPlan[subDomain] = {
|
||||||
@@ -225,6 +252,19 @@ async function onDomainsChanged(domains: string[]) {
|
|||||||
domain: subDomain,
|
domain: subDomain,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dnsPersistOrigin?.[subDomain]) {
|
||||||
|
//@ts-ignore
|
||||||
|
dnsPersistPlan[subDomain] = {
|
||||||
|
domain: subDomain,
|
||||||
|
wildcard,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
dnsPersistPlan[subDomain] = {
|
||||||
|
...dnsPersistOrigin[subDomain],
|
||||||
|
wildcard,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const subDomain of Object.keys(cnamePlan)) {
|
for (const subDomain of Object.keys(cnamePlan)) {
|
||||||
@@ -238,6 +278,12 @@ async function onDomainsChanged(domains: string[]) {
|
|||||||
delete httpPlan[subDomain];
|
delete httpPlan[subDomain];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const subDomain of Object.keys(dnsPersistPlan)) {
|
||||||
|
if (!domainGroupItem.keySubDomains.includes(subDomain)) {
|
||||||
|
delete dnsPersistPlan[subDomain];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const domain of Object.keys(planRef.value)) {
|
for (const domain of Object.keys(planRef.value)) {
|
||||||
const mainDomains = Object.keys(domainGroups);
|
const mainDomains = Object.keys(domainGroups);
|
||||||
@@ -268,6 +314,7 @@ watch(
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
.fullscreen-modal {
|
.fullscreen-modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.fullscreen {
|
&.fullscreen {
|
||||||
|
|||||||
+18
-1
@@ -7,15 +7,32 @@ export type HttpRecord = {
|
|||||||
httpUploadRootDir: string;
|
httpUploadRootDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DnsPersistRecord = {
|
||||||
|
id?: number;
|
||||||
|
domain: string;
|
||||||
|
mainDomain?: string;
|
||||||
|
status?: string;
|
||||||
|
hostRecord?: string;
|
||||||
|
recordValue?: string;
|
||||||
|
caType?: string;
|
||||||
|
acmeAccountAccessId?: number;
|
||||||
|
accountUri?: string;
|
||||||
|
wildcard?: boolean;
|
||||||
|
persistUntil?: number;
|
||||||
|
dnsProviderType?: string;
|
||||||
|
dnsProviderAccess?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type DomainVerifyPlanInput = {
|
export type DomainVerifyPlanInput = {
|
||||||
domain: string;
|
domain: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
type: "cname" | "dns" | "http";
|
type: "cname" | "dns" | "http" | "dns-persist";
|
||||||
dnsProviderType?: string;
|
dnsProviderType?: string;
|
||||||
dnsProviderAccessType?: string;
|
dnsProviderAccessType?: string;
|
||||||
dnsProviderAccessId?: number;
|
dnsProviderAccessId?: number;
|
||||||
cnameVerifyPlan?: Record<string, CnameRecord>;
|
cnameVerifyPlan?: Record<string, CnameRecord>;
|
||||||
httpVerifyPlan?: Record<string, HttpRecord>;
|
httpVerifyPlan?: Record<string, HttpRecord>;
|
||||||
|
dnsPersistVerifyPlan?: Record<string, DnsPersistRecord>;
|
||||||
};
|
};
|
||||||
export type DomainsVerifyPlanInput = {
|
export type DomainsVerifyPlanInput = {
|
||||||
[key: string]: DomainVerifyPlanInput;
|
[key: string]: DomainVerifyPlanInput;
|
||||||
|
|||||||
+8
@@ -46,6 +46,14 @@ function checkDomainVerifyPlan(rule: any, value: DomainsVerifyPlanInput) {
|
|||||||
if (!value[domain].dnsProviderType || !value[domain].dnsProviderAccessId) {
|
if (!value[domain].dnsProviderType || !value[domain].dnsProviderAccessId) {
|
||||||
throw new Error($t("certd.verifyPlan.errors.dnsProviderRequired", { domain }));
|
throw new Error($t("certd.verifyPlan.errors.dnsProviderRequired", { domain }));
|
||||||
}
|
}
|
||||||
|
} else if (type === "dns-persist") {
|
||||||
|
const subDomains = Object.keys(value[domain].dnsPersistVerifyPlan || {});
|
||||||
|
for (const subDomain of subDomains) {
|
||||||
|
const plan = value[domain].dnsPersistVerifyPlan[subDomain];
|
||||||
|
if (plan.status !== "valid") {
|
||||||
|
throw new Error(`DNS持久验证记录(${subDomain})还未校验成功`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="refresh-input">
|
<div class="refresh-input">
|
||||||
<div class="refresh-input-line">
|
<div class="refresh-input-line">
|
||||||
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" allow-clear @update:value="emit('update:value', $event)"></a-input>
|
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" :allow-clear="!disabled" :disabled="disabled" @update:value="emit('update:value', $event)"></a-input>
|
||||||
<fs-button :loading="loading" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
|
<fs-button :loading="loading" :disabled="disabled" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="helper" :class="{ error: hasError }">
|
<div class="helper" :class="{ error: hasError }">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
@@ -25,6 +25,7 @@ type RefreshInputProps = ComponentPropsType & {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromType: any = inject("getFromType");
|
const fromType: any = inject("getFromType");
|
||||||
@@ -49,6 +50,9 @@ const placeholder = computed(() => props.placeholder || "");
|
|||||||
const successMessage = computed(() => props.successMessage || "刷新成功,请保存配置");
|
const successMessage = computed(() => props.successMessage || "刷新成功,请保存配置");
|
||||||
|
|
||||||
const doRefresh = async () => {
|
const doRefresh = async () => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ function createChallengeTypeDict() {
|
|||||||
return dict({
|
return dict({
|
||||||
data: [
|
data: [
|
||||||
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
|
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
|
||||||
|
{ value: "dns-persist", label: "DNS持久验证", color: "cyan" },
|
||||||
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
|
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
|
||||||
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
|
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
|
||||||
],
|
],
|
||||||
@@ -39,7 +40,12 @@ export const Dicts = {
|
|||||||
sslProviderDict: dict({
|
sslProviderDict: dict({
|
||||||
data: [
|
data: [
|
||||||
{ value: "letsencrypt", label: "Let's Encrypt" },
|
{ value: "letsencrypt", label: "Let's Encrypt" },
|
||||||
|
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
|
||||||
|
{ value: "google", label: "Google" },
|
||||||
{ value: "zerossl", label: "ZeroSSL" },
|
{ value: "zerossl", label: "ZeroSSL" },
|
||||||
|
{ value: "sslcom", label: "SSL.com" },
|
||||||
|
{ value: "litessl", label: "litessl" },
|
||||||
|
{ value: "custom", label: "自定义ACME" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
get challengeTypeDict() {
|
get challengeTypeDict() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default {
|
|||||||
siteMonitor: "Site Certificate Monitor",
|
siteMonitor: "Site Certificate Monitor",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
accessManager: "Access Management",
|
accessManager: "Access Management",
|
||||||
|
dnsPersistRecord: "DNS Persist Records",
|
||||||
subDomain: "Subdomain Delegation Settings",
|
subDomain: "Subdomain Delegation Settings",
|
||||||
pipelineGroup: "Pipeline Group Management",
|
pipelineGroup: "Pipeline Group Management",
|
||||||
openKey: "Open API Key",
|
openKey: "Open API Key",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default {
|
|||||||
siteMonitor: "站点证书监控",
|
siteMonitor: "站点证书监控",
|
||||||
settings: "设置",
|
settings: "设置",
|
||||||
accessManager: "授权管理",
|
accessManager: "授权管理",
|
||||||
|
dnsPersistRecord: "DNS持久验证记录",
|
||||||
subDomain: "子域名托管设置",
|
subDomain: "子域名托管设置",
|
||||||
pipelineGroup: "流水线分组管理",
|
pipelineGroup: "流水线分组管理",
|
||||||
openKey: "开放接口密钥",
|
openKey: "开放接口密钥",
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ export const certdResources = [
|
|||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "certd.dnsPersistRecord",
|
||||||
|
name: "DnsPersistRecord",
|
||||||
|
path: "/certd/cert/dns-persist",
|
||||||
|
component: "/certd/cert/dns-persist/index.vue",
|
||||||
|
meta: {
|
||||||
|
icon: "ion:shield-half-outline",
|
||||||
|
auth: true,
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "certd.subDomain",
|
title: "certd.subDomain",
|
||||||
name: "SubDomain",
|
name: "SubDomain",
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
const { props, ctx, api } = context;
|
const { props, ctx, api } = context;
|
||||||
const lastResRef = ref();
|
const lastResRef = ref();
|
||||||
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
|
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
|
||||||
|
query.query = query.query || {};
|
||||||
|
if (props.subtype) {
|
||||||
|
query.query.subtype = props.subtype;
|
||||||
|
} else {
|
||||||
|
delete query.query.subtype;
|
||||||
|
}
|
||||||
return await context.api.GetList(query);
|
return await context.api.GetList(query);
|
||||||
};
|
};
|
||||||
const editRequest = async (req: EditReq) => {
|
const editRequest = async (req: EditReq) => {
|
||||||
@@ -47,7 +53,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
const { myProjectDict } = useDicts();
|
const { myProjectDict } = useDicts();
|
||||||
const typeRef = ref("aliyun");
|
const typeRef = ref("aliyun");
|
||||||
context.typeRef = typeRef;
|
context.typeRef = typeRef;
|
||||||
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api);
|
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api, props.subtype);
|
||||||
commonColumnsDefine.type.form.component.disabled = true;
|
commonColumnsDefine.type.form.component.disabled = true;
|
||||||
const projectStore = useProjectStore();
|
const projectStore = useProjectStore();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export default defineComponent({
|
|||||||
type: String, //user | sys
|
type: String, //user | sys
|
||||||
default: "user",
|
default: "user",
|
||||||
},
|
},
|
||||||
|
subtype: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
modelValue: {},
|
modelValue: {},
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
emits: ["update:modelValue"],
|
||||||
@@ -30,10 +34,17 @@ export default defineComponent({
|
|||||||
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
|
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
|
||||||
|
|
||||||
// 你可以调用此方法,重新初始化crud配置
|
// 你可以调用此方法,重新初始化crud配置
|
||||||
|
function refreshSearch() {
|
||||||
|
const form: any = { type: props.type };
|
||||||
|
if (props.subtype) {
|
||||||
|
form.subtype = props.subtype;
|
||||||
|
}
|
||||||
|
crudExpose.setSearchFormData({ form, mergeForm: true });
|
||||||
|
crudExpose.doRefresh();
|
||||||
|
}
|
||||||
function onTypeChanged(value: any) {
|
function onTypeChanged(value: any) {
|
||||||
context.typeRef.value = value;
|
context.typeRef.value = value;
|
||||||
crudExpose.setSearchFormData({ form: { type: value }, mergeForm: true });
|
refreshSearch();
|
||||||
crudExpose.doRefresh();
|
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
() => {
|
() => {
|
||||||
@@ -44,6 +55,14 @@ export default defineComponent({
|
|||||||
onTypeChanged(value);
|
onTypeChanged(value);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
return props.subtype;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
refreshSearch();
|
||||||
|
}
|
||||||
|
);
|
||||||
// 页面打开后获取列表数据
|
// 页面打开后获取列表数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
onTypeChanged(props.type);
|
onTypeChanged(props.type);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<a-form-item-rest v-if="chooseForm.show">
|
<a-form-item-rest v-if="chooseForm.show">
|
||||||
<a-modal v-model:open="chooseForm.show" title="选择授权提供者" width="900px" @ok="chooseForm.ok">
|
<a-modal v-model:open="chooseForm.show" title="选择授权提供者" width="900px" @ok="chooseForm.ok">
|
||||||
<div style="height: 400px; position: relative">
|
<div style="height: 400px; position: relative">
|
||||||
<cert-access-modal v-model="selectedId" :type="type" :from="from"></cert-access-modal>
|
<cert-access-modal v-model="selectedId" :type="type" :subtype="subtype" :from="from"></cert-access-modal>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</a-form-item-rest>
|
</a-form-item-rest>
|
||||||
@@ -35,6 +35,10 @@ export default defineComponent({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "aliyun",
|
default: "aliyun",
|
||||||
},
|
},
|
||||||
|
subtype: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "请选择",
|
default: "请选择",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ColumnCompositionProps, dict } from "@fast-crud/fast-crud";
|
import { ColumnCompositionProps, dict } from "@fast-crud/fast-crud";
|
||||||
import { computed, provide, ref, toRef } from "vue";
|
import { provide, ref, toRef } from "vue";
|
||||||
import { useReference } from "/@/use/use-refrence";
|
import { useReference } from "/@/use/use-refrence";
|
||||||
import { forEach, get, merge, set } from "lodash-es";
|
import { forEach, get, merge, set } from "lodash-es";
|
||||||
import SecretPlainGetter from "/@/views/certd/access/access-selector/access/secret-plain-getter.vue";
|
import SecretPlainGetter from "/@/views/certd/access/access-selector/access/secret-plain-getter.vue";
|
||||||
import { utils } from "/@/utils";
|
import { utils } from "/@/utils";
|
||||||
|
|
||||||
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
|
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any, fixedSubtype?: string) {
|
||||||
provide("getFromType", api.from);
|
provide("getFromType", api.from);
|
||||||
provide("accessApi", api);
|
provide("accessApi", api);
|
||||||
provide("get:plugin:type", () => {
|
provide("get:plugin:type", () => {
|
||||||
@@ -34,6 +34,13 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('crudBinding.value[mode + "Form"].columns', columnsRef.value);
|
console.log('crudBinding.value[mode + "Form"].columns', columnsRef.value);
|
||||||
|
if (mode === "add" && define.subtype && fixedSubtype) {
|
||||||
|
form.access = form.access || {};
|
||||||
|
const subtypeKey = `access.${define.subtype}`;
|
||||||
|
if (get(form, subtypeKey) == null) {
|
||||||
|
set(form, subtypeKey, fixedSubtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
forEach(define.input, (value: any, mapKey: any) => {
|
forEach(define.input, (value: any, mapKey: any) => {
|
||||||
const key = "access." + mapKey;
|
const key = "access." + mapKey;
|
||||||
const field = {
|
const field = {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { request } from "/src/api/service";
|
||||||
|
|
||||||
|
const apiPrefix = "/cert/dns-persist";
|
||||||
|
|
||||||
|
export async function GetList(query: any) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/page",
|
||||||
|
method: "post",
|
||||||
|
data: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function AddObj(obj: any) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/add",
|
||||||
|
method: "post",
|
||||||
|
data: obj,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function UpdateObj(obj: any) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/update",
|
||||||
|
method: "post",
|
||||||
|
data: obj,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DelObj(id: any) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/delete",
|
||||||
|
method: "post",
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function BuildRecord(body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/build",
|
||||||
|
method: "post",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GetByDomain(body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/getByDomain",
|
||||||
|
method: "post",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CheckRecord(body: { hostRecord: string; recordValue: string }) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/check",
|
||||||
|
method: "post",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Verify(id: number) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/verify",
|
||||||
|
method: "post",
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function TriggerVerify(id: number) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/triggerVerify",
|
||||||
|
method: "post",
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CreateTxt(body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/createTxt",
|
||||||
|
method: "post",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||||
|
import { message, Modal, notification } from "ant-design-vue";
|
||||||
|
import * as api from "./api";
|
||||||
|
import { Dicts } from "/@/components/plugins/lib/dicts";
|
||||||
|
import { createAccessApi } from "/@/views/certd/access/api";
|
||||||
|
import { useDnsPersistSettingDialog } from "./use-setting-dialog";
|
||||||
|
|
||||||
|
function parseAccount(account: any) {
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof account === "string") {
|
||||||
|
return JSON.parse(account);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
||||||
|
const accessApi = createAccessApi();
|
||||||
|
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
|
||||||
|
const accessDict = dict({
|
||||||
|
value: "id",
|
||||||
|
label: "name",
|
||||||
|
url: "accessDict",
|
||||||
|
async getNodesByValues(ids: number[]) {
|
||||||
|
return await accessApi.GetDictByIds(ids);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dnsProviderTypeDict = dict({
|
||||||
|
url: "pi/dnsProvider/dnsProviderTypeDict",
|
||||||
|
});
|
||||||
|
const statusDict = dict({
|
||||||
|
data: [
|
||||||
|
{ value: "pending", label: "待设置", color: "warning" },
|
||||||
|
{ value: "created", label: "已创建", color: "blue" },
|
||||||
|
{ value: "validating", label: "校验中", color: "blue" },
|
||||||
|
{ value: "valid", label: "有效", color: "green" },
|
||||||
|
{ value: "failed", label: "请重试", color: "red" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
|
||||||
|
return await api.GetList(query);
|
||||||
|
};
|
||||||
|
const editRequest = async ({ form, row }: EditReq) => {
|
||||||
|
form.id = row.id;
|
||||||
|
return await api.UpdateObj(form);
|
||||||
|
};
|
||||||
|
const delRequest = async ({ row }: DelReq) => {
|
||||||
|
const res = await api.DelObj(row.id);
|
||||||
|
if (res?.message) {
|
||||||
|
notification.warning({
|
||||||
|
message: "请到供应商删除TXT记录",
|
||||||
|
description: res.message,
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
const addRequest = async ({ form }: AddReq) => {
|
||||||
|
return await api.AddObj(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fillRecord(form: any) {
|
||||||
|
if (!form.domain || !form.acmeAccountAccessId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const access: any = await accessApi.GetObj(form.acmeAccountAccessId);
|
||||||
|
const setting = JSON.parse(access.setting || "{}");
|
||||||
|
const account = parseAccount(setting.account);
|
||||||
|
if (!account?.accountUri) {
|
||||||
|
message.error("ACME账号授权缺少accountUri,请重新生成账号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = await api.BuildRecord({
|
||||||
|
domain: form.domain,
|
||||||
|
accountUri: account.accountUri,
|
||||||
|
wildcard: true,
|
||||||
|
persistUntil: form.persistUntil,
|
||||||
|
});
|
||||||
|
form.caType = account.caType;
|
||||||
|
form.accountUri = account.accountUri;
|
||||||
|
form.hostRecord = record.hostRecord;
|
||||||
|
form.recordValue = record.recordValue;
|
||||||
|
form.status = "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRecord(row: any) {
|
||||||
|
const ok = await api.Verify(row.id);
|
||||||
|
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
|
||||||
|
await crudExpose.doRefresh();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRecordHelp(row: any) {
|
||||||
|
openDnsPersistSettingDialog({
|
||||||
|
record: row,
|
||||||
|
async onDone() {
|
||||||
|
await crudExpose.doRefresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
crudOptions: {
|
||||||
|
request: {
|
||||||
|
pageRequest,
|
||||||
|
addRequest,
|
||||||
|
editRequest,
|
||||||
|
delRequest,
|
||||||
|
},
|
||||||
|
actionbar: {
|
||||||
|
buttons: {
|
||||||
|
add: {
|
||||||
|
icon: "ion:add-circle-outline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowHandle: {
|
||||||
|
minWidth: 120,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
id: {
|
||||||
|
title: "ID",
|
||||||
|
key: "id",
|
||||||
|
type: "number",
|
||||||
|
column: { width: 80, order: -999 },
|
||||||
|
form: { show: false },
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
title: "域名",
|
||||||
|
type: "text",
|
||||||
|
search: { show: true },
|
||||||
|
form: {
|
||||||
|
required: true,
|
||||||
|
valueChange({ form }) {
|
||||||
|
fillRecord(form);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainDomain: {
|
||||||
|
title: "主域名",
|
||||||
|
type: "text",
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
width: 160,
|
||||||
|
order: 901,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wildcard: {
|
||||||
|
title: "通配符",
|
||||||
|
type: "dict-switch",
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
column: { show: false },
|
||||||
|
},
|
||||||
|
acmeAccountAccessId: {
|
||||||
|
title: "ACME账号授权",
|
||||||
|
type: "dict-select",
|
||||||
|
dict: accessDict,
|
||||||
|
form: {
|
||||||
|
required: true,
|
||||||
|
order: -9,
|
||||||
|
component: {
|
||||||
|
name: "AccessSelector",
|
||||||
|
vModel: "modelValue",
|
||||||
|
type: "acmeAccount",
|
||||||
|
subtype: compute(({ form }) => {
|
||||||
|
return form.caType;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
valueChange({ form }) {
|
||||||
|
fillRecord(form);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
caType: {
|
||||||
|
title: "颁发机构",
|
||||||
|
type: "dict-select",
|
||||||
|
dict: Dicts.sslProviderDict,
|
||||||
|
form: {
|
||||||
|
required: true,
|
||||||
|
value: "letsencrypt",
|
||||||
|
order: -10,
|
||||||
|
valueChange({ form }) {
|
||||||
|
form.acmeAccountAccessId = null;
|
||||||
|
fillRecord(form);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
column: { width: 120 },
|
||||||
|
},
|
||||||
|
persistUntil: {
|
||||||
|
title: "有效期至",
|
||||||
|
type: "datetime",
|
||||||
|
form: {
|
||||||
|
helper: "可选;为空表示长期有效",
|
||||||
|
order: 20,
|
||||||
|
valueChange({ form }) {
|
||||||
|
fillRecord(form);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
column: { width: 180, order: 900 },
|
||||||
|
},
|
||||||
|
hostRecord: {
|
||||||
|
title: "TXT主机名",
|
||||||
|
type: "copyable",
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
width: 220,
|
||||||
|
cellRender({ value }) {
|
||||||
|
return (
|
||||||
|
<a-tooltip title={value}>
|
||||||
|
<fs-copyable modelValue={value}></fs-copyable>
|
||||||
|
</a-tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recordValue: {
|
||||||
|
title: "请设置TXT记录",
|
||||||
|
type: "copyable",
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
width: 380,
|
||||||
|
cellRender({ value }) {
|
||||||
|
return (
|
||||||
|
<a-tooltip title={value}>
|
||||||
|
<fs-copyable modelValue={value}></fs-copyable>
|
||||||
|
</a-tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dnsProviderType: {
|
||||||
|
title: "DNS服务商",
|
||||||
|
type: "dict-select",
|
||||||
|
dict: dnsProviderTypeDict,
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
name: "DnsProviderSelector",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
column: { show: false },
|
||||||
|
},
|
||||||
|
dnsProviderAccess: {
|
||||||
|
title: "DNS授权",
|
||||||
|
type: "dict-select",
|
||||||
|
dict: accessDict,
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
name: "AccessSelector",
|
||||||
|
vModel: "modelValue",
|
||||||
|
type: compute(({ form }) => {
|
||||||
|
const type = form.dnsProviderType || "aliyun";
|
||||||
|
return dnsProviderTypeDict?.dataMap[type]?.accessType || type;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
column: { show: false },
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
title: "状态",
|
||||||
|
type: "dict-select",
|
||||||
|
dict: statusDict,
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
value: "pending",
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
width: 120,
|
||||||
|
cellRender({ value, row }) {
|
||||||
|
async function resetStatus() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "重新校验",
|
||||||
|
content: "确认将该记录状态重置为待设置,并重新校验吗?",
|
||||||
|
onOk: async () => {
|
||||||
|
await api.UpdateObj({ id: row.id, status: "pending" });
|
||||||
|
await verifyRecord(row);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div class={"flex flex-left"}>
|
||||||
|
<fs-values-format modelValue={value} dict={statusDict}></fs-values-format>
|
||||||
|
{row.status === "valid" && (
|
||||||
|
<a-tooltip title="撤销并重新校验">
|
||||||
|
<fs-icon class={"ml-5 pointer color-yellow"} icon="solar:undo-left-square-bold" onClick={resetStatus}></fs-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
triggerValidate: {
|
||||||
|
title: "校验",
|
||||||
|
type: "text",
|
||||||
|
form: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
conditionalRenderDisabled: true,
|
||||||
|
width: 210,
|
||||||
|
align: "center",
|
||||||
|
cellRender({ row }) {
|
||||||
|
return (
|
||||||
|
<a-space>
|
||||||
|
{row.status === "valid" ? (
|
||||||
|
<span class="text-gray-500">请勿删除TXT记录</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a-button type="primary" size="small" onClick={() => showRecordHelp(row)}>
|
||||||
|
设置TXT
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" size="small" onClick={() => verifyRecord(row)}>
|
||||||
|
校验
|
||||||
|
</a-button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a-space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accountUri: {
|
||||||
|
title: "Account URI",
|
||||||
|
type: "text",
|
||||||
|
form: { show: false },
|
||||||
|
column: { show: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<fs-page class="page-cert-dns-persist">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<div class="title">DNS持久验证记录</div>
|
||||||
|
<div class="text-orange-500 mt-5">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书。</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
|
||||||
|
</fs-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onActivated, onMounted } from "vue";
|
||||||
|
import { useFs } from "@fast-crud/fast-crud";
|
||||||
|
import createCrudOptions from "./crud";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DnsPersistRecord",
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: any = {
|
||||||
|
permission: { isProjectPermission: true },
|
||||||
|
};
|
||||||
|
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
crudExpose.doRefresh();
|
||||||
|
});
|
||||||
|
onActivated(async () => {
|
||||||
|
await crudExpose.doRefresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
|
||||||
|
import DnsProviderSelector from "/@/components/plugins/cert/dns-provider-selector/index.vue";
|
||||||
|
import { useFormDialog } from "/@/use/use-dialog";
|
||||||
|
import { CreateTxt, TriggerVerify } from "./api";
|
||||||
|
|
||||||
|
export type DnsPersistSettingRecord = {
|
||||||
|
id?: number;
|
||||||
|
mainDomain?: string;
|
||||||
|
hostRecord?: string;
|
||||||
|
recordValue?: string;
|
||||||
|
dnsProviderType?: string;
|
||||||
|
dnsProviderAccess?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDnsPersistSettingDialog() {
|
||||||
|
const { openFormDialog } = useFormDialog();
|
||||||
|
|
||||||
|
function copyableRow(label: string, value?: string) {
|
||||||
|
return (
|
||||||
|
<div class="mb-10 flex items-center">
|
||||||
|
<div style={{ width: "90px", flexShrink: 0 }}>{label}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<fs-copyable class="w-full" model-value={value || ""}></fs-copyable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDnsPersistSettingDialog(req: { record: DnsPersistSettingRecord; onDone?: () => Promise<void> | void }) {
|
||||||
|
const record = req.record;
|
||||||
|
const form = reactive({
|
||||||
|
mode: "manual",
|
||||||
|
dnsProviderType: record.dnsProviderType || "",
|
||||||
|
dnsProviderAccessType: "",
|
||||||
|
dnsProviderAccess: record.dnsProviderAccess || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!record.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.mode === "manual") {
|
||||||
|
await TriggerVerify(record.id);
|
||||||
|
message.success("已提交校验");
|
||||||
|
await req.onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.dnsProviderType || !form.dnsProviderAccess) {
|
||||||
|
throw new Error("请选择DNS服务商和授权");
|
||||||
|
}
|
||||||
|
await CreateTxt({
|
||||||
|
id: record.id,
|
||||||
|
dnsProviderType: form.dnsProviderType,
|
||||||
|
dnsProviderAccess: form.dnsProviderAccess,
|
||||||
|
});
|
||||||
|
message.success("TXT记录已创建");
|
||||||
|
await req.onDone?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
await openFormDialog({
|
||||||
|
title: "设置DNS TXT记录",
|
||||||
|
wrapper: {
|
||||||
|
width: 680,
|
||||||
|
buttons: {
|
||||||
|
reset: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
show: true,
|
||||||
|
text: "确定",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: () => (
|
||||||
|
<div>
|
||||||
|
<a-radio-group value={form.mode} buttonStyle="solid" class="mb-10" onUpdate:value={(value: string) => (form.mode = value)}>
|
||||||
|
<a-radio-button value="manual">手动添加</a-radio-button>
|
||||||
|
<a-radio-button value="auto">选择授权添加</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
{form.mode === "manual" ? (
|
||||||
|
<div>
|
||||||
|
<a-alert class="mb-10" type="info" show-icon message="请到DNS解析控制台添加以下TXT记录,添加后点击确定会立即校验。" />
|
||||||
|
{copyableRow("主域名", record.mainDomain)}
|
||||||
|
{copyableRow("TXT主机名", record.hostRecord)}
|
||||||
|
{copyableRow("TXT值", record.recordValue)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<a-alert class="mb-10" type="info" show-icon message="请选择DNS服务商和授权,系统会创建TXT记录,后续校验由后台完成。" />
|
||||||
|
{copyableRow("主域名", record.mainDomain)}
|
||||||
|
<div class="mb-10 flex items-center">
|
||||||
|
<div style={{ width: "90px", flexShrink: 0 }}>DNS服务商</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<DnsProviderSelector
|
||||||
|
class="w-full"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
modelValue={form.dnsProviderType}
|
||||||
|
onUpdate:modelValue={(value: string) => {
|
||||||
|
form.dnsProviderType = value;
|
||||||
|
form.dnsProviderAccess = null;
|
||||||
|
}}
|
||||||
|
onSelectedChange={(option: any) => {
|
||||||
|
form.dnsProviderAccessType = option?.accessType || form.dnsProviderType;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-10 flex items-center">
|
||||||
|
<div style={{ width: "90px", flexShrink: 0 }}>DNS授权</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<AccessSelector
|
||||||
|
modelValue={form.dnsProviderAccess}
|
||||||
|
type={form.dnsProviderAccessType || form.dnsProviderType || "aliyun"}
|
||||||
|
onUpdate:modelValue={(value: number) => {
|
||||||
|
form.dnsProviderAccess = value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onSubmit: submit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openDnsPersistSettingDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -92,6 +92,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
if (form.challengeType === "cname") {
|
if (form.challengeType === "cname") {
|
||||||
throw new Error(t("certd.domain.cnameManagedInCnamePage"));
|
throw new Error(t("certd.domain.cnameManagedInCnamePage"));
|
||||||
}
|
}
|
||||||
|
if (form.challengeType === "dns-persist") {
|
||||||
|
throw new Error("DNS持久验证记录请在DNS持久验证记录页面管理");
|
||||||
|
}
|
||||||
if (form.challengeType === "dns") {
|
if (form.challengeType === "dns") {
|
||||||
const isSubdomain = await api.IsSubdomain({ domain: form.domain });
|
const isSubdomain = await api.IsSubdomain({ domain: form.domain });
|
||||||
if (isSubdomain && !subdomainConfirmed.value) {
|
if (isSubdomain && !subdomainConfirmed.value) {
|
||||||
@@ -221,6 +224,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
|||||||
crudExpose.getFormWrapperRef().close();
|
crudExpose.getFormWrapperRef().close();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (value === "dns-persist") {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "请前往DNS持久验证记录页面添加记录",
|
||||||
|
content: "DNS持久验证需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请。",
|
||||||
|
async onOk() {
|
||||||
|
router.push({
|
||||||
|
path: "/certd/cert/dns-persist",
|
||||||
|
});
|
||||||
|
crudExpose.getFormWrapperRef().close();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+4
@@ -213,6 +213,10 @@ function useStepForm() {
|
|||||||
const stepOpen = (step: any, emit: any) => {
|
const stepOpen = (step: any, emit: any) => {
|
||||||
callback.value = emit;
|
callback.value = emit;
|
||||||
currentStep.value = merge({ input: {}, strategy: {} }, step);
|
currentStep.value = merge({ input: {}, strategy: {} }, step);
|
||||||
|
// 旧版证书申请任务没有 version 字段,编辑时补成 1,保持旧任务继续走兼容逻辑。
|
||||||
|
if (mode.value === "edit" && currentStep.value.type === "CertApply" && currentStep.value.input?.version == null) {
|
||||||
|
currentStep.value.input.version = 1;
|
||||||
|
}
|
||||||
if (step.type) {
|
if (step.type) {
|
||||||
changeCurrentPlugin(currentStep.value);
|
changeCurrentPlugin(currentStep.value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="sys-plugin-config settings-form">
|
<div class="sys-plugin-config settings-form">
|
||||||
<a-form :model="formState" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finish-failed="onFinishFailed">
|
<a-form :model="formState" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finish-failed="onFinishFailed">
|
||||||
<a-form-item label="公共Google EAB授权" :name="['CertApply', 'sysSetting', 'input', 'googleCommonEabAccessId']">
|
<a-form-item v-show="false" label="公共Google EAB授权" :name="['CertApply', 'sysSetting', 'input', 'googleCommonEabAccessId']">
|
||||||
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.googleCommonEabAccessId" type="eab" from="sys"></access-selector>
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.googleCommonEabAccessId" type="eab" from="sys"></access-selector>
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
<div>设置公共Google EAB授权给用户使用,避免用户自己去翻墙获取Google EAB授权</div>
|
<div>设置公共Google EAB授权给用户使用,避免用户自己去翻墙获取Google EAB授权</div>
|
||||||
@@ -16,7 +16,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="公共ZeroSSL EAB授权" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonEabAccessId']">
|
<a-form-item label="公共Google ACME账号" :name="['CertApply', 'sysSetting', 'input', 'googleCommonAcmeAccountAccessId']">
|
||||||
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.googleCommonAcmeAccountAccessId" type="acmeAccount" subtype="google" from="sys"></access-selector>
|
||||||
|
<div class="helper">
|
||||||
|
<div>优先推荐配置公共ACME账号。配置后普通用户申请Google证书时无需选择账号,也不会重复消费公共EAB。</div>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-show="false" label="公共ZeroSSL EAB授权" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonEabAccessId']">
|
||||||
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.zerosslCommonEabAccessId" type="eab" from="sys"></access-selector>
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.zerosslCommonEabAccessId" type="eab" from="sys"></access-selector>
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
<div>设置公共ZeroSSL EAB授权给用户使用,避免用户自己去翻墙获取Zero EAB授权</div>
|
<div>设置公共ZeroSSL EAB授权给用户使用,避免用户自己去翻墙获取Zero EAB授权</div>
|
||||||
@@ -26,7 +33,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="公共litessl EAB授权" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonEabAccessId']">
|
<a-form-item label="公共ZeroSSL ACME账号" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonAcmeAccountAccessId']">
|
||||||
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.zerosslCommonAcmeAccountAccessId" type="acmeAccount" subtype="zerossl" from="sys"></access-selector>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-show="false" label="公共litessl EAB授权" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonEabAccessId']">
|
||||||
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.litesslCommonEabAccessId" type="eab" from="sys"></access-selector>
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.litesslCommonEabAccessId" type="eab" from="sys"></access-selector>
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
<div>设置公共litessl EAB授权给用户使用,避免用户自己获取litessl EAB授权</div>
|
<div>设置公共litessl EAB授权给用户使用,避免用户自己获取litessl EAB授权</div>
|
||||||
@@ -36,6 +47,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="公共litessl ACME账号" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonAcmeAccountAccessId']">
|
||||||
|
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.litesslCommonAcmeAccountAccessId" type="acmeAccount" subtype="litessl" from="sys"></access-selector>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="其他配置">
|
<a-form-item label="其他配置">
|
||||||
<a-button type="primary" @click="doPluginConfig">证书申请插件默认值设置</a-button>
|
<a-button type="primary" @click="doPluginConfig">证书申请插件默认值设置</a-button>
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
ALTER TABLE cd_access ADD COLUMN subtype varchar(100);
|
||||||
|
CREATE INDEX "index_access_subtype" ON "cd_access" ("subtype");
|
||||||
|
|
||||||
|
CREATE TABLE "cd_dns_persist_record"
|
||||||
|
(
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"project_id" integer,
|
||||||
|
"domain" varchar(255) NOT NULL,
|
||||||
|
"main_domain" varchar(255) NOT NULL,
|
||||||
|
"ca_type" varchar(50) NOT NULL,
|
||||||
|
"acme_account_access_id" integer NOT NULL,
|
||||||
|
"account_uri" varchar(512) NOT NULL,
|
||||||
|
"host_record" varchar(255) NOT NULL,
|
||||||
|
"record_value" text NOT NULL,
|
||||||
|
"policy" varchar(50),
|
||||||
|
"persist_until" integer,
|
||||||
|
"status" varchar(50) NOT NULL DEFAULT 'pending',
|
||||||
|
"dns_provider_type" varchar(50),
|
||||||
|
"dns_provider_access" integer,
|
||||||
|
"record_res" text,
|
||||||
|
"disabled" integer NOT NULL DEFAULT 0,
|
||||||
|
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "index_dns_persist_user_id" ON "cd_dns_persist_record" ("user_id");
|
||||||
|
CREATE INDEX "index_dns_persist_project_id" ON "cd_dns_persist_record" ("project_id");
|
||||||
|
CREATE INDEX "index_dns_persist_domain" ON "cd_dns_persist_record" ("domain");
|
||||||
|
CREATE INDEX "index_dns_persist_main_domain" ON "cd_dns_persist_record" ("main_domain");
|
||||||
|
CREATE INDEX "index_dns_persist_account" ON "cd_dns_persist_record" ("acme_account_access_id");
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
|
||||||
|
import { Constants, CrudController } from "@certd/lib-server";
|
||||||
|
import { ApiTags } from "@midwayjs/swagger";
|
||||||
|
import { DnsPersistRecordService } from "../../../modules/cert/service/dns-persist-record-service.js";
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Controller("/api/cert/dns-persist")
|
||||||
|
@ApiTags(["cert"])
|
||||||
|
export class DnsPersistRecordController extends CrudController<DnsPersistRecordService> {
|
||||||
|
@Inject()
|
||||||
|
service: DnsPersistRecordService;
|
||||||
|
|
||||||
|
getService(): DnsPersistRecordService {
|
||||||
|
return this.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/page", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录分页列表" })
|
||||||
|
async page(@Body(ALL) body: any) {
|
||||||
|
const { projectId, userId } = await this.getProjectUserIdRead();
|
||||||
|
body.query = body.query ?? {};
|
||||||
|
body.query.projectId = projectId;
|
||||||
|
body.query.userId = userId;
|
||||||
|
return super.page(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/add", { description: Constants.per.authOnly, summary: "添加DNS持久验证记录" })
|
||||||
|
async add(@Body(ALL) bean: any) {
|
||||||
|
const { projectId, userId } = await this.getProjectUserIdWrite();
|
||||||
|
bean.projectId = projectId;
|
||||||
|
bean.userId = userId;
|
||||||
|
return super.add(bean);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/update", { description: Constants.per.authOnly, summary: "更新DNS持久验证记录" })
|
||||||
|
async update(@Body(ALL) bean: any) {
|
||||||
|
await this.checkOwner(this.getService(), bean.id, "write");
|
||||||
|
delete bean.userId;
|
||||||
|
delete bean.projectId;
|
||||||
|
return super.update(bean);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/info", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录详情" })
|
||||||
|
async info(@Query("id") id: number) {
|
||||||
|
await this.checkOwner(this.getService(), id, "read");
|
||||||
|
return super.info(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/delete", { description: Constants.per.authOnly, summary: "删除DNS持久验证记录" })
|
||||||
|
async delete(@Query("id") id: number) {
|
||||||
|
await this.checkOwner(this.getService(), id, "write");
|
||||||
|
await this.service.delete(id as any);
|
||||||
|
return this.ok({
|
||||||
|
message: this.service.lastDeleteMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/build", { description: Constants.per.authOnly, summary: "生成DNS持久验证记录值" })
|
||||||
|
async build(@Body(ALL) body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
|
||||||
|
const { projectId, userId } = await this.getProjectUserIdRead();
|
||||||
|
return this.ok(await this.service.buildRecord({ ...body, userId, projectId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/getByDomain", { description: Constants.per.authOnly, summary: "根据域名获取或创建DNS持久验证记录" })
|
||||||
|
async getByDomain(@Body(ALL) body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
|
||||||
|
const { projectId, userId } = await this.getProjectUserIdWrite();
|
||||||
|
return this.ok(await this.service.getByDomain({ ...body, userId, projectId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/check", { description: Constants.per.authOnly, summary: "校验DNS持久验证记录" })
|
||||||
|
async check(@Body(ALL) body: { hostRecord: string; recordValue: string }) {
|
||||||
|
return this.ok(await this.service.checkRecord(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/verify", { description: Constants.per.authOnly, summary: "验证DNS持久验证记录" })
|
||||||
|
async verify(@Body(ALL) body: { id: number }) {
|
||||||
|
await this.checkOwner(this.getService(), body.id, "write");
|
||||||
|
return this.ok(await this.service.verify(body.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/triggerVerify", { description: Constants.per.authOnly, summary: "后台验证DNS持久验证记录" })
|
||||||
|
async triggerVerify(@Body(ALL) body: { id: number }) {
|
||||||
|
await this.checkOwner(this.getService(), body.id, "write");
|
||||||
|
return this.ok(await this.service.triggerVerify(body.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/createTxt", { description: Constants.per.authOnly, summary: "一键创建DNS持久验证TXT记录" })
|
||||||
|
async createTxt(@Body(ALL) body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||||
|
await this.checkOwner(this.getService(), body.id, "write");
|
||||||
|
return this.ok(await this.service.createDnsTxt(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,14 +44,28 @@ describe("AutoFix", () => {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
autoFix.legacyAcmeAccountAccessFix = {
|
||||||
|
async init() {
|
||||||
|
calls.push("legacy-acme");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
autoFix.commonEabToAcmeAccountFix = {
|
||||||
|
async init() {
|
||||||
|
calls.push("common-eab-acme");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
await autoFix.init();
|
await autoFix.init();
|
||||||
|
|
||||||
assert.deepEqual(calls, ["google", "cert", "suite"]);
|
assert.deepEqual(calls, ["google", "cert", "suite", "legacy-acme", "common-eab-acme"]);
|
||||||
assert.equal(savedSetting.fixed["google-common-eab-account-key"], true);
|
assert.equal(savedSetting.fixed["google-common-eab-account-key"], true);
|
||||||
assert.equal(savedSetting.fixed["oauth-subtype-bound-type"], true);
|
assert.equal(savedSetting.fixed["oauth-subtype-bound-type"], true);
|
||||||
assert.equal(savedSetting.fixed["cert-info-wildcard-domain-count"], true);
|
assert.equal(savedSetting.fixed["cert-info-wildcard-domain-count"], true);
|
||||||
assert.equal(savedSetting.fixed["suite-content-wildcard-domain-count"], true);
|
assert.equal(savedSetting.fixed["suite-content-wildcard-domain-count"], true);
|
||||||
|
assert.equal(savedSetting.fixed["legacy-acme-account-access"], true);
|
||||||
|
assert.equal(savedSetting.fixed["common-eab-to-acme-account"], true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes missing fixed map", async () => {
|
it("initializes missing fixed map", async () => {
|
||||||
@@ -66,6 +80,8 @@ describe("AutoFix", () => {
|
|||||||
autoFix.oauthSubtypeBoundTypeFix = { async init() {} } as any;
|
autoFix.oauthSubtypeBoundTypeFix = { async init() {} } as any;
|
||||||
autoFix.certInfoWildcardDomainCountFix = { async init() {} } as any;
|
autoFix.certInfoWildcardDomainCountFix = { async init() {} } as any;
|
||||||
autoFix.suiteContentWildcardDomainCountFix = { async init() {} } as any;
|
autoFix.suiteContentWildcardDomainCountFix = { async init() {} } as any;
|
||||||
|
autoFix.legacyAcmeAccountAccessFix = { async init() {} } as any;
|
||||||
|
autoFix.commonEabToAcmeAccountFix = { async init() {} } as any;
|
||||||
|
|
||||||
await autoFix.init();
|
await autoFix.init();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { GoogleCommonEabAccountKeyFix } from "./google-common-eab-account-key-fi
|
|||||||
import { OauthSubtypeBoundTypeFix } from "./oauth-subtype-bound-type-fix.js";
|
import { OauthSubtypeBoundTypeFix } from "./oauth-subtype-bound-type-fix.js";
|
||||||
import { CertInfoWildcardDomainCountFix } from "./cert-info-wildcard-domain-count-fix.js";
|
import { CertInfoWildcardDomainCountFix } from "./cert-info-wildcard-domain-count-fix.js";
|
||||||
import { SuiteContentWildcardDomainCountFix } from "./suite-content-wildcard-domain-count-fix.js";
|
import { SuiteContentWildcardDomainCountFix } from "./suite-content-wildcard-domain-count-fix.js";
|
||||||
|
import { LegacyAcmeAccountAccessFix } from "./legacy-acme-account-access-fix.js";
|
||||||
|
import { CommonEabToAcmeAccountFix } from "./common-eab-to-acme-account-fix.js";
|
||||||
|
|
||||||
type AutoFixTask = {
|
type AutoFixTask = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -30,6 +32,12 @@ export class AutoFix {
|
|||||||
@Inject()
|
@Inject()
|
||||||
suiteContentWildcardDomainCountFix: SuiteContentWildcardDomainCountFix;
|
suiteContentWildcardDomainCountFix: SuiteContentWildcardDomainCountFix;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
legacyAcmeAccountAccessFix: LegacyAcmeAccountAccessFix;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
commonEabToAcmeAccountFix: CommonEabToAcmeAccountFix;
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const setting = await this.sysSettingsService.getSetting<SysAutoFixSetting>(SysAutoFixSetting);
|
const setting = await this.sysSettingsService.getSetting<SysAutoFixSetting>(SysAutoFixSetting);
|
||||||
setting.fixed = setting.fixed || {};
|
setting.fixed = setting.fixed || {};
|
||||||
@@ -50,6 +58,14 @@ export class AutoFix {
|
|||||||
key: "suite-content-wildcard-domain-count",
|
key: "suite-content-wildcard-domain-count",
|
||||||
fix: this.suiteContentWildcardDomainCountFix,
|
fix: this.suiteContentWildcardDomainCountFix,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "legacy-acme-account-access",
|
||||||
|
fix: this.legacyAcmeAccountAccessFix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "common-eab-to-acme-account",
|
||||||
|
fix: this.commonEabToAcmeAccountFix,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from "assert";
|
||||||
|
import { buildLegacyCommonEabAccountStorageWhere, CommonEabToAcmeAccountFix, parseEabAccountKey } from "./common-eab-to-acme-account-fix.js";
|
||||||
|
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
|
||||||
|
|
||||||
|
describe("CommonEabToAcmeAccountFix", () => {
|
||||||
|
it("parses legacy EAB account key payload", () => {
|
||||||
|
assert.equal(
|
||||||
|
parseEabAccountKey(
|
||||||
|
JSON.stringify({
|
||||||
|
kid: "kid-1",
|
||||||
|
privateKey: "private-key",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
"private-key"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds legacy common EAB account storage query", () => {
|
||||||
|
assert.deepEqual(buildLegacyCommonEabAccountStorageWhere("google", 12), {
|
||||||
|
userId: 0,
|
||||||
|
scope: "user",
|
||||||
|
namespace: "0",
|
||||||
|
key: "acme.config.google.access.12",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates common acme account from common eab and legacy storage", async () => {
|
||||||
|
let addParam: any;
|
||||||
|
const fix = new CommonEabToAcmeAccountFix();
|
||||||
|
fix.accessService = {
|
||||||
|
async getAccessById(id: number) {
|
||||||
|
assert.equal(id, 12);
|
||||||
|
return {
|
||||||
|
accountKey: JSON.stringify({
|
||||||
|
privateKey: "private-key",
|
||||||
|
}),
|
||||||
|
email: "common@example.com",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async findOne() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async add(param: any) {
|
||||||
|
addParam = param;
|
||||||
|
return { id: 99 };
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
fix.storageService = {
|
||||||
|
getRepository() {
|
||||||
|
return {
|
||||||
|
async findOne(options: any) {
|
||||||
|
assert.deepEqual(options.where, buildLegacyCommonEabAccountStorageWhere("google", 12));
|
||||||
|
return {
|
||||||
|
value: JSON.stringify({
|
||||||
|
value: {
|
||||||
|
accountUrl: "https://example.com/acct/1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
|
||||||
|
|
||||||
|
assert.equal(id, 99);
|
||||||
|
assert.equal(addParam.userId, 0);
|
||||||
|
assert.equal(addParam.type, "acmeAccount");
|
||||||
|
const setting = JSON.parse(addParam.setting);
|
||||||
|
const account = JSON.parse(setting.account);
|
||||||
|
assert.equal(account.accountKey, "private-key");
|
||||||
|
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates common acme account by resolving account uri from eab private key", async () => {
|
||||||
|
const original = AcmeService.prototype.getAcmeClient;
|
||||||
|
const calls: string[] = [];
|
||||||
|
AcmeService.prototype.getAcmeClient = async function (email: string) {
|
||||||
|
calls.push(email);
|
||||||
|
return {
|
||||||
|
getAccountUrl() {
|
||||||
|
return "https://example.com/acct/generated";
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let addParam: any;
|
||||||
|
const fix = new CommonEabToAcmeAccountFix();
|
||||||
|
fix.accessService = {
|
||||||
|
async getAccessById(id: number) {
|
||||||
|
assert.equal(id, 12);
|
||||||
|
return {
|
||||||
|
id: 12,
|
||||||
|
kid: "kid-1",
|
||||||
|
hmacKey: "hmac-1",
|
||||||
|
accountKey: JSON.stringify({
|
||||||
|
kid: "kid-1",
|
||||||
|
privateKey: "private-key",
|
||||||
|
}),
|
||||||
|
email: "common@example.com",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async findOne() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async add(param: any) {
|
||||||
|
addParam = param;
|
||||||
|
return { id: 100 };
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
fix.storageService = {
|
||||||
|
getRepository() {
|
||||||
|
return {
|
||||||
|
async findOne() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
|
||||||
|
|
||||||
|
assert.equal(id, 100);
|
||||||
|
assert.deepEqual(calls, ["common@example.com"]);
|
||||||
|
const setting = JSON.parse(addParam.setting);
|
||||||
|
const account = JSON.parse(setting.account);
|
||||||
|
assert.equal(account.accountKey, "private-key");
|
||||||
|
assert.equal(account.accountUri, "https://example.com/acct/generated");
|
||||||
|
} finally {
|
||||||
|
AcmeService.prototype.getAcmeClient = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { logger } from "@certd/basic";
|
||||||
|
import { AccessService } from "@certd/lib-server";
|
||||||
|
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||||
|
import { PluginConfigService } from "../../plugin/service/plugin-config-service.js";
|
||||||
|
import { StorageService } from "../../pipeline/service/storage-service.js";
|
||||||
|
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
|
||||||
|
import { buildAcmeAccountSetting, LegacyAcmeAccountConfig } from "./legacy-acme-account-access-fix.js";
|
||||||
|
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
|
||||||
|
|
||||||
|
const COMMON_EAB_TO_ACME_ACCOUNT_FIELDS = [
|
||||||
|
{
|
||||||
|
caType: "google",
|
||||||
|
eabField: "googleCommonEabAccessId",
|
||||||
|
acmeField: "googleCommonAcmeAccountAccessId",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caType: "zerossl",
|
||||||
|
eabField: "zerosslCommonEabAccessId",
|
||||||
|
acmeField: "zerosslCommonAcmeAccountAccessId",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caType: "sslcom",
|
||||||
|
eabField: "sslcomCommonEabAccessId",
|
||||||
|
acmeField: "sslcomCommonAcmeAccountAccessId",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caType: "litessl",
|
||||||
|
eabField: "litesslCommonEabAccessId",
|
||||||
|
acmeField: "litesslCommonAcmeAccountAccessId",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseEabAccountKey(accountKey?: string) {
|
||||||
|
if (!accountKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(accountKey);
|
||||||
|
return parsed?.privateKey || parsed?.accountKey || parsed?.key || accountKey;
|
||||||
|
} catch {
|
||||||
|
return accountKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLegacyCommonEabAccountStorageWhere(caType: string, accessId: number) {
|
||||||
|
return {
|
||||||
|
userId: 0,
|
||||||
|
scope: "user",
|
||||||
|
namespace: "0",
|
||||||
|
key: `acme.config.${caType}.access.${accessId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||||
|
export class CommonEabToAcmeAccountFix {
|
||||||
|
@Inject()
|
||||||
|
pluginConfigService: PluginConfigService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
accessService: AccessService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
storageService: StorageService;
|
||||||
|
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const certApplyConfig = await this.pluginConfigService.getPluginConfig({
|
||||||
|
name: "CertApply",
|
||||||
|
type: "builtIn",
|
||||||
|
});
|
||||||
|
const input = certApplyConfig.sysSetting.input || {};
|
||||||
|
let changed = false;
|
||||||
|
for (const item of COMMON_EAB_TO_ACME_ACCOUNT_FIELDS) {
|
||||||
|
if (input[item.acmeField]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const eabAccessId = input[item.eabField];
|
||||||
|
if (!eabAccessId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const acmeAccessId = await this.createCommonAcmeAccountFromEab(item.caType, eabAccessId);
|
||||||
|
if (acmeAccessId) {
|
||||||
|
input[item.acmeField] = acmeAccessId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await this.pluginConfigService.savePluginConfig({
|
||||||
|
name: "CertApply",
|
||||||
|
disabled: certApplyConfig.disabled,
|
||||||
|
sysSetting: {
|
||||||
|
...certApplyConfig.sysSetting,
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("公共EAB迁移为公共ACME账号失败", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommonAcmeAccountFromEab(caType: string, eabAccessId: number) {
|
||||||
|
const eabAccess = await this.accessService.getAccessById(eabAccessId, false);
|
||||||
|
const privateKey = parseEabAccountKey(eabAccess.accountKey);
|
||||||
|
const accountConfig = await this.getLegacyCommonEabAccountConfig(caType, eabAccessId);
|
||||||
|
const accountUri = await this.resolveAccountUriByPrivateKey(caType, eabAccess, accountConfig?.accountUri || accountConfig?.accountUrl);
|
||||||
|
if (!privateKey || !accountUri) {
|
||||||
|
logger.info(`公共${caType} EAB缺少可迁移的accountKey或无法获取accountUri,跳过生成公共ACME账号`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const email = eabAccess.email || `${caType}@common.certd.local`;
|
||||||
|
const exists = await this.accessService.findOne({
|
||||||
|
where: {
|
||||||
|
userId: 0,
|
||||||
|
projectId: null,
|
||||||
|
type: "acmeAccount",
|
||||||
|
subtype: caType,
|
||||||
|
name: `公共${caType} ACME账号`,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
return exists.id;
|
||||||
|
}
|
||||||
|
const setting = buildAcmeAccountSetting({
|
||||||
|
caType,
|
||||||
|
email,
|
||||||
|
config: {
|
||||||
|
privateKey,
|
||||||
|
accountUri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { id } = await this.accessService.add({
|
||||||
|
userId: 0,
|
||||||
|
projectId: null,
|
||||||
|
type: "acmeAccount",
|
||||||
|
name: `公共${caType} ACME账号`,
|
||||||
|
setting: JSON.stringify(setting),
|
||||||
|
});
|
||||||
|
logger.info(`已根据公共${caType} EAB生成公共ACME账号,accessId=${id}`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveAccountUriByPrivateKey(caType: string, eabAccess: any, accountUri?: string | null) {
|
||||||
|
if (accountUri) {
|
||||||
|
return accountUri;
|
||||||
|
}
|
||||||
|
const privateKey = parseEabAccountKey(eabAccess.accountKey);
|
||||||
|
if (!privateKey || !eabAccess?.kid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const acmeService = new AcmeService({
|
||||||
|
userId: 0,
|
||||||
|
userContext: {
|
||||||
|
async getObj() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async setObj() {},
|
||||||
|
} as any,
|
||||||
|
logger: logger as any,
|
||||||
|
sslProvider: caType as any,
|
||||||
|
eab: {
|
||||||
|
id: eabAccess.id || eabAccess.accessId || eabAccess.eabAccessId || 0,
|
||||||
|
kid: eabAccess.kid,
|
||||||
|
hmacKey: eabAccess.hmacKey,
|
||||||
|
accountKey: JSON.stringify({
|
||||||
|
kid: eabAccess.kid,
|
||||||
|
privateKey,
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
domainParser: {} as any,
|
||||||
|
privateKeyType: "rsa_2048",
|
||||||
|
});
|
||||||
|
const client = await acmeService.getAcmeClient(eabAccess.email || `${caType}@common.certd.local`);
|
||||||
|
return client.getAccountUrl() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLegacyCommonEabAccountConfig(caType: string, accessId: number): Promise<LegacyAcmeAccountConfig | null> {
|
||||||
|
const repository = this.storageService.getRepository();
|
||||||
|
const record = await repository.findOne({
|
||||||
|
where: buildLegacyCommonEabAccountStorageWhere(caType, accessId),
|
||||||
|
});
|
||||||
|
return parseStorageValue(record?.value) as LegacyAcmeAccountConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "assert";
|
||||||
|
import { buildAcmeAccountAccessName, buildAcmeAccountSetting, maskAcmeAccountEmail, parseLegacyAcmeStorageKey } from "./legacy-acme-account-access-fix.js";
|
||||||
|
|
||||||
|
describe("LegacyAcmeAccountAccessFix", () => {
|
||||||
|
it("parses legacy storage account key", () => {
|
||||||
|
assert.deepEqual(parseLegacyAcmeStorageKey("acme.config.letsencrypt.user@example.com"), {
|
||||||
|
caType: "letsencrypt",
|
||||||
|
email: "user@example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips EAB access cache keys", () => {
|
||||||
|
assert.equal(parseLegacyAcmeStorageKey("acme.config.google.access.12"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds acme account access setting from legacy config", () => {
|
||||||
|
const setting = buildAcmeAccountSetting({
|
||||||
|
caType: "letsencrypt",
|
||||||
|
email: "user@example.com",
|
||||||
|
config: {
|
||||||
|
key: "private-key",
|
||||||
|
accountUrl: "https://example.com/acct/1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setting.caType, "letsencrypt");
|
||||||
|
const account = JSON.parse(setting.account);
|
||||||
|
assert.equal(account.accountKey, "private-key");
|
||||||
|
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds masked acme account access name", () => {
|
||||||
|
assert.equal(maskAcmeAccountEmail("xiaojunnuo@qq.com"), "xi*******qq.com");
|
||||||
|
assert.equal(buildAcmeAccountAccessName("zerossl", "xiaojunnuo@qq.com"), "zerossl-acme-xi*******qq.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips incomplete legacy config", () => {
|
||||||
|
const setting = buildAcmeAccountSetting({
|
||||||
|
caType: "letsencrypt",
|
||||||
|
email: "user@example.com",
|
||||||
|
config: {
|
||||||
|
key: "private-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setting, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { logger } from "@certd/basic";
|
||||||
|
import { AccessService } from "@certd/lib-server";
|
||||||
|
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import { StorageService } from "../../pipeline/service/storage-service.js";
|
||||||
|
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
|
||||||
|
|
||||||
|
export type LegacyAcmeAccountConfig = {
|
||||||
|
key?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
accountKey?: string;
|
||||||
|
accountUrl?: string;
|
||||||
|
accountUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseLegacyAcmeStorageKey(key: string) {
|
||||||
|
const match = /^acme\.config\.([^.]+)\.(.+)$/.exec(key);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (match[2].startsWith("access.")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
caType: match[1],
|
||||||
|
email: match[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAcmeAccountSetting(req: { caType: string; email: string; config: LegacyAcmeAccountConfig }) {
|
||||||
|
const accountKey = req.config.privateKey || req.config.key || req.config.accountKey;
|
||||||
|
const accountUri = req.config.accountUri || req.config.accountUrl;
|
||||||
|
if (!accountKey || !accountUri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
caType: req.caType,
|
||||||
|
email: req.email,
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey,
|
||||||
|
accountUri,
|
||||||
|
caType: req.caType,
|
||||||
|
email: req.email,
|
||||||
|
directoryUrl: "",
|
||||||
|
migratedFrom: "legacy-storage",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskAcmeAccountEmail(email: string) {
|
||||||
|
if (!email) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const atIndex = email.indexOf("@");
|
||||||
|
if (atIndex < 0) {
|
||||||
|
return email.length <= 2 ? `${email[0] || ""}*******` : `${email.substring(0, 2)}*******`;
|
||||||
|
}
|
||||||
|
const name = email.substring(0, atIndex);
|
||||||
|
const domain = email.substring(atIndex + 1);
|
||||||
|
const prefix = name.substring(0, Math.min(2, name.length));
|
||||||
|
return `${prefix}*******${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAcmeAccountAccessName(caType: string, email: string) {
|
||||||
|
return `${caType}-acme-${maskAcmeAccountEmail(email)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||||
|
export class LegacyAcmeAccountAccessFix {
|
||||||
|
@Inject()
|
||||||
|
storageService: StorageService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
accessService: AccessService;
|
||||||
|
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const repository = this.storageService.getRepository();
|
||||||
|
const records = await repository.find({
|
||||||
|
where: {
|
||||||
|
scope: "user",
|
||||||
|
key: Like("acme.config.%"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
for (const record of records) {
|
||||||
|
const parsedKey = parseLegacyAcmeStorageKey(record.key);
|
||||||
|
if (!parsedKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const config = parseStorageValue(record.value) as LegacyAcmeAccountConfig;
|
||||||
|
const setting = buildAcmeAccountSetting({
|
||||||
|
...parsedKey,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
if (!setting) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = buildAcmeAccountAccessName(parsedKey.caType, parsedKey.email);
|
||||||
|
const exists = await this.accessService.findOne({
|
||||||
|
where: {
|
||||||
|
userId: record.userId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
type: "acmeAccount",
|
||||||
|
subtype: parsedKey.caType,
|
||||||
|
name,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.accessService.add({
|
||||||
|
userId: record.userId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
type: "acmeAccount",
|
||||||
|
subtype: parsedKey.caType,
|
||||||
|
name,
|
||||||
|
setting: JSON.stringify(setting),
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
logger.info(`旧ACME账号迁移完成,生成${count}个ACME账号授权`);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("旧ACME账号迁移失败", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity("cd_dns_persist_record")
|
||||||
|
export class DnsPersistRecordEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ name: "user_id" })
|
||||||
|
userId: number;
|
||||||
|
|
||||||
|
@Column({ name: "project_id", nullable: true })
|
||||||
|
projectId: number;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
domain: string;
|
||||||
|
|
||||||
|
@Column({ name: "main_domain", length: 255 })
|
||||||
|
mainDomain: string;
|
||||||
|
|
||||||
|
@Column({ name: "ca_type", length: 50 })
|
||||||
|
caType: string;
|
||||||
|
|
||||||
|
@Column({ name: "acme_account_access_id" })
|
||||||
|
acmeAccountAccessId: number;
|
||||||
|
|
||||||
|
@Column({ name: "account_uri", length: 512 })
|
||||||
|
accountUri: string;
|
||||||
|
|
||||||
|
@Column({ name: "host_record", length: 255 })
|
||||||
|
hostRecord: string;
|
||||||
|
|
||||||
|
@Column({ name: "record_value", type: "text" })
|
||||||
|
recordValue: string;
|
||||||
|
|
||||||
|
@Column({ length: 50, nullable: true })
|
||||||
|
policy: string;
|
||||||
|
|
||||||
|
@Column({ name: "persist_until", nullable: true })
|
||||||
|
persistUntil: number;
|
||||||
|
|
||||||
|
@Column({ length: 50 })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: "dns_provider_type", length: 50, nullable: true })
|
||||||
|
dnsProviderType: string;
|
||||||
|
|
||||||
|
@Column({ name: "dns_provider_access", nullable: true })
|
||||||
|
dnsProviderAccess: number;
|
||||||
|
|
||||||
|
@Column({ name: "record_res", type: "text", nullable: true })
|
||||||
|
recordRes: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
disabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: "create_time", default: () => "CURRENT_TIMESTAMP" })
|
||||||
|
createTime: Date;
|
||||||
|
|
||||||
|
@Column({ name: "update_time", default: () => "CURRENT_TIMESTAMP" })
|
||||||
|
updateTime: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import assert from "assert";
|
||||||
|
import { buildDnsPersistRecordValue, DnsPersistRecordService } from "./dns-persist-record-service.js";
|
||||||
|
|
||||||
|
describe("DnsPersistRecordService", () => {
|
||||||
|
it("builds dns-persist-01 record value", () => {
|
||||||
|
const value = buildDnsPersistRecordValue({
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
wildcard: true,
|
||||||
|
persistUntil: 1893456000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(value, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard; persistUntil=1893456000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds validation host from wildcard domain", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
|
||||||
|
const record = await service.buildRecord({
|
||||||
|
domain: "*.example.com",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
wildcard: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(record.hostRecord, "_validation-persist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds relative validation host from subdomain", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
|
||||||
|
const record = await service.buildRecord({
|
||||||
|
domain: "aaa.handsfree.work",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(record.hostRecord, "_validation-persist.aaa");
|
||||||
|
assert.equal(record.mainDomain, "handsfree.work");
|
||||||
|
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds dns-persist record from acme account access", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
(service as any).accessService = {
|
||||||
|
async getAccessById(id: number, checkUser: boolean, userId?: number) {
|
||||||
|
assert.equal(id, 12);
|
||||||
|
assert.equal(checkUser, true);
|
||||||
|
assert.equal(userId, 1);
|
||||||
|
return {
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "zerossl",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = await service.buildRecordByAcmeAccount({
|
||||||
|
domain: "*.example.com",
|
||||||
|
caType: "zerossl",
|
||||||
|
acmeAccountAccessId: 12,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(record.domain, "example.com");
|
||||||
|
assert.equal(record.caType, "zerossl");
|
||||||
|
assert.equal(record.acmeAccountAccessId, 12);
|
||||||
|
assert.equal(record.accountUri, "https://example.com/acct/1");
|
||||||
|
assert.equal(record.hostRecord, "_validation-persist");
|
||||||
|
assert.equal(record.mainDomain, "example.com");
|
||||||
|
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||||
|
assert.equal(record.policy, "wildcard");
|
||||||
|
assert.equal(record.status, "pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mismatched ca type", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
(service as any).accessService = {
|
||||||
|
async getAccessById() {
|
||||||
|
return {
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "google",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
service.buildRecordByAcmeAccount({
|
||||||
|
domain: "example.com",
|
||||||
|
caType: "zerossl",
|
||||||
|
acmeAccountAccessId: 12,
|
||||||
|
userId: 1,
|
||||||
|
}),
|
||||||
|
/颁发机构不匹配/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns full local record after add and triggers auto create hook", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
let saved: any = null;
|
||||||
|
let autoCreateId: number | null = null;
|
||||||
|
(service as any).repository = {
|
||||||
|
async save(param: any) {
|
||||||
|
param.id = 77;
|
||||||
|
saved = { ...param };
|
||||||
|
},
|
||||||
|
async findOneBy(where: any) {
|
||||||
|
return where.id === 77 ? saved : null;
|
||||||
|
},
|
||||||
|
async findOne() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(service as any).accessService = {
|
||||||
|
async getAccessById() {
|
||||||
|
return {
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(service as any).tryAutoCreateDnsTxt = async (id: number) => {
|
||||||
|
autoCreateId = id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const record: any = await service.add({
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
acmeAccountAccessId: 1,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(autoCreateId, 77);
|
||||||
|
assert.equal(record.id, 77);
|
||||||
|
assert.equal(record.hostRecord, "_validation-persist");
|
||||||
|
assert.equal(record.mainDomain, "example.com");
|
||||||
|
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||||
|
assert.equal(record.status, "pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses existing record for the same domain and acme account", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
let saveCount = 0;
|
||||||
|
const exists = {
|
||||||
|
id: 88,
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
acmeAccountAccessId: 1,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
hostRecord: "_validation-persist",
|
||||||
|
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||||
|
policy: "wildcard",
|
||||||
|
status: "valid",
|
||||||
|
};
|
||||||
|
(service as any).repository = {
|
||||||
|
async save() {
|
||||||
|
saveCount++;
|
||||||
|
},
|
||||||
|
async findOne(options: any) {
|
||||||
|
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? exists : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(service as any).accessService = {
|
||||||
|
async getAccessById() {
|
||||||
|
return {
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const record: any = await service.add({
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
acmeAccountAccessId: 1,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(saveCount, 0);
|
||||||
|
assert.equal(record.id, 88);
|
||||||
|
assert.equal(record.status, "valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades existing non-wildcard record to wildcard and pending", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
let saved: any = {
|
||||||
|
id: 89,
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
acmeAccountAccessId: 1,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
hostRecord: "_validation-persist",
|
||||||
|
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1",
|
||||||
|
policy: null,
|
||||||
|
status: "valid",
|
||||||
|
recordRes: JSON.stringify({ old: true }),
|
||||||
|
};
|
||||||
|
(service as any).repository = {
|
||||||
|
async save(param: any) {
|
||||||
|
saved = { ...saved, ...param };
|
||||||
|
},
|
||||||
|
async findOne(options: any) {
|
||||||
|
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? saved : null;
|
||||||
|
},
|
||||||
|
async findOneBy(where: any) {
|
||||||
|
return where.id === 89 ? saved : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(service as any).accessService = {
|
||||||
|
async getAccessById() {
|
||||||
|
return {
|
||||||
|
account: JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const record: any = await service.add({
|
||||||
|
domain: "example.com",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
acmeAccountAccessId: 1,
|
||||||
|
userId: 1,
|
||||||
|
projectId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(record.id, 89);
|
||||||
|
assert.equal(record.policy, "wildcard");
|
||||||
|
assert.equal(record.status, "pending");
|
||||||
|
assert.equal(record.mainDomain, "example.com");
|
||||||
|
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||||
|
assert.equal(record.recordRes, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns manual cleanup hint when deleting record", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
let deletedIds: any = null;
|
||||||
|
(service as any).repository = {
|
||||||
|
async findOneBy(where: any) {
|
||||||
|
return where.id === 90
|
||||||
|
? {
|
||||||
|
id: 90,
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
hostRecord: "_validation-persist",
|
||||||
|
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||||
|
recordRes: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
async delete(ids: any) {
|
||||||
|
deletedIds = ids;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.delete([90]);
|
||||||
|
|
||||||
|
assert.deepEqual(deletedIds.id._value, [90]);
|
||||||
|
assert.match(service.lastDeleteMessage, /请到域名供应商删除TXT记录/);
|
||||||
|
assert.match(service.lastDeleteMessage, /_validation-persist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers dns-persist verification asynchronously", async () => {
|
||||||
|
const service = new DnsPersistRecordService();
|
||||||
|
const savedStatuses: string[] = [];
|
||||||
|
let saved: any = {
|
||||||
|
id: 91,
|
||||||
|
domain: "example.com",
|
||||||
|
mainDomain: "example.com",
|
||||||
|
hostRecord: "_validation-persist",
|
||||||
|
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
(service as any).repository = {
|
||||||
|
async findOneBy(where: any) {
|
||||||
|
return where.id === 91 ? saved : null;
|
||||||
|
},
|
||||||
|
async save(param: any) {
|
||||||
|
saved = { ...saved, ...param };
|
||||||
|
savedStatuses.push(saved.status);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(service as any).checkRecord = async () => true;
|
||||||
|
|
||||||
|
const triggered = await service.triggerVerify(91);
|
||||||
|
|
||||||
|
assert.equal(triggered, true);
|
||||||
|
assert.equal(saved.status, "validating");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(savedStatuses, ["validating", "valid"]);
|
||||||
|
assert.equal(saved.status, "valid");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { BaseService } from "@certd/lib-server";
|
||||||
|
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||||
|
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||||
|
import { In, Repository } from "typeorm";
|
||||||
|
import { createChallengeFn } from "@certd/acme-client";
|
||||||
|
import { AccessService } from "@certd/lib-server";
|
||||||
|
import { http, logger, utils } from "@certd/basic";
|
||||||
|
import { createDnsProvider, DomainParser } from "@certd/plugin-lib";
|
||||||
|
import { DnsPersistRecordEntity } from "../entity/dns-persist-record.js";
|
||||||
|
import { TaskServiceBuilder } from "../../pipeline/service/getter/task-service-getter.js";
|
||||||
|
import { DomainEntity } from "../entity/domain.js";
|
||||||
|
|
||||||
|
export function buildDnsPersistRecordValue(req: { issuer?: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
|
||||||
|
const parts = [req.issuer || "letsencrypt.org", `accounturi=${req.accountUri}`];
|
||||||
|
if (req.wildcard !== false) {
|
||||||
|
parts.push("policy=wildcard");
|
||||||
|
}
|
||||||
|
if (req.persistUntil) {
|
||||||
|
parts.push(`persistUntil=${req.persistUntil}`);
|
||||||
|
}
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DnsPersistRecordBuildReq = {
|
||||||
|
domain: string;
|
||||||
|
caType?: string;
|
||||||
|
acmeAccountAccessId?: number;
|
||||||
|
commonAcmeAccountAccessId?: number;
|
||||||
|
wildcard?: boolean;
|
||||||
|
persistUntil?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||||
|
export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity> {
|
||||||
|
@InjectEntityModel(DnsPersistRecordEntity)
|
||||||
|
repository: Repository<DnsPersistRecordEntity>;
|
||||||
|
|
||||||
|
@InjectEntityModel(DomainEntity)
|
||||||
|
domainRepository: Repository<DomainEntity>;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
accessService: AccessService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
taskServiceBuilder: TaskServiceBuilder;
|
||||||
|
|
||||||
|
lastDeleteMessage = "";
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
getRepository() {
|
||||||
|
return this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDomain(domain: string) {
|
||||||
|
return domain?.replace(/^\*\./, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseMainDomain(domain: string, userId?: number, projectId?: number) {
|
||||||
|
if (this.taskServiceBuilder && userId != null) {
|
||||||
|
const taskService = this.taskServiceBuilder.create({ userId, projectId });
|
||||||
|
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||||
|
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||||
|
return await domainParser.parse(domain);
|
||||||
|
}
|
||||||
|
const parts = domain.split(".");
|
||||||
|
return parts.length > 2 ? parts.slice(-2).join(".") : domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRelativeHostRecord(domain: string, mainDomain: string) {
|
||||||
|
let prefix = domain;
|
||||||
|
if (domain === mainDomain) {
|
||||||
|
prefix = "";
|
||||||
|
} else if (domain.endsWith(`.${mainDomain}`)) {
|
||||||
|
prefix = domain.substring(0, domain.length - mainDomain.length - 1);
|
||||||
|
}
|
||||||
|
return prefix ? `_validation-persist.${prefix}` : "_validation-persist";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFullHostRecord(record: Pick<DnsPersistRecordEntity, "domain" | "hostRecord" | "userId" | "projectId" | "mainDomain">) {
|
||||||
|
if (record.hostRecord === `_validation-persist.${record.domain}` || record.hostRecord.endsWith(`.${record.domain}`)) {
|
||||||
|
return record.hostRecord;
|
||||||
|
}
|
||||||
|
const mainDomain = record.mainDomain || (await this.parseMainDomain(record.domain, record.userId, record.projectId));
|
||||||
|
return `${record.hostRecord}.${mainDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAcmeAccount(account: string | any) {
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
|
||||||
|
}
|
||||||
|
const parsed = typeof account === "string" ? JSON.parse(account) : account;
|
||||||
|
if (!parsed.accountKey || !parsed.accountUri) {
|
||||||
|
throw new Error("ACME账号授权无效,请重新生成ACME账号");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
|
||||||
|
const accessId = req.acmeAccountAccessId || req.commonAcmeAccountAccessId;
|
||||||
|
if (!accessId) {
|
||||||
|
throw new Error("请选择ACME账号授权");
|
||||||
|
}
|
||||||
|
let access: any;
|
||||||
|
if (req.commonAcmeAccountAccessId) {
|
||||||
|
const entity = await this.accessService.info(accessId);
|
||||||
|
if (!entity || entity.userId !== 0 || entity.type !== "acmeAccount") {
|
||||||
|
throw new Error("公共ACME账号授权不存在");
|
||||||
|
}
|
||||||
|
access = await this.accessService.getAccessById(accessId, false);
|
||||||
|
} else {
|
||||||
|
access = await this.accessService.getAccessById(accessId, true, req.userId, req.projectId);
|
||||||
|
}
|
||||||
|
const account = this.parseAcmeAccount(access.account);
|
||||||
|
const caType = req.caType || account.caType;
|
||||||
|
if (caType && account.caType && caType !== account.caType) {
|
||||||
|
throw new Error("ACME账号授权与颁发机构不匹配");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessId,
|
||||||
|
caType,
|
||||||
|
account,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildRecord(req: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
|
||||||
|
const domain = this.normalizeDomain(req.domain);
|
||||||
|
const mainDomain = await this.parseMainDomain(domain, req.userId, req.projectId);
|
||||||
|
return {
|
||||||
|
mainDomain,
|
||||||
|
hostRecord: this.buildRelativeHostRecord(domain, mainDomain),
|
||||||
|
recordValue: buildDnsPersistRecordValue({
|
||||||
|
...req,
|
||||||
|
wildcard: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildRecordByAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
|
||||||
|
const { account, caType, accessId } = await this.getAcmeAccount(req);
|
||||||
|
const record = await this.buildRecord({
|
||||||
|
domain: req.domain,
|
||||||
|
accountUri: account.accountUri,
|
||||||
|
wildcard: true,
|
||||||
|
persistUntil: req.persistUntil,
|
||||||
|
userId: req.userId,
|
||||||
|
projectId: req.projectId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
domain: this.normalizeDomain(req.domain),
|
||||||
|
mainDomain: record.mainDomain,
|
||||||
|
caType,
|
||||||
|
acmeAccountAccessId: accessId,
|
||||||
|
accountUri: account.accountUri,
|
||||||
|
policy: "wildcard",
|
||||||
|
persistUntil: req.persistUntil,
|
||||||
|
status: "pending",
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async add(param: any) {
|
||||||
|
const record = await this.buildRecordByAcmeAccount(param);
|
||||||
|
const exists = await this.findOne({
|
||||||
|
where: {
|
||||||
|
domain: record.domain,
|
||||||
|
caType: record.caType,
|
||||||
|
acmeAccountAccessId: record.acmeAccountAccessId,
|
||||||
|
userId: param.userId,
|
||||||
|
projectId: param.projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
if (exists.policy !== "wildcard") {
|
||||||
|
await this.upgradeToWildcardRecord(exists, record);
|
||||||
|
return await this.info(exists.id);
|
||||||
|
}
|
||||||
|
if (exists.status !== "valid" && !exists.dnsProviderAccess) {
|
||||||
|
await this.tryAutoCreateDnsTxt(exists.id);
|
||||||
|
return await this.info(exists.id);
|
||||||
|
}
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
const result = await super.add({
|
||||||
|
...param,
|
||||||
|
...record,
|
||||||
|
});
|
||||||
|
await this.tryAutoCreateDnsTxt(result.id);
|
||||||
|
return await this.info(result.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(param: any) {
|
||||||
|
const old = await this.info(param.id);
|
||||||
|
if (!old) {
|
||||||
|
throw new Error("DNS持久验证记录不存在");
|
||||||
|
}
|
||||||
|
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
|
||||||
|
const record = await this.buildRecordByAcmeAccount({
|
||||||
|
domain: param.domain || old.domain,
|
||||||
|
caType: param.caType || old.caType,
|
||||||
|
acmeAccountAccessId: param.acmeAccountAccessId || old.acmeAccountAccessId,
|
||||||
|
commonAcmeAccountAccessId: param.commonAcmeAccountAccessId,
|
||||||
|
wildcard: true,
|
||||||
|
persistUntil: param.persistUntil ?? old.persistUntil,
|
||||||
|
userId: old.userId,
|
||||||
|
projectId: old.projectId,
|
||||||
|
});
|
||||||
|
param = {
|
||||||
|
...param,
|
||||||
|
...record,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await super.update(param);
|
||||||
|
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
|
||||||
|
await this.tryAutoCreateDnsTxt(param.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkRecord(req: { hostRecord: string; recordValue: string }) {
|
||||||
|
const { walkTxtRecord } = createChallengeFn();
|
||||||
|
const values = await walkTxtRecord(req.hostRecord);
|
||||||
|
return values.includes(req.recordValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByDomain(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number; createOnNotFound?: boolean }) {
|
||||||
|
const account = await this.getAcmeAccount(req);
|
||||||
|
const domain = this.normalizeDomain(req.domain);
|
||||||
|
let record = await this.findOne({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
caType: account.caType,
|
||||||
|
acmeAccountAccessId: account.accessId,
|
||||||
|
userId: req.userId,
|
||||||
|
projectId: req.projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!record && req.createOnNotFound) {
|
||||||
|
record = await this.add({
|
||||||
|
...req,
|
||||||
|
domain,
|
||||||
|
caType: account.caType,
|
||||||
|
acmeAccountAccessId: account.accessId,
|
||||||
|
});
|
||||||
|
} else if (record && record.policy !== "wildcard") {
|
||||||
|
const wildcardRecord = await this.buildRecordByAcmeAccount({
|
||||||
|
...req,
|
||||||
|
domain,
|
||||||
|
caType: account.caType,
|
||||||
|
acmeAccountAccessId: account.accessId,
|
||||||
|
persistUntil: req.persistUntil ?? record.persistUntil,
|
||||||
|
});
|
||||||
|
await this.upgradeToWildcardRecord(record, wildcardRecord);
|
||||||
|
record = await this.info(record.id);
|
||||||
|
} else if (record && record.status !== "valid" && !record.dnsProviderAccess) {
|
||||||
|
await this.tryAutoCreateDnsTxt(record.id);
|
||||||
|
record = await this.info(record.id);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upgradeToWildcardRecord(exists: DnsPersistRecordEntity, wildcardRecord: Partial<DnsPersistRecordEntity>) {
|
||||||
|
await super.update({
|
||||||
|
id: exists.id,
|
||||||
|
hostRecord: wildcardRecord.hostRecord,
|
||||||
|
recordValue: wildcardRecord.recordValue,
|
||||||
|
mainDomain: wildcardRecord.mainDomain,
|
||||||
|
policy: "wildcard",
|
||||||
|
persistUntil: wildcardRecord.persistUntil ?? exists.persistUntil,
|
||||||
|
status: "pending",
|
||||||
|
recordRes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(id: number) {
|
||||||
|
const record = await this.info(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("DNS持久验证记录不存在");
|
||||||
|
}
|
||||||
|
const ok = await this.checkRecord({
|
||||||
|
hostRecord: await this.buildFullHostRecord(record),
|
||||||
|
recordValue: record.recordValue,
|
||||||
|
});
|
||||||
|
await this.update({
|
||||||
|
id: record.id,
|
||||||
|
status: ok ? "valid" : "failed",
|
||||||
|
});
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerVerify(id: number) {
|
||||||
|
await super.update({
|
||||||
|
id,
|
||||||
|
status: "validating",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
this.verify(id).catch(async (e: any) => {
|
||||||
|
logger.error(`DNS持久验证记录后台校验失败:${e.message || e}`);
|
||||||
|
await super.update({
|
||||||
|
id,
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findDomainDnsProvider(record: DnsPersistRecordEntity) {
|
||||||
|
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
|
||||||
|
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||||
|
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||||
|
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
|
||||||
|
const domains = [...new Set([record.domain, mainDomain].filter(Boolean))];
|
||||||
|
const list = await this.domainRepository.find({
|
||||||
|
where: {
|
||||||
|
domain: In(domains),
|
||||||
|
userId: record.userId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
challengeType: "dns",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const matched = list.find(item => item.domain === record.domain) || list.find(item => item.domain === mainDomain);
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dnsProviderType: matched.dnsProviderType,
|
||||||
|
dnsProviderAccess: matched.dnsProviderAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveDnsProvider(record: DnsPersistRecordEntity, req: { dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||||
|
if (req.dnsProviderType && req.dnsProviderAccess) {
|
||||||
|
return {
|
||||||
|
dnsProviderType: req.dnsProviderType,
|
||||||
|
dnsProviderAccess: req.dnsProviderAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const provider = await this.findDomainDnsProvider(record);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("未找到该域名在域名管理中的DNS授权配置,请手动选择DNS服务商和授权");
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAutoCreateDnsTxt(id: number) {
|
||||||
|
const record = await this.info(id);
|
||||||
|
if (!record || record.status === "valid") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = await this.findDomainDnsProvider(record);
|
||||||
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.createDnsTxt({
|
||||||
|
id,
|
||||||
|
...provider,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
await super.update({
|
||||||
|
id,
|
||||||
|
status: "failed",
|
||||||
|
recordRes: JSON.stringify({
|
||||||
|
autoCreateError: e.message || `${e}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDnsTxt(req: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||||
|
const record = await this.info(req.id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("DNS持久验证记录不存在");
|
||||||
|
}
|
||||||
|
const provider = await this.resolveDnsProvider(record, req);
|
||||||
|
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
|
||||||
|
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||||
|
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||||
|
const access = await this.accessService.getAccessById(provider.dnsProviderAccess, true, record.userId, record.projectId);
|
||||||
|
const dnsProvider = await createDnsProvider({
|
||||||
|
dnsProviderType: provider.dnsProviderType,
|
||||||
|
context: {
|
||||||
|
access,
|
||||||
|
logger,
|
||||||
|
http,
|
||||||
|
utils,
|
||||||
|
domainParser,
|
||||||
|
serviceGetter: taskService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
|
||||||
|
const fullRecordRaw = await this.buildFullHostRecord(record);
|
||||||
|
const fullRecord = dnsProvider.usePunyCode() ? fullRecordRaw : dnsProvider.punyCodeDecode(fullRecordRaw);
|
||||||
|
let hostRecord = fullRecord.replace(`${mainDomain}`, "");
|
||||||
|
if (hostRecord.endsWith(".")) {
|
||||||
|
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
|
||||||
|
}
|
||||||
|
const recordReq = {
|
||||||
|
domain: mainDomain,
|
||||||
|
fullRecord,
|
||||||
|
hostRecord,
|
||||||
|
type: "TXT",
|
||||||
|
value: record.recordValue,
|
||||||
|
};
|
||||||
|
const recordRes = await dnsProvider.createRecord(recordReq);
|
||||||
|
const verified = await this.checkRecord({
|
||||||
|
hostRecord: await this.buildFullHostRecord(record),
|
||||||
|
recordValue: record.recordValue,
|
||||||
|
});
|
||||||
|
await this.update({
|
||||||
|
id: record.id,
|
||||||
|
dnsProviderType: provider.dnsProviderType,
|
||||||
|
dnsProviderAccess: provider.dnsProviderAccess,
|
||||||
|
recordRes: JSON.stringify({ recordReq, recordRes }),
|
||||||
|
status: verified ? "valid" : "validating",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
recordReq,
|
||||||
|
recordRes,
|
||||||
|
verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(ids: string | any[], where?: any) {
|
||||||
|
const idList = this.resolveIdArr(ids);
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (const id of idList) {
|
||||||
|
const record = await this.info(id);
|
||||||
|
if (!record) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push(`DNS持久验证记录已删除,请到域名供应商删除TXT记录:${record.hostRecord} => ${record.recordValue}`);
|
||||||
|
}
|
||||||
|
this.lastDeleteMessage = messages.join("\n");
|
||||||
|
return await super.delete(ids, where);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "assert";
|
||||||
|
import { AcmeAccountAccess } from "./acme-account-access.js";
|
||||||
|
import { AcmeService } from "../plugin/cert-plugin/acme.js";
|
||||||
|
|
||||||
|
describe("AcmeAccountAccess", () => {
|
||||||
|
it("requires generated account payload before use", () => {
|
||||||
|
const access = new AcmeAccountAccess();
|
||||||
|
|
||||||
|
assert.throws(() => access.getAccount(), /ACME账号信息无效/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses generated account payload", () => {
|
||||||
|
const access = new AcmeAccountAccess();
|
||||||
|
access.account = JSON.stringify({
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://example.com/acct/1",
|
||||||
|
caType: "letsencrypt",
|
||||||
|
email: "user@example.com",
|
||||||
|
directoryUrl: "https://example.com/directory",
|
||||||
|
});
|
||||||
|
|
||||||
|
const account = access.getAccount();
|
||||||
|
|
||||||
|
assert.equal(account.accountKey, "private-key");
|
||||||
|
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates account payload through acme service", async () => {
|
||||||
|
const original = AcmeService.prototype.getAcmeClient;
|
||||||
|
const calls: string[] = [];
|
||||||
|
AcmeService.prototype.getAcmeClient = async function (email: string) {
|
||||||
|
calls.push(email);
|
||||||
|
await this.userContext.setObj(this.buildAccountKey(email), { key: "generated-key" });
|
||||||
|
return {
|
||||||
|
getAccountUrl() {
|
||||||
|
return "https://example.com/acct/2";
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access = new AcmeAccountAccess();
|
||||||
|
access.caType = "google";
|
||||||
|
access.email = "user@example.com";
|
||||||
|
access.eabKid = "kid-1";
|
||||||
|
access.eabHmacKey = "hmac-1";
|
||||||
|
|
||||||
|
const account = JSON.parse(await access.onGenerateAccount());
|
||||||
|
|
||||||
|
assert.equal(calls[0], "user@example.com");
|
||||||
|
assert.equal(account.accountKey, "generated-key");
|
||||||
|
assert.equal(account.accountUri, "https://example.com/acct/2");
|
||||||
|
assert.equal(account.caType, "google");
|
||||||
|
assert.equal(account.email, "user@example.com");
|
||||||
|
} finally {
|
||||||
|
AcmeService.prototype.getAcmeClient = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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账号已生成,请保存授权配置",
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
helper: "请生成ACME账号,账号一旦生成不允许修改",
|
||||||
|
encrypt: true,
|
||||||
|
mergeScript: `
|
||||||
|
return {
|
||||||
|
component: {
|
||||||
|
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
account = "";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./eab-access.js";
|
export * from "./eab-access.js";
|
||||||
export * from "./google-access.js";
|
export * from "./google-access.js";
|
||||||
|
export * from "./acme-account-access.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
|
import { utils } from "@certd/basic";
|
||||||
import { AcmeService } from "./acme.js";
|
import { AcmeService } from "./acme.js";
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
@@ -173,4 +174,28 @@ describe("AcmeService challenge", () => {
|
|||||||
|
|
||||||
assert.deepEqual(parseCalls, ["www.example.com", "certd-key.cname.sub.example.com"]);
|
assert.deepEqual(parseCalls, ["www.example.com", "certd-key.cname.sub.example.com"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enables proxy mapping when acme directory request fails", async () => {
|
||||||
|
const originalRequest = utils.http.request;
|
||||||
|
utils.http.request = async () => {
|
||||||
|
throw new Error("timeout");
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = new AcmeService({
|
||||||
|
userId: 1,
|
||||||
|
userContext: {} as any,
|
||||||
|
logger: logger as any,
|
||||||
|
sslProvider: "google",
|
||||||
|
domainParser: {} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlMapping = await service.resolveUrlMapping("https://dv.acme-v02.api.pki.goog/directory");
|
||||||
|
|
||||||
|
assert.equal(urlMapping.enabled, true);
|
||||||
|
assert.equal(urlMapping.mappings["dv.acme-v02.api.pki.goog"], "gg.px.certd.handfree.work");
|
||||||
|
} finally {
|
||||||
|
utils.http.request = originalRequest;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,15 +21,30 @@ export type HttpVerifyPlan = {
|
|||||||
export type DomainVerifyPlan = {
|
export type DomainVerifyPlan = {
|
||||||
domain: string;
|
domain: string;
|
||||||
mainDomain: string;
|
mainDomain: string;
|
||||||
type: "cname" | "dns" | "http";
|
type: "cname" | "dns" | "http" | "dns-persist";
|
||||||
dnsProvider?: IDnsProvider;
|
dnsProvider?: IDnsProvider;
|
||||||
cnameVerifyPlan?: CnameVerifyPlan;
|
cnameVerifyPlan?: CnameVerifyPlan;
|
||||||
httpVerifyPlan?: HttpVerifyPlan;
|
httpVerifyPlan?: HttpVerifyPlan;
|
||||||
|
dnsPersistVerifyPlan?: DnsPersistVerifyPlan;
|
||||||
};
|
};
|
||||||
export type DomainsVerifyPlan = {
|
export type DomainsVerifyPlan = {
|
||||||
[key: string]: DomainVerifyPlan;
|
[key: string]: DomainVerifyPlan;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AcmeAccountInfo = {
|
||||||
|
accountKey: string;
|
||||||
|
accountUri: string;
|
||||||
|
caType: SSLProvider | string;
|
||||||
|
email: string;
|
||||||
|
directoryUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DnsPersistVerifyPlan = {
|
||||||
|
hostRecord: string;
|
||||||
|
recordValue: string;
|
||||||
|
accountUri: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Providers = {
|
export type Providers = {
|
||||||
dnsProvider?: IDnsProvider;
|
dnsProvider?: IDnsProvider;
|
||||||
domainsVerifyPlan?: DomainsVerifyPlan;
|
domainsVerifyPlan?: DomainsVerifyPlan;
|
||||||
@@ -153,8 +168,7 @@ export class AcmeService {
|
|||||||
await this.userContext.setObj(this.buildAccountKey(email), conf);
|
await this.userContext.setObj(this.buildAccountKey(email), conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAcmeClient(email: string): Promise<acme.Client> {
|
buildUrlMapping(directoryUrl: string): UrlMapping {
|
||||||
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
|
|
||||||
let targetUrl = directoryUrl.replace("https://", "");
|
let targetUrl = directoryUrl.replace("https://", "");
|
||||||
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
|
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
|
||||||
|
|
||||||
@@ -174,10 +188,29 @@ export class AcmeService {
|
|||||||
if (this.options.reverseProxy && targetUrl) {
|
if (this.options.reverseProxy && targetUrl) {
|
||||||
mappings[targetUrl] = this.options.reverseProxy;
|
mappings[targetUrl] = this.options.reverseProxy;
|
||||||
}
|
}
|
||||||
const urlMapping: UrlMapping = {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
mappings,
|
mappings,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveUrlMapping(directoryUrl: string) {
|
||||||
|
const urlMapping = this.buildUrlMapping(directoryUrl);
|
||||||
|
if (this.options.useMappingProxy) {
|
||||||
|
urlMapping.enabled = true;
|
||||||
|
return urlMapping;
|
||||||
|
}
|
||||||
|
const isOk = await this.testDirectory(directoryUrl);
|
||||||
|
if (!isOk) {
|
||||||
|
this.logger.info("测试访问失败,自动使用代理");
|
||||||
|
urlMapping.enabled = true;
|
||||||
|
}
|
||||||
|
return urlMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAcmeClient(email: string): Promise<acme.Client> {
|
||||||
|
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
|
||||||
|
const urlMapping = await this.resolveUrlMapping(directoryUrl);
|
||||||
const conf = await this.getAccountConfig(email, urlMapping);
|
const conf = await this.getAccountConfig(email, urlMapping);
|
||||||
if (conf.key == null) {
|
if (conf.key == null) {
|
||||||
conf.key = await this.createNewKey();
|
conf.key = await this.createNewKey();
|
||||||
@@ -185,16 +218,6 @@ export class AcmeService {
|
|||||||
this.logger.info(`创建新的Accountkey:${email}`);
|
this.logger.info(`创建新的Accountkey:${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.useMappingProxy) {
|
|
||||||
urlMapping.enabled = true;
|
|
||||||
} else {
|
|
||||||
//测试directory是否可以访问
|
|
||||||
const isOk = await this.testDirectory(directoryUrl);
|
|
||||||
if (!isOk) {
|
|
||||||
this.logger.info("测试访问失败,自动使用代理");
|
|
||||||
urlMapping.enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const client = new acme.Client({
|
const client = new acme.Client({
|
||||||
sslProvider: this.sslProvider,
|
sslProvider: this.sslProvider,
|
||||||
directoryUrl: directoryUrl,
|
directoryUrl: directoryUrl,
|
||||||
@@ -238,6 +261,26 @@ export class AcmeService {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAcmeClientByAccount(account: AcmeAccountInfo): Promise<acme.Client> {
|
||||||
|
if (!account?.accountKey || !account?.accountUri) {
|
||||||
|
throw new Error("ACME账号信息无效,请重新生成ACME账号");
|
||||||
|
}
|
||||||
|
const directoryUrl = account.directoryUrl || acme.getDirectoryUrl({ sslProvider: account.caType, pkType: this.options.privateKeyType });
|
||||||
|
const urlMapping = await this.resolveUrlMapping(directoryUrl);
|
||||||
|
return new acme.Client({
|
||||||
|
sslProvider: account.caType,
|
||||||
|
directoryUrl,
|
||||||
|
accountKey: account.accountKey,
|
||||||
|
accountUrl: account.accountUri,
|
||||||
|
backoffAttempts: this.options.maxCheckRetryCount || 20,
|
||||||
|
backoffMin: 5000,
|
||||||
|
backoffMax: 30 * 1000,
|
||||||
|
urlMapping,
|
||||||
|
signal: this.options.signal,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createNewKey() {
|
async createNewKey() {
|
||||||
const key = await acme.crypto.createPrivateKey(2048);
|
const key = await acme.crypto.createPrivateKey(2048);
|
||||||
return key.toString();
|
return key.toString();
|
||||||
@@ -300,6 +343,18 @@ export class AcmeService {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doDnsPersistVerify = async (challenge: any, plan: DnsPersistVerifyPlan) => {
|
||||||
|
if (challenge == null) {
|
||||||
|
throw new Error("该域名不支持dns-persist-01方式校验,请确认当前CA是否已开放该能力");
|
||||||
|
}
|
||||||
|
this.logger.info("DNS持久验证");
|
||||||
|
challenge.expectedRecordValue = plan.recordValue;
|
||||||
|
return {
|
||||||
|
challenge,
|
||||||
|
keyAuthorization: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
let dnsProvider = providers.dnsProvider;
|
let dnsProvider = providers.dnsProvider;
|
||||||
let fullRecord = `_acme-challenge.${fullDomain}`;
|
let fullRecord = `_acme-challenge.${fullDomain}`;
|
||||||
|
|
||||||
@@ -343,6 +398,9 @@ export class AcmeService {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
|
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
|
||||||
}
|
}
|
||||||
|
} else if (domainVerifyPlan.type === "dns-persist") {
|
||||||
|
checkIpChallenge("dns-persist");
|
||||||
|
return await doDnsPersistVerify(getChallenge("dns-persist-01"), domainVerifyPlan.dnsPersistVerifyPlan);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("不支持的校验类型", domainVerifyPlan.type);
|
throw new Error("不支持的校验类型", domainVerifyPlan.type);
|
||||||
}
|
}
|
||||||
@@ -394,6 +452,8 @@ export class AcmeService {
|
|||||||
this.logger.error("删除解析记录出错:", e);
|
this.logger.error("删除解析记录出错:", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
} else if (challenge.type === "dns-persist-01") {
|
||||||
|
this.logger.info(`DNS持久验证无需清理:${fullDomain}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +467,10 @@ export class AcmeService {
|
|||||||
privateKeyType?: string;
|
privateKeyType?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
preferredChain?: string;
|
preferredChain?: string;
|
||||||
|
acmeAccount?: AcmeAccountInfo;
|
||||||
}): Promise<CertInfo> {
|
}): Promise<CertInfo> {
|
||||||
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain } = options;
|
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain, acmeAccount } = options;
|
||||||
const client: acme.Client = await this.getAcmeClient(email);
|
const client: acme.Client = acmeAccount ? await this.getAcmeClientByAccount(acmeAccount) : await this.getAcmeClient(email);
|
||||||
|
|
||||||
let domains = options.domains;
|
let domains = options.domains;
|
||||||
const encodingDomains = [];
|
const encodingDomains = [];
|
||||||
@@ -463,12 +524,13 @@ export class AcmeService {
|
|||||||
domainsVerifyPlan,
|
domainsVerifyPlan,
|
||||||
};
|
};
|
||||||
/* 自动申请证书 */
|
/* 自动申请证书 */
|
||||||
|
const challengePriority = domainsVerifyPlan && Object.values(domainsVerifyPlan).some((item: any) => item?.type === "dns-persist") ? ["dns-persist-01"] : ["dns-01", "http-01"];
|
||||||
const crt = await client.auto({
|
const crt = await client.auto({
|
||||||
csr,
|
csr,
|
||||||
email: email,
|
email: email,
|
||||||
termsOfServiceAgreed: true,
|
termsOfServiceAgreed: true,
|
||||||
skipChallengeVerification: this.skipLocalVerify,
|
skipChallengeVerification: this.skipLocalVerify,
|
||||||
challengePriority: ["dns-01", "http-01"],
|
challengePriority,
|
||||||
challengeCreateFn: async (
|
challengeCreateFn: async (
|
||||||
authz: acme.Authorization,
|
authz: acme.Authorization,
|
||||||
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
|
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "assert";
|
||||||
|
import { CertApplyPlugin } from "./apply.js";
|
||||||
|
|
||||||
|
describe("CertApplyPlugin dns-persist verify plan", () => {
|
||||||
|
it("keeps dns-persist entries when building mixed domain verify plans", async () => {
|
||||||
|
const plugin: any = new CertApplyPlugin();
|
||||||
|
plugin.acme = {
|
||||||
|
options: {
|
||||||
|
domainParser: {
|
||||||
|
async parse(domain: string) {
|
||||||
|
return domain;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const plan = await plugin.createDomainsVerifyPlan(
|
||||||
|
["*.handfree.work"],
|
||||||
|
{
|
||||||
|
"handfree.work": {
|
||||||
|
domain: "handfree.work",
|
||||||
|
type: "dns-persist",
|
||||||
|
dnsPersistVerifyPlan: {
|
||||||
|
"handfree.work": {
|
||||||
|
domain: "handfree.work",
|
||||||
|
status: "valid",
|
||||||
|
hostRecord: "_validation-persist",
|
||||||
|
recordValue: "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard",
|
||||||
|
accountUri: "https://acme.example/acct/1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountKey: "private-key",
|
||||||
|
accountUri: "https://acme.example/acct/1",
|
||||||
|
caType: "letsencrypt_staging",
|
||||||
|
email: "user@example.com",
|
||||||
|
directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(plan["handfree.work"].type, "dns-persist");
|
||||||
|
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.hostRecord, "_validation-persist");
|
||||||
|
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.recordValue, "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
||||||
import { utils } from "@certd/basic";
|
import { utils } from "@certd/basic";
|
||||||
|
|
||||||
import { AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
import { AcmeAccountInfo, AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
||||||
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
|
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
|
||||||
import { CertReader } from "@certd/plugin-lib";
|
import { CertReader } from "@certd/plugin-lib";
|
||||||
import { CertApplyBasePlugin } from "./base.js";
|
import { CertApplyBasePlugin } from "./base.js";
|
||||||
@@ -22,14 +22,22 @@ export type HttpRecordInput = {
|
|||||||
httpUploaderAccess: number;
|
httpUploaderAccess: number;
|
||||||
httpUploadRootDir: string;
|
httpUploadRootDir: string;
|
||||||
};
|
};
|
||||||
|
export type DnsPersistRecordInput = {
|
||||||
|
domain: string;
|
||||||
|
status?: string;
|
||||||
|
hostRecord?: string;
|
||||||
|
recordValue?: string;
|
||||||
|
accountUri?: string;
|
||||||
|
};
|
||||||
export type DomainVerifyPlanInput = {
|
export type DomainVerifyPlanInput = {
|
||||||
domain: string;
|
domain: string;
|
||||||
type: "cname" | "dns" | "http";
|
type: "cname" | "dns" | "http" | "dns-persist";
|
||||||
dnsProviderType?: string;
|
dnsProviderType?: string;
|
||||||
dnsProviderAccessType?: string;
|
dnsProviderAccessType?: string;
|
||||||
dnsProviderAccessId?: number;
|
dnsProviderAccessId?: number;
|
||||||
cnameVerifyPlan?: Record<string, CnameRecordInput>;
|
cnameVerifyPlan?: Record<string, CnameRecordInput>;
|
||||||
httpVerifyPlan?: Record<string, HttpRecordInput>;
|
httpVerifyPlan?: Record<string, HttpRecordInput>;
|
||||||
|
dnsPersistVerifyPlan?: Record<string, DnsPersistRecordInput>;
|
||||||
};
|
};
|
||||||
export type DomainsVerifyPlanInput = {
|
export type DomainsVerifyPlanInput = {
|
||||||
[key: string]: DomainVerifyPlanInput;
|
[key: string]: DomainVerifyPlanInput;
|
||||||
@@ -99,6 +107,19 @@ const preferredChainMergeScript = (() => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class CertApplyPlugin extends CertApplyBasePlugin {
|
export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "版本",
|
||||||
|
value: 2,
|
||||||
|
isSys: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
version?: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "域名验证方式",
|
title: "域名验证方式",
|
||||||
value: "dns",
|
value: "dns",
|
||||||
@@ -107,6 +128,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
vModel: "value",
|
vModel: "value",
|
||||||
options: [
|
options: [
|
||||||
{ value: "dns", label: "DNS直接验证" },
|
{ value: "dns", label: "DNS直接验证" },
|
||||||
|
{ value: "dns-persist", label: "DNS持久验证" },
|
||||||
{ value: "cname", label: "CNAME代理验证" },
|
{ value: "cname", label: "CNAME代理验证" },
|
||||||
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
|
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
|
||||||
{ value: "dnses", label: "多DNS提供商" },
|
{ value: "dnses", label: "多DNS提供商" },
|
||||||
@@ -119,12 +141,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
|
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
|
||||||
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
|
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
|
||||||
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
|
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
|
||||||
|
6. <b>DNS持久验证</b>:需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
challengeType!: string;
|
challengeType!: string;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "DNS解析服务商",
|
title: "DNS解析服务商",
|
||||||
component: {
|
component: {
|
||||||
@@ -145,7 +166,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
required: true,
|
required: true,
|
||||||
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证校验方式",
|
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证",
|
||||||
})
|
})
|
||||||
dnsProviderType!: string;
|
dnsProviderType!: string;
|
||||||
|
|
||||||
@@ -190,18 +211,30 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
}),
|
}),
|
||||||
defaultType: ctx.compute(({form})=>{
|
defaultType: ctx.compute(({form})=>{
|
||||||
return form.challengeType || 'cname'
|
return form.challengeType || 'cname'
|
||||||
|
}),
|
||||||
|
caType: ctx.compute(({form})=>{
|
||||||
|
return form.sslProvider
|
||||||
|
}),
|
||||||
|
acmeAccountAccessId: ctx.compute(({form})=>{
|
||||||
|
return form.acmeAccountAccessId
|
||||||
|
}),
|
||||||
|
commonAcmeAccountAccessId: ctx.compute(({form})=>{
|
||||||
|
const key = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||||
|
return form[key]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
show: ctx.compute(({form})=>{
|
show: ctx.compute(({form})=>{
|
||||||
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses'
|
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses' || form.challengeType === 'dns-persist'
|
||||||
}),
|
}),
|
||||||
helper: ctx.compute(({form})=>{
|
helper: ctx.compute(({form})=>{
|
||||||
if(form.challengeType === 'cname' ){
|
if(form.challengeType === 'cname' ){
|
||||||
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
|
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
|
||||||
}else if (form.challengeType === 'http'){
|
}else if (form.challengeType === 'http'){
|
||||||
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
|
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
|
||||||
}else if (form.challengeType === 'http'){
|
}else if (form.challengeType === 'dnses'){
|
||||||
return '给每个域名单独配置dns提供商'
|
return '给每个域名单独配置dns提供商'
|
||||||
|
}else if (form.challengeType === 'dns-persist'){
|
||||||
|
return '请先创建并校验_validation-persist TXT持久记录,校验成功后才能提交流水线;当前仅 Let\\'s Encrypt 测试环境可以申请'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -209,7 +242,6 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
domainsVerifyPlan!: DomainsVerifyPlanInput;
|
domainsVerifyPlan!: DomainsVerifyPlanInput;
|
||||||
|
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "证书颁发机构",
|
title: "证书颁发机构",
|
||||||
value: "letsencrypt",
|
value: "letsencrypt",
|
||||||
@@ -237,6 +269,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
googleCommonEabAccessId!: number;
|
googleCommonEabAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "Google公共ACME账号授权",
|
||||||
|
isSys: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
googleCommonAcmeAccountAccessId!: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "ZeroSSL公共EAB授权",
|
title: "ZeroSSL公共EAB授权",
|
||||||
isSys: true,
|
isSys: true,
|
||||||
@@ -244,6 +283,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
zerosslCommonEabAccessId!: number;
|
zerosslCommonEabAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "ZeroSSL公共ACME账号授权",
|
||||||
|
isSys: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
zerosslCommonAcmeAccountAccessId!: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "SSL.com公共EAB授权",
|
title: "SSL.com公共EAB授权",
|
||||||
isSys: true,
|
isSys: true,
|
||||||
@@ -251,6 +297,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
sslcomCommonEabAccessId!: number;
|
sslcomCommonEabAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "SSL.com公共ACME账号授权",
|
||||||
|
isSys: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
sslcomCommonAcmeAccountAccessId!: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "litessl公共EAB授权",
|
title: "litessl公共EAB授权",
|
||||||
isSys: true,
|
isSys: true,
|
||||||
@@ -258,6 +311,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
litesslCommonEabAccessId!: number;
|
litesslCommonEabAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "litessl公共ACME账号授权",
|
||||||
|
isSys: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
litesslCommonAcmeAccountAccessId!: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "EAB授权",
|
title: "EAB授权",
|
||||||
component: {
|
component: {
|
||||||
@@ -275,7 +335,16 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
mergeScript: `
|
mergeScript: `
|
||||||
return {
|
return {
|
||||||
show: ctx.compute(({form})=>{
|
show: ctx.compute(({form})=>{
|
||||||
console.log("show",form)
|
if (form.version === 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if(form.acmeAccountAccessId){
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const commonAcmeKey = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||||
|
if (form[commonAcmeKey]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|
||||||
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|
||||||
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
|
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
|
||||||
@@ -286,6 +355,31 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
})
|
})
|
||||||
eabAccessId!: number;
|
eabAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "ACME账号授权",
|
||||||
|
component: {
|
||||||
|
name: "access-selector",
|
||||||
|
type: "acmeAccount",
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
helper: "请选择颁发机构对应的ACME账号",
|
||||||
|
mergeScript: `
|
||||||
|
return {
|
||||||
|
show: ctx.compute(({form})=>{
|
||||||
|
const commonKey = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||||
|
if (form[commonKey]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !!form.sslProvider
|
||||||
|
}),
|
||||||
|
component:{
|
||||||
|
subtype: ctx.compute(({form})=> form.sslProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
acmeAccountAccessId!: number;
|
||||||
|
|
||||||
@TaskInput({
|
@TaskInput({
|
||||||
title: "服务账号授权",
|
title: "服务账号授权",
|
||||||
component: {
|
component: {
|
||||||
@@ -298,6 +392,15 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
mergeScript: `
|
mergeScript: `
|
||||||
return {
|
return {
|
||||||
show: ctx.compute(({form})=>{
|
show: ctx.compute(({form})=>{
|
||||||
|
if (form.version === 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if(form.acmeAccountAccessId){
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if(form.googleCommonAcmeAccountAccessId){
|
||||||
|
return false
|
||||||
|
}
|
||||||
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
|
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -432,7 +535,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
async onInit() {
|
async onInit() {
|
||||||
let eab: EabAccess = null;
|
let eab: EabAccess = null;
|
||||||
|
|
||||||
if (this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
|
const isNewVersion = this.version === 2;
|
||||||
|
if (!isNewVersion && this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
|
||||||
if (this.sslProvider === "google" && this.googleAccessId) {
|
if (this.sslProvider === "google" && this.googleAccessId) {
|
||||||
this.logger.info("当前正在使用 google服务账号授权获取EAB");
|
this.logger.info("当前正在使用 google服务账号授权获取EAB");
|
||||||
const googleAccess = await this.getAccess(this.googleAccessId);
|
const googleAccess = await this.getAccess(this.googleAccessId);
|
||||||
@@ -499,8 +603,23 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
|
|
||||||
let dnsProvider: IDnsProvider = null;
|
let dnsProvider: IDnsProvider = null;
|
||||||
let domainsVerifyPlan: DomainsVerifyPlan = null;
|
let domainsVerifyPlan: DomainsVerifyPlan = null;
|
||||||
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
let acmeAccount: AcmeAccountInfo = null;
|
||||||
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan);
|
if (this.acmeAccountAccessId) {
|
||||||
|
const access: any = await this.getAccess(this.acmeAccountAccessId);
|
||||||
|
acmeAccount = this.parseAcmeAccount(access.account);
|
||||||
|
} else {
|
||||||
|
acmeAccount = await this.getCommonAcmeAccount();
|
||||||
|
}
|
||||||
|
if (this.version === 2 && !this.sslProvider.startsWith("letsencrypt") && !acmeAccount) {
|
||||||
|
throw new Error("请选择颁发机构对应的ACME账号");
|
||||||
|
}
|
||||||
|
if (this.challengeType === "dns-persist") {
|
||||||
|
if (!acmeAccount) {
|
||||||
|
throw new Error("DNS持久验证需要先选择ACME账号授权");
|
||||||
|
}
|
||||||
|
domainsVerifyPlan = await this.createDnsPersistDomainsVerifyPlan(domains, acmeAccount);
|
||||||
|
} else if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
||||||
|
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan, acmeAccount);
|
||||||
} else if (this.challengeType === "auto") {
|
} else if (this.challengeType === "auto") {
|
||||||
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
|
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
|
||||||
} else {
|
} else {
|
||||||
@@ -519,6 +638,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
privateKeyType: this.privateKeyType,
|
privateKeyType: this.privateKeyType,
|
||||||
profile: this.certProfile,
|
profile: this.certProfile,
|
||||||
preferredChain: this.preferredChain,
|
preferredChain: this.preferredChain,
|
||||||
|
acmeAccount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const certInfo = this.formatCerts(cert);
|
const certInfo = this.formatCerts(cert);
|
||||||
@@ -552,7 +672,80 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
|
parseAcmeAccount(account: string | AcmeAccountInfo): AcmeAccountInfo {
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
|
||||||
|
}
|
||||||
|
const parsed = typeof account === "string" ? JSON.parse(account) : account;
|
||||||
|
if (!parsed.accountKey || !parsed.accountUri) {
|
||||||
|
throw new Error("ACME账号授权无效,请重新生成ACME账号");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommonAcmeAccount(): Promise<AcmeAccountInfo | null> {
|
||||||
|
if (!this.sslProvider || this.sslProvider === "letsencrypt" || this.sslProvider === "letsencrypt_staging") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const commonAccessId = this[`${this.sslProvider}CommonAcmeAccountAccessId`];
|
||||||
|
if (!commonAccessId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const accessService: any = this.ctx.accessService;
|
||||||
|
if (!accessService?.getCommonById) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await accessService.getCommonById(commonAccessId);
|
||||||
|
if (!access?.account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.logger.info(`使用系统公共${this.sslProvider} ACME账号`);
|
||||||
|
return this.parseAcmeAccount(access.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDnsPersistDomainsVerifyPlan(domains: string[], acmeAccount: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
|
||||||
|
const plan: DomainsVerifyPlan = {};
|
||||||
|
const domainParser = this.acme.options.domainParser;
|
||||||
|
for (const fullDomain of domains) {
|
||||||
|
const domain = fullDomain.replaceAll("*.", "");
|
||||||
|
const mainDomain = await domainParser.parse(domain);
|
||||||
|
const persistRecord = this.domainsVerifyPlan?.[mainDomain]?.dnsPersistVerifyPlan?.[domain];
|
||||||
|
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, persistRecord);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDnsPersistDomainVerifyPlan(domain: string, mainDomain: string, acmeAccount: AcmeAccountInfo, persistRecord?: DnsPersistRecordInput): DomainVerifyPlan {
|
||||||
|
if (!persistRecord) {
|
||||||
|
throw new Error(`DNS持久验证记录${domain}不存在,请先创建并校验`);
|
||||||
|
}
|
||||||
|
if (persistRecord.status !== "valid") {
|
||||||
|
throw new Error(`DNS持久验证记录${domain}还未校验成功`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "dns-persist",
|
||||||
|
mainDomain,
|
||||||
|
domain,
|
||||||
|
dnsPersistVerifyPlan: {
|
||||||
|
hostRecord: persistRecord.hostRecord || `_validation-persist.${domain}`,
|
||||||
|
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true),
|
||||||
|
accountUri: persistRecord.accountUri || acmeAccount.accountUri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number) {
|
||||||
|
const parts = [`letsencrypt.org`, `accounturi=${accountUri}`];
|
||||||
|
if (wildcard !== false) {
|
||||||
|
parts.push("policy=wildcard");
|
||||||
|
}
|
||||||
|
if (persistUntil) {
|
||||||
|
parts.push(`persistUntil=${persistUntil}`);
|
||||||
|
}
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput, acmeAccount?: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
|
||||||
const plan: DomainsVerifyPlan = {};
|
const plan: DomainsVerifyPlan = {};
|
||||||
|
|
||||||
const domainParser = this.acme.options.domainParser;
|
const domainParser = this.acme.options.domainParser;
|
||||||
@@ -569,6 +762,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
|
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
|
||||||
} else if (planSetting.type === "http") {
|
} else if (planSetting.type === "http") {
|
||||||
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
|
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
|
||||||
|
} else if (planSetting.type === "dns-persist") {
|
||||||
|
if (!acmeAccount) {
|
||||||
|
throw new Error("DNS持久验证需要先选择ACME账号授权");
|
||||||
|
}
|
||||||
|
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, planSetting.dnsPersistVerifyPlan?.[domain]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plan;
|
return plan;
|
||||||
@@ -677,9 +875,9 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async onGetReverseProxyList() {
|
async onGetReverseProxyList() {
|
||||||
const sysSettingsService:any = await this.ctx.serviceGetter.get("sysSettingsService");
|
const sysSettingsService: any = await this.ctx.serviceGetter.get("sysSettingsService");
|
||||||
const sysSettings = await sysSettingsService.getPrivateSettings();
|
const sysSettings = await sysSettingsService.getPrivateSettings();
|
||||||
return sysSettings.reverseProxyList || []
|
return sysSettings.reverseProxyList || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user