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
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:
@@ -3,10 +3,14 @@ package tunnel
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -231,6 +235,8 @@ func (c *Client) handleStream(stream net.Conn) {
|
||||
c.handleClientRestart(stream, msg)
|
||||
case protocol.MsgTypePluginConfigUpdate:
|
||||
c.handlePluginConfigUpdate(stream, msg)
|
||||
case protocol.MsgTypeUpdateDownload:
|
||||
c.handleUpdateDownload(stream, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,3 +744,181 @@ func (c *Client) sendPluginConfigUpdateResult(stream net.Conn, pluginName, ruleN
|
||||
msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, result)
|
||||
protocol.WriteMessage(stream, msg)
|
||||
}
|
||||
|
||||
// handleUpdateDownload 处理更新下载请求
|
||||
func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) {
|
||||
defer stream.Close()
|
||||
|
||||
var req protocol.UpdateDownloadRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
log.Printf("[Client] Parse update request error: %v", err)
|
||||
c.sendUpdateResult(stream, false, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Client] Update download requested: %s", req.DownloadURL)
|
||||
|
||||
// 异步执行更新
|
||||
go func() {
|
||||
if err := c.performSelfUpdate(req.DownloadURL); err != nil {
|
||||
log.Printf("[Client] Update failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
c.sendUpdateResult(stream, true, "update started")
|
||||
}
|
||||
|
||||
// sendUpdateResult 发送更新结果
|
||||
func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string) {
|
||||
result := protocol.UpdateResultResponse{
|
||||
Success: success,
|
||||
Message: message,
|
||||
}
|
||||
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateResult, result)
|
||||
protocol.WriteMessage(stream, msg)
|
||||
}
|
||||
|
||||
// performSelfUpdate 执行自更新
|
||||
func (c *Client) performSelfUpdate(downloadURL string) error {
|
||||
log.Printf("[Client] Starting self-update from: %s", downloadURL)
|
||||
|
||||
// 创建临时文件
|
||||
tempDir := os.TempDir()
|
||||
tempFile := filepath.Join(tempDir, "gotunnel_client_update")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
tempFile += ".exe"
|
||||
}
|
||||
|
||||
// 下载新版本
|
||||
if err := downloadUpdateFile(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 performWindowsClientUpdate(tempFile, currentPath, c.ServerAddr, c.Token, c.ID)
|
||||
}
|
||||
|
||||
// Linux/Mac: 直接替换
|
||||
backupPath := currentPath + ".bak"
|
||||
|
||||
// 停止所有插件
|
||||
c.stopAllPlugins()
|
||||
|
||||
// 备份当前文件
|
||||
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)
|
||||
|
||||
log.Printf("[Client] Update completed, restarting...")
|
||||
|
||||
// 重启进程
|
||||
restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopAllPlugins 停止所有运行中的插件
|
||||
func (c *Client) stopAllPlugins() {
|
||||
c.pluginMu.Lock()
|
||||
for key, handler := range c.runningPlugins {
|
||||
log.Printf("[Client] Stopping plugin %s for update", key)
|
||||
handler.Stop()
|
||||
}
|
||||
c.runningPlugins = make(map[string]plugin.ClientPlugin)
|
||||
c.pluginMu.Unlock()
|
||||
}
|
||||
|
||||
// downloadUpdateFile 下载更新文件
|
||||
func downloadUpdateFile(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
|
||||
}
|
||||
|
||||
// performWindowsClientUpdate Windows 平台更新
|
||||
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token, id string) error {
|
||||
// 创建批处理脚本
|
||||
args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token)
|
||||
if id != "" {
|
||||
args += fmt.Sprintf(` -id "%s"`, id)
|
||||
}
|
||||
|
||||
batchScript := fmt.Sprintf(`@echo off
|
||||
ping 127.0.0.1 -n 2 > nul
|
||||
del "%s"
|
||||
move "%s" "%s"
|
||||
start "" "%s" %s
|
||||
del "%%~f0"
|
||||
`, currentPath, newFile, currentPath, currentPath, args)
|
||||
|
||||
batchPath := filepath.Join(os.TempDir(), "gotunnel_client_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
|
||||
}
|
||||
|
||||
// restartClientProcess 重启客户端进程
|
||||
func restartClientProcess(path, serverAddr, token, id string) {
|
||||
args := []string{"-s", serverAddr, "-t", token}
|
||||
if id != "" {
|
||||
args = append(args, "-id", id)
|
||||
}
|
||||
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
58
internal/server/router/dto/client.go
Normal file
58
internal/server/router/dto/client.go
Normal 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"`
|
||||
}
|
||||
53
internal/server/router/dto/config.go
Normal file
53
internal/server/router/dto/config.go
Normal 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"` // 显示为 ****
|
||||
}
|
||||
105
internal/server/router/dto/plugin.go
Normal file
105
internal/server/router/dto/plugin.go
Normal 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"`
|
||||
}
|
||||
76
internal/server/router/dto/update.go
Normal file
76
internal/server/router/dto/update.go
Normal 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"`
|
||||
}
|
||||
106
internal/server/router/errors.go
Normal file
106
internal/server/router/errors.go
Normal 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
|
||||
}
|
||||
100
internal/server/router/handler/auth.go
Normal file
100
internal/server/router/handler/auth.go
Normal 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,
|
||||
})
|
||||
}
|
||||
393
internal/server/router/handler/client.go
Normal file
393
internal/server/router/handler/client.go
Normal 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
|
||||
}
|
||||
131
internal/server/router/handler/config.go
Normal file
131
internal/server/router/handler/config.go
Normal 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"})
|
||||
}
|
||||
230
internal/server/router/handler/helpers.go
Normal file
230
internal/server/router/handler/helpers.go
Normal 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
|
||||
}
|
||||
83
internal/server/router/handler/interfaces.go
Normal file
83
internal/server/router/handler/interfaces.go
Normal 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"`
|
||||
}
|
||||
212
internal/server/router/handler/js_plugin.go
Normal file
212
internal/server/router/handler/js_plugin.go
Normal 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,
|
||||
})
|
||||
}
|
||||
285
internal/server/router/handler/plugin.go
Normal file
285
internal/server/router/handler/plugin.go
Normal 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
|
||||
}
|
||||
159
internal/server/router/handler/response.go
Normal file
159
internal/server/router/handler/response.go
Normal 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()
|
||||
}
|
||||
}
|
||||
51
internal/server/router/handler/status.go
Normal file
51
internal/server/router/handler/status.go
Normal 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())
|
||||
}
|
||||
169
internal/server/router/handler/store.go
Normal file
169
internal/server/router/handler/store.go
Normal 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,
|
||||
})
|
||||
}
|
||||
132
internal/server/router/handler/update.go
Normal file
132
internal/server/router/handler/update.go
Normal 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,
|
||||
}
|
||||
}
|
||||
28
internal/server/router/middleware/cors.go
Normal file
28
internal/server/router/middleware/cors.go
Normal 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()
|
||||
}
|
||||
}
|
||||
49
internal/server/router/middleware/jwt.go
Normal file
49
internal/server/router/middleware/jwt.go
Normal 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()
|
||||
}
|
||||
}
|
||||
31
internal/server/router/middleware/logger.go
Normal file
31
internal/server/router/middleware/logger.go
Normal 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)
|
||||
}
|
||||
}
|
||||
26
internal/server/router/middleware/recovery.go
Normal file
26
internal/server/router/middleware/recovery.go
Normal 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()
|
||||
}
|
||||
}
|
||||
116
internal/server/router/response.go
Normal file
116
internal/server/router/response.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
256
internal/server/router/update_helpers.go
Normal file
256
internal/server/router/update_helpers.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user