updete
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
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

This commit is contained in:
2026-01-02 01:59:44 +08:00
parent 82c1a6a266
commit f46741a84b
44 changed files with 10502 additions and 1486 deletions

View File

@@ -2,10 +2,8 @@ package app
import (
"embed"
"io"
"io/fs"
"log"
"net/http"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
@@ -16,33 +14,6 @@ import (
//go:embed dist/*
var staticFiles embed.FS
// spaHandler SPA路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
f, err := h.fs.Open(path)
if err != nil {
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, _ := f.Stat()
if stat.IsDir() {
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
}
http.ServeContent(w, r, path, stat.ModTime(), f.(io.ReadSeeker))
}
// WebServer Web控制台服务
type WebServer struct {
ClientStore db.ClientStore
@@ -63,36 +34,29 @@ func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.Ser
}
}
// Run 启动Web服务
// Run 启动Web服务 (无认证,仅用于开发)
func (w *WebServer) Run(addr string) error {
r := router.New()
router.RegisterRoutes(r, w)
// 使用默认凭据和 JWT
jwtAuth := auth.NewJWTAuth("dev-secret", 24)
r.SetupRoutes(w, jwtAuth, "admin", "admin")
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s", addr)
return http.ListenAndServe(addr, r.Handler())
return r.Engine.Run(addr)
}
// RunWithAuth 启动带认证的Web服务
// RunWithAuth 启动带 Basic Auth 的 Web 服务 (已废弃,使用 RunWithJWT)
func (w *WebServer) RunWithAuth(addr, username, password string) error {
r := router.New()
router.RegisterRoutes(r, w)
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
auth := &router.AuthConfig{Username: username, Password: password}
handler := router.BasicAuthMiddleware(auth, r.Handler())
log.Printf("[Web] Console listening on %s (auth enabled)", addr)
return http.ListenAndServe(addr, handler)
// 转发到 JWT 认证
return w.RunWithJWT(addr, username, password, "auto-generated-secret")
}
// RunWithJWT 启动带 JWT 认证的 Web 服务
@@ -102,26 +66,18 @@ func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error
// JWT 认证器
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
// 注册认证路由(不需要认证)
authHandler := router.NewAuthHandler(username, password, jwtAuth)
router.RegisterAuthRoutes(r, authHandler)
// 注册业务路由
router.RegisterRoutes(r, w)
// 设置所有路由
r.SetupRoutes(w, jwtAuth, username, password)
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/
skipPaths := []string{"/api/auth/"}
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
return http.ListenAndServe(addr, handler)
return r.Engine.Run(addr)
}
// GetClientStore 获取客户端存储

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package router
import (
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// RegisterAuthRoutes 注册认证路由
func RegisterAuthRoutes(r *Router, h *AuthHandler) {
r.HandleFunc("/api/auth/login", h.handleLogin)
r.HandleFunc("/api/auth/check", h.handleCheck)
}
// handleLogin 处理登录请求
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
// 验证用户名密码
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(r.RemoteAddr, req.Username, false)
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return
}
security.LogWebLogin(r.RemoteAddr, req.Username, true)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
}
// handleCheck 检查 token 是否有效
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从 Authorization header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
// 解析 Bearer token
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
tokenStr := authHeader[len(prefix):]
// 验证 token
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": claims.Username,
})
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
)
// CreateClientRequest 创建客户端请求
// @Description 创建新客户端的请求体
type CreateClientRequest struct {
ID string `json:"id" binding:"required,min=1,max=64" example:"client-001"`
Rules []protocol.ProxyRule `json:"rules"`
}
// UpdateClientRequest 更新客户端请求
// @Description 更新客户端配置的请求体
type UpdateClientRequest struct {
Nickname string `json:"nickname" binding:"max=128" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins"`
}
// ClientResponse 客户端详情响应
// @Description 客户端详细信息
type ClientResponse struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins,omitempty"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
}
// ClientListItem 客户端列表项
// @Description 客户端列表中的单个项目
type ClientListItem struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
RuleCount int `json:"rule_count" example:"3"`
}
// InstallPluginsRequest 安装插件到客户端请求
// @Description 安装插件到指定客户端
type InstallPluginsRequest struct {
Plugins []string `json:"plugins" binding:"required,min=1,dive,required" example:"socks5,http-proxy"`
}
// ClientPluginActionRequest 客户端插件操作请求
// @Description 对客户端插件执行操作
type ClientPluginActionRequest struct {
RuleName string `json:"rule_name"`
Config map[string]string `json:"config,omitempty"`
Restart bool `json:"restart"`
}

View File

@@ -0,0 +1,53 @@
package dto
// UpdateServerConfigRequest 更新服务器配置请求
// @Description 更新服务器配置
type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
}
// ServerConfigPart 服务器配置部分
// @Description 隧道服务器配置
type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Token string `json:"token" binding:"omitempty,min=8"`
HeartbeatSec int `json:"heartbeat_sec" binding:"omitempty,min=1,max=300"`
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
}
// WebConfigPart Web 配置部分
// @Description Web 控制台配置
type WebConfigPart struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username" binding:"omitempty,min=3,max=32"`
Password string `json:"password" binding:"omitempty,min=6,max=64"`
}
// ServerConfigResponse 服务器配置响应
// @Description 服务器配置信息
type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
}
// ServerConfigInfo 服务器配置信息
type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token
HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
}
// WebConfigInfo Web 配置信息
type WebConfigInfo struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Username string `json:"username"`
Password string `json:"password"` // 显示为 ****
}

View File

