111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 39s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m0s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 58s

This commit is contained in:
Flik
2025-12-28 00:05:21 +08:00
parent f43256d33d
commit abfc235357
10 changed files with 513 additions and 15 deletions

View File

@@ -194,6 +194,9 @@ func (c *Client) handleStream(stream net.Conn) {
c.handleProxyConnect(stream, msg)
case protocol.MsgTypeUDPData:
c.handleUDPData(stream, msg)
case protocol.MsgTypePluginConfig:
defer stream.Close()
c.handlePluginConfig(msg)
}
}
@@ -346,3 +349,28 @@ func (c *Client) findRuleByPort(port int) *protocol.ProxyRule {
}
return nil
}
// handlePluginConfig 处理插件配置同步
func (c *Client) handlePluginConfig(msg *protocol.Message) {
var cfg protocol.PluginConfigSync
if err := msg.ParsePayload(&cfg); err != nil {
log.Printf("[Client] Parse plugin config error: %v", err)
return
}
log.Printf("[Client] Received config for plugin: %s", cfg.PluginName)
// 应用配置到插件
if c.pluginRegistry != nil {
handler, err := c.pluginRegistry.Get(cfg.PluginName)
if err != nil {
log.Printf("[Client] Plugin %s not found: %v", cfg.PluginName, err)
return
}
if err := handler.Init(cfg.Config); err != nil {
log.Printf("[Client] Plugin %s init error: %v", cfg.PluginName, err)
return
}
log.Printf("[Client] Plugin %s config applied", cfg.PluginName)
}
}

View File

@@ -4,9 +4,10 @@ import "github.com/gotunnel/pkg/protocol"
// ClientPlugin 客户端已安装的插件
type ClientPlugin struct {
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Config map[string]string `json:"config,omitempty"` // 插件配置
}
// Client 客户端数据

View File

@@ -46,6 +46,20 @@ type ServerInterface interface {
EnablePlugin(name string) error
DisablePlugin(name string) error
InstallPluginsToClient(clientID string, plugins []string) error
// 插件配置
GetPluginConfigSchema(name string) ([]ConfigField, error)
SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error
}
// ConfigField 配置字段(从 plugin 包导出)
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// PluginInfo 插件信息
@@ -92,6 +106,7 @@ func RegisterRoutes(r *Router, app AppInterface) {
api.HandleFunc("/plugins", h.handlePlugins)
api.HandleFunc("/plugin/", h.handlePlugin)
api.HandleFunc("/store/plugins", h.handleStorePlugins)
api.HandleFunc("/client-plugin/", h.handleClientPlugin)
}
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
@@ -586,3 +601,143 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request)
"store_url": storeURL,
})
}
// handleClientPlugin 处理客户端插件配置
// 路由: /api/client-plugin/{clientID}/{pluginName}/config
func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/api/client-plugin/"):]
if path == "" {
http.Error(rw, "client id required", http.StatusBadRequest)
return
}
// 解析路径: clientID/pluginName/action
parts := splitPathMulti(path)
if len(parts) < 3 {
http.Error(rw, "invalid path, expected: /api/client-plugin/{clientID}/{pluginName}/config", http.StatusBadRequest)
return
}
clientID := parts[0]
pluginName := parts[1]
action := parts[2]
if action != "config" {
http.Error(rw, "invalid action", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
h.getClientPluginConfig(rw, clientID, pluginName)
case http.MethodPut:
h.updateClientPluginConfig(rw, r, clientID, pluginName)
default:
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// splitPathMulti 分割路径为多个部分
func splitPathMulti(path string) []string {
var parts []string
start := 0
for i, c := range path {
if c == '/' {
if i > start {
parts = append(parts, path[start:i])
}
start = i + 1
}
}
if start < len(path) {
parts = append(parts, path[start:])
}
return parts
}
// getClientPluginConfig 获取客户端插件配置
func (h *APIHandler) getClientPluginConfig(rw http.ResponseWriter, clientID, pluginName string) {
client, err := h.clientStore.GetClient(clientID)
if err != nil {
http.Error(rw, "client not found", http.StatusNotFound)
return
}
// 获取插件配置模式
schema, err := h.server.GetPluginConfigSchema(pluginName)
if err != nil {
http.Error(rw, err.Error(), http.StatusNotFound)
return
}
// 查找客户端的插件配置
var config map[string]string
for _, p := range client.Plugins {
if p.Name == pluginName {
config = p.Config
break
}
}
if config == nil {
config = make(map[string]string)
}
h.jsonResponse(rw, map[string]interface{}{
"plugin_name": pluginName,
"schema": schema,
"config": config,
})
}
// updateClientPluginConfig 更新客户端插件配置
func (h *APIHandler) updateClientPluginConfig(rw http.ResponseWriter, r *http.Request, clientID, pluginName string) {
var req struct {
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
client, err := h.clientStore.GetClient(clientID)
if err != nil {
http.Error(rw, "client not found", http.StatusNotFound)
return
}
// 更新插件配置
found := false
for i, p := range client.Plugins {
if p.Name == pluginName {
client.Plugins[i].Config = req.Config
found = true
break
}
}
if !found {
http.Error(rw, "plugin not installed on client", http.StatusNotFound)
return
}
// 保存到数据库
if err := h.clientStore.UpdateClient(client); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
// 如果客户端在线,同步配置
online, _ := h.server.GetClientStatus(clientID)
if online {
if err := h.server.SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
// 配置已保存,但同步失败,返回警告
h.jsonResponse(rw, map[string]interface{}{
"status": "partial",
"message": "config saved but sync failed: " + err.Error(),
})
return
}
}
h.jsonResponse(rw, map[string]string{"status": "ok"})
}

View File

@@ -700,3 +700,62 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
conn.WriteToUDP(respPacket.Data, clientAddr)
}
}
// GetPluginConfigSchema 获取插件配置模式
func (s *Server) GetPluginConfigSchema(name string) ([]router.ConfigField, error) {
if s.pluginRegistry == nil {
return nil, fmt.Errorf("plugin registry not initialized")
}
handler, err := s.pluginRegistry.Get(name)
if err != nil {
return nil, fmt.Errorf("plugin %s not found", name)
}
metadata := handler.Metadata()
var result []router.ConfigField
for _, f := range metadata.ConfigSchema {
result = append(result, router.ConfigField{
Key: f.Key,
Label: f.Label,
Type: string(f.Type),
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
})
}
return result, nil
}
// SyncPluginConfigToClient 同步插件配置到客户端
func (s *Server) SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not online", clientID)
}
return s.sendPluginConfig(cs.Session, pluginName, config)
}
// sendPluginConfig 发送插件配置到客户端
func (s *Server) sendPluginConfig(session *yamux.Session, pluginName string, config map[string]string) error {
stream, err := session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.PluginConfigSync{
PluginName: pluginName,
Config: config,
}
msg, err := protocol.NewMessage(protocol.MsgTypePluginConfig, req)
if err != nil {
return err
}
return protocol.WriteMessage(stream, msg)
}

