Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9edd3c02 | ||
|
|
e40d079f7a | ||
|
|
8ce5b149f7 |
@@ -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 {
|
||||
|
||||
@@ -55,9 +55,21 @@ 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 {
|
||||
@@ -889,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()
|
||||
|
||||
@@ -397,14 +397,19 @@ 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
|
||||
SELECT hour_ts, inbound, outbound FROM traffic_stats
|
||||
WHERE hour_ts >= ? ORDER BY hour_ts ASC
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ func generateClientID() string {
|
||||
// Server 隧道服务端
|
||||
type Server struct {
|
||||
clientStore db.ClientStore
|
||||
jsPluginStore db.JSPluginStore // JS 插件存储
|
||||
jsPluginStore db.JSPluginStore // JS 插件存储
|
||||
trafficStore db.TrafficStore // 流量存储
|
||||
bindAddr string
|
||||
bindPort int
|
||||
token string
|
||||
@@ -161,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
|
||||
@@ -278,9 +284,10 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
}
|
||||
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 {
|
||||
if client.Nickname == "" {
|
||||
client.Nickname = authReq.Name
|
||||
s.clientStore.UpdateClient(client)
|
||||
}
|
||||
@@ -535,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 心跳检测循环
|
||||
@@ -1223,7 +1230,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
|
||||
@@ -1906,6 +1913,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user