commit d6ef302b8c5aa8a9be7585931f27c4ead9588d21 Author: lxh2875931338 Date: Mon Jul 27 00:00:27 2026 +0800 首次推送 diff --git a/api.go b/api.go new file mode 100644 index 0000000..13ea49e --- /dev/null +++ b/api.go @@ -0,0 +1,553 @@ +package main + +import ( + "bytes" + "embed" + "encoding/json" + "io" + "io/fs" // 新增 + "net/http" + "os" + "strings" + "text/template" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +//go:embed static/* +var staticFS embed.FS + +func SetupRouter() *gin.Engine { + r := gin.Default() + + staticSubFS, _ := fs.Sub(staticFS, "static") + r.StaticFS("/static", http.FS(staticSubFS)) + + r.GET("/", func(c *gin.Context) { + content, err := staticFS.ReadFile("static/index.html") + if err != nil { + c.String(500, "加载前端页面失败") + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", content) + }) + + r.GET("/ping", func(c *gin.Context) { + c.String(200, "frps-console 后端已启动 🎉") + }) + + api := r.Group("/api") + { + api.GET("/check/users", checkUsersHandler) + api.POST("/register", registerHandler) + api.POST("/login", loginHandler) + + auth := api.Group("/") + auth.Use(AuthMiddleware()) + { + auth.GET("/config", getConfigHandler) + auth.PUT("/config", updateConfigHandler) + + auth.GET("/frps/log", getFrpsLogHandler) + + // frps 特有:客户端列表(从 Dashboard API 读取) + auth.GET("/clients", getClientsHandler) + auth.GET("/proxies", getProxiesHandler) + + auth.POST("/frps/reload", reloadFrpsHandler) + auth.POST("/frps/start", startFrpsHandler) + auth.POST("/frps/stop", stopFrpsHandler) + auth.GET("/frps/status", getFrpsStatusHandler) + + auth.POST("/import/toml", importTomlHandler) + auth.GET("/export/toml", ExportTomlHandler) + + auth.PUT("/user/password", changePasswordHandler) + } + } + + return r +} + +// ============================================================ +// 认证 Handler(与 frpc-console 相同,略) +// ============================================================ + +func checkUsersHandler(c *gin.Context) { + count, err := CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{"hasUsers": count > 0, "count": count}, + }) +} + +func registerHandler(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + + count, err := CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"}) + return + } + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"code": 1, "msg": "已存在管理员账户,请登录"}) + return + } + + if len(req.Username) < 5 { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "用户名至少 5 位"}) + return + } + + if !ValidatePassword(req.Password) { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"}) + return + } + + if err := CreateUser(req.Username, string(hash)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建用户失败: " + err.Error()}) + return + } + + token, err := GenerateJWT(req.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "注册成功", + "data": gin.H{"token": token}, + }) +} + +func loginHandler(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + + user, err := GetUserByUsername(req.Username) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"}) + return + } + + token, err := GenerateJWT(user.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "登录成功", + "data": gin.H{"token": token}, + }) +} + +func changePasswordHandler(c *gin.Context) { + var req struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + + username, exists := c.Get("username") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未登录"}) + return + } + + user, err := GetUserByUsername(username.(string)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "用户不存在"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "当前密码错误"}) + return + } + + if !ValidatePassword(req.NewPassword) { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"}) + return + } + + if err := UpdatePassword(username.(string), string(hash)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新密码失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"}) +} + +// ============================================================ +// 配置 Handler +// ============================================================ + +func getConfigHandler(c *gin.Context) { + cfg, err := GetGlobalConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": cfg}) +} + +func updateConfigHandler(c *gin.Context) { + var cfg GlobalConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + + if err := UpdateGlobalConfig(&cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) + return + } + + if err := GenerateFrpsConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()}) + return + } + + if err := ReloadFrps(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) +} + +// ============================================================ +// 客户端/代理列表(从 frps Dashboard API 读取) +// ============================================================ + +// FrpsDashboardClient frps Dashboard API 返回的客户端信息 +type FrpsDashboardClient struct { + ID int `json:"id"` + Name string `json:"name"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Status string `json:"status"` + ConnTime string `json:"connTime"` +} + +// FrpsDashboardProxy frps Dashboard API 返回的代理信息 +type FrpsDashboardProxy struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LocalAddr string `json:"localAddr"` + RemoteAddr string `json:"remoteAddr"` +} + +func getClientsHandler(c *gin.Context) { + clients, err := fetchClientsFromDashboard() + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": clients}) +} + +func getProxiesHandler(c *gin.Context) { + proxies, err := fetchProxiesFromDashboard() + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": proxies}) +} + +// fetchClientsFromDashboard 从 frps Dashboard API 读取客户端列表 +func fetchClientsFromDashboard() ([]FrpsDashboardClient, error) { + // frps Dashboard 默认在 127.0.0.1:7500,后续可配置 + resp, err := http.Get("http://127.0.0.1:7500/api/v2/clients") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data []FrpsDashboardClient `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Data, nil +} + +// fetchProxiesFromDashboard 从 frps Dashboard API 读取代理列表 +func fetchProxiesFromDashboard() ([]FrpsDashboardProxy, error) { + resp, err := http.Get("http://127.0.0.1:7500/api/v2/proxies") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data []FrpsDashboardProxy `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Data, nil +} + +// ============================================================ +// frps 进程管理 Handler +// ============================================================ + +func reloadFrpsHandler(c *gin.Context) { + if err := GenerateFrpsConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) + return + } + if err := ReloadFrps(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "热加载成功"}) +} + +func startFrpsHandler(c *gin.Context) { + if err := StartFrps(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "启动失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 启动成功"}) +} + +func stopFrpsHandler(c *gin.Context) { + if err := StopFrps(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "停止失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 已停止"}) +} + +func getFrpsStatusHandler(c *gin.Context) { + running, err := GetFrpsStatus() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询状态失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}}) +} + +// ============================================================ +// 导入/导出 TOML +// ============================================================ + +func importTomlHandler(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请选择文件"}) + return + } + + f, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"}) + return + } + defer f.Close() + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(f); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"}) + return + } + + parsed, err := ParseToml(buf.String()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "解析 TOML 失败: " + err.Error()}) + return + } + + cfg := parsed.ToGlobalConfig() + if err := UpdateGlobalConfig(cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) + return + } + + if err := GenerateFrpsConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) + return + } + + if err := ReloadFrps(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "导入成功,但热加载失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "导入成功"}) +} + +func ExportTomlHandler(c *gin.Context) { + cfg, err := GetGlobalConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败: " + err.Error()}) + return + } + + tmpl, err := template.New("frps").Parse(FrpsTemplateContent) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()}) + return + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "渲染模板失败: " + err.Error()}) + return + } + + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=frps.toml") + c.String(http.StatusOK, buf.String()) +} + +// ============================================================ +// 日志读取 +// ============================================================ + +func getFrpsLogHandler(c *gin.Context) { + lines, err := readTailLog("./frps.log", 200) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "lines": []string{}, + "total": 0, + "error": err.Error(), + }, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "lines": lines, + "total": len(lines), + }, + }) +} + +// readTailLog 读取文件末尾 n 行 +func readTailLog(filePath string, n int) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, err + } + fileSize := info.Size() + if fileSize == 0 { + return []string{}, nil + } + + const chunkSize = 4096 + var lines []string + var leftover []byte + offset := fileSize + + for len(lines) < n && offset > 0 { + readSize := chunkSize + if offset < int64(chunkSize) { + readSize = int(offset) + } + offset -= int64(readSize) + + buf := make([]byte, readSize) + _, err := file.ReadAt(buf, offset) + if err != nil && err != io.EOF { + return nil, err + } + + data := append(buf, leftover...) + leftover = nil + + start := 0 + for i := len(data) - 1; i >= 0; i-- { + if data[i] == '\n' { + if i+1 < len(data) { + line := string(data[i+1:]) + if line != "" { + lines = append([]string{line}, lines...) + if len(lines) >= n { + break + } + } + } + start = i + } + } + + if len(lines) < n && start > 0 { + leftover = data[:start] + } + } + + if len(lines) < n && len(leftover) > 0 { + parts := strings.Split(string(leftover), "\n") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + lines = append([]string{parts[i]}, lines...) + if len(lines) >= n { + break + } + } + } + } + + return lines, nil +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..5b46007 --- /dev/null +++ b/auth.go @@ -0,0 +1,142 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecretCache []byte + +func getJwtSecret() []byte { + if len(jwtSecretCache) > 0 { + return jwtSecretCache + } + secret, err := GetJwtSecret() + if err != nil { + log.Fatal("❌ 获取 JWT 密钥失败:", err) + } + jwtSecretCache = []byte(secret) + return jwtSecretCache +} + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +func GenerateJWT(username string) (string, error) { + claims := Claims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getJwtSecret()) +} + +func ParseJWT(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return getJwtSecret(), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} + +func ValidatePassword(pwd string) bool { + if len(pwd) < 8 { + return false + } + var hasUpper, hasLower, hasDigit, hasSpecial bool + for _, ch := range pwd { + if ch >= 'A' && ch <= 'Z' { + hasUpper = true + } else if ch >= 'a' && ch <= 'z' { + hasLower = true + } else if ch >= '0' && ch <= '9' { + hasDigit = true + } else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") { + hasSpecial = true + } + } + return hasUpper && hasLower && hasDigit && hasSpecial +} + +func InitAdminUser() { + count, err := CountUsers() + if err != nil { + log.Fatal("❌ 检查用户表失败:", err) + } + if count > 0 { + log.Println("✅ 已存在管理员账户,跳过初始化") + return + } + log.Println("⚠️ 首次启动,请设置管理员账户") + log.Print("用户名: ") + var username string + fmt.Scanln(&username) + if username == "" { + username = "admin" + } + for { + log.Print("密码 (至少8位,含大小写、数字、特殊字符): ") + var password string + fmt.Scanln(&password) + if ValidatePassword(password) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Println("❌ 密码加密失败:", err) + continue + } + err = CreateUser(username, string(hash)) + if err != nil { + log.Println("❌ 创建用户失败:", err) + continue + } + log.Println("✅ 管理员账户创建成功!") + break + } else { + log.Println("❌ 密码不符合复杂度要求,请重新输入") + log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符") + } + } +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未提供认证令牌"}) + c.Abort() + return + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"}) + c.Abort() + return + } + claims, err := ParseJWT(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"}) + c.Abort() + return + } + c.Set("username", claims.Username) + c.Next() + } +} diff --git a/bin/frps_linux_amd64 b/bin/frps_linux_amd64 new file mode 100644 index 0000000..6e07ddd Binary files /dev/null and b/bin/frps_linux_amd64 differ diff --git a/bin/frps_linux_arm64 b/bin/frps_linux_arm64 new file mode 100644 index 0000000..2f2fcae Binary files /dev/null and b/bin/frps_linux_arm64 differ diff --git a/bin/frps_linux_arm_hf b/bin/frps_linux_arm_hf new file mode 100644 index 0000000..0979b35 Binary files /dev/null and b/bin/frps_linux_arm_hf differ diff --git a/bin/frps_windows_amd64.exe b/bin/frps_windows_amd64.exe new file mode 100644 index 0000000..1990bc3 Binary files /dev/null and b/bin/frps_windows_amd64.exe differ diff --git a/build.exe b/build.exe new file mode 100644 index 0000000..2fc2804 Binary files /dev/null and b/build.exe differ diff --git a/build/frps-console-linux-amd64 b/build/frps-console-linux-amd64 new file mode 100644 index 0000000..c182aad Binary files /dev/null and b/build/frps-console-linux-amd64 differ diff --git a/build/frps-console-linux-amd64.tar.gz b/build/frps-console-linux-amd64.tar.gz new file mode 100644 index 0000000..72687a3 Binary files /dev/null and b/build/frps-console-linux-amd64.tar.gz differ diff --git a/db.go b/db.go new file mode 100644 index 0000000..c9fce2d --- /dev/null +++ b/db.go @@ -0,0 +1,410 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "time" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +// ============================================================ +// 数据库版本常量 +// ============================================================ + +const SchemaVersion = "2.0.0" + +// ============================================================ +// 数据模型 +// ============================================================ + +// GlobalConfig frps 全局配置 +type GlobalConfig struct { + ID int `json:"id"` + BindPort int `json:"bindPort"` + Token string `json:"token"` + LogLevel string `json:"logLevel"` + LogMaxDays int `json:"logMaxDays"` + TcpMux bool `json:"tcpMux"` +} + +// Client 客户端连接记录 +type Client struct { + ID int `json:"id"` + Name string `json:"name"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Status string `json:"status"` + ConnTime string `json:"connTime"` + UpdatedAt string `json:"updatedAt"` +} + +// User 用户表 +type User struct { + ID int `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt string `json:"createdAt"` +} + +// ============================================================ +// 数据库初始化 +// ============================================================ + +func InitDB() error { + var err error + DB, err = sql.Open("sqlite", "./frps-console.db") + if err != nil { + return err + } + + if err := createTables(); err != nil { + return err + } + + if err := runMigrations(); err != nil { + return err + } + + if err := ensureJwtSecret(); err != nil { + return err + } + + log.Println("✅ 数据库初始化完成 (Schema v" + SchemaVersion + ")") + return nil +} + +// ============================================================ +// 建表 +// ============================================================ + +func createTables() error { + // 用户表 + _, err := DB.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 全局配置表(frps 专用) + _, err = DB.Exec(` + CREATE TABLE IF NOT EXISTS global_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + bind_port INTEGER NOT NULL DEFAULT 9358, + token TEXT NOT NULL DEFAULT 'CHANGE_ME', + log_level TEXT NOT NULL DEFAULT 'info', + log_max_days INTEGER NOT NULL DEFAULT 3, + tcp_mux INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 客户端记录表 + _, err = DB.Exec(` + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + os TEXT, + arch TEXT, + version TEXT, + status TEXT, + conn_time DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 应用配置表 + _, 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 + } + + // 初始化默认配置 + var count int + DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) + if count == 0 { + _, err = DB.Exec(` + INSERT INTO global_config ( + id, bind_port, token, log_level, log_max_days, tcp_mux + ) VALUES (1, 9358, 'CHANGE_ME', 'info', 3, 1) + `) + if err != nil { + return err + } + log.Println("✅ 全局配置初始化完成") + } + + return nil +} + +// ============================================================ +// 迁移引擎 +// ============================================================ + +func getSchemaVersion() string { + var version string + err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'schema_version'").Scan(&version) + if err != nil { + if err == sql.ErrNoRows { + var count int + DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count > 0 { + return "1.0.0" + } + return SchemaVersion + } + return "1.0.0" + } + return version +} + +func setSchemaVersion(version string) error { + _, err := DB.Exec(` + INSERT INTO app_config (key, value) VALUES ('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + `, version) + return err +} + +func backupDatabase() (string, error) { + src := "./frps-console.db" + if _, err := os.Stat(src); os.IsNotExist(err) { + return "", nil + } + + timestamp := time.Now().Format("20060102_150405") + dst := fmt.Sprintf("./frps-console.db.pre-v%s.%s", SchemaVersion, timestamp) + + srcFile, err := os.Open(src) + if err != nil { + return "", fmt.Errorf("打开源数据库失败: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return "", fmt.Errorf("创建备份文件失败: %w", err) + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return "", fmt.Errorf("复制数据库失败: %w", err) + } + + log.Printf("✅ 数据库备份完成: %s", dst) + return dst, nil +} + +func restoreDatabase(backupPath string) error { + srcFile, err := os.Open(backupPath) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create("./frps-console.db") + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + + log.Printf("✅ 数据库已从备份恢复: %s", backupPath) + return nil +} + +func runMigrations() error { + currentVersion := getSchemaVersion() + log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVersion, SchemaVersion) + + if currentVersion != SchemaVersion { + log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVersion, SchemaVersion) + + backupPath, err := backupDatabase() + if err != nil { + return fmt.Errorf("备份数据库失败: %w", err) + } + if backupPath != "" { + log.Printf("📦 备份文件: %s", backupPath) + } + + if err := setSchemaVersion(SchemaVersion); err != nil { + return fmt.Errorf("更新 Schema 版本失败: %w", err) + } + + log.Printf("✅ 版本迁移完成") + } else { + log.Println("✅ Schema 已是最新,无需迁移") + } + + return nil +} + +// ============================================================ +// JWT 密钥管理 +// ============================================================ + +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 + } + + 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 +} + +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, bind_port, token, log_level, log_max_days, tcp_mux + FROM global_config WHERE id = 1 + `).Scan( + &cfg.ID, &cfg.BindPort, &cfg.Token, + &cfg.LogLevel, &cfg.LogMaxDays, + &cfg.TcpMux, + ) + if err != nil { + return nil, err + } + cfg.TcpMux = true + return &cfg, nil +} + +func UpdateGlobalConfig(cfg *GlobalConfig) error { + _, err := DB.Exec(` + UPDATE global_config SET + bind_port = ?, token = ?, log_level = ?, log_max_days = ?, + tcp_mux = 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, cfg.BindPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays) + return err +} + +// ============================================================ +// 客户端记录 CRUD +// ============================================================ + +func SaveClient(client *Client) error { + _, err := DB.Exec(` + INSERT INTO clients (name, os, arch, version, status, conn_time) + VALUES (?, ?, ?, ?, ?, ?) + `, client.Name, client.OS, client.Arch, client.Version, client.Status, client.ConnTime) + return err +} + +func GetClients() ([]Client, error) { + rows, err := DB.Query(` + SELECT id, name, os, arch, version, status, conn_time, updated_at + FROM clients ORDER BY id DESC LIMIT 100 + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var clients []Client + for rows.Next() { + var c Client + err := rows.Scan(&c.ID, &c.Name, &c.OS, &c.Arch, &c.Version, &c.Status, &c.ConnTime, &c.UpdatedAt) + if err != nil { + return nil, err + } + clients = append(clients, c) + } + return clients, rows.Err() +} + +// ============================================================ +// 用户 CRUD +// ============================================================ + +func GetUserByUsername(username string) (*User, error) { + var u User + err := DB.QueryRow(` + SELECT id, username, password_hash, created_at + FROM users WHERE username = ? + `, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func CreateUser(username, passwordHash string) error { + _, err := DB.Exec(` + INSERT INTO users (username, password_hash) VALUES (?, ?) + `, username, passwordHash) + return err +} + +func UpdatePassword(username, passwordHash string) error { + _, err := DB.Exec(` + UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE username = ? + `, passwordHash, username) + return err +} + +func CountUsers() (int, error) { + var count int + err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..e18ab92 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,493 @@ +#!/bin/bash + +# ============================================================ +# frps-console 一键部署脚本 +# 支持:Linux x86_64 / ARM64 / ARMv7 +# 自动安装:git / curl / wget / Go / Docker +# +# 用法: +# ./deploy.sh # 完整交互流程 +# ./deploy.sh --yes # 跳过确认,直接执行 +# ./deploy.sh --check # 只检测环境,不执行 +# ./deploy.sh --dry-run # 显示将执行的操作,不实际执行 +# ============================================================ + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' + +REPO_URL="https://git.whitetop.xyz/lxh2875931338/frps-console.git" +BRANCH="main" +WORK_DIR="/tmp/frps-console-build" +DEFAULT_PORT=9365 +DEFAULT_DEPLOY_DIR="/opt/frps-console" +IMAGE_NAME="frps-console" +GO_VERSION="1.25.0" + +# 状态变量 +OS="" +OS_VERSION="" +ARCH="" +GO_ARCH="" +HAS_GIT=false +HAS_CURL=false +HAS_WGET=false +HAS_GO=false +HAS_DOCKER=false +NEED_INSTALL_GIT=false +NEED_INSTALL_CURL=false +NEED_INSTALL_WGET=false +NEED_INSTALL_GO=false +PORT=${DEFAULT_PORT} +DEPLOY_DIR=${DEFAULT_DEPLOY_DIR} +DATA_DIR="${DEPLOY_DIR}/data" +CONTAINER_EXISTS=false +CONTAINER_RUNNING=false +SKIP_CONFIRM=false +CHECK_ONLY=false +DRY_RUN=false + +# ---------- 打印函数 ---------- +print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +print_success() { echo -e "${GREEN}[✓]${NC} $1"; } +print_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; } +print_error() { echo -e "${RED}[✗]${NC} $1"; } +print_step() { echo -e "\n${CYAN}▶${NC} $1"; } +print_title() { echo -e "\n${MAGENTA}════════════════════════════════════════════════════════${NC}"; } +print_subtitle() { echo -e "${MAGENTA} $1${NC}"; } + +# ---------- 解析参数 ---------- +parse_args() { + for arg in "$@"; do + case $arg in + --yes|-y) SKIP_CONFIRM=true ;; + --check) CHECK_ONLY=true ;; + --dry-run) DRY_RUN=true ;; + --help|-h) + echo "用法: ./deploy.sh [选项]" + echo "" + echo "选项:" + echo " --yes, -y 跳过所有确认提示,直接执行" + echo " --check 只检测环境,不执行部署" + echo " --dry-run 显示将执行的操作,不实际执行" + echo " --help, -h 显示帮助信息" + exit 0 + ;; + *) print_error "未知选项: $arg"; exit 1 ;; + esac + done +} + +# ---------- 检查 root ---------- +check_root() { + if [ "$EUID" -ne 0 ]; then + print_error "请使用 root 权限运行此脚本" + echo " sudo bash deploy.sh" + exit 1 + fi +} + +# ---------- 检测环境 ---------- +detect_os() { + if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + OS_VERSION=$VERSION_ID + else + print_error "无法识别操作系统" + exit 1 + fi +} + +detect_arch() { + ARCH=$(uname -m) + case $ARCH in + x86_64|amd64) GO_ARCH="amd64" ;; + aarch64|arm64) GO_ARCH="arm64" ;; + armv7l|armhf) GO_ARCH="armv6l" ;; + *) print_error "不支持的 CPU 架构: $ARCH"; exit 1 ;; + esac +} + +check_tools() { + command -v git &> /dev/null && HAS_GIT=true || NEED_INSTALL_GIT=true + command -v curl &> /dev/null && HAS_CURL=true || NEED_INSTALL_CURL=true + command -v wget &> /dev/null && HAS_WGET=true || NEED_INSTALL_WGET=true + command -v go &> /dev/null && HAS_GO=true || NEED_INSTALL_GO=true + command -v docker &> /dev/null && HAS_DOCKER=true +} + +check_container() { + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then + CONTAINER_EXISTS=true + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then + CONTAINER_RUNNING=true + fi + fi +} + +# ---------- 打印环境摘要 ---------- +print_environment_summary() { + print_title + print_subtitle "环境检测结果" + echo "" + echo -e " ${CYAN}操作系统:${NC} $OS $OS_VERSION" + echo -e " ${CYAN}CPU 架构:${NC} $ARCH → Go 架构: $GO_ARCH" + echo "" + echo " ${CYAN}必要工具:${NC}" + if [ "$HAS_GIT" = true ]; then + echo -e " git ✅ 已安装 ($(git --version | awk '{print $3}'))" + else + echo " git ❌ 未安装 (将自动安装)" + fi + if [ "$HAS_CURL" = true ]; then + echo " curl ✅ 已安装" + else + echo " curl ❌ 未安装 (将自动安装)" + fi + if [ "$HAS_WGET" = true ]; then + echo " wget ✅ 已安装" + else + echo " wget ❌ 未安装 (将自动安装)" + fi + echo "" + echo " ${CYAN}Go 环境:${NC}" + if [ "$HAS_GO" = true ]; then + echo -e " go ✅ 已安装 ($(go version | awk '{print $3}'))" + else + echo " go ❌ 未安装 (将自动安装 Go ${GO_VERSION})" + fi + echo "" + echo " ${CYAN}Docker 环境:${NC}" + if [ "$HAS_DOCKER" = true ]; then + echo -e " docker ✅ 已安装 ($(docker --version | awk '{print $3}' | tr -d ','))" + else + echo " docker ❌ 未安装" + print_error "Docker 未安装,请先安装 Docker" + echo "" + echo " 快速安装:" + echo " curl -fsSL https://get.docker.com | bash" + echo " systemctl enable --now docker" + exit 1 + fi + if [ "$CONTAINER_EXISTS" = true ]; then + echo "" + echo " ${CYAN}容器状态:${NC}" + if [ "$CONTAINER_RUNNING" = true ]; then + echo -e " frps-console ✅ 运行中" + else + echo -e " frps-console ⏸️ 已存在但未运行" + fi + fi + echo "" +} + +# ---------- 生成部署计划 ---------- +generate_plan() { + PLAN="" + if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then + local pkgs="" + [ "$NEED_INSTALL_GIT" = true ] && pkgs="${pkgs} git" + [ "$NEED_INSTALL_CURL" = true ] && pkgs="${pkgs} curl" + [ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget" + PLAN="${PLAN} • 安装必要工具:${pkgs}\n" + fi + if [ "$NEED_INSTALL_GO" = true ]; then + PLAN="${PLAN} • 安装 Go ${GO_VERSION}\n" + fi + PLAN="${PLAN} • 拉取 frps-console 源码 (${BRANCH} 分支)\n" + PLAN="${PLAN} • 编译 frps-console 二进制\n" + PLAN="${PLAN} • 构建 Docker 镜像\n" + if [ "$CONTAINER_EXISTS" = true ]; then + PLAN="${PLAN} • 停止并删除旧容器\n" + fi + PLAN="${PLAN} • 启动 frps-console 容器 (端口 ${PORT})" +} + +print_deployment_plan() { + print_title + print_subtitle "部署计划" + echo "" + echo -e " ${CYAN}将执行以下操作:${NC}" + echo -e "$(echo -e "$PLAN")" + echo "" + echo -e " ${CYAN}配置信息:${NC}" + echo -e " ────────────────────────────────────" + echo -e " 监听端口 : ${PORT}" + echo -e " 部署目录 : ${DEPLOY_DIR}" + echo -e " 数据目录 : ${DATA_DIR}" + echo -e " ────────────────────────────────────" + if [ "$CONTAINER_EXISTS" = true ]; then + echo "" + echo -e " ${YELLOW}⚠ 检测到已存在的 frps-console 容器${NC}" + if [ "$CONTAINER_RUNNING" = true ]; then + echo -e " 状态: 运行中 → 将被停止并重新创建" + else + echo -e " 状态: 已停止 → 将被删除并重新创建" + fi + echo -e " ${YELLOW}数据目录中的数据库文件将被保留${NC}" + fi + echo "" +} + +# ---------- 确认与自定义 ---------- +confirm_deploy() { + if [ "$SKIP_CONFIRM" = true ]; then + echo -e "${GREEN}▶ 已启用 --yes,自动确认${NC}" + return 0 + fi + echo -e -n "${CYAN}确认执行? 输入 Y 继续,输入 n 自定义配置 [Y/n]: ${NC}" + read -r CONFIRM /etc/profile.d/go.sh + /usr/local/go/bin/go env -w GOPROXY=https://goproxy.cn,direct + /usr/local/go/bin/go env -w GOPRIVATE=git.whitetop.xyz + print_success "Go ${GO_VERSION} 安装完成" + fi + + export PATH=$PATH:/usr/local/go/bin + + # 拉取代码 + print_step "拉取代码..." + rm -rf "$WORK_DIR" + git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$WORK_DIR" + cd "$WORK_DIR" + mkdir -p bin static + print_success "代码拉取完成" + + # 修复 go.mod + if grep -q "go 1.2[6-9]" go.mod 2>/dev/null; then + print_warn "检测到 go.mod 版本过高,自动降级到 go 1.21" + sed -i 's/go 1.2[6-9].*/go 1.21/' go.mod + fi + + # 下载依赖 + print_step "下载 Go 依赖..." + go mod download + print_success "依赖下载完成" + + # 编译 + print_step "编译 frps-console..." + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o frps-console . + if [ -f "frps-console" ]; then + SIZE=$(du -h frps-console | cut -f1) + print_success "编译完成 ($SIZE)" + else + print_error "编译失败" + exit 1 + fi + + # 准备目录 + print_step "准备部署目录..." + mkdir -p "$DEPLOY_DIR" + mkdir -p "$DATA_DIR" + if [ ! -f "$DATA_DIR/frps-console.db" ]; then + touch "$DATA_DIR/frps-console.db" + print_info "新数据目录已创建" + else + print_info "已有数据目录,保留现有数据" + fi + cp frps-console "$DEPLOY_DIR/" + print_success "二进制已复制到 $DEPLOY_DIR" + + # 构建镜像 + print_step "构建 Docker 镜像..." + docker build -t "${IMAGE_NAME}:latest" . + print_success "Docker 镜像构建完成" + + # 处理旧容器 + if [ "$CONTAINER_EXISTS" = true ]; then + print_step "处理旧容器..." + if [ "$CONTAINER_RUNNING" = true ]; then + docker stop frps-console 2>/dev/null || true + fi + docker rm frps-console 2>/dev/null || true + print_success "旧容器已清理" + fi + + # 启动容器 + print_step "启动 frps-console 容器..." + docker run -d \ + --name frps-console \ + --restart=always \ + --network host \ + -v ${DATA_DIR}:/app/data \ + -e PORT=${PORT} \ + -e TZ=Asia/Shanghai \ + ${IMAGE_NAME}:latest + + if docker ps | grep -q frps-console; then + print_success "容器启动成功!" + else + print_error "容器启动失败,请检查日志: docker logs frps-console" + exit 1 + fi + + # 检查 frps 子进程 + print_step "检查 frps 状态..." + sleep 3 + if docker exec frps-console ps aux 2>/dev/null | grep -q "[f]rps -c"; then + print_success "frps 进程运行正常" + else + print_warn "frps 进程未运行(可能配置为空,请在 WebUI 中导入 TOML)" + fi + + # 清理 + print_step "清理临时文件..." + rm -rf "$WORK_DIR" + print_success "临时文件已清理" + + print_title + print_success "frps-console 部署完成!" + print_title + echo "" + echo -e " ${CYAN}📍 访问地址:${NC} http://$(hostname -I | awk '{print $1}'):${PORT}" + echo -e " ${CYAN}📂 数据目录:${NC} ${DATA_DIR}" + echo -e " ${CYAN}🐳 容器名称:${NC} frps-console" + echo "" + echo -e " ${CYAN}常用命令:${NC}" + echo " docker logs frps-console # 查看日志" + echo " docker restart frps-console # 重启服务" + echo " docker stop frps-console # 停止服务" + echo " docker start frps-console # 启动服务" + echo "" + echo -e " ${YELLOW}首次访问需要注册管理员账户${NC}" + echo "" + print_title +} + +# ---------- 主流程 ---------- +main() { + clear 2>/dev/null || true + parse_args "$@" + check_root + + print_step "检测环境..." + detect_os + detect_arch + check_tools + check_container + + print_environment_summary + + if [ "$CHECK_ONLY" = true ]; then + print_info "环境检测完成(--check 模式)" + exit 0 + fi + + if [ "$HAS_DOCKER" = false ]; then + print_error "Docker 未安装,请先安装 Docker" + echo "" + echo " 快速安装:" + echo " curl -fsSL https://get.docker.com | bash" + echo " systemctl enable --now docker" + exit 1 + fi + + generate_plan + print_deployment_plan + + if ! confirm_deploy; then + custom_config + print_deployment_plan + if ! confirm_deploy; then + print_info "已取消部署" + exit 0 + fi + fi + + if [ "$DRY_RUN" = true ]; then + print_info "演练模式(--dry-run),不实际执行部署" + exit 0 + fi + + do_deploy +} + +main "$@" \ No newline at end of file diff --git a/frps-unix.go b/frps-unix.go new file mode 100644 index 0000000..ebbe825 --- /dev/null +++ b/frps-unix.go @@ -0,0 +1,16 @@ +//go:build linux || darwin || freebsd || netbsd || openbsd || solaris + +package main + +import ( + "os/exec" + "syscall" +) + +// setSysProcAttr 为 Unix 系统设置 Setsid,让 frpc 进程脱离父进程独立运行 +func setSysProcAttr(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setsid = true +} diff --git a/frps.go b/frps.go new file mode 100644 index 0000000..fc89bb6 --- /dev/null +++ b/frps.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "text/template" +) + +//go:embed bin/* +var embeddedFrps embed.FS + +//go:embed frps.tmpl +var FrpsTemplateContent string + +var ( + cachedFrpsPath string + frpsPathMutex sync.Mutex +) + +// ============================================================ +// 获取 frps 路径 +// ============================================================ + +func getFrpsPath() (string, error) { + frpsPathMutex.Lock() + defer frpsPathMutex.Unlock() + + if cachedFrpsPath != "" { + if _, err := os.Stat(cachedFrpsPath); err == nil { + return cachedFrpsPath, nil + } + cachedFrpsPath = "" + } + + var fileName string + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + fileName = "frps_windows_amd64.exe" + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + fileName = "frps_linux_amd64" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + fileName = "frps_linux_arm64" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm": + fileName = "frps_linux_arm_hf" + default: + path, err := exec.LookPath("frps") + if err == nil { + cachedFrpsPath = path + return path, nil + } + return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + localPath := filepath.Join(".", "bin", fileName) + if _, err := os.Stat(localPath); err == nil { + cachedFrpsPath = localPath + return localPath, nil + } + + data, err := embeddedFrps.ReadFile("bin/" + fileName) + if err == nil { + tmpPath := filepath.Join(os.TempDir(), "frps") + if runtime.GOOS == "windows" { + tmpPath += ".exe" + } + if err := os.WriteFile(tmpPath, data, 0755); err == nil { + cachedFrpsPath = tmpPath + return tmpPath, nil + } + if _, statErr := os.Stat(tmpPath); statErr == nil { + cachedFrpsPath = tmpPath + return tmpPath, nil + } + } + + path, err := exec.LookPath("frps") + if err == nil { + cachedFrpsPath = path + return path, nil + } + + return "", fmt.Errorf("未找到 frps 文件") +} + +// ============================================================ +// 生成 frps.toml +// ============================================================ + +func GenerateFrpsConfig() error { + cfg, err := GetGlobalConfig() + if err != nil { + return fmt.Errorf("读取全局配置失败: %w", err) + } + + // 直接传递 cfg 即可,模板里能读到 .WireProtocolV2 + var tmplContent string + if _, err := os.Stat("frps.tmpl"); err == nil { + content, readErr := os.ReadFile("frps.tmpl") + if readErr == nil { + tmplContent = string(content) + } else { + tmplContent = FrpsTemplateContent + } + } else { + tmplContent = FrpsTemplateContent + } + + tmpl, err := template.New("frps").Parse(tmplContent) + if err != nil { + return fmt.Errorf("解析模板失败: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg); err != nil { + return fmt.Errorf("渲染模板失败: %w", err) + } + + if err := os.WriteFile("./frps.toml", buf.Bytes(), 0644); err != nil { + return fmt.Errorf("写入配置文件失败: %w", err) + } + + return nil +} + +// ============================================================ +// frps 进程管理 +// ============================================================ + +func isFrpsRunning() bool { + pidData, err := os.ReadFile("./frps.pid") + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return false + } + + if runtime.GOOS == "windows" { + cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid)) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(output), strconv.Itoa(pid)) + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +func StartFrps() error { + frpsPath, err := getFrpsPath() + if err != nil { + return fmt.Errorf("获取 frps 路径失败: %w", err) + } + + if _, err := os.Stat("./frps.toml"); os.IsNotExist(err) { + if err := GenerateFrpsConfig(); err != nil { + return fmt.Errorf("生成配置文件失败: %w", err) + } + } + + if isFrpsRunning() { + return nil + } + + os.Remove("./frps.pid") + + cmd := exec.Command(frpsPath, "-c", "./frps.toml") + setWindowHide(cmd) + setSysProcAttr(cmd) + + logFile, err := os.OpenFile("./frps.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("打开日志文件失败: %w", err) + } + cmd.Stdout = logFile + cmd.Stderr = logFile + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 frps 失败: %w", err) + } + + if err := os.WriteFile("./frps.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { + return fmt.Errorf("保存 PID 失败: %w", err) + } + + return nil +} + +func StopFrps() error { + if runtime.GOOS == "windows" { + cmd := exec.Command("taskkill", "/F", "/IM", "frps.exe") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { + return fmt.Errorf("停止 frps 失败: %w", err) + } + os.Remove("./frps.pid") + return nil + } + + pidData, err := os.ReadFile("./frps.pid") + if err != nil { + cmd := exec.Command("pkill", "-f", "frps") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { + return fmt.Errorf("停止 frps 失败: %w", err) + } + return nil + } + + pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData))) + process, err := os.FindProcess(pid) + if err != nil { + os.Remove("./frps.pid") + return nil + } + + if err := process.Kill(); err != nil { + return fmt.Errorf("杀死进程失败: %w", err) + } + + os.Remove("./frps.pid") + return nil +} + +func GetFrpsStatus() (bool, error) { + return isFrpsRunning(), nil +} + +func ReloadFrps() error { + running := isFrpsRunning() + if !running { + return StartFrps() + } + + frpsPath, err := getFrpsPath() + if err != nil { + return fmt.Errorf("获取 frps 路径失败: %w", err) + } + + cmd := exec.Command(frpsPath, "reload", "-c", "./frps.toml") + _, err = cmd.CombinedOutput() + if err != nil { + log.Printf("⚠️ 热加载失败 (%v),自动降级为重启 frps", err) + if stopErr := StopFrps(); stopErr != nil { + return fmt.Errorf("停止 frps 失败: %w", stopErr) + } + if startErr := StartFrps(); startErr != nil { + return fmt.Errorf("启动 frps 失败: %w", startErr) + } + return nil + } + return nil +} diff --git a/frps.tmpl b/frps.tmpl new file mode 100644 index 0000000..df64659 --- /dev/null +++ b/frps.tmpl @@ -0,0 +1,10 @@ +bindPort = {{.BindPort}} + +auth.token = "{{.Token}}" + +log.to = "./frps.log" +log.level = "{{.LogLevel}}" +log.maxDays = {{.LogMaxDays}} + +[transport] +tcpMux = {{.TcpMux}} \ No newline at end of file diff --git a/frps_other.go b/frps_other.go new file mode 100644 index 0000000..068ef7f --- /dev/null +++ b/frps_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "os/exec" + +func setWindowHide(cmd *exec.Cmd) { + // 非 Windows 平台什么都不做 +} diff --git a/frps_windows.go b/frps_windows.go new file mode 100644 index 0000000..b776362 --- /dev/null +++ b/frps_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +// setWindowHide Windows 隐藏窗口 +func setWindowHide(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} + +// setSysProcAttr Windows 不需要 Setsid,空操作 +func setSysProcAttr(cmd *exec.Cmd) { + // Windows 不需要 Setsid,什么都不做 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f7ce21e --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module frps-console + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.54.0 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b73ae87 --- /dev/null +++ b/go.sum @@ -0,0 +1,137 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a1165f4 --- /dev/null +++ b/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "log" + "time" +) + +func main() { + if err := InitDB(); err != nil { + log.Fatal("❌ 数据库初始化失败:", err) + } + + if err := GenerateFrpsConfig(); err != nil { + log.Println("⚠️ 生成配置文件失败:", err) + } + + if err := StartFrps(); err != nil { + log.Println("⚠️ 启动 frps 失败:", err) + } + + go startWatchdog() + + r := SetupRouter() + log.Println("🚀 frps-console 启动成功!") + log.Println("📍 访问地址: http://localhost:9365") + log.Println("📍 API 地址: http://localhost:9365/api") + + if err := r.Run(":9365"); err != nil { + log.Fatal("❌ 服务启动失败:", err) + } +} + +func startWatchdog() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if !isFrpsRunning() { + log.Println("⚠️ frps 进程已停止,自动重启...") + if err := StartFrps(); err != nil { + log.Printf("❌ 自动重启 frps 失败: %v", err) + } else { + log.Println("✅ frps 已自动重启") + } + } + } +} diff --git a/script/build.go b/script/build.go new file mode 100644 index 0000000..4f7434b --- /dev/null +++ b/script/build.go @@ -0,0 +1,197 @@ +//go:build ignore + +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Platform struct { + OS string + Arch string + Name string + Ext string +} + +var platforms = []Platform{ + {"windows", "amd64", "Windows x86-64", ".exe"}, + {"linux", "amd64", "Linux x86-64", ""}, + {"linux", "arm64", "Linux ARM64", ""}, + {"linux", "arm", "Linux ARMv7l", ""}, +} + +const ( + Version = "2.0.0" + BuildTime = "2026-07-26" + Binary = "frps-console" +) + +func main() { + if len(os.Args) > 1 { + target := os.Args[1] + switch target { + case "all": + buildAll() + return + case "clean": + clean() + return + case "list": + listPlatforms() + return + } + for _, p := range platforms { + if target == p.OS+"/"+p.Arch { + build(p, false) + return + } + } + fmt.Println("❌ 不支持的平台:", target) + fmt.Println(" 可用: windows/amd64, linux/amd64, linux/arm64, linux/arm") + fmt.Println(" 或: all, list, clean") + return + } + interactiveMenu() +} + +func listPlatforms() { + fmt.Println("可用平台:") + for i, p := range platforms { + fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch) + } +} + +func interactiveMenu() { + fmt.Println("========================================") + fmt.Println(" frps-console 多平台构建工具 v" + Version) + fmt.Println("========================================") + fmt.Println() + + for i, p := range platforms { + fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch) + } + fmt.Println(" a. 全部构建") + fmt.Println(" c. 清理构建产物") + fmt.Println(" q. 退出") + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + fmt.Print("请选择: ") + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + switch input { + case "q": + fmt.Println("已退出") + return + case "c": + clean() + return + case "a": + buildAll() + return + default: + var idx int + if n, err := fmt.Sscanf(input, "%d", &idx); n == 1 && err == nil && idx >= 1 && idx <= len(platforms) { + build(platforms[idx-1], false) + return + } + fmt.Println("❌ 无效选择") + interactiveMenu() + } +} + +func buildAll() { + for _, p := range platforms { + build(p, true) + } + fmt.Println("\n✅ 全部构建完成!产物位于 build/ 目录") +} + +func build(p Platform, silent bool) { + if !silent { + fmt.Printf("🔨 构建 %s (%s/%s)...\n", p.Name, p.OS, p.Arch) + } + + outDir := "./build" + if err := os.MkdirAll(outDir, 0755); err != nil { + fmt.Printf("❌ 创建目录失败: %v\n", err) + return + } + + outName := Binary + "-" + p.OS + "-" + p.Arch + if p.Arch == "arm" { + outName += "v7" + } + outName += p.Ext + outPath := filepath.Join(outDir, outName) + + cmd := exec.Command("go", "build", + "-ldflags=-s -w -X main.version="+Version, + "-o", outPath, + ".", + ) + cmd.Env = append(os.Environ(), + "GOOS="+p.OS, + "GOARCH="+p.Arch, + "CGO_ENABLED=0", + ) + + if p.Arch == "arm" { + cmd.Env = append(cmd.Env, "GOARM=7") + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("❌ 构建失败: %v\n", err) + return + } + + if !silent { + fmt.Printf("✅ 构建完成: %s\n", outPath) + if info, err := os.Stat(outPath); err == nil { + size := float64(info.Size()) / 1024 / 1024 + fmt.Printf(" 📦 %.2f MB\n", size) + } + } + + if !silent { + packageBinary(outPath, p) + } +} + +func packageBinary(binPath string, p Platform) { + base := strings.TrimSuffix(filepath.Base(binPath), p.Ext) + dir := filepath.Dir(binPath) + + if p.Ext == ".exe" { + zipName := filepath.Join(dir, base+".zip") + cmd := exec.Command("zip", "-j", zipName, binPath) + if err := cmd.Run(); err == nil { + fmt.Printf(" 📦 已打包: %s\n", zipName) + } + } else { + tarName := filepath.Join(dir, base+".tar.gz") + cmd := exec.Command("tar", "-czf", tarName, "-C", dir, filepath.Base(binPath)) + if err := cmd.Run(); err == nil { + fmt.Printf(" 📦 已打包: %s\n", tarName) + } + } +} + +func clean() { + fmt.Println("🧹 清理构建产物...") + if err := os.RemoveAll("./build"); err != nil { + fmt.Printf("❌ 清理失败: %v\n", err) + return + } + fmt.Println("✅ 清理完成") +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..beec2da --- /dev/null +++ b/static/app.js @@ -0,0 +1,607 @@ +// app.js - 普通脚本(非模块) + +const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = Vue; + +// ============================================================ +// 1. 基础 API +// ============================================================ +const API_BASE = "/api"; + +function getToken() { + return localStorage.getItem("frps_token") || ""; +} + +function getHeaders() { + return { + "Content-Type": "application/json", + Authorization: "Bearer " + getToken(), + }; +} + +async function apiFetch(endpoint, options = {}) { + const url = API_BASE + endpoint; + const headers = + options.body instanceof FormData + ? { Authorization: "Bearer " + getToken() } + : getHeaders(); + const res = await fetch(url, { + ...options, + headers: { ...headers, ...(options.headers || {}) }, + }); + return res.json(); +} + +// ============================================================ +// 2. Auth +// ============================================================ +function parseJwtExpire(token) { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.exp * 1000; + } catch { + return Date.now() + 7 * 24 * 60 * 60 * 1000; + } +} + +function saveAuthState(token, username) { + const expire = parseJwtExpire(token); + localStorage.setItem("frps_token", token); + localStorage.setItem("frps_token_expire", String(expire)); + localStorage.setItem("frps_username", username); + return expire; +} + +function loadAuthState() { + const token = localStorage.getItem("frps_token"); + const expire = parseInt(localStorage.getItem("frps_token_expire") || "0", 10); + const username = localStorage.getItem("frps_username") || ""; + if (token && expire > Date.now()) { + return { token, expire, username, valid: true }; + } + clearAuthState(); + return { token: "", expire: 0, username: "", valid: false }; +} + +function clearAuthState() { + localStorage.removeItem("frps_token"); + localStorage.removeItem("frps_token_expire"); + localStorage.removeItem("frps_username"); +} + +async function login(username, password) { + const data = await apiFetch("/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + if (data.code === 0) { + const token = data.data.token; + const expire = saveAuthState(token, username); + return { success: true, token, expire }; + } + return { success: false, error: data.msg || "登录失败" }; +} + +// ============================================================ +// 3. Config +// ============================================================ +const defaultConfig = { + bindPort: 9358, + token: "CHANGE_ME", + logLevel: "info", + logMaxDays: 3, + tcpMux: true, +}; + +async function loadConfig() { + try { + const data = await apiFetch("/config"); + if (data.code === 0) { + return { ...defaultConfig, ...data.data }; + } + } catch (e) { + console.warn("加载配置失败", e); + } + return { ...defaultConfig }; +} + +async function saveConfigApi(config) { + const data = await apiFetch("/config", { + method: "PUT", + body: JSON.stringify(config), + }); + return data; +} + +// ============================================================ +// 4. Clients +// ============================================================ +async function loadClients() { + try { + const data = await apiFetch("/clients"); + if (data.code === 0) { + return data.data || []; + } + } catch (e) { + console.warn("加载客户端失败", e); + } + return []; +} + +function filterClients(list, keyword) { + if (!keyword) return list; + const kw = keyword.toLowerCase(); + return list.filter( + (c) => + (c.name || "").toLowerCase().includes(kw) || + String(c.id).includes(kw) || + (c.os || "").toLowerCase().includes(kw) + ); +} + +// ============================================================ +// 5. Frps +// ============================================================ +async function getFrpsStatus() { + try { + const data = await apiFetch("/frps/status"); + if (data.code === 0) { + return data.data.running || false; + } + } catch (e) { + console.warn("获取 frps 状态失败", e); + } + return false; +} + +// ============================================================ +// 6. Logs +// ============================================================ +async function fetchLogsApi() { + try { + const data = await apiFetch("/frps/log"); + if (data.code === 0) { + return { lines: data.data.lines || [], total: data.data.total || 0, error: data.data.error || "" }; + } + return { lines: [], total: 0, error: "加载失败" }; + } catch (e) { + return { lines: [], total: 0, error: "网络错误" }; + } +} + +// ============================================================ +// 7. UI State +// ============================================================ +const activeTab = ref("clients"); +const searchKeyword = ref(""); +const showToken = ref(false); + +// ============================================================ +// 8. Vue App +// ============================================================ +const app = createApp({ + setup() { + // ---- 登录态 ---- + const loggedIn = ref(false); + const loading = ref(false); + const loginError = ref(""); + const loginForm = reactive({ username: "", password: "" }); + const token = ref(""); + const tokenExpireTime = ref(0); + const transitioning = ref(false); + const contentVisible = ref(false); + let expireTimer = null; + + // ---- 注册 ---- + const isFirstRun = ref(false); + const registerForm = reactive({ + username: "", + password: "", + confirmPassword: "", + }); + const registerError = ref(""); + const registering = ref(false); + + // ---- 数据 ---- + const globalConfig = reactive({}); + const clients = ref([]); + const clientsLoaded = ref(false); + const frpsRunning = ref(false); + + // ---- 修改密码 ---- + const passwordChange = reactive({ + oldPassword: "", + newPassword: "", + confirmPassword: "", + }); + const passwordChangeError = ref(""); + const passwordChangeSuccess = ref(""); + + // ---- 日志 ---- + const logLines = ref([]); + const logTotal = ref(0); + const logError = ref(""); + const logLastUpdate = ref(""); + const logAutoScroll = ref(true); + let logTimer = null; + let logFetching = false; + + // ---- 初始化 ---- + onMounted(() => { + const saved = loadAuthState(); + if (saved.valid) { + token.value = saved.token; + tokenExpireTime.value = saved.expire; + loginForm.username = saved.username; + loggedIn.value = true; + loadAllData().then(() => { + contentVisible.value = true; + startExpireTimer(); + }); + } else { + checkUsers(); + } + }); + + // ---- Tab 切换监听日志轮询 ---- + watch(activeTab, (newTab) => { + if (newTab === "logs") { + startLogPolling(); + } else { + stopLogPolling(); + } + if (newTab === "clients") { + fetchClients(); + } + }); + + async function checkUsers() { + try { + const res = await fetch("/api/check/users"); + const data = await res.json(); + if (data.code === 0) { + isFirstRun.value = !data.data.hasUsers; + } + } catch (e) { + console.warn("检查用户状态失败", e); + } + } + + function startExpireTimer() { + if (expireTimer) clearInterval(expireTimer); + expireTimer = setInterval(() => { + if (Date.now() >= tokenExpireTime.value) { + doLogout(); + alert("登录已超时,请重新登录"); + } + }, 5000); + } + + async function loadAllData() { + const [cfg, running] = await Promise.all([ + loadConfig(), + getFrpsStatus(), + ]); + Object.assign(globalConfig, cfg); + frpsRunning.value = running; + await fetchClients(); + } + + async function fetchClients() { + clientsLoaded.value = false; + const list = await loadClients(); + clients.value = list; + clientsLoaded.value = true; + } + + // ---- 日志轮询 ---- + async function fetchLogs() { + if (logFetching) return; + logFetching = true; + try { + const result = await fetchLogsApi(); + logLines.value = result.lines; + logTotal.value = result.total; + logError.value = result.error; + logLastUpdate.value = new Date().toLocaleTimeString(); + if (logAutoScroll.value) { + await nextTick(); + const container = document.getElementById("logContainer"); + if (container) { + container.scrollTop = container.scrollHeight; + } + } + } catch (e) { + logError.value = "网络错误"; + } finally { + logFetching = false; + } + } + + function startLogPolling() { + if (logTimer) return; + fetchLogs(); + logTimer = setInterval(fetchLogs, 8000); + } + + function stopLogPolling() { + if (logTimer) { + clearInterval(logTimer); + logTimer = null; + } + } + + function refreshLogs() { + fetchLogs(); + } + + function scrollLogToBottom() { + const container = document.getElementById("logContainer"); + if (container) { + container.scrollTop = container.scrollHeight; + } + } + + // ---- 登录 ---- + const doLogin = async () => { + if (!loginForm.username || !loginForm.password) { + loginError.value = "请输入用户名和密码"; + return; + } + loading.value = true; + loginError.value = ""; + const result = await login(loginForm.username, loginForm.password); + if (result.success) { + token.value = result.token; + tokenExpireTime.value = result.expire; + transitioning.value = true; + loggedIn.value = true; + setTimeout(async () => { + transitioning.value = false; + await loadAllData(); + loading.value = false; + setTimeout(() => { + contentVisible.value = true; + startExpireTimer(); + }, 300); + }, 500); + } else { + loginError.value = result.error; + loading.value = false; + } + }; + + // ---- 注册 ---- + const doRegister = async () => { + if (registerForm.username.length < 5) { + registerError.value = "用户名至少 5 位"; + return; + } + if (registerForm.password !== registerForm.confirmPassword) { + registerError.value = "两次输入的密码不一致"; + return; + } + if (registerForm.password.length < 8) { + registerError.value = "密码至少 8 位"; + return; + } + registering.value = true; + registerError.value = ""; + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: registerForm.username, + password: registerForm.password, + }), + }); + const data = await res.json(); + if (data.code === 0) { + isFirstRun.value = false; + const token = data.data.token; + const expire = saveAuthState(token, registerForm.username); + token.value = token; + tokenExpireTime.value = expire; + loginForm.username = registerForm.username; + loggedIn.value = true; + transitioning.value = true; + setTimeout(async () => { + transitioning.value = false; + await loadAllData(); + setTimeout(() => { + contentVisible.value = true; + startExpireTimer(); + }, 300); + }, 500); + } else { + registerError.value = data.msg || "注册失败"; + } + } catch (e) { + registerError.value = "网络错误,请重试"; + } + registering.value = false; + }; + + // ---- 退出 ---- + const doLogout = () => { + if (expireTimer) clearInterval(expireTimer); + stopLogPolling(); + contentVisible.value = false; + loggedIn.value = false; + token.value = ""; + tokenExpireTime.value = 0; + loginForm.username = ""; + loginForm.password = ""; + loading.value = false; + transitioning.value = false; + clearAuthState(); + isFirstRun.value = false; + registerForm.username = ""; + registerForm.password = ""; + registerForm.confirmPassword = ""; + registerError.value = ""; + registering.value = false; + }; + + // ---- 修改密码 ---- + const changePassword = async () => { + passwordChangeError.value = ""; + passwordChangeSuccess.value = ""; + if (passwordChange.newPassword !== passwordChange.confirmPassword) { + passwordChangeError.value = "两次输入的密码不一致"; + return; + } + if (passwordChange.newPassword.length < 8) { + passwordChangeError.value = "密码至少 8 位"; + return; + } + try { + const res = await fetch("/api/user/password", { + method: "PUT", + headers: getHeaders(), + body: JSON.stringify({ + oldPassword: passwordChange.oldPassword, + newPassword: passwordChange.newPassword, + }), + }); + const data = await res.json(); + if (data.code === 0) { + passwordChangeSuccess.value = "密码修改成功!"; + passwordChange.oldPassword = ""; + passwordChange.newPassword = ""; + passwordChange.confirmPassword = ""; + } else { + passwordChangeError.value = data.msg || "修改失败"; + } + } catch (e) { + passwordChangeError.value = "网络错误,请重试"; + } + }; + + // ---- 保存配置 ---- + const saveConfig = async () => { + const result = await saveConfigApi(globalConfig); + if (result.code === 0) { + frpsRunning.value = await getFrpsStatus(); + alert("配置已更新并热加载"); + } else { + alert("保存失败: " + result.msg); + } + }; + + // ---- 导入导出 ---- + const triggerImport = () => { + document.getElementById("tomlFileInput").click(); + }; + + const handleImport = async (e) => { + const file = e.target.files[0]; + if (!file) return; + const formData = new FormData(); + formData.append("file", file); + try { + const res = await fetch("/api/import/toml", { + method: "POST", + headers: { Authorization: "Bearer " + getToken() }, + body: formData, + }); + const data = await res.json(); + if (data.code === 0) { + alert(data.msg); + await loadAllData(); + } else { + alert("导入失败: " + data.msg); + } + } catch (err) { + alert("网络错误: " + err.message); + } + e.target.value = ""; + }; + + const exportToml = async () => { + try { + const res = await fetch("/api/export/toml", { + method: "GET", + headers: { Authorization: "Bearer " + getToken() }, + }); + if (!res.ok) { + const data = await res.json(); + alert("导出失败: " + (data.msg || "未知错误")); + return; + } + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "frps.toml"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); + } catch (e) { + alert("导出失败: " + e.message); + } + }; + + // ---- 计算属性 ---- + const filteredClients = computed(() => + filterClients(clients.value, searchKeyword.value) + ); + + const showDefaultTip = computed(() => { + const token = globalConfig.token; + return !token || token === "CHANGE_ME" || token.trim() === ""; + }); + + // ---- 返回 ---- + return { + loggedIn, + loading, + loginError, + loginForm, + doLogin, + doLogout, + + isFirstRun, + registerForm, + registerError, + registering, + doRegister, + + activeTab, + globalConfig, + saveConfig, + + clients, + clientsLoaded, + filteredClients, + fetchClients, + frpsRunning, + + searchKeyword, + transitioning, + contentVisible, + + triggerImport, + handleImport, + exportToml, + + passwordChange, + passwordChangeError, + passwordChangeSuccess, + changePassword, + + logLines, + logTotal, + logError, + logLastUpdate, + logAutoScroll, + refreshLogs, + scrollLogToBottom, + + showToken, + showDefaultTip, + }; + }, +}); + +app.mount("#app"); \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..fd7114e --- /dev/null +++ b/static/index.html @@ -0,0 +1,324 @@ + + + + + + + frps-console + + + + + + + + + + + +
+ +
+
+ + +
+ + + + + +
+ + +
+
+ +
+ frps-console + + + {{ frpsRunning ? 'frps 运行中' : 'frps 已停止' }} + +
+
+
+ {{ loginForm.username }} + +
+
+ + +
+ 客户端列表 + 全局配置信息 + 运行日志 +
+ + +
+ + +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ + {{ c.name || c.id }} + {{ c.os }} / {{ c.arch }} + v{{ c.version }} +
+
+ {{ c.status === 'online' ? '在线' : '离线' }} +
+
+ +
+
+ {{ clientsLoaded ? '暂无客户端连接' : '加载中...' }} +
+
+
+ + +
+ +
+

全局配置信息

+

管理 frps 服务端参数与传输设置

+
+ + +
+ 首次使用请修改「认证令牌」为您的真实 frps 配置 +
+ + +
+
+ 账户管理 + {{ loginForm.username }} +
+
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+ + +
+
+ 服务器连接 +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ 传输配置 +
+
+ +
+ +
+ + 已强制开启 + 优化连接性能,减少延迟,frp 官方推荐开启 +
+
+ +
+ +
+ + 已就绪 + frps 已支持 v2 协议,请在 frpc 侧开启 +
+ + frps 已内置 v2 协议支持,无需额外配置。如需启用,请在 frpc 配置中设置 transport.wireProtocol = "v2" + +
+
+
+ + +
+
+ 日志配置 +
+
+
+ + +
+
+ + +
+
+
+ + + +
+ + +
+ +
+
+ 共 {{ logTotal }} 行 + {{ logError }} +
+
+ + + +
+
+ + +
+
+ 暂无日志,frps 尚未产生输出 +
+
+
{{ line }}
+
+
+ + + +
+ +
+
+
+ + + +
+ + + + + \ No newline at end of file diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..b9ff8d4 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/style-1.css b/static/style-1.css new file mode 100644 index 0000000..38eec5f --- /dev/null +++ b/static/style-1.css @@ -0,0 +1,184 @@ +/* ===== style-1.css - 全局基础 ===== */ + +/* ---------- 字体定义 ---------- */ +@font-face { + font-family: 'HarmonyOS Sans SC'; + src: local('HarmonyOS Sans SC'), + local('PingFang SC'), + local('Microsoft YaHei'), + local('Helvetica Neue'); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'HarmonyOS Sans SC'; + src: local('HarmonyOS Sans SC Medium'), + local('PingFang SC Medium'); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: 'HarmonyOS Sans SC'; + src: local('HarmonyOS Sans SC Bold'), + local('PingFang SC Semibold'); + font-weight: 700; + font-display: swap; +} + +/* ---------- 重置 ---------- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: #0a0a0f; + height: 100vh; + overflow: hidden; + color: #e0e0e0; +} + +/* ---------- 数字专用字体(加粗) ---------- */ +.digit, +.big-number, +.remote-port, +.proxy-addr, +.metric-value .digit, +.metric-value .big-number, +.login-header h1, +.login-btn, +.save-btn, +.btn-confirm { + font-weight: 600 !important; + letter-spacing: 0.02em; +} + +/* 数字特别加粗(适用于大数字展示) */ +.big-number { + font-weight: 700 !important; + letter-spacing: -0.01em; +} + +/* 代码/端口类数字使用等宽字体,但保持加粗 */ +.digit, +.remote-port, +.proxy-addr { + font-family: 'HarmonyOS Sans SC', 'JetBrains Mono', monospace; + font-weight: 600 !important; +} + +/* ---------- 全局颜色变量 ---------- */ +:root { + --primary-blue: #4a7cff; + --primary-blue-dim: rgba(74, 124, 255, 0.15); + --primary-blue-glow: rgba(74, 124, 255, 0.3); + --primary-blue-border: rgba(74, 124, 255, 0.2); + --bg-deep: #0a0a18; + --bg-card: rgba(13, 13, 26, 0.6); + --bg-metric: rgba(255, 255, 255, 0.03); + --border-subtle: rgba(255, 255, 255, 0.04); + --text-dim: rgba(255, 255, 255, 0.25); + --text-muted: rgba(255, 255, 255, 0.35); + --text-secondary: rgba(255, 255, 255, 0.6); + --text-primary: rgba(255, 255, 255, 0.85); +} + +/* ---------- 滚动条 ---------- */ +::-webkit-scrollbar { + width: 4px; + height: 4px; +} +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; +} +::-webkit-scrollbar-thumb { + background: rgba(74, 124, 255, 0.25); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(74, 124, 255, 0.4); +} + +/* ---------- 背景磨砂层 ---------- */ +.app-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: + radial-gradient(ellipse at 20% 50%, rgba(30, 60, 120, 0.3) 0%, transparent 60%), + radial-gradient(ellipse at 80% 50%, rgba(80, 30, 120, 0.2) 0%, transparent 60%), + #0a0a0f; + z-index: 0; +} + +/* ---------- 主容器 ---------- */ +.app-container { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(40px) saturate(1.2); + -webkit-backdrop-filter: blur(40px) saturate(1.2); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 32px; + box-shadow: 0 40px 80px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1); + overflow: hidden; +} + +/* 登录态尺寸 */ +.app-container:not(.is-logged-in) { + width: 600px; + height: 500px; +} + +/* 登录后尺寸 */ +.app-container.is-logged-in { + width: min(1600px, 92vw); + height: min(900px, 88vh); +} + +/* ---------- 旋转圈 ---------- */ +.loading-ring { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + border: 3px solid rgba(255, 255, 255, 0.04); + border-top: 3px solid var(--primary-blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 5; +} +.loading-ring.visible { + opacity: 1; +} +@keyframes spin { + to { transform: translate(-50%, -50%) rotate(360deg); } +} + +/* ---------- 主内容淡入 ---------- */ +.main-panel { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding: 20px 28px; /* ← 补上内边距 */ + opacity: 0; + transition: opacity 0.3s ease; +} +.main-panel.visible { + opacity: 1; +} \ No newline at end of file diff --git a/static/style-2.css b/static/style-2.css new file mode 100644 index 0000000..8eb3728 --- /dev/null +++ b/static/style-2.css @@ -0,0 +1,130 @@ +/* ===== style-2.css - 登录页 ===== */ + +.login-card { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 32px 48px 48px 48px; /* 上左右不变,下边距加大 */ +} + +.login-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} +.login-icon { + font-size: 28px; +} +.login-header h1 { + font-size: 22px; + font-weight: 300; + letter-spacing: 4px; + color: var(--text-primary); + text-transform: lowercase; +} + +.login-form { + width: 100%; + max-width: 340px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.input-group label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--text-dim); +} +.input-group input { + height: 44px; + padding: 0 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; +} +.input-group input:focus { + border-color: var(--primary-blue-border); +} +.input-group input::placeholder { + color: var(--text-dim); +} + +.login-btn { + height: 44px; + margin-top: 6px; + background: linear-gradient(135deg, var(--primary-blue), #2a5adf); + border: none; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + color: #0a0a0f; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} +.login-btn:hover:not(:disabled) { + transform: scale(1.02); + box-shadow: 0 8px 24px var(--primary-blue-glow); +} +.login-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.login-error { + color: #f87171; + font-size: 14px; + text-align: center; + margin-top: 4px; + min-height: 24px; +} +.login-form .hint { + font-size: 12px; + color: var(--text-dim); + text-align: center; + margin-top: -6px; +} + +/* ---------- Logo 区域 ---------- */ +.login-logo { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} +.login-logo .logo-text { + font-size: 20px; + font-weight: 500; + color: var(--text-primary); + letter-spacing: 2px; +} + +.login-logo img { + width: 64px; + height: 64px; + flex-shrink: 0; +} + +/* ---------- 登录页补充优化 ---------- */ +.login-card .login-form { + margin-bottom: 4px; +} + +/* 注册按钮距上方输入框远一点 */ +.login-card .login-form .login-btn { + margin-top: 16px; +} \ No newline at end of file diff --git a/static/style-3.css b/static/style-3.css new file mode 100644 index 0000000..73a3c6b --- /dev/null +++ b/static/style-3.css @@ -0,0 +1,737 @@ +/* ===== style-3.css - 主界面核心 ===== */ + +/* ---------- 顶部导航 ---------- */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.top-left { + display: flex; + align-items: center; + gap: 14px; +} + +.top-left .nav-logo { + width: 48px; + height: 48px; + flex-shrink: 0; + display: block; + object-fit: contain; +} + +.brand-info { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +} + +.brand-info .logo-text { + font-size: 22px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; + letter-spacing: 0.5px; +} + +.status-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.status-wrapper .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + transition: background 0.3s; + flex-shrink: 0; +} + +.status-wrapper .status-dot.active { + background: #63e2b7; + box-shadow: 0 0 12px rgba(99, 226, 183, 0.4); +} + +.status-wrapper .status-text { + font-size: 13px; + color: var(--text-muted); +} + +.top-right { + display: flex; + align-items: center; + gap: 16px; +} + +.user-name { + font-size: 13px; + color: var(--text-secondary); +} + +.logout-btn { + padding: 6px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.logout-btn:hover { + background: rgba(255, 70, 70, 0.15); + border-color: rgba(255, 70, 70, 0.3); + color: #f87171; +} + +/* ---------- Tab 栏 ---------- */ +.tab-bar { + display: flex; + gap: 32px; + padding: 14px 0 12px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-subtle); +} + +.tab-item { + font-size: 14px; + color: var(--text-muted); + cursor: pointer; + padding: 4px 0; + transition: color 0.3s, border-color 0.3s; + border-bottom: 2px solid transparent; +} + +.tab-item:hover { + color: var(--text-secondary); +} + +.tab-item.active { + color: var(--primary-blue); + border-bottom-color: var(--primary-blue); +} + +/* ---------- 内容区 ---------- */ +.content-area { + flex: 1; + overflow-y: auto; + padding-top: 16px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.06) transparent; +} + +.content-area::-webkit-scrollbar { + width: 4px; +} + +.content-area::-webkit-scrollbar-track { + background: transparent; +} + +.content-area::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; +} + +.tab-content { + height: 100%; +} + +/* ---------- 账户管理区域 ---------- */ +.profile-section { + background: rgba(255, 255, 255, 0.02); + border-radius: 16px; + padding: 16px 20px; + border: 1px solid var(--border-subtle); + margin-bottom: 20px; +} + +.profile-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 12px; + color: var(--text-secondary); + font-size: 14px; +} + +.profile-username { + color: var(--primary-blue); + font-weight: 500; +} + +.profile-form { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px 16px; + align-items: end; +} + +.profile-form .form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.profile-form .form-row label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-dim); +} + +.profile-form .form-row input { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; +} + +.profile-form .form-row input:focus { + border-color: var(--primary-blue-border); +} + +.login-success { + color: #63e2b7; + font-size: 14px; + text-align: center; + margin-top: 4px; + min-height: 24px; +} + +/* ---------- 统一保存按钮 ---------- */ +.save-btn, +.save-config-btn { + padding: 10px 24px; + background: linear-gradient(135deg, var(--primary-blue), #2a5adf); + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + color: #0a0a0f; + cursor: pointer; + transition: filter 0.3s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + /* 确保按钮本身不裁切阴影 */ + isolation: isolate; +} + +.save-btn:hover, +.save-config-btn:hover { + filter: brightness(1.25); + transform: none; +} + +.save-btn:active, +.save-config-btn:active { + filter: brightness(0.9); + transform: scale(0.97); +} + +/* 账户管理中的修改密码按钮(覆盖尺寸) */ +.profile-form .save-btn { + grid-column: auto; + padding: 8px 20px; + margin-top: 0; + height: 40px; + font-size: 13px; + border-radius: 8px; +} + +/* 保存配置按钮全宽(在全局配置页使用) */ +.save-config-btn { + width: 100%; + margin-top: 8px; + padding: 12px; + font-size: 16px; + border-radius: 12px; +} + +/* ---------- 隧道列表 ---------- */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.toolbar-left { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.toolbar-left button { + padding: 8px 18px; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.03); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.toolbar-left button:hover { + background: rgba(255, 255, 255, 0.06); +} + +.btn-import:hover { + border-color: var(--primary-blue-border); + color: var(--primary-blue); +} + +.btn-export:hover { + border-color: rgba(99, 150, 255, 0.3); + color: #6a9fff; +} + +.btn-add { + background: var(--primary-blue-dim) !important; + border-color: var(--primary-blue-border) !important; + color: var(--primary-blue) !important; +} + +.btn-add:hover { + background: rgba(74, 124, 255, 0.2) !important; +} + +.toolbar-right .search-input { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + border-radius: 10px; + color: #fff; + font-size: 13px; + outline: none; + width: 200px; + transition: border-color 0.3s; +} + +.toolbar-right .search-input:focus { + border-color: var(--primary-blue-border); +} + +.toolbar-right .search-input::placeholder { + color: var(--text-dim); +} + +/* ---------- 隧道卡片 ---------- */ +.proxy-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.proxy-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 12px 16px; + transition: border-color 0.3s; +} + +.proxy-card:hover { + border-color: rgba(255, 255, 255, 0.08); +} + +.proxy-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.proxy-left { + display: flex; + align-items: center; + gap: 12px; +} + +.proxy-name { + font-size: 15px; + font-weight: 500; + color: #fff; +} + +.proxy-addr { + font-size: 13px; + color: var(--text-dim); + font-family: 'JetBrains Mono', monospace; +} + +.proxy-tag { + font-size: 10px; + padding: 2px 10px; + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.proxy-right { + display: flex; + align-items: center; + gap: 12px; +} + +.remote-port { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: var(--text-secondary); +} + +.icon-btn { + background: none; + border: none; + color: var(--text-dim); + font-size: 16px; + cursor: pointer; + padding: 4px 6px; + transition: color 0.2s; + border-radius: 6px; +} + +.icon-btn:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); +} + +.icon-btn.danger:hover { + color: #f87171; + background: rgba(248, 113, 113, 0.1); +} + +.proxy-footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 10px; +} + +.status-label { + font-size: 12px; + color: var(--text-dim); +} + +.empty-state { + padding: 48px 0; + text-align: center; + color: var(--text-dim); + font-size: 14px; +} + +/* ---------- 开关 ---------- */ +.switch { + position: relative; + width: 44px; + height: 24px; + display: inline-block; + cursor: pointer; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch .slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 24px; + transition: background 0.3s; +} + +.switch .slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #1a1a24; + border-radius: 50%; + transition: transform 0.3s; +} + +.switch input:checked + .slider { + background: var(--primary-blue); +} + +.switch input:checked + .slider::before { + transform: translateX(20px); + background: #0a0a0f; +} + +.switch.small { + width: 32px; + height: 18px; +} + +.switch.small .slider::before { + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; +} + +.switch.small input:checked + .slider::before { + transform: translateX(14px); +} + +/* ---------- 状态点(只读展示) ---------- */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #333; + transition: background 0.3s; +} + +.status-dot.active { + background: #63e2b7; + box-shadow: 0 0 10px rgba(99, 226, 183, 0.3); +} + +/* ---------- 弹窗 ---------- */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 10; + display: flex; + justify-content: center; + align-items: center; +} + +.dialog-card { + background: #1a1a24; + border: 1px solid var(--border-subtle); + border-radius: 20px; + padding: 28px 32px; + width: 480px; + max-width: 90vw; + box-shadow: 0 32px 64px rgba(0, 0, 0, 0.6); +} + +.dialog-card h3 { + font-size: 18px; + font-weight: 500; + color: #fff; + margin-bottom: 20px; +} + +.dialog-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.dialog-form .form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dialog-form .form-row label { + font-size: 12px; + color: var(--text-dim); +} + +.dialog-form .form-row input, +.dialog-form .form-row select { + padding: 8px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + border-radius: 10px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; +} + +.dialog-form .form-row input:focus, +.dialog-form .form-row select:focus { + border-color: var(--primary-blue-border); +} + +.dialog-actions { + display: flex; + gap: 12px; + margin-top: 20px; + justify-content: flex-end; +} + +.dialog-actions button { + padding: 8px 24px; + border-radius: 10px; + border: none; + font-size: 14px; + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} + +.btn-cancel { + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); +} + +.btn-cancel:hover { + background: rgba(255, 255, 255, 0.08); +} + +.btn-confirm { + background: linear-gradient(135deg, var(--primary-blue), #2a5adf); + color: #0a0a0f; + font-weight: 600; +} + +.btn-confirm:hover { + transform: scale(1.02); + box-shadow: 0 4px 16px var(--primary-blue-glow); +} + +/* ---------- 自定义 Select(墨蓝风格) ---------- */ +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: rgba(13, 13, 26, 0.85) !important; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid var(--primary-blue-border) !important; + border-radius: 10px !important; + color: #e0e0e0 !important; + padding: 10px 14px !important; + font-size: 14px; + font-family: inherit; + cursor: pointer; + outline: none; + transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease; + width: 100%; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%234a7cff' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + padding-right: 40px !important; +} + +select:hover { + border-color: rgba(74, 124, 255, 0.4) !important; + background: rgba(20, 20, 40, 0.9) !important; +} + +select:focus { + border-color: var(--primary-blue) !important; + box-shadow: 0 0 0 3px rgba(74, 124, 255, 0.15), inset 0 0 0 1px rgba(74, 124, 255, 0.05); +} + +select option { + background: #0d0d1a !important; + color: #e0e0e0 !important; + padding: 8px 14px !important; + border: none !important; +} + +select option:hover, +select option:checked, +select option:focus { + background: rgba(74, 124, 255, 0.2) !important; + color: #ffffff !important; +} + +select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #e0e0e0; +} + +select:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* ---------- 弹窗入场/出场动画 ---------- */ +.dialog-enter-active, +.dialog-leave-active { + transition: all 0.3s ease; +} + +.dialog-enter-from, +.dialog-leave-to { + opacity: 0; +} + +.dialog-enter-from .dialog-card, +.dialog-leave-to .dialog-card { + transform: scale(0.85) translateY(-10px); + opacity: 0; +} + +.dialog-enter-to .dialog-card, +.dialog-leave-from .dialog-card { + transform: scale(1) translateY(0); + opacity: 1; +} + +/* ---------- 响应式调整 ---------- */ +@media (max-width: 768px) { + .profile-form { + grid-template-columns: 1fr; + } + .proxy-list { + grid-template-columns: 1fr 1fr; + } + .toolbar { + flex-direction: column; + align-items: stretch; + } + .toolbar-right .search-input { + width: 100%; + } +} + +@media (max-width: 480px) { + .proxy-list { + grid-template-columns: 1fr; + } + .top-left .nav-logo { + width: 36px; + height: 36px; + } + .brand-info .logo-text { + font-size: 18px; + } + .dialog-card { + padding: 20px; + } +} \ No newline at end of file diff --git a/static/style-4.css b/static/style-4.css new file mode 100644 index 0000000..86b75b7 --- /dev/null +++ b/static/style-4.css @@ -0,0 +1,327 @@ +/* ===== style-4.css - v1.5 新增样式 ===== */ + +/* ---------- 配置页面 ---------- */ +.config-tab-content { + padding-bottom: 20px; +} + +.config-page-header { + margin-bottom: 12px; +} +.config-page-header h2 { + font-size: 18px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; +} +.config-subtitle { + font-size: 13px; + color: var(--text-dim); +} + +/* 首次使用提示 */ +.config-tip { + background: rgba(255, 200, 50, 0.08); + border: 1px solid rgba(255, 200, 50, 0.15); + border-radius: 10px; + padding: 10px 16px; + font-size: 13px; + color: #f0c040; + margin-bottom: 16px; +} + +/* ---------- 配置卡片 ---------- */ +.config-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 16px 20px; + margin-bottom: 14px; +} + +.config-card-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 14px; +} +.card-title { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); +} + +.config-card-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 24px; +} +.config-card-body .form-row { + display: flex; + flex-direction: column; + gap: 4px; +} +.config-card-body .form-row label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-dim); +} +.config-card-body .form-row input, +.config-card-body .form-row select { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; + width: 100%; +} +.config-card-body .form-row input:focus, +.config-card-body .form-row select:focus { + border-color: var(--primary-blue-border); +} + +/* 数字输入框填满列宽 */ +.config-card-body .form-row input[type="number"] { + width: 100%; +} + +/* 只读行(TCP Mux) */ +.readonly-value { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; +} +.readonly-value .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #63e2b7; + box-shadow: 0 0 10px rgba(99, 226, 183, 0.3); + flex-shrink: 0; +} +.readonly-value .status-text.active { + color: #63e2b7; + font-size: 13px; +} +.readonly-value .hint-text { + font-size: 12px; + color: var(--text-dim); + margin-left: 8px; +} + +/* Token 输入框 + 显示按钮 */ +.token-input-wrapper { + display: flex; + gap: 8px; +} +.token-input-wrapper input { + flex: 1; +} +.token-toggle-btn { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.token-toggle-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* ---------- v2 协议开关(灰标禁用) ---------- */ +.v2-switch-row { + grid-column: 1 / -1 !important; + padding-top: 4px; + border-top: 1px solid var(--border-subtle); + margin-top: 4px; +} + +.v2-switch-wrapper { + display: flex; + align-items: center; + gap: 14px; + padding: 6px 0; +} + + +/* "即将推出" 徽章 */ +.v2-badge { + font-size: 11px; + padding: 2px 12px; + background: rgba(255, 200, 50, 0.12); + border: 1px solid rgba(255, 200, 50, 0.15); + border-radius: 12px; + color: #f0c040; + letter-spacing: 0.5px; + font-weight: 500; +} + +.v2-badge.v2-enabled { + background: rgba(99, 226, 183, 0.12); + border-color: rgba(99, 226, 183, 0.2); + color: #63e2b7; +} + +.v2-hint { + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; + padding: 2px 0 4px; + display: block; +} + +/* ---------- 日志面板 ---------- */ +.log-tab-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 400px; +} + +.log-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0 12px; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; +} +.log-toolbar-left { + display: flex; + align-items: center; + gap: 14px; +} +.log-toolbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.log-info-badge { + font-size: 13px; + color: var(--text-secondary); +} +.log-error-badge { + font-size: 13px; + color: #f87171; +} +.log-auto-scroll-label { + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} +.log-auto-scroll-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary-blue); + cursor: pointer; +} + +.log-btn { + padding: 6px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} +.log-btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* 终端样式(类似命令行) */ +.log-terminal { + flex: 1; + background: rgba(0, 0, 0, 0.6); + border-radius: 12px; + border: 1px solid var(--border-subtle); + padding: 12px 16px; + overflow-y: auto; + font-family: 'JetBrains Mono', 'Consolas', monospace; + font-size: 13px; + line-height: 1.7; + min-height: 300px; + max-height: 500px; +} +.log-terminal::-webkit-scrollbar { + width: 6px; +} +.log-terminal::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; +} +.log-terminal::-webkit-scrollbar-thumb { + background: rgba(74, 124, 255, 0.25); + border-radius: 4px; +} +.log-terminal::-webkit-scrollbar-thumb:hover { + background: rgba(74, 124, 255, 0.4); +} + +.log-lines { + display: flex; + flex-direction: column; +} +.log-line { + color: #c0c0c0; + white-space: pre-wrap; + word-break: break-all; + padding: 1px 0; +} + +.log-empty { + color: var(--text-dim); + text-align: center; + padding: 40px 0; +} + +/* 日志底部状态 */ +.log-footer { + display: flex; + justify-content: space-between; + padding: 10px 4px 0; + font-size: 12px; + color: var(--text-dim); + flex-shrink: 0; +} +.log-polling-status { + color: var(--primary-blue); +} + +/* ---------- 响应式调整 ---------- */ +@media (max-width: 768px) { + .config-card-body { + grid-template-columns: 1fr; + } + .log-toolbar { + flex-direction: column; + align-items: stretch; + } + .log-toolbar-right { + flex-wrap: wrap; + } + .token-input-wrapper { + flex-wrap: wrap; + } + .v2-switch-wrapper { + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/toml_parser.go b/toml_parser.go new file mode 100644 index 0000000..7d9f393 --- /dev/null +++ b/toml_parser.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "strconv" + "strings" +) + +// FrpsToml 对应 frps.toml 结构 +type FrpsToml struct { + BindPort int `json:"bindPort"` + Auth struct { + Token string `json:"token"` + } `json:"auth"` + Log struct { + To string `json:"to"` + Level string `json:"level"` + MaxDays int `json:"maxDays"` + } `json:"log"` + Transport struct { + TcpMux bool `json:"tcpMux"` + WireProtocolV2 bool `json:"wireProtocolV2"` + } `json:"transport"` +} + +func ParseToml(content string) (*FrpsToml, error) { + lines := strings.Split(content, "\n") + result := &FrpsToml{ + Transport: struct { + TcpMux bool `json:"tcpMux"` + WireProtocolV2 bool `json:"wireProtocolV2"` + }{ + TcpMux: true, + WireProtocolV2: false, + }, + } + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") { + continue + } + if !strings.Contains(line, "=") { + continue + } + + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"`) + + switch key { + case "bindPort": + result.BindPort, _ = strconv.Atoi(value) + case "token": + result.Auth.Token = value + case "level": + result.Log.Level = value + case "maxDays": + result.Log.MaxDays, _ = strconv.Atoi(value) + case "tcpMux": + result.Transport.TcpMux = value == "true" + case "wireProtocol": + result.Transport.WireProtocolV2 = value == "v2" + } + } + + if result.BindPort == 0 { + return nil, fmt.Errorf("未找到 bindPort 字段") + } + + return result, nil +} + +func (f *FrpsToml) ToGlobalConfig() *GlobalConfig { + return &GlobalConfig{ + BindPort: f.BindPort, + Token: f.Auth.Token, + LogLevel: f.Log.Level, + LogMaxDays: f.Log.MaxDays, + TcpMux: f.Transport.TcpMux, + } +}