chore: 支持手动上传证书并部署

pull/361/head
xiaojunnuo 2025-03-19 00:28:50 +08:00
parent 873f2b618b
commit d1b61b6bf9
17 changed files with 491 additions and 172 deletions

View File

@ -11,11 +11,13 @@ function attachProperty(target: any, propertyKey: string | symbol) {
}
function getClassProperties(target: any) {
//获取父类
//获取父类, 向上追溯三层
const parent = Object.getPrototypeOf(target);
const pParent = Object.getPrototypeOf(parent);
const pParentMap = propertyMap[pParent] || {};
const parentMap = propertyMap[parent] || {};
const current = propertyMap[target] || {};
return _.merge({}, parentMap, current);
return _.merge({}, pParentMap, parentMap, current);
}
function target(target: any, propertyKey?: string | symbol) {

View File

@ -30,7 +30,7 @@ export abstract class BaseService<T> {
async transaction(callback: (entityManager: EntityManager) => Promise<any>) {
const dataSource = this.dataSourceManager.getDataSource('default');
await dataSource.transaction(callback as any);
return await dataSource.transaction(callback as any);
}
/**

View File

@ -31,14 +31,14 @@ export abstract class CertApplyBaseConvertPlugin extends AbstractTaskPlugin {
domains!: string[];
@TaskInput({
title: "证书密码",
title: "证书加密密码",
component: {
name: "input-password",
vModel: "value",
},
required: false,
order: 100,
helper: "PFX、jks格式证书是否加密\njks必须设置密码不传则默认123456\npfx不传则为空密码",
helper: "转换成PFX、jks格式证书是否需要加密\njks必须设置密码不传则默认123456\npfx不传则为空密码",
})
pfxPassword!: string;

View File

@ -11,7 +11,6 @@ export async function emitCertApplySuccess(emitter: TaskEmitter, cert: CertReade
}
export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin {
@TaskInput({
title: "邮箱",
component: {

View File

@ -0,0 +1,27 @@
<template>
<div class="file-input">
<a-button :type="type" @click="onClick">{{ text }}</a-button> {{ fileName }}
<div class="hidden">
<input ref="fileInputRef" type="file" @change="onFileChange" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, defineEmits, defineProps } from "vue";
const fileInputRef = ref<HTMLInputElement | null>(null);
const props = defineProps<{
text: string;
type: string;
}>();
const fileName = ref("");
const emit = defineEmits(["change"]);
function onClick() {
fileInputRef.value.click();
}
function onFileChange(e: any) {
fileName.value = e.target.files[0].name;
emit("change", e);
}
</script>

View File

@ -10,10 +10,12 @@ import Plugins from "./plugins/index";
import LoadingButton from "./loading-button.vue";
import IconSelect from "./icon-select.vue";
import ExpiresTimeText from "./expires-time-text.vue";
import FileInput from "./file-input.vue";
export default {
install(app: any) {
app.component("PiContainer", PiContainer);
app.component("TextEditable", TextEditable);
app.component("FileInput", FileInput);
app.component("CronLight", CronLight);
app.component("CronEditor", CronEditor);
@ -29,5 +31,5 @@ export default {
app.component("ExpiresTimeText", ExpiresTimeText);
app.use(vip);
app.use(Plugins);
}
},
};

View File

@ -6,8 +6,8 @@ const apiPrefix = "/pi/plugin";
const defaultInputDefine = {
component: {
name: "a-input",
vModel: "modelValue"
}
vModel: "modelValue",
},
};
function initPlugins(plugins: any) {
@ -35,7 +35,7 @@ export async function GetList(query: any) {
const plugins = await request({
url: apiPrefix + "/list",
method: "post",
params: query
params: query,
});
initPlugins(plugins);
return plugins;
@ -45,7 +45,7 @@ export async function GetGroups(query: any) {
const groups = await request({
url: apiPrefix + "/groups",
method: "post",
params: query
params: query,
});
const plugins: any = [];
for (const groupKey in groups) {
@ -60,8 +60,8 @@ export async function GetPluginDefine(type: string) {
url: apiPrefix + "/getDefineByType",
method: "post",
data: {
type
}
type,
},
});
initPlugins([define]);
return define;
@ -71,6 +71,6 @@ export async function GetPluginConfig(req: { id?: number; name: string; type: st
return await request({
url: apiPrefix + "/config",
method: "post",
data: req
data: req,
});
}

View File

@ -0,0 +1,10 @@
import { request } from "/src/api/service";
const apiPrefix = "/monitor/cert";
export async function UploadCert(body: { id?: number; cert: { crt: string; key: string }; pipeline?: any }) {
return await request({
url: apiPrefix + "/upload",
method: "post",
data: body,
});
}

View File

@ -0,0 +1,229 @@
import { compute, useFormWrapper } from "@fast-crud/fast-crud";
import NotificationSelector from "/@/views/certd/notification/notification-selector/index.vue";
import * as api from "./api";
import { omit, cloneDeep, set } from "lodash-es";
import { useReference } from "/@/use/use-refrence";
import { ref } from "vue";
import * as pluginApi from "../api.plugin";
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils";
import { notification } from "ant-design-vue";
import { useRouter } from "vue-router";
export function useCertUpload() {
const { openCrudFormDialog } = useFormWrapper();
const router = useRouter();
async function buildUploadCertPluginInputs(getFormData: any) {
const plugin: any = await pluginApi.GetPluginDefine("CertApplyUpload");
const inputs: any = {};
for (const inputKey in plugin.input) {
if (inputKey === "certInfoId" || inputKey === "domains") {
continue;
}
const inputDefine = cloneDeep(plugin.input[inputKey]);
useReference(inputDefine);
inputs[inputKey] = {
title: inputDefine.title,
form: {
...inputDefine,
show: compute(ctx => {
const form = getFormData();
if (!form) {
return false;
}
let inputDefineShow = true;
if (inputDefine.show != null) {
const computeShow = inputDefine.show as any;
if (computeShow === false) {
inputDefineShow = false;
} else if (computeShow && computeShow.computeFn) {
inputDefineShow = computeShow.computeFn({ form });
}
}
return inputDefineShow;
}),
},
};
}
return inputs;
}
function topRender({ form, key }: any) {
function onChange(e: any) {
const file = e.target.files[0];
const size = file.size;
if (size > 100 * 1024) {
notification.error({
message: "文件超过100k请选择正确的证书文件",
});
return;
}
const fileReader = new FileReader();
fileReader.onload = function (e: any) {
const value = e.target.result;
set(form, key, value);
};
fileReader.readAsText(file); // 以文本形式读取文件
}
return <file-input class="mb-5" type="primary" text={"选择文件"} onChange={onChange} />;
}
async function openUploadCreateDialog() {
//检查是否流水线数量超出限制
await checkPipelineLimit();
const wrapperRef = ref();
function getFormData() {
if (!wrapperRef.value) {
return null;
}
return wrapperRef.value.getFormData();
}
const inputs = await buildUploadCertPluginInputs(getFormData);
function createCrudOptions() {
return {
crudOptions: {
columns: {
"cert.crt": {
title: "证书",
type: "textarea",
form: {
component: {
rows: 4,
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
},
helper: "选择pem格式证书文件或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
topRender,
},
},
"cert.key": {
title: "证书私钥",
type: "textarea",
form: {
component: {
rows: 4,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n...\n-----END PRIVATE KEY----- ",
},
helper: "选择pem格式证书私钥文件或者粘贴到此",
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
order: -9999,
topRender,
},
},
...inputs,
notification: {
title: "失败通知",
type: "text",
form: {
value: 0,
component: {
name: NotificationSelector,
vModel: "modelValue",
on: {
selectedChange({ $event, form }: any) {
form.notificationTarget = $event;
},
},
},
order: 101,
helper: "任务执行失败实时提醒",
},
},
},
form: {
wrapper: {
title: "上传证书&创建部署流水线",
saveRemind: false,
},
async doSubmit({ form }: any) {
const notifications = [];
if (form.notification != null) {
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: form.notification,
title: form.notificationTarget?.name || "自定义通知",
});
}
const req = {
id: form.id,
cert: form.cert,
pipeline: {
input: omit(form, ["id", "cert", "notification", "notificationTarget"]),
notifications,
},
};
const res = await api.UploadCert(req);
router.push({
path: "/certd/pipeline/detail",
query: { id: res.pipelineId, editMode: "true" },
});
},
},
},
};
}
const { crudOptions } = createCrudOptions();
const wrapper = await openCrudFormDialog({ crudOptions });
wrapperRef.value = wrapper;
}
async function openUpdateCertDialog(id: any) {
function createCrudOptions() {
return {
crudOptions: {
columns: {
"cert.crt": {
title: "证书",
type: "textarea",
form: {
component: {
rows: 4,
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
"cert.key": {
title: "私钥",
type: "textarea",
form: {
component: {
rows: 4,
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
},
form: {
wrapper: {
title: "更新证书",
saveRemind: false,
},
async doSubmit({ form }: any) {
const req = {
id: id,
cert: form.cert,
};
return await api.UploadCert(form);
},
},
},
};
}
const { crudOptions } = createCrudOptions();
await openCrudFormDialog({ crudOptions });
}
return {
openUploadCreateDialog,
openUpdateCertDialog,
};
}

View File

@ -14,7 +14,7 @@ export default function (certPlugins: any[], formWrapperRef: any): CreateCrudOpt
for (const plugin of certPlugins) {
for (const inputKey in plugin.input) {
if (inputs[inputKey]) {
inputs[inputKey].form.show = true;
// inputs[inputKey].form.show = true;
continue;
}
const inputDefine = _.cloneDeep(plugin.input[inputKey]);

View File

@ -4,11 +4,11 @@ export const Dicts = {
sslProviderDict: dict({
data: [
{ value: "letsencrypt", label: "Lets Encrypt" },
{ value: "zerossl", label: "ZeroSSL" }
]
{ value: "zerossl", label: "ZeroSSL" },
],
}),
challengeTypeDict: dict({ data: [{ value: "dns", label: "DNS校验" }] }),
dnsProviderTypeDict: dict({
url: "pi/dnsProvider/dnsProviderTypeDict"
})
url: "pi/dnsProvider/dnsProviderTypeDict",
}),
};

View File

@ -44,8 +44,8 @@ export default {
const notificationApi = createNotificationApi();
await notificationApi.GetOrCreateDefault({ email: form.email });
}
}
}
},
},
}) as any
);
@ -60,9 +60,9 @@ export default {
return {
formWrapperRef,
open,
formWrapperOptions
formWrapperOptions,
};
}
},
};
</script>

View File

@ -0,0 +1,126 @@
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils";
import { omit } from "lodash-es";
import * as api from "/@/views/certd/pipeline/api";
import { message } from "ant-design-vue";
import { nanoid } from "nanoid";
import { useRouter } from "vue-router";
export function setRunnableIds(pipeline: any) {
const idMap: any = {};
function createId(oldId: any) {
if (oldId == null) {
return nanoid();
}
const newId = nanoid();
idMap[oldId] = newId;
return newId;
}
if (pipeline.stages) {
for (const stage of pipeline.stages) {
stage.id = createId(stage.id);
if (stage.tasks) {
for (const task of stage.tasks) {
task.id = createId(task.id);
if (task.steps) {
for (const step of task.steps) {
step.id = createId(step.id);
}
}
}
}
}
}
for (const trigger of pipeline.triggers) {
trigger.id = nanoid();
}
for (const notification of pipeline.notifications) {
notification.id = nanoid();
}
let content = JSON.stringify(pipeline);
for (const key in idMap) {
content = content.replaceAll(key, idMap[key]);
}
return JSON.parse(content);
}
export function useCertd(certdFormRef: any) {
const router = useRouter();
async function openAddCertdPipelineDialog() {
//检查是否流水线数量超出限制
await checkPipelineLimit();
certdFormRef.value.open(async ({ form }: any) => {
// 添加certd pipeline
const triggers = [];
if (form.triggerCron) {
triggers.push({ title: "定时触发", type: "timer", props: { cron: form.triggerCron } });
}
const notifications = [];
if (form.notification != null) {
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: form.notification,
title: form.notificationTarget?.name || "自定义通知",
});
}
const pluginInput = omit(form, ["triggerCron", "notification", "notificationTarget", "certApplyPlugin"]);
let pipeline = {
title: form.domains[0] + "证书自动化",
runnableType: "pipeline",
stages: [
{
title: "证书申请阶段",
maxTaskCount: 1,
runnableType: "stage",
tasks: [
{
title: "证书申请任务",
runnableType: "task",
steps: [
{
title: "申请证书",
runnableType: "step",
input: {
renewDays: 35,
...pluginInput,
},
strategy: {
runStrategy: 0, // 正常执行
},
type: form.certApplyPlugin,
},
],
},
],
},
],
triggers,
notifications,
};
pipeline = setRunnableIds(pipeline);
/**
* // cert: 证书; backup: 备份; custom:自定义;
* type: string;
* // custom: 自定义; monitor: 监控;
* from: string;
*/
const id = await api.Save({
title: pipeline.title,
content: JSON.stringify(pipeline),
keepHistoryCount: 30,
type: "cert",
from: "custom",
});
message.success("创建成功,请添加证书部署任务");
router.push({ path: "/certd/pipeline/detail", query: { id, editMode: "true" } });
});
}
return {
openAddCertdPipelineDialog,
};
}

