5 Commits

Author SHA1 Message Date
e0d88e9ad7 feat: Implement core tunnel server with client management, authentication, and SOCKS5/HTTP proxy capabilities.
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-02-03 21:59:55 +08:00
Flik
ba9edd3c02 feat(client): enhance data directory handling and update permissions check for self-update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m32s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m30s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m39s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m43s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m22s
2026-01-29 16:29:40 +08:00
Flik
e40d079f7a feat(server): add traffic storage and statistics tracking for improved traffic management
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-01-29 15:38:27 +08:00
Flik
8ce5b149f7 fix(server): update client nickname handling to prevent overwriting manual names
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m4s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m19s
2026-01-29 15:08:04 +08:00
Flik
0a41e10793 feat(client, server): add client name handling and machine ID retrieval
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-01-27 08:56:05 +08:00
20 changed files with 341 additions and 108 deletions

View File

@@ -84,6 +84,7 @@ func main() {
registry := plugin.NewRegistry()
server.SetPluginRegistry(registry)
server.SetJSPluginStore(clientStore) // 设置 JS 插件存储,用于客户端重连时恢复插件
server.SetTrafficStore(clientStore) // 设置流量存储,用于记录流量统计
// 启动 Web 控制台
if cfg.Server.Web.Enabled {

2
go.mod
View File

@@ -59,7 +59,7 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shoenig/go-m1cpu v0.1.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

8
go.sum
View File

@@ -122,10 +122,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0=
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

View File

@@ -32,7 +32,6 @@ const (
reconnectDelay = 5 * time.Second
disconnectDelay = 3 * time.Second
udpBufferSize = 65535
idFileName = "id"
)
// Client 隧道客户端
@@ -40,6 +39,7 @@ type Client struct {
ServerAddr string
Token string
ID string
Name string // 客户端名称(主机名)
TLSEnabled bool
TLSConfig *tls.Config
DataDir string // 数据目录
@@ -55,19 +55,35 @@ type Client struct {
// NewClient 创建客户端
func NewClient(serverAddr, token, id string) *Client {
// 默认数据目录
home, _ := os.UserHomeDir()
dataDir := filepath.Join(home, ".gotunnel")
// 默认数据目录:优先使用用户主目录,失败时回退到当前工作目录
var dataDir string
if home, err := os.UserHomeDir(); err == nil && home != "" {
dataDir = filepath.Join(home, ".gotunnel")
} else {
// UserHomeDir 失败(如 Android adb shell 环境),使用当前工作目录
if cwd, err := os.Getwd(); err == nil {
dataDir = filepath.Join(cwd, ".gotunnel")
log.Printf("[Client] UserHomeDir unavailable, using current directory: %s", dataDir)
} else {
// 最后回退到相对路径
dataDir = ".gotunnel"
log.Printf("[Client] Warning: using relative path for data directory")
}
}
// 确保数据目录存在
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Printf("Failed to create data dir: %v", err)
}
// ID 优先级:命令行参数 > 机器ID
if id == "" {
id = loadClientID(dataDir)
id = getMachineID()
}
// 获取主机名作为客户端名称
hostname, _ := os.Hostname()
// 初始化日志收集器
logger, err := NewLogger(dataDir)
if err != nil {
@@ -78,6 +94,7 @@ func NewClient(serverAddr, token, id string) *Client {
ServerAddr: serverAddr,
Token: token,
ID: id,
Name: hostname,
DataDir: dataDir,
runningPlugins: make(map[string]plugin.ClientPlugin),
logger: logger,
@@ -94,27 +111,6 @@ func (c *Client) InitVersionStore() error {
return nil
}
// getIDFilePath 获取 ID 文件路径
func getIDFilePath(dataDir string) string {
return filepath.Join(dataDir, idFileName)
}
// loadClientID 从本地文件加载客户端 ID
func loadClientID(dataDir string) string {
data, err := os.ReadFile(getIDFilePath(dataDir))
if err != nil {
return ""
}
return string(data)
}
// saveClientID 保存客户端 ID 到本地文件
func saveClientID(dataDir, id string) {
if err := os.WriteFile(getIDFilePath(dataDir), []byte(id), 0600); err != nil {
log.Printf("Failed to save client ID: %v", err)
}
}
// SetPluginRegistry 设置插件注册表
func (c *Client) SetPluginRegistry(registry *plugin.Registry) {
c.pluginRegistry = registry
@@ -181,6 +177,7 @@ func (c *Client) connect() error {
authReq := protocol.AuthRequest{
ClientID: c.ID,
Token: c.Token,
Name: c.Name,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: version.Version,
@@ -207,11 +204,10 @@ func (c *Client) connect() error {
return fmt.Errorf("auth failed: %s", authResp.Message)
}
// 如果服务端分配了新 ID则更新并保存
// 如果服务端分配了新 ID则更新
if authResp.ClientID != "" && authResp.ClientID != c.ID {
c.ID = authResp.ClientID
saveClientID(c.DataDir, c.ID)
c.logf("New ID assigned and saved: %s", c.ID)
c.logf("ID updated to: %s", c.ID)
}
c.logf("Authenticated as %s", c.ID)
@@ -905,58 +901,99 @@ func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string)
func (c *Client) performSelfUpdate(downloadURL string) error {
c.logf("Starting self-update from: %s", downloadURL)
// 使用共享的下载和解压逻辑
binaryPath, cleanup, err := update.DownloadAndExtract(downloadURL, "client")
if err != nil {
return err
}
defer cleanup()
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("get executable: %w", err)
c.logErrorf("Update failed: cannot get executable path: %v", err)
return err
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// 预检查:验证是否有写权限(在下载前检查,避免浪费带宽)
if err := c.checkUpdatePermissions(currentPath); err != nil {
c.logErrorf("Update failed: %v", err)
c.logErrorf("Self-update is not supported in this environment. Please update manually.")
return err
}
// 使用共享的下载和解压逻辑
c.logf("Downloading update package...")
binaryPath, cleanup, err := update.DownloadAndExtract(downloadURL, "client")
if err != nil {
c.logErrorf("Update failed: download/extract error: %v", err)
return err
}
defer cleanup()
// Windows 需要特殊处理
if runtime.GOOS == "windows" {
return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token, c.ID)
}
// Linux/Mac: 直接替换
// Linux/Mac/Android: 直接替换
backupPath := currentPath + ".bak"
// 停止所有插件
c.stopAllPlugins()
// 备份当前文件
c.logf("Backing up current binary...")
if err := os.Rename(currentPath, backupPath); err != nil {
return fmt.Errorf("backup current: %w", err)
c.logErrorf("Update failed: cannot backup current binary: %v", err)
c.logErrorf("This may be due to insufficient permissions or read-only filesystem.")
return err
}
// 复制新文件(不能用 rename可能跨文件系统
c.logf("Installing new binary...")
if err := update.CopyFile(binaryPath, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
c.logErrorf("Update failed: cannot install new binary: %v", err)
return err
}
// 设置执行权限
if err := os.Chmod(currentPath, 0755); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("chmod: %w", err)
c.logErrorf("Update failed: cannot set execute permission: %v", err)
return err
}
// 删除备份
os.Remove(backupPath)
c.logf("Update completed, restarting...")
c.logf("Update completed successfully, restarting...")
// 重启进程
restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID)
return nil
}
// checkUpdatePermissions 检查是否有更新权限
func (c *Client) checkUpdatePermissions(execPath string) error {
// 检查可执行文件所在目录是否可写
dir := filepath.Dir(execPath)
testFile := filepath.Join(dir, ".gotunnel_update_test")
f, err := os.Create(testFile)
if err != nil {
c.logErrorf("No write permission to directory: %s", dir)
return err
}
f.Close()
os.Remove(testFile)
// 检查可执行文件本身是否可写
f, err = os.OpenFile(execPath, os.O_WRONLY, 0)
if err != nil {
c.logErrorf("No write permission to executable: %s", execPath)
return err
}
f.Close()
return nil
}
// stopAllPlugins 停止所有运行中的插件
func (c *Client) stopAllPlugins() {
c.pluginMu.Lock()

View File

@@ -0,0 +1,133 @@
package tunnel
import (
"crypto/sha256"
"encoding/hex"
"net"
"os"
"os/exec"
"runtime"
"strings"
)
// getMachineID 获取机器唯一标识
// 优先级系统机器ID > MAC地址哈希
func getMachineID() string {
// 尝试获取系统机器 ID
if id := getSystemMachineID(); id != "" {
return hashID(id)
}
// 备选:使用主网卡 MAC 地址
if id := getMACAddress(); id != "" {
return hashID(id)
}
// 都失败则返回空,让服务端生成
return ""
}
// getSystemMachineID 获取系统机器 ID
func getSystemMachineID() string {
switch runtime.GOOS {
case "linux":
return getLinuxMachineID()
case "darwin":
return getDarwinMachineID()
case "windows":
return getWindowsMachineID()
default:
return ""
}
}
// getLinuxMachineID 获取 Linux 机器 ID
func getLinuxMachineID() string {
// 优先读取 /etc/machine-id
if data, err := os.ReadFile("/etc/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
// 备选 /var/lib/dbus/machine-id
if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
return ""
}
// getDarwinMachineID 获取 macOS 机器 ID (IOPlatformUUID)
func getDarwinMachineID() string {
cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
output, err := cmd.Output()
if err != nil {
return ""
}
// 解析 IOPlatformUUID
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "IOPlatformUUID") {
parts := strings.Split(line, "=")
if len(parts) == 2 {
uuid := strings.TrimSpace(parts[1])
uuid = strings.Trim(uuid, "\"")
return uuid
}
}
}
return ""
}
// getWindowsMachineID 获取 Windows 机器 ID
func getWindowsMachineID() string {
cmd := exec.Command("reg", "query",
`HKLM\SOFTWARE\Microsoft\Cryptography`,
"/v", "MachineGuid")
output, err := cmd.Output()
if err != nil {
return ""
}
// 解析注册表输出
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "MachineGuid") {
fields := strings.Fields(line)
if len(fields) >= 3 {
return fields[len(fields)-1]
}
}
}
return ""
}
// getMACAddress 获取主网卡 MAC 地址
func getMACAddress() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range interfaces {
// 跳过回环和无效接口
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagUp == 0 {
continue
}
if len(iface.HardwareAddr) == 0 {
continue
}
// 返回第一个有效的 MAC 地址
return iface.HardwareAddr.String()
}
return ""
}
// hashID 对 ID 进行哈希处理,生成固定长度的客户端 ID
func hashID(id string) string {
hash := sha256.Sum256([]byte(id))
// 取前 16 个字符作为客户端 ID
return hex.EncodeToString(hash[:])[:16]
}

View File

@@ -397,12 +397,17 @@ func (s *SQLiteStore) Get24HourTraffic() (inbound, outbound int64, err error) {
return
}
// GetHourlyTraffic 获取每小时流量记录
// GetHourlyTraffic 获取每小时流量记录(始终返回完整的 hours 小时数据)
func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
// 计算当前小时的起始时间戳
now := time.Now()
currentHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// 查询数据库中的记录
cutoff := currentHour.Add(-time.Duration(hours-1) * time.Hour).Unix()
rows, err := s.db.Query(`
SELECT hour_ts, inbound, outbound FROM traffic_stats
WHERE hour_ts >= ? ORDER BY hour_ts ASC
@@ -412,13 +417,26 @@ func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
}
defer rows.Close()
var records []TrafficRecord
// 将数据库记录放入 map 以便快速查找
dbRecords := make(map[int64]TrafficRecord)
for rows.Next() {
var r TrafficRecord
if err := rows.Scan(&r.Timestamp, &r.Inbound, &r.Outbound); err != nil {
return nil, err
}
records = append(records, r)
dbRecords[r.Timestamp] = r
}
// 生成完整的 hours 小时数据
records := make([]TrafficRecord, hours)
for i := 0; i < hours; i++ {
ts := currentHour.Add(-time.Duration(hours-1-i) * time.Hour).Unix()
if r, ok := dbRecords[ts]; ok {
records[i] = r
} else {
records[i] = TrafficRecord{Timestamp: ts, Inbound: 0, Outbound: 0}
}
}
return records, nil
}

View File

@@ -115,7 +115,7 @@ func (h *ClientHandler) Get(c *gin.Context) {
return
}
online, lastPing, remoteAddr, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
online, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
// 复制插件列表
plugins := make([]db.ClientPlugin, len(client.Plugins))
@@ -145,9 +145,15 @@ func (h *ClientHandler) Get(c *gin.Context) {
}
}
// 如果客户端在线且有名称,优先使用在线名称
nickname := client.Nickname
if online && clientName != "" && nickname == "" {
nickname = clientName
}
resp := dto.ClientResponse{
ID: client.ID,
Nickname: client.Nickname,
Nickname: nickname,
Rules: client.Rules,
Plugins: plugins,
Online: online,
@@ -242,8 +248,7 @@ func (h *ClientHandler) Delete(c *gin.Context) {
func (h *ClientHandler) PushConfig(c *gin.Context) {
clientID := c.Param("id")
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
ClientNotOnline(c)
return
}
@@ -311,8 +316,7 @@ func (h *ClientHandler) Restart(c *gin.Context) {
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
clientID := c.Param("id")
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
ClientNotOnline(c)
return
}

View File

@@ -19,11 +19,13 @@ type AppInterface interface {
// ServerInterface 服务端接口
type ServerInterface interface {
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch, clientVersion string)
IsClientOnline(clientID string) bool
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion string)
GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
Name string
OS string
Arch string
Version string

View File

@@ -177,8 +177,7 @@ func (h *JSPluginHandler) PushToClient(c *gin.Context) {
c.ShouldBindJSON(&pushReq) // 忽略错误,允许空请求体
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
ClientNotOnline(c)
return
}

View File

@@ -35,8 +35,7 @@ func (h *LogHandler) StreamLogs(c *gin.Context) {
clientID := c.Param("id")
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
return
}

View File

@@ -371,8 +371,7 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
}
// 如果客户端在线,同步配置
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if online {
if h.app.GetServer().IsClientOnline(clientID) {
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
return

View File

@@ -45,8 +45,7 @@ func (h *PluginAPIHandler) ProxyRequest(c *gin.Context) {
}
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
if !h.app.GetServer().IsClientOnline(clientID) {
ClientNotOnline(c)
return
}

View File

@@ -82,8 +82,7 @@ func (h *StoreHandler) Install(c *gin.Context) {
}
// 检查客户端是否在线
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
if !online {
if !h.app.GetServer().IsClientOnline(req.ClientID) {
ClientNotOnline(c)
return
}

View File

@@ -51,6 +51,7 @@ func generateClientID() string {
type Server struct {
clientStore db.ClientStore
jsPluginStore db.JSPluginStore // JS 插件存储
trafficStore db.TrafficStore // 流量存储
bindAddr string
bindPort int
token string
@@ -83,6 +84,7 @@ type JSPluginEntry struct {
// ClientSession 客户端会话
type ClientSession struct {
ID string
Name string // 客户端名称(主机名)
RemoteAddr string // 客户端 IP 地址
OS string // 客户端操作系统
Arch string // 客户端架构
@@ -160,6 +162,11 @@ func (s *Server) SetJSPluginStore(store db.JSPluginStore) {
s.jsPluginStore = store
}
// SetTrafficStore 设置流量存储
func (s *Server) SetTrafficStore(store db.TrafficStore) {
s.trafficStore = store
}
// LoadJSPlugins 加载 JS 插件配置
func (s *Server) LoadJSPlugins(plugins []JSPluginEntry) {
s.jsPlugins = plugins
@@ -269,13 +276,22 @@ func (s *Server) handleConnection(conn net.Conn) {
// 检查客户端是否存在,不存在则自动创建
exists, err := s.clientStore.ClientExists(clientID)
if err != nil || !exists {
newClient := &db.Client{ID: clientID, Rules: []protocol.ProxyRule{}}
newClient := &db.Client{ID: clientID, Nickname: authReq.Name, Rules: []protocol.ProxyRule{}}
if err := s.clientStore.CreateClient(newClient); err != nil {
log.Printf("[Server] Create client error: %v", err)
s.sendAuthResponse(conn, false, "failed to create client", "")
return
}
log.Printf("[Server] New client registered: %s", clientID)
log.Printf("[Server] New client registered: %s (%s)", clientID, authReq.Name)
} else if authReq.Name != "" {
// 客户端已存在,仅当 Nickname 为空时才用客户端名称更新
// 这样服务端手动设置的名称不会被客户端覆盖
if client, err := s.clientStore.GetClient(clientID); err == nil {
if client.Nickname == "" {
client.Nickname = authReq.Name
s.clientStore.UpdateClient(client)
}
}
}
rules, _ := s.clientStore.GetClientRules(clientID)
@@ -290,11 +306,11 @@ func (s *Server) handleConnection(conn net.Conn) {
}
security.LogAuthSuccess(clientIP, clientID)
s.setupClientSession(conn, clientID, authReq.OS, authReq.Arch, authReq.Version, rules)
s.setupClientSession(conn, clientID, authReq.Name, authReq.OS, authReq.Arch, authReq.Version, rules)
}
// setupClientSession 建立客户端会话
func (s *Server) setupClientSession(conn net.Conn, clientID, clientOS, clientArch, clientVersion string, rules []protocol.ProxyRule) {
func (s *Server) setupClientSession(conn net.Conn, clientID, clientName, clientOS, clientArch, clientVersion string, rules []protocol.ProxyRule) {
session, err := yamux.Server(conn, nil)
if err != nil {
log.Printf("[Server] Yamux error: %v", err)
@@ -309,6 +325,7 @@ func (s *Server) setupClientSession(conn net.Conn, clientID, clientOS, clientArc
cs := &ClientSession{
ID: clientID,
Name: clientName,
RemoteAddr: remoteAddr,
OS: clientOS,
Arch: clientArch,
@@ -497,8 +514,8 @@ func (s *Server) acceptProxyConns(cs *ClientSession, ln net.Listener, rule proto
func (s *Server) acceptProxyServerConns(cs *ClientSession, ln net.Listener, rule protocol.ProxyRule) {
dialer := proxy.NewTunnelDialer(cs.Session)
// 使用内置 proxy 实现
proxyServer := proxy.NewServer(rule.Type, dialer)
// 使用内置 proxy 实现 (带流量统计)
proxyServer := proxy.NewServer(rule.Type, dialer, s.recordTraffic)
for {
conn, err := ln.Accept()
if err != nil {
@@ -525,7 +542,7 @@ func (s *Server) handleProxyConn(cs *ClientSession, conn net.Conn, rule protocol
return
}
relay.Relay(conn, stream)
relay.RelayWithStats(conn, stream, s.recordTraffic)
}
// heartbeatLoop 心跳检测循环
@@ -586,16 +603,24 @@ func (s *Server) sendHeartbeat(cs *ClientSession) bool {
}
// GetClientStatus 获取客户端状态
func (s *Server) GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch, clientVersion string) {
func (s *Server) GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion string) {
s.mu.RLock()
defer s.mu.RUnlock()
if cs, ok := s.clients[clientID]; ok {
cs.mu.Lock()
defer cs.mu.Unlock()
return true, cs.LastPing.Format(time.RFC3339), cs.RemoteAddr, cs.OS, cs.Arch, cs.Version
return true, cs.LastPing.Format(time.RFC3339), cs.RemoteAddr, cs.Name, cs.OS, cs.Arch, cs.Version
}
return false, "", "", "", "", ""
return false, "", "", "", "", "", ""
}
// IsClientOnline 检查客户端是否在线
func (s *Server) IsClientOnline(clientID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.clients[clientID]
return ok
}
// GetClientPluginStatus 获取客户端插件运行状态
@@ -646,6 +671,7 @@ func (s *Server) GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
Name string
OS string
Arch string
Version string
@@ -662,6 +688,7 @@ func (s *Server) GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
Name string
OS string
Arch string
Version string
@@ -673,6 +700,7 @@ func (s *Server) GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
Name string
OS string
Arch string
Version string
@@ -680,6 +708,7 @@ func (s *Server) GetAllClientStatus() map[string]struct {
Online: true,
LastPing: cs.LastPing.Format(time.RFC3339),
RemoteAddr: cs.RemoteAddr,
Name: cs.Name,
OS: cs.OS,
Arch: cs.Arch,
Version: cs.Version,
@@ -934,6 +963,9 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
return
}
// 记录入站流量 (从外部接收的数据)
s.recordTraffic(int64(len(packet.Data)), 0)
// 等待客户端响应
respMsg, err := protocol.ReadMessage(stream)
if err != nil {
@@ -946,6 +978,8 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
return
}
conn.WriteToUDP(respPacket.Data, clientAddr)
// 记录出站流量 (发送回外部的数据)
s.recordTraffic(0, int64(len(respPacket.Data)))
}
}
@@ -1201,7 +1235,7 @@ func (s *Server) handleClientPluginConn(cs *ClientSession, conn net.Conn, rule p
}
}
relay.Relay(conn, stream)
relay.RelayWithStats(conn, stream, s.recordTraffic)
}
// checkHTTPBasicAuth 检查 HTTP Basic Auth
@@ -1884,6 +1918,16 @@ func (s *Server) StopClientLogStream(sessionID string) {
s.logSessions.RemoveSession(sessionID)
}
// recordTraffic 记录流量统计
func (s *Server) recordTraffic(inbound, outbound int64) {
if s.trafficStore == nil {
return
}
if err := s.trafficStore.AddTraffic(inbound, outbound); err != nil {
log.Printf("[Server] Record traffic error: %v", err)
}
}
// boolPtr 返回 bool 值的指针
func boolPtr(b bool) *bool {
return &b

View File

@@ -142,5 +142,5 @@ func (s *Server) handleWebsocketProxyConn(cs *ClientSession, conn net.Conn, rule
return
}
relay.Relay(conn, stream)
relay.RelayWithStats(conn, stream, s.recordTraffic)
}

View File

@@ -87,6 +87,7 @@ type Message struct {
type AuthRequest struct {
ClientID string `json:"client_id"`
Token string `json:"token"`
Name string `json:"name,omitempty"` // 客户端名称(主机名)
OS string `json:"os,omitempty"` // 客户端操作系统
Arch string `json:"arch,omitempty"` // 客户端架构
Version string `json:"version,omitempty"` // 客户端版本

View File

@@ -6,16 +6,19 @@ import (
"net"
"net/http"
"strings"
"github.com/gotunnel/pkg/relay"
)
// HTTPServer HTTP 代理服务
type HTTPServer struct {
dialer Dialer
dialer Dialer
onStats func(in, out int64) // 流量统计回调
}
// NewHTTPServer 创建 HTTP 代理服务
func NewHTTPServer(dialer Dialer) *HTTPServer {
return &HTTPServer{dialer: dialer}
func NewHTTPServer(dialer Dialer, onStats func(in, out int64)) *HTTPServer {
return &HTTPServer{dialer: dialer, onStats: onStats}
}
// HandleConn 处理 HTTP 代理连接
@@ -50,8 +53,8 @@ func (h *HTTPServer) handleConnect(conn net.Conn, req *http.Request) error {
conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go io.Copy(remote, conn)
io.Copy(conn, remote)
// 双向转发 (带流量统计)
relay.RelayWithStats(conn, remote, h.onStats)
return nil
}
@@ -82,7 +85,10 @@ func (h *HTTPServer) handleHTTP(conn net.Conn, req *http.Request, reader *bufio.
return err
}
// 转发响应
_, err = io.Copy(conn, remote)
// 转发响应 (带流量统计)
n, err := io.Copy(conn, remote)
if h.onStats != nil && n > 0 {
h.onStats(0, n) // 响应数据为出站流量
}
return err
}

View File

@@ -14,10 +14,10 @@ type Server struct {
}
// NewServer 创建代理服务器
func NewServer(typ string, dialer Dialer) *Server {
func NewServer(typ string, dialer Dialer, onStats func(in, out int64)) *Server {
return &Server{
socks5: NewSOCKS5Server(dialer),
http: NewHTTPServer(dialer),
socks5: NewSOCKS5Server(dialer, onStats),
http: NewHTTPServer(dialer, onStats),
typ: typ,
}
}

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net"
"github.com/gotunnel/pkg/relay"
)
const (
@@ -19,7 +21,8 @@ const (
// SOCKS5Server SOCKS5 代理服务
type SOCKS5Server struct {
dialer Dialer
dialer Dialer
onStats func(in, out int64) // 流量统计回调
}
// Dialer 连接拨号器接口
@@ -28,8 +31,8 @@ type Dialer interface {
}
// NewSOCKS5Server 创建 SOCKS5 服务
func NewSOCKS5Server(dialer Dialer) *SOCKS5Server {
return &SOCKS5Server{dialer: dialer}
func NewSOCKS5Server(dialer Dialer, onStats func(in, out int64)) *SOCKS5Server {
return &SOCKS5Server{dialer: dialer, onStats: onStats}
}
// HandleConn 处理 SOCKS5 连接
@@ -60,9 +63,8 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) error {
return err
}
// 双向转发
go io.Copy(remote, conn)
io.Copy(conn, remote)
// 双向转发 (带流量统计)
relay.RelayWithStats(conn, remote, s.onStats)
return nil
}

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline,
PushOutline, AddOutline, StorefrontOutline,
ExtensionPuzzleOutline, SettingsOutline, RefreshOutline
} from '@vicons/ionicons5'
import GlassModal from '../components/GlassModal.vue'
@@ -19,7 +19,6 @@ import {
type UpdateInfo, type SystemStats
} from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue'
import InlineLogPanel from '../components/InlineLogPanel.vue'
const route = useRoute()
@@ -513,9 +512,6 @@ onUnmounted(() => {
}
})
// Log Viewer
const showLogViewer = ref(false)
// Plugin Menu
const activePluginMenu = ref('')
const togglePluginMenu = (pluginId: string) => {
@@ -578,10 +574,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<PushOutline class="btn-icon" />
<span>推送配置</span>
</button>
<button class="glass-btn" @click="showLogViewer=true">
<DocumentTextOutline class="btn-icon" />
<span>日志</span>
</button>
<button class="glass-btn danger" @click="confirmDelete">
<TrashOutline class="btn-icon" />
<span>删除</span>
@@ -905,8 +897,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<button class="glass-btn primary" @click="confirmInstallPlugin">确认安装</button>
</template>
</GlassModal>
<LogViewer :visible="showLogViewer" @close="showLogViewer = false" :client-id="clientId" />
</div>
</template>