Просмотр исходного кода

refactor: separate API handlers into dedicated packages with improved HTTP utilities

fatedier 1 день назад
Родитель
Сommit
97dcb1e34b

+ 0 - 1
README.md

@@ -40,7 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
 	<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
   </a>
 </p>
-
 <div align="center">
 
 ## Recall.ai - API for meeting recordings

+ 28 - 217
client/admin_api.go

@@ -15,44 +15,29 @@
 package client
 
 import (
-	"cmp"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net"
 	"net/http"
-	"os"
-	"slices"
-	"strconv"
-	"time"
 
+	"github.com/fatedier/frp/client/api"
 	"github.com/fatedier/frp/client/proxy"
-	"github.com/fatedier/frp/pkg/config"
-	"github.com/fatedier/frp/pkg/config/v1/validation"
 	httppkg "github.com/fatedier/frp/pkg/util/http"
-	"github.com/fatedier/frp/pkg/util/log"
 	netpkg "github.com/fatedier/frp/pkg/util/net"
 )
 
-type GeneralResponse struct {
-	Code int
-	Msg  string
-}
-
 func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
-	helper.Router.HandleFunc("/healthz", svr.healthz)
-	subRouter := helper.Router.NewRoute().Subrouter()
+	apiController := newAPIController(svr)
 
-	subRouter.Use(helper.AuthMiddleware.Middleware)
+	// Healthz endpoint without auth
+	helper.Router.HandleFunc("/healthz", healthz)
 
-	// api, see admin_api.go
-	subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET")
-	subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST")
-	subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET")
-	subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET")
-	subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT")
-
-	// view
+	// API routes and static files with auth
+	subRouter := helper.Router.NewRoute().Subrouter()
+	subRouter.Use(helper.AuthMiddleware)
+	subRouter.Use(httppkg.NewRequestLogger)
+	subRouter.HandleFunc("/api/reload", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet)
+	subRouter.HandleFunc("/api/stop", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost)
+	subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
+	subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
+	subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
 	subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
 	subRouter.PathPrefix("/static/").Handler(
 		netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
@@ -62,202 +47,28 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
 	})
 }
 
