diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..c3572e2 --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,417 @@ +package api + +import ( + "net" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "frpc-console/internal/auth" + "frpc-console/internal/db" + "frpc-console/internal/frp" +) + +// ================================================================ +// 认证 Handler +// ================================================================ + +func CheckUsersHandler(c *gin.Context) { + count, err := db.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 := db.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 !auth.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 := db.CreateUser(req.Username, string(hash)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建用户失败: " + err.Error()}) + return + } + + token, err := auth.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 := db.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 := auth.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 := auth.GetUsernameFromContext(c) + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未登录"}) + return + } + + user, err := db.GetUserByUsername(username) + 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 !auth.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 := db.UpdateUserPassword(username, 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 := db.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 db.GlobalConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + cfg.TcpMux = true + + if err := db.UpdateGlobalConfig(&cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) + return + } + + if err := frp.GenerateConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()}) + return + } + + if err := frp.Reload(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) +} + +// ================================================================ +// 隧道 Handler +// ================================================================ + +func GetProxiesHandler(c *gin.Context) { + proxies, err := db.GetProxies() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取隧道列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": proxies}) +} + +func GetProxyHandler(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的ID"}) + return + } + p, err := db.GetProxy(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "隧道不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": p}) +} + +func CreateProxyHandler(c *gin.Context) { + var p db.Proxy + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + p.Enabled = true + + if err := db.CreateProxy(&p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建隧道失败: " + err.Error()}) + return + } + + if err := generateAndReload(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "配置生效失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道创建成功", "data": gin.H{"id": p.ID}}) +} + +func UpdateProxyHandler(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的ID"}) + return + } + var p db.Proxy + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) + return + } + p.ID = id + + if err := db.UpdateProxy(&p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新隧道失败: " + err.Error()}) + return + } + + if err := generateAndReload(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "配置生效失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道更新成功"}) +} + +func DeleteProxyHandler(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的ID"}) + return + } + + if err := db.DeleteProxy(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "删除隧道失败: " + err.Error()}) + return + } + + if err := generateAndReload(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "配置生效失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道删除成功"}) +} + +// ================================================================ +// frpc 进程管理 Handler +// ================================================================ + +func ReloadFrpcHandler(c *gin.Context) { + if err := frp.GenerateConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) + return + } + if err := frp.Reload(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "热加载成功"}) +} + +func StartFrpcHandler(c *gin.Context) { + if err := frp.Start(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "启动失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frpc 启动成功"}) +} + +func StopFrpcHandler(c *gin.Context) { + if err := frp.Stop(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "停止失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frpc 已停止"}) +} + +func GetFrpcStatusHandler(c *gin.Context) { + status, err := frp.GetStatus() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询状态失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": status}) +} + +// ================================================================ +// Ping Handler +// ================================================================ + +func PingHandler(c *gin.Context) { + target := c.Query("target") + if target == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "缺少 target 参数"}) + return + } + + cfg, err := db.GetGlobalConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) + return + } + + port := cfg.ServerPort + address := net.JoinHostPort(target, strconv.Itoa(port)) + + start := time.Now() + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "msg": "ping 失败", "latency": -1}) + return + } + conn.Close() + + latency := time.Since(start).Milliseconds() + c.JSON(http.StatusOK, gin.H{"code": 0, "latency": latency}) +} + +// ================================================================ +// 日志 Handler +// ================================================================ + +func GetFrpcLogHandler(c *gin.Context) { + lines, err := frp.ReadTailLog("./data/frpc.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), + }, + }) +} + +// ================================================================ +// 辅助函数 +// ================================================================ + +func generateAndReload() error { + if err := frp.GenerateConfig(); err != nil { + return err + } + return frp.Reload() +} + +// readTailLog 读取文件末尾 n 行 (临时放在这里,后续移到独立包) +func readTailLog(filePath string, n int) ([]string, error) { + // 这个函数在 frp 模块中也有,但为了避免循环依赖,在这里实现一份简单的 + // 或者直接调用 frp.ReadTailLog 如果导出的话 + // 目前保持和原来一致,后续可以统一到 pkg/utils + // 为了编译通过,先简单返回空 + return []string{}, nil +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..272f27e --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,74 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + + "frpc-console/internal/auth" +) + +//go:embed static/* +var staticFS embed.FS + +// SetupRouter 设置路由 +func SetupRouter() *gin.Engine { + r := gin.Default() + + // 从 embed 读取前端静态文件 + 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, "frpc-console 后端已启动 🎉") + }) + + api := r.Group("/api") + { + // ---- 公开路由(不需要认证) ---- + api.GET("/check/users", CheckUsersHandler) + api.POST("/register", RegisterHandler) + api.POST("/login", LoginHandler) + api.GET("/ping", PingHandler) + + // ---- 需要认证的路由 ---- + authGroup := api.Group("/") + authGroup.Use(auth.AuthMiddleware()) + { + authGroup.GET("/config", GetConfigHandler) + authGroup.PUT("/config", UpdateConfigHandler) + + authGroup.GET("/proxies", GetProxiesHandler) + authGroup.GET("/proxy/:id", GetProxyHandler) + authGroup.POST("/proxy", CreateProxyHandler) + authGroup.PUT("/proxy/:id", UpdateProxyHandler) + authGroup.DELETE("/proxy/:id", DeleteProxyHandler) + + authGroup.POST("/frpc/reload", ReloadFrpcHandler) + authGroup.POST("/frpc/start", StartFrpcHandler) + authGroup.POST("/frpc/stop", StopFrpcHandler) + authGroup.GET("/frpc/status", GetFrpcStatusHandler) + authGroup.GET("/frpc/log", GetFrpcLogHandler) + + authGroup.POST("/import/toml", ImportTomlHandler) + authGroup.GET("/export/toml", ExportTomlHandler) + + authGroup.PUT("/user/password", ChangePasswordHandler) + } + } + + return r +} diff --git a/internal/frp/binary.go b/internal/frp/binary.go index 5435937..c81a8c1 100644 --- a/internal/frp/binary.go +++ b/internal/frp/binary.go @@ -50,14 +50,12 @@ func GetFrpcPath() (string, error) { return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) } - // 尝试从本地 bin 目录加载 localPath := filepath.Join(".", "bin", fileName) if _, err := os.Stat(localPath); err == nil { cachedFrpcPath = localPath return localPath, nil } - // 尝试从 embed 提取到临时目录 data, err := embeddedFrpc.ReadFile("bin/" + fileName) if err == nil { tmpPath := filepath.Join(os.TempDir(), "frpc") @@ -74,7 +72,6 @@ func GetFrpcPath() (string, error) { } } - // 最后尝试从系统 PATH 查找 path, err := exec.LookPath("frpc") if err == nil { cachedFrpcPath = path diff --git a/internal/frp/config.go b/internal/frp/config.go new file mode 100644 index 0000000..64d79d9 --- /dev/null +++ b/internal/frp/config.go @@ -0,0 +1,78 @@ +package frp + +import ( + "bytes" + "os" + "text/template" + + "frpc-console/internal/db" +) + +//go:embed frpc.tmpl +var FrpcTemplateContent string + +// ConfigData frpc.toml 模板渲染数据 +type ConfigData struct { + *db.GlobalConfig + Proxies []db.Proxy + WireProtocolLine string +} + +// GenerateConfig 生成 frpc.toml 配置文件 +func GenerateConfig() error { + cfg, err := db.GetGlobalConfig() + if err != nil { + return err + } + + proxies, err := db.GetProxies() + if err != nil { + return err + } + + var activeProxies []db.Proxy + for _, p := range proxies { + if p.Enabled { + activeProxies = append(activeProxies, p) + } + } + + data := ConfigData{ + GlobalConfig: cfg, + Proxies: activeProxies, + } + + if cfg.WireProtocolV2 { + data.WireProtocolLine = `wireProtocol = "v2"` + } else { + data.WireProtocolLine = "" + } + + var tmplContent string + if _, err := os.Stat("frpc.tmpl"); err == nil { + content, readErr := os.ReadFile("frpc.tmpl") + if readErr == nil { + tmplContent = string(content) + } else { + tmplContent = FrpcTemplateContent + } + } else { + tmplContent = FrpcTemplateContent + } + + tmpl, err := template.New("frpc").Parse(tmplContent) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return err + } + + if err := os.MkdirAll("./data", 0755); err != nil { + return err + } + + return os.WriteFile("./data/frpc.toml", buf.Bytes(), 0644) +} diff --git a/internal/frp/legacy.go b/internal/frp/legacy.go index 1a83e15..ad4108a 100644 --- a/internal/frp/legacy.go +++ b/internal/frp/legacy.go @@ -15,7 +15,7 @@ import ( // ================================================================ // 兼容层:保持对外接口不变 -// 这些函数供 api 和外部调用,实际委托给 process.Manager +// 这些函数供 api 调用,实际委托给 process.Manager // ================================================================ // IsRunning 检查 frpc 是否在运行 @@ -263,23 +263,3 @@ func reloadLegacy() error { log.Printf("✅ frpc 热加载成功 (兼容模式): %s", string(output)) return nil } - -// ================================================================ -// 辅助函数 (平台相关) -// ================================================================ - -func setWindowHide(cmd *exec.Cmd) { - if runtime.GOOS == "windows" { - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - } - } -} - -func setSysProcAttr(cmd *exec.Cmd) { - if runtime.GOOS != "windows" { - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - } - } -} diff --git a/internal/frp/log.go b/internal/frp/log.go new file mode 100644 index 0000000..07c33df --- /dev/null +++ b/internal/frp/log.go @@ -0,0 +1,81 @@ +package frp + +import ( + "io" + "os" + "strings" +) + +// 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/internal/frp/platform_other.go b/internal/frp/platform_other.go new file mode 100644 index 0000000..7e75d13 --- /dev/null +++ b/internal/frp/platform_other.go @@ -0,0 +1,13 @@ +//go:build !windows && !linux && !darwin && !freebsd && !netbsd && !openbsd && !solaris + +package frp + +import ( + "os/exec" +) + +// setWindowHide 其他平台空实现 +func setWindowHide(cmd *exec.Cmd) {} + +// setSysProcAttr 其他平台空实现 +func setSysProcAttr(cmd *exec.Cmd) {} diff --git a/internal/frp/platform_unix.go b/internal/frp/platform_unix.go new file mode 100644 index 0000000..cfc0a65 --- /dev/null +++ b/internal/frp/platform_unix.go @@ -0,0 +1,21 @@ +//go:build linux || darwin || freebsd || netbsd || openbsd || solaris + +package frp + +import ( + "os/exec" + "syscall" +) + +// setSysProcAttr 为 Unix 系统设置 Setsid +func setSysProcAttr(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setsid = true +} + +// setWindowHide Unix 上不做任何事 +func setWindowHide(cmd *exec.Cmd) { + // Unix 不需要隐藏窗口 +} diff --git a/internal/frp/platform_windows.go b/internal/frp/platform_windows.go new file mode 100644 index 0000000..c7c1f09 --- /dev/null +++ b/internal/frp/platform_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package frp + +import ( + "os/exec" + "syscall" +) + +// setWindowHide Windows 隐藏窗口 +func setWindowHide(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + } +} + +// setSysProcAttr Windows 不需要 Setpgid +func setSysProcAttr(cmd *exec.Cmd) { + // Windows 不支持 Setpgid +} diff --git a/internal/frp/toml.go b/internal/frp/toml.go new file mode 100644 index 0000000..d5240d9 --- /dev/null +++ b/internal/frp/toml.go @@ -0,0 +1,175 @@ +package frp + +import ( + "fmt" + "strconv" + "strings" + + "frpc-console/internal/db" +) + +// FrpcToml 对应 frpc.toml 的完整结构 +type FrpcToml struct { + ServerAddr string `json:"serverAddr"` + ServerPort int `json:"serverPort"` + 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"` + TcpMuxKeepalive int `json:"tcpMuxKeepalive"` + HeartbeatInterval int `json:"heartbeatInterval"` + HeartbeatTimeout int `json:"heartbeatTimeout"` + PoolCount int `json:"poolCount"` + } `json:"transport"` + Proxies []TomlProxy `json:"proxies"` +} + +// TomlProxy 对应 [[proxies]] 条目 +type TomlProxy struct { + Name string `json:"name"` + Type string `json:"type"` + LocalIP string `json:"localIP"` + LocalPort int `json:"localPort"` + RemotePort int `json:"remotePort"` + Enabled bool `json:"enabled"` +} + +// ParseToml 解析 frpc.toml 内容 +func ParseToml(content string) (*FrpcToml, error) { + lines := strings.Split(content, "\n") + result := &FrpcToml{ + Proxies: []TomlProxy{}, + Transport: struct { + TcpMux bool `json:"tcpMux"` + TcpMuxKeepalive int `json:"tcpMuxKeepalive"` + HeartbeatInterval int `json:"heartbeatInterval"` + HeartbeatTimeout int `json:"heartbeatTimeout"` + PoolCount int `json:"poolCount"` + }{ + TcpMux: true, + TcpMuxKeepalive: 30, + HeartbeatInterval: 15, + HeartbeatTimeout: 70, + PoolCount: 8, + }, + } + + var currentProxy *TomlProxy + inProxies := false + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "[[proxies]]") { + inProxies = true + currentProxy = &TomlProxy{ + Type: "tcp", + Enabled: true, + } + result.Proxies = append(result.Proxies, *currentProxy) + currentProxy = &result.Proxies[len(result.Proxies)-1] + continue + } + + if strings.HasPrefix(line, "[") { + inProxies = false + currentProxy = nil + continue + } + + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"`) + + if inProxies && currentProxy != nil { + switch key { + case "name": + currentProxy.Name = value + case "type": + currentProxy.Type = value + case "localIP": + currentProxy.LocalIP = value + case "localPort": + currentProxy.LocalPort, _ = strconv.Atoi(value) + case "remotePort": + currentProxy.RemotePort, _ = strconv.Atoi(value) + } + } else { + switch key { + case "serverAddr": + result.ServerAddr = value + case "serverPort": + result.ServerPort, _ = 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 "tcpMuxKeepaliveInterval": + result.Transport.TcpMuxKeepalive, _ = strconv.Atoi(value) + case "heartbeatInterval": + result.Transport.HeartbeatInterval, _ = strconv.Atoi(value) + case "heartbeatTimeout": + result.Transport.HeartbeatTimeout, _ = strconv.Atoi(value) + case "poolCount": + result.Transport.PoolCount, _ = strconv.Atoi(value) + } + } + } + } + + if result.ServerAddr == "" { + return nil, fmt.Errorf("未找到 serverAddr 字段") + } + if len(result.Proxies) == 0 { + return nil, fmt.Errorf("未找到任何隧道条目") + } + + return result, nil +} + +// ToGlobalConfig 将解析结果转换为 db.GlobalConfig +func (f *FrpcToml) ToGlobalConfig() *db.GlobalConfig { + return &db.GlobalConfig{ + ServerAddr: f.ServerAddr, + ServerPort: f.ServerPort, + Token: f.Auth.Token, + LogLevel: f.Log.Level, + LogMaxDays: f.Log.MaxDays, + TcpMux: f.Transport.TcpMux, + TcpMuxKeepalive: f.Transport.TcpMuxKeepalive, + HeartbeatInterval: f.Transport.HeartbeatInterval, + HeartbeatTimeout: f.Transport.HeartbeatTimeout, + PoolCount: f.Transport.PoolCount, + } +} + +// ToProxies 将解析结果转换为 db.Proxy 列表 +func (f *FrpcToml) ToProxies() []db.Proxy { + var proxies []db.Proxy + for _, p := range f.Proxies { + proxies = append(proxies, db.Proxy{ + Name: p.Name, + Type: p.Type, + LocalIP: p.LocalIP, + LocalPort: p.LocalPort, + RemotePort: p.RemotePort, + Enabled: true, + }) + } + return proxies +} diff --git a/internal/process/attr_linux.go b/internal/process/attr_linux.go new file mode 100644 index 0000000..51472d6 --- /dev/null +++ b/internal/process/attr_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package process + +import ( + "os/exec" + "syscall" +) + +// setProcessAttributes 设置 Linux 进程属性 (Setpgid) +func setProcessAttributes(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} diff --git a/internal/process/attr_other.go b/internal/process/attr_other.go new file mode 100644 index 0000000..d93b17d --- /dev/null +++ b/internal/process/attr_other.go @@ -0,0 +1,19 @@ +//go:build !linux + +package process + +import ( + "os/exec" + "runtime" + "syscall" +) + +// setProcessAttributes 设置进程属性 (非 Linux) +func setProcessAttributes(cmd *exec.Cmd) { + if runtime.GOOS == "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + } + } + // 其他平台不做特殊设置 +} diff --git a/internal/process/lock_linux.go b/internal/process/lock_linux.go new file mode 100644 index 0000000..935f700 --- /dev/null +++ b/internal/process/lock_linux.go @@ -0,0 +1,67 @@ +//go:build linux + +package process + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/unix" +) + +// Lock 获取进程间互斥锁 (Linux: flock) +func (pm *ProcessManager) Lock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.locked { + return nil + } + + lockPath := filepath.Join(pm.dataDir, LockFileName) + if err := os.MkdirAll(pm.dataDir, 0755); err != nil { + return fmt.Errorf("创建数据目录失败: %w", err) + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("打开锁文件失败: %w", err) + } + + start := time.Now() + for { + err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + pm.lockFile = file + pm.locked = true + return nil + } + if err != unix.EWOULDBLOCK { + file.Close() + return fmt.Errorf("获取锁失败: %w", err) + } + if time.Since(start) > LockAcquireTimeout { + file.Close() + return fmt.Errorf("获取锁超时 (超过 %v)", LockAcquireTimeout) + } + time.Sleep(LockRetryInterval) + } +} + +// Unlock 释放互斥锁 (Linux: flock) +func (pm *ProcessManager) Unlock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.locked { + return nil + } + if pm.lockFile != nil { + unix.Flock(int(pm.lockFile.Fd()), unix.LOCK_UN) + pm.lockFile.Close() + pm.lockFile = nil + } + pm.locked = false + return nil +} diff --git a/internal/process/lock_other.go b/internal/process/lock_other.go new file mode 100644 index 0000000..27ce2df --- /dev/null +++ b/internal/process/lock_other.go @@ -0,0 +1,28 @@ +//go:build !linux && !windows + +package process + +// Lock 获取进程间互斥锁 (非 Linux/Windows: 内存锁) +func (pm *ProcessManager) Lock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.locked { + return nil + } + + pm.locked = true + return nil +} + +// Unlock 释放互斥锁 (非 Linux/Windows) +func (pm *ProcessManager) Unlock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.locked { + return nil + } + pm.locked = false + return nil +} diff --git a/internal/process/lock_windows.go b/internal/process/lock_windows.go new file mode 100644 index 0000000..462a719 --- /dev/null +++ b/internal/process/lock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package process + +// Lock 获取进程间互斥锁 (Windows: 内存锁) +// 注意: Windows 版本仅在同一进程内互斥,进程间不互斥 +// 如需真正的进程间锁,后续可改用 Windows Named Mutex +func (pm *ProcessManager) Lock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.locked { + return nil + } + + pm.locked = true + return nil +} + +// Unlock 释放互斥锁 (Windows) +func (pm *ProcessManager) Unlock() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.locked { + return nil + } + pm.locked = false + return nil +} diff --git a/internal/process/manager.go b/internal/process/manager.go new file mode 100644 index 0000000..6f9b2fe --- /dev/null +++ b/internal/process/manager.go @@ -0,0 +1,581 @@ +// process/manager.go +// frpc-console 进程管理模块 +// 2.6-preview: 端口检测 + 单实例锁定 + 状态自述 + +package process + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ================================================================ +// 常量定义 +// ================================================================ + +const ( + LockFileName = ".frpc.lock" + PortCheckTimeout = 500 * time.Millisecond + StartWaitTime = 500 * time.Millisecond + StopMaxWaitTime = 5 * time.Second + LockAcquireTimeout = 30 * time.Second + LockRetryInterval = 100 * time.Millisecond + APITimeout = 2 * time.Second +) + +// ================================================================ +// 数据结构 +// ================================================================ + +// PortStatus 端口检测结果 +type PortStatus struct { + Port int `json:"port"` + Occupied bool `json:"occupied"` + PID int `json:"pid"` + IsFRPC bool `json:"is_frpc"` + ProcessCmd string `json:"process_cmd,omitempty"` +} + +// ProcessStatus frpc 进程状态 +type ProcessStatus struct { + State string `json:"state"` // "running" | "stopped" | "unknown" | "conflict" + PID int `json:"pid"` // 进程 PID (如果运行中) + Port int `json:"port"` // 监听的端口 + Uptime string `json:"uptime,omitempty"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +// FRPCStatus 来自 frpc admin API 的状态响应 +type FRPCStatus struct { + Version string `json:"version"` + RunID string `json:"run_id"` + Proxies []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LocalAddr string `json:"local_addr"` + } `json:"proxies"` +} + +// ================================================================ +// ProcessManager 主结构 +// ================================================================ + +type ProcessManager struct { + mu sync.Mutex + dataDir string + configPath string + frpcBinPath string + adminPort int + lockFile *os.File + locked bool +} + +var ( + globalManager *ProcessManager + globalMu sync.Mutex +) + +// NewManager 创建进程管理器 +func NewManager(dataDir, configPath, frpcBinPath string) *ProcessManager { + return &ProcessManager{ + dataDir: dataDir, + configPath: configPath, + frpcBinPath: frpcBinPath, + adminPort: 0, + } +} + +// SetGlobalManager 设置全局管理器 +func SetGlobalManager(pm *ProcessManager) { + globalMu.Lock() + defer globalMu.Unlock() + globalManager = pm +} + +// GetGlobalManager 获取全局管理器 +func GetGlobalManager() *ProcessManager { + globalMu.Lock() + defer globalMu.Unlock() + return globalManager +} + +// AdminPort 获取 admin_port +func (pm *ProcessManager) AdminPort() int { + return pm.adminPort +} + +// ================================================================ +// 配置读取 +// ================================================================ + +// LoadConfig 从 frpc.toml 读取 admin_port 配置 +// 兼容 frp 0.52.0 前后的配置格式 +func (pm *ProcessManager) LoadConfig() error { + content, err := os.ReadFile(pm.configPath) + if err != nil { + return fmt.Errorf("读取配置文件失败: %w", err) + } + + if port := extractIntValue(string(content), "admin_port"); port > 0 { + pm.adminPort = port + return nil + } + + if port := extractIntValueFromSection(string(content), "webServer", "port"); port > 0 { + pm.adminPort = port + return nil + } + + return fmt.Errorf("未找到 admin_port 或 webServer.port 配置") +} + +func extractIntValue(content, key string) int { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, key) { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"`) + if port, err := strconv.Atoi(val); err == nil && port > 0 { + return port + } + } + } + } + return 0 +} + +func extractIntValueFromSection(content, section, key string) int { + lines := strings.Split(content, "\n") + inSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + inSection = strings.Trim(trimmed, "[]") == section + continue + } + if inSection && strings.HasPrefix(trimmed, key) { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"`) + if port, err := strconv.Atoi(val); err == nil && port > 0 { + return port + } + } + } + } + return 0 +} + +// ================================================================ +// 端口检测 +// ================================================================ + +// CheckPort 检测端口是否被占用 +func (pm *ProcessManager) CheckPort() (bool, error) { + if pm.adminPort <= 0 { + return false, fmt.Errorf("admin_port 未配置") + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", pm.adminPort), PortCheckTimeout) + if err != nil { + return false, nil + } + conn.Close() + return true, nil +} + +// GetPortStatus 获取端口完整状态 (占用 + PID + 进程类型) +func (pm *ProcessManager) GetPortStatus() (*PortStatus, error) { + status := &PortStatus{Port: pm.adminPort, Occupied: false, PID: 0, IsFRPC: false} + occupied, err := pm.CheckPort() + if err != nil { + return status, err + } + status.Occupied = occupied + if !occupied { + return status, nil + } + + pid, err := pm.getPIDByPort(pm.adminPort) + if err != nil { + if pidFromFile := pm.readPIDFile(); pidFromFile > 0 { + if pm.isProcessListeningOnPort(pidFromFile, pm.adminPort) { + status.PID = pidFromFile + } + } + } else { + status.PID = pid + } + if status.PID == 0 { + return status, nil + } + isFRPC, cmd := pm.isFRPCProcess(status.PID) + status.IsFRPC = isFRPC + status.ProcessCmd = cmd + return status, nil +} + +func (pm *ProcessManager) getPIDByPort(port int) (int, error) { + if pid, err := pm.getPIDBySS(port); err == nil && pid > 0 { + return pid, nil + } + if pid, err := pm.getPIDByNetstat(port); err == nil && pid > 0 { + return pid, nil + } + if pid, err := pm.getPIDByLsof(port); err == nil && pid > 0 { + return pid, nil + } + return 0, fmt.Errorf("无法通过端口反查 PID") +} + +func (pm *ProcessManager) getPIDBySS(port int) (int, error) { + cmd := exec.Command("ss", "-lpn", "state", "listening") + out, err := cmd.Output() + if err != nil { + return 0, err + } + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, fmt.Sprintf(":%d", port)) { + continue + } + if idx := strings.Index(line, "pid="); idx != -1 { + end := strings.Index(line[idx:], ",") + if end == -1 { + end = strings.Index(line[idx:], ")") + } + if end == -1 { + continue + } + pidStr := line[idx+4 : idx+end] + pidStr = strings.TrimSpace(pidStr) + if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 { + return pid, nil + } + } + } + return 0, fmt.Errorf("未找到监听端口 %d 的进程", port) +} + +func (pm *ProcessManager) getPIDByNetstat(port int) (int, error) { + cmd := exec.Command("netstat", "-tulpn") + out, err := cmd.Output() + if err != nil { + return 0, err + } + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, fmt.Sprintf(":%d", port)) { + continue + } + parts := strings.Fields(line) + if len(parts) < 7 { + continue + } + last := parts[len(parts)-1] + if idx := strings.Index(last, "/"); idx != -1 { + pidStr := last[:idx] + if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 { + return pid, nil + } + } + } + return 0, fmt.Errorf("未找到监听端口 %d 的进程", port) +} + +func (pm *ProcessManager) getPIDByLsof(port int) (int, error) { + cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN") + out, err := cmd.Output() + if err != nil { + return 0, err + } + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "frpc") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if pid, err := strconv.Atoi(parts[1]); err == nil && pid > 0 { + return pid, nil + } + } + } + } + return 0, fmt.Errorf("未找到监听端口 %d 的 frpc 进程", port) +} + +func (pm *ProcessManager) isProcessListeningOnPort(pid, port int) bool { + cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-a", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN") + out, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(out), "LISTEN") +} + +func (pm *ProcessManager) isFRPCProcess(pid int) (bool, string) { + cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid) + if data, err := os.ReadFile(cmdlinePath); err == nil { + cmd := strings.ReplaceAll(string(data), "\x00", " ") + if strings.Contains(cmd, "frpc") { + return true, cmd + } + } + cmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=") + out, err := cmd.Output() + if err == nil { + args := strings.TrimSpace(string(out)) + if strings.Contains(args, "frpc") { + return true, args + } + } + return false, "" +} + +// ================================================================ +// PID 文件操作 +// ================================================================ + +func (pm *ProcessManager) pidFilePath() string { + return filepath.Join(pm.dataDir, "frpc.pid") +} + +func (pm *ProcessManager) readPIDFile() int { + data, err := os.ReadFile(pm.pidFilePath()) + if err != nil { + return 0 + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 0 { + return 0 + } + return pid +} + +func (pm *ProcessManager) writePIDFile(pid int) error { + return os.WriteFile(pm.pidFilePath(), []byte(strconv.Itoa(pid)), 0644) +} + +func (pm *ProcessManager) deletePIDFile() error { + err := os.Remove(pm.pidFilePath()) + if os.IsNotExist(err) { + return nil + } + return err +} + +// ================================================================ +// 状态查询 +// ================================================================ + +// Status 获取 frpc 进程实时状态 +func (pm *ProcessManager) Status() (*ProcessStatus, error) { + status := &ProcessStatus{State: "unknown", PID: 0, Port: pm.adminPort} + if pm.adminPort <= 0 { + status.Error = "admin_port 未配置" + return status, nil + } + portStatus, err := pm.GetPortStatus() + if err != nil { + status.Error = err.Error() + return status, nil + } + if !portStatus.Occupied { + pm.deletePIDFile() + status.State = "stopped" + return status, nil + } + if !portStatus.IsFRPC { + status.State = "conflict" + status.PID = portStatus.PID + status.Error = fmt.Sprintf("端口 %d 被非 frpc 进程占用 (PID: %d)", pm.adminPort, portStatus.PID) + return status, nil + } + status.State = "running" + status.PID = portStatus.PID + pm.writePIDFile(portStatus.PID) + if info := pm.getFRPCStatus(portStatus.PID); info != nil { + status.Version = info.Version + } + return status, nil +} + +// ================================================================ +// API 回源 (2.7-preview 预留) +// ================================================================ + +func (pm *ProcessManager) getFRPCStatus(pid int) *FRPCStatus { + if pid <= 0 || pm.adminPort <= 0 { + return nil + } + url := fmt.Sprintf("http://127.0.0.1:%d/api/status", pm.adminPort) + ctx, cancel := context.WithTimeout(context.Background(), APITimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil + } + client := &http.Client{Timeout: APITimeout} + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + var status FRPCStatus + if err := json.Unmarshal(body, &status); err != nil { + return nil + } + return &status +} + +// ================================================================ +// 操作执行 (Start / Stop / Restart) +// ================================================================ + +// Start 启动 frpc (幂等) +func (pm *ProcessManager) Start(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + return pm.startLocked(ctx) +} + +func (pm *ProcessManager) startLocked(ctx context.Context) error { + portStatus, err := pm.GetPortStatus() + if err != nil { + return fmt.Errorf("检测端口状态失败: %w", err) + } + if portStatus.Occupied { + if portStatus.IsFRPC { + pm.writePIDFile(portStatus.PID) + return nil + } + return fmt.Errorf("端口 %d 被非 frpc 进程占用 (PID: %d)", pm.adminPort, portStatus.PID) + } + + cmd := exec.CommandContext(ctx, pm.frpcBinPath, "-c", pm.configPath) + + // 设置进程属性 (平台相关) + setProcessAttributes(cmd) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 frpc 失败: %w", err) + } + time.Sleep(StartWaitTime) + occupied, err := pm.CheckPort() + if err != nil { + return fmt.Errorf("验证启动状态失败: %w", err) + } + if !occupied { + return fmt.Errorf("frpc 启动失败: 端口未监听") + } + pid, _ := pm.getPIDByPort(pm.adminPort) + if pid > 0 { + pm.writePIDFile(pid) + } + return nil +} + +// Stop 停止 frpc (幂等) +func (pm *ProcessManager) Stop(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + return pm.stopLocked(ctx) +} + +func (pm *ProcessManager) stopLocked(ctx context.Context) error { + portStatus, err := pm.GetPortStatus() + if err != nil { + return fmt.Errorf("检测端口状态失败: %w", err) + } + if !portStatus.Occupied { + pm.deletePIDFile() + return nil + } + var pid int + if portStatus.IsFRPC { + pid = portStatus.PID + } else { + return fmt.Errorf("端口 %d 被非 frpc 进程占用, 无法安全停止", pm.adminPort) + } + if pid <= 0 { + pid = pm.readPIDFile() + if pid <= 0 { + return fmt.Errorf("无法确定 frpc 进程 PID") + } + } + proc, err := os.FindProcess(pid) + if err != nil { + pm.deletePIDFile() + return nil + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + pm.deletePIDFile() + return nil + } + start := time.Now() + for time.Since(start) < StopMaxWaitTime { + occupied, _ := pm.CheckPort() + if !occupied { + pm.deletePIDFile() + return nil + } + time.Sleep(200 * time.Millisecond) + } + proc.Kill() + time.Sleep(500 * time.Millisecond) + if occupied, _ := pm.CheckPort(); occupied { + return fmt.Errorf("强制停止失败: 端口仍被占用") + } + pm.deletePIDFile() + return nil +} + +// Restart 重启 frpc (原子操作) +func (pm *ProcessManager) Restart(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + if err := pm.stopLocked(ctx); err != nil { + return fmt.Errorf("停止失败: %w", err) + } + if err := pm.startLocked(ctx); err != nil { + return fmt.Errorf("启动失败: %w", err) + } + return nil +}