feat: implement plugin API request handling with HTTP Basic Auth support
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 31s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 5m31s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled

This commit is contained in:
2026-01-04 20:32:21 +08:00
parent 458bb35005
commit 78982a26b0
9 changed files with 620 additions and 48 deletions

View File

@@ -106,6 +106,10 @@ type StoreInstallRequest struct {
ClientID string `json:"client_id" binding:"required"`
RemotePort int `json:"remote_port"`
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
// HTTP Basic Auth 配置
AuthEnabled bool `json:"auth_enabled,omitempty"`
AuthUsername string `json:"auth_username,omitempty"`
AuthPassword string `json:"auth_password,omitempty"`
}
// JSPluginPushRequest 推送 JS 插件到客户端请求

View File

@@ -49,6 +49,8 @@ type ServerInterface interface {
GetClientPluginStatus(clientID string) ([]protocol.PluginStatusEntry, error)
// 插件规则管理
StartPluginRule(clientID string, rule protocol.ProxyRule) error
// 插件 API 代理
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
}
// ConfigField 配置字段

View File

@@ -0,0 +1,140 @@
package handler
import (
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/pkg/protocol"
)
// PluginAPIHandler 插件 API 代理处理器
type PluginAPIHandler struct {
app AppInterface
}
// NewPluginAPIHandler 创建插件 API 代理处理器
func NewPluginAPIHandler(app AppInterface) *PluginAPIHandler {
return &PluginAPIHandler{app: app}
}
// ProxyRequest 代理请求到客户端插件
// @Summary 代理插件 API 请求
// @Description 将请求代理到客户端的 JS 插件处理
// @Tags 插件 API
// @Accept json
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端 ID"
// @Param pluginName path string true "插件名称"
// @Param route path string true "插件路由"
// @Success 200 {object} object
// @Failure 404 {object} Response
// @Failure 502 {object} Response
// @Router /api/client/{clientID}/plugin/{pluginName}/{route} [get]
func (h *PluginAPIHandler) ProxyRequest(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
route := c.Param("route")
// 确保路由以 / 开头
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
// 读取请求体
var body string
if c.Request.Body != nil {
bodyBytes, _ := io.ReadAll(c.Request.Body)
body = string(bodyBytes)
}
// 构建请求头
headers := make(map[string]string)
for key, values := range c.Request.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
// 构建 API 请求
apiReq := protocol.PluginAPIRequest{
PluginName: pluginName,
Method: c.Request.Method,
Path: route,
Query: c.Request.URL.RawQuery,
Headers: headers,
Body: body,
}
// 发送请求到客户端
resp, err := h.app.GetServer().ProxyPluginAPIRequest(clientID, apiReq)
if err != nil {
BadGateway(c, "Plugin request failed: "+err.Error())
return
}
// 检查错误
if resp.Error != "" {
c.JSON(http.StatusBadGateway, gin.H{
"code": 502,
"message": resp.Error,
})
return
}
// 设置响应头
for key, value := range resp.Headers {
c.Header(key, value)
}
// 返回响应
c.String(resp.Status, resp.Body)
}
// ProxyPluginAPIRequest 接口方法声明 - 添加到 ServerInterface
type PluginAPIProxyInterface interface {
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
}
// AuthConfig 认证配置
type AuthConfig struct {
Type string `json:"type"` // none, basic, token
Username string `json:"username"` // Basic Auth 用户名
Password string `json:"password"` // Basic Auth 密码
Token string `json:"token"` // Token 认证
}
// BasicAuthMiddleware 创建 Basic Auth 中间件
func BasicAuthMiddleware(username, password string) gin.HandlerFunc {
return func(c *gin.Context) {
user, pass, ok := c.Request.BasicAuth()
if !ok || user != username || pass != password {
c.Header("WWW-Authenticate", `Basic realm="Plugin"`)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "Unauthorized",
})
return
}
c.Next()
}
}
// WithTimeout 带超时的请求处理
func WithTimeout(timeout time.Duration, handler gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// 设置请求超时
c.Request = c.Request.WithContext(c.Request.Context())
handler(c)
}
}

View File

