From f8b71a0e612fad527cf49136335e0b46f0f379cd Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Tue, 2 Jun 2026 23:08:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=8F=82=E6=95=B0=E6=A8=A1=E7=89=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E5=BC=80=E6=94=BE=E6=8E=A5=E5=8F=A3=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BD=BF=E7=94=A8=E8=AF=81=E4=B9=A6=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A8=A1=E7=89=88=E5=92=8C=E6=8C=87=E5=AE=9A=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/open/index.md | 8 +- .../locales/langs/en-US/certd/navigation.ts | 1 + .../locales/langs/zh-CN/certd/navigation.ts | 1 + .../src/router/source/modules/certd.ts | 11 + .../views/certd/cert/apply-template/api.ts | 66 +++++ .../views/certd/cert/apply-template/crud.tsx | 164 ++++++++++++ .../views/certd/cert/apply-template/fields.ts | 94 +++++++ .../views/certd/cert/apply-template/index.vue | 36 +++ .../views/certd/pipeline/certd-form/use.tsx | 249 +++++++++++++++++- .../src/views/certd/pipeline/index.vue | 1 - .../migration/v10049__cert_apply_template.sql | 16 ++ packages/ui/certd-server/src/configuration.ts | 2 + .../controller/openapi/v1/cert-controller.ts | 4 + .../cert/cert-apply-template-controller.ts | 75 ++++++ .../cert/entity/cert-apply-template.ts | 42 +++ .../cert-apply-template-fields.test.ts | 49 ++++ .../service/cert-apply-template-fields.ts | 25 ++ .../cert-apply-template-service.test.ts | 152 +++++++++++ .../service/cert-apply-template-service.ts | 112 ++++++++ .../monitor/facade/cert-info-facade.ts | 24 +- .../pipeline/service/pipeline-service.ts | 10 +- 21 files changed, 1130 insertions(+), 12 deletions(-) create mode 100644 packages/ui/certd-client/src/views/certd/cert/apply-template/api.ts create mode 100644 packages/ui/certd-client/src/views/certd/cert/apply-template/crud.tsx create mode 100644 packages/ui/certd-client/src/views/certd/cert/apply-template/fields.ts create mode 100644 packages/ui/certd-client/src/views/certd/cert/apply-template/index.vue create mode 100644 packages/ui/certd-server/db/migration/v10049__cert_apply_template.sql create mode 100644 packages/ui/certd-server/src/controller/user/cert/cert-apply-template-controller.ts create mode 100644 packages/ui/certd-server/src/modules/cert/entity/cert-apply-template.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.test.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.test.ts create mode 100644 packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.ts diff --git a/docs/guide/open/index.md b/docs/guide/open/index.md index 7eeedd46d..1aeb92a37 100644 --- a/docs/guide/open/index.md +++ b/docs/guide/open/index.md @@ -31,7 +31,11 @@ header中传入x-certd-token即可调用开放接口 支持证书id和域名两种方式获取证书。 ### 创建新的证书申请 -参数autoApply=true,将在没有证书时自动触发申请证书,检查逻辑如下: +参数`autoApply=true`将在没有证书时自动触发申请证书。申请参数支持另外传入: +- `autoApplyTemplateId`:使用指定 ID 的证书申请参数模版;不传时不使用模版 +- `autoApplyParams`:自定义证书申请参数,会与系统默认参数、模版参数合并,并覆盖同名字段 + +检查逻辑如下: 1. 如果证书仓库里面有,且没有过期,就直接返回证书 2. 如果没有或者已过期,就会去找流水线,有就触发流水线执行 3. 如果没有流水线,就创建一个流水线,触发运行(`注意:需要提前在域名管理中配置好域名校验方式,否则会申请失败`) @@ -48,4 +52,4 @@ header中传入x-certd-token即可调用开放接口 支持自动扫描主机`Nginx`配置,然后从Certd拉取证书并部署。 在不想暴露ssh主机密码情况下,该工具非常好用。 -开源地址: https://github.com/Youngxj/SSL-Assistant \ No newline at end of file +开源地址: https://github.com/Youngxj/SSL-Assistant diff --git a/packages/ui/certd-client/src/locales/langs/en-US/certd/navigation.ts b/packages/ui/certd-client/src/locales/langs/en-US/certd/navigation.ts index a3536e4ab..49f05df9d 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/certd/navigation.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/certd/navigation.ts @@ -12,6 +12,7 @@ export default { settings: "Settings", accessManager: "Access Management", dnsPersistRecord: "DNS Persist Records", + certApplyTemplate: "Certificate Apply Templates", subDomain: "Subdomain Delegation Settings", pipelineGroup: "Pipeline Group Management", openKey: "Open API Key", diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/navigation.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/navigation.ts index 5c8c282a5..54da7d2d1 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/navigation.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/navigation.ts @@ -12,6 +12,7 @@ export default { settings: "设置", accessManager: "授权管理", dnsPersistRecord: "DNS持久验证记录", + certApplyTemplate: "证书申请参数模版", subDomain: "子域名托管设置", pipelineGroup: "流水线分组管理", openKey: "开放接口密钥", diff --git a/packages/ui/certd-client/src/router/source/modules/certd.ts b/packages/ui/certd-client/src/router/source/modules/certd.ts index 9b01955d6..41f700f2e 100644 --- a/packages/ui/certd-client/src/router/source/modules/certd.ts +++ b/packages/ui/certd-client/src/router/source/modules/certd.ts @@ -197,6 +197,17 @@ export const certdResources = [ keepAlive: true, }, }, + { + title: "certd.certApplyTemplate", + name: "CertApplyTemplate", + path: "/certd/cert/apply-template", + component: "/certd/cert/apply-template/index.vue", + meta: { + icon: "ion:list-circle-outline", + auth: true, + keepAlive: true, + }, + }, { title: "certd.subDomain", name: "SubDomain", diff --git a/packages/ui/certd-client/src/views/certd/cert/apply-template/api.ts b/packages/ui/certd-client/src/views/certd/cert/apply-template/api.ts new file mode 100644 index 000000000..939fccca3 --- /dev/null +++ b/packages/ui/certd-client/src/views/certd/cert/apply-template/api.ts @@ -0,0 +1,66 @@ +import { request } from "/src/api/service"; + +const apiPrefix = "/cert/apply-template"; + +export async function GetList(query: any) { + return await request({ + url: apiPrefix + "/page", + method: "post", + data: query, + }); +} + +export async function ListAll() { + return await request({ + url: apiPrefix + "/list", + method: "post", + data: {}, + }); +} + +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: number) { + return await request({ + url: apiPrefix + "/delete", + method: "post", + params: { id }, + }); +} + +export async function GetObj(id: number) { + return await request({ + url: apiPrefix + "/info", + method: "post", + params: { id }, + }); +} + +export async function SetDefault(id: number) { + return await request({ + url: apiPrefix + "/setDefault", + method: "post", + data: { id }, + }); +} + +export async function GetDefault() { + return await request({ + url: apiPrefix + "/default", + method: "post", + }); +} diff --git a/packages/ui/certd-client/src/views/certd/cert/apply-template/crud.tsx b/packages/ui/certd-client/src/views/certd/cert/apply-template/crud.tsx new file mode 100644 index 000000000..02296e17a --- /dev/null +++ b/packages/ui/certd-client/src/views/certd/cert/apply-template/crud.tsx @@ -0,0 +1,164 @@ +import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, useFormWrapper, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; +import { message } from "ant-design-vue"; +import * as api from "./api"; +import { useProjectStore } from "/@/store/project"; +import { usePluginStore } from "/@/store/plugin"; +import { buildCertApplyTemplateColumns, buildTemplateSubmitData, pickCertApplyTemplateParams } from "./fields"; + +export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { + const pluginStore = usePluginStore(); + const projectStore = useProjectStore(); + const { openCrudFormDialog } = useFormWrapper(); + const isDefaultDict = dict({ + data: [ + { value: true, label: "默认", color: "green" }, + { value: false, label: "否", color: "gray" }, + ], + }); + + const pageRequest = async (query: UserPageQuery): Promise => { + return await api.GetList(query); + }; + const addRequest = async ({ form }: AddReq) => { + return await api.AddObj(buildTemplateSubmitData(form)); + }; + const editRequest = async ({ form, row }: EditReq) => { + form.id = row.id; + return await api.UpdateObj(buildTemplateSubmitData(form)); + }; + const delRequest = async ({ row }: DelReq) => { + return await api.DelObj(row.id); + }; + + async function setDefault(row: any) { + await api.SetDefault(row.id); + message.success("设置成功"); + await crudExpose.doRefresh(); + } + + async function openForm(row?: any) { + const certPlugin: any = await pluginStore.getPluginDefine("CertApply"); + const columns = buildCertApplyTemplateColumns(certPlugin); + const content = row?.content ? (typeof row.content === "string" ? JSON.parse(row.content || "{}") : row.content) : {}; + const initialForm = row + ? { + id: row.id, + name: row.name, + isDefault: row.isDefault, + disabled: row.disabled, + ...pickCertApplyTemplateParams(content), + } + : {}; + await openCrudFormDialog({ + crudOptions: { + columns, + form: { + mode: row ? "edit" : "add", + initialForm, + wrapper: { + width: 1100, + title: row ? "编辑证书申请参数模版" : "新增证书申请参数模版", + saveRemind: false, + }, + col: { + span: 12, + }, + async doSubmit({ form }: any) { + if (row) { + await editRequest({ form, row } as any); + } else { + await addRequest({ form } as any); + } + }, + async afterSubmit() { + await crudExpose.doRefresh(); + }, + }, + }, + }); + } + + return { + crudOptions: { + request: { + pageRequest, + addRequest, + editRequest, + delRequest, + }, + search: { + initialForm: { + ...projectStore.getSearchForm(), + }, + }, + actionbar: { + buttons: { + add: { + icon: "ion:add-circle-outline", + click: () => openForm(), + }, + }, + }, + rowHandle: { + fixed: "right", + width: 120, + buttons: { + edit: { + click: ({ row }) => openForm(row), + }, + remove: {}, + }, + }, + columns: { + id: { + title: "ID", + type: "number", + column: { width: 80 }, + form: { show: false }, + }, + name: { + title: "模版名称", + type: "text", + search: { show: true }, + column: { minWidth: 220 }, + }, + isDefault: { + title: "默认", + type: "dict-switch", + dict: isDefaultDict, + column: { + width: 150, + cellRender({ value, row }) { + return ( +
+ + {!row.isDefault && ( + + setDefault(row)}> + + )} +
+ ); + }, + }, + }, + disabled: { + title: "禁用", + type: "dict-switch", + column: { width: 100 }, + }, + createTime: { + title: "创建时间", + type: "datetime", + column: { width: 180 }, + }, + content: { + title: "配置", + type: "text", + column: { show: false }, + form: { show: false }, + }, + }, + }, + }; +} diff --git a/packages/ui/certd-client/src/views/certd/cert/apply-template/fields.ts b/packages/ui/certd-client/src/views/certd/cert/apply-template/fields.ts new file mode 100644 index 000000000..0ebdec44c --- /dev/null +++ b/packages/ui/certd-client/src/views/certd/cert/apply-template/fields.ts @@ -0,0 +1,94 @@ +import { cloneDeep, merge, omit } from "lodash-es"; +import { useReference } from "/@/use/use-refrence"; + +export const certApplyTemplateExcludeParamFields = ["domains", "domainsVerifyPlan"]; + +const excludeFieldSet = new Set(certApplyTemplateExcludeParamFields); + +export function pickCertApplyTemplateParams(input: any = {}) { + const params: any = {}; + for (const key of Object.keys(input || {})) { + if (!excludeFieldSet.has(key) && input[key] !== undefined) { + params[key] = input[key]; + } + } + return params; +} + +export function buildCertApplyTemplateColumns(certPlugin: any) { + const columns: any = { + name: { + title: "模版名称", + type: "text", + form: { + required: true, + order: -1000, + }, + }, + }; + + for (const key of Object.keys(certPlugin?.input || {})) { + if (excludeFieldSet.has(key)) { + continue; + } + const inputDefine = cloneDeep(certPlugin?.input?.[key]); + if (!inputDefine) { + continue; + } + useReference(inputDefine); + columns[key] = { + title: inputDefine.title, + form: { + ...inputDefine, + }, + }; + } + // if (columns.acmeAccountAccessId?.form) { + // columns.acmeAccountAccessId.form.show = true; + // columns.acmeAccountAccessId.form.required = false; + // columns.acmeAccountAccessId.form.component = { + // ...columns.acmeAccountAccessId.form.component, + // type: "acmeAccount", + // subtype: undefined, + // }; + // } + + merge(columns, { + isDefault: { + title: "默认模版", + type: "switch", + form: { + value: false, + component: { + name: "a-switch", + vModel: "checked", + }, + order: 900, + }, + }, + disabled: { + title: "禁用", + type: "switch", + form: { + value: false, + component: { + name: "a-switch", + vModel: "checked", + }, + order: 901, + }, + }, + }); + + return columns; +} + +export function buildTemplateSubmitData(form: any) { + return { + id: form.id, + name: form.name, + content: pickCertApplyTemplateParams(omit(form, ["id", "name", "content", "isDefault", "disabled"])), + isDefault: form.isDefault, + disabled: form.disabled, + }; +} diff --git a/packages/ui/certd-client/src/views/certd/cert/apply-template/index.vue b/packages/ui/certd-client/src/views/certd/cert/apply-template/index.vue new file mode 100644 index 000000000..61031a83c --- /dev/null +++ b/packages/ui/certd-client/src/views/certd/cert/apply-template/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/ui/certd-client/src/views/certd/pipeline/certd-form/use.tsx b/packages/ui/certd-client/src/views/certd/pipeline/certd-form/use.tsx index e86e19f07..a16bb14e6 100644 --- a/packages/ui/certd-client/src/views/certd/pipeline/certd-form/use.tsx +++ b/packages/ui/certd-client/src/views/certd/pipeline/certd-form/use.tsx @@ -1,12 +1,12 @@ import { checkPipelineLimit } from "/@/views/certd/pipeline/utils"; import { cloneDeep, merge, omit } from "lodash-es"; -import { message } from "ant-design-vue"; +import { message, Modal } from "ant-design-vue"; import { nanoid } from "nanoid"; import { useRouter } from "vue-router"; import { compute, CreateCrudOptionsRet, dict, useFormWrapper } from "@fast-crud/fast-crud"; import NotificationSelector from "/@/views/certd/notification/notification-selector/index.vue"; import { useReference } from "/@/use/use-refrence"; -import { computed, provide, Ref, ref } from "vue"; +import { computed, provide, reactive, Ref, ref } from "vue"; import * as api from "../api"; import { PluginGroup, usePluginStore } from "/@/store/plugin"; import { createNotificationApi } from "/@/views/certd/notification/api"; @@ -14,6 +14,8 @@ import GroupSelector from "../group/group-selector.vue"; import { useI18n } from "/src/locales"; import { useSettingStore } from "/@/store/settings"; import dayjs from "dayjs"; +import * as certApplyTemplateApi from "/@/views/certd/cert/apply-template/api"; +import { buildCertApplyTemplateColumns, buildTemplateSubmitData, pickCertApplyTemplateParams } from "/@/views/certd/cert/apply-template/fields"; export function fillPipelineByDefaultForm(pipeline: any, form: any) { const triggers = []; @@ -107,6 +109,7 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref const pluginStore = usePluginStore(); const settingStore = useSettingStore(); const router = useRouter(); + const { openCrudFormDialog: openInnerCrudFormDialog } = useFormWrapper(); function createCrudOptions(req: { certPlugin: any; doSubmit: any; title?: string; initialForm?: any }): CreateCrudOptionsRet { const inputs: any = {}; @@ -150,6 +153,236 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref const initialForm = req.initialForm || {}; initialForm.type = certPlugin.name; + const templateDict = dict({ + value: "id", + label: "name", + async getData() { + return await certApplyTemplateApi.ListAll(); + }, + async getNodesByValues(ids: any[]) { + const list = await certApplyTemplateApi.ListAll(); + return list.filter((item: any) => ids.includes(item.id)); + }, + immediate: false, + }); + const applyTemplates = reactive([]); + + async function reloadApplyTemplates() { + const list = await certApplyTemplateApi.ListAll(); + applyTemplates.splice(0, applyTemplates.length, ...list); + return list; + } + + async function applyTemplateToForm(templateId: number, form: any) { + if (!templateId) { + return; + } + const template = await certApplyTemplateApi.GetObj(templateId); + const params = pickCertApplyTemplateParams(typeof template.content === "string" ? JSON.parse(template.content || "{}") : template.content); + form.input = { + ...form.input, + ...params, + }; + } + + function getSelectedApplyTemplateName(form: any) { + if (!form?.applyTemplateId) { + return "选择模版"; + } + const template = applyTemplates.find(item => item.id === form.applyTemplateId); + return template?.name || "选择模版"; + } + + async function saveCurrentTemplate(form: any) { + await openInnerCrudFormDialog({ + crudOptions: { + columns: { + name: { + title: "模版名称", + type: "text", + form: { + required: true, + }, + }, + isDefault: { + title: "设为默认", + type: "switch", + form: { + value: false, + component: { + name: "a-switch", + vModel: "checked", + }, + }, + }, + }, + form: { + mode: "add", + wrapper: { + width: 520, + title: "保存证书申请参数模版", + saveRemind: false, + }, + col: { + span: 24, + }, + async doSubmit({ form: templateForm }: any) { + await certApplyTemplateApi.AddObj({ + name: templateForm.name, + isDefault: templateForm.isDefault, + content: pickCertApplyTemplateParams(form.input), + }); + await reloadApplyTemplates(); + await templateDict.reloadDict(); + message.success("保存成功"); + }, + }, + }, + }); + } + + async function openApplyTemplateEditor(templateId: number) { + const row = await certApplyTemplateApi.GetObj(templateId); + const columns = buildCertApplyTemplateColumns(certPlugin); + const content = row?.content ? (typeof row.content === "string" ? JSON.parse(row.content || "{}") : row.content) : {}; + await openInnerCrudFormDialog({ + crudOptions: { + columns, + form: { + mode: "edit", + initialForm: { + id: row.id, + name: row.name, + isDefault: row.isDefault, + disabled: row.disabled, + ...pickCertApplyTemplateParams(content), + }, + wrapper: { + width: 1100, + title: "编辑证书申请参数模版", + saveRemind: false, + }, + col: { + span: 12, + }, + async doSubmit({ form: templateForm }: any) { + await certApplyTemplateApi.UpdateObj(buildTemplateSubmitData(templateForm)); + await reloadApplyTemplates(); + await templateDict.reloadDict(); + message.success("保存成功"); + }, + }, + }, + }); + } + + function deleteApplyTemplate(templateId: number) { + Modal.confirm({ + title: "确认删除该模版?", + content: "删除后无法恢复。", + async onOk() { + await certApplyTemplateApi.DelObj(templateId); + await reloadApplyTemplates(); + await templateDict.reloadDict(); + message.success("删除成功"); + }, + }); + } + + function stopMenuAction(event: MouseEvent, action: () => void) { + event.preventDefault(); + event.stopPropagation(); + action(); + } + + function goApplyTemplateManage() { + formWrapperRef.value?.close?.(); + router.push({ name: "CertApplyTemplate" }); + } + + function renderTemplateFooter(scope: any) { + if (certPlugin.name !== "CertApply") { + return null; + } + const form = scope?.getFormData?.(); + if (!form) { + return null; + } + return ( +
+ { + if (open) { + reloadApplyTemplates(); + } + }} + v-slots={{ + overlay: () => ( + { + if (key === "save") { + saveCurrentTemplate(form); + return; + } + if (key === "empty") { + return; + } + const templateId = Number(key); + form.applyTemplateId = templateId; + applyTemplateToForm(templateId, form); + }} + > + {applyTemplates.length === 0 ? ( + + 暂无模版 + + ) : ( + applyTemplates.map(item => ( + +
+ {item.name} + + stopMenuAction(event, () => openApplyTemplateEditor(item.id))}> + 编辑 + + stopMenuAction(event, () => deleteApplyTemplate(item.id))}> + 删除 + + +
+
+ )) + )} + + +
+
+ + 保存当前参数为模版 +
+ + stopMenuAction(event, goApplyTemplateManage)}> + + + +
+
+
+ ), + }} + > + + + {getSelectedApplyTemplateName(form)} + + + +
+
+ ); + } + return { crudOptions: { form: { @@ -160,6 +393,9 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref width: 1350, saveRemind: false, title: req.title || t("certd.pipelineForm.createTitle"), + slots: { + "form-footer-left": renderTemplateFooter, + }, }, group: { groups: { @@ -323,6 +559,15 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref initialForm.input[key] = pluginSysConfig.sysSetting?.input[key]; } } + const defaultTemplate = req.pluginName === "CertApply" ? await certApplyTemplateApi.GetDefault() : null; + if (defaultTemplate) { + initialForm.applyTemplateId = defaultTemplate.id; + const templateParams = pickCertApplyTemplateParams(typeof defaultTemplate.content === "string" ? JSON.parse(defaultTemplate.content || "{}") : defaultTemplate.content); + initialForm.input = { + ...initialForm.input, + ...templateParams, + }; + } async function doSubmit({ form }: any) { // const certDetail = readCertDetail(form.cert.crt); diff --git a/packages/ui/certd-client/src/views/certd/pipeline/index.vue b/packages/ui/certd-client/src/views/certd/pipeline/index.vue index b3fd1f7da..c86388cf2 100644 --- a/packages/ui/certd-client/src/views/certd/pipeline/index.vue +++ b/packages/ui/certd-client/src/views/certd/pipeline/index.vue @@ -75,7 +75,6 @@ import { groupDictRef } from "./group/dicts"; import { useCertPipelineCreator } from "./certd-form/use"; import { useRouter } from "vue-router"; import { useCrudPermission } from "/@/plugin/permission"; -import CertdForm from "./certd-form/certd-form.vue"; defineOptions({ name: "PipelineManager", diff --git a/packages/ui/certd-server/db/migration/v10049__cert_apply_template.sql b/packages/ui/certd-server/db/migration/v10049__cert_apply_template.sql new file mode 100644 index 000000000..563925e3b --- /dev/null +++ b/packages/ui/certd-server/db/migration/v10049__cert_apply_template.sql @@ -0,0 +1,16 @@ +CREATE TABLE "cd_cert_apply_template" +( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "user_id" integer NOT NULL, + "project_id" integer NULL, + "name" varchar(100) NOT NULL, + "content" text NOT NULL, + "is_default" boolean NOT NULL DEFAULT (false), + "disabled" boolean NOT NULL DEFAULT (false), + "create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); + +CREATE INDEX "index_cert_apply_template_user_id" ON "cd_cert_apply_template" ("user_id"); +CREATE INDEX "index_cert_apply_template_project_id" ON "cd_cert_apply_template" ("project_id"); +CREATE INDEX "index_cert_apply_template_default" ON "cd_cert_apply_template" ("user_id", "project_id", "is_default"); diff --git a/packages/ui/certd-server/src/configuration.ts b/packages/ui/certd-server/src/configuration.ts index 8eaaef70c..55bb01d0e 100644 --- a/packages/ui/certd-server/src/configuration.ts +++ b/packages/ui/certd-server/src/configuration.ts @@ -134,5 +134,7 @@ export class MainConfiguration { }); logger.info("当前环境:", this.app.getEnv()); // prod + + } } diff --git a/packages/ui/certd-server/src/controller/openapi/v1/cert-controller.ts b/packages/ui/certd-server/src/controller/openapi/v1/cert-controller.ts index b66318af9..b287890ba 100644 --- a/packages/ui/certd-server/src/controller/openapi/v1/cert-controller.ts +++ b/packages/ui/certd-server/src/controller/openapi/v1/cert-controller.ts @@ -11,6 +11,8 @@ export type CertGetReq = { domains?: string; certId: number; autoApply?: boolean; + autoApplyTemplateId?: number; + autoApplyParams?: Record | string; format?: string; //默认是所有,pem,der,p12,pfx,jks,one,p7b }; @@ -43,6 +45,8 @@ export class OpenCertController extends BaseOpenController { domains: req.domains, certId: req.certId, autoApply: req.autoApply ?? false, + autoApplyTemplateId: req.autoApplyTemplateId, + autoApplyParams: typeof req.autoApplyParams === "string" ? JSON.parse(req.autoApplyParams) : req.autoApplyParams, format: req.format, projectId, }); diff --git a/packages/ui/certd-server/src/controller/user/cert/cert-apply-template-controller.ts b/packages/ui/certd-server/src/controller/user/cert/cert-apply-template-controller.ts new file mode 100644 index 000000000..8d17a3b0a --- /dev/null +++ b/packages/ui/certd-server/src/controller/user/cert/cert-apply-template-controller.ts @@ -0,0 +1,75 @@ +import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core"; +import { Constants, CrudController } from "@certd/lib-server"; +import { ApiTags } from "@midwayjs/swagger"; +import { CertApplyTemplateService } from "../../../modules/cert/service/cert-apply-template-service.js"; + +@Provide() +@Controller("/api/cert/apply-template") +@ApiTags(["cert"]) +export class CertApplyTemplateController extends CrudController { + @Inject() + service: CertApplyTemplateService; + + getService(): CertApplyTemplateService { + return this.service; + } + + @Post("/page", { description: Constants.per.authOnly, summary: "查询证书申请参数模版分页列表" }) + 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("/list", { description: Constants.per.authOnly, summary: "查询证书申请参数模版列表" }) + async list(@Body(ALL) body: any) { + const { projectId, userId } = await this.getProjectUserIdRead(); + body.query = body.query ?? {}; + body.query.projectId = projectId; + body.query.userId = userId; + body.query.disabled = false; + return super.list(body); + } + + @Post("/add", { description: Constants.per.authOnly, summary: "添加证书申请参数模版" }) + 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: "更新证书申请参数模版" }) + 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: "查询证书申请参数模版详情" }) + async info(@Query("id") id: number) { + await this.checkOwner(this.getService(), id, "read"); + return super.info(id); + } + + @Post("/delete", { description: Constants.per.authOnly, summary: "删除证书申请参数模版" }) + async delete(@Query("id") id: number) { + await this.checkOwner(this.getService(), id, "write"); + return super.delete(id); + } + + @Post("/setDefault", { description: Constants.per.authOnly, summary: "设置默认证书申请参数模版" }) + async setDefault(@Body("id") id: number) { + const { projectId, userId } = await this.getProjectUserIdWrite(); + return this.ok(await this.service.setDefault(id, userId, projectId)); + } + + @Post("/default", { description: Constants.per.authOnly, summary: "查询默认证书申请参数模版" }) + async getDefault() { + const { projectId, userId } = await this.getProjectUserIdRead(); + return this.ok(await this.service.getDefault(userId, projectId)); + } +} diff --git a/packages/ui/certd-server/src/modules/cert/entity/cert-apply-template.ts b/packages/ui/certd-server/src/modules/cert/entity/cert-apply-template.ts new file mode 100644 index 000000000..e9406d0a8 --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/entity/cert-apply-template.ts @@ -0,0 +1,42 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +/** + * 证书申请参数模版 + */ +@Entity("cd_cert_apply_template") +export class CertApplyTemplateEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ comment: "用户ID", name: "user_id" }) + userId: number; + + @Column({ name: "project_id", comment: "项目ID" }) + projectId: number; + + @Column({ comment: "模版名称", length: 100 }) + name: string; + + @Column({ comment: "配置", type: "text" }) + content: string; + + @Column({ name: "is_default", comment: "是否默认模版", default: false }) + isDefault: boolean; + + @Column({ comment: "是否禁用", default: false }) + disabled: boolean; + + @Column({ + comment: "创建时间", + name: "create_time", + default: () => "CURRENT_TIMESTAMP", + }) + createTime: Date; + + @Column({ + comment: "修改时间", + name: "update_time", + default: () => "CURRENT_TIMESTAMP", + }) + updateTime: Date; +} diff --git a/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.test.ts b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.test.ts new file mode 100644 index 000000000..92f6e49d3 --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { pickCertApplyCustomParams, pickCertApplyTemplateParams } from "./cert-apply-template-fields.js"; + +describe("cert apply template fields", () => { + it("keeps certificate apply and domain verify params but drops domains and verify plan for template", () => { + const params = pickCertApplyTemplateParams({ + domains: ["example.com"], + challengeType: "dns", + dnsProviderType: "aliyun", + dnsProviderAccess: 1, + dnsProviderAccessType: "aliyun", + domainsVerifyPlan: [{ domain: "example.com", type: "dns" }], + sslProvider: "google", + acmeAccountAccessId: 2, + privateKeyType: "ec_256", + pfxPassword: "secret", + renewDays: 15, + preferredChain: "GTS Root R1", + newApplyParam: "kept", + }); + + assert.deepEqual(params, { + challengeType: "dns", + dnsProviderType: "aliyun", + dnsProviderAccess: 1, + dnsProviderAccessType: "aliyun", + sslProvider: "google", + acmeAccountAccessId: 2, + privateKeyType: "ec_256", + pfxPassword: "secret", + renewDays: 15, + preferredChain: "GTS Root R1", + newApplyParam: "kept", + }); + }); + + it("keeps domain verify plan for custom auto apply params", () => { + const params = pickCertApplyCustomParams({ + domains: ["example.com"], + domainsVerifyPlan: [{ domain: "example.com", type: "dns" }], + challengeType: "dns", + }); + + assert.deepEqual(params, { + domainsVerifyPlan: [{ domain: "example.com", type: "dns" }], + challengeType: "dns", + }); + }); +}); diff --git a/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.ts b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.ts new file mode 100644 index 000000000..6fee6d1a2 --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-fields.ts @@ -0,0 +1,25 @@ +export type CertApplyTemplateParams = Record; + +export const certApplyTemplateExcludeParamFields = ["domains", "domainsVerifyPlan"] as const; +export const certApplyCustomExcludeParamFields = ["domains"] as const; + +const certApplyTemplateExcludeParamFieldSet = new Set(certApplyTemplateExcludeParamFields); +const certApplyCustomExcludeParamFieldSet = new Set(certApplyCustomExcludeParamFields); + +export function pickCertApplyTemplateParams(input: CertApplyTemplateParams = {}) { + return pickCertApplyParams(input, certApplyTemplateExcludeParamFieldSet); +} + +export function pickCertApplyCustomParams(input: CertApplyTemplateParams = {}) { + return pickCertApplyParams(input, certApplyCustomExcludeParamFieldSet); +} + +function pickCertApplyParams(input: CertApplyTemplateParams = {}, excludeFieldSet: Set) { + const params: CertApplyTemplateParams = {}; + for (const key of Object.keys(input)) { + if (!excludeFieldSet.has(key) && input[key] !== undefined) { + params[key] = input[key]; + } + } + return params; +} diff --git a/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.test.ts b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.test.ts new file mode 100644 index 000000000..b51c2a96b --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { CertApplyTemplateService } from "./cert-apply-template-service.js"; + +function createService(list: any[]) { + const service = new CertApplyTemplateService(); + (service as any).repository = { + async findOne({ where }: any) { + return list.find(item => { + if (where.id != null && item.id !== where.id) { + return false; + } + if (where.userId != null && item.userId !== where.userId) { + return false; + } + if (where.projectId != null && item.projectId !== where.projectId) { + return false; + } + if (where.isDefault != null && item.isDefault !== where.isDefault) { + return false; + } + return true; + }); + }, + }; + return service; +} + +describe("CertApplyTemplateService", () => { + it("does not apply default template when template id is not specified", async () => { + const service = createService([ + { + id: 1, + userId: 10, + projectId: 20, + isDefault: true, + content: JSON.stringify({ + sslProvider: "google", + privateKeyType: "ec_256", + renewDays: 10, + domains: ["bad.example.com"], + challengeType: "dns", + }), + }, + ]); + + const params = await service.resolveApplyParams({ + userId: 10, + projectId: 20, + }); + + assert.deepEqual(params, {}); + }); + + it("uses selected template when auto apply uses integer template id", async () => { + const service = createService([ + { + id: 2, + userId: 10, + projectId: 20, + isDefault: false, + content: JSON.stringify({ + sslProvider: "zerossl", + acmeAccountAccessId: 8, + preferredChain: "ZeroSSL RSA Domain Secure Site CA", + }), + }, + ]); + + const params = await service.resolveApplyParams({ + userId: 10, + projectId: 20, + templateId: 2, + }); + + assert.deepEqual(params, { + sslProvider: "zerossl", + acmeAccountAccessId: 8, + preferredChain: "ZeroSSL RSA Domain Secure Site CA", + }); + }); + + it("uses custom params only when template id is not specified", async () => { + const service = createService([ + { + id: 1, + userId: 10, + projectId: 20, + isDefault: true, + content: JSON.stringify({ + sslProvider: "google", + renewDays: 10, + }), + }, + ]); + + const params = await service.resolveApplyParams({ + userId: 10, + projectId: 20, + params: { + renewDays: 30, + privateKeyType: "rsa_4096", + dnsProviderType: "cloudflare", + domainsVerifyPlan: [{ domain: "example.com", type: "dns" }], + domains: ["example.com"], + challengeType: "auto", + }, + }); + + assert.deepEqual(params, { + renewDays: 30, + privateKeyType: "rsa_4096", + dnsProviderType: "cloudflare", + challengeType: "auto", + domainsVerifyPlan: [{ domain: "example.com", type: "dns" }], + }); + }); + + it("merges selected template and custom params when both are specified", async () => { + const service = createService([ + { + id: 2, + userId: 10, + projectId: 20, + isDefault: false, + content: JSON.stringify({ + sslProvider: "zerossl", + acmeAccountAccessId: 8, + preferredChain: "ZeroSSL RSA Domain Secure Site CA", + renewDays: 10, + }), + }, + ]); + + const params = await service.resolveApplyParams({ + userId: 10, + projectId: 20, + templateId: 2, + params: { + renewDays: 30, + privateKeyType: "rsa_4096", + }, + }); + + assert.deepEqual(params, { + sslProvider: "zerossl", + acmeAccountAccessId: 8, + preferredChain: "ZeroSSL RSA Domain Secure Site CA", + renewDays: 30, + privateKeyType: "rsa_4096", + }); + }); +}); diff --git a/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.ts b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.ts new file mode 100644 index 000000000..9e3ffe7c6 --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/service/cert-apply-template-service.ts @@ -0,0 +1,112 @@ +import { Provide, Scope, ScopeEnum } from "@midwayjs/core"; +import { InjectEntityModel } from "@midwayjs/typeorm"; +import { BaseService, ValidateException } from "@certd/lib-server"; +import { Repository } from "typeorm"; +import { CertApplyTemplateEntity } from "../entity/cert-apply-template.js"; +import { CertApplyTemplateParams, pickCertApplyCustomParams, pickCertApplyTemplateParams } from "./cert-apply-template-fields.js"; + +export type ResolveApplyTemplateReq = { + userId: number; + projectId?: number; + templateId?: number; + params?: CertApplyTemplateParams; +}; + +@Provide() +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class CertApplyTemplateService extends BaseService { + @InjectEntityModel(CertApplyTemplateEntity) + repository: Repository; + + getRepository(): Repository { + return this.repository; + } + + async add(param: any) { + param.content = this.stringifyContent(param.content); + const res = await super.add(param); + if (param.isDefault) { + await this.setDefault(res.id, param.userId, param.projectId); + } + return res; + } + + async update(param: any) { + if (param.content != null) { + param.content = this.stringifyContent(param.content); + } + await super.update(param); + if (param.isDefault === true) { + const entity = await this.info(param.id); + await this.setDefault(param.id, entity.userId, entity.projectId); + } + } + + async setDefault(id: number, userId: number, projectId?: number) { + const entity = await this.getTemplateById(id, userId, projectId); + if (entity.disabled) { + throw new ValidateException("禁用的模版不能设为默认"); + } + await this.repository.update({ userId, projectId }, { isDefault: false }); + await this.repository.update({ id: entity.id, userId, projectId }, { isDefault: true }); + return entity; + } + + async getDefault(userId: number, projectId?: number) { + return await this.repository.findOne({ + where: { + userId, + projectId, + isDefault: true, + disabled: false, + }, + }); + } + + async resolveApplyParams(req: ResolveApplyTemplateReq) { + const templateParams = await this.getTemplateParams(req); + const customParams = pickCertApplyCustomParams(req.params || {}); + return { + ...templateParams, + ...customParams, + }; + } + + private async getTemplateParams(req: ResolveApplyTemplateReq) { + if (!req.templateId) { + return {}; + } + const template = await this.getTemplateById(req.templateId, req.userId, req.projectId); + if (!template) { + return {}; + } + return this.parseContent(template.content); + } + + private async getTemplateById(id: number, userId: number, projectId?: number) { + const template = await this.repository.findOne({ + where: { + id, + userId, + projectId, + }, + }); + if (!template) { + throw new ValidateException("证书申请参数模版不存在"); + } + return template; + } + + private stringifyContent(content: any) { + const params = this.parseContent(content); + return JSON.stringify(params); + } + + private parseContent(content: any) { + if (!content) { + return {}; + } + const raw = typeof content === "string" ? JSON.parse(content) : content; + return pickCertApplyTemplateParams(raw); + } +} diff --git a/packages/ui/certd-server/src/modules/monitor/facade/cert-info-facade.ts b/packages/ui/certd-server/src/modules/monitor/facade/cert-info-facade.ts index ba431c0a3..69a34846c 100644 --- a/packages/ui/certd-server/src/modules/monitor/facade/cert-info-facade.ts +++ b/packages/ui/certd-server/src/modules/monitor/facade/cert-info-facade.ts @@ -9,6 +9,8 @@ import { PipelineEntity } from "../../pipeline/entity/pipeline.js"; import { CertInfoService } from "../service/cert-info-service.js"; import { DomainService } from "../../cert/service/domain-service.js"; import { DomainVerifierGetter } from "../../pipeline/service/getter/domain-verifier-getter.js"; +import { CertApplyTemplateService } from "../../cert/service/cert-apply-template-service.js"; +import { CertApplyTemplateParams } from "../../cert/service/cert-apply-template-fields.js"; @Provide("CertInfoFacade") @Scope(ScopeEnum.Request, { allowDowngrade: true }) @@ -25,7 +27,10 @@ export class CertInfoFacade { @Inject() userSettingsService: UserSettingsService; - async getCertInfo(req: { domains?: string; certId?: number; userId: number; projectId: number; autoApply?: boolean; format?: string }) { + @Inject() + certApplyTemplateService: CertApplyTemplateService; + + async getCertInfo(req: { domains?: string; certId?: number; userId: number; projectId: number; autoApply?: boolean; format?: string; autoApplyTemplateId?: number; autoApplyParams?: CertApplyTemplateParams }) { const { domains, certId, userId, projectId } = req; if (certId) { return await this.certInfoService.getCertInfoById({ id: certId, userId, projectId }); @@ -43,7 +48,13 @@ export class CertInfoFacade { if (matchedList.length === 0) { if (req.autoApply === true) { //自动申请,先创建自动申请流水线 - const pipeline: PipelineEntity = await this.createAutoPipeline({ domains: domainArr, userId, projectId }); + const pipeline: PipelineEntity = await this.createAutoPipeline({ + domains: domainArr, + userId, + projectId, + autoApplyTemplateId: req.autoApplyTemplateId, + autoApplyParams: req.autoApplyParams, + }); await this.triggerApplyPipeline({ pipelineId: pipeline.id }); } else { throw new CodeException({ @@ -98,7 +109,7 @@ export class CertInfoFacade { return matched; } - async createAutoPipeline(req: { domains: string[]; userId: number; projectId: number }) { + async createAutoPipeline(req: { domains: string[]; userId: number; projectId: number; autoApplyTemplateId?: number; autoApplyParams?: CertApplyTemplateParams }) { const verifierGetter = new DomainVerifierGetter(req.userId, req.projectId, this.domainService); const allDomains = []; @@ -123,6 +134,12 @@ export class CertInfoFacade { throw new CodeException(Constants.res.openEmailNotFound); } const email = userEmailSetting.list[0]; + const applyParams = await this.certApplyTemplateService.resolveApplyParams({ + userId: req.userId, + projectId: req.projectId, + templateId: req.autoApplyTemplateId, + params: req.autoApplyParams, + }); return await this.pipelineService.createAutoPipeline({ domains: req.domains, @@ -130,6 +147,7 @@ export class CertInfoFacade { projectId: req.projectId, userId: req.userId, from: "OpenAPI", + applyParams, }); } diff --git a/packages/ui/certd-server/src/modules/pipeline/service/pipeline-service.ts b/packages/ui/certd-server/src/modules/pipeline/service/pipeline-service.ts index 6d89cfe7c..d08dd0713 100644 --- a/packages/ui/certd-server/src/modules/pipeline/service/pipeline-service.ts +++ b/packages/ui/certd-server/src/modules/pipeline/service/pipeline-service.ts @@ -32,6 +32,7 @@ import parser from "cron-parser"; import { ProjectService } from "../../sys/enterprise/service/project-service.js"; import { CertApplyStepInputPatch, updateCertApplyStepInputs } from "./pipeline-batch-update.js"; import { calcNextSuiteCountUsed } from "./pipeline-suite-limit.js"; +import { CertApplyTemplateParams } from "../../cert/service/cert-apply-template-fields.js"; const runningTasks: Map = new Map(); /** @@ -1298,7 +1299,7 @@ export class PipelineService extends BaseService { } } - async createAutoPipeline(req: { domains: string[]; email: string; userId: number; projectId?: number; from: string }) { + async createAutoPipeline(req: { domains: string[]; email: string; userId: number; projectId?: number; from: string; applyParams?: CertApplyTemplateParams }) { const randomHour = Math.floor(Math.random() * 6); const randomMin = Math.floor(Math.random() * 60); const randomCron = `0 ${randomMin} ${randomHour} * * *`; @@ -1343,9 +1344,6 @@ export class PipelineService extends BaseService { runnableType: "step", input: { renewDays: 20, - domains: req.domains, - email: req.email, - challengeType: "auto", sslProvider: "letsencrypt", privateKeyType: "rsa_2048", certProfile: "classic", @@ -1356,6 +1354,10 @@ export class PipelineService extends BaseService { waitDnsDiffuseTime: 30, pfxArgs: "-macalg SHA1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES", successNotify: true, + ...req.applyParams, + domains: req.domains, + email: req.email, + challengeType: "auto", }, strategy: { runStrategy: 0, // 正常执行