Compare commits

...

4 Commits

Author SHA1 Message Date
fatedier
541878af4d docs: update release notes for config parsing error improvements (#5196) 2026-03-05 00:00:46 +08:00
fatedier
b7435967b0 pkg/config: fix line numbers shown incorrectly for TOML type mismatch errors (#5195) 2026-03-04 20:53:22 +08:00
fatedier
774478d071 pkg/config: improve error messages with line number details for config parsing (#5194)
When frpc verify encounters config errors, the error messages now include
line/column information to help users locate problems:

- TOML syntax errors: report line and column from the TOML parser
  (e.g., "toml: line 4, column 11: expected character ]")
- Type mismatch errors: report the field name and approximate line number
  (e.g., "line 3: field \"proxies\": cannot unmarshal string into ...")
- File format detection: use file extension to determine format, preventing
  silent fallthrough from TOML to YAML parser which produced confusing errors

Previously, a TOML file with syntax errors would silently fall through to the
YAML parser, which would misinterpret the content and produce unhelpful errors
like "json: cannot unmarshal string into Go value of type v1.ClientConfig".

https://claude.ai/code/session_017HWLfcXS3U2hLoy4dsg8Nv

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 19:27:30 +08:00
fatedier
fbeb6ca43a refactor: restructure API packages into client/http and server/http with typed proxy/visitor models (#5193) 2026-03-04 17:38:43 +08:00
33 changed files with 1910 additions and 728 deletions

View File

@@ -6,3 +6,4 @@
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.

View File

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

View File

@@ -133,12 +133,13 @@ func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, e
return cfg, nil
}
func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error {
func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
name := cfg.GetBaseConfig().Name
persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.AddProxy(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
@@ -146,30 +147,30 @@ func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error {
return err
}
return nil
}); err != nil {
return err
})
if err != nil {
return nil, err
}
log.Infof("store: created proxy %q", cfg.GetBaseConfig().Name)
return nil
log.Infof("store: created proxy %q", name)
return persisted, nil
}
func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error {
func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if name == "" {
return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.UpdateProxy(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
@@ -177,12 +178,13 @@ func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigu
return err
}
return nil
}); err != nil {
return err
})
if err != nil {
return nil, err
}
log.Infof("store: updated proxy %q", name)
return nil
return persisted, nil
}
func (m *serviceConfigManager) DeleteStoreProxy(name string) error {
@@ -231,12 +233,13 @@ func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigure
return cfg, nil
}
func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) error {
func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
name := cfg.GetBaseConfig().Name
persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.AddVisitor(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
@@ -244,30 +247,31 @@ func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) erro
return err
}
return nil
}); err != nil {
return err
})
if err != nil {
return nil, err
}
log.Infof("store: created visitor %q", cfg.GetBaseConfig().Name)
return nil
log.Infof("store: created visitor %q", name)
return persisted, nil
}
func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error {
func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if name == "" {
return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.UpdateVisitor(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
@@ -275,12 +279,13 @@ func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorCon
return err
}
return nil
}); err != nil {
return err
})
if err != nil {
return nil, err
}
log.Infof("store: updated visitor %q", name)
return nil
return persisted, nil
}
func (m *serviceConfigManager) DeleteStoreVisitor(name string) error {
@@ -340,6 +345,58 @@ func (m *serviceConfigManager) withStoreMutationAndReload(
return nil
}
func (m *serviceConfigManager) withStoreProxyMutationAndReload(
name string,
fn func(storeSource *source.StoreSource) error,
) (v1.ProxyConfigurer, error) {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
storeSource := m.svr.storeSource
if storeSource == nil {
return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
if err := fn(storeSource); err != nil {
return nil, err
}
if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
}
persisted := storeSource.GetProxy(name)
if persisted == nil {
return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
}
return persisted.Clone(), nil
}
func (m *serviceConfigManager) withStoreVisitorMutationAndReload(
name string,
fn func(storeSource *source.StoreSource) error,
) (v1.VisitorConfigurer, error) {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
storeSource := m.svr.storeSource
if storeSource == nil {
return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
if err := fn(storeSource); err != nil {
return nil, err
}
if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
}
persisted := storeSource.GetVisitor(name)
if persisted == nil {
return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
}
return persisted.Clone(), nil
}
func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {
if cfg == nil {
return fmt.Errorf("invalid proxy config")

View File

@@ -45,7 +45,7 @@ func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
},
}
err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected conflict error")
}
@@ -69,7 +69,7 @@ func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testin
},
}
err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected apply config error")
}
@@ -88,7 +88,7 @@ func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) {
},
}
err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
_, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected store disabled error")
}
@@ -116,10 +116,13 @@ func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *te
},
}
err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if persisted == nil {
t.Fatal("expected persisted proxy to be returned")
}
got := storeSource.GetProxy("raw-proxy")
if got == nil {

View File

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

View File

@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package api
package http
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"net"
@@ -26,9 +25,10 @@ import (
"time"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/jsonx"
)
// Controller handles HTTP API requests for frpc.
@@ -67,15 +67,6 @@ func (c *Controller) toHTTPError(err error) error {
return httppkg.NewError(code, err.Error())
}
// TODO(fatedier): Remove this lock wrapper after migrating typed config
// decoding to encoding/json/v2 with per-call options.
// TypedProxyConfig/TypedVisitorConfig currently read global strictness state.
func unmarshalTypedConfig[T any](body []byte, out *T) error {
return v1.WithDisallowUnknownFields(false, func() error {
return json.Unmarshal(body, out)
})
}
// Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false
@@ -98,7 +89,7 @@ func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
// Status handles GET /api/status
func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
res := make(StatusResp)
res := make(model.StatusResp)
ps := c.manager.GetProxyStatus()
if ps == nil {
return res, nil
@@ -112,7 +103,7 @@ func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
if len(arrs) <= 1 {
continue
}
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int {
return cmp.Compare(a.Name, b.Name)
})
}
@@ -145,8 +136,8 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
return nil, nil
}
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
psr := ProxyStatusResp{
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {
psr := model.ProxyStatusResp{
Name: status.Name,
Type: status.Type,
Status: status.Phase,
@@ -166,7 +157,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat
}
if c.manager.IsStoreProxyEnabled(status.Name) {
psr.Source = SourceStore
psr.Source = model.SourceStore
}
return psr
}
@@ -177,18 +168,17 @@ func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err)
}
resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))}
resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))}
for _, p := range proxies {
cfg, err := configurerToMap(p)
payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil {
continue
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
resp.Proxies = append(resp.Proxies, ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
})
resp.Proxies = append(resp.Proxies, payload)
}
slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil
}
@@ -203,16 +193,12 @@ func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err)
}
cfg, err := configurerToMap(p)
payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
}, nil
return payload, nil
}
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
@@ -221,19 +207,28 @@ func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := unmarshalTypedConfig(body, &typed); err != nil {
var payload model.ProxyDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.manager.CreateStoreProxy(typed.ProxyConfigurer); err != nil {
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
created, err := c.manager.CreateStoreProxy(cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
resp, err := model.ProxyDefinitionFromConfigurer(created)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
@@ -247,19 +242,28 @@ func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := unmarshalTypedConfig(body, &typed); err != nil {
var payload model.ProxyDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.manager.UpdateStoreProxy(name, typed.ProxyConfigurer); err != nil {
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
updated, err := c.manager.UpdateStoreProxy(name, cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
resp, err := model.ProxyDefinitionFromConfigurer(updated)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
@@ -280,18 +284,17 @@ func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err)
}
resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))}
resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))}
for _, v := range visitors {
cfg, err := configurerToMap(v)
payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil {
continue
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
resp.Visitors = append(resp.Visitors, VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
})
resp.Visitors = append(resp.Visitors, payload)
}
slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil
}
@@ -306,16 +309,12 @@ func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
return nil, c.toHTTPError(err)
}
cfg, err := configurerToMap(v)
payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
}, nil
return payload, nil
}
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
@@ -324,19 +323,28 @@ func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := unmarshalTypedConfig(body, &typed); err != nil {
var payload model.VisitorDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.manager.CreateStoreVisitor(typed.VisitorConfigurer); err != nil {
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
created, err := c.manager.CreateStoreVisitor(cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
resp, err := model.VisitorDefinitionFromConfigurer(created)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
@@ -350,19 +358,28 @@ func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := unmarshalTypedConfig(body, &typed); err != nil {
var payload model.VisitorDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.manager.UpdateStoreVisitor(name, typed.VisitorConfigurer); err != nil {
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
updated, err := c.manager.UpdateStoreVisitor(name, cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
resp, err := model.VisitorDefinitionFromConfigurer(updated)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
@@ -376,15 +393,3 @@ func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
}
return nil, nil
}
func configurerToMap(v any) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}

View File

@@ -1,4 +1,4 @@
package api
package http
import (
"bytes"
@@ -13,6 +13,7 @@ import (
"github.com/gorilla/mux"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -28,13 +29,13 @@ type fakeConfigManager struct {
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
createStoreProxyFn func(cfg v1.ProxyConfigurer) error
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) error
createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
deleteStoreProxyFn func(name string) error
listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
createStoreVisitFn func(cfg v1.VisitorConfigurer) error
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) error
createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
deleteStoreVisitFn func(name string) error
gracefulCloseFn func(d time.Duration)
}
@@ -95,18 +96,18 @@ func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, erro
return nil, nil
}
func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) error {
func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if m.createStoreProxyFn != nil {
return m.createStoreProxyFn(cfg)
}
return nil
return cfg, nil
}
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error {
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if m.updateStoreProxyFn != nil {
return m.updateStoreProxyFn(name, cfg)
}
return nil
return cfg, nil
}
func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
@@ -130,18 +131,18 @@ func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer,
return nil, nil
}
func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) error {
func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if m.createStoreVisitFn != nil {
return m.createStoreVisitFn(cfg)
}
return nil
return cfg, nil
}
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error {
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if m.updateStoreVisitFn != nil {
return m.updateStoreVisitFn(name, cfg)
}
return nil
return cfg, nil
}
func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
@@ -157,25 +158,6 @@ func (m *fakeConfigManager) GracefulClose(d time.Duration) {
}
}
func setDisallowUnknownFieldsForTest(t *testing.T, value bool) func() {
t.Helper()
v1.DisallowUnknownFieldsMu.Lock()
prev := v1.DisallowUnknownFields
v1.DisallowUnknownFields = value
v1.DisallowUnknownFieldsMu.Unlock()
return func() {
v1.DisallowUnknownFieldsMu.Lock()
v1.DisallowUnknownFields = prev
v1.DisallowUnknownFieldsMu.Unlock()
}
}
func getDisallowUnknownFieldsForTest() bool {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
return v1.DisallowUnknownFields
}
func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
return &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
@@ -188,18 +170,6 @@ func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
}
}
func newRawXTCPVisitorConfig(name string) *v1.XTCPVisitorConfig {
return &v1.XTCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: name,
Type: "xtcp",
ServerName: "server",
BindPort: 10081,
SecretKey: "secret",
},
}
}
func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
status := &proxy.WorkingStatus{
Name: "shared-proxy",
@@ -265,22 +235,20 @@ func TestStoreProxyErrorMapping(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(newRawTCPProxyConfig("shared-proxy"))
if err != nil {
t.Fatalf("marshal body: %v", err)
}
body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
controller := &Controller{
manager: &fakeConfigManager{
updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) error { return tc.err },
updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
return nil, tc.err
},
},
}
_, err = controller.UpdateStoreProxy(ctx)
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
@@ -290,11 +258,7 @@ func TestStoreProxyErrorMapping(t *testing.T) {
}
func TestStoreVisitorErrorMapping(t *testing.T) {
body, err := json.Marshal(newRawXTCPVisitorConfig("shared-visitor"))
if err != nil {
t.Fatalf("marshal body: %v", err)
}
body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
@@ -307,70 +271,208 @@ func TestStoreVisitorErrorMapping(t *testing.T) {
},
}
_, err = controller.DeleteStoreVisitor(ctx)
_, err := controller.DeleteStoreVisitor(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusNotFound)
}
func TestCreateStoreProxy_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) error {
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
gotName = cfg.GetBaseConfig().Name
return nil
return cfg, nil
},
},
}
body := []byte(`{"name":"raw-proxy","type":"tcp","localPort":10080,"unexpected":"value"}`)
body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.CreateStoreProxy(ctx)
resp, err := controller.CreateStoreProxy(ctx)
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if gotName != "raw-proxy" {
t.Fatalf("unexpected proxy name: %q", gotName)
}
if !getDisallowUnknownFieldsForTest() {
t.Fatal("global strictness flag was not restored")
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.Type != "tcp" || payload.TCP == nil {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestCreateStoreVisitor_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) error {
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
gotName = cfg.GetBaseConfig().Name
return nil
return cfg, nil
},
},
}
body := []byte(`{"name":"raw-visitor","type":"xtcp","serverName":"server","bindPort":10081,"secretKey":"secret","unexpected":"value"}`)
body := []byte(`{
"name":"raw-visitor","type":"xtcp","unexpected":"value",
"xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.CreateStoreVisitor(ctx)
resp, err := controller.CreateStoreVisitor(ctx)
if err != nil {
t.Fatalf("create store visitor: %v", err)
}
if gotName != "raw-visitor" {
t.Fatalf("unexpected visitor name: %q", gotName)
}
if !getDisallowUnknownFieldsForTest() {
t.Fatal("global strictness flag was not restored")
payload, ok := resp.(model.VisitorDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.Type != "xtcp" || payload.XTCP == nil {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
var gotPluginType string
controller := &Controller{
manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
gotPluginType = cfg.GetBaseConfig().Plugin.Type
return cfg, nil
},
},
}
body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreProxy(ctx)
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if gotPluginType != "http2https" {
t.Fatalf("unexpected plugin type: %q", gotPluginType)
}
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.TCP == nil {
t.Fatalf("unexpected response payload: %#v", payload)
}
pluginType := payload.TCP.Plugin.Type
if pluginType != "http2https" {
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
}
}
func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
var gotPluginType string
controller := &Controller{
manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
gotPluginType = cfg.GetBaseConfig().Plugin.Type
return cfg, nil
},
},
}
body := []byte(`{
"name":"plugin-visitor","type":"stcp",
"stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreVisitor(ctx)
if err != nil {
t.Fatalf("create store visitor: %v", err)
}
if gotPluginType != "virtual_net" {
t.Fatalf("unexpected plugin type: %q", gotPluginType)
}
payload, ok := resp.(model.VisitorDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.STCP == nil {
t.Fatalf("unexpected response payload: %#v", payload)
}
pluginType := payload.STCP.Plugin.Type
if pluginType != "virtual_net" {
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
}
}
func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
controller := &Controller{manager: &fakeConfigManager{}}
body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusBadRequest)
}
func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
controller := &Controller{manager: &fakeConfigManager{}}
body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusBadRequest)
}
func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
controller := &Controller{
manager: &fakeConfigManager{
listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
b := newRawTCPProxyConfig("b")
a := newRawTCPProxyConfig("a")
return []v1.ProxyConfigurer{b, a}, nil
},
},
}
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
resp, err := controller.ListStoreProxies(ctx)
if err != nil {
t.Fatalf("list store proxies: %v", err)
}
out, ok := resp.(model.ProxyListResp)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if len(out.Proxies) != 2 {
t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
}
if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
}
}
@@ -388,3 +490,42 @@ func assertHTTPCode(t *testing.T, err error, expected int) {
t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
}
}
func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
controller := &Controller{
manager: &fakeConfigManager{
updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
return cfg, nil
},
},
}
body := map[string]any{
"name": "shared-proxy",
"type": "tcp",
"tcp": map[string]any{
"localPort": 10080,
"remotePort": 7000,
},
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.UpdateStoreProxy(ctx)
if err != nil {
t.Fatalf("update store proxy: %v", err)
}
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
t.Fatalf("unexpected response payload: %#v", payload)
}
}

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
// limitations under the License.
package api
package model
const SourceStore = "store"
@@ -31,26 +31,12 @@ type ProxyStatusResp struct {
Source string `json:"source,omitempty"` // "store" or "config"
}
// ProxyConfig wraps proxy configuration for API requests/responses.
type ProxyConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// VisitorConfig wraps visitor configuration for API requests/responses.
type VisitorConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct {
Proxies []ProxyConfig `json:"proxies"`
Proxies []ProxyDefinition `json:"proxies"`
}
// VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct {
Visitors []VisitorConfig `json:"visitors"`
Visitors []VisitorDefinition `json:"visitors"`
}

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

