diff --git a/Dockerfile b/Dockerfile index 6f3d795..8807dbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ FROM alpine:latest # 先切换到国内镜像源,再安装包 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories -RUN apk --no-cache add ca-certificates tzdata sqlite +RUN apk --no-cache add ca-certificates tzdata sqlite bash curl iproute2 net-tools WORKDIR /app diff --git a/Dockerfile.txt b/Dockerfile.txt deleted file mode 100644 index 2b2d186..0000000 --- a/Dockerfile.txt +++ /dev/null @@ -1,30 +0,0 @@ -# 多阶段构建 -FROM golang:alpine AS builder - -WORKDIR /app - -COPY go.mod go.sum ./ - -RUN sed -i 's/go 1.2[6-9].*/go 1.21/' go.mod && go mod tidy -RUN go env -w GOPROXY=https://goproxy.cn,direct -RUN go mod download - -COPY . . - -# 编译时不注入任何版本信息(纯净二进制) -RUN CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w" \ - -o frpc-console . - -FROM alpine:latest - -RUN apk --no-cache add ca-certificates tzdata sqlite - -WORKDIR /app - -COPY --from=builder /app/frpc-console /app/frpc-console -COPY static/ /app/static/ - -EXPOSE 9300 - -ENTRYPOINT ["/app/frpc-console"] \ No newline at end of file diff --git a/auth.go b/auth.go deleted file mode 100644 index 5b46007..0000000 --- a/auth.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "log" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" - "golang.org/x/crypto/bcrypt" -) - -var jwtSecretCache []byte - -func getJwtSecret() []byte { - if len(jwtSecretCache) > 0 { - return jwtSecretCache - } - secret, err := GetJwtSecret() - if err != nil { - log.Fatal("❌ 获取 JWT 密钥失败:", err) - } - jwtSecretCache = []byte(secret) - return jwtSecretCache -} - -type Claims struct { - Username string `json:"username"` - jwt.RegisteredClaims -} - -func GenerateJWT(username string) (string, error) { - claims := Claims{ - Username: username, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(getJwtSecret()) -} - -func ParseJWT(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - return getJwtSecret(), nil - }) - if err != nil { - return nil, err - } - if claims, ok := token.Claims.(*Claims); ok && token.Valid { - return claims, nil - } - return nil, errors.New("invalid token") -} - -func ValidatePassword(pwd string) bool { - if len(pwd) < 8 { - return false - } - var hasUpper, hasLower, hasDigit, hasSpecial bool - for _, ch := range pwd { - if ch >= 'A' && ch <= 'Z' { - hasUpper = true - } else if ch >= 'a' && ch <= 'z' { - hasLower = true - } else if ch >= '0' && ch <= '9' { - hasDigit = true - } else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") { - hasSpecial = true - } - } - return hasUpper && hasLower && hasDigit && hasSpecial -} - -func InitAdminUser() { - count, err := CountUsers() - if err != nil { - log.Fatal("❌ 检查用户表失败:", err) - } - if count > 0 { - log.Println("✅ 已存在管理员账户,跳过初始化") - return - } - log.Println("⚠️ 首次启动,请设置管理员账户") - log.Print("用户名: ") - var username string - fmt.Scanln(&username) - if username == "" { - username = "admin" - } - for { - log.Print("密码 (至少8位,含大小写、数字、特殊字符): ") - var password string - fmt.Scanln(&password) - if ValidatePassword(password) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - log.Println("❌ 密码加密失败:", err) - continue - } - err = CreateUser(username, string(hash)) - if err != nil { - log.Println("❌ 创建用户失败:", err) - continue - } - log.Println("✅ 管理员账户创建成功!") - break - } else { - log.Println("❌ 密码不符合复杂度要求,请重新输入") - log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符") - } - } -} - -func AuthMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未提供认证令牌"}) - c.Abort() - return - } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"}) - c.Abort() - return - } - claims, err := ParseJWT(parts[1]) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"}) - c.Abort() - return - } - c.Set("username", claims.Username) - c.Next() - } -} diff --git a/bin/frpc_windows_amd64.exe b/bin/frpc_windows_amd64.exe deleted file mode 100644 index e8b0189..0000000 Binary files a/bin/frpc_windows_amd64.exe and /dev/null differ diff --git a/deploy.sh b/deploy.sh index 1ee7007..6b8f7b3 100644 --- a/deploy.sh +++ b/deploy.sh @@ -666,10 +666,10 @@ print_environment_summary() { print_title print_subtitle "环境检测结果" echo "" - echo -e " ${CYAN}操作系统:${NC} $OS $OS_VERSION" - echo -e " ${CYAN}CPU 架构:${NC} $ARCH" + echo -e " 操作系统: $OS $OS_VERSION" + echo -e " CPU 架构: $ARCH" echo "" - echo " ${CYAN}必要工具:${NC}" + echo " 必要工具:" if [ "$HAS_GIT" = true ]; then echo -e " git ✅ 已安装 ($(git --version | awk '{print $3}'))" else @@ -686,7 +686,7 @@ print_environment_summary() { echo " wget ❌ 未安装 (将自动安装)" fi echo "" - echo " ${CYAN}Docker 环境:${NC}" + echo " Docker 环境:" if [ "$HAS_DOCKER" = true ]; then echo -e " docker ✅ 已安装 ($(docker --version | awk '{print $3}' | tr -d ','))" else @@ -696,7 +696,7 @@ print_environment_summary() { fi if [ "$CONTAINER_EXISTS" = true ]; then echo "" - echo " ${CYAN}容器状态:${NC}" + echo " 容器状态:" if [ "$CONTAINER_RUNNING" = true ]; then echo -e " frpc-console ✅ 运行中" else diff --git a/frp.go b/frp.go deleted file mode 100644 index b525ee4..0000000 --- a/frp.go +++ /dev/null @@ -1,373 +0,0 @@ -package main - -import ( - "bytes" - "embed" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "syscall" - "text/template" -) - -//go:embed bin/* -var embeddedFrpc embed.FS - -//go:embed frpc.tmpl -var FrpcTemplateContent string - -var ( - cachedFrpcPath string - frpcPathMutex sync.Mutex -) - -func getFrpcPath() (string, error) { - frpcPathMutex.Lock() - defer frpcPathMutex.Unlock() - - if cachedFrpcPath != "" { - if _, err := os.Stat(cachedFrpcPath); err == nil { - return cachedFrpcPath, nil - } - cachedFrpcPath = "" - } - - var fileName string - switch { - case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": - fileName = "frpc_windows_amd64.exe" - case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": - fileName = "frpc_linux_amd64" - case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": - fileName = "frpc_linux_arm64" - case runtime.GOOS == "linux" && runtime.GOARCH == "arm": - fileName = "frpc_linux_arm_hf" - default: - path, err := exec.LookPath("frpc") - if err == nil { - cachedFrpcPath = path - return path, nil - } - return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) - } - - localPath := filepath.Join(".", "bin", fileName) - if _, err := os.Stat(localPath); err == nil { - cachedFrpcPath = localPath - return localPath, nil - } - - data, err := embeddedFrpc.ReadFile("bin/" + fileName) - if err == nil { - tmpPath := filepath.Join(os.TempDir(), "frpc") - if runtime.GOOS == "windows" { - tmpPath += ".exe" - } - if err := os.WriteFile(tmpPath, data, 0755); err == nil { - cachedFrpcPath = tmpPath - return tmpPath, nil - } - if _, statErr := os.Stat(tmpPath); statErr == nil { - cachedFrpcPath = tmpPath - return tmpPath, nil - } - } - - path, err := exec.LookPath("frpc") - if err == nil { - cachedFrpcPath = path - return path, nil - } - - return "", fmt.Errorf("未找到 frpc 文件") -} - -func GenerateFrpcConfig() error { - cfg, err := GetGlobalConfig() - if err != nil { - return fmt.Errorf("读取全局配置失败: %w", err) - } - - proxies, err := GetProxies() - if err != nil { - return fmt.Errorf("读取隧道列表失败: %w", err) - } - - var activeProxies []Proxy - for _, p := range proxies { - if p.Enabled { - activeProxies = append(activeProxies, p) - } - } - - data := struct { - *GlobalConfig - Proxies []Proxy - WireProtocolLine string - }{ - 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 fmt.Errorf("解析模板失败: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return fmt.Errorf("渲染模板失败: %w", err) - } - - if err := os.MkdirAll("./data", 0755); err != nil { - return fmt.Errorf("创建 data 目录失败: %w", err) - } - - if err := os.WriteFile("./data/frpc.toml", buf.Bytes(), 0644); err != nil { - return fmt.Errorf("写入配置文件失败: %w", err) - } - - return nil -} - -func isFrpcRunning() bool { - pidData, err := os.ReadFile("./data/frpc.pid") - if err != nil { - return false - } - pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) - if err != nil { - return false - } - - if runtime.GOOS == "windows" { - cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid)) - output, err := cmd.CombinedOutput() - if err != nil { - return false - } - return strings.Contains(string(output), strconv.Itoa(pid)) - } - - process, err := os.FindProcess(pid) - if err != nil { - return false - } - return process.Signal(syscall.Signal(0)) == nil -} - -func StartFrpc() error { - frpcPath, err := getFrpcPath() - if err != nil { - return fmt.Errorf("获取 frpc 路径失败: %w", err) - } - - if err := os.MkdirAll("./data", 0755); err != nil { - return fmt.Errorf("创建 data 目录失败: %w", err) - } - - if _, err := os.Stat("./data/frpc.toml"); os.IsNotExist(err) { - if err := GenerateFrpcConfig(); err != nil { - return fmt.Errorf("生成配置文件失败: %w", err) - } - } - - if isFrpcRunning() { - return nil - } - - os.Remove("./data/frpc.pid") - - cmd := exec.Command(frpcPath, "-c", "./data/frpc.toml") - setWindowHide(cmd) - setSysProcAttr(cmd) - - logFile, err := os.OpenFile("./data/frpc.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return fmt.Errorf("打开日志文件失败: %w", err) - } - cmd.Stdout = logFile - cmd.Stderr = logFile - - if err := cmd.Start(); err != nil { - return fmt.Errorf("启动 frpc 失败: %w", err) - } - - // 回收子进程(防止僵尸进程) - go func() { - if err := cmd.Wait(); err != nil { - log.Printf("frpc 子进程退出: %v", err) - } - // 子进程退出后清理 PID 文件 - os.Remove("./data/frpc.pid") - }() - - if err := os.WriteFile("./data/frpc.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { - return fmt.Errorf("保存 PID 失败: %w", err) - } - - return nil -} - -func StopFrpc() error { - if runtime.GOOS == "windows" { - cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") - if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { - return fmt.Errorf("停止 frpc 失败: %w", err) - } - os.Remove("./data/frpc.pid") - return nil - } - - pidData, err := os.ReadFile("./data/frpc.pid") - if err != nil { - cmd := exec.Command("pkill", "-f", "frpc") - if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { - return fmt.Errorf("停止 frpc 失败: %w", err) - } - return nil - } - - pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData))) - process, err := os.FindProcess(pid) - if err != nil { - os.Remove("./data/frpc.pid") - return nil - } - - if err := process.Kill(); err != nil { - return fmt.Errorf("杀死进程失败: %w", err) - } - - os.Remove("./data/frpc.pid") - return nil -} - -func GetFrpcStatus() (bool, error) { - return isFrpcRunning(), nil -} - -func ReloadFrpc() error { - running := isFrpcRunning() - if !running { - return StartFrpc() - } - - frpcPath, err := getFrpcPath() - if err != nil { - return fmt.Errorf("获取 frpc 路径失败: %w", err) - } - - cmd := exec.Command(frpcPath, "reload", "-c", "./data/frpc.toml") - _, err = cmd.CombinedOutput() - if err != nil { - log.Printf("⚠️ 热加载失败 (%v),自动降级为重启 frpc", err) - if stopErr := StopFrpc(); stopErr != nil { - return fmt.Errorf("停止 frpc 失败: %w", stopErr) - } - if startErr := StartFrpc(); startErr != nil { - return fmt.Errorf("启动 frpc 失败: %w", startErr) - } - return nil - } - return nil -} - -// 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/frp_other.go b/frp_other.go deleted file mode 100644 index 068ef7f..0000000 --- a/frp_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package main - -import "os/exec" - -func setWindowHide(cmd *exec.Cmd) { - // 非 Windows 平台什么都不做 -} diff --git a/frp_windows.go b/frp_windows.go deleted file mode 100644 index b776362..0000000 --- a/frp_windows.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build windows - -package main - -import ( - "os/exec" - "syscall" -) - -// setWindowHide Windows 隐藏窗口 -func setWindowHide(cmd *exec.Cmd) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} - } - cmd.SysProcAttr.HideWindow = true -} - -// setSysProcAttr Windows 不需要 Setsid,空操作 -func setSysProcAttr(cmd *exec.Cmd) { - // Windows 不需要 Setsid,什么都不做 -} diff --git a/go.mod b/go.mod index 4161340..864447a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 golang.org/x/crypto v0.54.0 + golang.org/x/sys v0.47.0 modernc.org/sqlite v1.54.0 ) @@ -32,14 +33,13 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.10 // indirect modernc.org/libc v1.74.1 // indirect diff --git a/go.sum b/go.sum index 935b577..b73ae87 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/api.go b/internal/api/handler.go similarity index 70% rename from api.go rename to internal/api/handler.go index 9ba667c..52727b3 100644 --- a/api.go +++ b/internal/api/handler.go @@ -1,86 +1,28 @@ -package main +package api import ( "bytes" - "embed" "fmt" - "io/fs" + "html/template" "net" "net/http" "strconv" - "text/template" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + + "frpc-console/internal/auth" + "frpc-console/internal/db" + "frpc-console/internal/frp" ) -//go:embed static/* -var staticFS embed.FS +// ================================================================ +// 认证 Handler +// ================================================================ -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) - - // ---- 需要认证的路由 ---- - auth := api.Group("/") - auth.Use(AuthMiddleware()) - { - auth.GET("/config", getConfigHandler) - auth.PUT("/config", updateConfigHandler) - - auth.GET("/proxies", getProxiesHandler) - auth.GET("/proxy/:id", getProxyHandler) - auth.POST("/proxy", createProxyHandler) - auth.PUT("/proxy/:id", updateProxyHandler) - auth.DELETE("/proxy/:id", deleteProxyHandler) - - auth.POST("/frpc/reload", reloadFrpcHandler) - auth.POST("/frpc/start", startFrpcHandler) - auth.POST("/frpc/stop", stopFrpcHandler) - auth.GET("/frpc/status", getFrpcStatusHandler) - auth.GET("/frpc/log", getFrpcLogHandler) - - auth.POST("/import/toml", importTomlHandler) - auth.GET("/export/toml", ExportTomlHandler) - - auth.PUT("/user/password", changePasswordHandler) - } - } - - return r -} - -// ========== 认证 Handler ========== - -func checkUsersHandler(c *gin.Context) { - count, err := CountUsers() +func CheckUsersHandler(c *gin.Context) { + count, err := db.CountUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"}) return @@ -91,7 +33,7 @@ func checkUsersHandler(c *gin.Context) { }) } -func registerHandler(c *gin.Context) { +func RegisterHandler(c *gin.Context) { var req struct { Username string `json:"username"` Password string `json:"password"` @@ -101,7 +43,7 @@ func registerHandler(c *gin.Context) { return } - count, err := CountUsers() + count, err := db.CountUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"}) return @@ -116,7 +58,7 @@ func registerHandler(c *gin.Context) { return } - if !ValidatePassword(req.Password) { + if !auth.ValidatePassword(req.Password) { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) return } @@ -127,12 +69,12 @@ func registerHandler(c *gin.Context) { return } - if err := CreateUser(req.Username, string(hash)); err != nil { + if err := db.CreateUser(req.Username, string(hash)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建用户失败: " + err.Error()}) return } - token, err := GenerateJWT(req.Username) + token, err := auth.GenerateJWT(req.Username) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"}) return @@ -145,7 +87,7 @@ func registerHandler(c *gin.Context) { }) } -func loginHandler(c *gin.Context) { +func LoginHandler(c *gin.Context) { var req struct { Username string `json:"username"` Password string `json:"password"` @@ -155,7 +97,7 @@ func loginHandler(c *gin.Context) { return } - user, err := GetUserByUsername(req.Username) + user, err := db.GetUserByUsername(req.Username) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"}) return @@ -166,7 +108,7 @@ func loginHandler(c *gin.Context) { return } - token, err := GenerateJWT(user.Username) + token, err := auth.GenerateJWT(user.Username) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"}) return @@ -179,7 +121,7 @@ func loginHandler(c *gin.Context) { }) } -func changePasswordHandler(c *gin.Context) { +func ChangePasswordHandler(c *gin.Context) { var req struct { OldPassword string `json:"oldPassword"` NewPassword string `json:"newPassword"` @@ -189,13 +131,13 @@ func changePasswordHandler(c *gin.Context) { return } - username, exists := c.Get("username") - if !exists { + username := auth.GetUsernameFromContext(c) + if username == "" { c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未登录"}) return } - user, err := GetUserByUsername(username.(string)) + user, err := db.GetUserByUsername(username) if err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "用户不存在"}) return @@ -206,7 +148,7 @@ func changePasswordHandler(c *gin.Context) { return } - if !ValidatePassword(req.NewPassword) { + if !auth.ValidatePassword(req.NewPassword) { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) return } @@ -217,7 +159,7 @@ func changePasswordHandler(c *gin.Context) { return } - if err := UpdatePassword(username.(string), string(hash)); err != nil { + if err := db.UpdateUserPassword(username, string(hash)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新密码失败"}) return } @@ -225,10 +167,12 @@ func changePasswordHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"}) } -// ========== 配置 Handler ========== +// ================================================================ +// 配置 Handler +// ================================================================ -func getConfigHandler(c *gin.Context) { - cfg, err := GetGlobalConfig() +func GetConfigHandler(c *gin.Context) { + cfg, err := db.GetGlobalConfig() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) return @@ -236,25 +180,30 @@ func getConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": cfg}) } -func updateConfigHandler(c *gin.Context) { - var cfg GlobalConfig +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 := UpdateGlobalConfig(&cfg); err != nil { + // AdminPort 已从 JSON 绑定,直接使用 + if cfg.AdminPort <= 0 { + cfg.AdminPort = 7400 // 如果前端没传,默认 7400 + } + + if err := db.UpdateGlobalConfig(&cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) return } - if err := GenerateFrpcConfig(); err != nil { + if err := frp.GenerateConfig(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()}) return } - if err := ReloadFrpc(); err != nil { + if err := frp.Reload(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()}) return } @@ -262,10 +211,12 @@ func updateConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) } -// ========== 隧道 Handler ========== +// ================================================================ +// 隧道 Handler +// ================================================================ -func getProxiesHandler(c *gin.Context) { - proxies, err := GetProxies() +func GetProxiesHandler(c *gin.Context) { + proxies, err := db.GetProxies() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取隧道列表失败"}) return @@ -273,13 +224,13 @@ func getProxiesHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": proxies}) } -func getProxyHandler(c *gin.Context) { +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 := GetProxy(id) + p, err := db.GetProxy(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "隧道不存在"}) return @@ -287,15 +238,15 @@ func getProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": p}) } -func createProxyHandler(c *gin.Context) { - var p Proxy +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 := CreateProxy(&p); err != nil { + if err := db.CreateProxy(&p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建隧道失败: " + err.Error()}) return } @@ -308,20 +259,20 @@ func createProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道创建成功", "data": gin.H{"id": p.ID}}) } -func updateProxyHandler(c *gin.Context) { +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 Proxy + 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 := UpdateProxy(&p); err != nil { + if err := db.UpdateProxy(&p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新隧道失败: " + err.Error()}) return } @@ -334,14 +285,14 @@ func updateProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道更新成功"}) } -func deleteProxyHandler(c *gin.Context) { +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 := DeleteProxy(id); err != nil { + if err := db.DeleteProxy(id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "删除隧道失败: " + err.Error()}) return } @@ -354,80 +305,59 @@ func deleteProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道删除成功"}) } -// ========== frpc 进程管理 Handler ========== +// ================================================================ +// frpc 进程管理 Handler +// ================================================================ -func reloadFrpcHandler(c *gin.Context) { - if err := GenerateFrpcConfig(); err != nil { +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 := ReloadFrpc(); err != nil { + 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 := StartFrpc(); err != nil { +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 := StopFrpc(); err != nil { +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) { - running, err := GetFrpcStatus() +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": gin.H{"running": running}}) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": status}) } -// ========== 日志 Handler ========== +// ================================================================ +// Ping Handler +// ================================================================ -func getFrpcLogHandler(c *gin.Context) { - // readTailLog 在 frp.go 中定义,读取 ./data/frpc.log - lines, err := 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), - }, - }) -} - -// ========== Ping Handler ========== - -func pingHandler(c *gin.Context) { +func PingHandler(c *gin.Context) { target := c.Query("target") if target == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "缺少 target 参数"}) return } - cfg, err := GetGlobalConfig() + cfg, err := db.GetGlobalConfig() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) return @@ -448,9 +378,57 @@ func pingHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "latency": latency}) } -// ========== 导入/导出 TOML ========== +// ================================================================ +// 日志 Handler +// ================================================================ -func importTomlHandler(c *gin.Context) { +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 +} + +// ================================================================ +// 导入/导出 TOML +// ================================================================ + +func ImportTomlHandler(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请选择文件"}) @@ -470,7 +448,7 @@ func importTomlHandler(c *gin.Context) { return } - parsed, err := ParseToml(buf.String()) + parsed, err := frp.ParseToml(buf.String()) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "解析 TOML 失败: " + err.Error()}) return @@ -478,30 +456,30 @@ func importTomlHandler(c *gin.Context) { cfg := parsed.ToGlobalConfig() cfg.TcpMux = true - if err := UpdateGlobalConfig(cfg); err != nil { + if err := db.UpdateGlobalConfig(cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) return } - if _, err := DB.Exec("DELETE FROM proxies"); err != nil { + if _, err := db.DB.Exec("DELETE FROM proxies"); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "清空隧道失败"}) return } proxies := parsed.ToProxies() for _, p := range proxies { - if err := CreateProxy(&p); err != nil { + if err := db.CreateProxy(&p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "导入隧道失败: " + err.Error()}) return } } - if err := GenerateFrpcConfig(); err != nil { + if err := frp.GenerateConfig(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) return } - if err := ReloadFrpc(); err != nil { + if err := frp.Reload(); err != nil { c.JSON(http.StatusOK, gin.H{ "code": 0, "msg": fmt.Sprintf("导入成功!共 %d 条隧道,但热加载失败: %s", len(proxies), err.Error()), @@ -516,19 +494,19 @@ func importTomlHandler(c *gin.Context) { } func ExportTomlHandler(c *gin.Context) { - cfg, err := GetGlobalConfig() + cfg, err := db.GetGlobalConfig() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败: " + err.Error()}) return } - proxies, err := GetProxies() + proxies, err := db.GetProxies() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取隧道失败: " + err.Error()}) return } - var activeProxies []Proxy + var activeProxies []db.Proxy for _, p := range proxies { if p.Enabled { activeProxies = append(activeProxies, p) @@ -536,8 +514,8 @@ func ExportTomlHandler(c *gin.Context) { } data := struct { - *GlobalConfig - Proxies []Proxy + *db.GlobalConfig + Proxies []db.Proxy WireProtocolLine string }{ GlobalConfig: cfg, @@ -550,7 +528,8 @@ func ExportTomlHandler(c *gin.Context) { data.WireProtocolLine = "" } - tmpl, err := template.New("frpc").Parse(FrpcTemplateContent) + // 这里需要 frp.FrpcTemplateContent,需要从 frp 包导出 + tmpl, err := template.New("frpc").Parse(frp.FrpcTemplateContent) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()}) return @@ -566,10 +545,3 @@ func ExportTomlHandler(c *gin.Context) { c.Header("Content-Disposition", "attachment; filename=frpc.toml") c.String(http.StatusOK, buf.String()) } - -func generateAndReload() error { - if err := GenerateFrpcConfig(); err != nil { - return err - } - return ReloadFrpc() -} 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/static/app.js b/internal/api/static/app.js similarity index 99% rename from static/app.js rename to internal/api/static/app.js index 112e7fe..17aa017 100644 --- a/static/app.js +++ b/internal/api/static/app.js @@ -95,6 +95,7 @@ const defaultConfig = { serverAddr: "frp.example.com", serverPort: 7000, token: "CHANGE_ME", + adminPort: 7400, logLevel: "info", logMaxDays: 3, tcpMux: true, @@ -181,7 +182,7 @@ async function getFrpcStatus() { try { const data = await apiFetch("/frpc/status"); if (data.code === 0) { - return data.data.running || false; + return data.data.phase === "RUNNING"; } } catch (e) { console.warn("获取 frpc 状态失败", e); diff --git a/static/fonts/HarmonyOS_Sans_SC_Regular.ttf b/internal/api/static/fonts/HarmonyOS_Sans_SC_Regular.ttf similarity index 100% rename from static/fonts/HarmonyOS_Sans_SC_Regular.ttf rename to internal/api/static/fonts/HarmonyOS_Sans_SC_Regular.ttf diff --git a/static/index.html b/internal/api/static/index.html similarity index 98% rename from static/index.html rename to internal/api/static/index.html index 9649269..278aa98 100644 --- a/static/index.html +++ b/internal/api/static/index.html @@ -201,6 +201,10 @@ +
+ + +
diff --git a/static/logo.svg b/internal/api/static/logo.svg similarity index 100% rename from static/logo.svg rename to internal/api/static/logo.svg diff --git a/static/style-1.css b/internal/api/static/style-1.css similarity index 100% rename from static/style-1.css rename to internal/api/static/style-1.css diff --git a/static/style-2.css b/internal/api/static/style-2.css similarity index 100% rename from static/style-2.css rename to internal/api/static/style-2.css diff --git a/static/style-3.css b/internal/api/static/style-3.css similarity index 100% rename from static/style-3.css rename to internal/api/static/style-3.css diff --git a/static/style-4.css b/internal/api/static/style-4.css similarity index 100% rename from static/style-4.css rename to internal/api/static/style-4.css diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..4b8bd05 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,234 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "frpc-console/internal/db" +) + +var jwtSecretCache []byte + +// getJwtSecret 从数据库获取 JWT 密钥 +func getJwtSecret() []byte { + if len(jwtSecretCache) > 0 { + return jwtSecretCache + } + secret, err := db.GetJwtSecret() + if err != nil { + // 如果数据库还没有密钥,生成一个默认的(仅用于开发) + // 生产环境应该通过数据库初始化时生成 + return []byte("frpc-console-default-secret-key-2024") + } + jwtSecretCache = []byte(secret) + return jwtSecretCache +} + +// Claims JWT 声明 +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +// GenerateJWT 生成 JWT +func GenerateJWT(username string) (string, error) { + claims := Claims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getJwtSecret()) +} + +// ParseJWT 解析 JWT +func ParseJWT(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return getJwtSecret(), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} + +// ValidatePassword 校验密码复杂度 +// 要求: 至少8位,包含大小写字母、数字、特殊字符 +func ValidatePassword(pwd string) bool { + if len(pwd) < 8 { + return false + } + var hasUpper, hasLower, hasDigit, hasSpecial bool + for _, ch := range pwd { + switch { + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= '0' && ch <= '9': + hasDigit = true + case strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?"): + hasSpecial = true + } + } + return hasUpper && hasLower && hasDigit && hasSpecial +} + +// HashPassword 加密密码 +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword 验证密码 +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// ================================================================ +// 登录 / 注册 / 修改密码 的业务逻辑 (供 handler 调用) +// ================================================================ + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Token string `json:"token"` +} + +// Login 登录业务逻辑 +func Login(username, password string) (string, error) { + user, err := db.GetUserByUsername(username) + if err != nil { + return "", errors.New("用户不存在") + } + if !CheckPassword(password, user.PasswordHash) { + return "", errors.New("密码错误") + } + return GenerateJWT(username) +} + +// RegisterRequest 注册请求 +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Register 注册业务逻辑 +func Register(username, password string) (string, error) { + // 检查用户名是否已存在 + exists, err := db.UserExists(username) + if err != nil { + return "", err + } + if exists { + return "", errors.New("用户名已存在") + } + // 密码复杂度校验 + if !ValidatePassword(password) { + return "", errors.New("密码不符合复杂度要求") + } + // 加密密码 + hash, err := HashPassword(password) + if err != nil { + return "", err + } + // 创建用户 + if err := db.CreateUser(username, hash); err != nil { + return "", err + } + // 生成 JWT + return GenerateJWT(username) +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` +} + +// ChangePassword 修改密码业务逻辑 +func ChangePassword(username, oldPassword, newPassword string) error { + user, err := db.GetUserByUsername(username) + if err != nil { + return errors.New("用户不存在") + } + if !CheckPassword(oldPassword, user.PasswordHash) { + return errors.New("原密码错误") + } + if !ValidatePassword(newPassword) { + return errors.New("新密码不符合复杂度要求") + } + hash, err := HashPassword(newPassword) + if err != nil { + return err + } + return db.UpdateUserPassword(username, hash) +} + +// HasUsers 检查是否存在用户 +func HasUsers() (bool, error) { + count, err := db.CountUsers() + if err != nil { + return false, err + } + return count > 0, nil +} + +// ================================================================ +// Gin 中间件 +// ================================================================ + +// AuthMiddleware JWT 认证中间件 +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未提供认证令牌"}) + c.Abort() + return + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"}) + c.Abort() + return + } + claims, err := ParseJWT(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"}) + c.Abort() + return + } + c.Set("username", claims.Username) + c.Next() + } +} + +// GetUsernameFromContext 从 gin.Context 获取当前用户名 +func GetUsernameFromContext(c *gin.Context) string { + username, exists := c.Get("username") + if !exists { + return "" + } + return username.(string) +} diff --git a/db.go b/internal/db/db.go similarity index 62% rename from db.go rename to internal/db/db.go index 178abdb..4842684 100644 --- a/db.go +++ b/internal/db/db.go @@ -1,4 +1,4 @@ -package main +package db import ( "crypto/rand" @@ -17,42 +17,13 @@ import ( var DB *sql.DB -const SchemaVersion = "v2" +const SchemaVersion = "v3" -type GlobalConfig struct { - ID int `json:"id"` - ServerAddr string `json:"serverAddr"` - ServerPort int `json:"serverPort"` - Token string `json:"token"` - LogLevel string `json:"logLevel"` - LogMaxDays int `json:"logMaxDays"` - TcpMux bool `json:"tcpMux"` - TcpMuxKeepalive int `json:"tcpMuxKeepalive"` - HeartbeatInterval int `json:"heartbeatInterval"` - HeartbeatTimeout int `json:"heartbeatTimeout"` - PoolCount int `json:"poolCount"` - WireProtocolV2 bool `json:"wireProtocolV2"` -} - -type Proxy struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - LocalIP string `json:"localIP"` - LocalPort int `json:"localPort"` - RemotePort int `json:"remotePort"` - Enabled bool `json:"enabled"` -} - -type User struct { - ID int `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - CreatedAt string `json:"createdAt"` -} +// ================================================================ +// 初始化 +// ================================================================ func InitDB() error { - // 确保 data 目录存在 if err := os.MkdirAll("./data", 0755); err != nil { return fmt.Errorf("创建数据目录失败: %w", err) } @@ -81,6 +52,7 @@ func InitDB() error { } func createTables() error { + // users 表 _, err := DB.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -94,12 +66,14 @@ func createTables() error { return err } + // global_config 表 _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS global_config ( id INTEGER PRIMARY KEY CHECK (id = 1), server_addr TEXT NOT NULL DEFAULT 'frp.example.com', server_port INTEGER NOT NULL DEFAULT 7000, token TEXT NOT NULL DEFAULT 'CHANGE_ME', + admin_port INTEGER NOT NULL DEFAULT 7400, log_level TEXT NOT NULL DEFAULT 'info', log_max_days INTEGER NOT NULL DEFAULT 3, tcp_mux INTEGER NOT NULL DEFAULT 1, @@ -115,6 +89,7 @@ func createTables() error { return err } + // proxies 表 _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS proxies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -132,6 +107,7 @@ func createTables() error { return err } + // app_config 表 _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, @@ -143,15 +119,16 @@ func createTables() error { return err } + // 初始化 global_config 默认值(新数据库) var count int DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) if count == 0 { _, err = DB.Exec(` INSERT INTO global_config ( - id, server_addr, server_port, token, log_level, log_max_days, + id, server_addr, server_port, token, admin_port, log_level, log_max_days, tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count, wire_protocol_v2 - ) VALUES (1, 'frp.example.com', 7000, 'CHANGE_ME', 'info', 3, 1, 30, 15, 70, 8, 0) + ) VALUES (1, 'frp.example.com', 7000, 'CHANGE_ME', 7400, 'info', 3, 1, 30, 15, 70, 8, 0) `) if err != nil { return err @@ -162,6 +139,116 @@ func createTables() error { return nil } +// ================================================================ +// v2 → v3 重型迁移:global_config 表添加 admin_port +// ================================================================ + +func migrateGlobalConfigToV3() error { + log.Println(" 开始 global_config 表迁移 (v2→v3)") + + // 1. 检查 admin_port 列是否已存在 + var hasAdminPort bool + rows, err := DB.Query("PRAGMA table_info(global_config)") + if err != nil { + return fmt.Errorf("查询表结构失败: %w", err) + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return err + } + if name == "admin_port" { + hasAdminPort = true + break + } + } + + if hasAdminPort { + log.Println(" ✅ admin_port 列已存在,跳过迁移") + return nil + } + + log.Println(" 创建 global_config_new 表...") + + _, err = DB.Exec(` + CREATE TABLE global_config_new ( + id INTEGER PRIMARY KEY CHECK (id = 1), + server_addr TEXT NOT NULL DEFAULT 'frp.example.com', + server_port INTEGER NOT NULL DEFAULT 7000, + token TEXT NOT NULL DEFAULT 'CHANGE_ME', + admin_port INTEGER NOT NULL DEFAULT 7400, + log_level TEXT NOT NULL DEFAULT 'info', + log_max_days INTEGER NOT NULL DEFAULT 3, + tcp_mux INTEGER NOT NULL DEFAULT 1, + tcp_mux_keepalive INTEGER NOT NULL DEFAULT 30, + heartbeat_interval INTEGER NOT NULL DEFAULT 15, + heartbeat_timeout INTEGER NOT NULL DEFAULT 70, + pool_count INTEGER NOT NULL DEFAULT 8, + wire_protocol_v2 INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("创建 global_config_new 表失败: %w", err) + } + + log.Println(" 迁移数据 (admin_port = 7400)...") + + _, err = DB.Exec(` + INSERT INTO global_config_new ( + id, server_addr, server_port, token, admin_port, + log_level, log_max_days, tcp_mux, tcp_mux_keepalive, + heartbeat_interval, heartbeat_timeout, pool_count, + wire_protocol_v2, updated_at + ) + SELECT + id, server_addr, server_port, token, 7400, + log_level, log_max_days, tcp_mux, tcp_mux_keepalive, + heartbeat_interval, heartbeat_timeout, pool_count, + wire_protocol_v2, updated_at + FROM global_config + `) + if err != nil { + return fmt.Errorf("复制数据失败: %w", err) + } + + var oldCount, newCount int + DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&oldCount) + DB.QueryRow("SELECT COUNT(*) FROM global_config_new").Scan(&newCount) + + if oldCount != newCount { + return fmt.Errorf("数据迁移不完整: 旧表 %d 行,新表 %d 行", oldCount, newCount) + } + log.Printf(" 数据迁移验证通过: %d 行", newCount) + + log.Println(" 交换表名...") + + if _, err := DB.Exec("ALTER TABLE global_config RENAME TO global_config_old"); err != nil { + return fmt.Errorf("重命名旧表失败: %w", err) + } + + if _, err := DB.Exec("ALTER TABLE global_config_new RENAME TO global_config"); err != nil { + DB.Exec("ALTER TABLE global_config_old RENAME TO global_config") + return fmt.Errorf("重命名新表失败: %w", err) + } + + if _, err := DB.Exec("DROP TABLE global_config_old"); err != nil { + log.Printf("⚠️ 删除旧表失败(不影响使用): %v", err) + } + + log.Println(" ✅ global_config 表迁移完成") + return nil +} + +// ================================================================ +// Schema 版本管理 +// ================================================================ + func getCurrentSchemaVersion() string { var version string err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'schema_version'").Scan(&version) @@ -174,7 +261,7 @@ func getCurrentSchemaVersion() string { } return SchemaVersion } - log.Printf("⚠️ 读取 Schema 版本失败: %v", err) + log.Printf("⚠️ 读取 Schema 版本失败: %v", err) return "v1" } return version @@ -238,6 +325,10 @@ func restoreDatabase(backupPath string) error { return nil } +// ================================================================ +// runMigrations - 核心迁移入口 +// ================================================================ + func runMigrations() error { currentVer := getCurrentSchemaVersion() targetVer := SchemaVersion @@ -265,33 +356,37 @@ func runMigrations() error { log.Printf("📦 备份文件: %s", backupPath) } - currentSchema := getSchemaDef(currentVer) - targetSchema := getSchemaDef(targetVer) - - if targetSchema == nil { - return fmt.Errorf("目标 Schema 版本 %s 未在 schemaHistory 中定义", targetVer) + // ---- 阶段1: v1 → v2(proxies 表迁移) ---- + if currentVer == "v1" { + oldDef := getSchemaDef("v1") + newDef := getSchemaDef("v2") + if oldDef == nil || newDef == nil { + return fmt.Errorf("v1 或 v2 schema 定义不存在") + } + if !schemaVersionsEqual(oldDef, newDef) { + log.Println(" 阶段1: v1→v2 重型迁移(proxies 表结构变更)") + if err := heavyMigration(oldDef, newDef); err != nil { + if backupPath != "" { + restoreDatabase(backupPath) + } + return fmt.Errorf("v1→v2 迁移失败: %w", err) + } + } else { + log.Println(" 阶段1: v1→v2 轻量迁移(proxies 表结构无变更)") + } + currentVer = "v2" } - if currentSchema == nil || schemaVersionsEqual(currentSchema, targetSchema) { - log.Println(" 迁移类型: 轻量复制(Schema 无变更)") - var userCount int - err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) - if err != nil || userCount == 0 { - log.Println(" 数据库为空或无效,跳过迁移,直接初始化") - return nil - } - log.Println(" 数据库有效,继续使用") - } else { - log.Println(" 迁移类型: 重型迁移(Schema 有变更,新建表 + 搬数据)") - if err := heavyMigration(currentSchema, targetSchema); err != nil { + // ---- 阶段2: v2 → v3(global_config 表新增 admin_port) ---- + if currentVer == "v2" { + log.Println(" 阶段2: v2→v3 重型迁移(global_config 表新增 admin_port)") + if err := migrateGlobalConfigToV3(); err != nil { if backupPath != "" { - log.Printf("❌ 迁移失败,尝试恢复备份: %s", backupPath) - if restoreErr := restoreDatabase(backupPath); restoreErr != nil { - log.Printf("⚠️ 恢复备份失败: %v", restoreErr) - } + restoreDatabase(backupPath) } - return fmt.Errorf("重型迁移失败: %w", err) + return fmt.Errorf("v2→v3 迁移失败: %w", err) } + currentVer = "v3" } if err := setSchemaVersion(targetVer); err != nil { @@ -302,9 +397,13 @@ func runMigrations() error { return nil } +// ================================================================ +// 重型迁移引擎(用于 proxies 表) +// ================================================================ + func heavyMigration(oldDef, newDef *SchemaVersionDef) error { if oldDef == nil { - return fmt.Errorf("旧 Schema 定义为空,无法执行重型迁移") + return fmt.Errorf("旧 Schema 定义为空") } oldTable := oldDef.TableName @@ -342,7 +441,7 @@ func heavyMigration(oldDef, newDef *SchemaVersionDef) error { return fmt.Errorf("重命名新表失败: %w", err) } if _, err := DB.Exec(fmt.Sprintf("DROP TABLE %s", tempTable)); err != nil { - log.Printf("⚠️ 删除临时表失败(不影响使用): %v", err) + log.Printf("⚠️ 删除临时表失败(不影响使用): %v", err) } log.Printf(" 表交换完成: %s (新表已生效)", oldTable) @@ -419,6 +518,10 @@ func buildInsertSQL(oldTable, newTable string, oldDef, newDef *SchemaVersionDef) ), nil } +// ================================================================ +// JWT 密钥管理 +// ================================================================ + func ensureJwtSecret() error { var value string err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value) @@ -450,133 +553,3 @@ func GetJwtSecret() (string, error) { } return secret, nil } - -func GetGlobalConfig() (*GlobalConfig, error) { - var cfg GlobalConfig - err := DB.QueryRow(` - SELECT id, server_addr, server_port, token, log_level, log_max_days, - tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count, - wire_protocol_v2 - FROM global_config WHERE id = 1 - `).Scan( - &cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token, - &cfg.LogLevel, &cfg.LogMaxDays, &cfg.TcpMux, &cfg.TcpMuxKeepalive, - &cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount, - &cfg.WireProtocolV2, - ) - if err != nil { - return nil, err - } - cfg.TcpMux = true - return &cfg, nil -} - -func UpdateGlobalConfig(cfg *GlobalConfig) error { - _, err := DB.Exec(` - UPDATE global_config SET - server_addr = ?, server_port = ?, token = ?, log_level = ?, log_max_days = ?, - tcp_mux = 1, - tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?, - wire_protocol_v2 = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = 1 - `, cfg.ServerAddr, cfg.ServerPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays, - cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount, - cfg.WireProtocolV2) - return err -} - -func GetProxies() ([]Proxy, error) { - rows, err := DB.Query(` - SELECT id, name, type, local_ip, local_port, remote_port, enabled - FROM proxies ORDER BY id - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var proxies []Proxy - for rows.Next() { - var p Proxy - err := rows.Scan(&p.ID, &p.Name, &p.Type, &p.LocalIP, &p.LocalPort, &p.RemotePort, &p.Enabled) - if err != nil { - return nil, err - } - proxies = append(proxies, p) - } - return proxies, rows.Err() -} - -func GetProxy(id int) (*Proxy, error) { - var p Proxy - err := DB.QueryRow(` - SELECT id, name, type, local_ip, local_port, remote_port, enabled - FROM proxies WHERE id = ? - `, id).Scan(&p.ID, &p.Name, &p.Type, &p.LocalIP, &p.LocalPort, &p.RemotePort, &p.Enabled) - if err != nil { - return nil, err - } - return &p, nil -} - -func CreateProxy(p *Proxy) error { - result, err := DB.Exec(` - INSERT INTO proxies (name, type, local_ip, local_port, remote_port, enabled) - VALUES (?, ?, ?, ?, ?, ?) - `, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled) - if err != nil { - return err - } - id, _ := result.LastInsertId() - p.ID = int(id) - return nil -} - -func UpdateProxy(p *Proxy) error { - _, err := DB.Exec(` - UPDATE proxies SET - name = ?, type = ?, local_ip = ?, local_port = ?, remote_port = ?, enabled = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled, p.ID) - return err -} - -func DeleteProxy(id int) error { - _, err := DB.Exec("DELETE FROM proxies WHERE id = ?", id) - return err -} - -func GetUserByUsername(username string) (*User, error) { - var u User - err := DB.QueryRow(` - SELECT id, username, password_hash, created_at - FROM users WHERE username = ? - `, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) - if err != nil { - return nil, err - } - return &u, nil -} - -func CreateUser(username, passwordHash string) error { - _, err := DB.Exec(` - INSERT INTO users (username, password_hash) VALUES (?, ?) - `, username, passwordHash) - return err -} - -func UpdatePassword(username, passwordHash string) error { - _, err := DB.Exec(` - UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP - WHERE username = ? - `, passwordHash, username) - return err -} - -func CountUsers() (int, error) { - var count int - err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) - return count, err -} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..a10277f --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,37 @@ +package db + +// GlobalConfig 全局配置 +type GlobalConfig struct { + ID int `json:"id"` + ServerAddr string `json:"serverAddr"` + ServerPort int `json:"serverPort"` + Token string `json:"token"` + AdminPort int `json:"adminPort"` // ← 新增 + LogLevel string `json:"logLevel"` + LogMaxDays int `json:"logMaxDays"` + TcpMux bool `json:"tcpMux"` + TcpMuxKeepalive int `json:"tcpMuxKeepalive"` + HeartbeatInterval int `json:"heartbeatInterval"` + HeartbeatTimeout int `json:"heartbeatTimeout"` + PoolCount int `json:"poolCount"` + WireProtocolV2 bool `json:"wireProtocolV2"` +} + +// Proxy 隧道配置 +type Proxy struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + LocalIP string `json:"localIP"` + LocalPort int `json:"localPort"` + RemotePort int `json:"remotePort"` + Enabled bool `json:"enabled"` +} + +// User 用户 +type User struct { + ID int `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt string `json:"createdAt"` +} diff --git a/internal/db/repository.go b/internal/db/repository.go new file mode 100644 index 0000000..5d2c15c --- /dev/null +++ b/internal/db/repository.go @@ -0,0 +1,155 @@ +package db + +// ================================================================ +// 全局配置 +// ================================================================ + +// internal/db/repository.go + +func GetGlobalConfig() (*GlobalConfig, error) { + var cfg GlobalConfig + err := DB.QueryRow(` + SELECT id, server_addr, server_port, token, admin_port, + log_level, log_max_days, + tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count, + wire_protocol_v2 + FROM global_config WHERE id = 1 + `).Scan( + &cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token, &cfg.AdminPort, + &cfg.LogLevel, &cfg.LogMaxDays, + &cfg.TcpMux, &cfg.TcpMuxKeepalive, + &cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount, + &cfg.WireProtocolV2, + ) + if err != nil { + return nil, err + } + cfg.TcpMux = true + return &cfg, nil +} + +func UpdateGlobalConfig(cfg *GlobalConfig) error { + _, err := DB.Exec(` + UPDATE global_config SET + server_addr = ?, server_port = ?, token = ?, admin_port = ?, + log_level = ?, log_max_days = ?, + tcp_mux = 1, + tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?, + wire_protocol_v2 = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, cfg.ServerAddr, cfg.ServerPort, cfg.Token, cfg.AdminPort, + cfg.LogLevel, cfg.LogMaxDays, + cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount, + cfg.WireProtocolV2) + return err +} + +// ================================================================ +// 隧道管理 +// ================================================================ + +func GetProxies() ([]Proxy, error) { + rows, err := DB.Query(` + SELECT id, name, type, local_ip, local_port, remote_port, enabled + FROM proxies ORDER BY id + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var proxies []Proxy + for rows.Next() { + var p Proxy + err := rows.Scan(&p.ID, &p.Name, &p.Type, &p.LocalIP, &p.LocalPort, &p.RemotePort, &p.Enabled) + if err != nil { + return nil, err + } + proxies = append(proxies, p) + } + return proxies, rows.Err() +} + +func GetProxy(id int) (*Proxy, error) { + var p Proxy + err := DB.QueryRow(` + SELECT id, name, type, local_ip, local_port, remote_port, enabled + FROM proxies WHERE id = ? + `, id).Scan(&p.ID, &p.Name, &p.Type, &p.LocalIP, &p.LocalPort, &p.RemotePort, &p.Enabled) + if err != nil { + return nil, err + } + return &p, nil +} + +func CreateProxy(p *Proxy) error { + result, err := DB.Exec(` + INSERT INTO proxies (name, type, local_ip, local_port, remote_port, enabled) + VALUES (?, ?, ?, ?, ?, ?) + `, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled) + if err != nil { + return err + } + id, _ := result.LastInsertId() + p.ID = int(id) + return nil +} + +func UpdateProxy(p *Proxy) error { + _, err := DB.Exec(` + UPDATE proxies SET + name = ?, type = ?, local_ip = ?, local_port = ?, remote_port = ?, enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled, p.ID) + return err +} + +func DeleteProxy(id int) error { + _, err := DB.Exec("DELETE FROM proxies WHERE id = ?", id) + return err +} + +// ================================================================ +// 用户管理 +// ================================================================ + +func GetUserByUsername(username string) (*User, error) { + var u User + err := DB.QueryRow(` + SELECT id, username, password_hash, created_at + FROM users WHERE username = ? + `, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func UserExists(username string) (bool, error) { + var count int + err := DB.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count) + return count > 0, err +} + +func CreateUser(username, passwordHash string) error { + _, err := DB.Exec(` + INSERT INTO users (username, password_hash) VALUES (?, ?) + `, username, passwordHash) + return err +} + +func UpdateUserPassword(username, passwordHash string) error { + _, err := DB.Exec(` + UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE username = ? + `, passwordHash, username) + return err +} + +func CountUsers() (int, error) { + var count int + err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} diff --git a/db-history.go b/internal/db/schema.go similarity index 72% rename from db-history.go rename to internal/db/schema.go index 62417b9..57c68a3 100644 --- a/db-history.go +++ b/internal/db/schema.go @@ -1,8 +1,8 @@ -package main +package db -// ============================================================ -// db-history.go - Schema 版本声明与字段映射 -// ============================================================ +// ================================================================ +// Schema 版本声明 +// ================================================================ type SchemaVersionDef struct { Version string @@ -18,7 +18,7 @@ type ColumnDef struct { } var schemaHistory = []SchemaVersionDef{ - // v1:初始版本 + // v1: 初始版本 { Version: "v1", TableName: "proxies", @@ -34,7 +34,7 @@ var schemaHistory = []SchemaVersionDef{ "updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"}, }, }, - // v2:当前版本(frpc-console 2.0 LTS) + // v2: 当前版本 { Version: "v2", TableName: "proxies", @@ -50,6 +50,22 @@ var schemaHistory = []SchemaVersionDef{ "updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"}, }, }, + + { + Version: "v3", + TableName: "proxies", + Columns: map[string]ColumnDef{ + "id": {Type: "INTEGER", Primary: true}, + "name": {Type: "TEXT", NotNull: true}, + "type": {Type: "TEXT", NotNull: true, Default: "'tcp'"}, + "local_ip": {Type: "TEXT", NotNull: true}, + "local_port": {Type: "INTEGER", NotNull: true}, + "remote_port": {Type: "INTEGER", NotNull: true}, + "enabled": {Type: "INTEGER", NotNull: true, Default: "1"}, + "created_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"}, + "updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"}, + }, + }, } func getSchemaDef(version string) *SchemaVersionDef { diff --git a/bin/frpc_linux_amd64 b/internal/frp/bin/frpc_linux_amd64 similarity index 100% rename from bin/frpc_linux_amd64 rename to internal/frp/bin/frpc_linux_amd64 diff --git a/bin/frpc_linux_arm64 b/internal/frp/bin/frpc_linux_arm64 similarity index 100% rename from bin/frpc_linux_arm64 rename to internal/frp/bin/frpc_linux_arm64 diff --git a/bin/frpc_linux_arm_hf b/internal/frp/bin/frpc_linux_arm_hf similarity index 100% rename from bin/frpc_linux_arm_hf rename to internal/frp/bin/frpc_linux_arm_hf diff --git a/internal/frp/binary.go b/internal/frp/binary.go new file mode 100644 index 0000000..ac27c78 --- /dev/null +++ b/internal/frp/binary.go @@ -0,0 +1,98 @@ +package frp + +import ( + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" +) + +//go:embed bin/* +var embeddedFrpc embed.FS + +var ( + cachedFrpcPath string + frpcPathMutex sync.Mutex +) + +// GetFrpcPath 获取 frpc 二进制路径 +// 优先级: 本地缓存 > 程序同目录 > 当前工作目录 > embed 提取到当前目录 > 系统 PATH +func GetFrpcPath() (string, error) { + frpcPathMutex.Lock() + defer frpcPathMutex.Unlock() + + if cachedFrpcPath != "" { + if _, err := os.Stat(cachedFrpcPath); err == nil { + return cachedFrpcPath, nil + } + cachedFrpcPath = "" + } + + var fileName string + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + fileName = "frpc_windows_amd64.exe" + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + fileName = "frpc_linux_amd64" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + fileName = "frpc_linux_arm64" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm": + fileName = "frpc_linux_arm_hf" + default: + path, err := exec.LookPath("frpc") + if err == nil { + cachedFrpcPath = path + return path, nil + } + return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + // 方法1: 尝试从程序同目录加载 + execPath, err := os.Executable() + if err == nil { + execDir := filepath.Dir(execPath) + localPath := filepath.Join(execDir, "frpc") + if runtime.GOOS == "windows" { + localPath += ".exe" + } + if _, err := os.Stat(localPath); err == nil { + cachedFrpcPath = localPath + return localPath, nil + } + } + + // 方法2: 尝试从当前工作目录加载 + cwdPath := "./frpc" + if runtime.GOOS == "windows" { + cwdPath += ".exe" + } + if _, err := os.Stat(cwdPath); err == nil { + cachedFrpcPath = cwdPath + return cwdPath, nil + } + + // 方法3: 从 embed 提取到当前工作目录 + data, err := embeddedFrpc.ReadFile("bin/" + fileName) + if err == nil { + extractPath := "./frpc" + if runtime.GOOS == "windows" { + extractPath += ".exe" + } + if err := os.WriteFile(extractPath, data, 0755); err == nil { + cachedFrpcPath = extractPath + return extractPath, nil + } + } + + // 方法4: 从系统 PATH 查找 + path, err := exec.LookPath("frpc") + if err == nil { + cachedFrpcPath = path + return path, nil + } + + return "", fmt.Errorf("未找到 frpc 文件") +} diff --git a/internal/frp/config.go b/internal/frp/config.go new file mode 100644 index 0000000..89839ff --- /dev/null +++ b/internal/frp/config.go @@ -0,0 +1,87 @@ +package frp + +import ( + "bytes" + _ "embed" + "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 +} + +// ConfigData 中已包含 *db.GlobalConfig,AdminPort 会自动传递到模板 +// 不需要额外修改,但模板 frpc.tmpl 需要支持 admin_port 输出 + +// 在 frpc.tmpl 中添加: +// {{- if .AdminPort }} +// admin_port = {{ .AdminPort }} +// {{- end }} + +// 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/frpc.tmpl b/internal/frp/frpc.tmpl similarity index 86% rename from frpc.tmpl rename to internal/frp/frpc.tmpl index cc2e575..eb5848f 100644 --- a/frpc.tmpl +++ b/internal/frp/frpc.tmpl @@ -5,7 +5,7 @@ serverPort = {{.ServerPort}} token = "{{.Token}}" [log] -to = "./frpc.log" +to = "./data/frpc.log" level = "{{.LogLevel}}" maxDays = {{.LogMaxDays}} @@ -19,8 +19,11 @@ poolCount = {{.PoolCount}} {{.WireProtocolLine}} {{end}} +{{if .AdminPort}} [webServer] -addr = "127.0.0.1:7400" +addr = "127.0.0.1" +port = {{.AdminPort}} +{{end}} {{range .Proxies}} [[proxies]] diff --git a/internal/frp/legacy.go b/internal/frp/legacy.go new file mode 100644 index 0000000..8415db8 --- /dev/null +++ b/internal/frp/legacy.go @@ -0,0 +1,226 @@ +// internal/frp/legacy.go +// Reload 函数适配 P0 改动 + +package frp + +import ( + "context" + "log" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + + "frpc-console/internal/process" +) + +// ================================================================ +// 兼容层:保持对外接口不变 +// ================================================================ + +func IsRunning() bool { + pm := process.GetGlobalManager() + if pm != nil { + status, err := pm.Status() + if err != nil { + log.Printf("[WARN] ProcessManager.Status() 失败: %v,降级到 PID 文件", err) + return isRunningLegacy() + } + return status.Phase == "RUNNING" + } + return isRunningLegacy() +} + +func Start() error { + pm := process.GetGlobalManager() + if pm != nil { + log.Println("[INFO] 使用 ProcessManager 启动 frpc") + return pm.Start(context.Background()) + } + log.Println("[WARN] ProcessManager 未初始化,使用兼容模式启动 frpc") + return startLegacy() +} + +func Stop() error { + pm := process.GetGlobalManager() + if pm != nil { + log.Println("[INFO] 使用 ProcessManager 停止 frpc") + return pm.Stop(context.Background()) + } + log.Println("[WARN] ProcessManager 未初始化,使用兼容模式停止 frpc") + return stopLegacy() +} + +func Restart() error { + pm := process.GetGlobalManager() + if pm != nil { + log.Println("[INFO] 使用 ProcessManager 重启 frpc") + return pm.Restart(context.Background()) + } + log.Println("[WARN] ProcessManager 未初始化,使用兼容模式重启 frpc") + if err := stopLegacy(); err != nil { + return err + } + return startLegacy() +} + +func GetStatus() (map[string]interface{}, error) { + pm := process.GetGlobalManager() + if pm != nil { + state, err := pm.Status() + if err != nil { + return nil, err + } + return map[string]interface{}{ + "phase": state.Phase, + "pid": state.PID, + "port": state.Port, + "alive": state.Alive, + "frp_ready": state.FRPReady, + }, nil + } + running := isRunningLegacy() + return map[string]interface{}{ + "phase": map[bool]string{true: "RUNNING", false: "STOPPED"}[running], + "pid": 0, + "port": 0, + "legacy": true, + }, nil +} + +// ================================================================ +// Reload 热加载 frpc 配置(P0 核心) +// ================================================================ + +func Reload() error { + pm := process.GetGlobalManager() + if pm == nil { + log.Println("[WARN] ProcessManager 未初始化,使用兼容模式 reload") + return reloadLegacy() + } + + // 使用 ProcessManager 的 ReloadConfig 方法 + // 该方法内部处理了 RELOADING 状态和 PID 归属验证 + log.Println("[INFO] 使用 ProcessManager 执行热加载") + return pm.ReloadConfig(context.Background()) +} + +// ================================================================ +// 旧版实现 (降级方案) +// ================================================================ + +func isRunningLegacy() bool { + pidData, err := os.ReadFile("./data/frpc.pid") + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return false + } + if runtime.GOOS == "windows" { + cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid)) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(output), strconv.Itoa(pid)) + } + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +func startLegacy() error { + frpcPath, err := GetFrpcPath() + if err != nil { + return err + } + if err := os.MkdirAll("./data", 0755); err != nil { + return err + } + if _, err := os.Stat("./data/frpc.toml"); os.IsNotExist(err) { + if err := GenerateConfig(); err != nil { + return err + } + } + if isRunningLegacy() { + return nil + } + os.Remove("./data/frpc.pid") + cmd := exec.Command(frpcPath, "-c", "./data/frpc.toml") + setWindowHide(cmd) + setSysProcAttr(cmd) + logFile, err := os.OpenFile("./data/frpc.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + cmd.Stdout = logFile + cmd.Stderr = logFile + if err := cmd.Start(); err != nil { + return err + } + go func() { + if err := cmd.Wait(); err != nil { + log.Printf("frpc 子进程退出: %v", err) + } + os.Remove("./data/frpc.pid") + }() + return os.WriteFile("./data/frpc.pid", []byte(strconv.Itoa(cmd.Process.Pid)), 0644) +} + +func stopLegacy() error { + if runtime.GOOS == "windows" { + cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { + return err + } + os.Remove("./data/frpc.pid") + return nil + } + pidData, err := os.ReadFile("./data/frpc.pid") + if err != nil { + cmd := exec.Command("pkill", "-f", "frpc") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { + return err + } + return nil + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData))) + process, err := os.FindProcess(pid) + if err != nil { + os.Remove("./data/frpc.pid") + return nil + } + if err := process.Kill(); err != nil { + return err + } + os.Remove("./data/frpc.pid") + return nil +} + +func reloadLegacy() error { + if !isRunningLegacy() { + return startLegacy() + } + frpcPath, err := GetFrpcPath() + if err != nil { + return err + } + cmd := exec.Command(frpcPath, "reload", "-c", "./data/frpc.toml") + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("⚠️ 热加载失败 (%v),降级为重启 frpc", err) + log.Printf(" reload 输出: %s", string(output)) + if stopErr := stopLegacy(); stopErr != nil { + return stopErr + } + return startLegacy() + } + log.Printf("✅ frpc 热加载成功 (兼容模式): %s", string(output)) + return nil +} 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/frp-unix.go b/internal/frp/platform_unix.go similarity index 59% rename from frp-unix.go rename to internal/frp/platform_unix.go index ebbe825..cfc0a65 100644 --- a/frp-unix.go +++ b/internal/frp/platform_unix.go @@ -1,16 +1,21 @@ //go:build linux || darwin || freebsd || netbsd || openbsd || solaris -package main +package frp import ( "os/exec" "syscall" ) -// setSysProcAttr 为 Unix 系统设置 Setsid,让 frpc 进程脱离父进程独立运行 +// 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/toml_parser.go b/internal/frp/toml.go similarity index 85% rename from toml_parser.go rename to internal/frp/toml.go index f39f44f..d5240d9 100644 --- a/toml_parser.go +++ b/internal/frp/toml.go @@ -1,9 +1,11 @@ -package main +package frp import ( "fmt" "strconv" "strings" + + "frpc-console/internal/db" ) // FrpcToml 对应 frpc.toml 的完整结构 @@ -35,7 +37,7 @@ type TomlProxy struct { LocalIP string `json:"localIP"` LocalPort int `json:"localPort"` RemotePort int `json:"remotePort"` - Enabled bool `json:"enabled"` // 导入时默认 true + Enabled bool `json:"enabled"` } // ParseToml 解析 frpc.toml 内容 @@ -50,7 +52,7 @@ func ParseToml(content string) (*FrpcToml, error) { HeartbeatTimeout int `json:"heartbeatTimeout"` PoolCount int `json:"poolCount"` }{ - TcpMux: true, // 默认值 + TcpMux: true, TcpMuxKeepalive: 30, HeartbeatInterval: 15, HeartbeatTimeout: 70, @@ -58,7 +60,6 @@ func ParseToml(content string) (*FrpcToml, error) { }, } - // 简单状态机解析 var currentProxy *TomlProxy inProxies := false @@ -68,7 +69,6 @@ func ParseToml(content string) (*FrpcToml, error) { continue } - // 检测 [[proxies]] 段开始 if strings.HasPrefix(line, "[[proxies]]") { inProxies = true currentProxy = &TomlProxy{ @@ -76,28 +76,23 @@ func ParseToml(content string) (*FrpcToml, error) { 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 @@ -111,7 +106,6 @@ func ParseToml(content string) (*FrpcToml, error) { currentProxy.RemotePort, _ = strconv.Atoi(value) } } else { - // 解析全局字段 switch key { case "serverAddr": result.ServerAddr = value @@ -148,9 +142,9 @@ func ParseToml(content string) (*FrpcToml, error) { return result, nil } -// ToGlobalConfig 将解析结果转换为 GlobalConfig -func (f *FrpcToml) ToGlobalConfig() *GlobalConfig { - return &GlobalConfig{ +// ToGlobalConfig 将解析结果转换为 db.GlobalConfig +func (f *FrpcToml) ToGlobalConfig() *db.GlobalConfig { + return &db.GlobalConfig{ ServerAddr: f.ServerAddr, ServerPort: f.ServerPort, Token: f.Auth.Token, @@ -164,17 +158,17 @@ func (f *FrpcToml) ToGlobalConfig() *GlobalConfig { } } -// ToProxies 将解析结果转换为 Proxy 列表 -func (f *FrpcToml) ToProxies() []Proxy { - var proxies []Proxy +// ToProxies 将解析结果转换为 db.Proxy 列表 +func (f *FrpcToml) ToProxies() []db.Proxy { + var proxies []db.Proxy for _, p := range f.Proxies { - proxies = append(proxies, Proxy{ + proxies = append(proxies, db.Proxy{ Name: p.Name, Type: p.Type, LocalIP: p.LocalIP, LocalPort: p.LocalPort, RemotePort: p.RemotePort, - Enabled: true, // 导入默认启用 + Enabled: true, }) } return proxies diff --git a/internal/process/attr_linux.go b/internal/process/attr_linux.go new file mode 100644 index 0000000..719cab8 --- /dev/null +++ b/internal/process/attr_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package process + +import ( + "os/exec" + "syscall" +) + +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..73cb6dd --- /dev/null +++ b/internal/process/attr_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !windows + +package process + +import ( + "os/exec" +) + +func setProcessAttributes(cmd *exec.Cmd) { + // 其他平台不做特殊设置 +} diff --git a/internal/process/attr_windows.go b/internal/process/attr_windows.go new file mode 100644 index 0000000..7e10535 --- /dev/null +++ b/internal/process/attr_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package process + +import ( + "os/exec" + "syscall" +) + +func setProcessAttributes(cmd *exec.Cmd) { + 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..83e3986 --- /dev/null +++ b/internal/process/manager.go @@ -0,0 +1,1364 @@ +// internal/process/manager.go +// 新增 PhaseReloading 状态 + FRPReady 绑定 PID + +// 嗷呜! ^_^ +// 作者留:现在2.7-Preview首战告捷!前端检测正常,后端匹配达成,该热加载的都能上了! +// 代价嘛……之前极致的 20M + 15M ,现在变成了 22M + 37M …… +// 多点就多点嘛……总比低内存消耗下黑盒状态强嘛…… + +package process + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ================================================================ +// 常量定义 +// ================================================================ + +const ( + LockFileName = ".frpc.lock" + PortCheckTimeout = 500 * time.Millisecond + StopMaxWaitTime = 5 * time.Second + LockAcquireTimeout = 30 * time.Second + LockRetryInterval = 100 * time.Millisecond + APITimeout = 2 * time.Second + + StartupMaxAttempts = 50 + StartupRetryDelay = 200 * time.Millisecond + StartupTimeout = 10 * time.Second + + HealthCheckInterval = 15 * time.Second +) + +// ================================================================ +// 进程状态定义 +// ================================================================ + +type ProcessPhase string + +const ( + PhaseUnknown ProcessPhase = "UNKNOWN" + PhaseStarting ProcessPhase = "STARTING" + PhaseRunning ProcessPhase = "RUNNING" + PhaseDegraded ProcessPhase = "DEGRADED" + PhaseFailed ProcessPhase = "FAILED" + PhaseStopped ProcessPhase = "STOPPED" + PhaseStopping ProcessPhase = "STOPPING" + PhaseConflict ProcessPhase = "CONFLICT" + PhaseReloading ProcessPhase = "RELOADING" // 新增:reload 中间态 +) + +// ================================================================ +// 数据结构 +// ================================================================ + +type PortCheckResult struct { + Ready bool + Err error + PID int + Process string +} + +type FrpcInstance struct { + PID int + ParentPID int + ExecPath string + CmdLine string + Owned bool +} + +type ProcessState struct { + Phase ProcessPhase `json:"phase"` + PID int `json:"pid"` + Port int `json:"port"` + StartedAt time.Time `json:"started_at"` + ExitCode int `json:"exit_code,omitempty"` + + Alive bool `json:"alive"` + PortReady bool `json:"port_ready"` + PortPID int `json:"port_pid"` + PortOwner string `json:"port_owner"` + PortError string `json:"port_error,omitempty"` + FRPReady bool `json:"frp_ready"` + Version string `json:"version,omitempty"` + + ExpectedStop bool `json:"expected_stop"` + + LastOutput string `json:"last_output,omitempty"` + LastError string `json:"last_error,omitempty"` + Error string `json:"error,omitempty"` + + Conflicts []FrpcInstance `json:"conflicts,omitempty"` +} + +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"` +} + +// FRPCStatus 匹配 frpc admin API /api/status 的实际返回结构 +type FRPCStatus struct { + TCP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"tcp"` + UDP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"udp"` + HTTP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"http"` + HTTPS []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"https"` + STCP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"stcp"` + XTCP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"xtcp"` + SUDP []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + } `json:"sudp"` +} + +type ConflictInfo struct { + HasConflict bool + Count int + Owned []FrpcInstance + Unknown []FrpcInstance +} + +// ================================================================ +// 全局变量 +// ================================================================ + +var ( + globalManager *ProcessManager + globalMu sync.Mutex +) + +// ================================================================ +// ProcessManager 主结构 +// ================================================================ + +type ProcessManager struct { + mu sync.Mutex + dataDir string + configPath string + frpcBinPath string + adminPort int + + startTime time.Time + expectedStop bool + exitCode int + exitMu sync.Mutex + + lastOutput string + lastError string + outputMu sync.Mutex + + lockFile *os.File + locked bool + + currentPhase ProcessPhase + statusMu sync.RWMutex + + // 用于 admin API 检测的 HTTP 客户端 + httpClient *http.Client + + // 当前实例的 run_id(从 admin API 获取) + runID string +} + +// ================================================================ +// 构造函数 +// ================================================================ + +func NewManager(dataDir, configPath, frpcBinPath string) *ProcessManager { + pm := &ProcessManager{ + dataDir: dataDir, + configPath: configPath, + frpcBinPath: frpcBinPath, + adminPort: 0, + currentPhase: PhaseUnknown, + httpClient: &http.Client{ + Timeout: APITimeout, + }, + } + return pm +} + +func SetGlobalManager(pm *ProcessManager) { + globalMu.Lock() + defer globalMu.Unlock() + globalManager = pm +} + +func GetGlobalManager() *ProcessManager { + globalMu.Lock() + defer globalMu.Unlock() + return globalManager +} + +func (pm *ProcessManager) AdminPort() int { + return pm.adminPort +} + +func (pm *ProcessManager) CurrentPhase() ProcessPhase { + pm.statusMu.RLock() + defer pm.statusMu.RUnlock() + return pm.currentPhase +} + +func (pm *ProcessManager) CurrentPID() int { + pm.statusMu.RLock() + defer pm.statusMu.RUnlock() + return pm.readPIDFile() +} + +func (pm *ProcessManager) setPhase(phase ProcessPhase) { + pm.statusMu.Lock() + defer pm.statusMu.Unlock() + if pm.currentPhase != phase { + log.Printf("[STATUS] %s → %s", pm.currentPhase, phase) + pm.currentPhase = phase + } +} + +// ================================================================ +// 配置读取 +// ================================================================ + +func (pm *ProcessManager) LoadConfig() error { + content, err := os.ReadFile(pm.configPath) + if err != nil { + return fmt.Errorf("读取配置文件失败: %w", err) + } + + log.Printf("[DEBUG] LoadConfig 读取到文件,长度: %d 字节", len(content)) + + preview := string(content) + if len(preview) > 600 { + preview = preview[:600] + "\n... (截断)" + } + log.Printf("[DEBUG] 文件内容预览:\n%s", preview) + + if port := extractIntValue(string(content), "admin_port"); port > 0 { + pm.adminPort = port + log.Printf("[DEBUG] ✅ 从 admin_port 解析到端口: %d", port) + return nil + } + log.Printf("[DEBUG] admin_port 未找到,尝试解析 webServer") + + if port := extractIntValueFromSection(string(content), "webServer", "port"); port > 0 { + pm.adminPort = port + log.Printf("[DEBUG] ✅ 从 webServer.port 解析到端口: %d", port) + return nil + } + log.Printf("[DEBUG] webServer.port 未找到") + + if port := extractPortFromAddrSection(string(content), "webServer", "addr"); port > 0 { + pm.adminPort = port + log.Printf("[DEBUG] ✅ 从 webServer.addr 解析到端口: %d", port) + return nil + } + log.Printf("[DEBUG] webServer.addr 未找到或解析失败") + + return fmt.Errorf("未找到 admin_port 或 webServer.port/addr 配置") +} + +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, "]") { + sectionName := strings.TrimSpace(strings.Trim(trimmed, "[]")) + inSection = strings.EqualFold(sectionName, 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 +} + +func extractPortFromAddrSection(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, "]") { + sectionName := strings.TrimSpace(strings.Trim(trimmed, "[]")) + inSection = strings.EqualFold(sectionName, 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 idx := strings.LastIndex(val, ":"); idx != -1 { + portStr := val[idx+1:] + if port, err := strconv.Atoi(portStr); err == nil && port > 0 { + return port + } + } + } + } + } + return 0 +} + +// ================================================================ +// 端口检测(含归属信息) +// ================================================================ + +func (pm *ProcessManager) CheckPort() PortCheckResult { + if pm.adminPort <= 0 { + return PortCheckResult{Ready: false, Err: fmt.Errorf("admin_port 未配置")} + } + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", pm.adminPort), PortCheckTimeout) + if err != nil { + return PortCheckResult{Ready: false, Err: err} + } + conn.Close() + + // 获取端口占用者信息 + pid, process := pm.getPortOwner(pm.adminPort) + // 即使 pid=0,也返回 Ready=true,让调用方决定如何处理 + return PortCheckResult{ + Ready: true, + PID: pid, + Process: process, + } +} + +func (pm *ProcessManager) getPortOwner(port int) (int, string) { + // 方法1: ss + if pid, name := pm.getPortOwnerBySS(port); pid > 0 { + return pid, name + } + // 方法2: netstat + if pid, name := pm.getPortOwnerByNetstat(port); pid > 0 { + return pid, name + } + return 0, "" +} + +func (pm *ProcessManager) getPortOwnerBySS(port int) (int, string) { + // ss -lntp | grep ':7400 ' | grep -oP 'pid=\K[0-9]+' | head -1 + cmd := exec.Command("sh", "-c", fmt.Sprintf("ss -lntp | grep ':%d ' | grep -oP 'pid=\\K[0-9]+' | head -1", port)) + out, err := cmd.Output() + if err != nil { + return 0, "" + } + pidStr := strings.TrimSpace(string(out)) + if pidStr == "" { + return 0, "" + } + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + return 0, "" + } + return pid, "" +} + +func (pm *ProcessManager) getPortOwnerByNetstat(port int) (int, string) { + // netstat -tlnp | grep ':7400 ' | awk '{print $7}' | cut -d'/' -f1 | head -1 + cmd := exec.Command("sh", "-c", fmt.Sprintf("netstat -tlnp 2>/dev/null | grep ':%d ' | awk '{print $7}' | cut -d'/' -f1 | head -1", port)) + out, err := cmd.Output() + if err != nil { + return 0, "" + } + pidStr := strings.TrimSpace(string(out)) + if pidStr == "" { + return 0, "" + } + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + return 0, "" + } + return pid, "" +} + +// ================================================================ +// 实例检测 +// ================================================================ + +func (pm *ProcessManager) DetectFrpcInstances() []FrpcInstance { + var instances []FrpcInstance + + if runtime.GOOS == "linux" { + cmd := exec.Command("pgrep", "-f", "frpc") + out, err := cmd.Output() + if err != nil { + return instances + } + pids := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, pidStr := range pids { + pidStr = strings.TrimSpace(pidStr) + if pidStr == "" { + continue + } + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + continue + } + if pid == 1 || pid == os.Getpid() { + continue + } + if !pm.isProcessAlive(pid) { + continue + } + cmdline, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + cmdLineStr := strings.ReplaceAll(string(cmdline), "\x00", " ") + exePath, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + stat, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + var parentPID int + if len(stat) > 0 { + parts := strings.Fields(string(stat)) + if len(parts) > 3 { + parentPID, _ = strconv.Atoi(parts[3]) + } + } + inst := FrpcInstance{ + PID: pid, + ParentPID: parentPID, + ExecPath: exePath, + CmdLine: cmdLineStr, + Owned: false, + } + ownedPid := pm.readPIDFile() + if ownedPid == pid { + inst.Owned = true + } + instances = append(instances, inst) + } + } + return instances +} + +func (pm *ProcessManager) filterOwned(instances []FrpcInstance) []FrpcInstance { + var result []FrpcInstance + for _, inst := range instances { + if inst.Owned { + result = append(result, inst) + } + } + return result +} + +func (pm *ProcessManager) filterUnknown(instances []FrpcInstance) []FrpcInstance { + var result []FrpcInstance + for _, inst := range instances { + if !inst.Owned { + result = append(result, inst) + } + } + return result +} + +func (pm *ProcessManager) DetectConflict() ConflictInfo { + instances := pm.DetectFrpcInstances() + owned := pm.filterOwned(instances) + unknown := pm.filterUnknown(instances) + + return ConflictInfo{ + HasConflict: len(unknown) > 0 || len(owned) > 1, + Count: len(instances), + Owned: owned, + Unknown: unknown, + } +} + +// ================================================================ +// 进程存活检测 +// ================================================================ + +func (pm *ProcessManager) isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + if runtime.GOOS == "windows" { + cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid)) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(output), strconv.Itoa(pid)) + } + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +// ================================================================ +// FRPReady 检测(绑定 PID) +// ================================================================ + +func (pm *ProcessManager) isFRPReady(pid int) bool { + if pid <= 0 || pm.adminPort <= 0 { + log.Printf("[DEBUG] FRPReady(pid=%d): pid 无效或 admin_port 未配置", pid) + return false + } + + // stdout 快速通道 + if strings.Contains(pm.getLastOutput(), "start proxy success") || + strings.Contains(pm.getLastOutput(), "login to server success") { + log.Printf("[DEBUG] FRPReady(pid=%d): 检测到 stdout 关键字", pid) + return true + } + + // 端口归属检测 + portResult := pm.CheckPort() + if !portResult.Ready { + log.Printf("[DEBUG] FRPReady(pid=%d): 端口 %d 未就绪", pid, pm.adminPort) + return false + } + + // 关键修复:端口被占用但无法识别归属 → 保守返回 false + if portResult.PID == 0 { + log.Printf("[DEBUG] FRPReady(pid=%d): 端口 %d 被占用但无法识别归属进程,保守返回 false", pid, pm.adminPort) + return false + } + + if portResult.PID != pid { + log.Printf("[DEBUG] FRPReady(pid=%d): 端口 %d 被进程 %d 占用,与期望 PID %d 不一致", + pid, pm.adminPort, portResult.PID, pid) + return false + } + + // admin API 检测 + 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 { + log.Printf("[DEBUG] FRPReady(pid=%d): 创建请求失败: %v", pid, err) + return false + } + resp, err := pm.httpClient.Do(req) + if err != nil { + log.Printf("[DEBUG] FRPReady(pid=%d): admin API 请求失败: %v", pid, err) + return false + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + log.Printf("[DEBUG] FRPReady(pid=%d): admin API 返回状态码 %d", pid, resp.StatusCode) + return false + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[DEBUG] FRPReady(pid=%d): 读取响应失败: %v", pid, err) + return false + } + + var status FRPCStatus + if err := json.Unmarshal(body, &status); err != nil { + log.Printf("[DEBUG] FRPReady(pid=%d): 解析 JSON 失败: %v", pid, err) + return false + } + + // 检查代理状态 + proxyLists := [][]struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Err string `json:"err"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + }{ + status.TCP, status.UDP, status.HTTP, status.HTTPS, + status.STCP, status.XTCP, status.SUDP, + } + + for _, proxies := range proxyLists { + for _, p := range proxies { + if p.Status == "running" { + log.Printf("[DEBUG] FRPReady(pid=%d): 代理 %s 状态为 running", pid, p.Name) + return true + } + } + } + + log.Printf("[DEBUG] FRPReady(pid=%d): 没有代理处于 running 状态", pid) + return false +} + +// ================================================================ +// 进程清理 +// ================================================================ + +func (pm *ProcessManager) killProcess(pid int) error { + if pid <= 0 { + return nil + } + if runtime.GOOS == "windows" { + cmd := exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)) + return cmd.Run() + } + process, err := os.FindProcess(pid) + if err != nil { + return err + } + return process.Kill() +} + +func (pm *ProcessManager) cleanupOrphans() { + result := pm.CheckPort() + if !result.Ready { + return + } + pid := pm.readPIDFile() + if pid > 0 && pm.isProcessAlive(pid) { + return + } + log.Printf("[WARN] 检测到孤儿 frpc 进程 (端口 %d 被占用但无有效 PID),正在清理...", pm.adminPort) + if runtime.GOOS == "windows" { + exec.Command("taskkill", "/F", "/IM", "frpc.exe").Run() + } else { + exec.Command("pkill", "-f", "frpc").Run() + } + pm.deletePIDFile() + time.Sleep(1 * time.Second) +} + +// ================================================================ +// 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 +} + +// ================================================================ +// stdout/stderr 捕获 +// ================================================================ + +func (pm *ProcessManager) captureOutput(reader io.ReadCloser, isError bool) { + defer reader.Close() + scanner := bufio.NewScanner(reader) + buf := make([]byte, 64*1024) + scanner.Buffer(buf, 256*1024) + + var lines []string + const maxLines = 20 + + for scanner.Scan() { + line := scanner.Text() + pm.outputMu.Lock() + if isError { + pm.lastError = line + } else { + pm.lastOutput = line + } + pm.outputMu.Unlock() + + if isError { + lines = append(lines, line) + if len(lines) > maxLines { + lines = lines[1:] + } + pm.outputMu.Lock() + pm.lastError = strings.Join(lines, "\n") + pm.outputMu.Unlock() + } else { + lines = append(lines, line) + if len(lines) > maxLines { + lines = lines[1:] + } + pm.outputMu.Lock() + pm.lastOutput = strings.Join(lines, "\n") + pm.outputMu.Unlock() + } + } +} + +func (pm *ProcessManager) getLastOutput() string { + pm.outputMu.Lock() + defer pm.outputMu.Unlock() + return pm.lastOutput +} + +func (pm *ProcessManager) getLastError() string { + pm.outputMu.Lock() + defer pm.outputMu.Unlock() + return pm.lastError +} + +// ================================================================ +// 状态计算 +// ================================================================ + +func (pm *ProcessManager) computeState(pid int, result PortCheckResult) ProcessState { + alive := pm.isProcessAlive(pid) + portReady := result.Ready + portPID := result.PID + portOwner := result.Process + portErr := result.Err + + pm.exitMu.Lock() + exitCode := pm.exitCode + pm.exitMu.Unlock() + + state := ProcessState{ + PID: pid, + Port: pm.adminPort, + Alive: alive, + PortReady: portReady, + PortPID: portPID, + PortOwner: portOwner, + PortError: "", + FRPReady: false, + StartedAt: pm.startTime, + ExpectedStop: pm.expectedStop, + LastOutput: pm.getLastOutput(), + LastError: pm.getLastError(), + ExitCode: exitCode, + } + + if portErr != nil { + state.PortError = portErr.Error() + } + + // ---- 状态判定逻辑 ---- + + // 1. 主动停止中 + if pm.expectedStop && alive { + state.Phase = PhaseStopping + return state + } + + // 2. 进程不存在 + if !alive { + log.Printf("[DEBUG] computeState: alive=false, pid=%d, expectedStop=%v, exitCode=%d", pid, pm.expectedStop, exitCode) + if pm.expectedStop { + state.Phase = PhaseStopped + return state + } + if exitCode != 0 && exitCode != -1 { + state.Error = fmt.Sprintf("进程异常退出 (exit code: %d)", exitCode) + if state.LastError != "" { + state.Error += ": " + state.LastError + } + } else { + state.Error = "进程意外退出" + if state.LastError != "" { + state.Error += ": " + state.LastError + } + } + state.Phase = PhaseFailed + return state + } + + // 3. 启动超时(只对 STARTING 状态生效) + if pm.currentPhase == PhaseStarting && time.Since(pm.startTime) > StartupTimeout { + log.Printf("[DEBUG] computeState: 启动超时, startTime=%v, elapsed=%v", + pm.startTime, time.Since(pm.startTime)) + state.Error = fmt.Sprintf("启动超时 (超过 %v)", StartupTimeout) + if portErr != nil { + state.Error += ": " + portErr.Error() + } + state.Phase = PhaseFailed + return state + } + + // 4. 端口检测结果 + if !portReady { + if portErr != nil && strings.Contains(portErr.Error(), "permission denied") { + state.Phase = PhaseDegraded + state.Error = "端口检测权限不足: " + portErr.Error() + return state + } + state.Phase = PhaseStarting + if portErr != nil { + state.Error = "等待端口就绪: " + portErr.Error() + } + return state + } + + // 5. 端口就绪,检查归属 + if portPID > 0 && pid > 0 && portPID != pid { + log.Printf("[DEBUG] computeState: 端口冲突, portPID=%d, pid=%d", portPID, pid) + // 端口被其他进程占用 → CONFLICT + state.Phase = PhaseConflict + state.Error = fmt.Sprintf("端口 %d 被进程 %d (%s) 占用,与 PID 文件 %d 不一致", + pm.adminPort, portPID, portOwner, pid) + state.FRPReady = false + return state + } + + // 6. 端口被自己的进程占用,检查 FRPReady + state.FRPReady = pm.isFRPReady(pid) + + if state.FRPReady { + state.Phase = PhaseRunning + return state + } + + // 7. 端口就绪但 FRP 未就绪 + state.Phase = PhaseDegraded + state.Error = "端口已监听,但 admin API 未就绪或代理未运行" + return state +} + +// ================================================================ +// 状态查询 +// ================================================================ + +func (pm *ProcessManager) Status() (*ProcessState, error) { + pid := pm.readPIDFile() + result := pm.CheckPort() + state := pm.computeState(pid, result) + pm.setPhase(state.Phase) + return &state, nil +} + +// ================================================================ +// Reload 操作(P0 核心) +// ================================================================ + +// ReloadConfig 执行 frpc 配置热加载,带状态管理 +func (pm *ProcessManager) ReloadConfig(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + + // 获取当前状态 + pid := pm.readPIDFile() + if pid <= 0 || !pm.isProcessAlive(pid) { + // 进程不存在,直接启动 + log.Printf("[INFO] Reload: frpc 未运行,执行启动") + return pm.startLocked(ctx) + } + + // 记录 reload 前的 PID 和 run_id + oldPID := pid + log.Printf("[INFO] Reload: 开始热加载 (当前 PID: %d)", oldPID) + + // 进入 RELOADING 状态 + pm.setPhase(PhaseReloading) + + // 执行 frpc reload 命令 + frpcPath, err := pm.getFrpcPath() + if err != nil { + pm.setPhase(PhaseDegraded) + return fmt.Errorf("获取 frpc 路径失败: %w", err) + } + + cmd := exec.Command(frpcPath, "reload", "-c", pm.configPath) + output, err := cmd.CombinedOutput() + + // 检查 reload 执行结果 + if err != nil { + // reload 命令失败,检查进程是否还在 + if !pm.isProcessAlive(oldPID) { + // 进程已退出,reload 失败且进程丢失 + log.Printf("[WARN] Reload: frpc 进程在 reload 期间退出 (PID: %d)", oldPID) + pm.setPhase(PhaseFailed) + pm.deletePIDFile() + return fmt.Errorf("reload 失败,frpc 进程已退出: %w", err) + } + + // 进程还在,但 reload 命令失败,可能是配置问题 + log.Printf("[WARN] Reload: 命令失败但进程仍在运行 (PID: %d), 输出: %s", oldPID, string(output)) + pm.setPhase(PhaseDegraded) + return fmt.Errorf("reload 命令执行失败: %w", err) + } + + log.Printf("[INFO] Reload: 命令执行成功,输出: %s", string(output)) + + // 等待新进程就绪 + time.Sleep(1 * time.Second) + + // 获取新进程的 PID + newPID := pm.readPIDFile() + if newPID <= 0 || newPID == oldPID { + // PID 没变化,可能是 reload 没有触发进程切换 + log.Printf("[INFO] Reload: PID 未变化 (PID: %d),验证服务状态...", oldPID) + if pm.isFRPReady(oldPID) { + pm.setPhase(PhaseRunning) + log.Printf("[INFO] Reload: 服务仍健康,保持运行 (PID: %d)", oldPID) + return nil + } + pm.setPhase(PhaseDegraded) + return fmt.Errorf("reload 后服务未就绪 (PID: %d)", oldPID) + } + + // PID 已变化,验证新进程 + log.Printf("[INFO] Reload: PID 从 %d 变为 %d", oldPID, newPID) + + // 等待新进程的 FRPReady + for attempt := 0; attempt < 20; attempt++ { + if pm.isFRPReady(newPID) { + pm.setPhase(PhaseRunning) + log.Printf("[INFO] Reload: 成功切换到新进程 (PID: %d)", newPID) + return nil + } + time.Sleep(200 * time.Millisecond) + } + + // 新进程未就绪,回退状态 + pm.setPhase(PhaseDegraded) + return fmt.Errorf("reload 后新进程未就绪 (PID: %d)", newPID) +} + +// getFrpcPath 获取 frpc 二进制路径 +func (pm *ProcessManager) getFrpcPath() (string, error) { + // 如果 frpcBinPath 有效,直接返回 + if pm.frpcBinPath != "" { + if _, err := os.Stat(pm.frpcBinPath); err == nil { + return pm.frpcBinPath, nil + } + } + // 否则使用 frp 模块的 GetFrpcPath + // 避免循环引用,从外部传入 + return pm.frpcBinPath, nil +} + +// ================================================================ +// 操作执行 +// ================================================================ + +// Lock 由平台文件 (lock_*.go) 实现 + +func (pm *ProcessManager) Start(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + + pm.expectedStop = false + + conflict := pm.DetectConflict() + if conflict.HasConflict { + log.Printf("[WARN] 检测到 %d 个 frpc 实例 (Owned: %d, Unknown: %d),进入 CONFLICT 状态", + conflict.Count, len(conflict.Owned), len(conflict.Unknown)) + + for _, inst := range conflict.Unknown { + log.Printf("[INFO] 清理 Unknown frpc 实例 (PID: %d)", inst.PID) + pm.killProcess(inst.PID) + } + + if len(conflict.Owned) == 1 { + pid := conflict.Owned[0].PID + log.Printf("[INFO] 保留 Owned frpc 实例 (PID: %d),清理完成", pid) + pm.writePIDFile(pid) + pm.setPhase(PhaseRunning) + return nil + } + + if len(conflict.Owned) > 1 { + log.Printf("[WARN] 多个 Owned 实例冲突,全部清理") + for _, inst := range conflict.Owned { + pm.killProcess(inst.PID) + } + } + pm.deletePIDFile() + } + + pm.cleanupOrphans() + return pm.startLocked(ctx) +} + +func (pm *ProcessManager) startLocked(ctx context.Context) error { + result := pm.CheckPort() + if result.Ready { + pid := pm.readPIDFile() + if pid > 0 && pm.isProcessAlive(pid) { + if result.PID == pid { + log.Printf("[INFO] frpc 已在运行 (PID: %d)", pid) + pm.setPhase(PhaseRunning) + return nil + } + log.Printf("[WARN] 端口被进程 %d 占用,但 PID 文件指向 %d,可能存在冲突", result.PID, pid) + } + pm.cleanupOrphans() + } + + pm.startTime = time.Now() + pm.expectedStop = false + pm.runID = "" + + pm.exitMu.Lock() + pm.exitCode = -1 + pm.exitMu.Unlock() + + cmd := exec.CommandContext(ctx, pm.frpcBinPath, "-c", pm.configPath) + setProcessAttributes(cmd) + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("创建 stdout pipe 失败: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("创建 stderr pipe 失败: %w", err) + } + + go pm.captureOutput(stdoutPipe, false) + go pm.captureOutput(stderrPipe, true) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 frpc 失败: %w", err) + } + + if err := pm.writePIDFile(cmd.Process.Pid); err != nil { + log.Printf("[WARN] 写入 PID 文件失败: %v", err) + } + log.Printf("[DEBUG] frpc 进程已启动,PID: %d", cmd.Process.Pid) + + pm.setPhase(PhaseStarting) + + go func() { + err := cmd.Wait() + var code int + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } else { + code = -1 + } + } else { + code = 0 + } + pm.exitMu.Lock() + pm.exitCode = code + pm.exitMu.Unlock() + log.Printf("[INFO] frpc 进程 (PID: %d) 已退出,退出码: %d", cmd.Process.Pid, code) + + if !pm.expectedStop && code != 0 { + log.Printf("[WARN] frpc 进程异常退出 (exit code: %d)", code) + if pm.currentPhase != PhaseReloading { + pm.setPhase(PhaseFailed) + } + } + if !pm.expectedStop { + pm.deletePIDFile() + } + }() + + for attempt := 0; attempt < StartupMaxAttempts; attempt++ { + r := pm.CheckPort() + if r.Ready { + if r.PID > 0 && r.PID != cmd.Process.Pid { + log.Printf("[WARN] 端口被进程 %d 占用,与当前进程 %d 不一致,可能存在冲突", r.PID, cmd.Process.Pid) + time.Sleep(StartupRetryDelay) + continue + } + if pm.isFRPReady(cmd.Process.Pid) { + log.Printf("[INFO] frpc 启动成功 (PID: %d, 端口: %d),耗时 %dms", + cmd.Process.Pid, pm.adminPort, attempt*int(StartupRetryDelay/time.Millisecond)) + pm.setPhase(PhaseRunning) + return nil + } + log.Printf("[DEBUG] 端口已就绪,等待 frpc 初始化...") + } + time.Sleep(StartupRetryDelay) + } + + if pm.isProcessAlive(cmd.Process.Pid) { + stderr := pm.getLastError() + log.Printf("[WARN] frpc 启动超时 (PID: %d),当前 stderr: %s", cmd.Process.Pid, stderr) + if pm.isFRPReady(cmd.Process.Pid) { + log.Printf("[INFO] frpc 实际已就绪 (admin API 可访问),超时误判,修正状态") + pm.setPhase(PhaseRunning) + return nil + } + pm.killProcess(cmd.Process.Pid) + pm.deletePIDFile() + pm.setPhase(PhaseFailed) + return fmt.Errorf("frpc 启动超时: 进程存在但端口未就绪") + } + + pm.deletePIDFile() + pm.setPhase(PhaseFailed) + return fmt.Errorf("frpc 启动失败: 进程已退出") +} + +func (pm *ProcessManager) Stop(ctx context.Context) error { + if err := pm.Lock(); err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + defer pm.Unlock() + + pm.expectedStop = true + pm.setPhase(PhaseStopping) + return pm.stopLocked(ctx) +} + +func (pm *ProcessManager) stopLocked(_ context.Context) error { + pid := pm.readPIDFile() + if pid <= 0 { + if runtime.GOOS == "windows" { + exec.Command("taskkill", "/F", "/IM", "frpc.exe").Run() + } else { + exec.Command("pkill", "-f", "frpc").Run() + } + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil + } + + if !pm.isProcessAlive(pid) { + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil + } + + process, err := os.FindProcess(pid) + if err != nil { + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil + } + if err := process.Signal(syscall.SIGTERM); err != nil { + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil + } + + start := time.Now() + for time.Since(start) < StopMaxWaitTime { + if !pm.isProcessAlive(pid) { + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil + } + time.Sleep(200 * time.Millisecond) + } + + if runtime.GOOS == "windows" { + exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run() + } else { + process.Kill() + } + time.Sleep(500 * time.Millisecond) + + if pm.isProcessAlive(pid) { + pm.setPhase(PhaseFailed) + return fmt.Errorf("强制停止失败: 进程仍存活 (PID: %d)", pid) + } + + pm.deletePIDFile() + pm.setPhase(PhaseStopped) + return nil +} + +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) + } + time.Sleep(1 * time.Second) + if err := pm.startLocked(ctx); err != nil { + return fmt.Errorf("启动失败: %w", err) + } + return nil +} + +// ================================================================ +// 健康检查看门狗 +// ================================================================ + +func (pm *ProcessManager) StartHealthMonitor(ctx context.Context) { + ticker := time.NewTicker(HealthCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pm.runHealthCheck() + } + } +} + +func (pm *ProcessManager) runHealthCheck() { + // 如果正在 reloading,跳过健康检查 + if pm.currentPhase == PhaseReloading { + log.Printf("[DEBUG] 健康检查跳过: 正在 RELOADING") + return + } + + state, err := pm.Status() + if err != nil { + log.Printf("[WARN] 健康检查失败: %v", err) + return + } + + switch state.Phase { + case PhaseConflict: + log.Printf("[WARN] 检测到冲突,尝试自动恢复...") + for _, inst := range state.Conflicts { + if !inst.Owned { + pm.killProcess(inst.PID) + } + } + pm.setPhase(PhaseStarting) + case PhaseDegraded: + log.Printf("[WARN] frpc 处于降级状态,尝试恢复...") + pm.Restart(context.Background()) + case PhaseFailed: + if !state.ExpectedStop { + log.Printf("[WARN] frpc 已失败,自动重启...") + pm.Start(context.Background()) + } + } +} + +// ================================================================ +// 健康检查(供 API 调用) +// ================================================================ + +func (pm *ProcessManager) HealthCheck() map[string]interface{} { + result := make(map[string]interface{}) + result["admin_port"] = pm.adminPort + result["phase"] = pm.CurrentPhase() + + state, err := pm.Status() + if err != nil { + result["error"] = err.Error() + return result + } + + result["pid"] = state.PID + result["port"] = state.Port + result["alive"] = state.Alive + result["port_ready"] = state.PortReady + result["port_pid"] = state.PortPID + result["port_owner"] = state.PortOwner + result["frp_ready"] = state.FRPReady + if state.PortError != "" { + result["port_error"] = state.PortError + } + if state.Error != "" { + result["error"] = state.Error + } + if state.Version != "" { + result["version"] = state.Version + } + if len(state.Conflicts) > 0 { + result["conflicts"] = state.Conflicts + } + + return result +} diff --git a/main.go b/main.go index 4bef73f..98f637e 100644 --- a/main.go +++ b/main.go @@ -1,19 +1,26 @@ package main import ( + "context" "log" "os" + "runtime" "strings" "time" + + "frpc-console/internal/api" + "frpc-console/internal/db" + "frpc-console/internal/frp" + "frpc-console/internal/process" ) func main() { - // 确保 data 目录存在 + log.SetFlags(log.LstdFlags | log.Lshortfile) + if err := os.MkdirAll("./data", 0755); err != nil { - log.Printf("⚠️ 创建 data 目录失败: %v", err) + log.Printf("⚠️ 创建 data 目录失败: %v", err) } - // 读取 version.ini 显示版本 if data, err := os.ReadFile("./data/version.ini"); err == nil { version := strings.TrimSpace(string(data)) log.Printf("📌 版本: %s", version) @@ -21,38 +28,76 @@ func main() { log.Printf("📌 版本: (未记录)") } - if err := InitDB(); err != nil { + if err := db.InitDB(); err != nil { log.Fatal("❌ 数据库初始化失败:", err) } - if err := GenerateFrpcConfig(); err != nil { - log.Println("⚠️ 生成配置文件失败:", err) + if err := frp.GenerateConfig(); err != nil { + log.Println("⚠️ 生成配置文件失败:", err) } - if err := StartFrpc(); err != nil { - log.Println("⚠️ 启动 frpc 失败:", err) + // ============================================================ + // 获取 frpc 二进制路径(自动处理平台差异) + // ============================================================ + + frpcPath, err := frp.GetFrpcPath() + if err != nil { + log.Printf("⚠️ 获取 frpc 路径失败: %v", err) + frpcPath = "./frpc" + if runtime.GOOS == "windows" { + frpcPath += ".exe" + } + } + log.Printf("📌 frpc 路径: %s", frpcPath) + + pm := process.NewManager("./data", "./data/frpc.toml", frpcPath) + process.SetGlobalManager(pm) + + if err := pm.LoadConfig(); err != nil { + log.Printf("⚠️ 加载 admin_port 配置失败: %v (将使用 PID 文件模式)", err) + } else { + log.Printf("📌 admin_port: %d", pm.AdminPort()) } - go startWatchdog() + ctx := context.Background() + if err := pm.Start(ctx); err != nil { + log.Printf("⚠️ 启动 frpc 失败: %v", err) + } else { + if status, err := pm.Status(); err == nil { + log.Printf("✅ frpc 状态: %s", status.Phase) + if status.PID > 0 { + log.Printf(" PID: %d, 端口: %d", status.PID, status.Port) + } + } + } - r := SetupRouter() + go startWatchdog(pm) + + router := api.SetupRouter() log.Println("🚀 frpc-console 启动成功!") log.Println("📍 访问地址: http://localhost:9300") log.Println("📍 API 地址: http://localhost:9300/api") - if err := r.Run(":9300"); err != nil { + if err := router.Run(":9300"); err != nil { log.Fatal("❌ 服务启动失败:", err) } } -func startWatchdog() { +func startWatchdog(pm *process.ProcessManager) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { - if !isFrpcRunning() { - log.Println("⚠️ frpc 进程已停止,自动重启...") - if err := StartFrpc(); err != nil { + status, err := pm.Status() + if err != nil { + log.Printf("⚠️ 检查 frpc 状态失败: %v", err) + continue + } + + if status.Phase != "RUNNING" { + log.Printf("⚠️ frpc 进程已停止 (状态: %s),自动重启...", status.Phase) + ctx := context.Background() + if err := pm.Start(ctx); err != nil { log.Printf("❌ 自动重启 frpc 失败: %v", err) } else { log.Println("✅ frpc 已自动重启") diff --git a/readme.md b/readme.md index 689aa5c..6e81ad8 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,5 @@

