feat: enhance plugin configuration with remote port and schema support
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m16s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m57s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m56s

This commit is contained in:
2026-01-03 23:53:04 +08:00
parent 08262654d6
commit d6bde71c94
8 changed files with 148 additions and 78 deletions

View File

@@ -2,6 +2,17 @@ package db
import "github.com/gotunnel/pkg/protocol" import "github.com/gotunnel/pkg/protocol"
// ConfigField 配置字段定义
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// ClientPlugin 客户端已安装的插件 // ClientPlugin 客户端已安装的插件
type ClientPlugin struct { type ClientPlugin struct {
Name string `json:"name"` Name string `json:"name"`
@@ -9,6 +20,8 @@ type ClientPlugin struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Running bool `json:"running"` // 运行状态 Running bool `json:"running"` // 运行状态
Config map[string]string `json:"config,omitempty"` // 插件配置 Config map[string]string `json:"config,omitempty"` // 插件配置
RemotePort int `json:"remote_port,omitempty"`// 远程监听端口
ConfigSchema []ConfigField `json:"config_schema,omitempty"` // 配置模式
} }
// Client 客户端数据 // Client 客户端数据

View File

@@ -93,16 +93,19 @@ type StorePluginInfo struct {
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
SignatureURL string `json:"signature_url,omitempty"` SignatureURL string `json:"signature_url,omitempty"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
} }
// StoreInstallRequest 从商店安装插件请求 // StoreInstallRequest 从商店安装插件请求
// @Description 从插件商店安装插件到客户端 // @Description 从插件商店安装插件到客户端
type StoreInstallRequest struct { type StoreInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"` PluginName string `json:"plugin_name" binding:"required"`
Version string `json:"version"`
DownloadURL string `json:"download_url" binding:"required,url"` DownloadURL string `json:"download_url" binding:"required,url"`
SignatureURL string `json:"signature_url" binding:"required,url"` SignatureURL string `json:"signature_url" binding:"required,url"`
ClientID string `json:"client_id" binding:"required"` ClientID string `json:"client_id" binding:"required"`
RemotePort int `json:"remote_port"` RemotePort int `json:"remote_port"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
} }
// JSPluginPushRequest 推送 JS 插件到客户端请求 // JSPluginPushRequest 推送 JS 插件到客户端请求

View File

@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
// removed router import "github.com/gotunnel/internal/server/db"
"github.com/gotunnel/internal/server/router/dto" "github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/plugin" "github.com/gotunnel/pkg/plugin"
) )
@@ -145,16 +147,42 @@ func (h *PluginHandler) GetClientConfig(c *gin.Context) {
return return
} }
// 查找客户端的插件
var clientPlugin *db.ClientPlugin
for i, p := range client.Plugins {
if p.Name == pluginName {
clientPlugin = &client.Plugins[i]
break
}
}
if clientPlugin == nil {
NotFound(c, "plugin not installed on client")
return
}
var schemaFields []dto.ConfigField
// 优先使用客户端插件保存的 ConfigSchema
if len(clientPlugin.ConfigSchema) > 0 {
for _, f := range clientPlugin.ConfigSchema {
schemaFields = append(schemaFields, dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
})
}
} else {
// 尝试从内置插件获取配置模式 // 尝试从内置插件获取配置模式
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName) schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
var schemaFields []dto.ConfigField
if err != nil { if err != nil {
// 如果内置插件中找不到,尝试从 JS 插件获取 // 如果内置插件中找不到,尝试从 JS 插件获取
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName) jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if jsErr != nil { if jsErr == nil {
// 两者都找不到,返回空 schema
schemaFields = []dto.ConfigField{}
} else {
// 使用 JS 插件的 config 作为动态 schema // 使用 JS 插件的 config 作为动态 schema
for key := range jsPlugin.Config { for key := range jsPlugin.Config {
schemaFields = append(schemaFields, dto.ConfigField{ schemaFields = append(schemaFields, dto.ConfigField{
@@ -167,18 +195,25 @@ func (h *PluginHandler) GetClientConfig(c *gin.Context) {
} else { } else {
schemaFields = convertRouterConfigFields(schema) schemaFields = convertRouterConfigFields(schema)
} }
}
// 查找客户端的插件配置 // 添加 remote_port 作为系统配置字段(始终显示)
var config map[string]string schemaFields = append([]dto.ConfigField{{
for _, p := range client.Plugins { Key: "remote_port",
if p.Name == pluginName { Label: "远程端口",
config = p.Config Type: "number",
break Description: "服务端监听端口,修改后需重启插件生效",
} }}, schemaFields...)
}
// 构建配置值
config := clientPlugin.Config
if config == nil { if config == nil {
config = make(map[string]string) config = make(map[string]string)
} }
// 将 remote_port 加入配置
if clientPlugin.RemotePort > 0 {
config["remote_port"] = fmt.Sprintf("%d", clientPlugin.RemotePort)
}
Success(c, dto.PluginConfigResponse{ Success(c, dto.PluginConfigResponse{
PluginName: pluginName, PluginName: pluginName,
@@ -218,8 +253,19 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
// 更新插件配置 // 更新插件配置
found := false found := false
portChanged := false
for i, p := range client.Plugins { for i, p := range client.Plugins {
if p.Name == pluginName { if p.Name == pluginName {
// 提取 remote_port 并单独处理
if portStr, ok := req.Config["remote_port"]; ok {
var newPort int
fmt.Sscanf(portStr, "%d", &newPort)
if newPort > 0 && newPort != client.Plugins[i].RemotePort {
client.Plugins[i].RemotePort = newPort
portChanged = true
}
delete(req.Config, "remote_port") // 不保存到 Config map
}
client.Plugins[i].Config = req.Config client.Plugins[i].Config = req.Config
found = true found = true
break break
@@ -242,12 +288,12 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
if online { if online {
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil { if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
// 配置已保存,但同步失败,返回警告 // 配置已保存,但同步失败,返回警告
PartialSuccess(c, gin.H{"status": "partial"}, "config saved but sync failed: "+err.Error()) PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
return return
} }
} }
Success(c, gin.H{"status": "ok"}) Success(c, gin.H{"status": "ok", "port_changed": portChanged})
} }
// convertConfigFields 转换插件配置字段到 DTO // convertConfigFields 转换插件配置字段到 DTO

View File

@@ -164,10 +164,29 @@ func (h *StoreHandler) Install(c *gin.Context) {
} }
} }
if !exists { if !exists {
version := req.Version
if version == "" {
version = "1.0.0"
}
// 转换 ConfigSchema
var configSchema []db.ConfigField
for _, f := range req.ConfigSchema {
configSchema = append(configSchema, db.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
})
}
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{ dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
Name: req.PluginName, Name: req.PluginName,
Version: "1.0.0", Version: version,
Enabled: true, Enabled: true,
RemotePort: req.RemotePort,
ConfigSchema: configSchema,
}) })
} }
h.app.GetClientStore().UpdateClient(dbClient) h.app.GetClientStore().UpdateClient(dbClient)

View File

@@ -1,5 +1,5 @@
import { get, post, put, del, getToken } from '../config/axios' import { get, post, put, del, getToken } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions } from '../types' import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions, ConfigField } from '../types'
// 重新导出 token 管理方法 // 重新导出 token 管理方法
export { getToken, setToken, removeToken } from '../config/axios' export { getToken, setToken, removeToken } from '../config/axios'
@@ -49,8 +49,8 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店 // 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins') export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string, remotePort?: number) => export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string, remotePort?: number, version?: string, configSchema?: ConfigField[]) =>
post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId, remote_port: remotePort || 0 }) post('/store/install', { plugin_name: pluginName, version: version || '', download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId, remote_port: remotePort || 0, config_schema: configSchema || [] })
// 客户端插件配置 // 客户端插件配置
export const getClientPluginConfig = (clientId: string, pluginName: string) => export const getClientPluginConfig = (clientId: string, pluginName: string) =>

View File

@@ -112,6 +112,7 @@ export interface StorePluginInfo {
icon?: string icon?: string
download_url?: string download_url?: string
signature_url?: string signature_url?: string
config_schema?: ConfigField[]
} }
// JS 插件信息 // JS 插件信息

View File

@@ -86,8 +86,7 @@ const configLoading = ref(false)
const showStoreModal = ref(false) const showStoreModal = ref(false)
const storePlugins = ref<StorePluginInfo[]>([]) const storePlugins = ref<StorePluginInfo[]>([])
const storeLoading = ref(false) const storeLoading = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null) const storeInstalling = ref<string | null>(null) // 正在安装的插件名称
const storeInstalling = ref(false)
// 日志查看相关 // 日志查看相关
const showLogViewer = ref(false) const showLogViewer = ref(false)
@@ -96,7 +95,6 @@ const showLogViewer = ref(false)
const openStoreModal = async () => { const openStoreModal = async () => {
showStoreModal.value = true showStoreModal.value = true
storeLoading.value = true storeLoading.value = true
selectedStorePlugin.value = null
try { try {
const { data } = await getStorePlugins() const { data } = await getStorePlugins()
storePlugins.value = (data.plugins || []).filter(p => p.download_url) storePlugins.value = (data.plugins || []).filter(p => p.download_url)
@@ -113,18 +111,17 @@ const handleInstallStorePlugin = async (plugin: StorePluginInfo) => {
message.error('该插件没有下载地址') message.error('该插件没有下载地址')
return return
} }
storeInstalling.value = true
selectedStorePlugin.value = plugin storeInstalling.value = plugin.name
try { try {
await installStorePlugin(plugin.name, plugin.download_url, plugin.signature_url || '', clientId) await installStorePlugin(plugin.name, plugin.download_url, plugin.signature_url || '', clientId, 8080, plugin.version, plugin.config_schema)
message.success(`已安装 ${plugin.name}`) message.success(`已安装 ${plugin.name},可在配置中修改端口和其他设置`)
showStoreModal.value = false showStoreModal.value = false
await loadClient() await loadClient()
} catch (e: any) { } catch (e: any) {
message.error(e.response?.data || '安装失败') message.error(e.response?.data || '安装失败')
} finally { } finally {
storeInstalling.value = false storeInstalling.value = null
selectedStorePlugin.value = null
} }
} }
@@ -724,7 +721,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-button <n-button
size="small" size="small"
type="primary" type="primary"
:loading="storeInstalling && selectedStorePlugin?.name === plugin.name" :loading="storeInstalling === plugin.name"
@click="handleInstallStorePlugin(plugin)" @click="handleInstallStorePlugin(plugin)"
> >
安装 安装

View File

@@ -230,13 +230,11 @@ const toggleJSPlugin = async (plugin: JSPlugin) => {
const showInstallModal = ref(false) const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null) const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const selectedClientId = ref('') const selectedClientId = ref('')
const storePluginRemotePort = ref<number | null>(8080)
const installing = ref(false) const installing = ref(false)
const openInstallModal = (plugin: StorePluginInfo) => { const openInstallModal = (plugin: StorePluginInfo) => {
selectedStorePlugin.value = plugin selectedStorePlugin.value = plugin
selectedClientId.value = '' selectedClientId.value = ''
storePluginRemotePort.value = 8080
showInstallModal.value = true showInstallModal.value = true
} }
@@ -260,9 +258,11 @@ const handleInstallStorePlugin = async () => {
selectedStorePlugin.value.download_url, selectedStorePlugin.value.download_url,
selectedStorePlugin.value.signature_url, selectedStorePlugin.value.signature_url,
selectedClientId.value, selectedClientId.value,
storePluginRemotePort.value || 0 8080, // 默认端口,可在配置中修改
selectedStorePlugin.value.version,
selectedStorePlugin.value.config_schema
) )
message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`) message.success(`已安装 ${selectedStorePlugin.value.name},可在客户端配置中修改端口和其他设置`)
showInstallModal.value = false showInstallModal.value = false
} catch (e: any) { } catch (e: any) {
message.error(e.response?.data || '安装失败') message.error(e.response?.data || '安装失败')
@@ -464,16 +464,7 @@ onMounted(() => {
placeholder="选择要安装到的客户端" placeholder="选择要安装到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))" :options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/> />
<div> <p style="margin: 0; color: #999; font-size: 12px;">安装后可在客户端详情页配置端口和其他设置</p>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口服务端监听端口:</p>
<n-input-number
v-model:value="storePluginRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
</div>
</n-space> </n-space>
<template #footer> <template #footer>
<n-space justify="end"> <n-space justify="end">