perf: 插件支持导入导出

pull/409/head
xiaojunnuo 2025-04-15 23:43:01 +08:00
parent d66de26de4
commit cf8abb4528
8 changed files with 306 additions and 45 deletions

View File

@ -1,7 +1,4 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline"; import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { ConnectConfig } from "ssh2";
import { SshClient } from "./ssh.js";
@IsAccess({ @IsAccess({
name: "ssh", name: "ssh",
title: "主机登录授权", title: "主机登录授权",
@ -9,7 +6,7 @@ import { SshClient } from "./ssh.js";
icon: "clarity:host-line", icon: "clarity:host-line",
input: {}, input: {},
}) })
export class SshAccess extends BaseAccess implements ConnectConfig { export class SshAccess extends BaseAccess {
@AccessInput({ @AccessInput({
title: "主机地址", title: "主机地址",
component: { component: {
@ -125,6 +122,7 @@ export class SshAccess extends BaseAccess implements ConnectConfig {
testRequest = true; testRequest = true;
async onTestRequest() { async onTestRequest() {
const { SshClient } = await import("./ssh.js");
const client = new SshClient(this.ctx.logger); const client = new SshClient(this.ctx.logger);
await client.exec({ await client.exec({

View File

@ -1,22 +1,33 @@
// @ts-ignore // @ts-ignore
import ssh2, { ConnectConfig, ExecOptions } from "ssh2";
import ssh2Constants from "ssh2/lib/protocol/constants.js";
import path from "path"; import path from "path";
import * as _ from "lodash-es"; import { isArray } from "lodash-es";
import { ILogger } from "@certd/basic"; import { ILogger } from "@certd/basic";
import { SshAccess } from "./ssh-access.js"; import { SshAccess } from "./ssh-access.js";
import stripAnsi from "strip-ansi";
import { SocksClient } from "socks";
import { SocksProxy, SocksProxyType } from "socks/typings/common/constants.js";
import fs from "fs"; import fs from "fs";
import { SocksProxyType } from "socks/typings/common/constants";
export type TransportItem = { localPath: string; remotePath: string }; export type TransportItem = { localPath: string; remotePath: string };
export interface SocksProxy {
ipaddress?: string;
host?: string;
port: number;
type: any;
userId?: string;
password?: string;
custom_auth_method?: number;
custom_auth_request_handler?: () => Promise<Buffer>;
custom_auth_response_size?: number;
custom_auth_response_handler?: (data: Buffer) => Promise<boolean>;
}
export type SshConnectConfig = {
sock?: any;
};
export class AsyncSsh2Client { export class AsyncSsh2Client {
conn: ssh2.Client; conn: any;
logger: ILogger; logger: ILogger;
connConf: SshAccess & ssh2.ConnectConfig; connConf: SshAccess & SshConnectConfig;
windows = false; windows = false;
encoding: string; encoding: string;
constructor(connConf: SshAccess, logger: ILogger) { constructor(connConf: SshAccess, logger: ILogger) {
@ -40,7 +51,10 @@ export class AsyncSsh2Client {
if (typeof this.connConf.port === "string") { if (typeof this.connConf.port === "string") {
this.connConf.port = parseInt(this.connConf.port); this.connConf.port = parseInt(this.connConf.port);
} }
const proxyOption: SocksProxy = this.parseSocksProxyFromUri(this.connConf.socksProxy);
const { SocksClient } = await import("socks");
const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy);
const info = await SocksClient.createConnection({ const info = await SocksClient.createConnection({
proxy: proxyOption, proxy: proxyOption,
command: "connect", command: "connect",
@ -53,10 +67,12 @@ export class AsyncSsh2Client {
this.connConf.sock = info.socket; this.connConf.sock = info.socket;
} }
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants; const ssh2 = await import("ssh2");
const ssh2Constants = await import("ssh2/lib/protocol/constants.js");
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const conn = new ssh2.Client(); const conn = new ssh2.default.Client();
conn conn
.on("error", (err: any) => { .on("error", (err: any) => {
this.logger.error("连接失败", err); this.logger.error("连接失败", err);
@ -197,6 +213,8 @@ export class AsyncSsh2Client {
} }
async shell(script: string | string[]): Promise<string> { async shell(script: string | string[]): Promise<string> {
const stripAnsiModule = await import("strip-ansi");
const stripAnsi = stripAnsiModule.default;
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
this.logger.info(`执行shell脚本[${this.connConf.host}][shell]: ` + script); this.logger.info(`执行shell脚本[${this.connConf.host}][shell]: ` + script);
this.conn.shell((err: Error, stream: any) => { this.conn.shell((err: Error, stream: any) => {
@ -449,7 +467,7 @@ export class SshClient {
script = script.join(" && "); script = script.join(" && ");
} else { } else {
const newLine = isLinux ? "\n" : "\r\n"; const newLine = isLinux ? "\n" : "\r\n";
if (_.isArray(script)) { if (isArray(script)) {
script = script as Array<string>; script = script as Array<string>;
script = script.join(newLine); script = script.join(newLine);
} }
@ -465,7 +483,7 @@ export class SshClient {
async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> { async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> {
let { script } = options; let { script } = options;
const { connectConf } = options; const { connectConf } = options;
if (_.isArray(script)) { if (isArray(script)) {
script = script as Array<string>; script = script as Array<string>;
if (connectConf.windows) { if (connectConf.windows) {
script = script.join("\r\n"); script = script.join("\r\n");

View File

@ -66,6 +66,22 @@ export async function SetDisabled(data: { id?: number; name?: string; type?: str
}); });
} }
export async function ExportPlugin(id: number) {
return await request({
url: apiPrefix + "/export",
method: "post",
data: { id },
});
}
export async function ImportPlugin(body: any) {
return await request({
url: apiPrefix + "/import",
method: "post",
data: body,
});
}
export type PluginConfigBean = { export type PluginConfigBean = {
name: string; name: string;
disabled: boolean; disabled: boolean;

View File

@ -2,8 +2,8 @@ import * as api from "./api";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { Ref, ref } from "vue"; import { Ref, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, useFormWrapper, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal } from "ant-design-vue"; import { Modal, notification } from "ant-design-vue";
//@ts-ignore //@ts-ignore
import yaml from "js-yaml"; import yaml from "js-yaml";
@ -36,7 +36,74 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const selectedRowKeys: Ref<any[]> = ref([]); const selectedRowKeys: Ref<any[]> = ref([]);
context.selectedRowKeys = selectedRowKeys; context.selectedRowKeys = selectedRowKeys;
const { openCrudFormDialog } = useFormWrapper();
async function openImportDialog() {
function createCrudOptions() {
return {
crudOptions: {
columns: {
content: {
title: "插件文件",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 8,
},
},
col: {
span: 24,
},
helper: "选择插件文件",
},
},
override: {
title: "同名覆盖",
type: "dict-switch",
dict: dict({
data: [
{
value: true,
label: "覆盖",
},
{
value: false,
label: "不覆盖",
},
],
}),
form: {
value: false,
col: {
span: 24,
},
helper: "如果已有相同名称插件,直接覆盖",
},
},
},
form: {
wrapper: {
title: "导入插件",
saveRemind: false,
},
afterSubmit() {
notification.success({ message: "操作成功" });
},
async doSubmit({ form }: any) {
return await api.ImportPlugin({
...form,
});
},
},
},
};
}
const { crudOptions } = createCrudOptions();
await openCrudFormDialog({ crudOptions });
}
return { return {
crudOptions: { crudOptions: {
settings: { settings: {
@ -65,8 +132,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
buttons: { buttons: {
add: { add: {
show: true, show: true,
icon: "ion:ios-add-circle-outline",
text: "自定义插件", text: "自定义插件",
}, },
import: {
show: true,
icon: "ion:cloud-upload-outline",
text: "导入",
type: "primary",
async click() {
await openImportDialog();
},
},
}, },
}, },
rowHandle: { rowHandle: {
@ -85,10 +162,33 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}), }),
}, },
remove: { remove: {
order: 999,
show: compute(({ row }) => { show: compute(({ row }) => {
return row.type === "custom"; return row.type === "custom";
}), }),
}, },
export: {
text: null,
icon: "ion:cloud-download-outline",
title: "导出",
type: "link",
show: compute(({ row }) => {
return row.type === "custom";
}),
async click({ row }) {
//将文本内容,作为文件下载
const content = await api.ExportPlugin(row.id);
if (content) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${row.name}.yaml`;
link.click();
URL.revokeObjectURL(url);
}
},
},
}, },
}, },
table: { table: {
@ -182,8 +282,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
form: { form: {
show: true, show: true,
helper: "必须为英文,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用不要修改名称", helper: "必须为英文或数字,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用不要修改名称",
rules: [{ required: true }], rules: [
{ required: true },
{
type: "regexp",
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
message: "必须为英文或数字,驼峰命名,类型作为前缀",
},
],
}, },
column: { column: {
width: 250, width: 250,
@ -205,7 +312,14 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
form: { form: {
show: true, show: true,
helper: "上传到插件商店时,将作为插件名称前缀,例如greper/pluginName", helper: "上传到插件商店时,将作为插件名称前缀,例如greper/pluginName",
rules: [{ required: true }], rules: [
{ required: true },
{
type: "regexp",
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
message: "必须为英文字母或数字",
},
],
}, },
column: { column: {
width: 200, width: 200,

View File

@ -26,6 +26,11 @@ process.on('uncaughtException', error => {
}); });
@Configuration({ @Configuration({
// detectorOptions: {
// ignore: [
// '**/plugins/**'
// ]
// },
imports: [ imports: [
koa, koa,
orm, orm,

View File

@ -1,7 +1,7 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core'; import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { CrudController } from '@certd/lib-server'; import { CrudController } from '@certd/lib-server';
import { PluginService } from '../../../modules/plugin/service/plugin-service.js'; import { PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
import { CommPluginConfig, PluginConfigService } from '../../../modules/plugin/service/plugin-config-service.js'; import { CommPluginConfig, PluginConfigService } from '../../../modules/plugin/service/plugin-config-service.js';
/** /**
* *
@ -82,4 +82,17 @@ export class PluginController extends CrudController<PluginService> {
const res = await this.pluginConfigService.saveCommPluginConfig(body); const res = await this.pluginConfigService.saveCommPluginConfig(body);
return this.ok(res); return this.ok(res);
} }
@Post('/import', { summary: 'sys:settings:edit' })
async import(@Body(ALL) body: PluginImportReq) {
const res = await this.service.importPlugin(body);
return this.ok(res);
}
@Post('/export', { summary: 'sys:settings:edit' })
async export(@Body('id') id: number) {
const res = await this.service.exportPlugin(id);
return this.ok(res);
}
} }

View File

@ -18,6 +18,8 @@ export type PluginFindReq = {
name?: string; name?: string;
type: string; type: string;
}; };
@Provide() @Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true }) @Scope(ScopeEnum.Request, { allowDowngrade: true })
export class PluginConfigService { export class PluginConfigService {

View File

@ -12,6 +12,10 @@ import { logger } from "@certd/basic";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js"; import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js";
export type PluginImportReq = {
content: string,
override?: boolean;
};
@Provide() @Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true }) @Scope(ScopeEnum.Request, { allowDowngrade: true })
@ -41,7 +45,7 @@ export class PluginService extends BaseService<PluginEntity> {
const builtInList = await this.getBuiltInEntityList(); const builtInList = await this.getBuiltInEntityList();
//获取分页数据 //获取分页数据
const data = builtInList.slice(offset, offset + limit); const data = builtInList.slice(offset, offset + limit);
return { return {
records: data, records: data,
@ -53,7 +57,7 @@ export class PluginService extends BaseService<PluginEntity> {
async getEnabledBuildInGroup(isSimple = false) { async getEnabledBuildInGroup(isSimple = false) {
const groups = this.builtInPluginService.getGroups(); const groups = this.builtInPluginService.getGroups();
if(isSimple){ if (isSimple) {
for (const key in groups) { for (const key in groups) {
const group = groups[key]; const group = groups[key];
group.plugins.forEach(item => { group.plugins.forEach(item => {
@ -97,8 +101,8 @@ export class PluginService extends BaseService<PluginEntity> {
}); });
const disabledNames = list.map(it => it.name); const disabledNames = list.map(it => it.name);
return builtInList.filter(it =>{ return builtInList.filter(it => {
return !disabledNames.includes(it.name) return !disabledNames.includes(it.name);
}); });
} }
@ -168,33 +172,48 @@ export class PluginService extends BaseService<PluginEntity> {
name: param.name, name: param.name,
author: param.author author: param.author
} }
}) });
if (old) { if (old) {
throw new Error(`插件${param.author}/${param.name}已存在`); throw new Error(`插件${param.author}/${param.name}已存在`);
} }
let plugin:any = {} let plugin: any = {};
if (param.pluginType === "access") { if (param.pluginType === "access") {
plugin = getDefaultAccessPlugin() plugin = getDefaultAccessPlugin();
delete param.group delete param.group;
}else if (param.pluginType === "deploy") { } else if (param.pluginType === "deploy") {
plugin = getDefaultDeployPlugin() plugin = getDefaultDeployPlugin();
}else if (param.pluginType === "dnsProvider") { } else if (param.pluginType === "dnsProvider") {
plugin = getDefaultDnsPlugin() plugin = getDefaultDnsPlugin();
delete param.group delete param.group;
}else{ } else {
throw new Error(`插件类型${param.pluginType}不支持`); throw new Error(`插件类型${param.pluginType}不支持`);
} }
return await super.add({ return await super.add({
...param, ...param,
...plugin ...plugin
}); });
} }
async update(param: any) {
const old = await this.repository.findOne({
where: {
name: param.name,
author: param.author
}
});
if (old && old.id !== param.id) {
throw new Error(`插件${param.author}/${param.name}已存在`);
}
return await super.update(param);
}
async compile(code: string) { async compile(code: string) {
const ts = await import("typescript") const ts = await import("typescript");
return ts.transpileModule(code, { return ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.ESNext } compilerOptions: { module: ts.ModuleKind.ESNext }
}).outputText; }).outputText;
@ -220,16 +239,16 @@ export class PluginService extends BaseService<PluginEntity> {
if (info && info.length > 0) { if (info && info.length > 0) {
const plugin = info[0]; const plugin = info[0];
try{ try {
const AsyncFunction = Object.getPrototypeOf(async () => { const AsyncFunction = Object.getPrototypeOf(async () => {
}).constructor; }).constructor;
// const script = await this.compile(plugin.content); // const script = await this.compile(plugin.content);
const script = plugin.content const script = plugin.content;
const getPluginClass = new AsyncFunction(script); const getPluginClass = new AsyncFunction(script);
return await getPluginClass({ logger: logger }); return await getPluginClass({ logger: logger });
}catch (e) { } catch (e) {
logger.error("编译插件失败:",e) logger.error("编译插件失败:", e);
throw e throw e;
} }
} }
@ -284,4 +303,80 @@ export class PluginService extends BaseService<PluginEntity> {
}); });
} }
async exportPlugin(id: number) {
const info = await this.info(id);
if (!info) {
throw new Error("插件不存在");
}
const metadata = yaml.load(info.metadata || "");
const extra = yaml.load(info.extra || "");
const content = info.content;
delete info.metadata;
delete info.extra;
delete info.content;
delete info.id;
delete info.createTime;
delete info.updateTime;
const plugin = {
...info,
...metadata,
...extra,
content
};
return yaml.dump(plugin) as string;
}
async importPlugin(req: PluginImportReq) {
const loaded = yaml.load(req.content);
if (!loaded) {
throw new Error("插件内容不能为空");
}
delete loaded.id
const old = await this.repository.findOne({
where: {
name: loaded.name,
author: loaded.author
}
});
const metadata = {
input: loaded.input,
output: loaded.output
};
const extra = {
dependPlugins: loaded.dependPlugins,
default: loaded.default,
showRunStrategy: loaded.showRunStrategy
};
const pluginEntity = {
...loaded,
metadata: yaml.dump(metadata),
extra: yaml.dump(extra),
content: req.content,
disabled: false
};
if (!pluginEntity.pluginType) {
throw new Error(`插件类型不能为空`);
}
if (old) {
if (!req.override) {
throw new Error(`插件${loaded.author}/${loaded.name}已存在`);
}
//update
pluginEntity.id = old.id;
await this.update(pluginEntity);
} else {
//add
const { id } = await this.add(pluginEntity);
pluginEntity.id = id;
}
return {
id: pluginEntity.id
};
}
} }