diff --git a/api.go b/api.go index eb1332f..a5d76dc 100644 --- a/api.go +++ b/api.go @@ -1,6 +1,7 @@ package main import ( + "log" "net/http" "strconv" @@ -46,7 +47,6 @@ func SetupRouter() *gin.Engine { // 前端入口 r.GET("/", func(c *gin.Context) { c.File("./static/index.html") - c.String(200, "frpc-console 后端已启动 🚀") }) return r @@ -101,21 +101,29 @@ func updateConfigHandler(c *gin.Context) { return } + log.Printf("收到配置更新: %+v", cfg) + if err := UpdateGlobalConfig(&cfg); err != nil { + log.Printf("❌ 更新数据库失败: %v", err) // 新增日志 c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败"}) return } + log.Printf("✅ 数据库更新成功") // 自动生成配置文件并热加载 if err := GenerateFrpcConfig(); err != nil { + log.Printf("❌ 生成配置文件失败: %v", err) // 新增日志 c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()}) return } + log.Printf("✅ 配置文件生成成功") if err := ReloadFrpc(); err != nil { + log.Printf("❌ 热加载失败: %v", err) // 新增日志 c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) return } + log.Printf("✅ 热加载成功") c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) } diff --git a/auth.go b/auth.go index a64b5f0..4f04e0e 100644 --- a/auth.go +++ b/auth.go @@ -13,7 +13,13 @@ import ( "golang.org/x/crypto/bcrypt" ) -var jwtSecret = []byte("frpc-console-secret-key-2024") +func getJwtSecret() []byte { + secret, err := GetJwtSecret() + if err != nil { + log.Fatal("❌ 获取 JWT 密钥失败:", err) + } + return []byte(secret) +} type Claims struct { Username string `json:"username"` @@ -29,12 +35,12 @@ func GenerateJWT(username string) (string, error) { }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) + return token.SignedString(getJwtSecret()) } func ParseJWT(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - return jwtSecret, nil + return getJwtSecret(), nil }) if err != nil { return nil, err diff --git a/db.go b/db.go index 2864b17..d6042db 100644 --- a/db.go +++ b/db.go @@ -1,15 +1,19 @@ package main import ( + "crypto/rand" "database/sql" + "encoding/hex" "log" + "sync" _ "modernc.org/sqlite" ) var DB *sql.DB +var dbInitOnce sync.Once -// 全局配置表(A表) +// ===== 全局配置表(A表)===== type GlobalConfig struct { ID int `json:"id"` ServerAddr string `json:"serverAddr"` @@ -24,7 +28,7 @@ type GlobalConfig struct { PoolCount int `json:"poolCount"` } -// 隧道表(B表) +// ===== 隧道表(B表)===== type Proxy struct { ID int `json:"id"` Name string `json:"name"` @@ -35,7 +39,7 @@ type Proxy struct { Enabled bool `json:"enabled"` } -// 用户表(C表) +// ===== 用户表(C表)===== type User struct { ID int `json:"id"` Username string `json:"username"` @@ -43,6 +47,7 @@ type User struct { CreatedAt string `json:"createdAt"` } +// ===== 初始化数据库 ===== func InitDB() error { var err error DB, err = sql.Open("sqlite", "./frpc-console.db") @@ -50,7 +55,7 @@ func InitDB() error { return err } - // 创建用户表 + // 用户表(C表) _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -64,7 +69,7 @@ func InitDB() error { return err } - // 创建全局配置表(只有一行数据) + // 全局配置表(A表) _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS global_config ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -85,13 +90,15 @@ func InitDB() error { return err } - // 确保全局配置表有一行数据 + // 确保全局配置有一行数据 var count int DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) if count == 0 { _, err = DB.Exec(` - INSERT INTO global_config (id, server_addr, server_port, token, log_level, log_max_days, tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count) - VALUES (1, 'frp.whitetop.xyz', 9358, 'Lxh10020328', 'info', 3, 1, 30, 15, 70, 8) + INSERT INTO global_config ( + id, server_addr, server_port, token, log_level, log_max_days, + tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count + ) VALUES (1, 'frp.whitetop.xyz', 9358, 'Lxh10020328', 'info', 3, 1, 30, 15, 70, 8) `) if err != nil { return err @@ -99,7 +106,7 @@ func InitDB() error { log.Println("✅ 全局配置初始化完成") } - // 创建隧道表 + // 隧道表(B表) _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS proxies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -117,36 +124,101 @@ func InitDB() error { return err } + // 🟢 新增:应用配置表(用于存储 JWT 密钥等全局配置) + _, err = DB.Exec(` + CREATE TABLE IF NOT EXISTS app_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 🟢 确保 JWT 密钥存在 + if err := ensureJwtSecret(); err != nil { + return err + } + log.Println("✅ 数据库初始化完成") return nil } -// ========== 全局配置 CRUD ========== +// ===== 🟢 新增:JWT 密钥管理 ===== + +// ensureJwtSecret 检查 app_config 表里是否有 jwt_secret,没有则生成一个 +func ensureJwtSecret() error { + var value string + err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value) + if err == nil && value != "" { + // 密钥已存在 + return nil + } + + // 生成 32 字节随机密钥 + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return err + } + secret := hex.EncodeToString(bytes) + + // 存入数据库 + _, err = DB.Exec(` + INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?) + `, secret) + if err != nil { + return err + } + log.Printf("✅ JWT 密钥已生成并保存到数据库") + return nil +} + +// GetJwtSecret 从数据库读取 JWT 密钥 +func GetJwtSecret() (string, error) { + var secret string + err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret) + if err != nil { + return "", err + } + return secret, nil +} + +// ===== 全局配置 CRUD ===== func GetGlobalConfig() (*GlobalConfig, error) { var cfg GlobalConfig err := DB.QueryRow(` - SELECT id, server_addr, server_port, token, log_level, log_max_days, tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count + SELECT id, server_addr, server_port, token, log_level, log_max_days, + tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count FROM global_config WHERE id = 1 - `).Scan(&cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token, &cfg.LogLevel, &cfg.LogMaxDays, &cfg.TcpMux, &cfg.TcpMuxKeepalive, &cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount) + `).Scan( + &cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token, + &cfg.LogLevel, &cfg.LogMaxDays, &cfg.TcpMux, &cfg.TcpMuxKeepalive, + &cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount, + ) if err != nil { return nil, err } + // 🟢 强制开启 tcpMux,忽略数据库中存储的值 + cfg.TcpMux = true return &cfg, nil } func UpdateGlobalConfig(cfg *GlobalConfig) error { + // 忽略前端传过来的 TcpMux,强制保持为 true _, err := DB.Exec(` UPDATE global_config SET server_addr = ?, server_port = ?, token = ?, log_level = ?, log_max_days = ?, - tcp_mux = ?, tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?, + tcp_mux = 1, -- 🟢 强制写死为 1 + tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `, cfg.ServerAddr, cfg.ServerPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays, - cfg.TcpMux, cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount) + cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount) return err } -// ========== 隧道 CRUD ========== +// ===== 隧道 CRUD ===== func GetProxies() ([]Proxy, error) { rows, err := DB.Query(` SELECT id, name, type, local_ip, local_port, remote_port, enabled @@ -182,11 +254,16 @@ func GetProxy(id int) (*Proxy, error) { } func CreateProxy(p *Proxy) error { - _, err := DB.Exec(` + result, err := DB.Exec(` INSERT INTO proxies (name, type, local_ip, local_port, remote_port, enabled) VALUES (?, ?, ?, ?, ?, ?) `, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled) - return err + if err != nil { + return err + } + id, _ := result.LastInsertId() + p.ID = int(id) + return nil } func UpdateProxy(p *Proxy) error { @@ -204,7 +281,7 @@ func DeleteProxy(id int) error { return err } -// ========== 用户 CRUD ========== +// ===== 用户 CRUD ===== func GetUserByUsername(username string) (*User, error) { var u User err := DB.QueryRow(` diff --git a/frp.go b/frp.go index ee21280..ccfa53d 100644 --- a/frp.go +++ b/frp.go @@ -8,6 +8,7 @@ import ( "os/exec" "runtime" "strings" + "sync" "syscall" "text/template" ) @@ -15,10 +16,30 @@ import ( //go:embed bin/* var embeddedFrpc embed.FS -// 获取当前平台对应的 frpc 路径(从 embed 解压到临时目录) -func getFrpcPath() (string, error) { - var fileName string +//go:embed frpc.tmpl +var frpcTemplateContent string +var ( + cachedFrpcPath string + frpcPathMutex sync.Mutex +) + +// getFrpcPath 获取当前平台对应的 frpc 路径(优先使用缓存) +func getFrpcPath() (string, error) { + frpcPathMutex.Lock() + defer frpcPathMutex.Unlock() + + // 如果缓存有效,直接返回 + if cachedFrpcPath != "" { + if _, err := os.Stat(cachedFrpcPath); err == nil { + return cachedFrpcPath, nil + } + // 缓存失效,清空 + cachedFrpcPath = "" + } + + // 确定文件名 + var fileName string switch { case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": fileName = "frpc_windows_amd64.exe" @@ -27,20 +48,22 @@ func getFrpcPath() (string, error) { case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": fileName = "frpc_linux_arm64" default: - // 降级:尝试从 PATH 中查找 frpc + // 降级:尝试从系统 PATH 查找 frpc path, err := exec.LookPath("frpc") if err == nil { + cachedFrpcPath = path return path, nil } - return "", fmt.Errorf("不支持的平台: %s/%s,且 PATH 中未找到 frpc", runtime.GOOS, runtime.GOARCH) + return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) } - // 从 embed 读取对应文件 + // 从 embed 读取 data, err := embeddedFrpc.ReadFile("bin/" + fileName) if err != nil { - // 如果 embed 中没有,降级到 PATH + // 降级到 PATH path, lookErr := exec.LookPath("frpc") if lookErr == nil { + cachedFrpcPath = path return path, nil } return "", fmt.Errorf("未找到 frpc 文件 (embed 和 PATH 均无): %w", err) @@ -51,40 +74,21 @@ func getFrpcPath() (string, error) { if runtime.GOOS == "windows" { tmpPath += ".exe" } + + // 尝试写入(如果文件已存在且正在使用,忽略错误,直接使用已有文件) if err := os.WriteFile(tmpPath, data, 0755); err != nil { + // 如果写入失败,检查是否已有可用的 frpc + if _, statErr := os.Stat(tmpPath); statErr == nil { + cachedFrpcPath = tmpPath + return tmpPath, nil + } return "", fmt.Errorf("写入临时文件失败: %w", err) } + + cachedFrpcPath = tmpPath return tmpPath, nil } -// frpc.toml 模板 - 完全复制你的配置文件结构 -const frpcTemplate = `serverAddr = "{{.ServerAddr}}" -serverPort = {{.ServerPort}} - -[auth] -token = "{{.Token}}" - -[log] -to = "./frpc.log" -level = "{{.LogLevel}}" -maxDays = {{.LogMaxDays}} - -[transport] -tcpMux = {{.TcpMux}} -tcpMuxKeepaliveInterval = {{.TcpMuxKeepalive}} -heartbeatInterval = {{.HeartbeatInterval}} -heartbeatTimeout = {{.HeartbeatTimeout}} -poolCount = {{.PoolCount}} - -{{range .Proxies}} -[[proxies]] -name = "{{.Name}}" -type = "{{.Type}}" -localIP = "{{.LocalIP}}" -localPort = {{.LocalPort}} -remotePort = {{.RemotePort}} -{{end}}` - // GenerateFrpcConfig 生成 frpc.toml 配置文件 func GenerateFrpcConfig() error { cfg, err := GetGlobalConfig() @@ -112,19 +116,35 @@ func GenerateFrpcConfig() error { Proxies: activeProxies, } - tmpl, err := template.New("frpc").Parse(frpcTemplate) + // ===== 核心改动 ===== + // 优先使用外部模板文件 frpc.tmpl,如果不存在则使用 embed 中的模板 + var tmplContent string + if _, err := os.Stat("frpc.tmpl"); err == nil { + // 外部文件存在,读取它 + content, readErr := os.ReadFile("frpc.tmpl") + if readErr == nil { + tmplContent = string(content) + } else { + // 读取失败时回退到 embed + tmplContent = frpcTemplateContent + } + } else { + // 外部文件不存在,使用 embed 中的模板 + tmplContent = frpcTemplateContent + } + // ===== 核心改动结束 ===== + + tmpl, err := template.New("frpc").Parse(tmplContent) if err != nil { return fmt.Errorf("解析模板失败: %w", err) } var buf bytes.Buffer - err = tmpl.Execute(&buf, data) - if err != nil { + if err := tmpl.Execute(&buf, data); err != nil { return fmt.Errorf("渲染模板失败: %w", err) } - err = os.WriteFile("./frpc.toml", buf.Bytes(), 0644) - if err != nil { + if err := os.WriteFile("./frpc.toml", buf.Bytes(), 0644); err != nil { return fmt.Errorf("写入配置文件失败: %w", err) } @@ -138,50 +158,47 @@ func StartFrpc() error { return fmt.Errorf("获取 frpc 路径失败: %w", err) } - // 检查配置文件是否存在 + // 确保配置文件存在 if _, err := os.Stat("./frpc.toml"); os.IsNotExist(err) { if err := GenerateFrpcConfig(); err != nil { return fmt.Errorf("生成配置文件失败: %w", err) } } - // 先检查是否已在运行 + // 检查是否已在运行 running, _ := GetFrpcStatus() if running { - return nil // 已经在运行,不重复启动 + return nil } cmd := exec.Command(frpcPath, "-c", "./frpc.toml") - // Windows 下需要隐藏窗口(可选) if runtime.GOOS == "windows" { cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} } - err = cmd.Start() - if err != nil { + if err := cmd.Start(); err != nil { return fmt.Errorf("启动 frpc 失败: %w", err) } return nil } -// ReloadFrpc 热加载 frpc 配置 +// ReloadFrpc 热加载 frpc 配置(仅发信号,不重复写入二进制) func ReloadFrpc() error { - frpcPath, err := getFrpcPath() - if err != nil { - return fmt.Errorf("获取 frpc 路径失败: %w", err) - } - - // 检查 frpc 是否在运行 running, err := GetFrpcStatus() if err != nil { return fmt.Errorf("检查 frpc 状态失败: %w", err) } if !running { - // 没运行就直接启动 return StartFrpc() } - // 执行 reload - cmd := exec.Command(frpcPath, "reload", "-c", "./frpc.toml") + // 确保缓存路径存在 + if cachedFrpcPath == "" { + if _, err := getFrpcPath(); err != nil { + return fmt.Errorf("获取 frpc 路径失败: %w", err) + } + } + + cmd := exec.Command(cachedFrpcPath, "reload", "-c", "./frpc.toml") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("reload 失败: %s, %w", string(output), err) @@ -192,41 +209,33 @@ func ReloadFrpc() error { // StopFrpc 停止 frpc func StopFrpc() error { if runtime.GOOS == "windows" { - // Windows 用 taskkill cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") - err := cmd.Run() - if err != nil && !strings.Contains(err.Error(), "not found") { - return fmt.Errorf("停止 frpc 失败: %w", err) - } - return nil - } else { - // Linux/Unix 用 pkill - cmd := exec.Command("pkill", "-f", "frpc") - err := cmd.Run() - if err != nil && !strings.Contains(err.Error(), "no process") { + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { return fmt.Errorf("停止 frpc 失败: %w", err) } return nil } + cmd := exec.Command("pkill", "-f", "frpc") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { + return fmt.Errorf("停止 frpc 失败: %w", err) + } + return nil } // GetFrpcStatus 检查 frpc 是否在运行 func GetFrpcStatus() (bool, error) { if runtime.GOOS == "windows" { - // Windows 用 tasklist 检查 cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq frpc.exe") output, err := cmd.CombinedOutput() if err != nil { return false, nil } return strings.Contains(string(output), "frpc.exe"), nil - } else { - // Linux/Unix 用 pgrep - cmd := exec.Command("pgrep", "-f", "frpc") - output, err := cmd.Output() - if err != nil { - return false, nil - } - return len(output) > 0, nil } + cmd := exec.Command("pgrep", "-f", "frpc") + output, err := cmd.Output() + if err != nil { + return false, nil + } + return len(output) > 0, nil } diff --git a/frpc.log b/frpc.log new file mode 100644 index 0000000..eb21a48 --- /dev/null +++ b/frpc.log @@ -0,0 +1,15 @@ +2026-07-23 19:03:20.982 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 19:03:20.991 [I] [client/service.go:308] try to connect to server... +2026-07-23 19:03:21.150 [I] [client/service.go:328] [153a390fe71d72b9] login to server success, get run id [153a390fe71d72b9] +2026-07-23 19:05:38.975 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 19:05:38.984 [I] [client/service.go:308] try to connect to server... +2026-07-23 19:05:39.171 [I] [client/service.go:328] [7e21ed8b7758eb2e] login to server success, get run id [7e21ed8b7758eb2e] +2026-07-23 19:08:07.129 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 19:08:07.142 [I] [client/service.go:308] try to connect to server... +2026-07-23 19:08:07.302 [I] [client/service.go:328] [93745a8de846e221] login to server success, get run id [93745a8de846e221] +2026-07-23 19:10:02.070 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 19:10:02.079 [I] [client/service.go:308] try to connect to server... +2026-07-23 19:10:02.297 [I] [client/service.go:328] [a6203105fbd4c067] login to server success, get run id [a6203105fbd4c067] +2026-07-23 19:19:21.669 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 19:19:21.680 [I] [client/service.go:308] try to connect to server... +2026-07-23 19:19:21.974 [I] [client/service.go:328] [5da2ae4457692b0e] login to server success, get run id [5da2ae4457692b0e] diff --git a/frpc.tmpl b/frpc.tmpl new file mode 100644 index 0000000..ffe8e6f --- /dev/null +++ b/frpc.tmpl @@ -0,0 +1,29 @@ +serverAddr = "{{.ServerAddr}}" +serverPort = {{.ServerPort}} + +[auth] +token = "{{.Token}}" + +[log] +to = "./frpc.log" +level = "{{.LogLevel}}" +maxDays = {{.LogMaxDays}} + +[transport] +tcpMux = {{.TcpMux}} +tcpMuxKeepaliveInterval = {{.TcpMuxKeepalive}} +heartbeatInterval = {{.HeartbeatInterval}} +heartbeatTimeout = {{.HeartbeatTimeout}} +poolCount = {{.PoolCount}} + +[webServer] +addr = "127.0.0.1:7400" + +{{range .Proxies}} +[[proxies]] +name = "{{.Name}}" +type = "{{.Type}}" +localIP = "{{.LocalIP}}" +localPort = {{.LocalPort}} +remotePort = {{.RemotePort}} +{{end}} \ No newline at end of file diff --git a/frpc.toml b/frpc.toml deleted file mode 100644 index 4ffcb92..0000000 --- a/frpc.toml +++ /dev/null @@ -1,18 +0,0 @@ -serverAddr = "frp.whitetop.xyz" -serverPort = 9358 - -[auth] -token = "Lxh10020328" - -[log] -to = "./frpc.log" -level = "info" -maxDays = 3 - -[transport] -tcpMux = true -tcpMuxKeepaliveInterval = 30 -heartbeatInterval = 15 -heartbeatTimeout = 70 -poolCount = 8 - diff --git a/static/index.html b/static/index.html index 4162e71..14db023 100644 --- a/static/index.html +++ b/static/index.html @@ -1,6 +1,7 @@ - + + frpc-console @@ -8,331 +9,251 @@ - + - + - +
- -
-
+ +
+
- -
- -