diff --git a/README.md b/README.md
index 822e1023..1bc0db60 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
An open source, self-hosted alternative to public clouds, built for data ownership and privacy
-
## Recall.ai - API for meeting recordings
diff --git a/client/admin_api.go b/client/admin_api.go
index 98357464..09936352 100644
--- a/client/admin_api.go
+++ b/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)
+ apiController := newAPIController(svr)
+
+ // Healthz endpoint without auth
+ helper.Router.HandleFunc("/healthz", healthz)
+
+ // API routes and static files with auth
subRouter := helper.Router.NewRoute().Subrouter()
-
- subRouter.Use(helper.AuthMiddleware.Middleware)
-
- // 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
+ 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)
+func healthz(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
}
-// 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")
+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,
+ })
}
-// 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 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
-}
-
-// 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()
}
diff --git a/client/api/controller.go b/client/api/controller.go
new file mode 100644
index 00000000..6874b724
--- /dev/null
+++ b/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
+}
diff --git a/client/api/types.go b/client/api/types.go
new file mode 100644
index 00000000..d7c930d0
--- /dev/null
+++ b/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"`
+}
diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go
index 13713e27..dfad996d 100644
--- a/pkg/sdk/client/client.go
+++ b/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))
}
diff --git a/pkg/util/http/context.go b/pkg/util/http/context.go
new file mode 100644
index 00000000..ae4b7ea6
--- /dev/null
+++ b/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)
+}
diff --git a/pkg/util/http/error.go b/pkg/util/http/error.go
new file mode 100644
index 00000000..8206141b
--- /dev/null
+++ b/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),
+ }
+}
diff --git a/pkg/util/http/handler.go b/pkg/util/http/handler.go
new file mode 100644
index 00000000..d6c7be18
--- /dev/null
+++ b/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)
+ }
+ }
+}
diff --git a/pkg/util/http/middleware.go b/pkg/util/http/middleware.go
new file mode 100644
index 00000000..40009a40
--- /dev/null
+++ b/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)
+ })
+}
diff --git a/server/api/controller.go b/server/api/controller.go
new file mode 100644
index 00000000..ead224d4
--- /dev/null
+++ b/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
+ }
+}
diff --git a/server/api/types.go b/server/api/types.go
new file mode 100644
index 00000000..86e68da0
--- /dev/null
+++ b/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"`
+}
diff --git a/server/control.go b/server/control.go
index d9f8293b..23e98c9e 100644
--- a/server/control.go
+++ b/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
diff --git a/server/dashboard_api.go b/server/dashboard_api.go
deleted file mode 100644
index 44aa23c9..00000000
--- a/server/dashboard_api.go
+++ /dev/null
@@ -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
- }
-}
diff --git a/server/client_registry.go b/server/registry/registry.go
similarity index 84%
rename from server/client_registry.go
rename to server/registry/registry.go
index 8f51973a..88bf90e3 100644
--- a/server/client_registry.go
+++ b/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"
diff --git a/server/service.go b/server/service.go
index 20688c29..decf1cef 100644
--- a/server/service.go
+++ b/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)
+}