diff --git a/internal/client/tunnel/client.go b/internal/client/tunnel/client.go index 65750a9..40034c1 100644 --- a/internal/client/tunnel/client.go +++ b/internal/client/tunnel/client.go @@ -194,6 +194,9 @@ func (c *Client) handleStream(stream net.Conn) { c.handleProxyConnect(stream, msg) case protocol.MsgTypeUDPData: c.handleUDPData(stream, msg) + case protocol.MsgTypePluginConfig: + defer stream.Close() + c.handlePluginConfig(msg) } } @@ -346,3 +349,28 @@ func (c *Client) findRuleByPort(port int) *protocol.ProxyRule { } return nil } + +// handlePluginConfig 处理插件配置同步 +func (c *Client) handlePluginConfig(msg *protocol.Message) { + var cfg protocol.PluginConfigSync + if err := msg.ParsePayload(&cfg); err != nil { + log.Printf("[Client] Parse plugin config error: %v", err) + return + } + + log.Printf("[Client] Received config for plugin: %s", cfg.PluginName) + + // 应用配置到插件 + if c.pluginRegistry != nil { + handler, err := c.pluginRegistry.Get(cfg.PluginName) + if err != nil { + log.Printf("[Client] Plugin %s not found: %v", cfg.PluginName, err) + return + } + if err := handler.Init(cfg.Config); err != nil { + log.Printf("[Client] Plugin %s init error: %v", cfg.PluginName, err) + return + } + log.Printf("[Client] Plugin %s config applied", cfg.PluginName) + } +} diff --git a/internal/server/db/interface.go b/internal/server/db/interface.go index b4354d5..0a70515 100644 --- a/internal/server/db/interface.go +++ b/internal/server/db/interface.go @@ -4,9 +4,10 @@ import "github.com/gotunnel/pkg/protocol" // ClientPlugin 客户端已安装的插件 type ClientPlugin struct { - Name string `json:"name"` - Version string `json:"version"` - Enabled bool `json:"enabled"` + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + Config map[string]string `json:"config,omitempty"` // 插件配置 } // Client 客户端数据 diff --git a/internal/server/router/api.go b/internal/server/router/api.go index fdeee97..7a3623b 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -46,6 +46,20 @@ type ServerInterface interface { EnablePlugin(name string) error DisablePlugin(name string) error InstallPluginsToClient(clientID string, plugins []string) error + // 插件配置 + GetPluginConfigSchema(name string) ([]ConfigField, error) + SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error +} + +// ConfigField 配置字段(从 plugin 包导出) +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"` } // PluginInfo 插件信息 @@ -92,6 +106,7 @@ func RegisterRoutes(r *Router, app AppInterface) { api.HandleFunc("/plugins", h.handlePlugins) api.HandleFunc("/plugin/", h.handlePlugin) api.HandleFunc("/store/plugins", h.handleStorePlugins) + api.HandleFunc("/client-plugin/", h.handleClientPlugin) } func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) { @@ -586,3 +601,143 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) "store_url": storeURL, }) } + +// handleClientPlugin 处理客户端插件配置 +// 路由: /api/client-plugin/{clientID}/{pluginName}/config +func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) { + path := r.URL.Path[len("/api/client-plugin/"):] + if path == "" { + http.Error(rw, "client id required", http.StatusBadRequest) + return + } + + // 解析路径: clientID/pluginName/action + parts := splitPathMulti(path) + if len(parts) < 3 { + http.Error(rw, "invalid path, expected: /api/client-plugin/{clientID}/{pluginName}/config", http.StatusBadRequest) + return + } + + clientID := parts[0] + pluginName := parts[1] + action := parts[2] + + if action != "config" { + http.Error(rw, "invalid action", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + h.getClientPluginConfig(rw, clientID, pluginName) + case http.MethodPut: + h.updateClientPluginConfig(rw, r, clientID, pluginName) + default: + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// splitPathMulti 分割路径为多个部分 +func splitPathMulti(path string) []string { + var parts []string + start := 0 + for i, c := range path { + if c == '/' { + if i > start { + parts = append(parts, path[start:i]) + } + start = i + 1 + } + } + if start < len(path) { + parts = append(parts, path[start:]) + } + return parts +} + +// getClientPluginConfig 获取客户端插件配置 +func (h *APIHandler) getClientPluginConfig(rw http.ResponseWriter, clientID, pluginName string) { + client, err := h.clientStore.GetClient(clientID) + if err != nil { + http.Error(rw, "client not found", http.StatusNotFound) + return + } + + // 获取插件配置模式 + schema, err := h.server.GetPluginConfigSchema(pluginName) + if err != nil { + http.Error(rw, err.Error(), http.StatusNotFound) + return + } + + // 查找客户端的插件配置 + var config map[string]string + for _, p := range client.Plugins { + if p.Name == pluginName { + config = p.Config + break + } + } + if config == nil { + config = make(map[string]string) + } + + h.jsonResponse(rw, map[string]interface{}{ + "plugin_name": pluginName, + "schema": schema, + "config": config, + }) +} + +// updateClientPluginConfig 更新客户端插件配置 +func (h *APIHandler) updateClientPluginConfig(rw http.ResponseWriter, r *http.Request, clientID, pluginName string) { + var req struct { + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + client, err := h.clientStore.GetClient(clientID) + if err != nil { + http.Error(rw, "client not found", http.StatusNotFound) + return + } + + // 更新插件配置 + found := false + for i, p := range client.Plugins { + if p.Name == pluginName { + client.Plugins[i].Config = req.Config + found = true + break + } + } + + if !found { + http.Error(rw, "plugin not installed on client", http.StatusNotFound) + return + } + + // 保存到数据库 + if err := h.clientStore.UpdateClient(client); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + // 如果客户端在线,同步配置 + online, _ := h.server.GetClientStatus(clientID) + if online { + if err := h.server.SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil { + // 配置已保存,但同步失败,返回警告 + h.jsonResponse(rw, map[string]interface{}{ + "status": "partial", + "message": "config saved but sync failed: " + err.Error(), + }) + return + } + } + + h.jsonResponse(rw, map[string]string{"status": "ok"}) +} diff --git a/internal/server/tunnel/server.go b/internal/server/tunnel/server.go index dc3c996..da4f7cd 100644 --- a/internal/server/tunnel/server.go +++ b/internal/server/tunnel/server.go @@ -700,3 +700,62 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr conn.WriteToUDP(respPacket.Data, clientAddr) } } + +// GetPluginConfigSchema 获取插件配置模式 +func (s *Server) GetPluginConfigSchema(name string) ([]router.ConfigField, error) { + if s.pluginRegistry == nil { + return nil, fmt.Errorf("plugin registry not initialized") + } + + handler, err := s.pluginRegistry.Get(name) + if err != nil { + return nil, fmt.Errorf("plugin %s not found", name) + } + + metadata := handler.Metadata() + var result []router.ConfigField + for _, f := range metadata.ConfigSchema { + result = append(result, router.ConfigField{ + Key: f.Key, + Label: f.Label, + Type: string(f.Type), + Default: f.Default, + Required: f.Required, + Options: f.Options, + Description: f.Description, + }) + } + return result, nil +} + +// SyncPluginConfigToClient 同步插件配置到客户端 +func (s *Server) SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error { + s.mu.RLock() + cs, ok := s.clients[clientID] + s.mu.RUnlock() + + if !ok { + return fmt.Errorf("client %s not online", clientID) + } + + return s.sendPluginConfig(cs.Session, pluginName, config) +} + +// sendPluginConfig 发送插件配置到客户端 +func (s *Server) sendPluginConfig(session *yamux.Session, pluginName string, config map[string]string) error { + stream, err := session.Open() + if err != nil { + return err + } + defer stream.Close() + + req := protocol.PluginConfigSync{ + PluginName: pluginName, + Config: config, + } + msg, err := protocol.NewMessage(protocol.MsgTypePluginConfig, req) + if err != nil { + return err + } + return protocol.WriteMessage(stream, msg) +} diff --git a/pkg/plugin/builtin/socks5.go b/pkg/plugin/builtin/socks5.go index 5285597..c14f37c 100644 --- a/pkg/plugin/builtin/socks5.go +++ b/pkg/plugin/builtin/socks5.go @@ -15,12 +15,17 @@ func init() { } const ( - socks5Version = 0x05 - noAuth = 0x00 - cmdConnect = 0x01 - atypIPv4 = 0x01 - atypDomain = 0x03 - atypIPv6 = 0x04 + socks5Version = 0x05 + noAuth = 0x00 + userPassAuth = 0x02 + noAcceptable = 0xFF + userPassAuthVer = 0x01 + authSuccess = 0x00 + authFailure = 0x01 + cmdConnect = 0x01 + atypIPv4 = 0x01 + atypDomain = 0x03 + atypIPv6 = 0x04 ) // SOCKS5Plugin 将现有 SOCKS5 实现封装为 plugin @@ -45,6 +50,28 @@ func (p *SOCKS5Plugin) Metadata() plugin.PluginMetadata { Capabilities: []string{ "dial", "read", "write", "close", }, + ConfigSchema: []plugin.ConfigField{ + { + Key: "auth", + Label: "认证方式", + Type: plugin.ConfigFieldSelect, + Default: "none", + Options: []string{"none", "password"}, + Description: "SOCKS5 认证方式", + }, + { + Key: "username", + Label: "用户名", + Type: plugin.ConfigFieldString, + Description: "认证用户名(仅 password 认证时需要)", + }, + { + Key: "password", + Label: "密码", + Type: plugin.ConfigFieldPassword, + Description: "认证密码(仅 password 认证时需要)", + }, + }, } } @@ -110,7 +137,31 @@ func (p *SOCKS5Plugin) handshake(conn net.Conn) error { return err } - // 响应:使用无认证 + // 检查是否需要密码认证 + if p.config["auth"] == "password" { + // 检查客户端是否支持用户名密码认证 + supported := false + for _, m := range methods { + if m == userPassAuth { + supported = true + break + } + } + if !supported { + conn.Write([]byte{socks5Version, noAcceptable}) + return errors.New("client does not support password auth") + } + + // 选择用户名密码认证 + if _, err := conn.Write([]byte{socks5Version, userPassAuth}); err != nil { + return err + } + + // 执行用户名密码认证 + return p.authenticateUserPass(conn) + } + + // 无认证 _, err := conn.Write([]byte{socks5Version, noAuth}) return err } @@ -163,6 +214,48 @@ func (p *SOCKS5Plugin) readRequest(conn net.Conn) (string, error) { return fmt.Sprintf("%s:%d", host, port), nil } +// authenticateUserPass 用户名密码认证 +func (p *SOCKS5Plugin) authenticateUserPass(conn net.Conn) error { + // 读取认证版本 + buf := make([]byte, 2) + if _, err := io.ReadFull(conn, buf); err != nil { + return err + } + if buf[0] != userPassAuthVer { + return errors.New("unsupported auth version") + } + + // 读取用户名 + ulen := int(buf[1]) + username := make([]byte, ulen) + if _, err := io.ReadFull(conn, username); err != nil { + return err + } + + // 读取密码长度和密码 + plenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, plenBuf); err != nil { + return err + } + plen := int(plenBuf[0]) + password := make([]byte, plen) + if _, err := io.ReadFull(conn, password); err != nil { + return err + } + + // 验证用户名密码 + expectedUser := p.config["username"] + expectedPass := p.config["password"] + + if string(username) == expectedUser && string(password) == expectedPass { + conn.Write([]byte{userPassAuthVer, authSuccess}) + return nil + } + + conn.Write([]byte{userPassAuthVer, authFailure}) + return errors.New("authentication failed") +} + // sendReply 发送响应 func (p *SOCKS5Plugin) sendReply(conn net.Conn, rep byte) error { reply := []byte{socks5Version, rep, 0x00, atypIPv4, 0, 0, 0, 0, 0, 0} diff --git a/pkg/plugin/types.go b/pkg/plugin/types.go index 0fd009c..85a389a 100644 --- a/pkg/plugin/types.go +++ b/pkg/plugin/types.go @@ -23,6 +23,28 @@ const ( PluginSourceWASM PluginSource = "wasm" // WASM 模块 ) +// ConfigFieldType 配置字段类型 +type ConfigFieldType string + +const ( + ConfigFieldString ConfigFieldType = "string" + ConfigFieldNumber ConfigFieldType = "number" + ConfigFieldBool ConfigFieldType = "bool" + ConfigFieldSelect ConfigFieldType = "select" // 下拉选择 + ConfigFieldPassword ConfigFieldType = "password" // 密码输入 +) + +// ConfigField 配置字段定义 +type ConfigField struct { + Key string `json:"key"` // 配置键名 + Label string `json:"label"` // 显示标签 + Type ConfigFieldType `json:"type"` // 字段类型 + Default string `json:"default,omitempty"` // 默认值 + Required bool `json:"required,omitempty"` // 是否必填 + Options []string `json:"options,omitempty"` // select 类型的选项 + Description string `json:"description,omitempty"` // 字段描述 +} + // PluginMetadata 描述一个 plugin type PluginMetadata struct { Name string `json:"name"` // 唯一标识符 (如 "socks5") @@ -35,7 +57,7 @@ type PluginMetadata struct { Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256 Size int64 `json:"size,omitempty"` // WASM 二进制大小 Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions - ConfigSchema map[string]string `json:"config_schema,omitempty"` + ConfigSchema []ConfigField `json:"config_schema,omitempty"`// 配置模式定义 } // PluginInfo 组合元数据和运行时状态 diff --git a/pkg/protocol/message.go b/pkg/protocol/message.go index c82818c..42b82cf 100644 --- a/pkg/protocol/message.go +++ b/pkg/protocol/message.go @@ -37,6 +37,7 @@ const ( // 插件安装消息 MsgTypeInstallPlugins uint8 = 24 // 服务端推送安装插件列表 + MsgTypePluginConfig uint8 = 25 // 插件配置同步 ) // Message 基础消息结构 @@ -155,6 +156,12 @@ type InstallPluginsRequest struct { Plugins []string `json:"plugins"` // 要安装的插件名称列表 } +// PluginConfigSync 插件配置同步 +type PluginConfigSync struct { + PluginName string `json:"plugin_name"` // 插件名称 + Config map[string]string `json:"config"` // 配置内容 +} + // UDPPacket UDP 数据包 type UDPPacket struct { RemotePort int `json:"remote_port"` // 服务端监听端口 diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a8a29f1..748213b 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,5 +1,5 @@ import { get, post, put, del } from '../config/axios' -import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo } from '../types' +import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse } from '../types' // 重新导出 token 管理方法 export { getToken, setToken, removeToken } from '../config/axios' @@ -33,3 +33,9 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`) // 扩展商店 export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins') + +// 客户端插件配置 +export const getClientPluginConfig = (clientId: string, pluginName: string) => + get(`/client-plugin/${clientId}/${pluginName}/config`) +export const updateClientPluginConfig = (clientId: string, pluginName: string, config: Record) => + put(`/client-plugin/${clientId}/${pluginName}/config`, { config }) diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 88e7a1b..fc4094d 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -13,6 +13,25 @@ export interface ClientPlugin { name: string version: string enabled: boolean + config?: Record +} + +// 插件配置字段 +export interface ConfigField { + key: string + label: string + type: 'string' | 'number' | 'bool' | 'select' | 'password' + default?: string + required?: boolean + options?: string[] + description?: string +} + +// 插件配置响应 +export interface PluginConfigResponse { + plugin_name: string + schema: ConfigField[] + config: Record } // 客户端配置 diff --git a/web/src/views/ClientView.vue b/web/src/views/ClientView.vue index 018ac37..35be8b4 100644 --- a/web/src/views/ClientView.vue +++ b/web/src/views/ClientView.vue @@ -9,10 +9,13 @@ import { import { ArrowBackOutline, CreateOutline, TrashOutline, PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline, - DownloadOutline + DownloadOutline, SettingsOutline } from '@vicons/ionicons5' -import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api' -import type { ProxyRule, PluginInfo, ClientPlugin } from '../types' +import { + getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, + getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig +} from '../api' +import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField } from '../types' const route = useRoute() const router = useRouter() @@ -42,6 +45,13 @@ const showInstallModal = ref(false) const availablePlugins = ref([]) const selectedPlugins = ref([]) +// 插件配置相关 +const showConfigModal = ref(false) +const configPluginName = ref('') +const configSchema = ref([]) +const configValues = ref>({}) +const configLoading = ref(false) + const loadPlugins = async () => { try { const { data } = await getPlugins() @@ -189,6 +199,42 @@ const toggleClientPlugin = async (plugin: ClientPlugin) => { message.error('操作失败') } } + +// 打开插件配置模态框 +const openConfigModal = async (plugin: ClientPlugin) => { + configPluginName.value = plugin.name + configLoading.value = true + showConfigModal.value = true + + try { + const { data } = await getClientPluginConfig(clientId, plugin.name) + configSchema.value = data.schema || [] + configValues.value = { ...data.config } + // 填充默认值 + for (const field of configSchema.value) { + if (field.default && !configValues.value[field.key]) { + configValues.value[field.key] = field.default + } + } + } catch (e: any) { + message.error(e.response?.data || '加载配置失败') + showConfigModal.value = false + } finally { + configLoading.value = false + } +} + +// 保存插件配置 +const savePluginConfig = async () => { + try { + await updateClientPluginConfig(clientId, configPluginName.value, configValues.value) + message.success('配置已保存') + showConfigModal.value = false + loadClient() + } catch (e: any) { + message.error(e.response?.data || '保存失败') + } +} + + + + + + + + + + + + + + + + + + + + + +