-// /healthz
-func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
-	w.WriteHeader(200)
-}
-
-// GET /api/reload
-func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	strictConfigMode := false
-	strictStr := r.URL.Query().Get("strictConfig")
-	if strictStr != "" {
-		strictConfigMode, _ = strconv.ParseBool(strictStr)
-	}
-
-	log.Infof("api request [/api/reload]")
-	defer func() {
-		log.Infof("api response [/api/reload], code [%d]", res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.configFilePath, strictConfigMode)
-	if err != nil {
-		res.Code = 400
-		res.Msg = err.Error()
-		log.Warnf("reload frpc proxy config error: %s", res.Msg)
-		return
-	}
-	if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, svr.unsafeFeatures); err != nil {
-		res.Code = 400
-		res.Msg = err.Error()
-		log.Warnf("reload frpc proxy config error: %s", res.Msg)
-		return
-	}
-
-	if err := svr.UpdateAllConfigurer(proxyCfgs, visitorCfgs); err != nil {
-		res.Code = 500
-		res.Msg = err.Error()
-		log.Warnf("reload frpc proxy config error: %s", res.Msg)
-		return
-	}
-	log.Infof("success reload conf")
-}
-
-// POST /api/stop
-func (svr *Service) apiStop(w http.ResponseWriter, _ *http.Request) {
-	res := GeneralResponse{Code: 200}
-
-	log.Infof("api request [/api/stop]")
-	defer func() {
-		log.Infof("api response [/api/stop], code [%d]", res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	go svr.GracefulClose(100 * time.Millisecond)
-}
-
-type StatusResp map[string][]ProxyStatusResp
-
-type ProxyStatusResp struct {
-	Name       string `json:"name"`
-	Type       string `json:"type"`
-	Status     string `json:"status"`
-	Err        string `json:"err"`
-	LocalAddr  string `json:"local_addr"`
-	Plugin     string `json:"plugin"`
-	RemoteAddr string `json:"remote_addr"`
+func healthz(w http.ResponseWriter, _ *http.Request) {
+	w.WriteHeader(http.StatusOK)
 }
 
-func NewProxyStatusResp(status *proxy.WorkingStatus, serverAddr string) ProxyStatusResp {
-	psr := ProxyStatusResp{
-		Name:   status.Name,
-		Type:   status.Type,
-		Status: status.Phase,
-		Err:    status.Err,
-	}
-	baseCfg := status.Cfg.GetBaseConfig()
-	if baseCfg.LocalPort != 0 {
-		psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
-	}
-	psr.Plugin = baseCfg.Plugin.Type
-
-	if status.Err == "" {
-		psr.RemoteAddr = status.RemoteAddr
-		if slices.Contains([]string{"tcp", "udp"}, status.Type) {
-			psr.RemoteAddr = serverAddr + psr.RemoteAddr
-		}
-	}
-	return psr
+func newAPIController(svr *Service) *api.Controller {
+	return api.NewController(api.ControllerParams{
+		GetProxyStatus: svr.getAllProxyStatus,
+		ServerAddr:     svr.common.ServerAddr,
+		ConfigFilePath: svr.configFilePath,
+		UnsafeFeatures: svr.unsafeFeatures,
+		UpdateConfig:   svr.UpdateAllConfigurer,
+		GracefulClose:  svr.GracefulClose,
+	})
 }
 
-// GET /api/status
-func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
-	var (
-		buf []byte
-		res StatusResp = make(map[string][]ProxyStatusResp)
-	)
-
-	log.Infof("http request [/api/status]")
-	defer func() {
-		log.Infof("http response [/api/status]")
-		w.Header().Set("Content-Type", "application/json")
-		buf, _ = json.Marshal(&res)
-		_, _ = w.Write(buf)
-	}()
-
+// getAllProxyStatus returns all proxy statuses.
+func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {
 	svr.ctlMu.RLock()
 	ctl := svr.ctl
 	svr.ctlMu.RUnlock()
 	if ctl == nil {
-		return
-	}
-
-	ps := ctl.pm.GetAllProxyStatus()
-	for _, status := range ps {
-		res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.common.ServerAddr))
-	}
-
-	for _, arrs := range res {
-		if len(arrs) <= 1 {
-			continue
-		}
-		slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
-			return cmp.Compare(a.Name, b.Name)
-		})
-	}
-}
-
-// GET /api/config
-func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
-	res := GeneralResponse{Code: 200}
-
-	log.Infof("http get request [/api/config]")
-	defer func() {
-		log.Infof("http get response [/api/config], code [%d]", res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	if svr.configFilePath == "" {
-		res.Code = 400
-		res.Msg = "frpc has no config file path"
-		log.Warnf("%s", res.Msg)
-		return
-	}
-
-	content, err := os.ReadFile(svr.configFilePath)
-	if err != nil {
-		res.Code = 400
-		res.Msg = err.Error()
-		log.Warnf("load frpc config file error: %s", res.Msg)
-		return
-	}
-	res.Msg = string(content)
-}
-
-// PUT /api/config
-func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-
-	log.Infof("http put request [/api/config]")
-	defer func() {
-		log.Infof("http put response [/api/config], code [%d]", res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	// get new config content
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		res.Code = 400
-		res.Msg = fmt.Sprintf("read request body error: %v", err)
-		log.Warnf("%s", res.Msg)
-		return
-	}
-
-	if len(body) == 0 {
-		res.Code = 400
-		res.Msg = "body can't be empty"
-		log.Warnf("%s", res.Msg)
-		return
-	}
-
-	if err := os.WriteFile(svr.configFilePath, body, 0o600); err != nil {
-		res.Code = 500
-		res.Msg = fmt.Sprintf("write content to frpc config file error: %v", err)
-		log.Warnf("%s", res.Msg)
-		return
+		return nil
 	}
+	return ctl.pm.GetAllProxyStatus()
 }

+ 189 - 0
client/api/controller.go

@@ -0,0 +1,189 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+	"cmp"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"slices"
+	"strconv"
+	"time"
+
+	"github.com/fatedier/frp/client/proxy"
+	"github.com/fatedier/frp/pkg/config"
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/config/v1/validation"
+	"github.com/fatedier/frp/pkg/policy/security"
+	httppkg "github.com/fatedier/frp/pkg/util/http"
+	"github.com/fatedier/frp/pkg/util/log"
+)
+
+// Controller handles HTTP API requests for frpc.
+type Controller struct {
+	// getProxyStatus returns the current proxy status.
+	// Returns nil if the control connection is not established.
+	getProxyStatus func() []*proxy.WorkingStatus
+
+	// serverAddr is the frps server address for display.
+	serverAddr string
+
+	// configFilePath is the path to the configuration file.
+	configFilePath string
+
+	// unsafeFeatures is used for validation.
+	unsafeFeatures *security.UnsafeFeatures
+
+	// updateConfig updates proxy and visitor configurations.
+	updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
+
+	// gracefulClose gracefully stops the service.
+	gracefulClose func(d time.Duration)
+}
+
+// ControllerParams contains parameters for creating an APIController.
+type ControllerParams struct {
+	GetProxyStatus func() []*proxy.WorkingStatus
+	ServerAddr     string
+	ConfigFilePath string
+	UnsafeFeatures *security.UnsafeFeatures
+	UpdateConfig   func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
+	GracefulClose  func(d time.Duration)
+}
+
+// NewController creates a new Controller.
+func NewController(params ControllerParams) *Controller {
+	return &Controller{
+		getProxyStatus: params.GetProxyStatus,
+		serverAddr:     params.ServerAddr,
+		configFilePath: params.ConfigFilePath,
+		unsafeFeatures: params.UnsafeFeatures,
+		updateConfig:   params.UpdateConfig,
+		gracefulClose:  params.GracefulClose,
+	}
+}
+
+// Reload handles GET /api/reload
+func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
+	strictConfigMode := false
+	strictStr := ctx.Query("strictConfig")
+	if strictStr != "" {
+		strictConfigMode, _ = strconv.ParseBool(strictStr)
+	}
+
+	cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
+	if err != nil {
+		log.Warnf("reload frpc proxy config error: %s", err.Error())
+		return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+	}
+
+	if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
+		log.Warnf("reload frpc proxy config error: %s", err.Error())
+		return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+	}
+
+	if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
+		log.Warnf("reload frpc proxy config error: %s", err.Error())
+		return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+	}
+
+	log.Infof("success reload conf")
+	return nil, nil
+}
+
+// Stop handles POST /api/stop
+func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
+	go c.gracefulClose(100 * time.Millisecond)
+	return nil, nil
+}
+
+// Status handles GET /api/status
+func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
+	res := make(StatusResp)
+	ps := c.getProxyStatus()
+	if ps == nil {
+		return res, nil
+	}
+
+	for _, status := range ps {
+		res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
+	}
+
+	for _, arrs := range res {
+		if len(arrs) <= 1 {
+			continue
+		}
+		slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
+			return cmp.Compare(a.Name, b.Name)
+		})
+	}
+	return res, nil
+}
+
+// GetConfig handles GET /api/config
+func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
+	if c.configFilePath == "" {
+		return nil, httppkg.NewError(http.StatusBadRequest, "frpc has no config file path")
+	}
+
+	content, err := os.ReadFile(c.configFilePath)
+	if err != nil {
+		log.Warnf("load frpc config file error: %s", err.Error())
+		return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+	}
+	return string(content), nil
+}
+
+// PutConfig handles PUT /api/config
+func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
+	body, err := ctx.Body()
+	if err != nil {
+		return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
+	}
+
+	if len(body) == 0 {
+		return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
+	}
+
+	if err := os.WriteFile(c.configFilePath, body, 0o600); err != nil {
+		return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("write content to frpc config file error: %v", err))
+	}
+	return nil, nil
+}
+
+// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
+func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
+	psr := ProxyStatusResp{
+		Name:   status.Name,
+		Type:   status.Type,
+		Status: status.Phase,
+		Err:    status.Err,
+	}
+	baseCfg := status.Cfg.GetBaseConfig()
+	if baseCfg.LocalPort != 0 {
+		psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
+	}
+	psr.Plugin = baseCfg.Plugin.Type
+
+	if status.Err == "" {
+		psr.RemoteAddr = status.RemoteAddr
+		if slices.Contains([]string{"tcp", "udp"}, status.Type) {
+			psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
+		}
+	}
+	return psr
+}

+ 29 - 0
client/api/types.go

