目录重构完成,但是Preview通道尚未可用

This commit is contained in:
2026-08-07 19:42:49 +08:00
parent 3a381c85ed
commit 86df4bf3c5
16 changed files with 1620 additions and 24 deletions
+417
View File
@@ -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
}
+74
View File
@@ -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
}
-3
View File
@@ -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
+78
View File
@@ -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
View File
@@ -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,
}
}
}
+81
View File
@@ -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
}
+13
View File
@@ -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) {}
+21
View File
@@ -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 不需要隐藏窗口
}
+20
View File
@@ -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
}
+175
View File
@@ -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
}
+15
View File
@@ -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,
}
}
+19
View File
@@ -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,
}
}
// 其他平台不做特殊设置
}
+67
View File
@@ -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
}
+28
View File
@@ -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
}
+30
View File
@@ -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
}
+581
View File
@@ -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
}