2 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
6 changed files with 103 additions and 47 deletions

View File

@@ -55,9 +55,21 @@ type Client struct {
// NewClient 创建客户端 // NewClient 创建客户端
func NewClient(serverAddr, token, id string) *Client { func NewClient(serverAddr, token, id string) *Client {
// 默认数据目录 // 默认数据目录:优先使用用户主目录,失败时回退到当前工作目录
home, _ := os.UserHomeDir() var dataDir string
dataDir := filepath.Join(home, ".gotunnel") 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 { if err := os.MkdirAll(dataDir, 0755); err != nil {
@@ -889,58 +901,99 @@ func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string)
func (c *Client) performSelfUpdate(downloadURL string) error { func (c *Client) performSelfUpdate(downloadURL string) error {
c.logf("Starting self-update from: %s", downloadURL) 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() currentPath, err := os.Executable()
if err != nil { 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) 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 需要特殊处理 // Windows 需要特殊处理
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token, c.ID) return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token, c.ID)
} }
// Linux/Mac: 直接替换 // Linux/Mac/Android: 直接替换
backupPath := currentPath + ".bak" backupPath := currentPath + ".bak"
// 停止所有插件 // 停止所有插件
c.stopAllPlugins() c.stopAllPlugins()
// 备份当前文件 // 备份当前文件
c.logf("Backing up current binary...")
if err := os.Rename(currentPath, backupPath); err != nil { 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可能跨文件系统 // 复制新文件(不能用 rename可能跨文件系统
c.logf("Installing new binary...")
if err := update.CopyFile(binaryPath, currentPath); err != nil { if err := update.CopyFile(binaryPath, currentPath); err != nil {
os.Rename(backupPath, currentPath) 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 { if err := os.Chmod(currentPath, 0755); err != nil {
os.Rename(backupPath, currentPath) 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) os.Remove(backupPath)
c.logf("Update completed, restarting...") c.logf("Update completed successfully, restarting...")
// 重启进程 // 重启进程
restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID) restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID)
return nil 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 停止所有运行中的插件 // stopAllPlugins 停止所有运行中的插件
func (c *Client) stopAllPlugins() { func (c *Client) stopAllPlugins() {
c.pluginMu.Lock() c.pluginMu.Lock()

View File

@@ -50,8 +50,8 @@ func generateClientID() string {
// Server 隧道服务端 // Server 隧道服务端
type Server struct { type Server struct {
clientStore db.ClientStore clientStore db.ClientStore
jsPluginStore db.JSPluginStore // JS 插件存储 jsPluginStore db.JSPluginStore // JS 插件存储
trafficStore db.TrafficStore // 流量存储 trafficStore db.TrafficStore // 流量存储
bindAddr string bindAddr string
bindPort int bindPort int
token string token string
@@ -514,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) { func (s *Server) acceptProxyServerConns(cs *ClientSession, ln net.Listener, rule protocol.ProxyRule) {
dialer := proxy.NewTunnelDialer(cs.Session) dialer := proxy.NewTunnelDialer(cs.Session)
// 使用内置 proxy 实现 // 使用内置 proxy 实现 (带流量统计)
proxyServer := proxy.NewServer(rule.Type, dialer) proxyServer := proxy.NewServer(rule.Type, dialer, s.recordTraffic)
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
@@ -963,6 +963,9 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
return return
} }
// 记录入站流量 (从外部接收的数据)
s.recordTraffic(int64(len(packet.Data)), 0)
// 等待客户端响应 // 等待客户端响应
respMsg, err := protocol.ReadMessage(stream) respMsg, err := protocol.ReadMessage(stream)
if err != nil { if err != nil {
@@ -975,6 +978,8 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
return return
} }
conn.WriteToUDP(respPacket.Data, clientAddr) conn.WriteToUDP(respPacket.Data, clientAddr)
// 记录出站流量 (发送回外部的数据)
s.recordTraffic(0, int64(len(respPacket.Data)))
} }
} }

View File

@@ -6,16 +6,19 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"github.com/gotunnel/pkg/relay"
) )
// HTTPServer HTTP 代理服务 // HTTPServer HTTP 代理服务
type HTTPServer struct { type HTTPServer struct {
dialer Dialer dialer Dialer
onStats func(in, out int64) // 流量统计回调
} }
// NewHTTPServer 创建 HTTP 代理服务 // NewHTTPServer 创建 HTTP 代理服务
func NewHTTPServer(dialer Dialer) *HTTPServer { func NewHTTPServer(dialer Dialer, onStats func(in, out int64)) *HTTPServer {
return &HTTPServer{dialer: dialer} return &HTTPServer{dialer: dialer, onStats: onStats}
} }
// HandleConn 处理 HTTP 代理连接 // 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")) 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 return nil
} }
@@ -82,7 +85,10 @@ func (h *HTTPServer) handleHTTP(conn net.Conn, req *http.Request, reader *bufio.
return err 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 return err
} }

View File

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

View File

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

View File

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