@@ -0,0 +1,29 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+// StatusResp is the response for GET /api/status
+type StatusResp map[string][]ProxyStatusResp
+
+// ProxyStatusResp contains proxy status information
+type ProxyStatusResp struct {
+	Name       string `json:"name"`
+	Type       string `json:"type"`
+	Status     string `json:"status"`
+	Err        string `json:"err"`
+	LocalAddr  string `json:"local_addr"`
+	Plugin     string `json:"plugin"`
+	RemoteAddr string `json:"remote_addr"`
+}

+ 5 - 5
pkg/sdk/client/client.go

@@ -11,7 +11,7 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/fatedier/frp/client"
+	"github.com/fatedier/frp/client/api"
 	httppkg "github.com/fatedier/frp/pkg/util/http"
 )
 
@@ -32,7 +32,7 @@ func (c *Client) SetAuth(user, pwd string) {
 	c.authPwd = pwd
 }
 
-func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.ProxyStatusResp, error) {
+func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxyStatusResp, error) {
 	req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
 	if err != nil {
 		return nil, err
@@ -41,7 +41,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.Proxy
 	if err != nil {
 		return nil, err
 	}
-	allStatus := make(client.StatusResp)
+	allStatus := make(api.StatusResp)
 	if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
 		return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
 	}
@@ -55,7 +55,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.Proxy
 	return nil, fmt.Errorf("no proxy status found")
 }
 
-func (c *Client) GetAllProxyStatus(ctx context.Context) (client.StatusResp, error) {
+func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) {
 	req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
 	if err != nil {
 		return nil, err
@@ -64,7 +64,7 @@ func (c *Client) GetAllProxyStatus(ctx context.Context) (client.StatusResp, erro
 	if err != nil {
 		return nil, err
 	}
-	allStatus := make(client.StatusResp)
+	allStatus := make(api.StatusResp)
 	if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
 		return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
 	}

+ 57 - 0
pkg/util/http/context.go

@@ -0,0 +1,57 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+
+	"github.com/gorilla/mux"
+)
+
+type Context struct {
+	Req  *http.Request
+	Resp http.ResponseWriter
+	vars map[string]string
+}
+
+func NewContext(w http.ResponseWriter, r *http.Request) *Context {
+	return &Context{
+		Req:  r,
+		Resp: w,
+		vars: mux.Vars(r),
+	}
+}
+
+func (c *Context) Param(key string) string {
+	return c.vars[key]
+}
+
+func (c *Context) Query(key string) string {
+	return c.Req.URL.Query().Get(key)
+}
+
+func (c *Context) BindJSON(obj any) error {
+	body, err := io.ReadAll(c.Req.Body)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(body, obj)
+}
+
+func (c *Context) Body() ([]byte, error) {
+	return io.ReadAll(c.Req.Body)
+}

+ 33 - 0
pkg/util/http/error.go

@@ -0,0 +1,33 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import "fmt"
+
+type Error struct {
+	Code int
+	Err  error
+}
+
+func (e *Error) Error() string {
+	return e.Err.Error()
+}
+
+func NewError(code int, msg string) *Error {
+	return &Error{
+		Code: code,
+		Err:  fmt.Errorf("%s", msg),
+	}
+}

+ 66 - 0
pkg/util/http/handler.go

@@ -0,0 +1,66 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/fatedier/frp/pkg/util/log"
+)
+
+type GeneralResponse struct {
+	Code int
+	Msg  string
+}
+
+// APIHandler is a handler function that returns a response object or an error.
+type APIHandler func(ctx *Context) (any, error)
+
+// MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc.
+func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		ctx := NewContext(w, r)
+		res, err := handler(ctx)
+		if err != nil {
+			log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
+			code := http.StatusInternalServerError
+			if e, ok := err.(*Error); ok {
+				code = e.Code
+			}
+
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(code)
+			_ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()})
+			return
+		}
+
+		if res == nil {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		switch v := res.(type) {
+		case []byte:
+			_, _ = w.Write(v)
+		case string:
+			_, _ = w.Write([]byte(v))
+		default:
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusOK)
+			_ = json.NewEncoder(w).Encode(v)
+		}
+	}
+}

+ 40 - 0
pkg/util/http/middleware.go

@@ -0,0 +1,40 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+	"net/http"
+
+	"github.com/fatedier/frp/pkg/util/log"
+)
+
+type responseWriter struct {
+	http.ResponseWriter
+	code int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+	rw.code = code
+	rw.ResponseWriter.WriteHeader(code)
+}
+
+func NewRequestLogger(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		log.Infof("http request: [%s]", r.URL.Path)
+		rw := &responseWriter{ResponseWriter: w, code: http.StatusOK}
+		next.ServeHTTP(rw, r)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, rw.code)
+	})
+}

+ 346 - 0
server/api/controller.go