@@ -17,6 +17,7 @@ package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -33,6 +34,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
return LoadConfigure(content, c, strict)
return LoadConfigure(content, c, strict, detectFormatFromPath(path))
}
// detectFormatFromPath returns a format hint based on the file extension.
func detectFormatFromPath(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".toml":
return "toml"
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
default:
return ""
}
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -129,45 +145,134 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
}
// Convert to JSON and decode with strict validation
jsonBytes, err := json.Marshal(temp)
jsonBytes, err := jsonx.Marshal(temp)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
return decodeJSONContent(jsonBytes, target, true)
}
func decodeJSONContent(content []byte, target any, strict bool) error {
if clientCfg, ok := target.(*v1.ClientConfig); ok {
decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{
DisallowUnknownFields: strict,
})
if err != nil {
return err
}
*clientCfg = decoded
return nil
}
return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{
RejectUnknownMembers: strict,
})
}
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
func LoadConfigure(b []byte, c any, strict bool) error {
return v1.WithDisallowUnknownFields(strict, func() error {
var tomlObj any
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
if err := toml.Unmarshal(b, &tomlObj); err == nil {
var err error
b, err = json.Marshal(&tomlObj)
if err != nil {
return err
}
}
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
decoder := json.NewDecoder(bytes.NewBuffer(b))
if strict {
decoder.DisallowUnknownFields()
}
return decoder.Decode(c)
}
// An optional format hint (e.g. "toml", "yaml", "json") can be provided
// to enable better error messages with line number information.
func LoadConfigure(b []byte, c any, strict bool, formats ...string) error {
format := ""
if len(formats) > 0 {
format = formats[0]
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c)
originalBytes := b
parsedFromTOML := false
var tomlObj any
tomlErr := toml.Unmarshal(b, &tomlObj)
if tomlErr == nil {
parsedFromTOML = true
var err error
b, err = jsonx.Marshal(&tomlObj)
if err != nil {
return err
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
})
} else if format == "toml" {
// File is known to be TOML but has syntax errors.
return formatTOMLError(tomlErr)
}
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
if err := decodeJSONContent(b, c, strict); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
return nil
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
return nil
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
}
// formatTOMLError extracts line/column information from TOML decode errors.
func formatTOMLError(err error) error {
var decErr *toml.DecodeError
if errors.As(err, &decErr) {
row, col := decErr.Position()
return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error())
}
var strictErr *toml.StrictMissingError
if errors.As(err, &strictErr) {
return strictErr
}
return err
}
// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.
func enhanceDecodeError(err error, originalContent []byte, includeLine bool) error {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) && typeErr.Field != "" {
if includeLine {
line := findFieldLineInContent(originalContent, typeErr.Field)
if line > 0 {
return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type)
}
}
return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type)
}
return err
}
// findFieldLineInContent searches the original config content for a field name
// and returns the 1-indexed line number where it appears, or 0 if not found.
func findFieldLineInContent(content []byte, fieldPath string) int {
if fieldPath == "" {
return 0
}
// Use the last component of the field path (e.g. "proxies" from "proxies" or
// "protocol" from "transport.protocol").
parts := strings.Split(fieldPath, ".")
searchKey := parts[len(parts)-1]
lines := bytes.Split(content, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
// Match TOML key assignments like: key = ...
if bytes.HasPrefix(trimmed, []byte(searchKey)) {
rest := bytes.TrimSpace(trimmed[len(searchKey):])
if len(rest) > 0 && rest[0] == '=' {
return i + 1
}
}
// Match TOML table array headers like: [[proxies]]
if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) {
return i + 1
}
}
return 0
}
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {

View File

@@ -189,6 +189,31 @@ unixPath = "/tmp/uds.sock"
require.Error(err)
}
func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) {
require := require.New(t)
content := `
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
localPort = 6000
[proxies.plugin]
type = "http2https"
localAddr = "127.0.0.1:8080"
unknownInPlugin = "value"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false)
require.NoError(err)
err = LoadConfigure([]byte(content), &clientCfg, true)
require.ErrorContains(err, "unknownInPlugin")
}
// TestYAMLMergeInStrictMode tests that YAML merge functionality works
// even in strict mode by properly handling dot-prefixed fields
func TestYAMLMergeInStrictMode(t *testing.T) {
@@ -470,3 +495,111 @@ serverPort: 7000
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
}
func TestTOMLSyntaxErrorWithPosition(t *testing.T) {
require := require.New(t)
// TOML with syntax error (unclosed table array header)
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]
name = "test"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
require.Contains(err.Error(), "toml")
require.Contains(err.Error(), "line")
require.Contains(err.Error(), "column")
}
func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {
require := require.New(t)
// TOML with wrong type: proxies should be a table array, not a string
content := `serverAddr = "127.0.0.1"
serverPort = 7000
proxies = "this should be a table array"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
// The error should contain field info
errMsg := err.Error()
require.Contains(errMsg, "proxies")
require.NotContains(errMsg, "line")
}
func TestFindFieldLineInContent(t *testing.T) {
content := []byte(`serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`)
tests := []struct {
fieldPath string
wantLine int
}{
{"serverAddr", 1},
{"serverPort", 2},
{"name", 5},
{"type", 6},
{"remotePort", 7},
{"nonexistent", 0},
}
for _, tt := range tests {
t.Run(tt.fieldPath, func(t *testing.T) {
got := findFieldLineInContent(content, tt.fieldPath)
require.Equal(t, tt.wantLine, got)
})
}
}
func TestFormatDetection(t *testing.T) {
tests := []struct {
path string
format string
}{
{"config.toml", "toml"},
{"config.TOML", "toml"},
{"config.yaml", "yaml"},
{"config.yml", "yaml"},
{"config.json", "json"},
{"config.ini", ""},
{"config", ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
require.Equal(t, tt.format, detectFormatFromPath(tt.path))
})
}
}
func TestValidTOMLStillWorks(t *testing.T) {
require := require.New(t)
// Valid TOML with format hint should work fine
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.NoError(err)
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
require.Len(clientCfg.Proxies, 1)
}

