From fbeb6ca43affc8878a074a08d463644aad59cf15 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Mar 2026 17:38:43 +0800 Subject: [PATCH] refactor: restructure API packages into client/http and server/http with typed proxy/visitor models (#5193) --- client/{admin_api.go => api_router.go} | 6 +- client/config_manager.go | 123 +++++++--- client/config_manager_test.go | 11 +- client/configmgmt/types.go | 8 +- client/{api => http}/controller.go | 175 +++++++------- client/{api => http}/controller_test.go | 295 +++++++++++++++++------- client/http/model/proxy_definition.go | 148 ++++++++++++ client/{api => http/model}/types.go | 20 +- client/http/model/visitor_definition.go | 107 +++++++++ pkg/config/load.go | 69 +++--- pkg/config/load_test.go | 25 ++ pkg/config/source/store.go | 52 +++-- pkg/config/source/store_test.go | 29 +-- pkg/config/v1/common.go | 25 -- pkg/config/v1/decode.go | 195 ++++++++++++++++ pkg/config/v1/decode_test.go | 86 +++++++ pkg/config/v1/proxy.go | 30 +-- pkg/config/v1/proxy_plugin.go | 39 +--- pkg/config/v1/visitor.go | 30 +-- pkg/config/v1/visitor_plugin.go | 40 +--- pkg/sdk/client/client.go | 10 +- pkg/util/jsonx/json_v1.go | 45 ++++ pkg/util/jsonx/raw_message.go | 36 +++ server/api_router.go | 64 +++++ server/{api => http}/controller.go | 133 ++++------- server/http/controller_test.go | 71 ++++++ server/{api => http/model}/types.go | 2 +- server/service.go | 41 ---- test/e2e/v1/features/store.go | 135 +++++++++-- web/frpc/src/api/frpc.ts | 32 +-- web/frpc/src/types/proxy.ts | 288 +++++++++++++++-------- web/frpc/src/views/Overview.vue | 61 +++-- 32 files changed, 1704 insertions(+), 727 deletions(-) rename client/{admin_api.go => api_router.go} (95%) rename client/{api => http}/controller.go (68%) rename client/{api => http}/controller_test.go (52%) create mode 100644 client/http/model/proxy_definition.go rename client/{api => http/model}/types.go (71%) create mode 100644 client/http/model/visitor_definition.go create mode 100644 pkg/config/v1/decode.go create mode 100644 pkg/config/v1/decode_test.go create mode 100644 pkg/util/jsonx/json_v1.go create mode 100644 pkg/util/jsonx/raw_message.go create mode 100644 server/api_router.go rename server/{api => http}/controller.go (71%) create mode 100644 server/http/controller_test.go rename server/{api => http/model}/types.go (99%) diff --git a/client/admin_api.go b/client/api_router.go similarity index 95% rename from client/admin_api.go rename to client/api_router.go index 1343931d..ceab4fbc 100644 --- a/client/admin_api.go +++ b/client/api_router.go @@ -17,7 +17,7 @@ package client import ( "net/http" - "github.com/fatedier/frp/client/api" + adminapi "github.com/fatedier/frp/client/http" "github.com/fatedier/frp/client/proxy" httppkg "github.com/fatedier/frp/pkg/util/http" netpkg "github.com/fatedier/frp/pkg/util/net" @@ -65,9 +65,9 @@ func healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } -func newAPIController(svr *Service) *api.Controller { +func newAPIController(svr *Service) *adminapi.Controller { manager := newServiceConfigManager(svr) - return api.NewController(api.ControllerParams{ + return adminapi.NewController(adminapi.ControllerParams{ ServerAddr: svr.common.ServerAddr, Manager: manager, }) diff --git a/client/config_manager.go b/client/config_manager.go index 0ed6b3c2..1d3fb0ec 100644 --- a/client/config_manager.go +++ b/client/config_manager.go @@ -133,12 +133,13 @@ func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, e return cfg, nil } -func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error { +func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if err := m.validateStoreProxyConfigurer(cfg); err != nil { - return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) + return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } - if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { + name := cfg.GetBaseConfig().Name + persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.AddProxy(cfg); err != nil { if errors.Is(err, source.ErrAlreadyExists) { return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) @@ -146,30 +147,30 @@ func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error { return err } return nil - }); err != nil { - return err + }) + if err != nil { + return nil, err } - - log.Infof("store: created proxy %q", cfg.GetBaseConfig().Name) - return nil + log.Infof("store: created proxy %q", name) + return persisted, nil } -func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error { +func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if name == "" { - return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument) } if cfg == nil { - return fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument) } bodyName := cfg.GetBaseConfig().Name if bodyName != name { - return fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument) } if err := m.validateStoreProxyConfigurer(cfg); err != nil { - return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) + return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } - if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { + persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.UpdateProxy(cfg); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) @@ -177,12 +178,13 @@ func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigu return err } return nil - }); err != nil { - return err + }) + if err != nil { + return nil, err } log.Infof("store: updated proxy %q", name) - return nil + return persisted, nil } func (m *serviceConfigManager) DeleteStoreProxy(name string) error { @@ -231,12 +233,13 @@ func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigure return cfg, nil } -func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) error { +func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if err := m.validateStoreVisitorConfigurer(cfg); err != nil { - return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) + return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } - if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { + name := cfg.GetBaseConfig().Name + persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.AddVisitor(cfg); err != nil { if errors.Is(err, source.ErrAlreadyExists) { return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) @@ -244,30 +247,31 @@ func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) erro return err } return nil - }); err != nil { - return err + }) + if err != nil { + return nil, err } - log.Infof("store: created visitor %q", cfg.GetBaseConfig().Name) - return nil + log.Infof("store: created visitor %q", name) + return persisted, nil } -func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error { +func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if name == "" { - return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument) } if cfg == nil { - return fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument) } bodyName := cfg.GetBaseConfig().Name if bodyName != name { - return fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument) + return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument) } if err := m.validateStoreVisitorConfigurer(cfg); err != nil { - return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) + return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } - if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { + persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.UpdateVisitor(cfg); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) @@ -275,12 +279,13 @@ func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorCon return err } return nil - }); err != nil { - return err + }) + if err != nil { + return nil, err } log.Infof("store: updated visitor %q", name) - return nil + return persisted, nil } func (m *serviceConfigManager) DeleteStoreVisitor(name string) error { @@ -340,6 +345,58 @@ func (m *serviceConfigManager) withStoreMutationAndReload( return nil } +func (m *serviceConfigManager) withStoreProxyMutationAndReload( + name string, + fn func(storeSource *source.StoreSource) error, +) (v1.ProxyConfigurer, error) { + m.svr.reloadMu.Lock() + defer m.svr.reloadMu.Unlock() + + storeSource := m.svr.storeSource + if storeSource == nil { + return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) + } + + if err := fn(storeSource); err != nil { + return nil, err + } + if err := m.svr.reloadConfigFromSourcesLocked(); err != nil { + return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err) + } + + persisted := storeSource.GetProxy(name) + if persisted == nil { + return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name) + } + return persisted.Clone(), nil +} + +func (m *serviceConfigManager) withStoreVisitorMutationAndReload( + name string, + fn func(storeSource *source.StoreSource) error, +) (v1.VisitorConfigurer, error) { + m.svr.reloadMu.Lock() + defer m.svr.reloadMu.Unlock() + + storeSource := m.svr.storeSource + if storeSource == nil { + return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) + } + + if err := fn(storeSource); err != nil { + return nil, err + } + if err := m.svr.reloadConfigFromSourcesLocked(); err != nil { + return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err) + } + + persisted := storeSource.GetVisitor(name) + if persisted == nil { + return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name) + } + return persisted.Clone(), nil +} + func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error { if cfg == nil { return fmt.Errorf("invalid proxy config") diff --git a/client/config_manager_test.go b/client/config_manager_test.go index 13152497..07ae3297 100644 --- a/client/config_manager_test.go +++ b/client/config_manager_test.go @@ -45,7 +45,7 @@ func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) { }, } - err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) + _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected conflict error") } @@ -69,7 +69,7 @@ func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testin }, } - err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) + _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected apply config error") } @@ -88,7 +88,7 @@ func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) { }, } - err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) + _, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected store disabled error") } @@ -116,10 +116,13 @@ func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *te }, } - err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy")) + persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy")) if err != nil { t.Fatalf("create store proxy: %v", err) } + if persisted == nil { + t.Fatal("expected persisted proxy to be returned") + } got := storeSource.GetProxy("raw-proxy") if got == nil { diff --git a/client/configmgmt/types.go b/client/configmgmt/types.go index 5da75fb8..090d0b7b 100644 --- a/client/configmgmt/types.go +++ b/client/configmgmt/types.go @@ -28,14 +28,14 @@ type ConfigManager interface { ListStoreProxies() ([]v1.ProxyConfigurer, error) GetStoreProxy(name string) (v1.ProxyConfigurer, error) - CreateStoreProxy(cfg v1.ProxyConfigurer) error - UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error + CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) + UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) DeleteStoreProxy(name string) error ListStoreVisitors() ([]v1.VisitorConfigurer, error) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) - CreateStoreVisitor(cfg v1.VisitorConfigurer) error - UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error + CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) + UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) DeleteStoreVisitor(name string) error GracefulClose(d time.Duration) diff --git a/client/api/controller.go b/client/http/controller.go similarity index 68% rename from client/api/controller.go rename to client/http/controller.go index 2ba44216..d06396dc 100644 --- a/client/api/controller.go +++ b/client/http/controller.go @@ -12,11 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package api +package http import ( "cmp" - "encoding/json" "errors" "fmt" "net" @@ -26,9 +25,10 @@ import ( "time" "github.com/fatedier/frp/client/configmgmt" + "github.com/fatedier/frp/client/http/model" "github.com/fatedier/frp/client/proxy" - v1 "github.com/fatedier/frp/pkg/config/v1" httppkg "github.com/fatedier/frp/pkg/util/http" + "github.com/fatedier/frp/pkg/util/jsonx" ) // Controller handles HTTP API requests for frpc. @@ -67,15 +67,6 @@ func (c *Controller) toHTTPError(err error) error { return httppkg.NewError(code, err.Error()) } -// TODO(fatedier): Remove this lock wrapper after migrating typed config -// decoding to encoding/json/v2 with per-call options. -// TypedProxyConfig/TypedVisitorConfig currently read global strictness state. -func unmarshalTypedConfig[T any](body []byte, out *T) error { - return v1.WithDisallowUnknownFields(false, func() error { - return json.Unmarshal(body, out) - }) -} - // Reload handles GET /api/reload func (c *Controller) Reload(ctx *httppkg.Context) (any, error) { strictConfigMode := false @@ -98,7 +89,7 @@ func (c *Controller) Stop(ctx *httppkg.Context) (any, error) { // Status handles GET /api/status func (c *Controller) Status(ctx *httppkg.Context) (any, error) { - res := make(StatusResp) + res := make(model.StatusResp) ps := c.manager.GetProxyStatus() if ps == nil { return res, nil @@ -112,7 +103,7 @@ func (c *Controller) Status(ctx *httppkg.Context) (any, error) { if len(arrs) <= 1 { continue } - slices.SortFunc(arrs, func(a, b ProxyStatusResp) int { + slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int { return cmp.Compare(a.Name, b.Name) }) } @@ -145,8 +136,8 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) { return nil, nil } -func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp { - psr := ProxyStatusResp{ +func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp { + psr := model.ProxyStatusResp{ Name: status.Name, Type: status.Type, Status: status.Phase, @@ -166,7 +157,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat } if c.manager.IsStoreProxyEnabled(status.Name) { - psr.Source = SourceStore + psr.Source = model.SourceStore } return psr } @@ -177,18 +168,17 @@ func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) { return nil, c.toHTTPError(err) } - resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))} + resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))} for _, p := range proxies { - cfg, err := configurerToMap(p) + payload, err := model.ProxyDefinitionFromConfigurer(p) if err != nil { - continue + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } - resp.Proxies = append(resp.Proxies, ProxyConfig{ - Name: p.GetBaseConfig().Name, - Type: p.GetBaseConfig().Type, - Config: cfg, - }) + resp.Proxies = append(resp.Proxies, payload) } + slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int { + return cmp.Compare(a.Name, b.Name) + }) return resp, nil } @@ -203,16 +193,12 @@ func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) { return nil, c.toHTTPError(err) } - cfg, err := configurerToMap(p) + payload, err := model.ProxyDefinitionFromConfigurer(p) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } - return ProxyConfig{ - Name: p.GetBaseConfig().Name, - Type: p.GetBaseConfig().Type, - Config: cfg, - }, nil + return payload, nil } func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) { @@ -221,19 +207,28 @@ func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } - var typed v1.TypedProxyConfig - if err := unmarshalTypedConfig(body, &typed); err != nil { + var payload model.ProxyDefinition + if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } - if typed.ProxyConfigurer == nil { - return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required") + if err := payload.Validate("", false); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - - if err := c.manager.CreateStoreProxy(typed.ProxyConfigurer); err != nil { + cfg, err := payload.ToConfigurer() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) + } + created, err := c.manager.CreateStoreProxy(cfg) + if err != nil { return nil, c.toHTTPError(err) } - return nil, nil + + resp, err := model.ProxyDefinitionFromConfigurer(created) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return resp, nil } func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) { @@ -247,19 +242,28 @@ func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } - var typed v1.TypedProxyConfig - if err := unmarshalTypedConfig(body, &typed); err != nil { + var payload model.ProxyDefinition + if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } - if typed.ProxyConfigurer == nil { - return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required") + if err := payload.Validate(name, true); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - - if err := c.manager.UpdateStoreProxy(name, typed.ProxyConfigurer); err != nil { + cfg, err := payload.ToConfigurer() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) + } + updated, err := c.manager.UpdateStoreProxy(name, cfg) + if err != nil { return nil, c.toHTTPError(err) } - return nil, nil + + resp, err := model.ProxyDefinitionFromConfigurer(updated) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return resp, nil } func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) { @@ -280,18 +284,17 @@ func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) { return nil, c.toHTTPError(err) } - resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))} + resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))} for _, v := range visitors { - cfg, err := configurerToMap(v) + payload, err := model.VisitorDefinitionFromConfigurer(v) if err != nil { - continue + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } - resp.Visitors = append(resp.Visitors, VisitorConfig{ - Name: v.GetBaseConfig().Name, - Type: v.GetBaseConfig().Type, - Config: cfg, - }) + resp.Visitors = append(resp.Visitors, payload) } + slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int { + return cmp.Compare(a.Name, b.Name) + }) return resp, nil } @@ -306,16 +309,12 @@ func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) { return nil, c.toHTTPError(err) } - cfg, err := configurerToMap(v) + payload, err := model.VisitorDefinitionFromConfigurer(v) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } - return VisitorConfig{ - Name: v.GetBaseConfig().Name, - Type: v.GetBaseConfig().Type, - Config: cfg, - }, nil + return payload, nil } func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) { @@ -324,19 +323,28 @@ func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } - var typed v1.TypedVisitorConfig - if err := unmarshalTypedConfig(body, &typed); err != nil { + var payload model.VisitorDefinition + if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } - if typed.VisitorConfigurer == nil { - return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required") + if err := payload.Validate("", false); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - - if err := c.manager.CreateStoreVisitor(typed.VisitorConfigurer); err != nil { + cfg, err := payload.ToConfigurer() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) + } + created, err := c.manager.CreateStoreVisitor(cfg) + if err != nil { return nil, c.toHTTPError(err) } - return nil, nil + + resp, err := model.VisitorDefinitionFromConfigurer(created) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return resp, nil } func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) { @@ -350,19 +358,28 @@ func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } - var typed v1.TypedVisitorConfig - if err := unmarshalTypedConfig(body, &typed); err != nil { + var payload model.VisitorDefinition + if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } - if typed.VisitorConfigurer == nil { - return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required") + if err := payload.Validate(name, true); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - - if err := c.manager.UpdateStoreVisitor(name, typed.VisitorConfigurer); err != nil { + cfg, err := payload.ToConfigurer() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) + } + updated, err := c.manager.UpdateStoreVisitor(name, cfg) + if err != nil { return nil, c.toHTTPError(err) } - return nil, nil + + resp, err := model.VisitorDefinitionFromConfigurer(updated) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return resp, nil } func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) { @@ -376,15 +393,3 @@ func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) { } return nil, nil } - -func configurerToMap(v any) (map[string]any, error) { - data, err := json.Marshal(v) - if err != nil { - return nil, err - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - return m, nil -} diff --git a/client/api/controller_test.go b/client/http/controller_test.go similarity index 52% rename from client/api/controller_test.go rename to client/http/controller_test.go index d237b6ba..aa88c545 100644 --- a/client/api/controller_test.go +++ b/client/http/controller_test.go @@ -1,4 +1,4 @@ -package api +package http import ( "bytes" @@ -13,6 +13,7 @@ import ( "github.com/gorilla/mux" "github.com/fatedier/frp/client/configmgmt" + "github.com/fatedier/frp/client/http/model" "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" httppkg "github.com/fatedier/frp/pkg/util/http" @@ -28,13 +29,13 @@ type fakeConfigManager struct { listStoreProxiesFn func() ([]v1.ProxyConfigurer, error) getStoreProxyFn func(name string) (v1.ProxyConfigurer, error) - createStoreProxyFn func(cfg v1.ProxyConfigurer) error - updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) error + createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) + updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) deleteStoreProxyFn func(name string) error listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error) getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error) - createStoreVisitFn func(cfg v1.VisitorConfigurer) error - updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) error + createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) + updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) deleteStoreVisitFn func(name string) error gracefulCloseFn func(d time.Duration) } @@ -95,18 +96,18 @@ func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, erro return nil, nil } -func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error { +func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if m.createStoreProxyFn != nil { return m.createStoreProxyFn(cfg) } - return nil + return cfg, nil } -func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error { +func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if m.updateStoreProxyFn != nil { return m.updateStoreProxyFn(name, cfg) } - return nil + return cfg, nil } func (m *fakeConfigManager) DeleteStoreProxy(name string) error { @@ -130,18 +131,18 @@ func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, return nil, nil } -func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) error { +func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if m.createStoreVisitFn != nil { return m.createStoreVisitFn(cfg) } - return nil + return cfg, nil } -func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error { +func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if m.updateStoreVisitFn != nil { return m.updateStoreVisitFn(name, cfg) } - return nil + return cfg, nil } func (m *fakeConfigManager) DeleteStoreVisitor(name string) error { @@ -157,25 +158,6 @@ func (m *fakeConfigManager) GracefulClose(d time.Duration) { } } -func setDisallowUnknownFieldsForTest(t *testing.T, value bool) func() { - t.Helper() - v1.DisallowUnknownFieldsMu.Lock() - prev := v1.DisallowUnknownFields - v1.DisallowUnknownFields = value - v1.DisallowUnknownFieldsMu.Unlock() - return func() { - v1.DisallowUnknownFieldsMu.Lock() - v1.DisallowUnknownFields = prev - v1.DisallowUnknownFieldsMu.Unlock() - } -} - -func getDisallowUnknownFieldsForTest() bool { - v1.DisallowUnknownFieldsMu.Lock() - defer v1.DisallowUnknownFieldsMu.Unlock() - return v1.DisallowUnknownFields -} - func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig { return &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ @@ -188,18 +170,6 @@ func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig { } } -func newRawXTCPVisitorConfig(name string) *v1.XTCPVisitorConfig { - return &v1.XTCPVisitorConfig{ - VisitorBaseConfig: v1.VisitorBaseConfig{ - Name: name, - Type: "xtcp", - ServerName: "server", - BindPort: 10081, - SecretKey: "secret", - }, - } -} - func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) { status := &proxy.WorkingStatus{ Name: "shared-proxy", @@ -265,22 +235,20 @@ func TestStoreProxyErrorMapping(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - body, err := json.Marshal(newRawTCPProxyConfig("shared-proxy")) - if err != nil { - t.Fatalf("marshal body: %v", err) - } - + body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`) req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) controller := &Controller{ manager: &fakeConfigManager{ - updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) error { return tc.err }, + updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { + return nil, tc.err + }, }, } - _, err = controller.UpdateStoreProxy(ctx) + _, err := controller.UpdateStoreProxy(ctx) if err == nil { t.Fatal("expected error") } @@ -290,11 +258,7 @@ func TestStoreProxyErrorMapping(t *testing.T) { } func TestStoreVisitorErrorMapping(t *testing.T) { - body, err := json.Marshal(newRawXTCPVisitorConfig("shared-visitor")) - if err != nil { - t.Fatalf("marshal body: %v", err) - } - + body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`) req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) @@ -307,70 +271,208 @@ func TestStoreVisitorErrorMapping(t *testing.T) { }, } - _, err = controller.DeleteStoreVisitor(ctx) + _, err := controller.DeleteStoreVisitor(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusNotFound) } -func TestCreateStoreProxy_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) { - restore := setDisallowUnknownFieldsForTest(t, true) - t.Cleanup(restore) - +func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) { var gotName string controller := &Controller{ manager: &fakeConfigManager{ - createStoreProxyFn: func(cfg v1.ProxyConfigurer) error { + createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { gotName = cfg.GetBaseConfig().Name - return nil + return cfg, nil }, }, } - body := []byte(`{"name":"raw-proxy","type":"tcp","localPort":10080,"unexpected":"value"}`) + body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`) req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) - _, err := controller.CreateStoreProxy(ctx) + resp, err := controller.CreateStoreProxy(ctx) if err != nil { t.Fatalf("create store proxy: %v", err) } if gotName != "raw-proxy" { t.Fatalf("unexpected proxy name: %q", gotName) } - if !getDisallowUnknownFieldsForTest() { - t.Fatal("global strictness flag was not restored") + + payload, ok := resp.(model.ProxyDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Type != "tcp" || payload.TCP == nil { + t.Fatalf("unexpected payload: %#v", payload) } } -func TestCreateStoreVisitor_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) { - restore := setDisallowUnknownFieldsForTest(t, true) - t.Cleanup(restore) - +func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) { var gotName string controller := &Controller{ manager: &fakeConfigManager{ - createStoreVisitFn: func(cfg v1.VisitorConfigurer) error { + createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { gotName = cfg.GetBaseConfig().Name - return nil + return cfg, nil }, }, } - body := []byte(`{"name":"raw-visitor","type":"xtcp","serverName":"server","bindPort":10081,"secretKey":"secret","unexpected":"value"}`) + body := []byte(`{ + "name":"raw-visitor","type":"xtcp","unexpected":"value", + "xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"} + }`) req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) - _, err := controller.CreateStoreVisitor(ctx) + resp, err := controller.CreateStoreVisitor(ctx) if err != nil { t.Fatalf("create store visitor: %v", err) } if gotName != "raw-visitor" { t.Fatalf("unexpected visitor name: %q", gotName) } - if !getDisallowUnknownFieldsForTest() { - t.Fatal("global strictness flag was not restored") + + payload, ok := resp.(model.VisitorDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Type != "xtcp" || payload.XTCP == nil { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) { + var gotPluginType string + controller := &Controller{ + manager: &fakeConfigManager{ + createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { + gotPluginType = cfg.GetBaseConfig().Plugin.Type + return cfg, nil + }, + }, + } + + body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`) + req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body)) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.CreateStoreProxy(ctx) + if err != nil { + t.Fatalf("create store proxy: %v", err) + } + if gotPluginType != "http2https" { + t.Fatalf("unexpected plugin type: %q", gotPluginType) + } + payload, ok := resp.(model.ProxyDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.TCP == nil { + t.Fatalf("unexpected response payload: %#v", payload) + } + pluginType := payload.TCP.Plugin.Type + + if pluginType != "http2https" { + t.Fatalf("unexpected plugin type in response payload: %q", pluginType) + } +} + +func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) { + var gotPluginType string + controller := &Controller{ + manager: &fakeConfigManager{ + createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { + gotPluginType = cfg.GetBaseConfig().Plugin.Type + return cfg, nil + }, + }, + } + + body := []byte(`{ + "name":"plugin-visitor","type":"stcp", + "stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}} + }`) + req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body)) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.CreateStoreVisitor(ctx) + if err != nil { + t.Fatalf("create store visitor: %v", err) + } + if gotPluginType != "virtual_net" { + t.Fatalf("unexpected plugin type: %q", gotPluginType) + } + payload, ok := resp.(model.VisitorDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.STCP == nil { + t.Fatalf("unexpected response payload: %#v", payload) + } + pluginType := payload.STCP.Plugin.Type + + if pluginType != "virtual_net" { + t.Fatalf("unexpected plugin type in response payload: %q", pluginType) + } +} + +func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) { + controller := &Controller{manager: &fakeConfigManager{}} + body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`) + req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"name": "p1"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.UpdateStoreProxy(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusBadRequest) +} + +func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) { + controller := &Controller{manager: &fakeConfigManager{}} + body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`) + req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"name": "p1"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.UpdateStoreProxy(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusBadRequest) +} + +func TestListStoreProxiesReturnsSortedPayload(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) { + b := newRawTCPProxyConfig("b") + a := newRawTCPProxyConfig("a") + return []v1.ProxyConfigurer{b, a}, nil + }, + }, + } + ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil)) + + resp, err := controller.ListStoreProxies(ctx) + if err != nil { + t.Fatalf("list store proxies: %v", err) + } + out, ok := resp.(model.ProxyListResp) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if len(out.Proxies) != 2 { + t.Fatalf("unexpected proxy count: %d", len(out.Proxies)) + } + if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" { + t.Fatalf("proxies are not sorted by name: %#v", out.Proxies) } } @@ -388,3 +490,42 @@ func assertHTTPCode(t *testing.T, err error, expected int) { t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected) } } + +func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { + return cfg, nil + }, + }, + } + + body := map[string]any{ + "name": "shared-proxy", + "type": "tcp", + "tcp": map[string]any{ + "localPort": 10080, + "remotePort": 7000, + }, + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data)) + req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.UpdateStoreProxy(ctx) + if err != nil { + t.Fatalf("update store proxy: %v", err) + } + payload, ok := resp.(model.ProxyDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.TCP == nil || payload.TCP.RemotePort != 7000 { + t.Fatalf("unexpected response payload: %#v", payload) + } +} diff --git a/client/http/model/proxy_definition.go b/client/http/model/proxy_definition.go new file mode 100644 index 00000000..dae4b4b8 --- /dev/null +++ b/client/http/model/proxy_definition.go @@ -0,0 +1,148 @@ +package model + +import ( + "fmt" + "strings" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +type ProxyDefinition struct { + Name string `json:"name"` + Type string `json:"type"` + + TCP *v1.TCPProxyConfig `json:"tcp,omitempty"` + UDP *v1.UDPProxyConfig `json:"udp,omitempty"` + HTTP *v1.HTTPProxyConfig `json:"http,omitempty"` + HTTPS *v1.HTTPSProxyConfig `json:"https,omitempty"` + TCPMux *v1.TCPMuxProxyConfig `json:"tcpmux,omitempty"` + STCP *v1.STCPProxyConfig `json:"stcp,omitempty"` + SUDP *v1.SUDPProxyConfig `json:"sudp,omitempty"` + XTCP *v1.XTCPProxyConfig `json:"xtcp,omitempty"` +} + +func (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error { + if strings.TrimSpace(p.Name) == "" { + return fmt.Errorf("proxy name is required") + } + if !IsProxyType(p.Type) { + return fmt.Errorf("invalid proxy type: %s", p.Type) + } + if isUpdate && pathName != "" && pathName != p.Name { + return fmt.Errorf("proxy name in URL must match name in body") + } + + _, blockType, blockCount := p.activeBlock() + if blockCount != 1 { + return fmt.Errorf("exactly one proxy type block is required") + } + if blockType != p.Type { + return fmt.Errorf("proxy type block %q does not match type %q", blockType, p.Type) + } + return nil +} + +func (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) { + block, _, _ := p.activeBlock() + if block == nil { + return nil, fmt.Errorf("exactly one proxy type block is required") + } + + cfg := block + cfg.GetBaseConfig().Name = p.Name + cfg.GetBaseConfig().Type = p.Type + return cfg, nil +} + +func ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) { + if cfg == nil { + return ProxyDefinition{}, fmt.Errorf("proxy config is nil") + } + + base := cfg.GetBaseConfig() + payload := ProxyDefinition{ + Name: base.Name, + Type: base.Type, + } + + switch c := cfg.(type) { + case *v1.TCPProxyConfig: + payload.TCP = c + case *v1.UDPProxyConfig: + payload.UDP = c + case *v1.HTTPProxyConfig: + payload.HTTP = c + case *v1.HTTPSProxyConfig: + payload.HTTPS = c + case *v1.TCPMuxProxyConfig: + payload.TCPMux = c + case *v1.STCPProxyConfig: + payload.STCP = c + case *v1.SUDPProxyConfig: + payload.SUDP = c + case *v1.XTCPProxyConfig: + payload.XTCP = c + default: + return ProxyDefinition{}, fmt.Errorf("unsupported proxy configurer type %T", cfg) + } + + return payload, nil +} + +func (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) { + count := 0 + var block v1.ProxyConfigurer + var blockType string + + if p.TCP != nil { + count++ + block = p.TCP + blockType = "tcp" + } + if p.UDP != nil { + count++ + block = p.UDP + blockType = "udp" + } + if p.HTTP != nil { + count++ + block = p.HTTP + blockType = "http" + } + if p.HTTPS != nil { + count++ + block = p.HTTPS + blockType = "https" + } + if p.TCPMux != nil { + count++ + block = p.TCPMux + blockType = "tcpmux" + } + if p.STCP != nil { + count++ + block = p.STCP + blockType = "stcp" + } + if p.SUDP != nil { + count++ + block = p.SUDP + blockType = "sudp" + } + if p.XTCP != nil { + count++ + block = p.XTCP + blockType = "xtcp" + } + + return block, blockType, count +} + +func IsProxyType(typ string) bool { + switch typ { + case "tcp", "udp", "http", "https", "tcpmux", "stcp", "sudp", "xtcp": + return true + default: + return false + } +} diff --git a/client/api/types.go b/client/http/model/types.go similarity index 71% rename from client/api/types.go rename to client/http/model/types.go index 8f7bece2..63704850 100644 --- a/client/api/types.go +++ b/client/http/model/types.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package api +package model const SourceStore = "store" @@ -31,26 +31,12 @@ type ProxyStatusResp struct { Source string `json:"source,omitempty"` // "store" or "config" } -// ProxyConfig wraps proxy configuration for API requests/responses. -type ProxyConfig struct { - Name string `json:"name"` - Type string `json:"type"` - Config map[string]any `json:"config"` -} - -// VisitorConfig wraps visitor configuration for API requests/responses. -type VisitorConfig struct { - Name string `json:"name"` - Type string `json:"type"` - Config map[string]any `json:"config"` -} - // ProxyListResp is the response for GET /api/store/proxies type ProxyListResp struct { - Proxies []ProxyConfig `json:"proxies"` + Proxies []ProxyDefinition `json:"proxies"` } // VisitorListResp is the response for GET /api/store/visitors type VisitorListResp struct { - Visitors []VisitorConfig `json:"visitors"` + Visitors []VisitorDefinition `json:"visitors"` } diff --git a/client/http/model/visitor_definition.go b/client/http/model/visitor_definition.go new file mode 100644 index 00000000..a108982d --- /dev/null +++ b/client/http/model/visitor_definition.go @@ -0,0 +1,107 @@ +package model + +import ( + "fmt" + "strings" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +type VisitorDefinition struct { + Name string `json:"name"` + Type string `json:"type"` + + STCP *v1.STCPVisitorConfig `json:"stcp,omitempty"` + SUDP *v1.SUDPVisitorConfig `json:"sudp,omitempty"` + XTCP *v1.XTCPVisitorConfig `json:"xtcp,omitempty"` +} + +func (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error { + if strings.TrimSpace(p.Name) == "" { + return fmt.Errorf("visitor name is required") + } + if !IsVisitorType(p.Type) { + return fmt.Errorf("invalid visitor type: %s", p.Type) + } + if isUpdate && pathName != "" && pathName != p.Name { + return fmt.Errorf("visitor name in URL must match name in body") + } + + _, blockType, blockCount := p.activeBlock() + if blockCount != 1 { + return fmt.Errorf("exactly one visitor type block is required") + } + if blockType != p.Type { + return fmt.Errorf("visitor type block %q does not match type %q", blockType, p.Type) + } + return nil +} + +func (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) { + block, _, _ := p.activeBlock() + if block == nil { + return nil, fmt.Errorf("exactly one visitor type block is required") + } + + cfg := block + cfg.GetBaseConfig().Name = p.Name + cfg.GetBaseConfig().Type = p.Type + return cfg, nil +} + +func VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) { + if cfg == nil { + return VisitorDefinition{}, fmt.Errorf("visitor config is nil") + } + + base := cfg.GetBaseConfig() + payload := VisitorDefinition{ + Name: base.Name, + Type: base.Type, + } + + switch c := cfg.(type) { + case *v1.STCPVisitorConfig: + payload.STCP = c + case *v1.SUDPVisitorConfig: + payload.SUDP = c + case *v1.XTCPVisitorConfig: + payload.XTCP = c + default: + return VisitorDefinition{}, fmt.Errorf("unsupported visitor configurer type %T", cfg) + } + + return payload, nil +} + +func (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) { + count := 0 + var block v1.VisitorConfigurer + var blockType string + + if p.STCP != nil { + count++ + block = p.STCP + blockType = "stcp" + } + if p.SUDP != nil { + count++ + block = p.SUDP + blockType = "sudp" + } + if p.XTCP != nil { + count++ + block = p.XTCP + blockType = "xtcp" + } + return block, blockType, count +} + +func IsVisitorType(typ string) bool { + switch typ { + case "stcp", "sudp", "xtcp": + return true + default: + return false + } +} diff --git a/pkg/config/load.go b/pkg/config/load.go index a9f98552..159cd097 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -16,7 +16,6 @@ package config import ( "bytes" - "encoding/json" "fmt" "os" "path/filepath" @@ -33,6 +32,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) @@ -129,45 +129,54 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error { } // Convert to JSON and decode with strict validation - jsonBytes, err := json.Marshal(temp) + jsonBytes, err := jsonx.Marshal(temp) if err != nil { return err } - decoder := json.NewDecoder(bytes.NewReader(jsonBytes)) - decoder.DisallowUnknownFields() - return decoder.Decode(target) + return decodeJSONContent(jsonBytes, target, true) +} + +func decodeJSONContent(content []byte, target any, strict bool) error { + if clientCfg, ok := target.(*v1.ClientConfig); ok { + decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{ + DisallowUnknownFields: strict, + }) + if err != nil { + return err + } + *clientCfg = decoded + return nil + } + + return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{ + RejectUnknownMembers: strict, + }) } // LoadConfigure loads configuration from bytes and unmarshal into c. // Now it supports json, yaml and toml format. func LoadConfigure(b []byte, c any, strict bool) error { - return v1.WithDisallowUnknownFields(strict, func() error { - var tomlObj any - // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). - if err := toml.Unmarshal(b, &tomlObj); err == nil { - var err error - b, err = json.Marshal(&tomlObj) - if err != nil { - return err - } - } - // If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly. - if yaml.IsJSONBuffer(b) { - decoder := json.NewDecoder(bytes.NewBuffer(b)) - if strict { - decoder.DisallowUnknownFields() - } - return decoder.Decode(c) + var tomlObj any + // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). + if err := toml.Unmarshal(b, &tomlObj); err == nil { + var err error + b, err = jsonx.Marshal(&tomlObj) + if err != nil { + return err } + } + // If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly. + if yaml.IsJSONBuffer(b) { + return decodeJSONContent(b, c, strict) + } - // Handle YAML content - if strict { - // In strict mode, always use our custom handler to support YAML merge - return parseYAMLWithDotFieldsHandling(b, c) - } - // Non-strict mode, parse normally - return yaml.Unmarshal(b, c) - }) + // Handle YAML content + if strict { + // In strict mode, always use our custom handler to support YAML merge + return parseYAMLWithDotFieldsHandling(b, c) + } + // Non-strict mode, parse normally + return yaml.Unmarshal(b, c) } func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 2675f636..b23955b8 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -189,6 +189,31 @@ unixPath = "/tmp/uds.sock" require.Error(err) } +func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) { + require := require.New(t) + + content := ` +serverPort = 7000 + +[[proxies]] +name = "test" +type = "tcp" +localPort = 6000 +[proxies.plugin] +type = "http2https" +localAddr = "127.0.0.1:8080" +unknownInPlugin = "value" +` + + clientCfg := v1.ClientConfig{} + + err := LoadConfigure([]byte(content), &clientCfg, false) + require.NoError(err) + + err = LoadConfigure([]byte(content), &clientCfg, true) + require.ErrorContains(err, "unknownInPlugin") +} + // TestYAMLMergeInStrictMode tests that YAML merge functionality works // even in strict mode by properly handling dot-prefixed fields func TestYAMLMergeInStrictMode(t *testing.T) { diff --git a/pkg/config/source/store.go b/pkg/config/source/store.go index 22024136..d1bf4fb5 100644 --- a/pkg/config/source/store.go +++ b/pkg/config/source/store.go @@ -15,13 +15,13 @@ package source import ( - "encoding/json" "errors" "fmt" "os" "path/filepath" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/jsonx" ) type StoreSourceConfig struct { @@ -74,36 +74,44 @@ func (s *StoreSource) loadFromFileUnlocked() error { return err } - var stored storeData - if err := v1.WithDisallowUnknownFields(false, func() error { - return json.Unmarshal(data, &stored) - }); err != nil { + type rawStoreData struct { + Proxies []jsonx.RawMessage `json:"proxies,omitempty"` + Visitors []jsonx.RawMessage `json:"visitors,omitempty"` + } + stored := rawStoreData{} + if err := jsonx.Unmarshal(data, &stored); err != nil { return fmt.Errorf("failed to parse JSON: %w", err) } s.proxies = make(map[string]v1.ProxyConfigurer) s.visitors = make(map[string]v1.VisitorConfigurer) - for _, tp := range stored.Proxies { - if tp.ProxyConfigurer != nil { - proxyCfg := tp.ProxyConfigurer - name := proxyCfg.GetBaseConfig().Name - if name == "" { - return fmt.Errorf("proxy name cannot be empty") - } - s.proxies[name] = proxyCfg + for i, proxyData := range stored.Proxies { + proxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{ + DisallowUnknownFields: false, + }) + if err != nil { + return fmt.Errorf("failed to decode proxy at index %d: %w", i, err) } + name := proxyCfg.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("proxy name cannot be empty") + } + s.proxies[name] = proxyCfg } - for _, tv := range stored.Visitors { - if tv.VisitorConfigurer != nil { - visitorCfg := tv.VisitorConfigurer - name := visitorCfg.GetBaseConfig().Name - if name == "" { - return fmt.Errorf("visitor name cannot be empty") - } - s.visitors[name] = visitorCfg + for i, visitorData := range stored.Visitors { + visitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{ + DisallowUnknownFields: false, + }) + if err != nil { + return fmt.Errorf("failed to decode visitor at index %d: %w", i, err) } + name := visitorCfg.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("visitor name cannot be empty") + } + s.visitors[name] = visitorCfg } return nil @@ -122,7 +130,7 @@ func (s *StoreSource) saveToFileUnlocked() error { stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v}) } - data, err := json.MarshalIndent(stored, "", " ") + data, err := jsonx.MarshalIndent(stored, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } diff --git a/pkg/config/source/store_test.go b/pkg/config/source/store_test.go index 801115fa..bb5382b0 100644 --- a/pkg/config/source/store_test.go +++ b/pkg/config/source/store_test.go @@ -15,7 +15,6 @@ package source import ( - "encoding/json" "os" "path/filepath" "testing" @@ -23,27 +22,9 @@ import ( "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/jsonx" ) -func setDisallowUnknownFieldsForStoreTest(t *testing.T, value bool) func() { - t.Helper() - v1.DisallowUnknownFieldsMu.Lock() - prev := v1.DisallowUnknownFields - v1.DisallowUnknownFields = value - v1.DisallowUnknownFieldsMu.Unlock() - return func() { - v1.DisallowUnknownFieldsMu.Lock() - v1.DisallowUnknownFields = prev - v1.DisallowUnknownFieldsMu.Unlock() - } -} - -func getDisallowUnknownFieldsForStoreTest() bool { - v1.DisallowUnknownFieldsMu.Lock() - defer v1.DisallowUnknownFieldsMu.Unlock() - return v1.DisallowUnknownFields -} - func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) { require := require.New(t) @@ -99,7 +80,7 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) { Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}}, Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}}, } - data, err := json.Marshal(stored) + data, err := jsonx.Marshal(stored) require.NoError(err) err = os.WriteFile(path, data, 0o600) require.NoError(err) @@ -117,12 +98,9 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) { require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) } -func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) { +func TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) { require := require.New(t) - restore := setDisallowUnknownFieldsForStoreTest(t, true) - t.Cleanup(restore) - path := filepath.Join(t.TempDir(), "store.json") raw := []byte(`{ "proxies": [ @@ -140,5 +118,4 @@ func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t require.NotNil(storeSource.GetProxy("proxy1")) require.NotNil(storeSource.GetVisitor("visitor1")) - require.True(getDisallowUnknownFieldsForStoreTest()) } diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index df1867a2..41bd9a4c 100644 --- a/pkg/config/v1/common.go +++ b/pkg/config/v1/common.go @@ -16,35 +16,10 @@ package v1 import ( "maps" - "sync" "github.com/fatedier/frp/pkg/util/util" ) -// TODO(fatedier): Migrate typed config decoding to encoding/json/v2 when it is stable for production use. -// The current encoding/json(v1) path cannot propagate DisallowUnknownFields into custom UnmarshalJSON -// methods, so we temporarily keep this global strictness flag protected by a mutex. -// -// https://github.com/golang/go/issues/41144 -// https://github.com/golang/go/discussions/63397 -var ( - DisallowUnknownFields = false - DisallowUnknownFieldsMu sync.Mutex -) - -// WithDisallowUnknownFields temporarily overrides typed config JSON strictness. -// It restores the previous value before returning. -func WithDisallowUnknownFields(disallow bool, fn func() error) error { - DisallowUnknownFieldsMu.Lock() - prev := DisallowUnknownFields - DisallowUnknownFields = disallow - defer func() { - DisallowUnknownFields = prev - DisallowUnknownFieldsMu.Unlock() - }() - return fn() -} - type AuthScope string const ( diff --git a/pkg/config/v1/decode.go b/pkg/config/v1/decode.go new file mode 100644 index 00000000..427cea7f --- /dev/null +++ b/pkg/config/v1/decode.go @@ -0,0 +1,195 @@ +// Copyright 2026 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 v1 + +import ( + "errors" + "fmt" + "reflect" + + "github.com/fatedier/frp/pkg/util/jsonx" +) + +type DecodeOptions struct { + DisallowUnknownFields bool +} + +func decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error { + return jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{ + RejectUnknownMembers: options.DisallowUnknownFields, + }) +} + +func isJSONNull(b []byte) bool { + return len(b) == 0 || string(b) == "null" +} + +type typedEnvelope struct { + Type string `json:"type"` + Plugin jsonx.RawMessage `json:"plugin,omitempty"` +} + +func DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) { + if isJSONNull(b) { + return nil, errors.New("type is required") + } + + var env typedEnvelope + if err := jsonx.Unmarshal(b, &env); err != nil { + return nil, err + } + + configurer := NewProxyConfigurerByType(ProxyType(env.Type)) + if configurer == nil { + return nil, fmt.Errorf("unknown proxy type: %s", env.Type) + } + if err := decodeJSONWithOptions(b, configurer, options); err != nil { + return nil, fmt.Errorf("unmarshal ProxyConfig error: %v", err) + } + + if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) { + plugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options) + if err != nil { + return nil, fmt.Errorf("unmarshal proxy plugin error: %v", err) + } + configurer.GetBaseConfig().Plugin = plugin + } + return configurer, nil +} + +func DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) { + if isJSONNull(b) { + return nil, errors.New("type is required") + } + + var env typedEnvelope + if err := jsonx.Unmarshal(b, &env); err != nil { + return nil, err + } + + configurer := NewVisitorConfigurerByType(VisitorType(env.Type)) + if configurer == nil { + return nil, fmt.Errorf("unknown visitor type: %s", env.Type) + } + if err := decodeJSONWithOptions(b, configurer, options); err != nil { + return nil, fmt.Errorf("unmarshal VisitorConfig error: %v", err) + } + + if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) { + plugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options) + if err != nil { + return nil, fmt.Errorf("unmarshal visitor plugin error: %v", err) + } + configurer.GetBaseConfig().Plugin = plugin + } + return configurer, nil +} + +func DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) { + if isJSONNull(b) { + return TypedClientPluginOptions{}, nil + } + + var env typedEnvelope + if err := jsonx.Unmarshal(b, &env); err != nil { + return TypedClientPluginOptions{}, err + } + if env.Type == "" { + return TypedClientPluginOptions{}, errors.New("plugin type is empty") + } + + v, ok := clientPluginOptionsTypeMap[env.Type] + if !ok { + return TypedClientPluginOptions{}, fmt.Errorf("unknown plugin type: %s", env.Type) + } + optionsStruct := reflect.New(v).Interface().(ClientPluginOptions) + if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil { + return TypedClientPluginOptions{}, fmt.Errorf("unmarshal ClientPluginOptions error: %v", err) + } + return TypedClientPluginOptions{ + Type: env.Type, + ClientPluginOptions: optionsStruct, + }, nil +} + +func DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) { + if isJSONNull(b) { + return TypedVisitorPluginOptions{}, nil + } + + var env typedEnvelope + if err := jsonx.Unmarshal(b, &env); err != nil { + return TypedVisitorPluginOptions{}, err + } + if env.Type == "" { + return TypedVisitorPluginOptions{}, errors.New("visitor plugin type is empty") + } + + v, ok := visitorPluginOptionsTypeMap[env.Type] + if !ok { + return TypedVisitorPluginOptions{}, fmt.Errorf("unknown visitor plugin type: %s", env.Type) + } + optionsStruct := reflect.New(v).Interface().(VisitorPluginOptions) + if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil { + return TypedVisitorPluginOptions{}, fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err) + } + return TypedVisitorPluginOptions{ + Type: env.Type, + VisitorPluginOptions: optionsStruct, + }, nil +} + +func DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) { + type rawClientConfig struct { + ClientCommonConfig + Proxies []jsonx.RawMessage `json:"proxies,omitempty"` + Visitors []jsonx.RawMessage `json:"visitors,omitempty"` + } + + raw := rawClientConfig{} + if err := decodeJSONWithOptions(b, &raw, options); err != nil { + return ClientConfig{}, err + } + + cfg := ClientConfig{ + ClientCommonConfig: raw.ClientCommonConfig, + Proxies: make([]TypedProxyConfig, 0, len(raw.Proxies)), + Visitors: make([]TypedVisitorConfig, 0, len(raw.Visitors)), + } + + for i, proxyData := range raw.Proxies { + proxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options) + if err != nil { + return ClientConfig{}, fmt.Errorf("decode proxy at index %d: %w", i, err) + } + cfg.Proxies = append(cfg.Proxies, TypedProxyConfig{ + Type: proxyCfg.GetBaseConfig().Type, + ProxyConfigurer: proxyCfg, + }) + } + + for i, visitorData := range raw.Visitors { + visitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options) + if err != nil { + return ClientConfig{}, fmt.Errorf("decode visitor at index %d: %w", i, err) + } + cfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{ + Type: visitorCfg.GetBaseConfig().Type, + VisitorConfigurer: visitorCfg, + }) + } + + return cfg, nil +} diff --git a/pkg/config/v1/decode_test.go b/pkg/config/v1/decode_test.go new file mode 100644 index 00000000..cba433ad --- /dev/null +++ b/pkg/config/v1/decode_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 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 v1 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) { + require := require.New(t) + + data := []byte(`{ + "name":"p1", + "type":"tcp", + "localPort":10080, + "plugin":{ + "type":"http2https", + "localAddr":"127.0.0.1:8080", + "unknownInPlugin":"value" + } + }`) + + _, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false}) + require.NoError(err) + + _, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true}) + require.ErrorContains(err, "unknownInPlugin") +} + +func TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) { + require := require.New(t) + + data := []byte(`{ + "name":"v1", + "type":"stcp", + "serverName":"server", + "bindPort":10081, + "plugin":{ + "type":"virtual_net", + "destinationIP":"10.0.0.1", + "unknownInPlugin":"value" + } + }`) + + _, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false}) + require.NoError(err) + + _, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true}) + require.ErrorContains(err, "unknownInPlugin") +} + +func TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) { + require := require.New(t) + + data := []byte(`{ + "serverPort":7000, + "proxies":[ + { + "name":"p1", + "type":"tcp", + "localPort":10080, + "unknownField":"value" + } + ] + }`) + + _, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false}) + require.NoError(err) + + _, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true}) + require.ErrorContains(err, "unknownField") +} diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index fb868775..97bfab22 100644 --- a/pkg/config/v1/proxy.go +++ b/pkg/config/v1/proxy.go @@ -15,16 +15,13 @@ package v1 import ( - "bytes" - "encoding/json" - "errors" - "fmt" "maps" "reflect" "slices" "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) @@ -202,35 +199,18 @@ type TypedProxyConfig struct { } func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error { - if len(b) == 4 && string(b) == "null" { - return errors.New("type is required") - } - - typeStruct := struct { - Type string `json:"type"` - }{} - if err := json.Unmarshal(b, &typeStruct); err != nil { + configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{}) + if err != nil { return err } - c.Type = typeStruct.Type - configurer := NewProxyConfigurerByType(ProxyType(typeStruct.Type)) - if configurer == nil { - return fmt.Errorf("unknown proxy type: %s", typeStruct.Type) - } - decoder := json.NewDecoder(bytes.NewBuffer(b)) - if DisallowUnknownFields { - decoder.DisallowUnknownFields() - } - if err := decoder.Decode(configurer); err != nil { - return fmt.Errorf("unmarshal ProxyConfig error: %v", err) - } + c.Type = configurer.GetBaseConfig().Type c.ProxyConfigurer = configurer return nil } func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) { - return json.Marshal(c.ProxyConfigurer) + return jsonx.Marshal(c.ProxyConfigurer) } type ProxyConfigurer interface { diff --git a/pkg/config/v1/proxy_plugin.go b/pkg/config/v1/proxy_plugin.go index 908b30ff..003f825f 100644 --- a/pkg/config/v1/proxy_plugin.go +++ b/pkg/config/v1/proxy_plugin.go @@ -15,14 +15,11 @@ package v1 import ( - "bytes" - "encoding/json" - "errors" - "fmt" "reflect" "github.com/samber/lo" + "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) @@ -71,42 +68,16 @@ func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions { } func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error { - if len(b) == 4 && string(b) == "null" { - return nil - } - - typeStruct := struct { - Type string `json:"type"` - }{} - if err := json.Unmarshal(b, &typeStruct); err != nil { + decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{}) + if err != nil { return err } - - c.Type = typeStruct.Type - if c.Type == "" { - return errors.New("plugin type is empty") - } - - v, ok := clientPluginOptionsTypeMap[typeStruct.Type] - if !ok { - return fmt.Errorf("unknown plugin type: %s", typeStruct.Type) - } - options := reflect.New(v).Interface().(ClientPluginOptions) - - decoder := json.NewDecoder(bytes.NewBuffer(b)) - if DisallowUnknownFields { - decoder.DisallowUnknownFields() - } - - if err := decoder.Decode(options); err != nil { - return fmt.Errorf("unmarshal ClientPluginOptions error: %v", err) - } - c.ClientPluginOptions = options + *c = decoded return nil } func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) { - return json.Marshal(c.ClientPluginOptions) + return jsonx.Marshal(c.ClientPluginOptions) } type HTTP2HTTPSPluginOptions struct { diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index b0ff1e34..8f94f672 100644 --- a/pkg/config/v1/visitor.go +++ b/pkg/config/v1/visitor.go @@ -15,12 +15,9 @@ package v1 import ( - "bytes" - "encoding/json" - "errors" - "fmt" "reflect" + "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) @@ -93,35 +90,18 @@ type TypedVisitorConfig struct { } func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error { - if len(b) == 4 && string(b) == "null" { - return errors.New("type is required") - } - - typeStruct := struct { - Type string `json:"type"` - }{} - if err := json.Unmarshal(b, &typeStruct); err != nil { + configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{}) + if err != nil { return err } - c.Type = typeStruct.Type - configurer := NewVisitorConfigurerByType(VisitorType(typeStruct.Type)) - if configurer == nil { - return fmt.Errorf("unknown visitor type: %s", typeStruct.Type) - } - decoder := json.NewDecoder(bytes.NewBuffer(b)) - if DisallowUnknownFields { - decoder.DisallowUnknownFields() - } - if err := decoder.Decode(configurer); err != nil { - return fmt.Errorf("unmarshal VisitorConfig error: %v", err) - } + c.Type = configurer.GetBaseConfig().Type c.VisitorConfigurer = configurer return nil } func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) { - return json.Marshal(c.VisitorConfigurer) + return jsonx.Marshal(c.VisitorConfigurer) } func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer { diff --git a/pkg/config/v1/visitor_plugin.go b/pkg/config/v1/visitor_plugin.go index 9fa28a0b..2e2d0494 100644 --- a/pkg/config/v1/visitor_plugin.go +++ b/pkg/config/v1/visitor_plugin.go @@ -15,11 +15,9 @@ package v1 import ( - "bytes" - "encoding/json" - "errors" - "fmt" "reflect" + + "github.com/fatedier/frp/pkg/util/jsonx" ) const ( @@ -49,42 +47,16 @@ func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions { } func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error { - if len(b) == 4 && string(b) == "null" { - return nil - } - - typeStruct := struct { - Type string `json:"type"` - }{} - if err := json.Unmarshal(b, &typeStruct); err != nil { + decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{}) + if err != nil { return err } - - c.Type = typeStruct.Type - if c.Type == "" { - return errors.New("visitor plugin type is empty") - } - - v, ok := visitorPluginOptionsTypeMap[typeStruct.Type] - if !ok { - return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type) - } - options := reflect.New(v).Interface().(VisitorPluginOptions) - - decoder := json.NewDecoder(bytes.NewBuffer(b)) - if DisallowUnknownFields { - decoder.DisallowUnknownFields() - } - - if err := decoder.Decode(options); err != nil { - return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err) - } - c.VisitorPluginOptions = options + *c = decoded return nil } func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) { - return json.Marshal(c.VisitorPluginOptions) + return jsonx.Marshal(c.VisitorPluginOptions) } type VirtualNetVisitorPluginOptions struct { diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go index dfad996d..088d3f8f 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/api" + "github.com/fatedier/frp/client/http/model" 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) (*api.ProxyStatusResp, error) { +func (c *Client) GetProxyStatus(ctx context.Context, name string) (*model.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) (*api.ProxySta if err != nil { return nil, err } - allStatus := make(api.StatusResp) + allStatus := make(model.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) (*api.ProxySta return nil, fmt.Errorf("no proxy status found") } -func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) { +func (c *Client) GetAllProxyStatus(ctx context.Context) (model.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) (api.StatusResp, error) if err != nil { return nil, err } - allStatus := make(api.StatusResp) + allStatus := make(model.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/jsonx/json_v1.go b/pkg/util/jsonx/json_v1.go new file mode 100644 index 00000000..1fb98290 --- /dev/null +++ b/pkg/util/jsonx/json_v1.go @@ -0,0 +1,45 @@ +// Copyright 2026 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 jsonx + +import ( + "bytes" + "encoding/json" +) + +type DecodeOptions struct { + RejectUnknownMembers bool +} + +func Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +func Unmarshal(data []byte, out any) error { + return json.Unmarshal(data, out) +} + +func UnmarshalWithOptions(data []byte, out any, options DecodeOptions) error { + if !options.RejectUnknownMembers { + return json.Unmarshal(data, out) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(out) +} diff --git a/pkg/util/jsonx/raw_message.go b/pkg/util/jsonx/raw_message.go new file mode 100644 index 00000000..3eda4628 --- /dev/null +++ b/pkg/util/jsonx/raw_message.go @@ -0,0 +1,36 @@ +// Copyright 2026 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 jsonx + +import "fmt" + +// RawMessage stores a raw encoded JSON value. +// It is equivalent to encoding/json.RawMessage behavior. +type RawMessage []byte + +func (m RawMessage) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +func (m *RawMessage) UnmarshalJSON(data []byte) error { + if m == nil { + return fmt.Errorf("jsonx.RawMessage: UnmarshalJSON on nil pointer") + } + *m = append((*m)[:0], data...) + return nil +} diff --git a/server/api_router.go b/server/api_router.go new file mode 100644 index 00000000..bb9e44ed --- /dev/null +++ b/server/api_router.go @@ -0,0 +1,64 @@ +// 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 ( + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" + + httppkg "github.com/fatedier/frp/pkg/util/http" + netpkg "github.com/fatedier/frp/pkg/util/net" + adminapi "github.com/fatedier/frp/server/http" +) + +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 := adminapi.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/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).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) +} diff --git a/server/api/controller.go b/server/http/controller.go similarity index 71% rename from server/api/controller.go rename to server/http/controller.go index f691fcad..a1842788 100644 --- a/server/api/controller.go +++ b/server/http/controller.go @@ -12,11 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package api +package http import ( "cmp" - "encoding/json" "fmt" "net/http" "slices" @@ -29,6 +28,7 @@ import ( 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/http/model" "github.com/fatedier/frp/server/proxy" "github.com/fatedier/frp/server/registry" ) @@ -59,7 +59,7 @@ func NewController( // /api/serverinfo func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { serverStats := mem.StatsCollector.GetServer() - svrResp := ServerInfoResp{ + svrResp := model.ServerInfoResp{ Version: version.Full(), BindPort: c.serverCfg.BindPort, VhostHTTPPort: c.serverCfg.VhostHTTPPort, @@ -80,22 +80,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { 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 } @@ -112,7 +96,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) { statusFilter := strings.ToLower(ctx.Query("status")) records := c.clientRegistry.List() - items := make([]ClientInfoResp, 0, len(records)) + items := make([]model.ClientInfoResp, 0, len(records)) for _, info := range records { if userFilter != "" && info.User != userFilter { continue @@ -129,7 +113,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) { items = append(items, buildClientInfoResp(info)) } - slices.SortFunc(items, func(a, b ClientInfoResp) int { + slices.SortFunc(items, func(a, b model.ClientInfoResp) int { if v := cmp.Compare(a.User, b.User); v != 0 { return v } @@ -165,9 +149,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) { proxyType := ctx.Param("type") - proxyInfoResp := GetProxyInfoResp{} + proxyInfoResp := model.GetProxyInfoResp{} proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType) - slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int { + slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int { return cmp.Compare(a.Name, b.Name) }) @@ -191,7 +175,7 @@ func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) { func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") - trafficResp := GetProxyTrafficResp{} + trafficResp := model.GetProxyTrafficResp{} trafficResp.Name = name proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name) @@ -213,7 +197,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") } - proxyInfo := GetProxyStatsResp{ + proxyInfo := model.GetProxyStatsResp{ Name: ps.Name, User: ps.User, ClientID: ps.ClientID, @@ -225,16 +209,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) { } if pxy, ok := c.pxyManager.GetByName(name); ok { - content, err := json.Marshal(pxy.GetConfigurer()) - if err != nil { - log.Warnf("marshal proxy [%s] conf info error: %v", name, err) - return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error") - } - proxyInfo.Conf = getConfByType(ps.Type) - if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil { - log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err) - return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error") - } + proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" @@ -254,25 +229,16 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil } -func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) { +func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) { proxyStats := mem.StatsCollector.GetProxiesByType(proxyType) - proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats)) + proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats)) for _, ps := range proxyStats { - proxyInfo := &ProxyStatsInfo{ + proxyInfo := &model.ProxyStatsInfo{ User: ps.User, ClientID: ps.ClientID, } 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.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" @@ -288,7 +254,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS return } -func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) { +func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) { proxyInfo.Name = proxyName ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName) if ps == nil { @@ -298,20 +264,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri proxyInfo.User = ps.User proxyInfo.ClientID = ps.ClientID 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.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" @@ -327,8 +280,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri return } -func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { - resp := ClientInfoResp{ +func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp { + resp := model.ClientInfoResp{ Key: info.Key, User: info.User, ClientID: info.ClientID(), @@ -366,23 +319,37 @@ func matchStatusFilter(online bool, filter string) bool { } } -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 +func getConfFromConfigurer(cfg v1.ProxyConfigurer) any { + outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()} + + switch c := cfg.(type) { + case *v1.TCPProxyConfig: + return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort} + case *v1.UDPProxyConfig: + return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort} + case *v1.HTTPProxyConfig: + return &model.HTTPOutConf{ + BaseOutConf: outBase, + DomainConfig: c.DomainConfig, + Locations: c.Locations, + HostHeaderRewrite: c.HostHeaderRewrite, + } + case *v1.HTTPSProxyConfig: + return &model.HTTPSOutConf{ + BaseOutConf: outBase, + DomainConfig: c.DomainConfig, + } + case *v1.TCPMuxProxyConfig: + return &model.TCPMuxOutConf{ + BaseOutConf: outBase, + DomainConfig: c.DomainConfig, + Multiplexer: c.Multiplexer, + RouteByHTTPUser: c.RouteByHTTPUser, + } + case *v1.STCPProxyConfig: + return &model.STCPOutConf{BaseOutConf: outBase} + case *v1.XTCPProxyConfig: + return &model.XTCPOutConf{BaseOutConf: outBase} } + return outBase } diff --git a/server/http/controller_test.go b/server/http/controller_test.go new file mode 100644 index 00000000..3ef50776 --- /dev/null +++ b/server/http/controller_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 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" + "testing" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) { + cfg := &v1.TCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "test-proxy", + Type: string(v1.ProxyTypeTCP), + ProxyBackend: v1.ProxyBackend{ + Plugin: v1.TypedClientPluginOptions{ + Type: v1.PluginHTTPProxy, + ClientPluginOptions: &v1.HTTPProxyPluginOptions{ + Type: v1.PluginHTTPProxy, + HTTPUser: "user", + HTTPPassword: "password", + }, + }, + }, + }, + RemotePort: 6000, + } + + content, err := json.Marshal(getConfFromConfigurer(cfg)) + if err != nil { + t.Fatalf("marshal conf failed: %v", err) + } + + var out map[string]any + if err := json.Unmarshal(content, &out); err != nil { + t.Fatalf("unmarshal conf failed: %v", err) + } + + pluginValue, ok := out["plugin"] + if !ok { + t.Fatalf("plugin field missing in output: %v", out) + } + plugin, ok := pluginValue.(map[string]any) + if !ok { + t.Fatalf("plugin field should be object, got: %#v", pluginValue) + } + + if got := plugin["type"]; got != v1.PluginHTTPProxy { + t.Fatalf("plugin type mismatch, want %q got %#v", v1.PluginHTTPProxy, got) + } + if got := plugin["httpUser"]; got != "user" { + t.Fatalf("plugin httpUser mismatch, want %q got %#v", "user", got) + } + if got := plugin["httpPassword"]; got != "password" { + t.Fatalf("plugin httpPassword mismatch, want %q got %#v", "password", got) + } +} diff --git a/server/api/types.go b/server/http/model/types.go similarity index 99% rename from server/api/types.go rename to server/http/model/types.go index 0a72af1e..92467e4b 100644 --- a/server/api/types.go +++ b/server/http/model/types.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package api +package model import ( v1 "github.com/fatedier/frp/pkg/config/v1" diff --git a/server/service.go b/server/service.go index 62f9ac8e..b0db0327 100644 --- a/server/service.go +++ b/server/service.go @@ -28,7 +28,6 @@ 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" @@ -48,7 +47,6 @@ 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" @@ -690,42 +688,3 @@ 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/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).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) -} diff --git a/test/e2e/v1/features/store.go b/test/e2e/v1/features/store.go index 8479b9be..284c85f4 100644 --- a/test/e2e/v1/features/store.go +++ b/test/e2e/v1/features/store.go @@ -34,11 +34,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { time.Sleep(500 * time.Millisecond) proxyConfig := map[string]any{ - "name": "test-tcp", - "type": "tcp", - "localIP": "127.0.0.1", - "localPort": f.PortByName(framework.TCPEchoServerPort), - "remotePort": remotePort, + "name": "test-tcp", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + }, } proxyBody, _ := json.Marshal(proxyConfig) @@ -73,11 +75,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { time.Sleep(500 * time.Millisecond) proxyConfig := map[string]any{ - "name": "test-tcp", - "type": "tcp", - "localIP": "127.0.0.1", - "localPort": f.PortByName(framework.TCPEchoServerPort), - "remotePort": remotePort1, + "name": "test-tcp", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort1, + }, } proxyBody, _ := json.Marshal(proxyConfig) @@ -92,7 +96,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { time.Sleep(time.Second) framework.NewRequestExpect(f).Port(remotePort1).Ensure() - proxyConfig["remotePort"] = remotePort2 + proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2 proxyBody, _ = json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { @@ -125,11 +129,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { time.Sleep(500 * time.Millisecond) proxyConfig := map[string]any{ - "name": "test-tcp", - "type": "tcp", - "localIP": "127.0.0.1", - "localPort": f.PortByName(framework.TCPEchoServerPort), - "remotePort": remotePort, + "name": "test-tcp", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + }, } proxyBody, _ := json.Marshal(proxyConfig) @@ -171,11 +177,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { time.Sleep(500 * time.Millisecond) proxyConfig := map[string]any{ - "name": "test-tcp", - "type": "tcp", - "localIP": "127.0.0.1", - "localPort": f.PortByName(framework.TCPEchoServerPort), - "remotePort": remotePort, + "name": "test-tcp", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + }, } proxyBody, _ := json.Marshal(proxyConfig) @@ -226,5 +234,90 @@ var _ = ginkgo.Describe("[Feature: Store]", func() { return resp.Code == 404 }) }) + + ginkgo.It("rejects mismatched type block", func() { + adminPort := f.AllocPort() + + serverConf := consts.DefaultServerConfig + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + webServer.addr = "127.0.0.1" + webServer.port = %d + + [store] + path = "%s/store.json" + `, adminPort, f.TempDirectory) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + time.Sleep(500 * time.Millisecond) + + invalidBody, _ := json.Marshal(map[string]any{ + "name": "bad-proxy", + "type": "tcp", + "udp": map[string]any{ + "localPort": 1234, + }, + }) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ + "Content-Type": "application/json", + }).Body(invalidBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 400 + }) + }) + + ginkgo.It("rejects path/body name mismatch on update", func() { + adminPort := f.AllocPort() + remotePort := f.AllocPort() + + serverConf := consts.DefaultServerConfig + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + webServer.addr = "127.0.0.1" + webServer.port = %d + + [store] + path = "%s/store.json" + `, adminPort, f.TempDirectory) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + time.Sleep(500 * time.Millisecond) + + createBody, _ := json.Marshal(map[string]any{ + "name": "proxy-a", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + }, + }) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ + "Content-Type": "application/json", + }).Body(createBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + updateBody, _ := json.Marshal(map[string]any{ + "name": "proxy-b", + "type": "tcp", + "tcp": map[string]any{ + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + }, + }) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/proxy-a").HTTPParams("PUT", "", "/api/store/proxies/proxy-a", map[string]string{ + "Content-Type": "application/json", + }).Body(updateBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 400 + }) + }) }) }) diff --git a/web/frpc/src/api/frpc.ts b/web/frpc/src/api/frpc.ts index bacfdcdc..213c04fa 100644 --- a/web/frpc/src/api/frpc.ts +++ b/web/frpc/src/api/frpc.ts @@ -1,10 +1,10 @@ import { http } from './http' import type { StatusResponse, - StoreProxyListResp, - StoreProxyConfig, - StoreVisitorListResp, - StoreVisitorConfig, + ProxyListResp, + ProxyDefinition, + VisitorListResp, + VisitorDefinition, } from '../types/proxy' export const getStatus = () => { @@ -25,21 +25,21 @@ export const reloadConfig = () => { // Store API - Proxies export const listStoreProxies = () => { - return http.get('/api/store/proxies') + return http.get('/api/store/proxies') } export const getStoreProxy = (name: string) => { - return http.get( + return http.get( `/api/store/proxies/${encodeURIComponent(name)}`, ) } -export const createStoreProxy = (config: Record) => { - return http.post('/api/store/proxies', config) +export const createStoreProxy = (config: ProxyDefinition) => { + return http.post('/api/store/proxies', config) } -export const updateStoreProxy = (name: string, config: Record) => { - return http.put( +export const updateStoreProxy = (name: string, config: ProxyDefinition) => { + return http.put( `/api/store/proxies/${encodeURIComponent(name)}`, config, ) @@ -51,24 +51,24 @@ export const deleteStoreProxy = (name: string) => { // Store API - Visitors export const listStoreVisitors = () => { - return http.get('/api/store/visitors') + return http.get('/api/store/visitors') } export const getStoreVisitor = (name: string) => { - return http.get( + return http.get( `/api/store/visitors/${encodeURIComponent(name)}`, ) } -export const createStoreVisitor = (config: Record) => { - return http.post('/api/store/visitors', config) +export const createStoreVisitor = (config: VisitorDefinition) => { + return http.post('/api/store/visitors', config) } export const updateStoreVisitor = ( name: string, - config: Record, + config: VisitorDefinition, ) => { - return http.put( + return http.put( `/api/store/visitors/${encodeURIComponent(name)}`, config, ) diff --git a/web/frpc/src/types/proxy.ts b/web/frpc/src/types/proxy.ts index b4c37eee..a4b9b88a 100644 --- a/web/frpc/src/types/proxy.ts +++ b/web/frpc/src/types/proxy.ts @@ -20,24 +20,33 @@ export type StatusResponse = Record // STORE API TYPES // ======================================== -export interface StoreProxyConfig { +export interface ProxyDefinition { name: string - type: string - config: Record + type: ProxyType + tcp?: Record + udp?: Record + http?: Record + https?: Record + tcpmux?: Record + stcp?: Record + sudp?: Record + xtcp?: Record } -export interface StoreVisitorConfig { +export interface VisitorDefinition { name: string - type: string - config: Record + type: VisitorType + stcp?: Record + sudp?: Record + xtcp?: Record } -export interface StoreProxyListResp { - proxies: StoreProxyConfig[] +export interface ProxyListResp { + proxies: ProxyDefinition[] } -export interface StoreVisitorListResp { - visitors: StoreVisitorConfig[] +export interface VisitorListResp { + visitors: VisitorDefinition[] } // ======================================== @@ -255,29 +264,24 @@ export function createDefaultVisitorForm(): VisitorFormData { // CONVERTERS: Form -> Store API // ======================================== -export function formToStoreProxy(form: ProxyFormData): Record { - const config: Record = { - name: form.name, - type: form.type, - } +export function formToStoreProxy(form: ProxyFormData): ProxyDefinition { + const block: Record = {} // Enabled (nil/true = enabled, false = disabled) if (!form.enabled) { - config.enabled = false + block.enabled = false } // Backend - LocalIP/LocalPort if (form.pluginType === '') { - // No plugin, use local backend if (form.localIP && form.localIP !== '127.0.0.1') { - config.localIP = form.localIP + block.localIP = form.localIP } if (form.localPort != null) { - config.localPort = form.localPort + block.localPort = form.localPort } } else { - // Plugin backend - config.plugin = { + block.plugin = { type: form.pluginType, ...form.pluginConfig, } @@ -291,109 +295,102 @@ export function formToStoreProxy(form: ProxyFormData): Record { (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || form.proxyProtocolVersion ) { - config.transport = {} - if (form.useEncryption) config.transport.useEncryption = true - if (form.useCompression) config.transport.useCompression = true - if (form.bandwidthLimit) - config.transport.bandwidthLimit = form.bandwidthLimit + block.transport = {} + if (form.useEncryption) block.transport.useEncryption = true + if (form.useCompression) block.transport.useCompression = true + if (form.bandwidthLimit) block.transport.bandwidthLimit = form.bandwidthLimit if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') { - config.transport.bandwidthLimitMode = form.bandwidthLimitMode + block.transport.bandwidthLimitMode = form.bandwidthLimitMode } if (form.proxyProtocolVersion) { - config.transport.proxyProtocolVersion = form.proxyProtocolVersion + block.transport.proxyProtocolVersion = form.proxyProtocolVersion } } // Load Balancer if (form.loadBalancerGroup) { - config.loadBalancer = { + block.loadBalancer = { group: form.loadBalancerGroup, } if (form.loadBalancerGroupKey) { - config.loadBalancer.groupKey = form.loadBalancerGroupKey + block.loadBalancer.groupKey = form.loadBalancerGroupKey } } // Health Check if (form.healthCheckType) { - config.healthCheck = { + block.healthCheck = { type: form.healthCheckType, } if (form.healthCheckTimeoutSeconds != null) { - config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds + block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds } if (form.healthCheckMaxFailed != null) { - config.healthCheck.maxFailed = form.healthCheckMaxFailed + block.healthCheck.maxFailed = form.healthCheckMaxFailed } if (form.healthCheckIntervalSeconds != null) { - config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds + block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds } if (form.healthCheckPath) { - config.healthCheck.path = form.healthCheckPath + block.healthCheck.path = form.healthCheckPath } if (form.healthCheckHTTPHeaders.length > 0) { - config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders + block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders } } // Metadata if (form.metadatas.length > 0) { - config.metadatas = Object.fromEntries( + block.metadatas = Object.fromEntries( form.metadatas.map((m) => [m.key, m.value]), ) } // Annotations if (form.annotations.length > 0) { - config.annotations = Object.fromEntries( + block.annotations = Object.fromEntries( form.annotations.map((a) => [a.key, a.value]), ) } // Type-specific fields - if (form.type === 'tcp' || form.type === 'udp') { - if (form.remotePort != null) { - config.remotePort = form.remotePort - } + if ((form.type === 'tcp' || form.type === 'udp') && form.remotePort != null) { + block.remotePort = form.remotePort } if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') { - // Domain config if (form.customDomains) { - config.customDomains = form.customDomains + block.customDomains = form.customDomains .split(',') .map((s) => s.trim()) .filter(Boolean) } if (form.subdomain) { - config.subdomain = form.subdomain + block.subdomain = form.subdomain } } if (form.type === 'http') { - // HTTP specific if (form.locations) { - config.locations = form.locations + block.locations = form.locations .split(',') .map((s) => s.trim()) .filter(Boolean) } - if (form.httpUser) config.httpUser = form.httpUser - if (form.httpPassword) config.httpPassword = form.httpPassword - if (form.hostHeaderRewrite) - config.hostHeaderRewrite = form.hostHeaderRewrite - if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser + if (form.httpUser) block.httpUser = form.httpUser + if (form.httpPassword) block.httpPassword = form.httpPassword + if (form.hostHeaderRewrite) block.hostHeaderRewrite = form.hostHeaderRewrite + if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser - // Header operations if (form.requestHeaders.length > 0) { - config.requestHeaders = { + block.requestHeaders = { set: Object.fromEntries( form.requestHeaders.map((h) => [h.key, h.value]), ), } } if (form.responseHeaders.length > 0) { - config.responseHeaders = { + block.responseHeaders = { set: Object.fromEntries( form.responseHeaders.map((h) => [h.key, h.value]), ), @@ -402,107 +399,194 @@ export function formToStoreProxy(form: ProxyFormData): Record { } if (form.type === 'tcpmux') { - // TCPMux specific - if (form.httpUser) config.httpUser = form.httpUser - if (form.httpPassword) config.httpPassword = form.httpPassword - if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser + if (form.httpUser) block.httpUser = form.httpUser + if (form.httpPassword) block.httpPassword = form.httpPassword + if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser if (form.multiplexer && form.multiplexer !== 'httpconnect') { - config.multiplexer = form.multiplexer + block.multiplexer = form.multiplexer } } if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') { - // Secure proxy types - if (form.secretKey) config.secretKey = form.secretKey + if (form.secretKey) block.secretKey = form.secretKey if (form.allowUsers) { - config.allowUsers = form.allowUsers + block.allowUsers = form.allowUsers .split(',') .map((s) => s.trim()) .filter(Boolean) } } - if (form.type === 'xtcp') { - // XTCP NAT traversal - if (form.natTraversalDisableAssistedAddrs) { - config.natTraversal = { - disableAssistedAddrs: true, - } + if (form.type === 'xtcp' && form.natTraversalDisableAssistedAddrs) { + block.natTraversal = { + disableAssistedAddrs: true, } } - return config + return withStoreProxyBlock( + { + name: form.name, + type: form.type, + }, + form.type, + block, + ) } -export function formToStoreVisitor(form: VisitorFormData): Record { - const config: Record = { - name: form.name, - type: form.type, - } +export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition { + const block: Record = {} - // Enabled if (!form.enabled) { - config.enabled = false + block.enabled = false } - // Transport if (form.useEncryption || form.useCompression) { - config.transport = {} - if (form.useEncryption) config.transport.useEncryption = true - if (form.useCompression) config.transport.useCompression = true + block.transport = {} + if (form.useEncryption) block.transport.useEncryption = true + if (form.useCompression) block.transport.useCompression = true } - // Base fields - if (form.secretKey) config.secretKey = form.secretKey - if (form.serverUser) config.serverUser = form.serverUser - if (form.serverName) config.serverName = form.serverName + if (form.secretKey) block.secretKey = form.secretKey + if (form.serverUser) block.serverUser = form.serverUser + if (form.serverName) block.serverName = form.serverName if (form.bindAddr && form.bindAddr !== '127.0.0.1') { - config.bindAddr = form.bindAddr + block.bindAddr = form.bindAddr } if (form.bindPort != null) { - config.bindPort = form.bindPort + block.bindPort = form.bindPort } - // XTCP specific if (form.type === 'xtcp') { if (form.protocol && form.protocol !== 'quic') { - config.protocol = form.protocol + block.protocol = form.protocol } if (form.keepTunnelOpen) { - config.keepTunnelOpen = true + block.keepTunnelOpen = true } if (form.maxRetriesAnHour != null) { - config.maxRetriesAnHour = form.maxRetriesAnHour + block.maxRetriesAnHour = form.maxRetriesAnHour } if (form.minRetryInterval != null) { - config.minRetryInterval = form.minRetryInterval + block.minRetryInterval = form.minRetryInterval } if (form.fallbackTo) { - config.fallbackTo = form.fallbackTo + block.fallbackTo = form.fallbackTo } if (form.fallbackTimeoutMs != null) { - config.fallbackTimeoutMs = form.fallbackTimeoutMs + block.fallbackTimeoutMs = form.fallbackTimeoutMs } if (form.natTraversalDisableAssistedAddrs) { - config.natTraversal = { + block.natTraversal = { disableAssistedAddrs: true, } } } - return config + return withStoreVisitorBlock( + { + name: form.name, + type: form.type, + }, + form.type, + block, + ) } // ======================================== // CONVERTERS: Store API -> Form // ======================================== -export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData { - const c = config.config || {} +function getStoreProxyBlock(config: ProxyDefinition): Record { + switch (config.type) { + case 'tcp': + return config.tcp || {} + case 'udp': + return config.udp || {} + case 'http': + return config.http || {} + case 'https': + return config.https || {} + case 'tcpmux': + return config.tcpmux || {} + case 'stcp': + return config.stcp || {} + case 'sudp': + return config.sudp || {} + case 'xtcp': + return config.xtcp || {} + } +} + +function withStoreProxyBlock( + payload: ProxyDefinition, + type: ProxyType, + block: Record, +): ProxyDefinition { + switch (type) { + case 'tcp': + payload.tcp = block + break + case 'udp': + payload.udp = block + break + case 'http': + payload.http = block + break + case 'https': + payload.https = block + break + case 'tcpmux': + payload.tcpmux = block + break + case 'stcp': + payload.stcp = block + break + case 'sudp': + payload.sudp = block + break + case 'xtcp': + payload.xtcp = block + break + } + return payload +} + +function getStoreVisitorBlock(config: VisitorDefinition): Record { + switch (config.type) { + case 'stcp': + return config.stcp || {} + case 'sudp': + return config.sudp || {} + case 'xtcp': + return config.xtcp || {} + } +} + +function withStoreVisitorBlock( + payload: VisitorDefinition, + type: VisitorType, + block: Record, +): VisitorDefinition { + switch (type) { + case 'stcp': + payload.stcp = block + break + case 'sudp': + payload.sudp = block + break + case 'xtcp': + payload.xtcp = block + break + } + return payload +} + +export function storeProxyToForm(config: ProxyDefinition): ProxyFormData { + const c = getStoreProxyBlock(config) const form = createDefaultProxyForm() form.name = config.name || '' - form.type = (config.type as ProxyType) || 'tcp' + form.type = config.type || 'tcp' form.enabled = c.enabled !== false // Backend @@ -608,13 +692,13 @@ export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData { } export function storeVisitorToForm( - config: StoreVisitorConfig, + config: VisitorDefinition, ): VisitorFormData { - const c = config.config || {} + const c = getStoreVisitorBlock(config) const form = createDefaultVisitorForm() form.name = config.name || '' - form.type = (config.type as VisitorType) || 'stcp' + form.type = config.type || 'stcp' form.enabled = c.enabled !== false // Transport diff --git a/web/frpc/src/views/Overview.vue b/web/frpc/src/views/Overview.vue index 6018403f..c8dfcdba 100644 --- a/web/frpc/src/views/Overview.vue +++ b/web/frpc/src/views/Overview.vue @@ -337,17 +337,18 @@
- - Server: {{ visitor.config.serverName }} + + Server: {{ getVisitorBlock(visitor)?.serverName }} - Bind: {{ visitor.config.bindAddr || '127.0.0.1' - }}:{{ getVisitorBlock(visitor)?.bindPort }}
@@ -388,8 +389,8 @@ import { } from '../api/frpc' import type { ProxyStatus, - StoreProxyConfig, - StoreVisitorConfig, + ProxyDefinition, + VisitorDefinition, } from '../types/proxy' import StatCard from '../components/StatCard.vue' import ProxyCard from '../components/ProxyCard.vue' @@ -398,8 +399,8 @@ const router = useRouter() // State const status = ref([]) -const storeProxies = ref([]) -const storeVisitors = ref([]) +const storeProxies = ref([]) +const storeVisitors = ref([]) const storeEnabled = ref(false) const loading = ref(false) const searchText = ref('') @@ -463,9 +464,41 @@ const filteredStatus = computed(() => { }) const disabledStoreProxies = computed(() => { - return storeProxies.value.filter((p) => p.config?.enabled === false) + return storeProxies.value.filter((p) => getProxyBlock(p)?.enabled === false) }) +const getProxyBlock = (proxy: ProxyDefinition) => { + switch (proxy.type) { + case 'tcp': + return proxy.tcp + case 'udp': + return proxy.udp + case 'http': + return proxy.http + case 'https': + return proxy.https + case 'tcpmux': + return proxy.tcpmux + case 'stcp': + return proxy.stcp + case 'sudp': + return proxy.sudp + case 'xtcp': + return proxy.xtcp + } +} + +const getVisitorBlock = (visitor: VisitorDefinition) => { + switch (visitor.type) { + case 'stcp': + return visitor.stcp + case 'sudp': + return visitor.sudp + case 'xtcp': + return visitor.xtcp + } +} + // Methods const toggleTypeFilter = (type: string) => { filterType.value = filterType.value === type ? '' : type @@ -563,11 +596,11 @@ const handleDelete = async (proxy: ProxyStatus) => { confirmAndDeleteProxy(proxy.name) } -const handleEditStoreProxy = (proxy: StoreProxyConfig) => { +const handleEditStoreProxy = (proxy: ProxyDefinition) => { router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit') } -const handleDeleteStoreProxy = async (proxy: StoreProxyConfig) => { +const handleDeleteStoreProxy = async (proxy: ProxyDefinition) => { confirmAndDeleteProxy(proxy.name) } @@ -575,7 +608,7 @@ const handleCreateVisitor = () => { router.push('/visitors/create') } -const handleEditVisitor = (visitor: StoreVisitorConfig) => { +const handleEditVisitor = (visitor: VisitorDefinition) => { router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit') }