- frpc-console + frpc-console

# frpc-console @@ -56,5 +56,5 @@ --- -[**MIT License © 2026 lxh2875931338(XHLiang0)**](./License.md) · [GitHub](https://github.com/XHLiang0) · [致谢](./Docs/acknowledgment.md) +[**MIT License © 2026 lxh2875931338(XHLiang0)**](./License.md) · [GitHub](https://github.com/XHLiang0) · [致谢](./Docs/acknowledgment.md) · [更新日志](update-logs.md) diff --git a/update-logs.md b/update-logs.md index c44a197..5a3c970 100644 --- a/update-logs.md +++ b/update-logs.md @@ -5,6 +5,80 @@ | LTS 正式版 | `-lts` | 生产环境,长期维护 | | 技术预览版 | `-preview` | 功能前瞻,建议测试环境验证 | +## 2.7-preview (2026-08-11) + +本次预览版的核心是 **ProcessManager 进程管理模块重构**,将 frpc 的管理方式从“基于 PID 文件的简单函数集”升级为“带状态机、冲突检测、健康检查、自动恢复的完整生命周期控制器”。这是 frpc-console 从“frpc 启动器”向“frpc 生命周期控制器”演进的关键版本。 + +### 核心变更 + +- **进程管理模块独立** —— 新增 `internal/process` 包,将进程管理逻辑从 `frp.go` 中抽离为独立模块,包含状态机、互斥锁、进程属性、实例归属检测等子模块,为长期维护和扩展奠定基础 + +- **状态机驱动生命周期管理** —— 从“PID 文件存在即运行”的隐式状态升级为 8 种显式状态(UNKNOWN / STARTING / RUNNING / DEGRADED / CONFLICT / FAILED / STOPPING / STOPPED),状态转换由检测结果驱动,状态语义清晰可追溯 + +- **三级健康检查体系** —— 建立 Process Health(PID 存活)+ Admin Health(端口可访问 + 归属验证)+ Service Health(代理 running)的递进式健康检查,不同层级失败对应不同恢复策略 + +- **FRPReady 绑定 PID** —— `FRPReady` 检测从全局状态改为绑定具体 PID,通过 admin API 读取代理状态确认服务就绪,避免旧实例状态干扰新实例判断 + +- **实例归属检测与 CONFLICT 状态** —— `DetectFrpcInstances()` 通过 PID + ExecPath + CmdLine 三重确认识别系统内所有 frpc 进程,区分 Owned/Unknown 实例,冲突时保留 Owned 实例、清理 Unknown 实例,新增 CONFLICT 状态承载冲突场景 + +- **RELOADING 中间态** —— Reload 操作期间状态机进入 RELOADING 中间态,看门狗和健康检查在此期间跳过恢复动作,彻底解决 reload 导致旧 PID 消失被误判为 FAILED 的竞态问题 + +- **启动超时逻辑修正** —— 超时判断仅对 STARTING 状态生效,已进入 RUNNING 的进程不再受超时影响,解决了长期运行后每 30 秒触发一次超时误判的问题 + +- **端口检测升级为归属验证** —— `CheckPort()` 从仅返回 bool 升级为返回 PortCheckResult(Ready + Err + PID + Process),支持端口归属验证,端口被占用但 PID 不一致时触发 CONFLICT 状态 + +- **孤儿进程与僵尸进程防护** —— 启动前自动清理孤儿进程(端口被占用但无有效 PID);启动后通过 goroutine 调用 `cmd.Wait()` 回收子进程,防止 frpc 退出后变成僵尸进程堆积 + +- **进程互斥锁** —— Linux 使用 `flock` 实现进程间互斥锁,Windows 使用内存锁,防止并发启动/停止操作产生竞态条件 + +- **看门狗升级为智能恢复** —— 每 30 秒检查完整状态,根据 Phase 执行差异化恢复策略(CONFLICT → 清理 Unknown 实例;DEGRADED → 重启;FAILED → 自动重启;RELOADING → 跳过检查) + +### 模块化重构 + +- **代码结构模块化** —— 将单体结构拆分为 `internal/process`、`internal/frp`、`internal/db`、`internal/auth`、`internal/api` 等独立模块,模块边界清晰,为后续 3.0 控制器架构铺路 + +- **frp 模块拆分** —— 原 `frp.go` 拆分为 `binary.go`(二进制提取)、`config.go`(模板渲染)、`legacy.go`(兼容层)、`toml.go`(TOML 解析),职责单一,便于维护 + +- **数据库 Schema v3 升级** —— 新增 `admin_port` 字段,默认 7400,采用重型迁移策略(建新表 → 迁移数据 → 交换表名)替代 ALTER TABLE,确保数据一致性;v2→v3 迁移自动完成,无需用户干预 + +### UI 优化 + +- **配置页面新增 admin_port 输入框** —— 用户可自定义 frpc admin 端口,默认 7400,保存后自动写入 `frpc.toml` 的 `[webServer]` 段 + +- **前端状态适配** —— 前端 `getFrpcStatus()` 从检查 `state` 字段升级为检查 `phase === "RUNNING"`,与后端状态机对齐 + +- **版本号结构化** —— `version.ini` 从单行版本号升级为 INI 格式,包含 `[build]`(version / channel / commit / build_time)和 `[environment]`(builder / go_version / platform),为“关于”页面提供完整数据源 + +### 部署变更 + +- **Docker 镜像加速自动配置** —— `deploy.sh` 自动检测 Docker daemon 的 registry-mirrors 配置,检测用户配置和默认地址连通性,按需写入可用镜像源,自动重启 Docker 应用配置 + +- **frpc 二进制提取路径统一** —— 从 embed 提取的 frpc 二进制统一放到程序同层目录(`./frpc`),不再写入 `./data/` 持久化目录,避免污染数据目录 + +- **日志路径统一** —— frpc 日志从根目录 `./frpc.log` 迁移至 `./data/frpc.log`,与数据库、配置文件、PID 文件统一存放,前后端路径一致 + +### 修复 + +- 修复 reload 触发 `STARTING → FAILED` 导致旧实例被误杀的问题 +- 修复 `startTime` 长期运行后每 30 秒触发启动超时误判的问题 +- 修复 `FRPReady` 检测到旧实例状态导致新实例误判为 RUNNING 的问题 +- 修复 `CheckPort()` 在容器环境下返回 PID=0 导致端口归属验证失效的问题 +- 修复 2.5-lts 升级到 2.7-preview 时 `admin_port` 字段缺失导致热加载失败的问题 +- 修复 Windows 编译后 `./data/frpc` 路径与 `frpc_windows_amd64.exe` 不一致的问题 + +### 已知问题 + +- `work connection pool is full` 在瞬时并发高峰时偶发,已通过 `poolCount` 从 8 调整为 10 缓解,持续观察中 +- Preview 通道尚未经过长期稳定性测试,生产环境请使用 LTS 通道 + +### 升级说明 + +- 从 2.5-lts 升级时,数据库 Schema 自动从 v2 迁移至 v3,`admin_port` 默认值为 7400,无需手动操作 +- `frpc.toml` 中 `[webServer]` 段格式需从 `addr = "127.0.0.1:7400"` 调整为 `addr = "127.0.0.1"` + `port = 7400`,新部署自动适配,旧部署升级时模板自动覆盖 +- 建议升级前备份 `./data/` 目录,确保回退路径可用 + +**版本定位:** 2.7-preview 是一个技术预览版,核心目标是验证 ProcessManager 状态机在真实环境中的稳定性和准确性。虽然 P0 级问题已修复,但建议在测试环境中充分验证后再考虑生产部署。LTS 通道用户请继续使用 2.5-lts。 + --- ## 2.5-lts (2026-08-03) diff --git a/version.ini b/version.ini index 31f58f7..0e78d28 100644 --- a/version.ini +++ b/version.ini @@ -3,4 +3,4 @@ ; 示例: ; 2.4 # Preview 版本,无日期 ; 2.5 20260729 # LTS 版本,带发布日期 -2.5 \ No newline at end of file +2.7 \ No newline at end of file