View File

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

View File

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

View File

@@ -16,35 +16,10 @@ package v1
import (
"maps"
"sync"
"github.com/fatedier/frp/pkg/util/util"
)
// TODO(fatedier): Migrate typed config decoding to encoding/json/v2 when it is stable for production use.
// The current encoding/json(v1) path cannot propagate DisallowUnknownFields into custom UnmarshalJSON
// methods, so we temporarily keep this global strictness flag protected by a mutex.
//
// https://github.com/golang/go/issues/41144
// https://github.com/golang/go/discussions/63397
var (
DisallowUnknownFields = false
DisallowUnknownFieldsMu sync.Mutex
)
// WithDisallowUnknownFields temporarily overrides typed config JSON strictness.
// It restores the previous value before returning.
func WithDisallowUnknownFields(disallow bool, fn func() error) error {
DisallowUnknownFieldsMu.Lock()
prev := DisallowUnknownFields
DisallowUnknownFields = disallow
defer func() {
DisallowUnknownFields = prev
DisallowUnknownFieldsMu.Unlock()
}()
return fn()
}
type AuthScope string
const (

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

View File

@@ -15,14 +15,11 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -71,42 +68,16 @@ func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
}
func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return nil
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
if c.Type == "" {
return errors.New("plugin type is empty")
}
v, ok := clientPluginOptionsTypeMap[typeStruct.Type]
if !ok {
return fmt.Errorf("unknown plugin type: %s", typeStruct.Type)
}
options := reflect.New(v).Interface().(ClientPluginOptions)
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(options); err != nil {
return fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
}
c.ClientPluginOptions = options
*c = decoded
return nil
}
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.ClientPluginOptions)
return jsonx.Marshal(c.ClientPluginOptions)
}
type HTTP2HTTPSPluginOptions struct {

View File

@@ -15,12 +15,9 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -93,35 +90,18 @@ type TypedVisitorConfig struct {
}
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return errors.New("type is required")
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
configurer := NewVisitorConfigurerByType(VisitorType(typeStruct.Type))
if configurer == nil {
return fmt.Errorf("unknown visitor type: %s", typeStruct.Type)
}
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(configurer); err != nil {
return fmt.Errorf("unmarshal VisitorConfig error: %v", err)
}
c.Type = configurer.GetBaseConfig().Type
c.VisitorConfigurer = configurer
return nil
}
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorConfigurer)
return jsonx.Marshal(c.VisitorConfigurer)
}
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {

View File

@@ -15,11 +15,9 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
)
const (
@@ -49,42 +47,16 @@ func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
}
func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return nil
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
if c.Type == "" {
return errors.New("visitor plugin type is empty")
}
v, ok := visitorPluginOptionsTypeMap[typeStruct.Type]
if !ok {
return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type)
}
options := reflect.New(v).Interface().(VisitorPluginOptions)
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(options); err != nil {
return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
}
c.VisitorPluginOptions = options
*c = decoded
return nil
}
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorPluginOptions)
return jsonx.Marshal(c.VisitorPluginOptions)
}
type VirtualNetVisitorPluginOptions struct {

View File

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

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
// limitations under the License.
package api
package http
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
@@ -29,6 +28,7 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
@@ -59,7 +59,7 @@ func NewController(
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer()
svrResp := ServerInfoResp{
svrResp := model.ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,22 +80,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
// For API that returns struct, we can just return it.
// But current GeneralResponse.Msg in legacy code expects a JSON string.
// Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
// The original code wraps it in GeneralResponse{Msg: string(json)}.
// If we return svrResp, the response body will be the JSON of svrResp.
// We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
// Looking at previous code:
// res := GeneralResponse{Code: 200}
// buf, _ := json.Marshal(&svrResp)
// res.Msg = string(buf)
// Response body: {"code": 200, "msg": "{\"version\":...}"}
// Wait, is it double encoded JSON? Yes it seems so!
// Let's check dashboard_api.go original code again.
// Yes: res.Msg = string(buf).
// So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
// This is kind of ugly, but we must preserve compatibility.
return svrResp, nil
}
@@ -112,7 +96,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
items := make([]ClientInfoResp, 0, len(records))
items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
@@ -129,7 +113,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
items = append(items, buildClientInfoResp(info))
}
slices.SortFunc(items, func(a, b ClientInfoResp) int {
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
@@ -165,9 +149,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp := model.GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
@@ -191,7 +175,7 @@ func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
trafficResp := GetProxyTrafficResp{}
trafficResp := model.GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
@@ -213,7 +197,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
proxyInfo := GetProxyStatsResp{
proxyInfo := model.GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
@@ -225,16 +209,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -254,25 +229,16 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
}
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{
proxyInfo := &model.ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -288,7 +254,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
return
}
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
@@ -298,20 +264,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -327,8 +280,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
return
}
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
resp := ClientInfoResp{
func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
resp := model.ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
@@ -366,23 +319,37 @@ func matchStatusFilter(online bool, filter string) bool {
}
}
func getConfByType(proxyType string) any {
switch v1.ProxyType(proxyType) {
case v1.ProxyTypeTCP:
return &TCPOutConf{}
case v1.ProxyTypeTCPMUX:
return &TCPMuxOutConf{}
case v1.ProxyTypeUDP:
return &UDPOutConf{}
case v1.ProxyTypeHTTP:
return &HTTPOutConf{}
case v1.ProxyTypeHTTPS:
return &HTTPSOutConf{}
case v1.ProxyTypeSTCP:
return &STCPOutConf{}
case v1.ProxyTypeXTCP:
return &XTCPOutConf{}
default:
return nil
func getConfFromConfigurer(cfg v1.ProxyConfigurer) any {
outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}
switch c := cfg.(type) {
case *v1.TCPProxyConfig:
return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
case *v1.UDPProxyConfig:
return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
case *v1.HTTPProxyConfig:
return &model.HTTPOutConf{
BaseOutConf: outBase,
DomainConfig: c.DomainConfig,
Locations: c.Locations,
HostHeaderRewrite: c.HostHeaderRewrite,
}
case *v1.HTTPSProxyConfig:
return &model.HTTPSOutConf{
BaseOutConf: outBase,
DomainConfig: c.DomainConfig,
}
case *v1.TCPMuxProxyConfig:
return &model.TCPMuxOutConf{
BaseOutConf: outBase,
DomainConfig: c.DomainConfig,
Multiplexer: c.Multiplexer,
RouteByHTTPUser: c.RouteByHTTPUser,
}
case *v1.STCPProxyConfig:
return &model.STCPOutConf{BaseOutConf: outBase}
case *v1.XTCPProxyConfig:
return &model.XTCPOutConf{BaseOutConf: outBase}
}
return outBase
}

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
// limitations under the License.
package api
package model
import (
v1 "github.com/fatedier/frp/pkg/config/v1"

View File

@@ -28,7 +28,6 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -48,7 +47,6 @@ import (
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics"
@@ -690,42 +688,3 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware)
subRouter.Use(httppkg.NewRequestLogger)
// metrics
if svr.cfg.EnablePrometheus {
subRouter.Handle("/metrics", promhttp.Handler())
}
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
// apis
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
).Methods("GET")
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
})
}
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}

View File

@@ -34,11 +34,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
"name": "test-tcp",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -73,11 +75,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort1,
"name": "test-tcp",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort1,
},
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -92,7 +96,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["remotePort"] = remotePort2
proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
proxyBody, _ = json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
@@ -125,11 +129,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
"name": "test-tcp",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -171,11 +177,13 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
"name": "test-tcp",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -226,5 +234,90 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 404
})
})
ginkgo.It("rejects mismatched type block", func() {
adminPort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
invalidBody, _ := json.Marshal(map[string]any{
"name": "bad-proxy",
"type": "tcp",
"udp": map[string]any{
"localPort": 1234,
},
})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(invalidBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 400
})
})
ginkgo.It("rejects path/body name mismatch on update", func() {
adminPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
createBody, _ := json.Marshal(map[string]any{
"name": "proxy-a",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(createBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
updateBody, _ := json.Marshal(map[string]any{
"name": "proxy-b",
"type": "tcp",
"tcp": map[string]any{
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
},
})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/proxy-a").HTTPParams("PUT", "", "/api/store/proxies/proxy-a", map[string]string{
"Content-Type": "application/json",
}).Body(updateBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 400
})
})
})
})

View File

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

View File

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

View File

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