@@ -0,0 +1,105 @@
package dto
// PluginConfigRequest 更新插件配置请求
// @Description 更新客户端插件配置
type PluginConfigRequest struct {
Config map[string]string `json:"config" binding:"required"`
}
// PluginConfigResponse 插件配置响应
// @Description 插件配置详情
type PluginConfigResponse struct {
PluginName string `json:"plugin_name"`
Schema []ConfigField `json:"schema"`
Config map[string]string `json:"config"`
}
// ConfigField 配置字段定义
// @Description 配置表单字段
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"`
}
// RuleSchema 规则表单模式
// @Description 代理规则的配置模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
// @Description 服务端插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginCreateRequest 创建 JS 插件请求
// @Description 创建新的 JS 插件
type JSPluginCreateRequest struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// JSPluginUpdateRequest 更新 JS 插件请求
// @Description 更新 JS 插件
type JSPluginUpdateRequest struct {
Source string `json:"source"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
Enabled bool `json:"enabled"`
}
// JSPluginInstallRequest JS 插件安装请求
// @Description 安装 JS 插件到客户端
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// StorePluginInfo 扩展商店插件信息
// @Description 插件商店中的插件信息
type StorePluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Author string `json:"author"`
Icon string `json:"icon,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
SignatureURL string `json:"signature_url,omitempty"`
}
// StoreInstallRequest 从商店安装插件请求
// @Description 从插件商店安装插件到客户端
type StoreInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
DownloadURL string `json:"download_url" binding:"required,url"`
SignatureURL string `json:"signature_url" binding:"required,url"`
ClientID string `json:"client_id" binding:"required"`
}

View File

@@ -0,0 +1,76 @@
package dto
// CheckUpdateResponse 检查更新响应
// @Description 更新检查结果
type CheckUpdateResponse struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
// CheckClientUpdateQuery 检查客户端更新查询参数
// @Description 检查客户端更新的查询参数
type CheckClientUpdateQuery struct {
OS string `form:"os" binding:"omitempty,oneof=linux darwin windows"`
Arch string `form:"arch" binding:"omitempty,oneof=amd64 arm64 386 arm"`
}
// ApplyServerUpdateRequest 应用服务端更新请求
// @Description 应用服务端更新
type ApplyServerUpdateRequest struct {
DownloadURL string `json:"download_url" binding:"required,url"`
Restart bool `json:"restart"`
}
// ApplyClientUpdateRequest 应用客户端更新请求
// @Description 推送更新到客户端
type ApplyClientUpdateRequest struct {
ClientID string `json:"client_id" binding:"required"`
DownloadURL string `json:"download_url" binding:"required,url"`
}
// VersionInfo 版本信息
// @Description 当前版本信息
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
// StatusResponse 服务器状态响应
// @Description 服务器状态信息
type StatusResponse struct {
Server ServerStatus `json:"server"`
ClientCount int `json:"client_count"`
}
// ServerStatus 服务器状态
type ServerStatus struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
}
// LoginRequest 登录请求
// @Description 用户登录
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应
// @Description 登录成功返回
type LoginResponse struct {
Token string `json:"token"`
}
// TokenCheckResponse Token 检查响应
// @Description Token 验证结果
type TokenCheckResponse struct {
Valid bool `json:"valid"`
Username string `json:"username"`
}

View File

@@ -0,0 +1,106 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ValidationError 验证错误详情
type ValidationError struct {
Field string `json:"field"` // 字段名
Message string `json:"message"` // 错误消息
}
// HandleValidationError 处理验证错误并返回统一格式
func HandleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]ValidationError, len(ve))
for i, fe := range ve {
errs[i] = ValidationError{
Field: fe.Field(),
Message: getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
// 非验证错误,返回通用错误消息
BadRequest(c, err.Error())
}
// getValidationMessage 根据验证标签返回友好的错误消息
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "email":
return "invalid email format"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
case "alphanum":
return "must contain only letters and numbers"
case "alphanumunicode":
return "must contain only letters, numbers and unicode characters"
case "ip":
return "invalid IP address"
case "hostname":
return "invalid hostname"
case "clientid":
return "must be 1-64 alphanumeric characters, underscore or hyphen"
case "gte":
return "value must be greater than or equal to " + fe.Param()
case "lte":
return "value must be less than or equal to " + fe.Param()
case "gt":
return "value must be greater than " + fe.Param()
case "lt":
return "value must be less than " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}
// BindJSON 绑定 JSON 并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindURI 绑定 URI 参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindURI(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindUri(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}

View File

@@ -0,0 +1,100 @@
package handler
import (
"crypto/subtle"
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// Login 用户登录
// @Summary 用户登录
// @Description 使用用户名密码登录,返回 JWT token
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "登录信息"
// @Success 200 {object} Response{data=dto.LoginResponse}
// @Failure 400 {object} Response
// @Failure 401 {object} Response
// @Router /api/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if !BindJSON(c, &req) {
return
}
// 验证用户名密码 (使用常量时间比较防止时序攻击)
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(c.ClientIP(), req.Username, false)
Unauthorized(c, "invalid credentials")
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
InternalError(c, "failed to generate token")
return
}
security.LogWebLogin(c.ClientIP(), req.Username, true)
Success(c, dto.LoginResponse{Token: token})
}
// Check 检查 token 是否有效
// @Summary 检查 Token
// @Description 验证 JWT token 是否有效
// @Tags 认证
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.TokenCheckResponse}
// @Failure 401 {object} Response
// @Router /api/auth/check [get]
func (h *AuthHandler) Check(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
Unauthorized(c, "missing authorization header")
return
}
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
Unauthorized(c, "invalid authorization format")
return
}
tokenStr := authHeader[len(prefix):]
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
Unauthorized(c, "invalid token")
return
}
Success(c, dto.TokenCheckResponse{
Valid: true,
Username: claims.Username,
})
}

View File

