update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 59s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 53s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 2m8s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m10s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m11s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 55s

This commit is contained in:
2026-01-01 14:43:33 +08:00
parent 76fde41e48
commit 0c00a9ffdc
13 changed files with 1096 additions and 578 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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})
}

View File

@@ -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
}

109
pkg/plugin/schema.go Normal file
View File

@@ -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
}

View File

@@ -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)

605
plan.md
View File

@@ -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 代理添加认证配置字段:
```go
// 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 获取)
### 1.2 Web 控制台无认证
#### 前端修改 (`web/src/views/ClientView.vue`)
- 页面加载时获取 rule-schemas
- 根据选择的协议类型动态渲染额外配置字段
- 支持的字段类型string, number, bool, select, password
**文件**: `cmd/server/main.go`
### 2.2 JS 插件管理界面优化
**问题**: 默认配置下 Web 控制台完全开放
#### 后端修改
**修复方案**:
- 首次启动时自动生成随机密码
- 强制要求配置用户名密码
- 无认证时拒绝启动 Web 服务
**修改内容**:
##### A. 扩展 JSPlugin 结构 (`internal/server/db/interface.go`)
```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)
}
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` - 插件管理界面

6
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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<RuleSchemasMap>('/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<string, string>, restart: boolean) =>
post(`/client/${clientId}/plugin/${pluginName}/config`, { rule_name: ruleName, config, restart })
// 插件管理
export const getPlugins = () => get<PluginInfo[]>('/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<string, string>) =>
put(`/js-plugin/${name}/config`, { config })
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)

View File

@@ -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<string, string>
auto_start: boolean
enabled: boolean
}
// 规则配置模式集合
export type RuleSchemasMap = Record<string, RuleSchema>

View File

@@ -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<Record<string, RuleSchema>>({})
// 插件 RuleSchema 映射(包含内置类型和插件类型)
const pluginRuleSchemas = ref<RuleSchemasMap>({})
// 加载规则配置模式
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<string, RuleSchema> = {}
// 合并插件的 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 () => {
<template #icon><n-icon><PowerOutline /></n-icon></template>
断开连接
</n-button>
<n-button type="error" @click="handleRestartClient">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重启客户端
</n-button>
</template>
<template v-if="!editing">
<n-button type="primary" @click="startEdit">
@@ -496,12 +550,45 @@ const savePluginConfig = async () => {
<!-- 插件额外字段 -->
<template v-for="field in getExtraFields(rule.type || '')" :key="field.key">
<n-form-item :label="field.label" :show-feedback="false">
<!-- 字符串输入 -->
<n-input
v-if="field.type === 'string'"
:value="rule.plugin_config?.[field.key] || field.default || ''"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
:placeholder="field.description"
/>
<!-- 密码输入 -->
<n-input
v-else-if="field.type === 'password'"
:value="rule.plugin_config?.[field.key] || ''"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
type="password"
show-password-on="click"
:placeholder="field.description"
/>
<!-- 数字输入 -->
<n-input-number
v-else-if="field.type === 'number'"
:value="rule.plugin_config?.[field.key] ? Number(rule.plugin_config[field.key]) : undefined"
@update:value="(v: number | null) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v !== null ? String(v) : '' }"
:placeholder="field.description"
:show-button="false"
style="width: 120px;"
/>
<!-- 布尔开关 -->
<n-switch
v-else-if="field.type === 'bool'"
:value="rule.plugin_config?.[field.key] === 'true'"
@update:value="(v: boolean) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = String(v) }"
/>
<!-- 下拉选择 -->
<n-select
v-else-if="field.type === 'select'"
:value="rule.plugin_config?.[field.key] || field.default"
@update:value="(v: string) => { if (!rule.plugin_config) rule.plugin_config = {}; rule.plugin_config[field.key] = v }"
:options="(field.options || []).map(o => ({ label: o, value: o }))"
style="width: 120px;"
/>
</n-form-item>
</template>
<n-button v-if="editRules.length > 1" quaternary type="error" @click="removeRule(i)">
@@ -537,10 +624,20 @@ const savePluginConfig = async () => {
<n-switch :value="plugin.enabled" @update:value="toggleClientPlugin(plugin)" />
</td>
<td>
<n-space :size="4">
<n-button size="small" quaternary @click="openConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-button v-if="online && plugin.enabled" size="small" quaternary type="info" @click="handleRestartPlugin(plugin)">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重启
</n-button>
<n-button v-if="online && plugin.enabled" size="small" quaternary type="warning" @click="handleStopPlugin(plugin)">
<template #icon><n-icon><StopOutline /></n-icon></template>
停止
</n-button>
</n-space>
</td>
</tr>
</tbody>

View File

@@ -4,12 +4,12 @@ import { useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
NSelect, NModal
NSelect, NModal, NInput
} from 'naive-ui'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5'
import {
getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins,
pushJSPluginToClient, getClients, installStorePlugin
pushJSPluginToClient, getClients, installStorePlugin, updateJSPluginConfig, setJSPluginEnabled
} from '../api'
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
@@ -142,6 +142,68 @@ const handlePushJSPlugin = async (pluginName: string, clientId: string) => {
const onlineClients = computed(() => clients.value.filter(c => c.online))
// JS 插件配置相关
const showJSConfigModal = ref(false)
const currentJSPlugin = ref<JSPlugin | null>(null)
const jsConfigItems = ref<Array<{ key: string; value: string }>>([])
const jsConfigSaving = ref(false)
const openJSConfigModal = (plugin: JSPlugin) => {
currentJSPlugin.value = plugin
// 将 config 转换为数组形式便于编辑
jsConfigItems.value = Object.entries(plugin.config || {}).map(([key, value]) => ({ key, value }))
if (jsConfigItems.value.length === 0) {
jsConfigItems.value.push({ key: '', value: '' })
}
showJSConfigModal.value = true
}
const addJSConfigItem = () => {
jsConfigItems.value.push({ key: '', value: '' })
}
const removeJSConfigItem = (index: number) => {
jsConfigItems.value.splice(index, 1)
}
const saveJSPluginConfig = async () => {
if (!currentJSPlugin.value) return
jsConfigSaving.value = true
try {
// 将数组转换回对象
const config: Record<string, string> = {}
for (const item of jsConfigItems.value) {
if (item.key.trim()) {
config[item.key.trim()] = item.value
}
}
await updateJSPluginConfig(currentJSPlugin.value.name, config)
// 更新本地数据
const plugin = jsPlugins.value.find(p => p.name === currentJSPlugin.value!.name)
if (plugin) {
plugin.config = config
}
message.success('配置已保存')
showJSConfigModal.value = false
} catch (e: any) {
message.error(e.response?.data || '保存失败')
} finally {
jsConfigSaving.value = false
}
}
// 切换 JS 插件启用状态
const toggleJSPlugin = async (plugin: JSPlugin) => {
try {
await setJSPluginEnabled(plugin.name, !plugin.enabled)
plugin.enabled = !plugin.enabled
message.success(plugin.enabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`)
} catch (e: any) {
message.error(e.response?.data || '操作失败')
}
}
// 商店插件安装相关
const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
@@ -246,8 +308,8 @@ onMounted(() => {
<n-tag size="small" :type="getTypeColor(plugin.type)">
{{ getTypeLabel(plugin.type) }}
</n-tag>
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'info'">
{{ plugin.source === 'builtin' ? '内置' : 'JS' }}
</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
@@ -301,15 +363,6 @@ onMounted(() => {
<!-- JS 插件 -->
<n-tab-pane name="js" tab="JS 插件">
<!-- 安全加固暂时禁用 Web UI 创建功能
<n-space justify="end" style="margin-bottom: 16px;">
<n-button type="primary" @click="showJSModal = true">
<template #icon><n-icon><AddOutline /></n-icon></template>
新建 JS 插件
</n-button>
</n-space>
-->
<n-spin :show="jsLoading">
<n-empty v-if="!jsLoading && jsPlugins.length === 0" description="暂无 JS 插件" />
@@ -320,36 +373,47 @@ onMounted(() => {
<n-space align="center">
<n-icon size="24" color="#f0a020"><CodeSlashOutline /></n-icon>
<span>{{ plugin.name }}</span>
<n-tag v-if="plugin.version" size="small">v{{ plugin.version }}</n-tag>
</n-space>
</template>
<template #header-extra>
<n-space>
<n-select
v-if="onlineClients.length > 0"
placeholder="推送到..."
size="small"
style="width: 120px;"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
@update:value="(v: string) => handlePushJSPlugin(plugin.name, v)"
/>
<!-- 安全加固暂时禁用删除功能
<n-popconfirm @positive-click="handleDeleteJSPlugin(plugin.name)">
<template #trigger>
<n-button size="small" type="error" quaternary>删除</n-button>
</template>
确定删除此插件
</n-popconfirm>
-->
</n-space>
<n-switch :value="plugin.enabled" @update:value="toggleJSPlugin(plugin)" />
</template>
<n-space vertical :size="8">
<n-space>
<n-tag size="small" type="warning">JS</n-tag>
<n-tag v-if="plugin.auto_start" size="small" type="success">自动启动</n-tag>
<n-tag v-if="plugin.signature" size="small" type="info">已签名</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description || '无描述' }}</p>
<p v-if="plugin.author" style="margin: 0; color: #999; font-size: 12px;">作者: {{ plugin.author }}</p>
<!-- 配置预览 -->
<div v-if="Object.keys(plugin.config || {}).length > 0" style="margin-top: 8px;">
<p style="margin: 0 0 4px 0; color: #999; font-size: 12px;">配置:</p>
<n-space :size="4" wrap>
<n-tag v-for="(value, key) in plugin.config" :key="key" size="small" type="default">
{{ key }}: {{ value.length > 10 ? value.slice(0, 10) + '...' : value }}
</n-tag>
</n-space>
</div>
</n-space>
<template #action>
<n-space justify="space-between">
<n-button size="small" quaternary @click="openJSConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-select
v-if="onlineClients.length > 0"
placeholder="推送到..."
size="small"
style="width: 140px;"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
@update:value="(v: string) => handlePushJSPlugin(plugin.name, v)"
/>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
@@ -390,5 +454,28 @@ onMounted(() => {
</n-space>
</template>
</n-modal>
<!-- JS 插件配置模态框 -->
<n-modal v-model:show="showJSConfigModal" preset="card" :title="`${currentJSPlugin?.name || ''} 配置`" style="width: 500px;">
<n-space vertical :size="12">
<p style="margin: 0; color: #666; font-size: 13px;">编辑插件配置参数键值对形式</p>
<div v-for="(item, index) in jsConfigItems" :key="index">
<n-space :size="8" align="center">
<n-input v-model:value="item.key" placeholder="参数名" style="width: 150px;" />
<n-input v-model:value="item.value" placeholder="参数值" style="width: 200px;" />
<n-button v-if="jsConfigItems.length > 1" quaternary type="error" size="small" @click="removeJSConfigItem(index)">
删除
</n-button>
</n-space>
</div>
<n-button dashed size="small" @click="addJSConfigItem">添加配置项</n-button>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showJSConfigModal = false">取消</n-button>
<n-button type="primary" :loading="jsConfigSaving" @click="saveJSPluginConfig">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>