View File

@ -4,62 +4,26 @@ import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes, useUi } from "@fast-crud/fast-crud";
import { statusUtil } from "/@/views/certd/pipeline/pipeline/utils/util.status";
import { nanoid } from "nanoid";
import { message, Modal, notification } from "ant-design-vue";
import { Modal, notification } from "ant-design-vue";
import { env } from "/@/utils/util.env";
import { useUserStore } from "/@/store/modules/user";
import dayjs from "dayjs";
import { useSettingStore } from "/@/store/modules/settings";
import * as _ from "lodash-es";
import { cloneDeep } from "lodash-es";
import { useModal } from "/@/use/use-modal";
import CertView from "./cert-view.vue";
import { eachStages } from "./utils";
import { createNotificationApi as createNotificationApi } from "../notification/api";
import { mySuiteApi } from "/@/views/certd/suite/mine/api";
import { setRunnableIds, useCertd } from "/@/views/certd/pipeline/certd-form/use";
import { useCertUpload } from "/@/views/certd/pipeline/cert-upload/use";
export default function ({ crudExpose, context: { certdFormRef, groupDictRef, selectedRowKeys } }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n();
const lastResRef = ref();
function setRunnableIds(pipeline: any) {
const idMap: any = {};
function createId(oldId: any) {
if (oldId == null) {
return nanoid();
}
const newId = nanoid();
idMap[oldId] = newId;
return newId;
}
if (pipeline.stages) {
for (const stage of pipeline.stages) {
stage.id = createId(stage.id);
if (stage.tasks) {
for (const task of stage.tasks) {
task.id = createId(task.id);
if (task.steps) {
for (const step of task.steps) {
step.id = createId(step.id);
}
}
}
}
}
}
const { openAddCertdPipelineDialog } = useCertd(certdFormRef);
const { openUploadCreateDialog } = useCertUpload();
for (const trigger of pipeline.triggers) {
trigger.id = nanoid();
}
for (const notification of pipeline.notifications) {
notification.id = nanoid();
}
let content = JSON.stringify(pipeline);
for (const key in idMap) {
content = content.replaceAll(key, idMap[key]);
}
return JSON.parse(content);
}
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
@ -96,90 +60,6 @@ export default function ({ crudExpose, context: { certdFormRef, groupDictRef, se
return res;
};
const settingsStore = useSettingStore();
async function addCertdPipeline() {
//检查是否流水线数量超出限制
if (settingsStore.isComm && settingsStore.suiteSetting.enabled) {
//检查数量是否超限
const suiteDetail = await mySuiteApi.SuiteDetailGet();
const max = suiteDetail.pipelineCount.max;
if (max != -1 && max <= suiteDetail.pipelineCount.used) {
notification.error({
message: `对不起,您最多只能创建${max}条流水线,请购买或升级套餐`,
});
return;
}
}
certdFormRef.value.open(async ({ form }: any) => {
// 添加certd pipeline
const triggers = [];
if (form.triggerCron) {
triggers.push({ title: "定时触发", type: "timer", props: { cron: form.triggerCron } });
}
const notifications = [];
if (form.notification != null) {
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: form.notification,
title: form.notificationTarget?.name || "自定义通知",
});
}
let pipeline = {
title: form.domains[0] + "证书自动化",
runnableType: "pipeline",
stages: [
{
title: "证书申请阶段",
maxTaskCount: 1,
runnableType: "stage",
tasks: [
{
title: "证书申请任务",
runnableType: "task",
steps: [
{
title: "申请证书",
runnableType: "step",
input: {
renewDays: 35,
...form,
},
strategy: {
runStrategy: 0, // 正常执行
},
type: form.certApplyPlugin,
},
],
},
],
},
],
triggers,
notifications,
};
pipeline = setRunnableIds(pipeline);
/**
* // cert: 证书; backup: 备份; custom:自定义;
* type: string;
* // custom: 自定义; monitor: 监控;
* from: string;
*/
const id = await api.Save({
title: pipeline.title,
content: JSON.stringify(pipeline),
keepHistoryCount: 30,
type: "cert",
from: "custom",
});
message.success("创建成功,请添加证书部署任务");
router.push({ path: "/certd/pipeline/detail", query: { id, editMode: "true" } });
});
}
const model = useModal();
const viewCert = async (row: any) => {
const cert = await api.GetCert(row.id);
@ -268,14 +148,25 @@ export default function ({ crudExpose, context: { certdFormRef, groupDictRef, se
buttons: {
add: {
order: 5,
icon: "ion:ios-add-circle-outline",
text: "自定义流水线",
},
addCertd: {
order: 1,
text: "创建证书流水线",
type: "primary",
icon: "ion:ios-add-circle-outline",
click() {
addCertdPipeline();
openAddCertdPipelineDialog();
},
},
uploadCert: {
order: 2,
text: "上传证书部署",
type: "primary",
icon: "ion:cloud-upload-outline",
click() {
openUploadCreateDialog();
},
},
},
@ -329,7 +220,7 @@ export default function ({ crudExpose, context: { certdFormRef, groupDictRef, se
const { ui } = useUi();
// @ts-ignore
let row = context[ui.tableColumn.row];
row = _.cloneDeep(row);
row = cloneDeep(row);
row.title = row.title + "_copy";
await crudExpose.openCopy({
row: row,

View File

@ -1,10 +1,13 @@
import { forEach } from "lodash-es";
import { mySuiteApi } from "/@/views/certd/suite/mine/api";
import { notification } from "ant-design-vue";
import { useSettingStore } from "/@/store/modules/settings";
export function eachStages(list: any[], exec: (item: any, runnableType: string) => void, runnableType: string = "stage") {
if (!list || list.length <= 0) {
return;
}
forEach(list, (item) => {
forEach(list, item => {
exec(item, runnableType);
if (runnableType === "stage") {
eachStages(item.tasks, exec, "task");
@ -13,3 +16,19 @@ export function eachStages(list: any[], exec: (item: any, runnableType: string)
}
});
}
export async function checkPipelineLimit() {
const settingsStore = useSettingStore();
if (settingsStore.isComm && settingsStore.suiteSetting.enabled) {
//检查数量是否超限
const suiteDetail = await mySuiteApi.SuiteDetailGet();
const max = suiteDetail.pipelineCount.max;
if (max != -1 && max <= suiteDetail.pipelineCount.used) {
notification.error({
message: `对不起,您最多只能创建${max}条流水线,请购买或升级套餐`,
});
throw new Error("流水线数量超限");
}
}
}

View File

@ -121,6 +121,16 @@ export class CertInfoController extends CrudController<CertInfoService> {
return this.ok(list);
}
@Post('/getCert', { summary: Constants.per.authOnly })
async getCert(@Query('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
const certInfoEntity = await this.service.info(id);
const certInfo = JSON.parse(certInfoEntity.certInfo);
return this.ok(certInfo);
}
@Post('/upload', { summary: Constants.per.authOnly })
async upload(@Body(ALL) body: {cert: CertInfo, pipeline: any, id?: number}) {
if (body.id) {
@ -131,23 +141,16 @@ export class CertInfoController extends CrudController<CertInfoService> {
userId: this.getUserId(),
cert: body.cert,
});
return this.ok();
}else{
//添加
await this.certUploadService.createUploadCertPipeline({
const res = await this.certUploadService.createUploadCertPipeline({
userId: this.getUserId(),
cert: body.cert,
pipeline: body.pipeline,
});
return this.ok(res)
}
return this.ok();
}
@Post('/getCert', { summary: Constants.per.authOnly })
async getCert(@Query('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
const certInfoEntity = await this.service.info(id);
const certInfo = JSON.parse(certInfoEntity.certInfo);
return this.ok(certInfo);
}
}

View File

@ -26,6 +26,10 @@ export type UpdateCertReq = {
export type CreateUploadPipelineReq = {
cert: CertInfo;
userId: number;
pipeline?:{
input?:any;
notifications?:any[]
}
};
@Provide("CertUploadService")
@ -86,13 +90,16 @@ export class CertUploadService extends BaseService<CertInfoEntity> {
});
const pipelineTitle = certReader.getAllDomains()[0] +"上传证书自动部署";
const notifications = [];
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: 0,
title: "默认通知",
});
const notifications = body.pipeline?.notifications ||[];
if(notifications.length === 0){
notifications.push({
type: "custom",
when: ["error", "turnToSuccess", "success"],
notificationId: 0,
title: "默认通知",
});
}
let pipeline = {
title: pipelineTitle,
runnableType: "pipeline",
@ -115,6 +122,7 @@ export class CertUploadService extends BaseService<CertInfoEntity> {
input: {
certInfoId: newCertInfo.id,
domains: newCertInfo.domains.split(','),
...body.pipeline?.input
},
strategy: {
runStrategy: 0, // 正常执行
@ -144,7 +152,10 @@ export class CertUploadService extends BaseService<CertInfoEntity> {
pipelineId: newPipeline.id
});
return newCertInfo.id
return {
id:newCertInfo.id,
pipelineId: newPipeline.id
}
})