目录重构完成,但是Preview通道尚未可用
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-21
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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 不需要隐藏窗口
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
// 其他平台不做特殊设置
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user