refactor: restructure API packages into client/http and server/http with typed proxy/visitor models (#5193)

This commit is contained in:
fatedier
2026-03-04 17:38:43 +08:00
committed by GitHub
parent 381245a439
commit fbeb6ca43a
32 changed files with 1704 additions and 727 deletions

View File

@@ -17,7 +17,7 @@ package client
import ( import (
"net/http" "net/http"
"github.com/fatedier/frp/client/api" adminapi "github.com/fatedier/frp/client/http"
"github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net" netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -65,9 +65,9 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func newAPIController(svr *Service) *api.Controller { func newAPIController(svr *Service) *adminapi.Controller {
manager := newServiceConfigManager(svr) manager := newServiceConfigManager(svr)
return api.NewController(api.ControllerParams{ return adminapi.NewController(adminapi.ControllerParams{
ServerAddr: svr.common.ServerAddr, ServerAddr: svr.common.ServerAddr,
Manager: manager, Manager: manager,
}) })

View File

@@ -133,12 +133,13 @@ func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, e
return cfg, nil 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 { 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 err := storeSource.AddProxy(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) { if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
@@ -146,30 +147,30 @@ func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error {
return err return err
} }
return nil return nil
}); err != nil { })
return err if err != nil {
return nil, err
} }
log.Infof("store: created proxy %q", name)
log.Infof("store: created proxy %q", cfg.GetBaseConfig().Name) return persisted, nil
return 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 == "" { 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 { 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 bodyName := cfg.GetBaseConfig().Name
if bodyName != 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 { 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 err := storeSource.UpdateProxy(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) { if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
@@ -177,12 +178,13 @@ func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigu
return err return err
} }
return nil return nil
}); err != nil { })
return err if err != nil {
return nil, err
} }
log.Infof("store: updated proxy %q", name) log.Infof("store: updated proxy %q", name)
return nil return persisted, nil
} }
func (m *serviceConfigManager) DeleteStoreProxy(name string) error { func (m *serviceConfigManager) DeleteStoreProxy(name string) error {
@@ -231,12 +233,13 @@ func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigure
return cfg, nil 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 { 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 err := storeSource.AddVisitor(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) { if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
@@ -244,30 +247,31 @@ func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) erro
return err return err
} }
return nil return nil
}); err != nil { })
return err if err != nil {
return nil, err
} }
log.Infof("store: created visitor %q", cfg.GetBaseConfig().Name) log.Infof("store: created visitor %q", name)
return nil 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 == "" { 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 { 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 bodyName := cfg.GetBaseConfig().Name
if bodyName != 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 { 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 err := storeSource.UpdateVisitor(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) { if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
@@ -275,12 +279,13 @@ func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorCon
return err return err
} }
return nil return nil
}); err != nil { })
return err if err != nil {
return nil, err
} }
log.Infof("store: updated visitor %q", name) log.Infof("store: updated visitor %q", name)
return nil return persisted, nil
} }
func (m *serviceConfigManager) DeleteStoreVisitor(name string) error { func (m *serviceConfigManager) DeleteStoreVisitor(name string) error {
@@ -340,6 +345,58 @@ func (m *serviceConfigManager) withStoreMutationAndReload(
return nil 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 { func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {
if cfg == nil { if cfg == nil {
return fmt.Errorf("invalid proxy config") return fmt.Errorf("invalid proxy config")

View File

@@ -45,7 +45,7 @@ func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
}, },
} }
err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil { if err == nil {
t.Fatal("expected conflict error") 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 { if err == nil {
t.Fatal("expected apply config error") 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 { if err == nil {
t.Fatal("expected store disabled error") 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 { if err != nil {
t.Fatalf("create store proxy: %v", err) t.Fatalf("create store proxy: %v", err)
} }
if persisted == nil {
t.Fatal("expected persisted proxy to be returned")
}
got := storeSource.GetProxy("raw-proxy") got := storeSource.GetProxy("raw-proxy")
if got == nil { if got == nil {

View File

@@ -28,14 +28,14 @@ type ConfigManager interface {
ListStoreProxies() ([]v1.ProxyConfigurer, error) ListStoreProxies() ([]v1.ProxyConfigurer, error)
GetStoreProxy(name string) (v1.ProxyConfigurer, error) GetStoreProxy(name string) (v1.ProxyConfigurer, error)
CreateStoreProxy(cfg v1.ProxyConfigurer) error CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
DeleteStoreProxy(name string) error DeleteStoreProxy(name string) error
ListStoreVisitors() ([]v1.VisitorConfigurer, error) ListStoreVisitors() ([]v1.VisitorConfigurer, error)
GetStoreVisitor(name string) (v1.VisitorConfigurer, error) GetStoreVisitor(name string) (v1.VisitorConfigurer, error)
CreateStoreVisitor(cfg v1.VisitorConfigurer) error CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
DeleteStoreVisitor(name string) error DeleteStoreVisitor(name string) error
GracefulClose(d time.Duration) GracefulClose(d time.Duration)

View File

@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package api package http
import ( import (
"cmp" "cmp"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -26,9 +25,10 @@ import (
"time" "time"
"github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/jsonx"
) )
// Controller handles HTTP API requests for frpc. // Controller handles HTTP API requests for frpc.
@@ -67,15 +67,6 @@ func (c *Controller) toHTTPError(err error) error {
return httppkg.NewError(code, err.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 // Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) { func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false strictConfigMode := false
@@ -98,7 +89,7 @@ func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
// Status handles GET /api/status // Status handles GET /api/status
func (c *Controller) Status(ctx *httppkg.Context) (any, error) { func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
res := make(StatusResp) res := make(model.StatusResp)
ps := c.manager.GetProxyStatus() ps := c.manager.GetProxyStatus()
if ps == nil { if ps == nil {
return res, nil return res, nil
@@ -112,7 +103,7 @@ func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
if len(arrs) <= 1 { if len(arrs) <= 1 {
continue 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) return cmp.Compare(a.Name, b.Name)
}) })
} }
@@ -145,8 +136,8 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
return nil, nil return nil, nil
} }
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp { func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {
psr := ProxyStatusResp{ psr := model.ProxyStatusResp{
Name: status.Name, Name: status.Name,
Type: status.Type, Type: status.Type,
Status: status.Phase, Status: status.Phase,
@@ -166,7 +157,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat
} }
if c.manager.IsStoreProxyEnabled(status.Name) { if c.manager.IsStoreProxyEnabled(status.Name) {
psr.Source = SourceStore psr.Source = model.SourceStore
} }
return psr return psr
} }
@@ -177,18 +168,17 @@ func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err) 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 { for _, p := range proxies {
cfg, err := configurerToMap(p) payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil { if err != nil {
continue return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
} }
resp.Proxies = append(resp.Proxies, ProxyConfig{ resp.Proxies = append(resp.Proxies, payload)
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
})
} }
slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil return resp, nil
} }
@@ -203,16 +193,12 @@ func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err) return nil, c.toHTTPError(err)
} }
cfg, err := configurerToMap(p) payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil { if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
} }
return ProxyConfig{ return payload, nil
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
}, nil
} }
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) { 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)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
} }
var typed v1.TypedProxyConfig var payload model.ProxyDefinition
if err := unmarshalTypedConfig(body, &typed); err != nil { if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
} }
if typed.ProxyConfigurer == nil { if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required") return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
} }
cfg, err := payload.ToConfigurer()
if err := c.manager.CreateStoreProxy(typed.ProxyConfigurer); err != nil { 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, 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) { 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)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
} }
var typed v1.TypedProxyConfig var payload model.ProxyDefinition
if err := unmarshalTypedConfig(body, &typed); err != nil { if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
} }
if typed.ProxyConfigurer == nil { if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required") return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
} }
cfg, err := payload.ToConfigurer()
if err := c.manager.UpdateStoreProxy(name, typed.ProxyConfigurer); err != nil { 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, 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) { 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) 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 { for _, v := range visitors {
cfg, err := configurerToMap(v) payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil { if err != nil {
continue return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
} }
resp.Visitors = append(resp.Visitors, VisitorConfig{ resp.Visitors = append(resp.Visitors, payload)
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
})
} }
slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil return resp, nil
} }
@@ -306,16 +309,12 @@ func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err) return nil, c.toHTTPError(err)
} }
cfg, err := configurerToMap(v) payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil { if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
} }
return VisitorConfig{ return payload, nil
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
}, nil
} }
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) { 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)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
} }
var typed v1.TypedVisitorConfig var payload model.VisitorDefinition
if err := unmarshalTypedConfig(body, &typed); err != nil { if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
} }
if typed.VisitorConfigurer == nil { if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required") return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
} }
cfg, err := payload.ToConfigurer()
if err := c.manager.CreateStoreVisitor(typed.VisitorConfigurer); err != nil { 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, 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) { 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)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
} }
var typed v1.TypedVisitorConfig var payload model.VisitorDefinition
if err := unmarshalTypedConfig(body, &typed); err != nil { if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
} }
if typed.VisitorConfigurer == nil { if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required") return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
} }
cfg, err := payload.ToConfigurer()
if err := c.manager.UpdateStoreVisitor(name, typed.VisitorConfigurer); err != nil { 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, 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) { 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 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
}