@@ -0,0 +1,346 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+	"cmp"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"slices"
+	"strings"
+	"time"
+
+	"github.com/fatedier/frp/pkg/config/types"
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/metrics/mem"
+	httppkg "github.com/fatedier/frp/pkg/util/http"
+	"github.com/fatedier/frp/pkg/util/log"
+	"github.com/fatedier/frp/pkg/util/version"
+	"github.com/fatedier/frp/server/proxy"
+	"github.com/fatedier/frp/server/registry"
+)
+
+type Controller struct {
+	// dependencies
+	serverCfg      *v1.ServerConfig
+	clientRegistry *registry.ClientRegistry
+	pxyManager     ProxyManager
+}
+
+type ProxyManager interface {
+	GetByName(name string) (proxy.Proxy, bool)
+}
+
+func NewController(
+	serverCfg *v1.ServerConfig,
+	clientRegistry *registry.ClientRegistry,
+	pxyManager ProxyManager,
+) *Controller {
+	return &Controller{
+		serverCfg:      serverCfg,
+		clientRegistry: clientRegistry,
+		pxyManager:     pxyManager,
+	}
+}
+
+// /api/serverinfo
+func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
+	serverStats := mem.StatsCollector.GetServer()
+	svrResp := ServerInfoResp{
+		Version:               version.Full(),
+		BindPort:              c.serverCfg.BindPort,
+		VhostHTTPPort:         c.serverCfg.VhostHTTPPort,
+		VhostHTTPSPort:        c.serverCfg.VhostHTTPSPort,
+		TCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort,
+		KCPBindPort:           c.serverCfg.KCPBindPort,
+		QUICBindPort:          c.serverCfg.QUICBindPort,
+		SubdomainHost:         c.serverCfg.SubDomainHost,
+		MaxPoolCount:          c.serverCfg.Transport.MaxPoolCount,
+		MaxPortsPerClient:     c.serverCfg.MaxPortsPerClient,
+		HeartBeatTimeout:      c.serverCfg.Transport.HeartbeatTimeout,
+		AllowPortsStr:         types.PortsRangeSlice(c.serverCfg.AllowPorts).String(),
+		TLSForce:              c.serverCfg.Transport.TLS.Force,
+
+		TotalTrafficIn:  serverStats.TotalTrafficIn,
+		TotalTrafficOut: serverStats.TotalTrafficOut,
+		CurConns:        serverStats.CurConns,
+		ClientCounts:    serverStats.ClientCounts,
+		ProxyTypeCounts: serverStats.ProxyTypeCounts,
+	}
+	// For API that returns struct, we can just return it.
+	// But current GeneralResponse.Msg in legacy code expects a JSON string.
+	// Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
+	// The original code wraps it in GeneralResponse{Msg: string(json)}.
+	// If we return svrResp, the response body will be the JSON of svrResp.
+	// We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
+	// Looking at previous code:
+	// res := GeneralResponse{Code: 200}
+	// buf, _ := json.Marshal(&svrResp)
+	// res.Msg = string(buf)
+	// Response body: {"code": 200, "msg": "{\"version\":...}"}
+	// Wait, is it double encoded JSON? Yes it seems so!
+	// Let's check dashboard_api.go original code again.
+	// Yes: res.Msg = string(buf).
+	// So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
+	// This is kind of ugly, but we must preserve compatibility.
+
+	return svrResp, nil
+}
+
+// /api/clients
+func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
+	if c.clientRegistry == nil {
+		return nil, fmt.Errorf("client registry unavailable")
+	}
+
+	userFilter := ctx.Query("user")
+	clientIDFilter := ctx.Query("clientId")
+	runIDFilter := ctx.Query("runId")
+	statusFilter := strings.ToLower(ctx.Query("status"))
+
+	records := c.clientRegistry.List()
+	items := make([]ClientInfoResp, 0, len(records))
+	for _, info := range records {
+		if userFilter != "" && info.User != userFilter {
+			continue
+		}
+		if clientIDFilter != "" && info.ClientID != clientIDFilter {
+			continue
+		}
+		if runIDFilter != "" && info.RunID != runIDFilter {
+			continue
+		}
+		if !matchStatusFilter(info.Online, statusFilter) {
+			continue
+		}
+		items = append(items, buildClientInfoResp(info))
+	}
+
+	slices.SortFunc(items, func(a, b ClientInfoResp) int {
+		if v := cmp.Compare(a.User, b.User); v != 0 {
+			return v
+		}
+		if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
+			return v
+		}
+		return cmp.Compare(a.Key, b.Key)
+	})
+
+	return items, nil
+}
+
+// /api/clients/{key}
+func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
+	key := ctx.Param("key")
+	if key == "" {
+		return nil, fmt.Errorf("missing client key")
+	}
+
+	if c.clientRegistry == nil {
+		return nil, fmt.Errorf("client registry unavailable")
+	}
+
+	info, ok := c.clientRegistry.GetByKey(key)
+	if !ok {
+		return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
+	}
+
+	return buildClientInfoResp(info), nil
+}
+
+// /api/proxy/:type
+func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
+	proxyType := ctx.Param("type")
+
+	proxyInfoResp := GetProxyInfoResp{}
+	proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
+	slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
+		return cmp.Compare(a.Name, b.Name)
+	})
+
+	return proxyInfoResp, nil
+}
+
+// /api/proxy/:type/:name
+func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
+	proxyType := ctx.Param("type")
+	name := ctx.Param("name")
+
+	proxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name)
+	if code != 200 {
+		return nil, httppkg.NewError(code, msg)
+	}
+
+	return proxyStatsResp, nil
+}
+
+// /api/traffic/:name
+func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
+	name := ctx.Param("name")
+
+	trafficResp := GetProxyTrafficResp{}
+	trafficResp.Name = name
+	proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
+
+	if proxyTrafficInfo == nil {
+		return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
+	}
+	trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
+	trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
+
+	return trafficResp, nil
+}
+
+// DELETE /api/proxies?status=offline
+func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
+	status := ctx.Query("status")
+	if status != "offline" {
+		return nil, httppkg.NewError(http.StatusBadRequest, "status only support offline")
+	}
+	cleared, total := mem.StatsCollector.ClearOfflineProxies()
+	log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
+	return nil, nil
+}
+
+func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
+	proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
+	proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
+	for _, ps := range proxyStats {
+		proxyInfo := &ProxyStatsInfo{}
+		if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
+			content, err := json.Marshal(pxy.GetConfigurer())
+			if err != nil {
+				log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
+				continue
+			}
+			proxyInfo.Conf = getConfByType(ps.Type)
+			if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
+				log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
+				continue
+			}
+			proxyInfo.Status = "online"
+			if pxy.GetLoginMsg() != nil {
+				proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
+			}
+		} else {
+			proxyInfo.Status = "offline"
+		}
+		proxyInfo.Name = ps.Name
+		proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
+		proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
+		proxyInfo.CurConns = ps.CurConns
+		proxyInfo.LastStartTime = ps.LastStartTime
+		proxyInfo.LastCloseTime = ps.LastCloseTime
+		proxyInfos = append(proxyInfos, proxyInfo)
+	}
+	return
+}
+
+func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
+	proxyInfo.Name = proxyName
+	ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
+	if ps == nil {
+		code = 404
+		msg = "no proxy info found"
+	} else {
+		if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
+			content, err := json.Marshal(pxy.GetConfigurer())
+			if err != nil {
+				log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
+				code = 400
+				msg = "parse conf error"
+				return
+			}
+			proxyInfo.Conf = getConfByType(ps.Type)
+			if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
+				log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
+				code = 400
+				msg = "parse conf error"
+				return
+			}
+			proxyInfo.Status = "online"
+		} else {
+			proxyInfo.Status = "offline"
+		}
+		proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
+		proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
+		proxyInfo.CurConns = ps.CurConns
+		proxyInfo.LastStartTime = ps.LastStartTime
+		proxyInfo.LastCloseTime = ps.LastCloseTime
+		code = 200
+	}
+
+	return
+}
+
+func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
+	resp := ClientInfoResp{
+		Key:              info.Key,
+		User:             info.User,
+		ClientID:         info.ClientID,
+		RunID:            info.RunID,
+		Hostname:         info.Hostname,
+		ClientIP:         info.IP,
+		FirstConnectedAt: toUnix(info.FirstConnectedAt),
+		LastConnectedAt:  toUnix(info.LastConnectedAt),
+		Online:           info.Online,
+	}
+	if !info.DisconnectedAt.IsZero() {
+		resp.DisconnectedAt = info.DisconnectedAt.Unix()
+	}
+	return resp
+}
+
+func toUnix(t time.Time) int64 {
+	if t.IsZero() {
+		return 0
+	}
+	return t.Unix()
+}
+
+func matchStatusFilter(online bool, filter string) bool {
+	switch strings.ToLower(filter) {
+	case "", "all":
+		return true
+	case "online":
+		return online
+	case "offline":
+		return !online
+	default:
+		return true
+	}
+}
+
+func getConfByType(proxyType string) any {
+	switch v1.ProxyType(proxyType) {
+	case v1.ProxyTypeTCP:
+		return &TCPOutConf{}
+	case v1.ProxyTypeTCPMUX:
+		return &TCPMuxOutConf{}
+	case v1.ProxyTypeUDP:
+		return &UDPOutConf{}
+	case v1.ProxyTypeHTTP:
+		return &HTTPOutConf{}
+	case v1.ProxyTypeHTTPS:
+		return &HTTPSOutConf{}
+	case v1.ProxyTypeSTCP:
+		return &STCPOutConf{}
+	case v1.ProxyTypeXTCP:
+		return &XTCPOutConf{}
+	default:
+		return nil
+	}
+}