@@ -0,0 +1,393 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// ClientHandler 客户端处理器
type ClientHandler struct {
app AppInterface
}
// NewClientHandler 创建客户端处理器
func NewClientHandler(app AppInterface) *ClientHandler {
return &ClientHandler{app: app}
}
// List 获取客户端列表
// @Summary 获取所有客户端
// @Description 返回所有注册客户端的列表及其在线状态
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.ClientListItem}
// @Router /api/clients [get]
func (h *ClientHandler) List(c *gin.Context) {
clients, err := h.app.GetClientStore().GetAllClients()
if err != nil {
InternalError(c, err.Error())
return
}
statusMap := h.app.GetServer().GetAllClientStatus()
result := make([]dto.ClientListItem, 0, len(clients))
for _, client := range clients {
item := dto.ClientListItem{
ID: client.ID,
Nickname: client.Nickname,
RuleCount: len(client.Rules),
}
if status, ok := statusMap[client.ID]; ok {
item.Online = status.Online
item.LastPing = status.LastPing
item.RemoteAddr = status.RemoteAddr
}
result = append(result, item)
}
Success(c, result)
}
// Create 创建客户端
// @Summary 创建新客户端
// @Description 创建一个新的客户端配置
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.CreateClientRequest true "客户端信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 409 {object} Response
// @Router /api/clients [post]
func (h *ClientHandler) Create(c *gin.Context) {
var req dto.CreateClientRequest
if !BindJSON(c, &req) {
return
}
// 验证客户端 ID 格式
if !validateClientID(req.ID) {
BadRequest(c, "invalid client id: must be 1-64 alphanumeric characters, underscore or hyphen")
return
}
// 检查客户端是否已存在
exists, _ := h.app.GetClientStore().ClientExists(req.ID)
if exists {
Conflict(c, "client already exists")
return
}
client := &db.Client{ID: req.ID, Rules: req.Rules}
if err := h.app.GetClientStore().CreateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个客户端
// @Summary 获取客户端详情
// @Description 获取指定客户端的详细信息
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response{data=dto.ClientResponse}
// @Failure 404 {object} Response
// @Router /api/client/{id} [get]
func (h *ClientHandler) Get(c *gin.Context) {
clientID := c.Param("id")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
online, lastPing, remoteAddr := h.app.GetServer().GetClientStatus(clientID)
resp := dto.ClientResponse{
ID: client.ID,
Nickname: client.Nickname,
Rules: client.Rules,
Plugins: client.Plugins,
Online: online,
LastPing: lastPing,
RemoteAddr: remoteAddr,
}
Success(c, resp)
}
// Update 更新客户端
// @Summary 更新客户端配置
// @Description 更新指定客户端的配置信息
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.UpdateClientRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [put]
func (h *ClientHandler) Update(c *gin.Context) {
clientID := c.Param("id")
var req dto.UpdateClientRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
client.Nickname = req.Nickname
client.Rules = req.Rules
if req.Plugins != nil {
client.Plugins = req.Plugins
}
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除客户端
// @Summary 删除客户端
// @Description 删除指定的客户端配置
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [delete]
func (h *ClientHandler) Delete(c *gin.Context) {
clientID := c.Param("id")
exists, _ := h.app.GetClientStore().ClientExists(clientID)
if !exists {
NotFound(c, "client not found")
return
}
if err := h.app.GetClientStore().DeleteClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushConfig 推送配置到客户端
// @Summary 推送配置
// @Description 将配置推送到在线客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/push [post]
func (h *ClientHandler) PushConfig(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
if err := h.app.GetServer().PushConfigToClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disconnect 断开客户端连接
// @Summary 断开连接
// @Description 强制断开客户端连接
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/disconnect [post]
func (h *ClientHandler) Disconnect(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().DisconnectClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Restart 重启客户端
// @Summary 重启客户端
// @Description 发送重启命令到客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/restart [post]
func (h *ClientHandler) Restart(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().RestartClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
SuccessWithMessage(c, gin.H{"status": "ok"}, "client restart initiated")
}
// InstallPlugins 安装插件到客户端
// @Summary 安装插件
// @Description 将指定插件安装到客户端
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.InstallPluginsRequest true "插件列表"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/install-plugins [post]
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
var req dto.InstallPluginsRequest
if !BindJSON(c, &req) {
return
}
if err := h.app.GetServer().InstallPluginsToClient(clientID, req.Plugins); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PluginAction 客户端插件操作
// @Summary 插件操作
// @Description 对客户端插件执行操作(stop/restart/config/delete)
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Param action path string true "操作类型" Enums(stop, restart, config, delete)
// @Param request body dto.ClientPluginActionRequest false "操作参数"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/plugin/{pluginName}/{action} [post]
func (h *ClientHandler) PluginAction(c *gin.Context) {
clientID := c.Param("id")
pluginName := c.Param("pluginName")
action := c.Param("action")
var req dto.ClientPluginActionRequest
c.ShouldBindJSON(&req) // 忽略错误,使用默认值
if req.RuleName == "" {
req.RuleName = pluginName
}
var err error
switch action {
case "stop":
err = h.app.GetServer().StopClientPlugin(clientID, pluginName, req.RuleName)
case "restart":
err = h.app.GetServer().RestartClientPlugin(clientID, pluginName, req.RuleName)
case "config":
if req.Config == nil {
BadRequest(c, "config required")
return
}
err = h.app.GetServer().UpdateClientPluginConfig(clientID, pluginName, req.RuleName, req.Config, req.Restart)
case "delete":
err = h.deleteClientPlugin(clientID, pluginName)
default:
BadRequest(c, "unknown action: "+action)
return
}
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"action": action,
"plugin": pluginName,
})
}
func (h *ClientHandler) deleteClientPlugin(clientID, pluginName string) error {
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
return fmt.Errorf("client not found")
}
var newPlugins []db.ClientPlugin
found := false
for _, p := range client.Plugins {
if p.Name == pluginName {
found = true
continue
}
newPlugins = append(newPlugins, p)
}
if !found {
return fmt.Errorf("plugin %s not found", pluginName)
}
client.Plugins = newPlugins
return h.app.GetClientStore().UpdateClient(client)
}
// validateClientID 验证客户端 ID 格式
func validateClientID(id string) bool {
if len(id) < 1 || len(id) > 64 {
return false
}
for _, c := range id {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}

View File

@@ -0,0 +1,131 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// ConfigHandler 配置处理器
type ConfigHandler struct {
app AppInterface
}
// NewConfigHandler 创建配置处理器
func NewConfigHandler(app AppInterface) *ConfigHandler {
return &ConfigHandler{app: app}
}
// Get 获取服务器配置
// @Summary 获取配置
// @Description 返回服务器配置(敏感信息脱敏)
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.ServerConfigResponse}
// @Router /api/config [get]
func (h *ConfigHandler) Get(c *gin.Context) {
cfg := h.app.GetConfig()
// Token 脱敏处理只显示前4位
maskedToken := cfg.Server.Token
if len(maskedToken) > 4 {
maskedToken = maskedToken[:4] + "****"
}
resp := dto.ServerConfigResponse{
Server: dto.ServerConfigInfo{
BindAddr: cfg.Server.BindAddr,
BindPort: cfg.Server.BindPort,
Token: maskedToken,
HeartbeatSec: cfg.Server.HeartbeatSec,
HeartbeatTimeout: cfg.Server.HeartbeatTimeout,
},
Web: dto.WebConfigInfo{
Enabled: cfg.Web.Enabled,
BindAddr: cfg.Web.BindAddr,
BindPort: cfg.Web.BindPort,
Username: cfg.Web.Username,
Password: "****",
},
}
Success(c, resp)
}
// Update 更新服务器配置
// @Summary 更新配置
// @Description 更新服务器配置
// @Tags 配置
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.UpdateServerConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/config [put]
func (h *ConfigHandler) Update(c *gin.Context) {
var req dto.UpdateServerConfigRequest
if !BindJSON(c, &req) {
return
}
cfg := h.app.GetConfig()
// 更新 Server 配置
if req.Server != nil {
if req.Server.BindAddr != "" {
cfg.Server.BindAddr = req.Server.BindAddr
}
if req.Server.BindPort > 0 {
cfg.Server.BindPort = req.Server.BindPort
}
if req.Server.Token != "" {
cfg.Server.Token = req.Server.Token
}
if req.Server.HeartbeatSec > 0 {
cfg.Server.HeartbeatSec = req.Server.HeartbeatSec
}
if req.Server.HeartbeatTimeout > 0 {
cfg.Server.HeartbeatTimeout = req.Server.HeartbeatTimeout
}
}
// 更新 Web 配置
if req.Web != nil {
cfg.Web.Enabled = req.Web.Enabled
if req.Web.BindAddr != "" {
cfg.Web.BindAddr = req.Web.BindAddr
}
if req.Web.BindPort > 0 {
cfg.Web.BindPort = req.Web.BindPort
}
cfg.Web.Username = req.Web.Username
cfg.Web.Password = req.Web.Password
}
if err := h.app.SaveConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Reload 重新加载配置
// @Summary 重新加载配置
// @Description 重新加载服务器配置
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response
// @Failure 500 {object} Response
// @Router /api/config/reload [post]
func (h *ConfigHandler) Reload(c *gin.Context) {
if err := h.app.GetServer().ReloadConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,230 @@
package handler
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/gotunnel/pkg/version"
)
// UpdateInfo 更新信息
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// checkUpdateForComponent 检查组件更新
func checkUpdateForComponent(component string) (*UpdateInfo, error) {
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
currentVersion := version.Version
available := version.CompareVersions(currentVersion, latestVersion) < 0
// 查找对应平台的资产
assetName := getAssetNameForPlatform(component, runtime.GOOS, runtime.GOARCH)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: available,
Current: currentVersion,
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// checkClientUpdateForPlatform 检查指定平台的客户端更新
func checkClientUpdateForPlatform(osName, arch string) (*UpdateInfo, error) {
if osName == "" {
osName = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
// 查找对应平台的资产
assetName := getAssetNameForPlatform("client", osName, arch)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: true,
Current: "",
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// getAssetNameForPlatform 获取指定平台的资产名称
func getAssetNameForPlatform(component, osName, arch string) string {
ext := ""
if osName == "windows" {
ext = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", component, osName, arch, ext)
}
// performSelfUpdate 执行自更新
func performSelfUpdate(downloadURL string, restart bool) error {
// 下载新版本
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, "gotunnel_update_"+time.Now().Format("20060102150405"))
if runtime.GOOS == "windows" {
tempFile += ".exe"
}
if err := downloadFile(downloadURL, tempFile); err != nil {
return fmt.Errorf("download update: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(tempFile, 0755); err != nil {
os.Remove(tempFile)
return fmt.Errorf("chmod: %w", err)
}
}
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
os.Remove(tempFile)
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理(运行中的文件无法直接替换)
if runtime.GOOS == "windows" {
return performWindowsUpdate(tempFile, currentPath, restart)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backup current: %w", err)
}
// 移动新文件
if err := os.Rename(tempFile, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
if restart {
restartProcess(currentPath)
}
return nil
}
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
`, currentPath, newFile, currentPath)
if restart {
batchScript += fmt.Sprintf(`start "" "%s"
`, currentPath)
}
batchScript += "del \"%~f0\"\n"
batchPath := filepath.Join(os.TempDir(), "gotunnel_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
os.Exit(0)
return nil
}
// restartProcess 重启进程
func restartProcess(path string) {
cmd := exec.Command(path, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}
// downloadFile 下载文件
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}

View File

@@ -0,0 +1,83 @@
package handler
import (
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
)
// AppInterface 应用接口
type AppInterface interface {
GetClientStore() db.ClientStore
GetServer() ServerInterface
GetConfig() *config.ServerConfig
GetConfigPath() string
SaveConfig() error
GetJSPluginStore() db.JSPluginStore
}
// ServerInterface 服务端接口
type ServerInterface interface {
GetClientStatus(clientID string) (online bool, lastPing string, remoteAddr string)
GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
}
ReloadConfig() error
GetBindAddr() string
GetBindPort() int
PushConfigToClient(clientID string) error
DisconnectClient(clientID string) error
GetPluginList() []PluginInfo
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
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
SendUpdateToClient(clientID, downloadURL string) error
}
// ConfigField 配置字段
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"`
}
// RuleSchema 规则表单模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name"`
Source string `json:"source"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}

View File

@@ -0,0 +1,212 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// JSPluginHandler JS 插件处理器
type JSPluginHandler struct {
app AppInterface
}
// NewJSPluginHandler 创建 JS 插件处理器
func NewJSPluginHandler(app AppInterface) *JSPluginHandler {
return &JSPluginHandler{app: app}
}
// List 获取 JS 插件列表
// @Summary 获取所有 JS 插件
// @Description 返回所有注册的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]db.JSPlugin}
// @Router /api/js-plugins [get]
func (h *JSPluginHandler) List(c *gin.Context) {
plugins, err := h.app.GetJSPluginStore().GetAllJSPlugins()
if err != nil {
InternalError(c, err.Error())
return
}
if plugins == nil {
plugins = []db.JSPlugin{}
}
Success(c, plugins)
}
// Create 创建 JS 插件
// @Summary 创建 JS 插件
// @Description 创建新的 JS 插件
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.JSPluginCreateRequest true "插件信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugins [post]
func (h *JSPluginHandler) Create(c *gin.Context) {
var req dto.JSPluginCreateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: req.Name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: true,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个 JS 插件
// @Summary 获取 JS 插件详情
// @Description 获取指定 JS 插件的详细信息
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response{data=db.JSPlugin}
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name} [get]
func (h *JSPluginHandler) Get(c *gin.Context) {
name := c.Param("name")
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(name)
if err != nil {
NotFound(c, "plugin not found")
return
}
Success(c, plugin)
}
// Update 更新 JS 插件
// @Summary 更新 JS 插件
// @Description 更新指定 JS 插件的信息
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param request body dto.JSPluginUpdateRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugin/{name} [put]
func (h *JSPluginHandler) Update(c *gin.Context) {
name := c.Param("name")
var req dto.JSPluginUpdateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: req.Enabled,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除 JS 插件
// @Summary 删除 JS 插件
// @Description 删除指定的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Router /api/js-plugin/{name} [delete]
func (h *JSPluginHandler) Delete(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetJSPluginStore().DeleteJSPlugin(name); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushToClient 推送 JS 插件到客户端
// @Summary 推送插件到客户端
// @Description 将 JS 插件推送到指定客户端
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param clientID path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name}/push/{clientID} [post]
func (h *JSPluginHandler) PushToClient(c *gin.Context) {
pluginName := c.Param("name")
clientID := c.Param("clientID")
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
// 获取插件
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if err != nil {
NotFound(c, "plugin not found")
return
}
if !plugin.Enabled {
Error(c, 400, CodePluginDisabled, "plugin is disabled")
return
}
// 推送到客户端
req := JSPluginInstallRequest{
PluginName: plugin.Name,
Source: plugin.Source,
Signature: plugin.Signature,
RuleName: plugin.Name,
Config: plugin.Config,
AutoStart: plugin.AutoStart,
}
if err := h.app.GetServer().InstallJSPluginToClient(clientID, req); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"plugin": pluginName,
"client": clientID,
})
}

View File

@@ -0,0 +1,285 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/plugin"
)
// PluginHandler 插件处理器
type PluginHandler struct {
app AppInterface
}
// NewPluginHandler 创建插件处理器
func NewPluginHandler(app AppInterface) *PluginHandler {
return &PluginHandler{app: app}
}
// List 获取插件列表
// @Summary 获取所有插件
// @Description 返回服务端所有注册的插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.PluginInfo}
// @Router /api/plugins [get]
func (h *PluginHandler) List(c *gin.Context) {
plugins := h.app.GetServer().GetPluginList()
result := make([]dto.PluginInfo, len(plugins))
for i, p := range plugins {
result[i] = dto.PluginInfo{
Name: p.Name,
Version: p.Version,
Type: p.Type,
Description: p.Description,
Source: p.Source,
Icon: p.Icon,
Enabled: p.Enabled,
}
if p.RuleSchema != nil {
result[i].RuleSchema = &dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, result)
}
// Enable 启用插件
// @Summary 启用插件
// @Description 启用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/enable [post]
func (h *PluginHandler) Enable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().EnablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disable 禁用插件
// @Summary 禁用插件
// @Description 禁用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/disable [post]
func (h *PluginHandler) Disable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().DisablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// GetRuleSchemas 获取规则配置模式
// @Summary 获取规则模式
// @Description 返回所有协议类型的配置模式
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=map[string]dto.RuleSchema}
// @Router /api/rule-schemas [get]
func (h *PluginHandler) GetRuleSchemas(c *gin.Context) {
// 获取内置协议模式
schemas := make(map[string]dto.RuleSchema)
for name, schema := range plugin.BuiltinRuleSchemas() {
schemas[name] = dto.RuleSchema{
NeedsLocalAddr: schema.NeedsLocalAddr,
ExtraFields: convertConfigFields(schema.ExtraFields),
}
}
// 添加已注册插件的模式
plugins := h.app.GetServer().GetPluginList()
for _, p := range plugins {
if p.RuleSchema != nil {
schemas[p.Name] = dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, schemas)
}
// GetClientConfig 获取客户端插件配置
// @Summary 获取客户端插件配置
// @Description 获取客户端上指定插件的配置
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Success 200 {object} Response{data=dto.PluginConfigResponse}
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [get]
func (h *PluginHandler) GetClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 尝试从内置插件获取配置模式
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
var schemaFields []dto.ConfigField
if err != nil {
// 如果内置插件中找不到,尝试从 JS 插件获取
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if jsErr != nil {
// 两者都找不到,返回空 schema
schemaFields = []dto.ConfigField{}
} else {
// 使用 JS 插件的 config 作为动态 schema
for key := range jsPlugin.Config {
schemaFields = append(schemaFields, dto.ConfigField{
Key: key,
Label: key,
Type: "string",
})
}
}
} else {
schemaFields = convertRouterConfigFields(schema)
}
// 查找客户端的插件配置
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)
}
Success(c, dto.PluginConfigResponse{
PluginName: pluginName,
Schema: schemaFields,
Config: config,
})
}
// UpdateClientConfig 更新客户端插件配置
// @Summary 更新客户端插件配置
// @Description 更新客户端上指定插件的配置
// @Tags 插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Param request body dto.PluginConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [put]
func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
var req dto.PluginConfigRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 更新插件配置
found := false
for i, p := range client.Plugins {
if p.Name == pluginName {
client.Plugins[i].Config = req.Config
found = true
break
}
}
if !found {
NotFound(c, "plugin not installed on client")
return
}
// 保存到数据库
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
// 如果客户端在线,同步配置
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if online {
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
// 配置已保存,但同步失败,返回警告
PartialSuccess(c, gin.H{"status": "partial"}, "config saved but sync failed: "+err.Error())
return
}
}
Success(c, gin.H{"status": "ok"})
}
// convertConfigFields 转换插件配置字段到 DTO
func convertConfigFields(fields []plugin.ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.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
}
// convertRouterConfigFields 转换 ConfigField 到 dto.ConfigField
func convertRouterConfigFields(fields []ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
}
}
return result
}

View File

@@ -0,0 +1,159 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
// 业务错误码定义
const (
CodeSuccess = 0
CodeBadRequest = 400
CodeUnauthorized = 401
CodeForbidden = 403
CodeNotFound = 404
CodeConflict = 409
CodeInternalError = 500
CodeBadGateway = 502
CodeClientNotOnline = 1001
CodePluginNotFound = 1002
CodeInvalidClientID = 1003
CodePluginDisabled = 1004
CodeConfigSyncFailed = 1005
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}
// BindJSON 绑定 JSON 并自动处理验证错误
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// handleValidationError 处理验证错误
func handleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]map[string]string, len(ve))
for i, fe := range ve {
errs[i] = map[string]string{
"field": fe.Field(),
"message": getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
BadRequest(c, err.Error())
}
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}

View File

@@ -0,0 +1,51 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// StatusHandler 状态处理器
type StatusHandler struct {
app AppInterface
}
// NewStatusHandler 创建状态处理器
func NewStatusHandler(app AppInterface) *StatusHandler {
return &StatusHandler{app: app}
}
// GetStatus 获取服务器状态
// @Summary 获取服务器状态
// @Description 返回服务器运行状态和客户端数量
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.StatusResponse}
// @Router /api/status [get]
func (h *StatusHandler) GetStatus(c *gin.Context) {
clients, _ := h.app.GetClientStore().GetAllClients()
status := dto.StatusResponse{
Server: dto.ServerStatus{
BindAddr: h.app.GetServer().GetBindAddr(),
BindPort: h.app.GetServer().GetBindPort(),
},
ClientCount: len(clients),
}
Success(c, status)
}
// GetVersion 获取版本信息
// @Summary 获取版本信息
// @Description 返回服务器版本信息
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.VersionInfo}
// @Router /api/update/version [get]
func (h *StatusHandler) GetVersion(c *gin.Context) {
Success(c, getVersionInfo())
}

View File

@@ -0,0 +1,169 @@
package handler
import (
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// StoreHandler 插件商店处理器
type StoreHandler struct {
app AppInterface
}
// NewStoreHandler 创建插件商店处理器
func NewStoreHandler(app AppInterface) *StoreHandler {
return &StoreHandler{app: app}
}
// ListPlugins 获取商店插件列表
// @Summary 获取商店插件
// @Description 从远程插件商店获取可用插件列表
// @Tags 插件商店
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=object{plugins=[]dto.StorePluginInfo}}
// @Failure 502 {object} Response
// @Router /api/store/plugins [get]
func (h *StoreHandler) ListPlugins(c *gin.Context) {
cfg := h.app.GetConfig()
storeURL := cfg.PluginStore.GetPluginStoreURL()
// 从远程 URL 获取插件列表
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(storeURL)
if err != nil {
BadGateway(c, "Failed to fetch store: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Store returned error")
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read response")
return
}
// 直接返回原始 JSON已经是数组格式
c.Header("Content-Type", "application/json")
c.Writer.Write([]byte(`{"code":0,"data":{"plugins":`))
c.Writer.Write(body)
c.Writer.Write([]byte(`}}`))
}
// Install 从商店安装插件到客户端
// @Summary 安装商店插件
// @Description 从插件商店下载并安装插件到指定客户端
// @Tags 插件商店
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.StoreInstallRequest true "安装请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 502 {object} Response
// @Router /api/store/install [post]
func (h *StoreHandler) Install(c *gin.Context) {
var req dto.StoreInstallRequest
if !BindJSON(c, &req) {
return
}
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
if !online {
ClientNotOnline(c)
return
}
// 下载插件
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(req.DownloadURL)
if err != nil {
BadGateway(c, "Failed to download plugin: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Plugin download failed with status: "+resp.Status)
return
}
source, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read plugin: "+err.Error())
return
}
// 下载签名文件
sigResp, err := client.Get(req.SignatureURL)
if err != nil {
BadGateway(c, "Failed to download signature: "+err.Error())
return
}
defer sigResp.Body.Close()
if sigResp.StatusCode != http.StatusOK {
BadGateway(c, "Signature download failed with status: "+sigResp.Status)
return
}
signature, err := io.ReadAll(sigResp.Body)
if err != nil {
InternalError(c, "Failed to read signature: "+err.Error())
return
}
// 安装到客户端
installReq := JSPluginInstallRequest{
PluginName: req.PluginName,
Source: string(source),
Signature: string(signature),
RuleName: req.PluginName,
AutoStart: true,
}
if err := h.app.GetServer().InstallJSPluginToClient(req.ClientID, installReq); err != nil {
InternalError(c, "Failed to install plugin: "+err.Error())
return
}
// 将插件信息保存到数据库
dbClient, err := h.app.GetClientStore().GetClient(req.ClientID)
if err == nil {
// 检查插件是否已存在
exists := false
for i, p := range dbClient.Plugins {
if p.Name == req.PluginName {
dbClient.Plugins[i].Enabled = true
exists = true
break
}
}
if !exists {
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
Name: req.PluginName,
Version: "1.0.0",
Enabled: true,
})
}
h.app.GetClientStore().UpdateClient(dbClient)
}
Success(c, gin.H{
"status": "ok",
"plugin": req.PluginName,
"client": req.ClientID,
})
}

View File

@@ -0,0 +1,132 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/version"
)
// UpdateHandler 更新处理器
type UpdateHandler struct {
app AppInterface
}
// NewUpdateHandler 创建更新处理器
func NewUpdateHandler(app AppInterface) *UpdateHandler {
return &UpdateHandler{app: app}
}
// CheckServer 检查服务端更新
// @Summary 检查服务端更新
// @Description 检查是否有新的服务端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/server [get]
func (h *UpdateHandler) CheckServer(c *gin.Context) {
updateInfo, err := checkUpdateForComponent("server")
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// CheckClient 检查客户端更新
// @Summary 检查客户端更新
// @Description 检查是否有新的客户端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Param os query string false "操作系统" Enums(linux, darwin, windows)
// @Param arch query string false "架构" Enums(amd64, arm64, 386, arm)
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/client [get]
func (h *UpdateHandler) CheckClient(c *gin.Context) {
var query dto.CheckClientUpdateQuery
if !BindQuery(c, &query) {
return
}
updateInfo, err := checkClientUpdateForPlatform(query.OS, query.Arch)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// ApplyServer 应用服务端更新
// @Summary 应用服务端更新
// @Description 下载并应用服务端更新,服务器将自动重启
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyServerUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/server [post]
func (h *UpdateHandler) ApplyServer(c *gin.Context) {
var req dto.ApplyServerUpdateRequest
if !BindJSON(c, &req) {
return
}
// 异步执行更新
go func() {
if err := performSelfUpdate(req.DownloadURL, req.Restart); err != nil {
println("[Update] Server update failed:", err.Error())
}
}()
Success(c, gin.H{
"success": true,
"message": "Update started, server will restart shortly",
})
}
// ApplyClient 应用客户端更新
// @Summary 推送客户端更新
// @Description 向指定客户端推送更新命令
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyClientUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/client [post]
func (h *UpdateHandler) ApplyClient(c *gin.Context) {
var req dto.ApplyClientUpdateRequest
if !BindJSON(c, &req) {
return
}
// 发送更新命令到客户端
if err := h.app.GetServer().SendUpdateToClient(req.ClientID, req.DownloadURL); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"success": true,
"message": "Update command sent to client",
})
}
// getVersionInfo 获取版本信息
func getVersionInfo() dto.VersionInfo {
info := version.GetInfo()
return dto.VersionInfo{
Version: info.Version,
GitCommit: info.GitCommit,
BuildTime: info.BuildTime,
GoVersion: info.GoVersion,
Platform: info.OS + "/" + info.Arch,
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = "*"
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gotunnel/pkg/auth"
)
// JWTAuth JWT 认证中间件
func JWTAuth(jwtAuth *auth.JWTAuth) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "missing authorization header",
})
c.Abort()
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid authorization format",
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := jwtAuth.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid or expired token",
})
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("username", claims.Username)
c.Set("claims", claims)
c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
// Logger 请求日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
method := c.Request.Method
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
if query != "" {
path = path + "?" + query
}
log.Printf("[API] %s %s %d %v %s",
method, path, status, latency, clientIP)
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// Recovery 自定义恢复中间件(返回统一格式)
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("[PANIC] %v\n%s", err, debug.Stack())
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "internal server error",
})
c.Abort()
}
}()
c.Next()
}
}

View File

@@ -0,0 +1,116 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"` // 业务状态码: 0=成功, 非0=错误
Data interface{} `json:"data,omitempty"` // 响应数据
Message string `json:"message,omitempty"` // 提示信息
}
// 业务错误码定义
const (
CodeSuccess = 0 // 成功
CodeBadRequest = 400 // 请求参数错误
CodeUnauthorized = 401 // 未授权
CodeForbidden = 403 // 禁止访问
CodeNotFound = 404 // 资源不存在
CodeConflict = 409 // 资源冲突
CodeInternalError = 500 // 服务器内部错误
CodeBadGateway = 502 // 网关错误
// 业务错误码 (1000+)
CodeClientNotOnline = 1001 // 客户端不在线
CodePluginNotFound = 1002 // 插件不存在
CodeInvalidClientID = 1003 // 无效的客户端ID
CodePluginDisabled = 1004 // 插件已禁用
CodeConfigSyncFailed = 1005 // 配置同步失败
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// ErrorWithData 错误响应带数据
func ErrorWithData(c *gin.Context, httpCode int, bizCode int, message string, data interface{}) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
Data: data,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// Forbidden 403 错误
func Forbidden(c *gin.Context, message string) {
Error(c, http.StatusForbidden, CodeForbidden, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}

View File

@@ -1,123 +1,162 @@
package router
import (
"crypto/subtle"
"io"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/gotunnel/internal/server/router/handler"
"github.com/gotunnel/internal/server/router/middleware"
"github.com/gotunnel/pkg/auth"
)
// Router 路由管理器
type Router struct {
mux *http.ServeMux
// GinRouter Gin 路由管理器
type GinRouter struct {
Engine *gin.Engine
}
// AuthConfig 认证配置
type AuthConfig struct {
Username string
Password string
}
// New 创建路由管理器
func New() *Router {
return &Router{
mux: http.NewServeMux(),
}
}
// Handle 注册路由处理器
func (r *Router) Handle(pattern string, handler http.Handler) {
r.mux.Handle(pattern, handler)
}
// HandleFunc 注册路由处理函数
func (r *Router) HandleFunc(pattern string, handler http.HandlerFunc) {
r.mux.HandleFunc(pattern, handler)
}
// Group 创建路由组
func (r *Router) Group(prefix string) *RouteGroup {
return &RouteGroup{
router: r,
prefix: prefix,
}
}
// RouteGroup 路由组
type RouteGroup struct {
router *Router
prefix string
}
// HandleFunc 注册路由组处理函数
func (g *RouteGroup) HandleFunc(pattern string, handler http.HandlerFunc) {
g.router.mux.HandleFunc(g.prefix+pattern, handler)
// New 创建 Gin 路由管理器
func New() *GinRouter {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
return &GinRouter{Engine: engine}
}
// Handler 返回 http.Handler
func (r *Router) Handler() http.Handler {
return r.mux
func (r *GinRouter) Handler() http.Handler {
return r.Engine
}
// BasicAuthMiddleware 基础认证中间件
func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth == nil || (auth.Username == "" && auth.Password == "") {
next.ServeHTTP(w, r)
return
}
// SetupRoutes 配置所有路由
func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth, username, password string) {
engine := r.Engine
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 全局中间件
engine.Use(middleware.Recovery())
engine.Use(middleware.Logger())
engine.Use(middleware.CORS())
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(auth.Username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(auth.Password)) == 1
// Swagger 文档
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
if !userMatch || !passMatch {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 认证路由 (无需 JWT)
authHandler := handler.NewAuthHandler(username, password, jwtAuth)
engine.POST("/api/auth/login", authHandler.Login)
engine.GET("/api/auth/check", authHandler.Check)
next.ServeHTTP(w, r)
})
// API 路由 (需要 JWT)
api := engine.Group("/api")
api.Use(middleware.JWTAuth(jwtAuth))
{
// 状态
statusHandler := handler.NewStatusHandler(app)
api.GET("/status", statusHandler.GetStatus)
api.GET("/update/version", statusHandler.GetVersion)
// 客户端管理
clientHandler := handler.NewClientHandler(app)
api.GET("/clients", clientHandler.List)
api.POST("/clients", clientHandler.Create)
api.GET("/client/:id", clientHandler.Get)
api.PUT("/client/:id", clientHandler.Update)
api.DELETE("/client/:id", clientHandler.Delete)
api.POST("/client/:id/push", clientHandler.PushConfig)
api.POST("/client/:id/disconnect", clientHandler.Disconnect)
api.POST("/client/:id/restart", clientHandler.Restart)
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
api.POST("/client/:id/plugin/:pluginName/:action", clientHandler.PluginAction)
// 配置管理
configHandler := handler.NewConfigHandler(app)
api.GET("/config", configHandler.Get)
api.PUT("/config", configHandler.Update)
api.POST("/config/reload", configHandler.Reload)
// 插件管理
pluginHandler := handler.NewPluginHandler(app)
api.GET("/plugins", pluginHandler.List)
api.POST("/plugin/:name/enable", pluginHandler.Enable)
api.POST("/plugin/:name/disable", pluginHandler.Disable)
api.GET("/rule-schemas", pluginHandler.GetRuleSchemas)
api.GET("/client-plugin/:clientID/:pluginName/config", pluginHandler.GetClientConfig)
api.PUT("/client-plugin/:clientID/:pluginName/config", pluginHandler.UpdateClientConfig)
// JS 插件管理
jsPluginHandler := handler.NewJSPluginHandler(app)
api.GET("/js-plugins", jsPluginHandler.List)
api.POST("/js-plugins", jsPluginHandler.Create)
api.GET("/js-plugin/:name", jsPluginHandler.Get)
api.PUT("/js-plugin/:name", jsPluginHandler.Update)
api.DELETE("/js-plugin/:name", jsPluginHandler.Delete)
api.POST("/js-plugin/:name/push/:clientID", jsPluginHandler.PushToClient)
// 插件商店
storeHandler := handler.NewStoreHandler(app)
api.GET("/store/plugins", storeHandler.ListPlugins)
api.POST("/store/install", storeHandler.Install)
// 更新管理
updateHandler := handler.NewUpdateHandler(app)
api.GET("/update/check/server", updateHandler.CheckServer)
api.GET("/update/check/client", updateHandler.CheckClient)
api.POST("/update/apply/server", updateHandler.ApplyServer)
api.POST("/update/apply/client", updateHandler.ApplyClient)
}
}
// JWTMiddleware JWT 认证中间件
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 只对 /api/ 路径进行认证
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// 检查是否跳过认证
for _, path := range skipPaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r)
return
}
}
// 从 Header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := jwtAuth.ValidateToken(token); err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
// SetupStaticFiles 配置静态文件处理
func (r *GinRouter) SetupStaticFiles(staticFS fs.FS) {
// 使用 NoRoute 处理 SPA 路由
r.Engine.NoRoute(gin.WrapH(&spaHandler{fs: http.FS(staticFS)}))
}
// spaHandler SPA 路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
f, err := h.fs.Open(path)
if err != nil {
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if stat.IsDir() {
f.Close()
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
stat, _ = f.Stat()
}
if seeker, ok := f.(io.ReadSeeker); ok {
http.ServeContent(w, r, path, stat.ModTime(), seeker)
}
}
// Re-export types from handler package for backward compatibility
type (
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
JSPluginInstallRequest = handler.JSPluginInstallRequest
)

View File

@@ -0,0 +1,256 @@
package router
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/gotunnel/pkg/version"
)
// UpdateInfo 更新信息
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// checkUpdateForComponent 检查组件更新
func checkUpdateForComponent(component string) (*UpdateInfo, error) {
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
currentVersion := version.Version
available := version.CompareVersions(currentVersion, latestVersion) < 0
// 查找对应平台的资产
assetName := getAssetNameForPlatform(component, runtime.GOOS, runtime.GOARCH)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: available,
Current: currentVersion,
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// checkClientUpdateForPlatform 检查指定平台的客户端更新
func checkClientUpdateForPlatform(osName, arch string) (*UpdateInfo, error) {
if osName == "" {
osName = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
// 查找对应平台的资产
assetName := getAssetNameForPlatform("client", osName, arch)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: true, // 客户端版本由服务端判断
Current: "", // 客户端版本需要单独获取
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// getAssetNameForPlatform 获取指定平台的资产名称
func getAssetNameForPlatform(component, osName, arch string) string {
ext := ""
if osName == "windows" {
ext = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", component, osName, arch, ext)
}
// performSelfUpdate 执行自更新
func performSelfUpdate(downloadURL string, restart bool) error {
// 下载新版本
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, "gotunnel_update_"+time.Now().Format("20060102150405"))
if runtime.GOOS == "windows" {
tempFile += ".exe"
}
if err := downloadFile(downloadURL, tempFile); err != nil {
return fmt.Errorf("download update: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(tempFile, 0755); err != nil {
os.Remove(tempFile)
return fmt.Errorf("chmod: %w", err)
}
}
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
os.Remove(tempFile)
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理(运行中的文件无法直接替换)
if runtime.GOOS == "windows" {
return performWindowsUpdate(tempFile, currentPath, restart)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backup current: %w", err)
}
// 移动新文件
if err := os.Rename(tempFile, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
if restart {
// 重启进程
restartProcess(currentPath)
}
return nil
}
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
// 创建批处理脚本来替换文件并重启
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
`, currentPath, newFile, currentPath)
if restart {
batchScript += fmt.Sprintf(`start "" "%s"
`, currentPath)
}
batchScript += "del \"%~f0\"\n"
batchPath := filepath.Join(os.TempDir(), "gotunnel_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
// 退出当前进程
os.Exit(0)
return nil
}
// restartProcess 重启进程
func restartProcess(path string) {
cmd := exec.Command(path, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}
// downloadFile 下载文件
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// VersionInfo 版本信息
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// getVersionInfo 获取版本信息
func getVersionInfo() VersionInfo {
info := version.GetInfo()
return VersionInfo{
Version: info.Version,
GitCommit: info.GitCommit,
BuildTime: info.BuildTime,
GoVersion: info.GoVersion,
OS: info.OS,
Arch: info.Arch,
}
}

View File

@@ -1306,3 +1306,32 @@ func (s *Server) UpdateClientPluginConfig(clientID, pluginName, ruleName string,
return nil
}
// SendUpdateToClient 发送更新命令到客户端
func (s *Server) SendUpdateToClient(clientID, downloadURL 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
}
defer stream.Close()
req := protocol.UpdateDownloadRequest{
DownloadURL: downloadURL,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateDownload, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
log.Printf("[Server] Update command sent to client %s: %s", clientID, downloadURL)
return nil
}