diff --git a/internal/client/tunnel/client.go b/internal/client/tunnel/client.go index cf11a02..7d75c60 100644 --- a/internal/client/tunnel/client.go +++ b/internal/client/tunnel/client.go @@ -221,10 +221,16 @@ func (c *Client) handleStream(stream net.Conn) { c.handlePluginConfig(msg) case protocol.MsgTypeClientPluginStart: c.handleClientPluginStart(stream, msg) + case protocol.MsgTypeClientPluginStop: + c.handleClientPluginStop(stream, msg) case protocol.MsgTypeClientPluginConn: c.handleClientPluginConn(stream, msg) case protocol.MsgTypeJSPluginInstall: c.handleJSPluginInstall(stream, msg) + case protocol.MsgTypeClientRestart: + c.handleClientRestart(stream, msg) + case protocol.MsgTypePluginConfigUpdate: + c.handlePluginConfigUpdate(stream, msg) } } @@ -609,3 +615,126 @@ func (c *Client) verifyJSPluginSignature(pluginName, source, signature string) e return nil } + +// handleClientPluginStop 处理客户端插件停止请求 +func (c *Client) handleClientPluginStop(stream net.Conn, msg *protocol.Message) { + defer stream.Close() + + var req protocol.ClientPluginStopRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendPluginStatus(stream, req.PluginName, req.RuleName, true, "", err.Error()) + return + } + + key := req.PluginName + ":" + req.RuleName + + c.pluginMu.Lock() + handler, ok := c.runningPlugins[key] + if ok { + if err := handler.Stop(); err != nil { + log.Printf("[Client] Plugin %s stop error: %v", key, err) + } + delete(c.runningPlugins, key) + } + c.pluginMu.Unlock() + + log.Printf("[Client] Plugin %s stopped", key) + c.sendPluginStatus(stream, req.PluginName, req.RuleName, false, "", "") +} + +// handleClientRestart 处理客户端重启请求 +func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { + defer stream.Close() + + var req protocol.ClientRestartRequest + msg.ParsePayload(&req) + + log.Printf("[Client] Restart requested: %s", req.Reason) + + // 发送响应 + resp := protocol.ClientRestartResponse{ + Success: true, + Message: "restarting", + } + respMsg, _ := protocol.NewMessage(protocol.MsgTypeClientRestart, resp) + protocol.WriteMessage(stream, respMsg) + + // 停止所有运行中的插件 + c.pluginMu.Lock() + for key, handler := range c.runningPlugins { + log.Printf("[Client] Stopping plugin %s for restart", key) + handler.Stop() + } + c.runningPlugins = make(map[string]plugin.ClientPlugin) + c.pluginMu.Unlock() + + // 关闭会话(会触发重连) + if c.session != nil { + c.session.Close() + } +} + +// handlePluginConfigUpdate 处理插件配置更新请求 +func (c *Client) handlePluginConfigUpdate(stream net.Conn, msg *protocol.Message) { + defer stream.Close() + + var req protocol.PluginConfigUpdateRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error()) + return + } + + key := req.PluginName + ":" + req.RuleName + log.Printf("[Client] Config update for plugin %s", key) + + c.pluginMu.RLock() + handler, ok := c.runningPlugins[key] + c.pluginMu.RUnlock() + + if !ok { + c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, "plugin not running") + return + } + + if req.Restart { + // 停止并重启插件 + c.pluginMu.Lock() + if err := handler.Stop(); err != nil { + log.Printf("[Client] Plugin %s stop error: %v", key, err) + } + delete(c.runningPlugins, key) + c.pluginMu.Unlock() + + // 重新初始化和启动 + if err := handler.Init(req.Config); err != nil { + c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error()) + return + } + + localAddr, err := handler.Start() + if err != nil { + c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, false, err.Error()) + return + } + + c.pluginMu.Lock() + c.runningPlugins[key] = handler + c.pluginMu.Unlock() + + log.Printf("[Client] Plugin %s restarted at %s with new config", key, localAddr) + } + + c.sendPluginConfigUpdateResult(stream, req.PluginName, req.RuleName, true, "") +} + +// sendPluginConfigUpdateResult 发送插件配置更新结果 +func (c *Client) sendPluginConfigUpdateResult(stream net.Conn, pluginName, ruleName string, success bool, errMsg string) { + result := protocol.PluginConfigUpdateResponse{ + PluginName: pluginName, + RuleName: ruleName, + Success: success, + Error: errMsg, + } + msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, result) + protocol.WriteMessage(stream, msg) +} diff --git a/internal/server/db/interface.go b/internal/server/db/interface.go index 983ce71..6923f32 100644 --- a/internal/server/db/interface.go +++ b/internal/server/db/interface.go @@ -18,21 +18,6 @@ type Client struct { 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"` - Icon string `json:"icon"` - Checksum string `json:"checksum"` - Size int64 `json:"size"` - Enabled bool `json:"enabled"` - WASMData []byte `json:"-"` -} - // JSPlugin JS 插件数据 type JSPlugin struct { Name string `json:"name"` @@ -40,6 +25,7 @@ type JSPlugin struct { Signature string `json:"signature"` // 官方签名 (Base64) Description string `json:"description"` Author string `json:"author"` + Version string `json:"version,omitempty"` AutoPush []string `json:"auto_push"` Config map[string]string `json:"config"` AutoStart bool `json:"auto_start"` @@ -58,16 +44,6 @@ type ClientStore interface { Close() error } -// PluginStore 插件存储接口 -type PluginStore interface { - GetAllPlugins() ([]PluginData, error) - GetPlugin(name string) (*PluginData, error) - SavePlugin(p *PluginData) error - DeletePlugin(name string) error - SetPluginEnabled(name string, enabled bool) error - GetPluginWASM(name string) ([]byte, error) -} - // JSPluginStore JS 插件存储接口 type JSPluginStore interface { GetAllJSPlugins() ([]JSPlugin, error) @@ -75,12 +51,12 @@ type JSPluginStore interface { SaveJSPlugin(p *JSPlugin) error DeleteJSPlugin(name string) error SetJSPluginEnabled(name string, enabled bool) error + UpdateJSPluginConfig(name string, config map[string]string) error } // Store 统一存储接口 type Store interface { ClientStore - PluginStore JSPluginStore Close() error } diff --git a/internal/server/db/sqlite.go b/internal/server/db/sqlite.go index d030811..8958643 100644 --- a/internal/server/db/sqlite.go +++ b/internal/server/db/sqlite.go @@ -52,41 +52,21 @@ func (s *SQLiteStore) init() error { // 迁移:添加 plugins 列 s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`) - // 创建插件表 - _, err = s.db.Exec(` - CREATE TABLE IF NOT EXISTS plugins ( - name TEXT PRIMARY KEY, - version TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'proxy', - 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 - ) - `) - if err != nil { - return err - } - - // 迁移:添加 icon 列 - s.db.Exec(`ALTER TABLE plugins ADD COLUMN icon TEXT`) - // 创建 JS 插件表 _, err = s.db.Exec(` CREATE TABLE IF NOT EXISTS js_plugins ( name TEXT PRIMARY KEY, source TEXT NOT NULL, + signature TEXT NOT NULL DEFAULT '', description TEXT, author TEXT, + version TEXT DEFAULT '', auto_push TEXT NOT NULL DEFAULT '[]', - config TEXT NOT NULL DEFAULT '', + config TEXT NOT NULL DEFAULT '{}', auto_start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { @@ -95,6 +75,10 @@ func (s *SQLiteStore) init() error { // 迁移:添加 signature 列 s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`) + // 迁移:添加 version 列 + s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN version TEXT DEFAULT ''`) + // 迁移:添加 updated_at 列 + s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`) return nil } @@ -217,107 +201,6 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) { return c.Rules, nil } -// ========== 插件存储方法 ========== - -// GetAllPlugins 获取所有插件 -func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - rows, err := s.db.Query(` - SELECT name, version, type, source, description, author, icon, checksum, size, enabled - FROM plugins - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var plugins []PluginData - 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, &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 -} - -// GetPlugin 获取单个插件 -func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - var p PluginData - var enabled int - var icon sql.NullString - err := s.db.QueryRow(` - 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, &icon, &p.Checksum, &p.Size, &enabled) - if err != nil { - return nil, err - } - p.Enabled = enabled == 1 - p.Icon = icon.String - return &p, nil -} - -// SavePlugin 保存插件 -func (s *SQLiteStore) SavePlugin(p *PluginData) error { - s.mu.Lock() - defer s.mu.Unlock() - - enabled := 0 - if p.Enabled { - enabled = 1 - } - _, err := s.db.Exec(` - INSERT OR REPLACE INTO plugins - (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.Icon, p.Checksum, p.Size, enabled, p.WASMData) - return err -} - -// DeletePlugin 删除插件 -func (s *SQLiteStore) DeletePlugin(name string) error { - s.mu.Lock() - defer s.mu.Unlock() - _, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, name) - return err -} - -// SetPluginEnabled 设置插件启用状态 -func (s *SQLiteStore) SetPluginEnabled(name string, enabled bool) error { - s.mu.Lock() - defer s.mu.Unlock() - val := 0 - if enabled { - val = 1 - } - _, err := s.db.Exec(`UPDATE plugins SET enabled = ? WHERE name = ?`, val, name) - return err -} - -// GetPluginWASM 获取插件 WASM 数据 -func (s *SQLiteStore) GetPluginWASM(name string) ([]byte, error) { - s.mu.RLock() - defer s.mu.RUnlock() - var data []byte - err := s.db.QueryRow(`SELECT wasm_data FROM plugins WHERE name = ?`, name).Scan(&data) - return data, err -} - // ========== JS 插件存储方法 ========== // GetAllJSPlugins 获取所有 JS 插件 @@ -326,7 +209,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) { defer s.mu.RUnlock() rows, err := s.db.Query(` - SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled + SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled FROM js_plugins `) if err != nil { @@ -338,12 +221,14 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) { for rows.Next() { var p JSPlugin var autoPushJSON, configJSON string + var version sql.NullString var autoStart, enabled int err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author, - &autoPushJSON, &configJSON, &autoStart, &enabled) + &version, &autoPushJSON, &configJSON, &autoStart, &enabled) if err != nil { return nil, err } + p.Version = version.String json.Unmarshal([]byte(autoPushJSON), &p.AutoPush) json.Unmarshal([]byte(configJSON), &p.Config) p.AutoStart = autoStart == 1 @@ -360,15 +245,17 @@ func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) { var p JSPlugin var autoPushJSON, configJSON string + var version sql.NullString var autoStart, enabled int err := s.db.QueryRow(` - SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled + SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled FROM js_plugins WHERE name = ? `, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author, - &autoPushJSON, &configJSON, &autoStart, &enabled) + &version, &autoPushJSON, &configJSON, &autoStart, &enabled) if err != nil { return nil, err } + p.Version = version.String json.Unmarshal([]byte(autoPushJSON), &p.AutoPush) json.Unmarshal([]byte(configJSON), &p.Config) p.AutoStart = autoStart == 1 @@ -393,9 +280,9 @@ func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error { _, err := s.db.Exec(` INSERT OR REPLACE INTO js_plugins - (name, source, signature, description, author, auto_push, config, auto_start, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, p.Name, p.Source, p.Signature, p.Description, p.Author, + (name, source, signature, description, author, version, auto_push, config, auto_start, enabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, p.Name, p.Source, p.Signature, p.Description, p.Author, p.Version, string(autoPushJSON), string(configJSON), autoStart, enabled) return err } @@ -416,6 +303,15 @@ func (s *SQLiteStore) SetJSPluginEnabled(name string, enabled bool) error { if enabled { val = 1 } - _, err := s.db.Exec(`UPDATE js_plugins SET enabled = ? WHERE name = ?`, val, name) + _, err := s.db.Exec(`UPDATE js_plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, val, name) + return err +} + +// UpdateJSPluginConfig 更新 JS 插件配置 +func (s *SQLiteStore) UpdateJSPluginConfig(name string, config map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + configJSON, _ := json.Marshal(config) + _, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name) return err } diff --git a/internal/server/router/api.go b/internal/server/router/api.go index ae9f3dc..eaf065f 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -9,6 +9,7 @@ import ( "github.com/gotunnel/internal/server/config" "github.com/gotunnel/internal/server/db" + "github.com/gotunnel/pkg/plugin" "github.com/gotunnel/pkg/protocol" ) @@ -53,6 +54,11 @@ type ServerInterface interface { SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error // JS 插件 InstallJSPluginToClient(clientID string, req JSPluginInstallRequest) error + // 客户端/插件重启 + RestartClient(clientID string) error + StopClientPlugin(clientID, pluginName, ruleName string) error + RestartClientPlugin(clientID, pluginName, ruleName string) error + UpdateClientPluginConfig(clientID, pluginName, ruleName string, config map[string]string, restart bool) error } // JSPluginInstallRequest JS 插件安装请求 @@ -135,6 +141,7 @@ func RegisterRoutes(r *Router, app AppInterface) { api.HandleFunc("/client-plugin/", h.handleClientPlugin) api.HandleFunc("/js-plugin/", h.handleJSPlugin) api.HandleFunc("/js-plugins", h.handleJSPlugins) + api.HandleFunc("/rule-schemas", h.handleRuleSchemas) } func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) { @@ -247,6 +254,22 @@ func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) { case "install-plugins": h.installPluginsToClient(rw, r, clientID) return + case "restart": + h.restartClient(rw, r, clientID) + return + } + } + + // 检查是否是插件操作: /api/client/{id}/plugin/{name}/{action} + if len(parts) >= 2 && parts[1] == "plugin" { + // 重新解析路径 + remaining := clientID[len(parts[0])+1:] // "plugin/xxx/action" + pluginParts := splitPath(remaining[7:]) // 跳过 "plugin/" + if len(pluginParts) >= 2 { + pluginName := pluginParts[0] + pluginAction := pluginParts[1] + h.handleClientPluginAction(rw, r, parts[0], pluginName, pluginAction) + return } } @@ -1064,3 +1087,105 @@ func (h *APIHandler) pushJSPluginToClient(rw http.ResponseWriter, pluginName, cl h.jsonResponse(rw, map[string]string{"status": "ok", "plugin": pluginName, "client": clientID}) } + +// handleRuleSchemas 返回所有协议类型的配置模式 +func (h *APIHandler) handleRuleSchemas(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取内置协议模式 + schemas := make(map[string]RuleSchema) + for name, schema := range plugin.BuiltinRuleSchemas() { + schemas[name] = RuleSchema{ + NeedsLocalAddr: schema.NeedsLocalAddr, + ExtraFields: convertConfigFields(schema.ExtraFields), + } + } + + // 添加已注册插件的模式 + plugins := h.server.GetPluginList() + for _, p := range plugins { + if p.RuleSchema != nil { + schemas[p.Name] = *p.RuleSchema + } + } + + h.jsonResponse(rw, schemas) +} + +// convertConfigFields 将 plugin.ConfigField 转换为 router.ConfigField +func convertConfigFields(fields []plugin.ConfigField) []ConfigField { + result := make([]ConfigField, len(fields)) + for i, f := range fields { + result[i] = 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 +} + +// restartClient 重启客户端 +func (h *APIHandler) restartClient(rw http.ResponseWriter, r *http.Request, clientID string) { + if r.Method != http.MethodPost { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := h.server.RestartClient(clientID); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + h.jsonResponse(rw, map[string]string{"status": "ok", "message": "client restart initiated"}) +} + +// handleClientPluginAction 处理客户端插件操作 +func (h *APIHandler) handleClientPluginAction(rw http.ResponseWriter, r *http.Request, clientID, pluginName, action string) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取规则名称(从请求体或使用插件名作为默认值) + var req struct { + RuleName string `json:"rule_name"` + Config map[string]string `json:"config"` + Restart bool `json:"restart"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.RuleName == "" { + req.RuleName = pluginName + } + + var err error + switch action { + case "stop": + err = h.server.StopClientPlugin(clientID, pluginName, req.RuleName) + case "restart": + err = h.server.RestartClientPlugin(clientID, pluginName, req.RuleName) + case "config": + if req.Config == nil { + http.Error(rw, "config required", http.StatusBadRequest) + return + } + err = h.server.UpdateClientPluginConfig(clientID, pluginName, req.RuleName, req.Config, req.Restart) + default: + http.Error(rw, "unknown action", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + h.jsonResponse(rw, map[string]string{"status": "ok", "action": action, "plugin": pluginName}) +} diff --git a/internal/server/tunnel/server.go b/internal/server/tunnel/server.go index 5d86cb9..d159b17 100644 --- a/internal/server/tunnel/server.go +++ b/internal/server/tunnel/server.go @@ -1146,3 +1146,163 @@ func (s *Server) shouldPushToClient(autoPush []string, clientID string) bool { } return false } + +// RestartClient 重启客户端(通过断开连接,让客户端自动重连) +func (s *Server) RestartClient(clientID string) error { + s.mu.RLock() + cs, ok := s.clients[clientID] + s.mu.RUnlock() + + if !ok { + return fmt.Errorf("client %s not found or not online", clientID) + } + + // 发送重启消息 + stream, err := cs.Session.Open() + if err != nil { + return err + } + + req := protocol.ClientRestartRequest{ + Reason: "server requested restart", + } + msg, _ := protocol.NewMessage(protocol.MsgTypeClientRestart, req) + protocol.WriteMessage(stream, msg) + stream.Close() + + // 等待一小段时间后断开连接 + time.AfterFunc(100*time.Millisecond, func() { + cs.Session.Close() + }) + + log.Printf("[Server] Restart initiated for client %s", clientID) + return nil +} + +// StopClientPlugin 停止客户端插件 +func (s *Server) StopClientPlugin(clientID, pluginName, ruleName string) error { + s.mu.RLock() + cs, ok := s.clients[clientID] + s.mu.RUnlock() + + if !ok { + return fmt.Errorf("client %s not found or not online", clientID) + } + + return s.sendClientPluginStop(cs.Session, pluginName, ruleName) +} + +// sendClientPluginStop 发送客户端插件停止命令 +func (s *Server) sendClientPluginStop(session *yamux.Session, pluginName, ruleName string) error { + stream, err := session.Open() + if err != nil { + return err + } + defer stream.Close() + + req := protocol.ClientPluginStopRequest{ + PluginName: pluginName, + RuleName: ruleName, + } + msg, err := protocol.NewMessage(protocol.MsgTypeClientPluginStop, req) + if err != nil { + return err + } + if err := protocol.WriteMessage(stream, msg); err != nil { + return err + } + + // 等待响应 + resp, err := protocol.ReadMessage(stream) + if err != nil { + return err + } + if resp.Type != protocol.MsgTypeClientPluginStatus { + return fmt.Errorf("unexpected response type: %d", resp.Type) + } + + var status protocol.ClientPluginStatusResponse + if err := resp.ParsePayload(&status); err != nil { + return err + } + if status.Running { + return fmt.Errorf("plugin still running: %s", status.Error) + } + return nil +} + +// RestartClientPlugin 重启客户端插件 +func (s *Server) RestartClientPlugin(clientID, pluginName, ruleName string) error { + s.mu.RLock() + cs, ok := s.clients[clientID] + s.mu.RUnlock() + + if !ok { + return fmt.Errorf("client %s not found or not online", clientID) + } + + // 查找规则 + var rule *protocol.ProxyRule + for _, r := range cs.Rules { + if r.Name == ruleName && r.Type == pluginName { + rule = &r + break + } + } + if rule == nil { + return fmt.Errorf("rule %s not found for plugin %s", ruleName, pluginName) + } + + // 先停止 + if err := s.sendClientPluginStop(cs.Session, pluginName, ruleName); err != nil { + log.Printf("[Server] Stop plugin warning: %v", err) + } + + // 再启动 + return s.sendClientPluginStart(cs.Session, *rule) +} + +// UpdateClientPluginConfig 更新客户端插件配置 +func (s *Server) UpdateClientPluginConfig(clientID, pluginName, ruleName string, config map[string]string, restart bool) error { + s.mu.RLock() + cs, ok := s.clients[clientID] + s.mu.RUnlock() + + if !ok { + return fmt.Errorf("client %s not found or not online", clientID) + } + + // 发送配置更新消息 + stream, err := cs.Session.Open() + if err != nil { + return err + } + defer stream.Close() + + req := protocol.PluginConfigUpdateRequest{ + PluginName: pluginName, + RuleName: ruleName, + Config: config, + Restart: restart, + } + msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, req) + if err := protocol.WriteMessage(stream, msg); err != nil { + return err + } + + // 等待响应 + resp, err := protocol.ReadMessage(stream) + if err != nil { + return err + } + + var result protocol.PluginConfigUpdateResponse + if err := resp.ParsePayload(&result); err != nil { + return err + } + if !result.Success { + return fmt.Errorf("config update failed: %s", result.Error) + } + + return nil +} diff --git a/pkg/plugin/schema.go b/pkg/plugin/schema.go new file mode 100644 index 0000000..57b37e1 --- /dev/null +++ b/pkg/plugin/schema.go @@ -0,0 +1,109 @@ +package plugin + +// 内置协议类型配置模式 + +// BuiltinRuleSchemas 返回所有内置协议类型的配置模式 +func BuiltinRuleSchemas() map[string]RuleSchema { + return map[string]RuleSchema{ + "tcp": { + NeedsLocalAddr: true, + ExtraFields: nil, + }, + "udp": { + NeedsLocalAddr: true, + ExtraFields: nil, + }, + "http": { + NeedsLocalAddr: false, + ExtraFields: []ConfigField{ + { + Key: "auth_enabled", + Label: "启用认证", + Type: ConfigFieldBool, + Default: "false", + Description: "是否启用 HTTP Basic 认证", + }, + { + Key: "username", + Label: "用户名", + Type: ConfigFieldString, + Description: "HTTP 代理认证用户名", + }, + { + Key: "password", + Label: "密码", + Type: ConfigFieldPassword, + Description: "HTTP 代理认证密码", + }, + }, + }, + "https": { + NeedsLocalAddr: false, + ExtraFields: []ConfigField{ + { + Key: "auth_enabled", + Label: "启用认证", + Type: ConfigFieldBool, + Default: "false", + Description: "是否启用 HTTPS 代理认证", + }, + { + Key: "username", + Label: "用户名", + Type: ConfigFieldString, + Description: "HTTPS 代理认证用户名", + }, + { + Key: "password", + Label: "密码", + Type: ConfigFieldPassword, + Description: "HTTPS 代理认证密码", + }, + }, + }, + "socks5": { + NeedsLocalAddr: false, + ExtraFields: []ConfigField{ + { + Key: "auth_enabled", + Label: "启用认证", + Type: ConfigFieldBool, + Default: "false", + Description: "是否启用 SOCKS5 用户名/密码认证", + }, + { + Key: "username", + Label: "用户名", + Type: ConfigFieldString, + Description: "SOCKS5 认证用户名", + }, + { + Key: "password", + Label: "密码", + Type: ConfigFieldPassword, + Description: "SOCKS5 认证密码", + }, + }, + }, + } +} + +// GetRuleSchema 获取指定协议类型的配置模式 +func GetRuleSchema(proxyType string) *RuleSchema { + schemas := BuiltinRuleSchemas() + if schema, ok := schemas[proxyType]; ok { + return &schema + } + return nil +} + +// IsBuiltinType 检查是否为内置协议类型 +func IsBuiltinType(proxyType string) bool { + builtinTypes := []string{"tcp", "udp", "http", "https"} + for _, t := range builtinTypes { + if t == proxyType { + return true + } + } + return false +} diff --git a/pkg/protocol/message.go b/pkg/protocol/message.go index eea0c40..2560ecf 100644 --- a/pkg/protocol/message.go +++ b/pkg/protocol/message.go @@ -48,6 +48,10 @@ const ( // JS 插件动态安装 MsgTypeJSPluginInstall uint8 = 50 // 安装 JS 插件 MsgTypeJSPluginResult uint8 = 51 // 安装结果 + + // 客户端控制消息 + MsgTypeClientRestart uint8 = 60 // 重启客户端 + MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置 ) // Message 基础消息结构 @@ -226,6 +230,33 @@ type JSPluginInstallResult struct { Error string `json:"error,omitempty"` } +// ClientRestartRequest 客户端重启请求 +type ClientRestartRequest struct { + Reason string `json:"reason,omitempty"` // 重启原因 +} + +// ClientRestartResponse 客户端重启响应 +type ClientRestartResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// PluginConfigUpdateRequest 插件配置更新请求 +type PluginConfigUpdateRequest struct { + PluginName string `json:"plugin_name"` // 插件名称 + RuleName string `json:"rule_name"` // 规则名称 + Config map[string]string `json:"config"` // 新配置 + Restart bool `json:"restart"` // 是否重启插件 +} + +// PluginConfigUpdateResponse 插件配置更新响应 +type PluginConfigUpdateResponse struct { + PluginName string `json:"plugin_name"` + RuleName string `json:"rule_name"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + // WriteMessage 写入消息到 writer func WriteMessage(w io.Writer, msg *Message) error { header := make([]byte, HeaderSize) diff --git a/plan.md b/plan.md index 7e81950..fb69f78 100644 --- a/plan.md +++ b/plan.md @@ -1,426 +1,319 @@ -# GoTunnel 架构修复计划 +# GoTunnel 重构计划 -> 面向 100 万用户发布前的安全与稳定性修复方案 +## 概述 -## 问题概览 - -| 严重程度 | 数量 | 状态 | -|---------|------|------| -| P0 严重 | 5 | ✅ 已修复 | -| P1 高 | 5 | ✅ 已修复 | -| P2 中 | 13 | 计划中 | -| P3 低 | 15 | 后续迭代 | +本次重构包含三个主要目标: +1. 移除 WASM 支持,只保留 JS 插件系统 +2. 优化 Web 界面,支持协议动态配置和 JS 插件管理 +3. 实现动态重启客户端和插件功能 --- -## 修复完成总结 +## 第一部分:移除 WASM,简化插件系统 -### P0 严重问题 (已全部修复) +### 1.1 需要删除的文件/目录 +- `pkg/plugin/wasm/` - WASM 运行时目录(如果存在) -| 编号 | 问题 | 修复文件 | 状态 | -|-----|------|---------|------| -| 1.1 | TLS 证书验证 | `pkg/crypto/tls.go` | ✅ TOFU 机制 | -| 1.2 | Web 控制台无认证 | `cmd/server/main.go`, `config/config.go` | ✅ 强制认证 | -| 1.3 | 认证检查端点失效 | `router/auth.go` | ✅ 实际验证 JWT | -| 1.4 | Token 生成错误 | `config/config.go` | ✅ 错误检查 | -| 1.5 | 客户端 ID 未验证 | `tunnel/server.go` | ✅ 正则验证 | +### 1.2 需要修改的文件 -### P1 高优先级问题 (已全部修复) +#### 数据库层 (`internal/server/db/`) +- **interface.go**: 移除 `PluginStore` 接口中的 `GetPluginWASM` 方法 +- **sqlite.go**: + - 移除 `plugins` 表(WASM 插件表) + - 移除相关的 CRUD 方法 + - 保留 `js_plugins` 表 -| 编号 | 问题 | 修复文件 | 状态 | -|-----|------|---------|------| -| 2.1 | 无连接数限制 | `tunnel/server.go` | ✅ 10000 上限 | -| 2.3 | 无优雅关闭 | `tunnel/server.go`, `cmd/server/main.go` | ✅ 信号处理 | -| 2.4 | 消息大小未验证 | `protocol/message.go` | ✅ 已有验证 | -| 2.5 | 无安全事件日志 | `pkg/security/audit.go` | ✅ 新增模块 | +#### 插件类型 (`pkg/plugin/types.go`) +- 移除 `PluginSource` 中的 `"wasm"` 选项,只保留 `"builtin"` 和 `"script"` + +#### 依赖清理 +- 检查 `go.mod` 是否有 wazero 依赖,如有则移除 --- -## 第一阶段:P0 严重问题 (发布前必须修复) +## 第二部分:优化 Web 界面 -### 1.1 TLS 证书验证被禁用 +### 2.1 协议动态配置 -**文件**: `pkg/crypto/tls.go` +#### 后端修改 -**问题**: `InsecureSkipVerify: true` 导致中间人攻击风险 - -**修复方案**: -- 添加服务端证书指纹验证机制 -- 客户端首次连接时保存服务端证书指纹 -- 后续连接验证指纹是否匹配(Trust On First Use) -- 提供 `--skip-verify` 参数供测试环境使用 - -**修改内容**: +##### A. 扩展 ConfigField 类型 (`pkg/plugin/types.go`) ```go -// pkg/crypto/tls.go -func ClientTLSConfig(serverFingerprint string) *tls.Config { - return &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: true, // 仍需要,因为是自签名证书 - VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { - // 验证证书指纹 - return verifyCertFingerprint(rawCerts, serverFingerprint) - }, - } +type ConfigField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` // string, number, bool, select, password + Default string `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Options []string `json:"options,omitempty"` + Description string `json:"description,omitempty"` +} + +type RuleSchema struct { + NeedsLocalAddr bool `json:"needs_local_addr"` + ExtraFields []ConfigField `json:"extra_fields"` } ``` ---- +##### B. 内置协议配置模式 +为 SOCKS5 和 HTTP 代理添加认证配置字段: -### 1.2 Web 控制台无认证 - -**文件**: `cmd/server/main.go` - -**问题**: 默认配置下 Web 控制台完全开放 - -**修复方案**: -- 首次启动时自动生成随机密码 -- 强制要求配置用户名密码 -- 无认证时拒绝启动 Web 服务 - -**修改内容**: ```go -// cmd/server/main.go -if cfg.Web.Enabled { - if cfg.Web.Username == "" || cfg.Web.Password == "" { - // 自动生成凭据 - cfg.Web.Username = "admin" - cfg.Web.Password = generateSecurePassword(16) - log.Printf("[Web] 自动生成凭据 - 用户名: %s, 密码: %s", - cfg.Web.Username, cfg.Web.Password) - // 保存到配置文件 - saveConfig(cfg) - } +// SOCKS5 配置模式 +var Socks5Schema = RuleSchema{ + NeedsLocalAddr: false, + ExtraFields: []ConfigField{ + {Key: "auth_enabled", Label: "启用认证", Type: "bool", Default: "false"}, + {Key: "username", Label: "用户名", Type: "string"}, + {Key: "password", Label: "密码", Type: "password"}, + }, +} + +// HTTP 代理配置模式 +var HTTPProxySchema = RuleSchema{ + NeedsLocalAddr: false, + ExtraFields: []ConfigField{ + {Key: "auth_enabled", Label: "启用认证", Type: "bool", Default: "false"}, + {Key: "username", Label: "用户名", Type: "string"}, + {Key: "password", Label: "密码", Type: "password"}, + }, } ``` +##### C. API 端点 (`internal/server/router/api.go`) +- **GET `/api/rule-schemas`**: 返回所有协议类型的配置模式 + - 内置类型 (tcp, udp, http, https) 的模式 + - 已注册插件的模式(从插件 Metadata 获取) + +#### 前端修改 (`web/src/views/ClientView.vue`) +- 页面加载时获取 rule-schemas +- 根据选择的协议类型动态渲染额外配置字段 +- 支持的字段类型:string, number, bool, select, password + +### 2.2 JS 插件管理界面优化 + +#### 后端修改 + +##### A. 扩展 JSPlugin 结构 (`internal/server/db/interface.go`) +```go +type JSPlugin struct { + Name string `json:"name"` + Source string `json:"source"` // JS 源码 + Signature string `json:"signature"` // 官方签名 + Description string `json:"description"` + Author string `json:"author"` + Version string `json:"version"` + AutoPush []string `json:"auto_push"` // 自动推送客户端列表 + Config map[string]string `json:"config"` // 插件运行时配置 + ConfigSchema []ConfigField `json:"config_schema"` // 配置字段定义 + AutoStart bool `json:"auto_start"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +##### B. 新增/修改 API 端点 +- **GET `/api/js-plugins`**: 获取所有 JS 插件列表(包含配置模式) +- **GET `/api/js-plugin/{name}`**: 获取单个插件详情 +- **PUT `/api/js-plugin/{name}/config`**: 更新插件运行时配置 +- **POST `/api/js-plugin/{name}/push/{clientId}`**: 推送插件到指定客户端 +- **POST `/api/js-plugin/{name}/reload`**: 重新加载插件(重新解析源码) + +#### 前端修改 (`web/src/views/PluginsView.vue`) + +##### A. 重新设计 JS 插件 Tab +``` +┌─────────────────────────────────────────────────────────────┐ +│ 已安装的 JS 插件 │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📦 socks5-auth v1.0.0 │ │ +│ │ 带认证的 SOCKS5 代理 │ │ +│ │ 作者: official │ │ +│ │ ────────────────────────────────────────────────────── │ │ +│ │ 状态: ✅ 启用 自动启动: ✅ │ │ +│ │ 推送目标: 所有客户端 │ │ +│ │ ────────────────────────────────────────────────────── │ │ +│ │ [⚙️ 配置] [🔄 重载] [📤 推送] [🗑️ 删除] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +##### B. 插件配置模态框 +- 动态渲染 ConfigSchema 定义的字段 +- 支持 string, number, bool, select, password 类型 +- 保存后可选择是否同步到已连接的客户端 + +##### C. 推送配置 +- 选择目标客户端(支持多选) +- 选择是否自动启动 +- 显示推送结果 + --- -### 1.3 认证检查端点失效 +## 第三部分:动态重启功能 -**文件**: `internal/server/router/auth.go` +### 3.1 协议消息定义 (`pkg/protocol/message.go`) -**问题**: `/auth/check` 始终返回 `valid: true` - -**修复方案**: -- 实际验证 JWT Token -- 返回真实的验证结果 - -**修改内容**: ```go -// internal/server/router/auth.go -func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) { - // 从 Authorization header 获取 token - token := extractToken(r) - if token == "" { - jsonError(w, "missing token", http.StatusUnauthorized) +// 已有消息类型 +const ( + MsgTypeClientPluginStart = 40 // 启动插件 + MsgTypeClientPluginStop = 41 // 停止插件 (需实现) + MsgTypeClientPluginStatus = 42 // 插件状态 + MsgTypeClientPluginConn = 43 // 插件连接 + + // 新增消息类型 + MsgTypeClientRestart = 44 // 客户端重启 + MsgTypePluginConfigUpdate = 45 // 插件配置更新 +) + +// 新增请求/响应结构 +type ClientPluginStopRequest struct { + PluginName string `json:"plugin_name"` + RuleName string `json:"rule_name"` +} + +type ClientRestartRequest struct { + Reason string `json:"reason,omitempty"` +} + +type PluginConfigUpdateRequest struct { + PluginName string `json:"plugin_name"` + RuleName string `json:"rule_name"` + Config map[string]string `json:"config"` +} +``` + +### 3.2 客户端实现 (`internal/client/tunnel/client.go`) + +#### A. 实现插件停止处理 +```go +func (c *Client) handleClientPluginStop(stream net.Conn, msg *protocol.Message) { + defer stream.Close() + + var req protocol.ClientPluginStopRequest + if err := msg.ParsePayload(&req); err != nil { return } - // 验证 token - claims, err := h.validateToken(token) - if err != nil { - jsonError(w, "invalid token", http.StatusUnauthorized) - return - } + key := req.PluginName + ":" + req.RuleName - json.NewEncoder(w).Encode(map[string]interface{}{ - "valid": true, - "user": claims.Username, - }) + c.pluginMu.Lock() + if handler, ok := c.runningPlugins[key]; ok { + handler.Stop() + delete(c.runningPlugins, key) + } + c.pluginMu.Unlock() + + // 发送确认 + resp := protocol.ClientPluginStatusResponse{ + PluginName: req.PluginName, + RuleName: req.RuleName, + Running: false, + } + respMsg, _ := protocol.NewMessage(protocol.MsgTypeClientPluginStatus, resp) + protocol.WriteMessage(stream, respMsg) } ``` ---- - -### 1.4 Token 生成错误未处理 - -**文件**: `internal/server/config/config.go` - -**问题**: `rand.Read()` 错误被忽略,可能生成弱 Token - -**修复方案**: -- 检查 `rand.Read()` 返回值 -- 失败时 panic 或返回错误 -- 增加 Token 强度验证 - -**修改内容**: +#### B. 实现配置热更新 ```go -// internal/server/config/config.go -func generateToken(length int) (string, error) { - bytes := make([]byte, length/2) - n, err := rand.Read(bytes) - if err != nil { - return "", fmt.Errorf("failed to generate token: %w", err) - } - if n != len(bytes) { - return "", fmt.Errorf("insufficient random bytes: got %d, want %d", n, len(bytes)) - } - return hex.EncodeToString(bytes), nil +func (c *Client) handlePluginConfigUpdate(stream net.Conn, msg *protocol.Message) { + // 更新运行中插件的配置(如果插件支持热更新) } ``` ---- - -### 1.5 客户端 ID 未验证 - -**文件**: `internal/server/tunnel/server.go` - -**问题**: tunnel server 中未使用已有的 ID 验证函数 - -**修复方案**: -- 在 handleConnection 中验证 clientID -- 拒绝非法格式的 ID -- 记录安全日志 - -**修改内容**: +#### C. 实现客户端优雅重启 ```go -// internal/server/tunnel/server.go -func (s *Server) handleConnection(conn net.Conn) { - // ... 读取认证消息后 - - clientID := authReq.ClientID - if clientID != "" && !isValidClientID(clientID) { - log.Printf("[Security] Invalid client ID format from %s: %s", - conn.RemoteAddr(), clientID) - sendAuthResponse(conn, false, "invalid client id format") - return - } - // ... -} - -var clientIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) - -func isValidClientID(id string) bool { - return clientIDRegex.MatchString(id) +func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { + // 1. 停止所有运行中的插件 + // 2. 关闭当前会话 + // 3. 触发重连(Run() 循环会自动处理) } ``` ---- +### 3.3 服务端实现 (`internal/server/tunnel/server.go`) -## 第二阶段:P1 高优先级问题 (发布前建议修复) - -### 2.1 无连接数限制 - -**文件**: `internal/server/tunnel/server.go` - -**修复方案**: -- 添加全局最大连接数限制 -- 添加单客户端连接数限制 -- 使用 semaphore 控制并发 - -**修改内容**: ```go -type Server struct { - // ... - maxConns int - connSem chan struct{} // semaphore - clientConns map[string]int -} +// 停止客户端插件 +func (s *Server) StopClientPlugin(clientID, pluginName, ruleName string) error -func (s *Server) handleConnection(conn net.Conn) { - select { - case s.connSem <- struct{}{}: - defer func() { <-s.connSem }() - default: - conn.Close() - log.Printf("[Server] Connection rejected: max connections reached") - return - } - // ... -} +// 重启客户端插件 +func (s *Server) RestartClientPlugin(clientID, pluginName, ruleName string) error + +// 更新插件配置 +func (s *Server) UpdateClientPluginConfig(clientID, pluginName, ruleName string, config map[string]string) error + +// 重启整个客户端 +func (s *Server) RestartClient(clientID string) error ``` ---- +### 3.4 REST API (`internal/server/router/api.go`) -### 2.2 Goroutine 泄漏 - -**文件**: 多个文件 - -**修复方案**: -- 使用 context 控制 goroutine 生命周期 -- 添加 goroutine 池 -- 确保所有 goroutine 有退出机制 - ---- - -### 2.3 无优雅关闭 - -**文件**: `cmd/server/main.go` - -**修复方案**: -- 监听 SIGTERM/SIGINT 信号 -- 关闭所有监听器 -- 等待现有连接完成 -- 设置关闭超时 - -**修改内容**: ```go -// cmd/server/main.go -func main() { - // ... +// 客户端控制 +POST /api/client/{id}/restart // 重启整个客户端 - // 优雅关闭 - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-quit - log.Println("[Server] Shutting down...") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - server.Shutdown(ctx) - webServer.Shutdown(ctx) - }() - - server.Run() -} +// 插件控制 +POST /api/client/{id}/plugin/{name}/stop // 停止插件 +POST /api/client/{id}/plugin/{name}/restart // 重启插件 +PUT /api/client/{id}/plugin/{name}/config // 更新配置并可选重启 ``` ---- +### 3.5 前端界面 (`web/src/views/ClientView.vue`) -### 2.4 消息大小未验证 - -**文件**: `pkg/protocol/message.go` - -**修复方案**: -- 在 ReadMessage 中检查消息长度 -- 超过限制时返回错误 +在客户端详情页添加控制按钮: +- **客户端级别**: "重启客户端" 按钮 +- **插件级别**: 每个运行中的插件显示 "停止"、"重启"、"配置" 按钮 --- -### 2.5 无读写超时 +## 实施顺序 -**文件**: `internal/server/tunnel/server.go` +### 阶段 1: 清理 WASM 代码 +1. 删除 WASM 相关文件和代码 +2. 更新数据库 schema +3. 清理依赖 -**修复方案**: -- 所有连接设置读写超时 -- 使用 SetDeadline 而非一次性设置 +### 阶段 2: 协议动态配置 +1. 定义 ConfigField 和 RuleSchema 类型 +2. 实现内置协议的配置模式 +3. 添加 `/api/rule-schemas` 端点 +4. 更新前端规则编辑界面 + +### 阶段 3: JS 插件管理优化 +1. 扩展 JSPlugin 结构和数据库 +2. 实现插件配置 API +3. 重新设计前端插件管理界面 + +### 阶段 4: 动态重启功能 +1. 实现客户端 pluginStop 处理 +2. 实现服务端重启方法 +3. 添加 REST API 端点 +4. 更新前端添加控制按钮 + +### 阶段 5: 测试和文档 +1. 端到端测试 +2. 更新 CLAUDE.md --- -### 2.6 竞态条件 +## 文件变更清单 -**文件**: `internal/server/tunnel/server.go` +### 后端 Go 文件 +- `pkg/plugin/types.go` - 添加 ConfigField, RuleSchema +- `pkg/plugin/schema.go` (新建) - 内置协议配置模式 +- `pkg/protocol/message.go` - 新增消息类型 +- `internal/server/db/interface.go` - 更新接口 +- `internal/server/db/sqlite.go` - 更新数据库操作 +- `internal/server/router/api.go` - 新增 API 端点 +- `internal/server/tunnel/server.go` - 重启控制方法 +- `internal/client/tunnel/client.go` - 插件停止/重启处理 -**修复方案**: -- 使用 sync.Map 替代 map + mutex -- 或确保所有 map 访问都在锁保护下 - ---- - -### 2.7 无安全事件日志 - -**修复方案**: -- 添加安全日志模块 -- 记录认证失败、异常访问等事件 -- 支持日志轮转 - ---- - -## 第三阶段:P2 中优先级问题 (发布后迭代) - -| 编号 | 问题 | 文件 | -|-----|------|------| -| 3.1 | 配置文件权限过宽 (0644) | config/config.go | -| 3.2 | 心跳机制不完善 | tunnel/server.go | -| 3.3 | HTTP 代理无 SSRF 防护 | proxy/http.go | -| 3.4 | SOCKS5 代理无验证 | proxy/socks5.go | -| 3.5 | 数据库操作无超时 | db/sqlite.go | -| 3.6 | 错误处理不一致 | 多个文件 | -| 3.7 | UDP 缓冲区无限制 | tunnel/server.go | -| 3.8 | 代理规则无验证 | tunnel/server.go | -| 3.9 | 客户端注册竞态 | tunnel/server.go | -| 3.10 | Relay 资源泄漏 | relay/relay.go | -| 3.11 | 插件配置无验证 | tunnel/server.go | -| 3.12 | 端口号无边界检查 | tunnel/server.go | -| 3.13 | 插件商店 URL 硬编码 | config/config.go | - ---- - -## 第四阶段:P3 低优先级问题 (后续优化) - -| 编号 | 问题 | 建议 | -|-----|------|------| -| 4.1 | 无结构化日志 | 引入 zap/zerolog | -| 4.2 | 无连接池 | 实现连接池 | -| 4.3 | 线性查找规则 | 使用 map 索引 | -| 4.4 | 无数据库缓存 | 添加内存缓存 | -| 4.5 | 魔法数字 | 提取为常量 | -| 4.6 | 无 godoc 注释 | 补充文档 | -| 4.7 | 配置无验证 | 添加验证逻辑 | - ---- - -## 修复顺序 - -``` -Week 1: P0 问题 (5个) - ├── Day 1-2: 1.1 TLS 证书验证 - ├── Day 2-3: 1.2 Web 控制台认证 - ├── Day 3-4: 1.3 认证检查端点 - ├── Day 4: 1.4 Token 生成 - └── Day 5: 1.5 客户端 ID 验证 - -Week 2: P1 问题 (7个) - ├── Day 1-2: 2.1 连接数限制 - ├── Day 2-3: 2.2 Goroutine 泄漏 - ├── Day 3-4: 2.3 优雅关闭 - ├── Day 4: 2.4 消息大小验证 - ├── Day 5: 2.5 读写超时 - └── Day 5: 2.6-2.7 竞态条件 + 安全日志 - -Week 3+: P2/P3 问题 - └── 按优先级逐步修复 -``` - ---- - -## 测试计划 - -### 安全测试 -- [ ] TLS 中间人攻击测试 -- [ ] 认证绕过测试 -- [ ] 注入攻击测试 -- [ ] DoS 攻击测试 - -### 稳定性测试 -- [ ] 长时间运行测试 (72h+) -- [ ] 高并发连接测试 (10000+) -- [ ] 内存泄漏测试 -- [ ] Goroutine 泄漏测试 - -### 性能测试 -- [ ] 吞吐量基准测试 -- [ ] 延迟基准测试 -- [ ] 资源使用监控 - ---- - -## 回滚方案 - -如发布后发现严重问题: - -1. **立即回滚**: 保留上一版本二进制文件 -2. **热修复**: 针对特定问题发布补丁 -3. **降级运行**: 禁用问题功能模块 - ---- - -## 监控告警 - -发布后需要监控的指标: - -- 连接数 / 活跃客户端数 -- 内存使用 / Goroutine 数量 -- 认证失败率 -- 错误日志频率 -- 响应延迟 P99 - ---- - -*文档版本: 1.0* -*创建时间: 2025-12-29* -*状态: 待审核* +### 前端文件 +- `web/src/types/index.ts` - 类型定义 +- `web/src/api/index.ts` - API 调用 +- `web/src/views/ClientView.vue` - 规则配置和重启控制 +- `web/src/views/PluginsView.vue` - 插件管理界面 diff --git a/web/package-lock.json b/web/package-lock.json index cff56fa..810ea46 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -941,7 +941,6 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1235,7 +1234,6 @@ "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -1258,7 +1256,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -2006,7 +2003,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2185,7 +2181,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2279,7 +2274,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 721cebd..6112410 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, PluginConfigResponse, JSPlugin } from '../types' +import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap } from '../types' // 重新导出 token 管理方法 export { getToken, setToken, removeToken } from '../config/axios' @@ -23,9 +23,21 @@ export const reloadConfig = () => post('/config/reload') // 客户端控制 export const pushConfigToClient = (id: string) => post(`/client/${id}/push`) export const disconnectClient = (id: string) => post(`/client/${id}/disconnect`) +export const restartClient = (id: string) => post(`/client/${id}/restart`) export const installPluginsToClient = (id: string, plugins: string[]) => post(`/client/${id}/install-plugins`, { plugins }) +// 规则配置模式 +export const getRuleSchemas = () => get('/rule-schemas') + +// 客户端插件控制 +export const stopClientPlugin = (clientId: string, pluginName: string, ruleName: string) => + post(`/client/${clientId}/plugin/${pluginName}/stop`, { rule_name: ruleName }) +export const restartClientPlugin = (clientId: string, pluginName: string, ruleName: string) => + post(`/client/${clientId}/plugin/${pluginName}/restart`, { rule_name: ruleName }) +export const updateClientPluginConfigWithRestart = (clientId: string, pluginName: string, ruleName: string, config: Record, restart: boolean) => + post(`/client/${clientId}/plugin/${pluginName}/config`, { rule_name: ruleName, config, restart }) + // 插件管理 export const getPlugins = () => get('/plugins') export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`) @@ -50,3 +62,7 @@ export const updateJSPlugin = (name: string, plugin: JSPlugin) => put(`/js-plugi export const deleteJSPlugin = (name: string) => del(`/js-plugin/${name}`) export const pushJSPluginToClient = (pluginName: string, clientId: string) => post(`/js-plugin/${pluginName}/push/${clientId}`) +export const updateJSPluginConfig = (name: string, config: Record) => + put(`/js-plugin/${name}/config`, { config }) +export const setJSPluginEnabled = (name: string, enabled: boolean) => + post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`) diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 0a39d82..1e61928 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -117,10 +117,15 @@ export interface StorePluginInfo { export interface JSPlugin { name: string source: string + signature?: string description: string author: string + version?: string auto_push: string[] config: Record auto_start: boolean enabled: boolean } + +// 规则配置模式集合 +export type RuleSchemasMap = Record diff --git a/web/src/views/ClientView.vue b/web/src/views/ClientView.vue index b6361d3..48c7b84 100644 --- a/web/src/views/ClientView.vue +++ b/web/src/views/ClientView.vue @@ -9,14 +9,14 @@ import { import { ArrowBackOutline, CreateOutline, TrashOutline, PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline, - DownloadOutline, SettingsOutline, StorefrontOutline + DownloadOutline, SettingsOutline, StorefrontOutline, RefreshOutline, StopOutline } from '@vicons/ionicons5' import { - getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, + getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient, getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig, - getStorePlugins, installStorePlugin + getStorePlugins, installStorePlugin, getRuleSchemas, restartClientPlugin, stopClientPlugin } from '../api' -import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema, StorePluginInfo } from '../types' +import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types' const route = useRoute() const router = useRouter() @@ -49,16 +49,23 @@ const builtinTypes = [ // 规则类型选项(内置 + 插件) const typeOptions = ref([...builtinTypes]) -// 插件 RuleSchema 映射 -const pluginRuleSchemas = ref>({}) +// 插件 RuleSchema 映射(包含内置类型和插件类型) +const pluginRuleSchemas = ref({}) + +// 加载规则配置模式 +const loadRuleSchemas = async () => { + try { + const { data } = await getRuleSchemas() + pluginRuleSchemas.value = data || {} + } catch (e) { + console.error('Failed to load rule schemas', e) + } +} // 判断类型是否需要本地地址 const needsLocalAddr = (type: string) => { - // 内置类型 - if (['tcp', 'udp'].includes(type)) return true - // 插件类型:查询 RuleSchema const schema = pluginRuleSchemas.value[type] - return schema?.needs_local_addr ?? false + return schema?.needs_local_addr ?? true // 默认需要 } // 获取类型的额外字段 @@ -97,14 +104,12 @@ const loadPlugins = async () => { .map(p => ({ label: `${p.name.toUpperCase()} (插件)`, value: p.name })) typeOptions.value = [...builtinTypes, ...proxyPlugins] - // 保存插件的 RuleSchema - const schemas: Record = {} + // 合并插件的 RuleSchema 到 pluginRuleSchemas for (const p of availablePlugins.value) { if (p.rule_schema) { - schemas[p.name] = p.rule_schema + pluginRuleSchemas.value[p.name] = p.rule_schema } } - pluginRuleSchemas.value = schemas } catch (e) { console.error('Failed to load plugins', e) } @@ -175,6 +180,7 @@ const loadClient = async () => { } onMounted(() => { + loadRuleSchemas() // 加载内置协议配置模式 loadClient() loadPlugins() }) @@ -289,6 +295,50 @@ const disconnect = () => { }) } +// 重启客户端 +const handleRestartClient = () => { + dialog.warning({ + title: '确认重启', + content: '确定要重启此客户端吗?客户端将断开连接并自动重连。', + positiveText: '重启', + negativeText: '取消', + onPositiveClick: async () => { + try { + await restartClient(clientId) + message.success('重启命令已发送,客户端将自动重连') + setTimeout(() => loadClient(), 3000) + } catch (e: any) { + message.error(e.response?.data || '重启失败') + } + } + }) +} + +// 重启客户端插件 +const handleRestartPlugin = async (plugin: ClientPlugin) => { + // 找到使用此插件的规则 + const rule = rules.value.find(r => r.type === plugin.name) + const ruleName = rule?.name || '' + try { + await restartClientPlugin(clientId, plugin.name, ruleName) + message.success(`已重启 ${plugin.name}`) + } catch (e: any) { + message.error(e.response?.data || '重启失败') + } +} + +// 停止客户端插件 +const handleStopPlugin = async (plugin: ClientPlugin) => { + const rule = rules.value.find(r => r.type === plugin.name) + const ruleName = rule?.name || '' + try { + await stopClientPlugin(clientId, plugin.name, ruleName) + message.success(`已停止 ${plugin.name}`) + } catch (e: any) { + message.error(e.response?.data || '停止失败') + } +} + const installPlugins = async () => { if (selectedPlugins.value.length === 0) { message.warning('请选择要安装的插件') @@ -403,6 +453,10 @@ const savePluginConfig = async () => { 断开连接 + + + 重启客户端 + JS 自动启动 + 已签名

{{ plugin.description || '无描述' }}

作者: {{ plugin.author }}

+ + +
+

配置:

+ + + {{ key }}: {{ value.length > 10 ? value.slice(0, 10) + '...' : value }} + + +
+ @@ -390,5 +454,28 @@ onMounted(() => { + + + + +

编辑插件配置参数(键值对形式)

+
+ + + + + 删除 + + +
+ 添加配置项 +
+ +