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) +}