View File

@@ -15,12 +15,17 @@ func init() {
}
const (
socks5Version = 0x05
noAuth = 0x00
cmdConnect = 0x01
atypIPv4 = 0x01
atypDomain = 0x03
atypIPv6 = 0x04
socks5Version = 0x05
noAuth = 0x00
userPassAuth = 0x02
noAcceptable = 0xFF
userPassAuthVer = 0x01
authSuccess = 0x00
authFailure = 0x01
cmdConnect = 0x01
atypIPv4 = 0x01
atypDomain = 0x03
atypIPv6 = 0x04
)
// SOCKS5Plugin 将现有 SOCKS5 实现封装为 plugin
@@ -45,6 +50,28 @@ func (p *SOCKS5Plugin) Metadata() plugin.PluginMetadata {
Capabilities: []string{
"dial", "read", "write", "close",
},
ConfigSchema: []plugin.ConfigField{
{
Key: "auth",
Label: "认证方式",
Type: plugin.ConfigFieldSelect,
Default: "none",
Options: []string{"none", "password"},
Description: "SOCKS5 认证方式",
},
{
Key: "username",
Label: "用户名",
Type: plugin.ConfigFieldString,
Description: "认证用户名(仅 password 认证时需要)",
},
{
Key: "password",
Label: "密码",
Type: plugin.ConfigFieldPassword,
Description: "认证密码(仅 password 认证时需要)",
},
},
}
}
@@ -110,7 +137,31 @@ func (p *SOCKS5Plugin) handshake(conn net.Conn) error {
return err
}
// 响应:使用无认证
// 检查是否需要密码认证
if p.config["auth"] == "password" {
// 检查客户端是否支持用户名密码认证
supported := false
for _, m := range methods {
if m == userPassAuth {
supported = true
break
}
}
if !supported {
conn.Write([]byte{socks5Version, noAcceptable})
return errors.New("client does not support password auth")
}
// 选择用户名密码认证
if _, err := conn.Write([]byte{socks5Version, userPassAuth}); err != nil {
return err
}
// 执行用户名密码认证
return p.authenticateUserPass(conn)
}
// 无认证
_, err := conn.Write([]byte{socks5Version, noAuth})
return err
}
@@ -163,6 +214,48 @@ func (p *SOCKS5Plugin) readRequest(conn net.Conn) (string, error) {
return fmt.Sprintf("%s:%d", host, port), nil
}
// authenticateUserPass 用户名密码认证
func (p *SOCKS5Plugin) authenticateUserPass(conn net.Conn) error {
// 读取认证版本
buf := make([]byte, 2)
if _, err := io.ReadFull(conn, buf); err != nil {
return err
}
if buf[0] != userPassAuthVer {
return errors.New("unsupported auth version")
}
// 读取用户名
ulen := int(buf[1])
username := make([]byte, ulen)
if _, err := io.ReadFull(conn, username); err != nil {
return err
}
// 读取密码长度和密码
plenBuf := make([]byte, 1)
if _, err := io.ReadFull(conn, plenBuf); err != nil {
return err
}
plen := int(plenBuf[0])
password := make([]byte, plen)
if _, err := io.ReadFull(conn, password); err != nil {
return err
}
// 验证用户名密码
expectedUser := p.config["username"]
expectedPass := p.config["password"]
if string(username) == expectedUser && string(password) == expectedPass {
conn.Write([]byte{userPassAuthVer, authSuccess})
return nil
}
conn.Write([]byte{userPassAuthVer, authFailure})
return errors.New("authentication failed")
}
// sendReply 发送响应
func (p *SOCKS5Plugin) sendReply(conn net.Conn, rep byte) error {
reply := []byte{socks5Version, rep, 0x00, atypIPv4, 0, 0, 0, 0, 0, 0}

View File

@@ -23,6 +23,28 @@ const (
PluginSourceWASM PluginSource = "wasm" // WASM 模块
)
// ConfigFieldType 配置字段类型
type ConfigFieldType string
const (
ConfigFieldString ConfigFieldType = "string"
ConfigFieldNumber ConfigFieldType = "number"
ConfigFieldBool ConfigFieldType = "bool"
ConfigFieldSelect ConfigFieldType = "select" // 下拉选择
ConfigFieldPassword ConfigFieldType = "password" // 密码输入
)
// ConfigField 配置字段定义
type ConfigField struct {
Key string `json:"key"` // 配置键名
Label string `json:"label"` // 显示标签
Type ConfigFieldType `json:"type"` // 字段类型
Default string `json:"default,omitempty"` // 默认值
Required bool `json:"required,omitempty"` // 是否必填
Options []string `json:"options,omitempty"` // select 类型的选项
Description string `json:"description,omitempty"` // 字段描述
}
// PluginMetadata 描述一个 plugin
type PluginMetadata struct {
Name string `json:"name"` // 唯一标识符 (如 "socks5")
@@ -35,7 +57,7 @@ type PluginMetadata struct {
Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256
Size int64 `json:"size,omitempty"` // WASM 二进制大小
Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions
ConfigSchema map[string]string `json:"config_schema,omitempty"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`// 配置模式定义
}
// PluginInfo 组合元数据和运行时状态

View File

@@ -37,6 +37,7 @@ const (
// 插件安装消息
MsgTypeInstallPlugins uint8 = 24 // 服务端推送安装插件列表
MsgTypePluginConfig uint8 = 25 // 插件配置同步
)
// Message 基础消息结构
@@ -155,6 +156,12 @@ type InstallPluginsRequest struct {
Plugins []string `json:"plugins"` // 要安装的插件名称列表
}
// PluginConfigSync 插件配置同步
type PluginConfigSync struct {
PluginName string `json:"plugin_name"` // 插件名称
Config map[string]string `json:"config"` // 配置内容
}
// UDPPacket UDP 数据包
type UDPPacket struct {
RemotePort int `json:"remote_port"` // 服务端监听端口

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo } from '../types'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse } from '../types'
// 重新导出 token 管理方法
export { getToken, setToken, removeToken } from '../config/axios'
@@ -33,3 +33,9 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')
// 客户端插件配置
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
get<PluginConfigResponse>(`/client-plugin/${clientId}/${pluginName}/config`)
export const updateClientPluginConfig = (clientId: string, pluginName: string, config: Record<string, string>) =>
put(`/client-plugin/${clientId}/${pluginName}/config`, { config })

View File

@@ -13,6 +13,25 @@ export interface ClientPlugin {
name: string
version: string
enabled: boolean
config?: Record<string, string>
}
// 插件配置字段
export interface ConfigField {
key: string
label: string
type: 'string' | 'number' | 'bool' | 'select' | 'password'
default?: string
required?: boolean
options?: string[]
description?: string
}
// 插件配置响应
export interface PluginConfigResponse {
plugin_name: string
schema: ConfigField[]
config: Record<string, string>
}
// 客户端配置

View File

@@ -9,10 +9,13 @@ import {
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
DownloadOutline
DownloadOutline, SettingsOutline
} from '@vicons/ionicons5'
import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api'
import type { ProxyRule, PluginInfo, ClientPlugin } from '../types'
import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient,
getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig
} from '../api'
import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField } from '../types'
const route = useRoute()
const router = useRouter()
@@ -42,6 +45,13 @@ const showInstallModal = ref(false)
const availablePlugins = ref<PluginInfo[]>([])
const selectedPlugins = ref<string[]>([])
// 插件配置相关
const showConfigModal = ref(false)
const configPluginName = ref('')
const configSchema = ref<ConfigField[]>([])
const configValues = ref<Record<string, string>>({})
const configLoading = ref(false)
const loadPlugins = async () => {
try {
const { data } = await getPlugins()
@@ -189,6 +199,42 @@ const toggleClientPlugin = async (plugin: ClientPlugin) => {
message.error('操作失败')
}
}
// 打开插件配置模态框
const openConfigModal = async (plugin: ClientPlugin) => {
configPluginName.value = plugin.name
configLoading.value = true
showConfigModal.value = true
try {
const { data } = await getClientPluginConfig(clientId, plugin.name)
configSchema.value = data.schema || []
configValues.value = { ...data.config }
// 填充默认值
for (const field of configSchema.value) {
if (field.default && !configValues.value[field.key]) {
configValues.value[field.key] = field.default
}
}
} catch (e: any) {
message.error(e.response?.data || '加载配置失败')
showConfigModal.value = false
} finally {
configLoading.value = false
}
}
// 保存插件配置
const savePluginConfig = async () => {
try {
await updateClientPluginConfig(clientId, configPluginName.value, configValues.value)
message.success('配置已保存')
showConfigModal.value = false
loadClient()
} catch (e: any) {
message.error(e.response?.data || '保存失败')
}
}
</script>
<template>
@@ -331,6 +377,7 @@ const toggleClientPlugin = async (plugin: ClientPlugin) => {
<th>名称</th>
<th>版本</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -340,6 +387,12 @@ const toggleClientPlugin = async (plugin: ClientPlugin) => {
<td>
<n-switch :value="plugin.enabled" @update:value="toggleClientPlugin(plugin)" />
</td>
<td>
<n-button size="small" quaternary @click="openConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
</td>
</tr>
</tbody>
</n-table>
@@ -377,5 +430,60 @@ const toggleClientPlugin = async (plugin: ClientPlugin) => {
</n-space>
</template>
</n-modal>
<!-- 插件配置模态框 -->
<n-modal v-model:show="showConfigModal" preset="card" :title="`${configPluginName} 配置`" style="width: 500px;">
<n-empty v-if="configLoading" description="加载中..." />
<n-empty v-else-if="configSchema.length === 0" description="该插件暂无可配置项" />
<n-space v-else vertical :size="16">
<n-form-item v-for="field in configSchema" :key="field.key" :label="field.label">
<!-- 字符串输入 -->
<n-input
v-if="field.type === 'string'"
v-model:value="configValues[field.key]"
:placeholder="field.description || field.label"
/>
<!-- 密码输入 -->
<n-input
v-else-if="field.type === 'password'"
v-model:value="configValues[field.key]"
type="password"
show-password-on="click"
:placeholder="field.description || field.label"
/>
<!-- 数字输入 -->
<n-input-number
v-else-if="field.type === 'number'"
:value="configValues[field.key] ? Number(configValues[field.key]) : undefined"
@update:value="(v: number | null) => configValues[field.key] = v !== null ? String(v) : ''"
:placeholder="field.description"
style="width: 100%;"
/>
<!-- 下拉选择 -->
<n-select
v-else-if="field.type === 'select'"
v-model:value="configValues[field.key]"
:options="(field.options || []).map(o => ({ label: o, value: o }))"
/>
<!-- 布尔开关 -->
<n-switch
v-else-if="field.type === 'bool'"
:value="configValues[field.key] === 'true'"
@update:value="(v: boolean) => configValues[field.key] = String(v)"
/>
<template #feedback v-if="field.description && field.type !== 'string' && field.type !== 'password'">
<span style="color: #999; font-size: 12px;">{{ field.description }}</span>
</template>
</n-form-item>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showConfigModal = false">取消</n-button>
<n-button type="primary" @click="savePluginConfig" :disabled="configSchema.length === 0">
保存
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>