+ 131 - 0
server/api/types.go

@@ -0,0 +1,131 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type ServerInfoResp struct {
+	Version               string `json:"version"`
+	BindPort              int    `json:"bindPort"`
+	VhostHTTPPort         int    `json:"vhostHTTPPort"`
+	VhostHTTPSPort        int    `json:"vhostHTTPSPort"`
+	TCPMuxHTTPConnectPort int    `json:"tcpmuxHTTPConnectPort"`
+	KCPBindPort           int    `json:"kcpBindPort"`
+	QUICBindPort          int    `json:"quicBindPort"`
+	SubdomainHost         string `json:"subdomainHost"`
+	MaxPoolCount          int64  `json:"maxPoolCount"`
+	MaxPortsPerClient     int64  `json:"maxPortsPerClient"`
+	HeartBeatTimeout      int64  `json:"heartbeatTimeout"`
+	AllowPortsStr         string `json:"allowPortsStr,omitempty"`
+	TLSForce              bool   `json:"tlsForce,omitempty"`
+
+	TotalTrafficIn  int64            `json:"totalTrafficIn"`
+	TotalTrafficOut int64            `json:"totalTrafficOut"`
+	CurConns        int64            `json:"curConns"`
+	ClientCounts    int64            `json:"clientCounts"`
+	ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
+}
+
+type ClientInfoResp struct {
+	Key              string `json:"key"`
+	User             string `json:"user"`
+	ClientID         string `json:"clientID"`
+	RunID            string `json:"runID"`
+	Hostname         string `json:"hostname"`
+	ClientIP         string `json:"clientIP,omitempty"`
+	FirstConnectedAt int64  `json:"firstConnectedAt"`
+	LastConnectedAt  int64  `json:"lastConnectedAt"`
+	DisconnectedAt   int64  `json:"disconnectedAt,omitempty"`
+	Online           bool   `json:"online"`
+}
+
+type BaseOutConf struct {
+	v1.ProxyBaseConfig
+}
+
+type TCPOutConf struct {
+	BaseOutConf
+	RemotePort int `json:"remotePort"`
+}
+
+type TCPMuxOutConf struct {
+	BaseOutConf
+	v1.DomainConfig
+	Multiplexer     string `json:"multiplexer"`
+	RouteByHTTPUser string `json:"routeByHTTPUser"`
+}
+
+type UDPOutConf struct {
+	BaseOutConf
+	RemotePort int `json:"remotePort"`
+}
+
+type HTTPOutConf struct {
+	BaseOutConf
+	v1.DomainConfig
+	Locations         []string `json:"locations"`
+	HostHeaderRewrite string   `json:"hostHeaderRewrite"`
+}
+
+type HTTPSOutConf struct {
+	BaseOutConf
+	v1.DomainConfig
+}
+
+type STCPOutConf struct {
+	BaseOutConf
+}
+
+type XTCPOutConf struct {
+	BaseOutConf
+}
+
+// Get proxy info.
+type ProxyStatsInfo struct {
+	Name            string `json:"name"`
+	Conf            any    `json:"conf"`
+	ClientVersion   string `json:"clientVersion,omitempty"`
+	TodayTrafficIn  int64  `json:"todayTrafficIn"`
+	TodayTrafficOut int64  `json:"todayTrafficOut"`
+	CurConns        int64  `json:"curConns"`
+	LastStartTime   string `json:"lastStartTime"`
+	LastCloseTime   string `json:"lastCloseTime"`
+	Status          string `json:"status"`
+}
+
+type GetProxyInfoResp struct {
+	Proxies []*ProxyStatsInfo `json:"proxies"`
+}
+
+// Get proxy info by name.
+type GetProxyStatsResp struct {
+	Name            string `json:"name"`
+	Conf            any    `json:"conf"`
+	TodayTrafficIn  int64  `json:"todayTrafficIn"`
+	TodayTrafficOut int64  `json:"todayTrafficOut"`
+	CurConns        int64  `json:"curConns"`
+	LastStartTime   string `json:"lastStartTime"`
+	LastCloseTime   string `json:"lastCloseTime"`
+	Status          string `json:"status"`
+}
+
+// /api/traffic/:name
+type GetProxyTrafficResp struct {
+	Name       string  `json:"name"`
+	TrafficIn  []int64 `json:"trafficIn"`
+	TrafficOut []int64 `json:"trafficOut"`
+}

+ 2 - 1
server/control.go

