diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 9fbf860..71886cf 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -10,8 +10,14 @@ import ( // ServerConfig 服务端配置 type ServerConfig struct { - Server ServerSettings `yaml:"server"` - Web WebSettings `yaml:"web"` + Server ServerSettings `yaml:"server"` + Web WebSettings `yaml:"web"` + PluginStore PluginStoreSettings `yaml:"plugin_store"` +} + +// PluginStoreSettings 扩展商店设置 +type PluginStoreSettings struct { + URL string `yaml:"url"` // 扩展商店URL,例如 GitHub 仓库的 raw URL } // ServerSettings 服务端设置 diff --git a/internal/server/db/interface.go b/internal/server/db/interface.go index 1bab761..b4354d5 100644 --- a/internal/server/db/interface.go +++ b/internal/server/db/interface.go @@ -2,25 +2,34 @@ package db import "github.com/gotunnel/pkg/protocol" +// ClientPlugin 客户端已安装的插件 +type ClientPlugin struct { + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` +} + // Client 客户端数据 type Client struct { ID string `json:"id"` Nickname string `json:"nickname,omitempty"` Rules []protocol.ProxyRule `json:"rules"` + Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件 } // PluginData 插件数据 type PluginData struct { - Name string `json:"name"` - Version string `json:"version"` - Type string `json:"type"` - Source string `json:"source"` - Description string `json:"description"` - Author string `json:"author"` - Checksum string `json:"checksum"` - Size int64 `json:"size"` - Enabled bool `json:"enabled"` - WASMData []byte `json:"-"` + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Source string `json:"source"` + Description string `json:"description"` + Author string `json:"author"` + Icon string `json:"icon"` + Checksum string `json:"checksum"` + Size int64 `json:"size"` + Enabled bool `json:"enabled"` + WASMData []byte `json:"-"` } // ClientStore 客户端存储接口 diff --git a/internal/server/db/sqlite.go b/internal/server/db/sqlite.go index fe0aa3f..4479ede 100644 --- a/internal/server/db/sqlite.go +++ b/internal/server/db/sqlite.go @@ -39,7 +39,8 @@ func (s *SQLiteStore) init() error { CREATE TABLE IF NOT EXISTS clients ( id TEXT PRIMARY KEY, nickname TEXT NOT NULL DEFAULT '', - rules TEXT NOT NULL DEFAULT '[]' + rules TEXT NOT NULL DEFAULT '[]', + plugins TEXT NOT NULL DEFAULT '[]' ) `) if err != nil { @@ -48,6 +49,8 @@ func (s *SQLiteStore) init() error { // 迁移:添加 nickname 列 s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`) + // 迁移:添加 plugins 列 + s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`) // 创建插件表 _, err = s.db.Exec(` @@ -58,13 +61,21 @@ func (s *SQLiteStore) init() error { source TEXT NOT NULL DEFAULT 'wasm', description TEXT, author TEXT, + icon TEXT, checksum TEXT, size INTEGER DEFAULT 0, enabled INTEGER DEFAULT 1, wasm_data BLOB ) `) - return err + if err != nil { + return err + } + + // 迁移:添加 icon 列 + s.db.Exec(`ALTER TABLE plugins ADD COLUMN icon TEXT`) + + return nil } // Close 关闭数据库连接 @@ -77,7 +88,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) { s.mu.RLock() defer s.mu.RUnlock() - rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`) + rows, err := s.db.Query(`SELECT id, nickname, rules, plugins FROM clients`) if err != nil { return nil, err } @@ -86,13 +97,16 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) { var clients []Client for rows.Next() { var c Client - var rulesJSON string - if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil { + var rulesJSON, pluginsJSON string + if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON); err != nil { return nil, err } if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil { c.Rules = []protocol.ProxyRule{} } + if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil { + c.Plugins = []ClientPlugin{} + } clients = append(clients, c) } return clients, nil @@ -104,14 +118,17 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) { defer s.mu.RUnlock() var c Client - var rulesJSON string - err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON) + var rulesJSON, pluginsJSON string + err := s.db.QueryRow(`SELECT id, nickname, rules, plugins FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON) if err != nil { return nil, err } if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil { c.Rules = []protocol.ProxyRule{} } + if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil { + c.Plugins = []ClientPlugin{} + } return &c, nil } @@ -124,7 +141,12 @@ func (s *SQLiteStore) CreateClient(c *Client) error { if err != nil { return err } - _, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`, c.ID, c.Nickname, string(rulesJSON)) + pluginsJSON, err := json.Marshal(c.Plugins) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules, plugins) VALUES (?, ?, ?, ?)`, + c.ID, c.Nickname, string(rulesJSON), string(pluginsJSON)) return err } @@ -137,7 +159,12 @@ func (s *SQLiteStore) UpdateClient(c *Client) error { if err != nil { return err } - _, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`, c.Nickname, string(rulesJSON), c.ID) + pluginsJSON, err := json.Marshal(c.Plugins) + if err != nil { + return err + } + _, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ?, plugins = ? WHERE id = ?`, + c.Nickname, string(rulesJSON), string(pluginsJSON), c.ID) return err } @@ -177,7 +204,7 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) { defer s.mu.RUnlock() rows, err := s.db.Query(` - SELECT name, version, type, source, description, author, checksum, size, enabled + SELECT name, version, type, source, description, author, icon, checksum, size, enabled FROM plugins `) if err != nil { @@ -189,12 +216,14 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) { for rows.Next() { var p PluginData var enabled int + var icon sql.NullString err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source, - &p.Description, &p.Author, &p.Checksum, &p.Size, &enabled) + &p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled) if err != nil { return nil, err } p.Enabled = enabled == 1 + p.Icon = icon.String plugins = append(plugins, p) } return plugins, nil @@ -207,15 +236,17 @@ func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) { var p PluginData var enabled int + var icon sql.NullString err := s.db.QueryRow(` - SELECT name, version, type, source, description, author, checksum, size, enabled + SELECT name, version, type, source, description, author, icon, checksum, size, enabled FROM plugins WHERE name = ? `, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source, - &p.Description, &p.Author, &p.Checksum, &p.Size, &enabled) + &p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled) if err != nil { return nil, err } p.Enabled = enabled == 1 + p.Icon = icon.String return &p, nil } @@ -230,10 +261,10 @@ func (s *SQLiteStore) SavePlugin(p *PluginData) error { } _, err := s.db.Exec(` INSERT OR REPLACE INTO plugins - (name, version, type, source, description, author, checksum, size, enabled, wasm_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (name, version, type, source, description, author, icon, checksum, size, enabled, wasm_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author, - p.Checksum, p.Size, enabled, p.WASMData) + p.Icon, p.Checksum, p.Size, enabled, p.WASMData) return err } diff --git a/internal/server/router/api.go b/internal/server/router/api.go index 6f2b2ec..fdeee97 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -2,8 +2,10 @@ package router import ( "encoding/json" + "io" "net/http" "regexp" + "time" "github.com/gotunnel/internal/server/config" "github.com/gotunnel/internal/server/db" @@ -53,6 +55,7 @@ type PluginInfo struct { Type string `json:"type"` Description string `json:"description"` Source string `json:"source"` + Icon string `json:"icon,omitempty"` Enabled bool `json:"enabled"` } @@ -88,6 +91,7 @@ func RegisterRoutes(r *Router, app AppInterface) { api.HandleFunc("/config/reload", h.handleReload) api.HandleFunc("/plugins", h.handlePlugins) api.HandleFunc("/plugin/", h.handlePlugin) + api.HandleFunc("/store/plugins", h.handleStorePlugins) } func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) { @@ -233,7 +237,7 @@ func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) { online, lastPing := h.server.GetClientStatus(clientID) h.jsonResponse(rw, map[string]interface{}{ "id": client.ID, "nickname": client.Nickname, "rules": client.Rules, - "online": online, "last_ping": lastPing, + "plugins": client.Plugins, "online": online, "last_ping": lastPing, }) } @@ -241,6 +245,7 @@ func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clien var req struct { Nickname string `json:"nickname"` Rules []protocol.ProxyRule `json:"rules"` + Plugins []db.ClientPlugin `json:"plugins"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) @@ -255,6 +260,9 @@ func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clien client.Nickname = req.Nickname client.Rules = req.Rules + if req.Plugins != nil { + client.Plugins = req.Plugins + } if err := h.clientStore.UpdateClient(client); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -518,3 +526,63 @@ func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Requ } h.jsonResponse(rw, map[string]string{"status": "ok"}) } + +// StorePluginInfo 扩展商店插件信息 +type StorePluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Description string `json:"description"` + Author string `json:"author"` + Icon string `json:"icon,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// handleStorePlugins 处理扩展商店插件列表 +func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + cfg := h.app.GetConfig() + storeURL := cfg.PluginStore.URL + if storeURL == "" { + h.jsonResponse(rw, map[string]interface{}{ + "plugins": []StorePluginInfo{}, + "store_url": "", + }) + return + } + + // 从远程URL获取插件列表 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(storeURL) + if err != nil { + http.Error(rw, "Failed to fetch store: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + http.Error(rw, "Store returned error", http.StatusBadGateway) + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(rw, "Failed to read response", http.StatusInternalServerError) + return + } + + var plugins []StorePluginInfo + if err := json.Unmarshal(body, &plugins); err != nil { + http.Error(rw, "Invalid store format", http.StatusBadGateway) + return + } + + h.jsonResponse(rw, map[string]interface{}{ + "plugins": plugins, + "store_url": storeURL, + }) +} diff --git a/pkg/plugin/types.go b/pkg/plugin/types.go index dd59322..0fd009c 100644 --- a/pkg/plugin/types.go +++ b/pkg/plugin/types.go @@ -31,6 +31,7 @@ type PluginMetadata struct { Source PluginSource `json:"source"` // builtin 或 wasm Description string `json:"description"` // 人类可读描述 Author string `json:"author"` // Plugin 作者 + Icon string `json:"icon,omitempty"` // 图标文件名 (如 "socks5.png") Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256 Size int64 `json:"size,omitempty"` // WASM 二进制大小 Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions diff --git a/pkg/protocol/message.go b/pkg/protocol/message.go index 6e5e1e8..c82818c 100644 --- a/pkg/protocol/message.go +++ b/pkg/protocol/message.go @@ -65,12 +65,21 @@ type ProxyRule struct { LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用 LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用 RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口 + Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true // Plugin 支持字段 PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"` PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"` PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"` } +// IsEnabled 检查规则是否启用,默认为 true +func (r *ProxyRule) IsEnabled() bool { + if r.Enabled == nil { + return true + } + return *r.Enabled +} + // ProxyConfig 代理配置下发 type ProxyConfig struct { Rules []ProxyRule `json:"rules"` diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 639a396..a8a29f1 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 } from '../types' +import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo } from '../types' // 重新导出 token 管理方法 export { getToken, setToken, removeToken } from '../config/axios' @@ -30,3 +30,6 @@ export const installPluginsToClient = (id: string, plugins: string[]) => export const getPlugins = () => get('/plugins') export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`) export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`) + +// 扩展商店 +export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins') diff --git a/web/src/types/index.ts b/web/src/types/index.ts index f38f52e..88e7a1b 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -5,6 +5,14 @@ export interface ProxyRule { local_port: number remote_port: number type?: string + enabled?: boolean +} + +// 客户端已安装的插件 +export interface ClientPlugin { + name: string + version: string + enabled: boolean } // 客户端配置 @@ -12,6 +20,7 @@ export interface ClientConfig { id: string nickname?: string rules: ProxyRule[] + plugins?: ClientPlugin[] } // 客户端状态 @@ -28,6 +37,7 @@ export interface ClientDetail { id: string nickname?: string rules: ProxyRule[] + plugins?: ClientPlugin[] online: boolean last_ping?: string } @@ -58,5 +68,17 @@ export interface PluginInfo { type: string description: string source: string + icon?: string enabled: boolean } + +// 扩展商店插件信息 +export interface StorePluginInfo { + name: string + version: string + type: string + description: string + author: string + icon?: string + download_url?: string +} diff --git a/web/src/views/ClientView.vue b/web/src/views/ClientView.vue index d2bf982..018ac37 100644 --- a/web/src/views/ClientView.vue +++ b/web/src/views/ClientView.vue @@ -3,7 +3,7 @@ import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { NCard, NButton, NSpace, NTag, NTable, NEmpty, - NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, + NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch, NIcon, useMessage, useDialog } from 'naive-ui' import { @@ -12,7 +12,7 @@ import { DownloadOutline } from '@vicons/ionicons5' import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api' -import type { ProxyRule, PluginInfo } from '../types' +import type { ProxyRule, PluginInfo, ClientPlugin } from '../types' const route = useRoute() const router = useRouter() @@ -24,6 +24,7 @@ const online = ref(false) const lastPing = ref('') const nickname = ref('') const rules = ref([]) +const clientPlugins = ref([]) const editing = ref(false) const editNickname = ref('') const editRules = ref([]) @@ -68,6 +69,7 @@ const loadClient = async () => { lastPing.value = data.last_ping || '' nickname.value = data.nickname || '' rules.value = data.rules || [] + clientPlugins.value = data.plugins || [] } catch (e) { console.error('Failed to load client', e) } @@ -79,7 +81,8 @@ const startEdit = () => { editNickname.value = nickname.value editRules.value = rules.value.map(rule => ({ ...rule, - type: rule.type || 'tcp' + type: rule.type || 'tcp', + enabled: rule.enabled !== false })) editing.value = true } @@ -90,7 +93,7 @@ const cancelEdit = () => { const addRule = () => { editRules.value.push({ - name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp' + name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp', enabled: true }) } @@ -167,6 +170,25 @@ const installPlugins = async () => { message.error(e.response?.data || '安装失败') } } + +const toggleClientPlugin = async (plugin: ClientPlugin) => { + const newEnabled = !plugin.enabled + const updatedPlugins = clientPlugins.value.map(p => + p.name === plugin.name ? { ...p, enabled: newEnabled } : p + ) + try { + await updateClient(clientId, { + id: clientId, + nickname: nickname.value, + rules: rules.value, + plugins: updatedPlugins + }) + plugin.enabled = newEnabled + message.success(newEnabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`) + } catch (e) { + message.error('操作失败') + } +} + + + + + + + 名称 + 版本 + 状态 + + + + + {{ plugin.name }} + v{{ plugin.version }} + + + + + + + + diff --git a/web/src/views/PluginsView.vue b/web/src/views/PluginsView.vue index a0052a1..c337fab 100644 --- a/web/src/views/PluginsView.vue +++ b/web/src/views/PluginsView.vue @@ -3,16 +3,20 @@ import { ref, onMounted, computed } from 'vue' import { useRouter } from 'vue-router' import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, - NEmpty, NSpin, NIcon, NSwitch, useMessage + NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage } from 'naive-ui' -import { ArrowBackOutline, ExtensionPuzzleOutline } from '@vicons/ionicons5' -import { getPlugins, enablePlugin, disablePlugin } from '../api' -import type { PluginInfo } from '../types' +import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline } from '@vicons/ionicons5' +import { getPlugins, enablePlugin, disablePlugin, getStorePlugins } from '../api' +import type { PluginInfo, StorePluginInfo } from '../types' const router = useRouter() const message = useMessage() const plugins = ref([]) +const storePlugins = ref([]) +const storeUrl = ref('') const loading = ref(true) +const storeLoading = ref(false) +const activeTab = ref('installed') const loadPlugins = async () => { try { @@ -25,6 +29,19 @@ const loadPlugins = async () => { } } +const loadStorePlugins = async () => { + storeLoading.value = true + try { + const { data } = await getStorePlugins() + storePlugins.value = data.plugins || [] + storeUrl.value = data.store_url || '' + } catch (e) { + console.error('Failed to load store plugins', e) + } finally { + storeLoading.value = false + } +} + const proxyPlugins = computed(() => plugins.value.filter(p => p.type === 'proxy') ) @@ -68,6 +85,12 @@ const getTypeColor = (type: string) => { return colors[type] || 'default' } +const handleTabChange = (tab: string) => { + if (tab === 'store' && storePlugins.value.length === 0) { + loadStorePlugins() + } +} + onMounted(loadPlugins) @@ -75,8 +98,8 @@ onMounted(loadPlugins)
-

插件管理

-

查看和管理已注册的插件

+

扩展商店

+

管理已安装扩展和浏览扩展商店

@@ -84,54 +107,92 @@ onMounted(loadPlugins)
- - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - v{{ plugin.version }} - - {{ getTypeLabel(plugin.type) }} - - - {{ plugin.source === 'builtin' ? '内置' : 'WASM' }} - - -

{{ plugin.description }}

-
-
-
-
-
+ + + + + + + + v{{ plugin.version }} + + {{ getTypeLabel(plugin.type) }} + + + {{ plugin.source === 'builtin' ? '内置' : 'WASM' }} + + +

{{ plugin.description }}

+
+
+
+
+
+ + + + + + + + + + + + + + + v{{ plugin.version }} + + {{ getTypeLabel(plugin.type) }} + + +

{{ plugin.description }}

+

作者: {{ plugin.author }}

+
+
+
+
+
+
+