@@ -155,15 +155,16 @@ func (h *StoreHandler) Install(c *gin.Context) {
dbClient, err := h.app.GetClientStore().GetClient(req.ClientID)
if err == nil {
// 检查插件是否已存在
exists := false
pluginExists := false
for i, p := range dbClient.Plugins {
if p.Name == req.PluginName {
dbClient.Plugins[i].Enabled = true
exists = true
dbClient.Plugins[i].RemotePort = req.RemotePort
pluginExists = true
break
}
}
if !exists {
if !pluginExists {
version := req.Version
if version == "" {
version = "1.0.0"
@@ -189,16 +190,50 @@ func (h *StoreHandler) Install(c *gin.Context) {
ConfigSchema: configSchema,
})
}
// 自动创建代理规则(如果指定了端口)
if req.RemotePort > 0 {
ruleExists := false
for i, r := range dbClient.Rules {
if r.Name == req.PluginName {
// 更新现有规则
dbClient.Rules[i].Type = req.PluginName
dbClient.Rules[i].RemotePort = req.RemotePort
dbClient.Rules[i].Enabled = boolPtr(true)
dbClient.Rules[i].AuthEnabled = req.AuthEnabled
dbClient.Rules[i].AuthUsername = req.AuthUsername
dbClient.Rules[i].AuthPassword = req.AuthPassword
ruleExists = true
break
}
}
if !ruleExists {
// 创建新规则
dbClient.Rules = append(dbClient.Rules, protocol.ProxyRule{
Name: req.PluginName,
Type: req.PluginName,
RemotePort: req.RemotePort,
Enabled: boolPtr(true),
AuthEnabled: req.AuthEnabled,
AuthUsername: req.AuthUsername,
AuthPassword: req.AuthPassword,
})
}
}
h.app.GetClientStore().UpdateClient(dbClient)
}
// 启动服务端监听器(让外部用户可以通过 RemotePort 访问插件)
if req.RemotePort > 0 {
pluginRule := protocol.ProxyRule{
Name: req.PluginName,
Type: req.PluginName, // 使用插件名作为类型,让 isClientPlugin 识别
RemotePort: req.RemotePort,
Enabled: boolPtr(true),
Name: req.PluginName,
Type: req.PluginName, // 使用插件名作为类型,让 isClientPlugin 识别
RemotePort: req.RemotePort,
Enabled: boolPtr(true),
AuthEnabled: req.AuthEnabled,
AuthUsername: req.AuthUsername,
AuthPassword: req.AuthPassword,
}
// 启动监听器(忽略错误,可能端口已被占用)
h.app.GetServer().StartPluginRule(req.ClientID, pluginRule)

View File

@@ -109,6 +109,10 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
// 日志管理
logHandler := handler.NewLogHandler(app)
api.GET("/client/:id/logs", logHandler.StreamLogs)
// 插件 API 代理 (通过 Web API 访问客户端插件)
pluginAPIHandler := handler.NewPluginAPIHandler(app)
api.Any("/client/:clientID/plugin/:pluginName/*route", pluginAPIHandler.ProxyRequest)
}
}

View File

@@ -3,11 +3,13 @@ package tunnel
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net"
"regexp"
"strings"
"sync"
"time"
@@ -1123,6 +1125,16 @@ func (s *Server) acceptClientPluginConns(cs *ClientSession, ln net.Listener, rul
func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule protocol.ProxyRule) {
defer conn.Close()
// 如果启用了 HTTP Basic Auth先进行认证
var bufferedData []byte
if rule.AuthEnabled {
authenticated, data := s.checkHTTPBasicAuth(conn, rule.AuthUsername, rule.AuthPassword)
if !authenticated {
return
}
bufferedData = data
}
stream, err := cs.Session.Open()
if err != nil {
log.Printf("[Server] Open stream error: %v", err)
@@ -1139,9 +1151,84 @@ func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule p
return
}
// 如果有缓冲的数据(已读取的 HTTP 请求头),先发送给客户端
if len(bufferedData) > 0 {
if _, err := stream.Write(bufferedData); err != nil {
return
}
}
relay.Relay(conn, stream)
}
// checkHTTPBasicAuth 检查 HTTP Basic Auth
// 返回 (认证成功, 已读取的数据)
func (s *Server) checkHTTPBasicAuth(conn net.Conn, username, password string) (bool, []byte) {
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
defer conn.SetReadDeadline(time.Time{}) // 重置超时
// 读取 HTTP 请求头
buf := make([]byte, 8192) // 增大缓冲区以处理更大的请求头
n, err := conn.Read(buf)
if err != nil {
return false, nil
}
data := buf[:n]
request := string(data)
// 解析 Authorization 头
authHeader := ""
lines := strings.Split(request, "\r\n")
for _, line := range lines {
if strings.HasPrefix(strings.ToLower(line), "authorization:") {
authHeader = strings.TrimSpace(line[14:])
break
}
}
// 检查 Basic Auth
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
s.sendHTTPUnauthorized(conn)
return false, nil
}
// 解码 Base64
encoded := authHeader[6:]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
s.sendHTTPUnauthorized(conn)
return false, nil
}
// 解析 username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
s.sendHTTPUnauthorized(conn)
return false, nil
}
if parts[0] != username || parts[1] != password {
s.sendHTTPUnauthorized(conn)
return false, nil
}
return true, data
}
// sendHTTPUnauthorized 发送 401 未授权响应
func (s *Server) sendHTTPUnauthorized(conn net.Conn) {
response := "HTTP/1.1 401 Unauthorized\r\n" +
"WWW-Authenticate: Basic realm=\"GoTunnel Plugin\"\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Length: 12\r\n" +
"\r\n" +
"Unauthorized"
conn.Write([]byte(response))
}
// autoPushJSPlugins 自动推送 JS 插件到客户端
func (s *Server) autoPushJSPlugins(cs *ClientSession) {
// 记录已推送的插件,避免重复推送
@@ -1375,6 +1462,52 @@ func (s *Server) StartPluginRule(clientID string, rule protocol.ProxyRule) error
return nil
}
// ProxyPluginAPIRequest 代理插件 API 请求到客户端
func (s *Server) ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error) {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %s not found or not online", clientID)
}
stream, err := cs.Session.Open()
if err != nil {
return nil, fmt.Errorf("open stream: %w", err)
}
defer stream.Close()
// 设置超时30秒
stream.SetDeadline(time.Now().Add(30 * time.Second))
// 发送 API 请求
msg, err := protocol.NewMessage(protocol.MsgTypePluginAPIRequest, req)
if err != nil {
return nil, err
}
if err := protocol.WriteMessage(stream, msg); err != nil {
return nil, err
}
// 读取响应
resp, err := protocol.ReadMessage(stream)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.Type != protocol.MsgTypePluginAPIResponse {
return nil, fmt.Errorf("unexpected response type: %d", resp.Type)
}
var apiResp protocol.PluginAPIResponse
if err := resp.ParsePayload(&apiResp); err != nil {
return nil, err
}
return &apiResp, nil
}
// RestartClientPlugin 重启客户端 JS 插件
func (s *Server) RestartClientPlugin(clientID, pluginName, ruleName string) error {
s.mu.RLock()