@@ -40,6 +40,7 @@ import (
 	"github.com/fatedier/frp/server/controller"
 	"github.com/fatedier/frp/server/metrics"
 	"github.com/fatedier/frp/server/proxy"
+	"github.com/fatedier/frp/server/registry"
 )
 
 type ControlManager struct {
@@ -147,7 +148,7 @@ type Control struct {
 	// Server configuration information
 	serverCfg *v1.ServerConfig
 
-	clientRegistry *ClientRegistry
+	clientRegistry *registry.ClientRegistry
 
 	xl     *xlog.Logger
 	ctx    context.Context

+ 0 - 563
server/dashboard_api.go

@@ -1,563 +0,0 @@
-// Copyright 2017 fatedier, fatedier@gmail.com
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package server
-
-import (
-	"cmp"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"slices"
-	"strings"
-	"time"
-
-	"github.com/gorilla/mux"
-	"github.com/prometheus/client_golang/prometheus/promhttp"
-
-	"github.com/fatedier/frp/pkg/config/types"
-	v1 "github.com/fatedier/frp/pkg/config/v1"
-	"github.com/fatedier/frp/pkg/metrics/mem"
-	httppkg "github.com/fatedier/frp/pkg/util/http"
-	"github.com/fatedier/frp/pkg/util/log"
-	netpkg "github.com/fatedier/frp/pkg/util/net"
-	"github.com/fatedier/frp/pkg/util/version"
-)
-
-type GeneralResponse struct {
-	Code int
-	Msg  string
-}
-
-func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
-	helper.Router.HandleFunc("/healthz", svr.healthz)
-	subRouter := helper.Router.NewRoute().Subrouter()
-
-	subRouter.Use(helper.AuthMiddleware.Middleware)
-
-	// metrics
-	if svr.cfg.EnablePrometheus {
-		subRouter.Handle("/metrics", promhttp.Handler())
-	}
-
-	// apis
-	subRouter.HandleFunc("/api/serverinfo", svr.apiServerInfo).Methods("GET")
-	subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET")
-	subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET")
-	subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET")
-	subRouter.HandleFunc("/api/clients", svr.apiClientList).Methods("GET")
-	subRouter.HandleFunc("/api/clients/{key}", svr.apiClientDetail).Methods("GET")
-	subRouter.HandleFunc("/api/proxies", svr.deleteProxies).Methods("DELETE")
-
-	// view
-	subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
-	subRouter.PathPrefix("/static/").Handler(
-		netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
-	).Methods("GET")
-
-	subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
-	})
-}
-
-type serverInfoResp struct {
-	Version               string `json:"version"`
-	BindPort              int    `json:"bindPort"`
-	VhostHTTPPort         int    `json:"vhostHTTPPort"`
-	VhostHTTPSPort        int    `json:"vhostHTTPSPort"`
-	TCPMuxHTTPConnectPort int    `json:"tcpmuxHTTPConnectPort"`
-	KCPBindPort           int    `json:"kcpBindPort"`
-	QUICBindPort          int    `json:"quicBindPort"`
-	SubdomainHost         string `json:"subdomainHost"`
-	MaxPoolCount          int64  `json:"maxPoolCount"`
-	MaxPortsPerClient     int64  `json:"maxPortsPerClient"`
-	HeartBeatTimeout      int64  `json:"heartbeatTimeout"`
-	AllowPortsStr         string `json:"allowPortsStr,omitempty"`
-	TLSForce              bool   `json:"tlsForce,omitempty"`
-
-	TotalTrafficIn  int64            `json:"totalTrafficIn"`
-	TotalTrafficOut int64            `json:"totalTrafficOut"`
-	CurConns        int64            `json:"curConns"`
-	ClientCounts    int64            `json:"clientCounts"`
-	ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
-}
-
-type clientInfoResp struct {
-	Key              string `json:"key"`
-	User             string `json:"user"`
-	ClientID         string `json:"clientID"`
-	RunID            string `json:"runID"`
-	Hostname         string `json:"hostname"`
-	ClientIP         string `json:"clientIP,omitempty"`
-	FirstConnectedAt int64  `json:"firstConnectedAt"`
-	LastConnectedAt  int64  `json:"lastConnectedAt"`
-	DisconnectedAt   int64  `json:"disconnectedAt,omitempty"`
-	Online           bool   `json:"online"`
-}
-
-// /healthz
-func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
-	w.WriteHeader(200)
-}
-
-// /api/serverinfo
-func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	log.Infof("http request: [%s]", r.URL.Path)
-	w.Header().Set("Content-Type", "application/json")
-	serverStats := mem.StatsCollector.GetServer()
-	svrResp := serverInfoResp{
-		Version:               version.Full(),
-		BindPort:              svr.cfg.BindPort,
-		VhostHTTPPort:         svr.cfg.VhostHTTPPort,
-		VhostHTTPSPort:        svr.cfg.VhostHTTPSPort,
-		TCPMuxHTTPConnectPort: svr.cfg.TCPMuxHTTPConnectPort,
-		KCPBindPort:           svr.cfg.KCPBindPort,
-		QUICBindPort:          svr.cfg.QUICBindPort,
-		SubdomainHost:         svr.cfg.SubDomainHost,
-		MaxPoolCount:          svr.cfg.Transport.MaxPoolCount,
-		MaxPortsPerClient:     svr.cfg.MaxPortsPerClient,
-		HeartBeatTimeout:      svr.cfg.Transport.HeartbeatTimeout,
-		AllowPortsStr:         types.PortsRangeSlice(svr.cfg.AllowPorts).String(),
-		TLSForce:              svr.cfg.Transport.TLS.Force,
-
-		TotalTrafficIn:  serverStats.TotalTrafficIn,
-		TotalTrafficOut: serverStats.TotalTrafficOut,
-		CurConns:        serverStats.CurConns,
-		ClientCounts:    serverStats.ClientCounts,
-		ProxyTypeCounts: serverStats.ProxyTypeCounts,
-	}
-
-	buf, _ := json.Marshal(&svrResp)
-	res.Msg = string(buf)
-}
-
-// /api/clients
-func (svr *Service) apiClientList(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	log.Infof("http request: [%s]", r.URL.RequestURI())
-
-	if svr.clientRegistry == nil {
-		res.Code = http.StatusInternalServerError
-		res.Msg = "client registry unavailable"
-		return
-	}
-
-	query := r.URL.Query()
-	userFilter := query.Get("user")
-	clientIDFilter := query.Get("clientId")
-	runIDFilter := query.Get("runId")
-	statusFilter := strings.ToLower(query.Get("status"))
-
-	records := svr.clientRegistry.List()
-	items := make([]clientInfoResp, 0, len(records))
-	for _, info := range records {
-		if userFilter != "" && info.User != userFilter {
-			continue
-		}
-		if clientIDFilter != "" && info.ClientID != clientIDFilter {
-			continue
-		}
-		if runIDFilter != "" && info.RunID != runIDFilter {
-			continue
-		}
-		if !matchStatusFilter(info.Online, statusFilter) {
-			continue
-		}
-		items = append(items, buildClientInfoResp(info))
-	}
-
-	slices.SortFunc(items, func(a, b clientInfoResp) int {
-		if v := cmp.Compare(a.User, b.User); v != 0 {
-			return v
-		}
-		if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
-			return v
-		}
-		return cmp.Compare(a.Key, b.Key)
-	})
-
-	buf, _ := json.Marshal(items)
-	res.Msg = string(buf)
-}
-
-// /api/clients/{key}
-func (svr *Service) apiClientDetail(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	log.Infof("http request: [%s]", r.URL.RequestURI())
-
-	vars := mux.Vars(r)
-	key := vars["key"]
-	if key == "" {
-		res.Code = http.StatusBadRequest
-		res.Msg = "missing client key"
-		return
-	}
-
-	if svr.clientRegistry == nil {
-		res.Code = http.StatusInternalServerError
-		res.Msg = "client registry unavailable"
-		return
-	}
-
-	info, ok := svr.clientRegistry.GetByKey(key)
-	if !ok {
-		res.Code = http.StatusNotFound
-		res.Msg = fmt.Sprintf("client %s not found", key)
-		return
-	}
-
-	buf, _ := json.Marshal(buildClientInfoResp(info))
-	res.Msg = string(buf)
-}
-
-type BaseOutConf struct {
-	v1.ProxyBaseConfig
-}
-
-type TCPOutConf struct {
-	BaseOutConf
-	RemotePort int `json:"remotePort"`
-}
-
-type TCPMuxOutConf struct {
-	BaseOutConf
-	v1.DomainConfig
-	Multiplexer     string `json:"multiplexer"`
-	RouteByHTTPUser string `json:"routeByHTTPUser"`
-}
-
-type UDPOutConf struct {
-	BaseOutConf
-	RemotePort int `json:"remotePort"`
-}
-
-type HTTPOutConf struct {
-	BaseOutConf
-	v1.DomainConfig
-	Locations         []string `json:"locations"`
-	HostHeaderRewrite string   `json:"hostHeaderRewrite"`
-}
-
-type HTTPSOutConf struct {
-	BaseOutConf
-	v1.DomainConfig
-}
-
-type STCPOutConf struct {
-	BaseOutConf
-}
-
-type XTCPOutConf struct {
-	BaseOutConf
-}
-
-func getConfByType(proxyType string) any {
-	switch v1.ProxyType(proxyType) {
-	case v1.ProxyTypeTCP:
-		return &TCPOutConf{}
-	case v1.ProxyTypeTCPMUX:
-		return &TCPMuxOutConf{}
-	case v1.ProxyTypeUDP:
-		return &UDPOutConf{}
-	case v1.ProxyTypeHTTP:
-		return &HTTPOutConf{}
-	case v1.ProxyTypeHTTPS:
-		return &HTTPSOutConf{}
-	case v1.ProxyTypeSTCP:
-		return &STCPOutConf{}
-	case v1.ProxyTypeXTCP:
-		return &XTCPOutConf{}
-	default:
-		return nil
-	}
-}
-
-// Get proxy info.
-type ProxyStatsInfo struct {
-	Name            string `json:"name"`
-	Conf            any    `json:"conf"`
-	ClientVersion   string `json:"clientVersion,omitempty"`
-	TodayTrafficIn  int64  `json:"todayTrafficIn"`
-	TodayTrafficOut int64  `json:"todayTrafficOut"`
-	CurConns        int64  `json:"curConns"`
-	LastStartTime   string `json:"lastStartTime"`
-	LastCloseTime   string `json:"lastCloseTime"`
-	Status          string `json:"status"`
-}
-
-type GetProxyInfoResp struct {
-	Proxies []*ProxyStatsInfo `json:"proxies"`
-}
-
-// /api/proxy/:type
-func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	params := mux.Vars(r)
-	proxyType := params["type"]
-
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-	log.Infof("http request: [%s]", r.URL.Path)
-
-	proxyInfoResp := GetProxyInfoResp{}
-	proxyInfoResp.Proxies = svr.getProxyStatsByType(proxyType)
-	slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
-		return cmp.Compare(a.Name, b.Name)
-	})
-
-	buf, _ := json.Marshal(&proxyInfoResp)
-	res.Msg = string(buf)
-}
-
-func (svr *Service) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
-	proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
-	proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
-	for _, ps := range proxyStats {
-		proxyInfo := &ProxyStatsInfo{}
-		if pxy, ok := svr.pxyManager.GetByName(ps.Name); ok {
-			content, err := json.Marshal(pxy.GetConfigurer())
-			if err != nil {
-				log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
-				continue
-			}
-			proxyInfo.Conf = getConfByType(ps.Type)
-			if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
-				log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
-				continue
-			}
-			proxyInfo.Status = "online"
-			if pxy.GetLoginMsg() != nil {
-				proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
-			}
-		} else {
-			proxyInfo.Status = "offline"
-		}
-		proxyInfo.Name = ps.Name
-		proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
-		proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
-		proxyInfo.CurConns = ps.CurConns
-		proxyInfo.LastStartTime = ps.LastStartTime
-		proxyInfo.LastCloseTime = ps.LastCloseTime
-		proxyInfos = append(proxyInfos, proxyInfo)
-	}
-	return
-}
-
-// Get proxy info by name.
-type GetProxyStatsResp struct {
-	Name            string `json:"name"`
-	Conf            any    `json:"conf"`
-	TodayTrafficIn  int64  `json:"todayTrafficIn"`
-	TodayTrafficOut int64  `json:"todayTrafficOut"`
-	CurConns        int64  `json:"curConns"`
-	LastStartTime   string `json:"lastStartTime"`
-	LastCloseTime   string `json:"lastCloseTime"`
-	Status          string `json:"status"`
-}
-
-// /api/proxy/:type/:name
-func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	params := mux.Vars(r)
-	proxyType := params["type"]
-	name := params["name"]
-
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-	log.Infof("http request: [%s]", r.URL.Path)
-
-	var proxyStatsResp GetProxyStatsResp
-	proxyStatsResp, res.Code, res.Msg = svr.getProxyStatsByTypeAndName(proxyType, name)
-	if res.Code != 200 {
-		return
-	}
-
-	buf, _ := json.Marshal(&proxyStatsResp)
-	res.Msg = string(buf)
-}
-
-func (svr *Service) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
-	proxyInfo.Name = proxyName
-	ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
-	if ps == nil {
-		code = 404
-		msg = "no proxy info found"
-	} else {
-		if pxy, ok := svr.pxyManager.GetByName(proxyName); ok {
-			content, err := json.Marshal(pxy.GetConfigurer())
-			if err != nil {
-				log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
-				code = 400
-				msg = "parse conf error"
-				return
-			}
-			proxyInfo.Conf = getConfByType(ps.Type)
-			if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
-				log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
-				code = 400
-				msg = "parse conf error"
-				return
-			}
-			proxyInfo.Status = "online"
-		} else {
-			proxyInfo.Status = "offline"
-		}
-		proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
-		proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
-		proxyInfo.CurConns = ps.CurConns
-		proxyInfo.LastStartTime = ps.LastStartTime
-		proxyInfo.LastCloseTime = ps.LastCloseTime
-		code = 200
-	}
-
-	return
-}
-
-// /api/traffic/:name
-type GetProxyTrafficResp struct {
-	Name       string  `json:"name"`
-	TrafficIn  []int64 `json:"trafficIn"`
-	TrafficOut []int64 `json:"trafficOut"`
-}
-
-func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-	params := mux.Vars(r)
-	name := params["name"]
-
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-	log.Infof("http request: [%s]", r.URL.Path)
-
-	trafficResp := GetProxyTrafficResp{}
-	trafficResp.Name = name
-	proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
-
-	if proxyTrafficInfo == nil {
-		res.Code = 404
-		res.Msg = "no proxy info found"
-		return
-	}
-	trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
-	trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
-
-	buf, _ := json.Marshal(&trafficResp)
-	res.Msg = string(buf)
-}
-
-// DELETE /api/proxies?status=offline
-func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
-	res := GeneralResponse{Code: 200}
-
-	log.Infof("http request: [%s]", r.URL.Path)
-	defer func() {
-		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
-		w.WriteHeader(res.Code)
-		if len(res.Msg) > 0 {
-			_, _ = w.Write([]byte(res.Msg))
-		}
-	}()
-
-	status := r.URL.Query().Get("status")
-	if status != "offline" {
-		res.Code = 400
-		res.Msg = "status only support offline"
-		return
-	}
-	cleared, total := mem.StatsCollector.ClearOfflineProxies()
-	log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
-}
-
-func buildClientInfoResp(info ClientInfo) clientInfoResp {
-	resp := clientInfoResp{
-		Key:              info.Key,
-		User:             info.User,
-		ClientID:         info.ClientID,
-		RunID:            info.RunID,
-		Hostname:         info.Hostname,
-		ClientIP:         info.IP,
-		FirstConnectedAt: toUnix(info.FirstConnectedAt),
-		LastConnectedAt:  toUnix(info.LastConnectedAt),
-		Online:           info.Online,
-	}
-	if !info.DisconnectedAt.IsZero() {
-		resp.DisconnectedAt = info.DisconnectedAt.Unix()
-	}
-	return resp
-}
-
-func toUnix(t time.Time) int64 {
-	if t.IsZero() {
-		return 0
-	}
-	return t.Unix()
-}
-
-func matchStatusFilter(online bool, filter string) bool {
-	switch strings.ToLower(filter) {
-	case "", "all":
-		return true
-	case "online":
-		return online
-	case "offline":
-		return !online
-	default:
-		return true
-	}
-}