View File

@@ -1,4 +1,4 @@
package api package http
import ( import (
"bytes" "bytes"
@@ -13,6 +13,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -28,13 +29,13 @@ type fakeConfigManager struct {
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error) listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error) getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
createStoreProxyFn func(cfg v1.ProxyConfigurer) error createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) error updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
deleteStoreProxyFn func(name string) error deleteStoreProxyFn func(name string) error
listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error) listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error) getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
createStoreVisitFn func(cfg v1.VisitorConfigurer) error createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) error updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
deleteStoreVisitFn func(name string) error deleteStoreVisitFn func(name string) error
gracefulCloseFn func(d time.Duration) gracefulCloseFn func(d time.Duration)
} }
@@ -95,18 +96,18 @@ func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, erro
return nil, nil 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 { if m.createStoreProxyFn != nil {
return m.createStoreProxyFn(cfg) 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 { if m.updateStoreProxyFn != nil {
return m.updateStoreProxyFn(name, cfg) return m.updateStoreProxyFn(name, cfg)
} }
return nil return cfg, nil
} }
func (m *fakeConfigManager) DeleteStoreProxy(name string) error { func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
@@ -130,18 +131,18 @@ func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer,
return nil, nil 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 { if m.createStoreVisitFn != nil {
return m.createStoreVisitFn(cfg) 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 { if m.updateStoreVisitFn != nil {
return m.updateStoreVisitFn(name, cfg) return m.updateStoreVisitFn(name, cfg)
} }
return nil return cfg, nil
} }
func (m *fakeConfigManager) DeleteStoreVisitor(name string) error { 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 { func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
return &v1.TCPProxyConfig{ return &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{ 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) { func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
status := &proxy.WorkingStatus{ status := &proxy.WorkingStatus{
Name: "shared-proxy", Name: "shared-proxy",
@@ -265,22 +235,20 @@ func TestStoreProxyErrorMapping(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(newRawTCPProxyConfig("shared-proxy")) body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"}) req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req) ctx := httppkg.NewContext(httptest.NewRecorder(), req)
controller := &Controller{ controller := &Controller{
manager: &fakeConfigManager{ 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 { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@@ -290,11 +258,7 @@ func TestStoreProxyErrorMapping(t *testing.T) {
} }
func TestStoreVisitorErrorMapping(t *testing.T) { func TestStoreVisitorErrorMapping(t *testing.T) {
body, err := json.Marshal(newRawXTCPVisitorConfig("shared-visitor")) body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"}) req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req) 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 { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
assertHTTPCode(t, err, http.StatusNotFound) assertHTTPCode(t, err, http.StatusNotFound)
} }
func TestCreateStoreProxy_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) { func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
var gotName string var gotName string
controller := &Controller{ controller := &Controller{
manager: &fakeConfigManager{ manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) error { createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
gotName = cfg.GetBaseConfig().Name 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)) req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req) ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.CreateStoreProxy(ctx) resp, err := controller.CreateStoreProxy(ctx)
if err != nil { if err != nil {
t.Fatalf("create store proxy: %v", err) t.Fatalf("create store proxy: %v", err)
} }
if gotName != "raw-proxy" { if gotName != "raw-proxy" {
t.Fatalf("unexpected proxy name: %q", gotName) 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) { func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
var gotName string var gotName string
controller := &Controller{ controller := &Controller{
manager: &fakeConfigManager{ manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) error { createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
gotName = cfg.GetBaseConfig().Name 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)) req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req) ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.CreateStoreVisitor(ctx) resp, err := controller.CreateStoreVisitor(ctx)
if err != nil { if err != nil {
t.Fatalf("create store visitor: %v", err) t.Fatalf("create store visitor: %v", err)
} }
if gotName != "raw-visitor" { if gotName != "raw-visitor" {
t.Fatalf("unexpected visitor name: %q", gotName) 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) 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)
}
}

View File

@@ -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
}
}

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package api package model
const SourceStore = "store" const SourceStore = "store"
@@ -31,26 +31,12 @@ type ProxyStatusResp struct {
Source string `json:"source,omitempty"` // "store" or "config" 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 // ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct { type ProxyListResp struct {
Proxies []ProxyConfig `json:"proxies"` Proxies []ProxyDefinition `json:"proxies"`
} }
// VisitorListResp is the response for GET /api/store/visitors // VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct { type VisitorListResp struct {
Visitors []VisitorConfig `json:"visitors"` Visitors []VisitorDefinition `json:"visitors"`
} }

View File

@@ -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
}
}

View File

@@ -16,7 +16,6 @@ package config
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -33,6 +32,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util" "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 // Convert to JSON and decode with strict validation
jsonBytes, err := json.Marshal(temp) jsonBytes, err := jsonx.Marshal(temp)
if err != nil { if err != nil {
return err return err
} }
decoder := json.NewDecoder(bytes.NewReader(jsonBytes)) return decodeJSONContent(jsonBytes, target, true)
decoder.DisallowUnknownFields() }
return decoder.Decode(target)
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. // LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format. // Now it supports json, yaml and toml format.
func LoadConfigure(b []byte, c any, strict bool) error { func LoadConfigure(b []byte, c any, strict bool) error {
return v1.WithDisallowUnknownFields(strict, func() error { var tomlObj any
var tomlObj any // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). if err := toml.Unmarshal(b, &tomlObj); err == nil {
if err := toml.Unmarshal(b, &tomlObj); err == nil { var err error
var err error b, err = jsonx.Marshal(&tomlObj)
b, err = json.Marshal(&tomlObj) if err != nil {
if err != nil { return err
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)
} }
}
// 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 // Handle YAML content
if strict { if strict {
// In strict mode, always use our custom handler to support YAML merge // In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c) return parseYAMLWithDotFieldsHandling(b, c)
} }
// Non-strict mode, parse normally // Non-strict mode, parse normally
return yaml.Unmarshal(b, c) return yaml.Unmarshal(b, c)
})
} }
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) { func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {

View File

@@ -189,6 +189,31 @@ unixPath = "/tmp/uds.sock"
require.Error(err) 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 // TestYAMLMergeInStrictMode tests that YAML merge functionality works
// even in strict mode by properly handling dot-prefixed fields // even in strict mode by properly handling dot-prefixed fields
func TestYAMLMergeInStrictMode(t *testing.T) { func TestYAMLMergeInStrictMode(t *testing.T) {

View File

@@ -15,13 +15,13 @@
package source package source
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/jsonx"
) )
type StoreSourceConfig struct { type StoreSourceConfig struct {
@@ -74,36 +74,44 @@ func (s *StoreSource) loadFromFileUnlocked() error {
return err return err
} }
var stored storeData type rawStoreData struct {
if err := v1.WithDisallowUnknownFields(false, func() error { Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
return json.Unmarshal(data, &stored) Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
}); err != nil { }
stored := rawStoreData{}
if err := jsonx.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err) return fmt.Errorf("failed to parse JSON: %w", err)
} }
s.proxies = make(map[string]v1.ProxyConfigurer) s.proxies = make(map[string]v1.ProxyConfigurer)
s.visitors = make(map[string]v1.VisitorConfigurer) s.visitors = make(map[string]v1.VisitorConfigurer)
for _, tp := range stored.Proxies { for i, proxyData := range stored.Proxies {
if tp.ProxyConfigurer != nil { proxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{
proxyCfg := tp.ProxyConfigurer DisallowUnknownFields: false,
name := proxyCfg.GetBaseConfig().Name })
if name == "" { if err != nil {
return fmt.Errorf("proxy name cannot be empty") return fmt.Errorf("failed to decode proxy at index %d: %w", i, err)
}
s.proxies[name] = proxyCfg
} }
name := proxyCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.proxies[name] = proxyCfg
} }
for _, tv := range stored.Visitors { for i, visitorData := range stored.Visitors {
if tv.VisitorConfigurer != nil { visitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{
visitorCfg := tv.VisitorConfigurer DisallowUnknownFields: false,
name := visitorCfg.GetBaseConfig().Name })
if name == "" { if err != nil {
return fmt.Errorf("visitor name cannot be empty") return fmt.Errorf("failed to decode visitor at index %d: %w", i, err)
}
s.visitors[name] = visitorCfg
} }
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
} }
return nil return nil
@@ -122,7 +130,7 @@ func (s *StoreSource) saveToFileUnlocked() error {
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v}) stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
} }
data, err := json.MarshalIndent(stored, "", " ") data, err := jsonx.MarshalIndent(stored, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err) return fmt.Errorf("failed to marshal JSON: %w", err)
} }

