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
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
109
pkg/plugin/schema.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
607
plan.md
607
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 代理添加认证配置字段:
|
||||
|
||||
```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
6
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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'}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user