+ 15 - 1
server/client_registry.go → server/registry/registry.go

@@ -1,4 +1,18 @@
-package server
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package registry
 
 import (
 	"fmt"

+ 43 - 2
server/service.go

@@ -28,6 +28,7 @@ import (
 	"github.com/fatedier/golib/crypto"
 	"github.com/fatedier/golib/net/mux"
 	fmux "github.com/hashicorp/yamux"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
 	quic "github.com/quic-go/quic-go"
 	"github.com/samber/lo"
 
@@ -47,11 +48,13 @@ import (
 	"github.com/fatedier/frp/pkg/util/version"
 	"github.com/fatedier/frp/pkg/util/vhost"
 	"github.com/fatedier/frp/pkg/util/xlog"
+	"github.com/fatedier/frp/server/api"
 	"github.com/fatedier/frp/server/controller"
 	"github.com/fatedier/frp/server/group"
 	"github.com/fatedier/frp/server/metrics"
 	"github.com/fatedier/frp/server/ports"
 	"github.com/fatedier/frp/server/proxy"
+	"github.com/fatedier/frp/server/registry"
 	"github.com/fatedier/frp/server/visitor"
 )
 
@@ -97,7 +100,7 @@ type Service struct {
 	ctlManager *ControlManager
 
 	// Track logical clients keyed by user.clientID.
-	clientRegistry *ClientRegistry
+	clientRegistry *registry.ClientRegistry
 
 	// Manage all proxies
 	pxyManager *proxy.Manager
@@ -159,7 +162,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
 
 	svr := &Service{
 		ctlManager:     NewControlManager(),
-		clientRegistry: NewClientRegistry(),
+		clientRegistry: registry.NewClientRegistry(),
 		pxyManager:     proxy.NewManager(),
 		pluginManager:  plugin.NewManager(),
 		rc: &controller.ResourceController{
@@ -687,3 +690,41 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
 	return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
 		newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
 }
+
+func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
+	helper.Router.HandleFunc("/healthz", healthz)
+	subRouter := helper.Router.NewRoute().Subrouter()
+
+	subRouter.Use(helper.AuthMiddleware)
+	subRouter.Use(httppkg.NewRequestLogger)
+
+	// metrics
+	if svr.cfg.EnablePrometheus {
+		subRouter.Handle("/metrics", promhttp.Handler())
+	}
+
+	apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
+
+	// apis
+	subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
+	subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
+	subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
+	subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
+	subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
+	subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
+	subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
+
+	// view
+	subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
+	subRouter.PathPrefix("/static/").Handler(
+		netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
+	).Methods("GET")
+
+	subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
+	})
+}
+
+func healthz(w http.ResponseWriter, _ *http.Request) {
+	w.WriteHeader(200)
+}