View File

@@ -15,7 +15,6 @@
package source package source
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -23,27 +22,9 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1" 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) { func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t) require := require.New(t)
@@ -99,7 +80,7 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}}, Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}}, Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
} }
data, err := json.Marshal(stored) data, err := jsonx.Marshal(stored)
require.NoError(err) require.NoError(err)
err = os.WriteFile(path, data, 0o600) err = os.WriteFile(path, data, 0o600)
require.NoError(err) require.NoError(err)
@@ -117,12 +98,9 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
} }
func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) { func TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) {
require := require.New(t) require := require.New(t)
restore := setDisallowUnknownFieldsForStoreTest(t, true)
t.Cleanup(restore)
path := filepath.Join(t.TempDir(), "store.json") path := filepath.Join(t.TempDir(), "store.json")
raw := []byte(`{ raw := []byte(`{
"proxies": [ "proxies": [
@@ -140,5 +118,4 @@ func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t
require.NotNil(storeSource.GetProxy("proxy1")) require.NotNil(storeSource.GetProxy("proxy1"))
require.NotNil(storeSource.GetVisitor("visitor1")) require.NotNil(storeSource.GetVisitor("visitor1"))
require.True(getDisallowUnknownFieldsForStoreTest())
} }

View File

@@ -16,35 +16,10 @@ package v1
import ( import (
"maps" "maps"
"sync"
"github.com/fatedier/frp/pkg/util/util" "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 type AuthScope string
const ( const (

195
pkg/config/v1/decode.go Normal file
View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -15,16 +15,13 @@
package v1 package v1
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps" "maps"
"reflect" "reflect"
"slices" "slices"
"github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/util"
) )
@@ -202,35 +199,18 @@ type TypedProxyConfig struct {
} }
func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error { func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" { configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})
return errors.New("type is required") if err != nil {
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err return err
} }
c.Type = typeStruct.Type c.Type = configurer.GetBaseConfig().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.ProxyConfigurer = configurer c.ProxyConfigurer = configurer
return nil return nil
} }
func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) { func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(c.ProxyConfigurer) return jsonx.Marshal(c.ProxyConfigurer)
} }
type ProxyConfigurer interface { type ProxyConfigurer interface {

View File

@@ -15,14 +15,11 @@
package v1 package v1
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect" "reflect"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/util"
) )
@@ -71,42 +68,16 @@ func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
} }
func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error { func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" { decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
return nil if err != nil {
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err return err
} }
*c = decoded
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
return nil return nil
} }
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) { func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.ClientPluginOptions) return jsonx.Marshal(c.ClientPluginOptions)
} }
type HTTP2HTTPSPluginOptions struct { type HTTP2HTTPSPluginOptions struct {

View File

@@ -15,12 +15,9 @@
package v1 package v1
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect" "reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/util"
) )
@@ -93,35 +90,18 @@ type TypedVisitorConfig struct {
} }
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error { func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" { configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
return errors.New("type is required") if err != nil {
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err return err
} }
c.Type = typeStruct.Type c.Type = configurer.GetBaseConfig().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.VisitorConfigurer = configurer c.VisitorConfigurer = configurer
return nil return nil
} }
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) { func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorConfigurer) return jsonx.Marshal(c.VisitorConfigurer)
} }
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer { func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {

View File

@@ -15,11 +15,9 @@
package v1 package v1
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect" "reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
) )
const ( const (
@@ -49,42 +47,16 @@ func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
} }
func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error { func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" { decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
return nil if err != nil {
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err return err
} }
*c = decoded
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
return nil return nil
} }
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) { func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorPluginOptions) return jsonx.Marshal(c.VisitorPluginOptions)
} }
type VirtualNetVisitorPluginOptions struct { type VirtualNetVisitorPluginOptions struct {

View File

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

45
pkg/util/jsonx/json_v1.go Normal file
View File

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

View File

@@ -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
}

64
server/api_router.go Normal file
View File

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

View File

@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package api package http
import ( import (
"cmp" "cmp"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"slices" "slices"
@@ -29,6 +28,7 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version" "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/proxy"
"github.com/fatedier/frp/server/registry" "github.com/fatedier/frp/server/registry"
) )
@@ -59,7 +59,7 @@ func NewController(
// /api/serverinfo // /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer() serverStats := mem.StatsCollector.GetServer()
svrResp := ServerInfoResp{ svrResp := model.ServerInfoResp{
Version: version.Full(), Version: version.Full(),
BindPort: c.serverCfg.BindPort, BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort, VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,22 +80,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts, ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts, 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 return svrResp, nil
} }
@@ -112,7 +96,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
statusFilter := strings.ToLower(ctx.Query("status")) statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List() records := c.clientRegistry.List()
items := make([]ClientInfoResp, 0, len(records)) items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records { for _, info := range records {
if userFilter != "" && info.User != userFilter { if userFilter != "" && info.User != userFilter {
continue continue
@@ -129,7 +113,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
items = append(items, buildClientInfoResp(info)) 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 { if v := cmp.Compare(a.User, b.User); v != 0 {
return v return v
} }
@@ -165,9 +149,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) { func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type") proxyType := ctx.Param("type")
proxyInfoResp := GetProxyInfoResp{} proxyInfoResp := model.GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType) 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) 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) { func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name") name := ctx.Param("name")
trafficResp := GetProxyTrafficResp{} trafficResp := model.GetProxyTrafficResp{}
trafficResp.Name = name trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(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") return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
} }
proxyInfo := GetProxyStatsResp{ proxyInfo := model.GetProxyStatsResp{
Name: ps.Name, Name: ps.Name,
User: ps.User, User: ps.User,
ClientID: ps.ClientID, ClientID: ps.ClientID,
@@ -225,16 +209,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
} }
if pxy, ok := c.pxyManager.GetByName(name); ok { if pxy, ok := c.pxyManager.GetByName(name); ok {
content, err := json.Marshal(pxy.GetConfigurer()) proxyInfo.Conf = getConfFromConfigurer(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.Status = "online" proxyInfo.Status = "online"
} else { } else {
proxyInfo.Status = "offline" proxyInfo.Status = "offline"
@@ -254,25 +229,16 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil 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) proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats)) proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats { for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{ proxyInfo := &model.ProxyStatsInfo{
User: ps.User, User: ps.User,
ClientID: ps.ClientID, ClientID: ps.ClientID,
} }
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer()) proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Status = "online" proxyInfo.Status = "online"
} else { } else {
proxyInfo.Status = "offline" proxyInfo.Status = "offline"
@@ -288,7 +254,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
return 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 proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName) ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil { if ps == nil {
@@ -298,20 +264,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
proxyInfo.User = ps.User proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok { if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer()) proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Status = "online" proxyInfo.Status = "online"
} else { } else {
proxyInfo.Status = "offline" proxyInfo.Status = "offline"
@@ -327,8 +280,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
return return
} }
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
resp := ClientInfoResp{ resp := model.ClientInfoResp{
Key: info.Key, Key: info.Key,
User: info.User, User: info.User,
ClientID: info.ClientID(), ClientID: info.ClientID(),
@@ -366,23 +319,37 @@ func matchStatusFilter(online bool, filter string) bool {
} }
} }
func getConfByType(proxyType string) any { func getConfFromConfigurer(cfg v1.ProxyConfigurer) any {
switch v1.ProxyType(proxyType) { outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}
case v1.ProxyTypeTCP:
return &TCPOutConf{} switch c := cfg.(type) {
case v1.ProxyTypeTCPMUX: case *v1.TCPProxyConfig:
return &TCPMuxOutConf{} return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
case v1.ProxyTypeUDP: case *v1.UDPProxyConfig:
return &UDPOutConf{} return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
case v1.ProxyTypeHTTP: case *v1.HTTPProxyConfig:
return &HTTPOutConf{} return &model.HTTPOutConf{
case v1.ProxyTypeHTTPS: BaseOutConf: outBase,
return &HTTPSOutConf{} DomainConfig: c.DomainConfig,
case v1.ProxyTypeSTCP: Locations: c.Locations,
return &STCPOutConf{} HostHeaderRewrite: c.HostHeaderRewrite,
case v1.ProxyTypeXTCP: }
return &XTCPOutConf{} case *v1.HTTPSProxyConfig:
default: return &model.HTTPSOutConf{
return nil 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
} }

View File

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

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package api package model
import ( import (
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"

View File

@@ -28,7 +28,6 @@ import (
"github.com/fatedier/golib/crypto" "github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux" "github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux" fmux "github.com/hashicorp/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go" quic "github.com/quic-go/quic-go"
"github.com/samber/lo" "github.com/samber/lo"
@@ -48,7 +47,6 @@ import (
"github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost" "github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group" "github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics" "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, return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser) 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)
}

View File

@@ -34,11 +34,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
"type": "tcp", "type": "tcp",
"localIP": "127.0.0.1", "tcp": map[string]any{
"localPort": f.PortByName(framework.TCPEchoServerPort), "localIP": "127.0.0.1",
"remotePort": remotePort, "localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
} }
proxyBody, _ := json.Marshal(proxyConfig) proxyBody, _ := json.Marshal(proxyConfig)
@@ -73,11 +75,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
"type": "tcp", "type": "tcp",
"localIP": "127.0.0.1", "tcp": map[string]any{
"localPort": f.PortByName(framework.TCPEchoServerPort), "localIP": "127.0.0.1",
"remotePort": remotePort1, "localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort1,
},
} }
proxyBody, _ := json.Marshal(proxyConfig) proxyBody, _ := json.Marshal(proxyConfig)
@@ -92,7 +96,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(time.Second) time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure() framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["remotePort"] = remotePort2 proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
proxyBody, _ = json.Marshal(proxyConfig) proxyBody, _ = json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
@@ -125,11 +129,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
"type": "tcp", "type": "tcp",
"localIP": "127.0.0.1", "tcp": map[string]any{
"localPort": f.PortByName(framework.TCPEchoServerPort), "localIP": "127.0.0.1",
"remotePort": remotePort, "localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
} }
proxyBody, _ := json.Marshal(proxyConfig) proxyBody, _ := json.Marshal(proxyConfig)
@@ -171,11 +177,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{ proxyConfig := map[string]any{
"name": "test-tcp", "name": "test-tcp",
"type": "tcp", "type": "tcp",
"localIP": "127.0.0.1", "tcp": map[string]any{
"localPort": f.PortByName(framework.TCPEchoServerPort), "localIP": "127.0.0.1",
"remotePort": remotePort, "localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
} }
proxyBody, _ := json.Marshal(proxyConfig) proxyBody, _ := json.Marshal(proxyConfig)
@@ -226,5 +234,90 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 404 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
})
})
}) })
}) })

View File

@@ -1,10 +1,10 @@
import { http } from './http' import { http } from './http'
import type { import type {
StatusResponse, StatusResponse,
StoreProxyListResp, ProxyListResp,
StoreProxyConfig, ProxyDefinition,
StoreVisitorListResp, VisitorListResp,
StoreVisitorConfig, VisitorDefinition,
} from '../types/proxy' } from '../types/proxy'
export const getStatus = () => { export const getStatus = () => {
@@ -25,21 +25,21 @@ export const reloadConfig = () => {
// Store API - Proxies // Store API - Proxies
export const listStoreProxies = () => { export const listStoreProxies = () => {
return http.get<StoreProxyListResp>('/api/store/proxies') return http.get<ProxyListResp>('/api/store/proxies')
} }
export const getStoreProxy = (name: string) => { export const getStoreProxy = (name: string) => {
return http.get<StoreProxyConfig>( return http.get<ProxyDefinition>(
`/api/store/proxies/${encodeURIComponent(name)}`, `/api/store/proxies/${encodeURIComponent(name)}`,
) )
} }
export const createStoreProxy = (config: Record<string, any>) => { export const createStoreProxy = (config: ProxyDefinition) => {
return http.post<void>('/api/store/proxies', config) return http.post<ProxyDefinition>('/api/store/proxies', config)
} }
export const updateStoreProxy = (name: string, config: Record<string, any>) => { export const updateStoreProxy = (name: string, config: ProxyDefinition) => {
return http.put<void>( return http.put<ProxyDefinition>(
`/api/store/proxies/${encodeURIComponent(name)}`, `/api/store/proxies/${encodeURIComponent(name)}`,
config, config,
) )
@@ -51,24 +51,24 @@ export const deleteStoreProxy = (name: string) => {
// Store API - Visitors // Store API - Visitors
export const listStoreVisitors = () => { export const listStoreVisitors = () => {
return http.get<StoreVisitorListResp>('/api/store/visitors') return http.get<VisitorListResp>('/api/store/visitors')
} }
export const getStoreVisitor = (name: string) => { export const getStoreVisitor = (name: string) => {
return http.get<StoreVisitorConfig>( return http.get<VisitorDefinition>(
`/api/store/visitors/${encodeURIComponent(name)}`, `/api/store/visitors/${encodeURIComponent(name)}`,
) )
} }
export const createStoreVisitor = (config: Record<string, any>) => { export const createStoreVisitor = (config: VisitorDefinition) => {
return http.post<void>('/api/store/visitors', config) return http.post<VisitorDefinition>('/api/store/visitors', config)
} }
export const updateStoreVisitor = ( export const updateStoreVisitor = (
name: string, name: string,
config: Record<string, any>, config: VisitorDefinition,
) => { ) => {
return http.put<void>( return http.put<VisitorDefinition>(
`/api/store/visitors/${encodeURIComponent(name)}`, `/api/store/visitors/${encodeURIComponent(name)}`,
config, config,
) )

View File

@@ -20,24 +20,33 @@ export type StatusResponse = Record<string, ProxyStatus[]>
// STORE API TYPES // STORE API TYPES
// ======================================== // ========================================
export interface StoreProxyConfig { export interface ProxyDefinition {
name: string name: string
type: string type: ProxyType
config: Record<string, any> tcp?: Record<string, any>
udp?: Record<string, any>
http?: Record<string, any>
https?: Record<string, any>
tcpmux?: Record<string, any>
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
} }
export interface StoreVisitorConfig { export interface VisitorDefinition {
name: string name: string
type: string type: VisitorType
config: Record<string, any> stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
} }
export interface StoreProxyListResp { export interface ProxyListResp {
proxies: StoreProxyConfig[] proxies: ProxyDefinition[]
} }
export interface StoreVisitorListResp { export interface VisitorListResp {
visitors: StoreVisitorConfig[] visitors: VisitorDefinition[]
} }
// ======================================== // ========================================
@@ -255,29 +264,24 @@ export function createDefaultVisitorForm(): VisitorFormData {
// CONVERTERS: Form -> Store API // CONVERTERS: Form -> Store API
// ======================================== // ========================================
export function formToStoreProxy(form: ProxyFormData): Record<string, any> { export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
const config: Record<string, any> = { const block: Record<string, any> = {}
name: form.name,
type: form.type,
}
// Enabled (nil/true = enabled, false = disabled) // Enabled (nil/true = enabled, false = disabled)
if (!form.enabled) { if (!form.enabled) {
config.enabled = false block.enabled = false
} }
// Backend - LocalIP/LocalPort // Backend - LocalIP/LocalPort
if (form.pluginType === '') { if (form.pluginType === '') {
// No plugin, use local backend
if (form.localIP && form.localIP !== '127.0.0.1') { if (form.localIP && form.localIP !== '127.0.0.1') {
config.localIP = form.localIP block.localIP = form.localIP
} }
if (form.localPort != null) { if (form.localPort != null) {
config.localPort = form.localPort block.localPort = form.localPort
} }
} else { } else {
// Plugin backend block.plugin = {
config.plugin = {
type: form.pluginType, type: form.pluginType,
...form.pluginConfig, ...form.pluginConfig,
} }
@@ -291,109 +295,102 @@ export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
(form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||
form.proxyProtocolVersion form.proxyProtocolVersion
) { ) {
config.transport = {} block.transport = {}
if (form.useEncryption) config.transport.useEncryption = true if (form.useEncryption) block.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true if (form.useCompression) block.transport.useCompression = true
if (form.bandwidthLimit) if (form.bandwidthLimit) block.transport.bandwidthLimit = form.bandwidthLimit
config.transport.bandwidthLimit = form.bandwidthLimit
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') { if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
config.transport.bandwidthLimitMode = form.bandwidthLimitMode block.transport.bandwidthLimitMode = form.bandwidthLimitMode
} }
if (form.proxyProtocolVersion) { if (form.proxyProtocolVersion) {
config.transport.proxyProtocolVersion = form.proxyProtocolVersion block.transport.proxyProtocolVersion = form.proxyProtocolVersion
} }
} }
// Load Balancer // Load Balancer
if (form.loadBalancerGroup) { if (form.loadBalancerGroup) {
config.loadBalancer = { block.loadBalancer = {
group: form.loadBalancerGroup, group: form.loadBalancerGroup,
} }
if (form.loadBalancerGroupKey) { if (form.loadBalancerGroupKey) {
config.loadBalancer.groupKey = form.loadBalancerGroupKey block.loadBalancer.groupKey = form.loadBalancerGroupKey
} }
} }
// Health Check // Health Check
if (form.healthCheckType) { if (form.healthCheckType) {
config.healthCheck = { block.healthCheck = {
type: form.healthCheckType, type: form.healthCheckType,
} }
if (form.healthCheckTimeoutSeconds != null) { if (form.healthCheckTimeoutSeconds != null) {
config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
} }
if (form.healthCheckMaxFailed != null) { if (form.healthCheckMaxFailed != null) {
config.healthCheck.maxFailed = form.healthCheckMaxFailed block.healthCheck.maxFailed = form.healthCheckMaxFailed
} }
if (form.healthCheckIntervalSeconds != null) { if (form.healthCheckIntervalSeconds != null) {
config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
} }
if (form.healthCheckPath) { if (form.healthCheckPath) {
config.healthCheck.path = form.healthCheckPath block.healthCheck.path = form.healthCheckPath
} }
if (form.healthCheckHTTPHeaders.length > 0) { if (form.healthCheckHTTPHeaders.length > 0) {
config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
} }
} }
// Metadata // Metadata
if (form.metadatas.length > 0) { if (form.metadatas.length > 0) {
config.metadatas = Object.fromEntries( block.metadatas = Object.fromEntries(
form.metadatas.map((m) => [m.key, m.value]), form.metadatas.map((m) => [m.key, m.value]),
) )
} }
// Annotations // Annotations
if (form.annotations.length > 0) { if (form.annotations.length > 0) {
config.annotations = Object.fromEntries( block.annotations = Object.fromEntries(
form.annotations.map((a) => [a.key, a.value]), form.annotations.map((a) => [a.key, a.value]),
) )
} }
// Type-specific fields // Type-specific fields
if (form.type === 'tcp' || form.type === 'udp') { if ((form.type === 'tcp' || form.type === 'udp') && form.remotePort != null) {
if (form.remotePort != null) { block.remotePort = form.remotePort
config.remotePort = form.remotePort
}
} }
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') { if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
// Domain config
if (form.customDomains) { if (form.customDomains) {
config.customDomains = form.customDomains block.customDomains = form.customDomains
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
} }
if (form.subdomain) { if (form.subdomain) {
config.subdomain = form.subdomain block.subdomain = form.subdomain
} }
} }
if (form.type === 'http') { if (form.type === 'http') {
// HTTP specific
if (form.locations) { if (form.locations) {
config.locations = form.locations block.locations = form.locations
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
} }
if (form.httpUser) config.httpUser = form.httpUser if (form.httpUser) block.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword if (form.httpPassword) block.httpPassword = form.httpPassword
if (form.hostHeaderRewrite) if (form.hostHeaderRewrite) block.hostHeaderRewrite = form.hostHeaderRewrite
config.hostHeaderRewrite = form.hostHeaderRewrite if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
// Header operations
if (form.requestHeaders.length > 0) { if (form.requestHeaders.length > 0) {
config.requestHeaders = { block.requestHeaders = {
set: Object.fromEntries( set: Object.fromEntries(
form.requestHeaders.map((h) => [h.key, h.value]), form.requestHeaders.map((h) => [h.key, h.value]),
), ),
} }
} }
if (form.responseHeaders.length > 0) { if (form.responseHeaders.length > 0) {
config.responseHeaders = { block.responseHeaders = {
set: Object.fromEntries( set: Object.fromEntries(
form.responseHeaders.map((h) => [h.key, h.value]), form.responseHeaders.map((h) => [h.key, h.value]),
), ),
@@ -402,107 +399,194 @@ export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
} }
if (form.type === 'tcpmux') { if (form.type === 'tcpmux') {
// TCPMux specific if (form.httpUser) block.httpUser = form.httpUser
if (form.httpUser) config.httpUser = form.httpUser if (form.httpPassword) block.httpPassword = form.httpPassword
if (form.httpPassword) config.httpPassword = form.httpPassword if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
if (form.multiplexer && form.multiplexer !== 'httpconnect') { if (form.multiplexer && form.multiplexer !== 'httpconnect') {
config.multiplexer = form.multiplexer block.multiplexer = form.multiplexer
} }
} }
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') { if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
// Secure proxy types if (form.secretKey) block.secretKey = form.secretKey
if (form.secretKey) config.secretKey = form.secretKey
if (form.allowUsers) { if (form.allowUsers) {
config.allowUsers = form.allowUsers block.allowUsers = form.allowUsers
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
} }
} }
if (form.type === 'xtcp') { if (form.type === 'xtcp' && form.natTraversalDisableAssistedAddrs) {
// XTCP NAT traversal block.natTraversal = {
if (form.natTraversalDisableAssistedAddrs) { disableAssistedAddrs: true,
config.natTraversal = {
disableAssistedAddrs: true,
}
} }
} }
return config return withStoreProxyBlock(
{
name: form.name,
type: form.type,
},
form.type,
block,
)
} }
export function formToStoreVisitor(form: VisitorFormData): Record<string, any> { export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {
const config: Record<string, any> = { const block: Record<string, any> = {}
name: form.name,
type: form.type,
}
// Enabled
if (!form.enabled) { if (!form.enabled) {
config.enabled = false block.enabled = false
} }
// Transport
if (form.useEncryption || form.useCompression) { if (form.useEncryption || form.useCompression) {
config.transport = {} block.transport = {}
if (form.useEncryption) config.transport.useEncryption = true if (form.useEncryption) block.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true if (form.useCompression) block.transport.useCompression = true
} }
// Base fields if (form.secretKey) block.secretKey = form.secretKey
if (form.secretKey) config.secretKey = form.secretKey if (form.serverUser) block.serverUser = form.serverUser
if (form.serverUser) config.serverUser = form.serverUser if (form.serverName) block.serverName = form.serverName
if (form.serverName) config.serverName = form.serverName
if (form.bindAddr && form.bindAddr !== '127.0.0.1') { if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
config.bindAddr = form.bindAddr block.bindAddr = form.bindAddr
} }
if (form.bindPort != null) { if (form.bindPort != null) {
config.bindPort = form.bindPort block.bindPort = form.bindPort
} }
// XTCP specific
if (form.type === 'xtcp') { if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') { if (form.protocol && form.protocol !== 'quic') {
config.protocol = form.protocol block.protocol = form.protocol
} }
if (form.keepTunnelOpen) { if (form.keepTunnelOpen) {
config.keepTunnelOpen = true block.keepTunnelOpen = true
} }
if (form.maxRetriesAnHour != null) { if (form.maxRetriesAnHour != null) {
config.maxRetriesAnHour = form.maxRetriesAnHour block.maxRetriesAnHour = form.maxRetriesAnHour
} }
if (form.minRetryInterval != null) { if (form.minRetryInterval != null) {
config.minRetryInterval = form.minRetryInterval block.minRetryInterval = form.minRetryInterval
} }
if (form.fallbackTo) { if (form.fallbackTo) {
config.fallbackTo = form.fallbackTo block.fallbackTo = form.fallbackTo
} }
if (form.fallbackTimeoutMs != null) { if (form.fallbackTimeoutMs != null) {
config.fallbackTimeoutMs = form.fallbackTimeoutMs block.fallbackTimeoutMs = form.fallbackTimeoutMs
} }
if (form.natTraversalDisableAssistedAddrs) { if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = { block.natTraversal = {
disableAssistedAddrs: true, disableAssistedAddrs: true,
} }
} }
} }
return config return withStoreVisitorBlock(
{
name: form.name,
type: form.type,
},
form.type,
block,
)
} }
// ======================================== // ========================================
// CONVERTERS: Store API -> Form // CONVERTERS: Store API -> Form
// ======================================== // ========================================
export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData { function getStoreProxyBlock(config: ProxyDefinition): Record<string, any> {
const c = config.config || {} 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<string, any>,
): 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<string, any> {
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<string, any>,
): 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() const form = createDefaultProxyForm()
form.name = config.name || '' form.name = config.name || ''
form.type = (config.type as ProxyType) || 'tcp' form.type = config.type || 'tcp'
form.enabled = c.enabled !== false form.enabled = c.enabled !== false
// Backend // Backend
@@ -608,13 +692,13 @@ export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData {
} }
export function storeVisitorToForm( export function storeVisitorToForm(
config: StoreVisitorConfig, config: VisitorDefinition,
): VisitorFormData { ): VisitorFormData {
const c = config.config || {} const c = getStoreVisitorBlock(config)
const form = createDefaultVisitorForm() const form = createDefaultVisitorForm()
form.name = config.name || '' form.name = config.name || ''
form.type = (config.type as VisitorType) || 'stcp' form.type = config.type || 'stcp'
form.enabled = c.enabled !== false form.enabled = c.enabled !== false
// Transport // Transport

View File

@@ -337,17 +337,18 @@
</div> </div>
</div> </div>
<div class="visitor-card-body"> <div class="visitor-card-body">
<span v-if="visitor.config?.serverName"> <span v-if="getVisitorBlock(visitor)?.serverName">
Server: {{ visitor.config.serverName }} Server: {{ getVisitorBlock(visitor)?.serverName }}
</span> </span>
<span <span
v-if=" v-if="
visitor.config?.bindAddr || visitor.config?.bindPort != null getVisitorBlock(visitor)?.bindAddr ||
getVisitorBlock(visitor)?.bindPort != null
" "
> >
Bind: {{ visitor.config.bindAddr || '127.0.0.1' Bind: {{ getVisitorBlock(visitor)?.bindAddr || '127.0.0.1'
}}<template v-if="visitor.config?.bindPort != null" }}<template v-if="getVisitorBlock(visitor)?.bindPort != null"
>:{{ visitor.config.bindPort }}</template >:{{ getVisitorBlock(visitor)?.bindPort }}</template
> >
</span> </span>
</div> </div>
@@ -388,8 +389,8 @@ import {
} from '../api/frpc' } from '../api/frpc'
import type { import type {
ProxyStatus, ProxyStatus,
StoreProxyConfig, ProxyDefinition,
StoreVisitorConfig, VisitorDefinition,
} from '../types/proxy' } from '../types/proxy'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue' import ProxyCard from '../components/ProxyCard.vue'
@@ -398,8 +399,8 @@ const router = useRouter()
// State // State
const status = ref<ProxyStatus[]>([]) const status = ref<ProxyStatus[]>([])
const storeProxies = ref<StoreProxyConfig[]>([]) const storeProxies = ref<ProxyDefinition[]>([])
const storeVisitors = ref<StoreVisitorConfig[]>([]) const storeVisitors = ref<VisitorDefinition[]>([])
const storeEnabled = ref(false) const storeEnabled = ref(false)
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
@@ -463,9 +464,41 @@ const filteredStatus = computed(() => {
}) })
const disabledStoreProxies = 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 // Methods
const toggleTypeFilter = (type: string) => { const toggleTypeFilter = (type: string) => {
filterType.value = filterType.value === type ? '' : type filterType.value = filterType.value === type ? '' : type
@@ -563,11 +596,11 @@ const handleDelete = async (proxy: ProxyStatus) => {
confirmAndDeleteProxy(proxy.name) confirmAndDeleteProxy(proxy.name)
} }
const handleEditStoreProxy = (proxy: StoreProxyConfig) => { const handleEditStoreProxy = (proxy: ProxyDefinition) => {
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit') router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
} }
const handleDeleteStoreProxy = async (proxy: StoreProxyConfig) => { const handleDeleteStoreProxy = async (proxy: ProxyDefinition) => {
confirmAndDeleteProxy(proxy.name) confirmAndDeleteProxy(proxy.name)
} }
@@ -575,7 +608,7 @@ const handleCreateVisitor = () => {
router.push('/visitors/create') router.push('/visitors/create')
} }
const handleEditVisitor = (visitor: StoreVisitorConfig) => { const handleEditVisitor = (visitor: VisitorDefinition) => {
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit') router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
} }