diff --git a/.gitignore b/.gitignore index c6480f59..9c67fc73 100644 --- a/.gitignore +++ b/.gitignore @@ -7,18 +7,6 @@ _obj _test -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - *.exe *.test *.prof @@ -42,3 +30,4 @@ client.key # AI CLAUDE.md +.sisyphus/ diff --git a/client/admin_api.go b/client/admin_api.go index 09936352..e733f54b 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -38,6 +38,20 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut) + + if svr.storeSource != nil { + subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost) + subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut) + subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete) + subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost) + subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut) + subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete) + } + subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.PathPrefix("/static/").Handler( netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))), @@ -53,12 +67,14 @@ func healthz(w http.ResponseWriter, _ *http.Request) { func newAPIController(svr *Service) *api.Controller { return api.NewController(api.ControllerParams{ - GetProxyStatus: svr.getAllProxyStatus, - ServerAddr: svr.common.ServerAddr, - ConfigFilePath: svr.configFilePath, - UnsafeFeatures: svr.unsafeFeatures, - UpdateConfig: svr.UpdateAllConfigurer, - GracefulClose: svr.GracefulClose, + GetProxyStatus: svr.getAllProxyStatus, + ServerAddr: svr.common.ServerAddr, + ConfigFilePath: svr.configFilePath, + UnsafeFeatures: svr.unsafeFeatures, + UpdateConfig: svr.UpdateConfigSource, + ReloadFromSources: svr.reloadConfigFromSources, + GracefulClose: svr.GracefulClose, + StoreSource: svr.storeSource, }) } diff --git a/client/api/controller.go b/client/api/controller.go index 6874b724..b8627294 100644 --- a/client/api/controller.go +++ b/client/api/controller.go @@ -16,6 +16,7 @@ package api import ( "cmp" + "encoding/json" "fmt" "net" "net/http" @@ -26,6 +27,7 @@ import ( "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/config" + "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" @@ -35,48 +37,48 @@ import ( // Controller handles HTTP API requests for frpc. type Controller struct { - // getProxyStatus returns the current proxy status. - // Returns nil if the control connection is not established. - getProxyStatus func() []*proxy.WorkingStatus - - // serverAddr is the frps server address for display. - serverAddr string - - // configFilePath is the path to the configuration file. - configFilePath string - - // unsafeFeatures is used for validation. - unsafeFeatures *security.UnsafeFeatures - - // updateConfig updates proxy and visitor configurations. - updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error - - // gracefulClose gracefully stops the service. - gracefulClose func(d time.Duration) + getProxyStatus func() []*proxy.WorkingStatus + serverAddr string + configFilePath string + unsafeFeatures *security.UnsafeFeatures + updateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error + reloadFromSources func() error + gracefulClose func(d time.Duration) + storeSource *source.StoreSource } // ControllerParams contains parameters for creating an APIController. type ControllerParams struct { - GetProxyStatus func() []*proxy.WorkingStatus - ServerAddr string - ConfigFilePath string - UnsafeFeatures *security.UnsafeFeatures - UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error - GracefulClose func(d time.Duration) + GetProxyStatus func() []*proxy.WorkingStatus + ServerAddr string + ConfigFilePath string + UnsafeFeatures *security.UnsafeFeatures + UpdateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error + ReloadFromSources func() error + GracefulClose func(d time.Duration) + StoreSource *source.StoreSource } -// NewController creates a new Controller. func NewController(params ControllerParams) *Controller { return &Controller{ - getProxyStatus: params.GetProxyStatus, - serverAddr: params.ServerAddr, - configFilePath: params.ConfigFilePath, - unsafeFeatures: params.UnsafeFeatures, - updateConfig: params.UpdateConfig, - gracefulClose: params.GracefulClose, + getProxyStatus: params.GetProxyStatus, + serverAddr: params.ServerAddr, + configFilePath: params.ConfigFilePath, + unsafeFeatures: params.UnsafeFeatures, + updateConfig: params.UpdateConfig, + reloadFromSources: params.ReloadFromSources, + gracefulClose: params.GracefulClose, + storeSource: params.StoreSource, } } +func (c *Controller) reloadFromSourcesOrError() error { + if err := c.reloadFromSources(); err != nil { + return httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to apply config: %v", err)) + } + return nil +} + // Reload handles GET /api/reload func (c *Controller) Reload(ctx *httppkg.Context) (any, error) { strictConfigMode := false @@ -85,18 +87,29 @@ func (c *Controller) Reload(ctx *httppkg.Context) (any, error) { strictConfigMode, _ = strconv.ParseBool(strictStr) } - cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode) + result, err := config.LoadClientConfigResult(c.configFilePath, strictConfigMode) if err != nil { log.Warnf("reload frpc proxy config error: %s", err.Error()) return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil { + proxyCfgs := result.Proxies + visitorCfgs := result.Visitors + + proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers( + result.Common, + proxyCfgs, + visitorCfgs, + ) + proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation) + visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation) + + if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, c.unsafeFeatures); err != nil { log.Warnf("reload frpc proxy config error: %s", err.Error()) return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } - if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil { + if err := c.updateConfig(result.Common, proxyCfgs, visitorCfgs); err != nil { log.Warnf("reload frpc proxy config error: %s", err.Error()) return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } @@ -165,7 +178,6 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) { return nil, nil } -// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp { psr := ProxyStatusResp{ Name: status.Name, @@ -185,5 +197,302 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat psr.RemoteAddr = c.serverAddr + psr.RemoteAddr } } + + // Check if proxy is from store + if c.storeSource != nil { + if c.storeSource.GetProxy(status.Name) != nil { + psr.Source = "store" + } + } return psr } + +func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) { + proxies, err := c.storeSource.GetAllProxies() + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list proxies: %v", err)) + } + resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))} + + for _, p := range proxies { + cfg, err := proxyConfigurerToMap(p) + if err != nil { + continue + } + resp.Proxies = append(resp.Proxies, ProxyConfig{ + Name: p.GetBaseConfig().Name, + Type: p.GetBaseConfig().Type, + Config: cfg, + }) + } + return resp, nil +} + +func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") + } + + p := c.storeSource.GetProxy(name) + if p == nil { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name)) + } + + cfg, err := proxyConfigurerToMap(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 +} + +func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) { + body, err := ctx.Body() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) + } + + var typed v1.TypedProxyConfig + if err := json.Unmarshal(body, &typed); 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") + } + + typed.Complete() + if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err)) + } + + if err := c.storeSource.AddProxy(typed.ProxyConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusConflict, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: created proxy %q", typed.ProxyConfigurer.GetBaseConfig().Name) + return nil, nil +} + +func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") + } + + body, err := ctx.Body() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) + } + + var typed v1.TypedProxyConfig + if err := json.Unmarshal(body, &typed); 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") + } + + bodyName := typed.ProxyConfigurer.GetBaseConfig().Name + if bodyName != name { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name in URL must match name in body") + } + + typed.Complete() + if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err)) + } + + if err := c.storeSource.UpdateProxy(typed.ProxyConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusNotFound, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: updated proxy %q", name) + return nil, nil +} + +func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") + } + + if err := c.storeSource.RemoveProxy(name); err != nil { + return nil, httppkg.NewError(http.StatusNotFound, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: deleted proxy %q", name) + return nil, nil +} + +func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) { + visitors, err := c.storeSource.GetAllVisitors() + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list visitors: %v", err)) + } + resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))} + + for _, v := range visitors { + cfg, err := visitorConfigurerToMap(v) + if err != nil { + continue + } + resp.Visitors = append(resp.Visitors, VisitorConfig{ + Name: v.GetBaseConfig().Name, + Type: v.GetBaseConfig().Type, + Config: cfg, + }) + } + return resp, nil +} + +func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") + } + + v := c.storeSource.GetVisitor(name) + if v == nil { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name)) + } + + cfg, err := visitorConfigurerToMap(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 +} + +func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) { + body, err := ctx.Body() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) + } + + var typed v1.TypedVisitorConfig + if err := json.Unmarshal(body, &typed); 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") + } + + typed.Complete() + if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err)) + } + + if err := c.storeSource.AddVisitor(typed.VisitorConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusConflict, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: created visitor %q", typed.VisitorConfigurer.GetBaseConfig().Name) + return nil, nil +} + +func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") + } + + body, err := ctx.Body() + if err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) + } + + var typed v1.TypedVisitorConfig + if err := json.Unmarshal(body, &typed); 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") + } + + bodyName := typed.VisitorConfigurer.GetBaseConfig().Name + if bodyName != name { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name in URL must match name in body") + } + + typed.Complete() + if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err)) + } + + if err := c.storeSource.UpdateVisitor(typed.VisitorConfigurer); err != nil { + return nil, httppkg.NewError(http.StatusNotFound, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: updated visitor %q", name) + return nil, nil +} + +func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") + } + + if err := c.storeSource.RemoveVisitor(name); err != nil { + return nil, httppkg.NewError(http.StatusNotFound, err.Error()) + } + if err := c.reloadFromSourcesOrError(); err != nil { + return nil, err + } + + log.Infof("store: deleted visitor %q", name) + return nil, nil +} + +func proxyConfigurerToMap(p v1.ProxyConfigurer) (map[string]any, error) { + data, err := json.Marshal(p) + 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 +} + +func visitorConfigurerToMap(v v1.VisitorConfigurer) (map[string]any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} diff --git a/client/api/types.go b/client/api/types.go index d7c930d0..bbc75bd8 100644 --- a/client/api/types.go +++ b/client/api/types.go @@ -26,4 +26,34 @@ type ProxyStatusResp struct { LocalAddr string `json:"local_addr"` Plugin string `json:"plugin"` RemoteAddr string `json:"remote_addr"` + 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"` +} + +// VisitorListResp is the response for GET /api/store/visitors +type VisitorListResp struct { + Visitors []VisitorConfig `json:"visitors"` +} + +// ErrorResp represents an error response +type ErrorResp struct { + Error string `json:"error"` } diff --git a/client/control.go b/client/control.go index 0f48c36d..f7d2c2a6 100644 --- a/client/control.go +++ b/client/control.go @@ -27,6 +27,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" @@ -156,6 +157,8 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) { return } + startMsg.ProxyName = util.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName) + // dispatch this work connection to related proxy ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg) } @@ -165,11 +168,12 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) { inMsg := m.(*msg.NewProxyResp) // Server will return NewProxyResp message to each NewProxy message. // Start a new proxy handler if no error got - err := ctl.pm.StartProxy(inMsg.ProxyName, inMsg.RemoteAddr, inMsg.Error) + proxyName := util.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName) + err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error) if err != nil { - xl.Warnf("[%s] start error: %v", inMsg.ProxyName, err) + xl.Warnf("[%s] start error: %v", proxyName, err) } else { - xl.Infof("[%s] start proxy success", inMsg.ProxyName) + xl.Infof("[%s] start proxy success", proxyName) } } diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go index 4698320a..6106f056 100644 --- a/client/proxy/proxy_wrapper.go +++ b/client/proxy/proxy_wrapper.go @@ -30,6 +30,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" + "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) @@ -86,6 +87,8 @@ type Wrapper struct { xl *xlog.Logger ctx context.Context + + wireName string } func NewWrapper( @@ -113,6 +116,7 @@ func NewWrapper( vnetController: vnetController, xl: xl, ctx: xlog.NewContext(ctx, xl), + wireName: util.AddUserPrefix(clientCfg.User, baseInfo.Name), } if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 { @@ -182,7 +186,7 @@ func (pw *Wrapper) Stop() { func (pw *Wrapper) close() { _ = pw.handler(&event.CloseProxyPayload{ CloseProxyMsg: &msg.CloseProxy{ - ProxyName: pw.Name, + ProxyName: pw.wireName, }, }) } @@ -208,6 +212,7 @@ func (pw *Wrapper) checkWorker() { var newProxyMsg msg.NewProxy pw.Cfg.MarshalToMsg(&newProxyMsg) + newProxyMsg.ProxyName = pw.wireName pw.lastSendStartMsg = now _ = pw.handler(&event.StartProxyPayload{ NewProxyMsg: &newProxyMsg, diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go index 6e1deac3..1ae87e5e 100644 --- a/client/proxy/xtcp.go +++ b/client/proxy/xtcp.go @@ -30,6 +30,7 @@ import ( "github.com/fatedier/frp/pkg/nathole" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/util" ) func init() { @@ -85,7 +86,7 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC transactionID := nathole.NewTransactionID() natHoleClientMsg := &msg.NatHoleClient{ TransactionID: transactionID, - ProxyName: pxy.cfg.Name, + ProxyName: util.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name), Sid: natHoleSidMsg.Sid, MappedAddrs: prepareResult.Addrs, AssistedAddrs: prepareResult.AssistedAddrs, diff --git a/client/service.go b/client/service.go index 8d639698..16423384 100644 --- a/client/service.go +++ b/client/service.go @@ -29,6 +29,8 @@ import ( "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/auth" + "github.com/fatedier/frp/pkg/config" + "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/policy/security" @@ -61,9 +63,11 @@ func (e cancelErr) Error() string { // ServiceOptions contains options for creating a new client service. type ServiceOptions struct { - Common *v1.ClientCommonConfig - ProxyCfgs []v1.ProxyConfigurer - VisitorCfgs []v1.VisitorConfigurer + Common *v1.ClientCommonConfig + + // ConfigSourceAggregator manages internal config and optional store sources. + // It is required for creating a Service. + ConfigSourceAggregator *source.Aggregator UnsafeFeatures *security.UnsafeFeatures @@ -119,11 +123,20 @@ type Service struct { vnetController *vnet.Controller - cfgMu sync.RWMutex - common *v1.ClientCommonConfig - proxyCfgs []v1.ProxyConfigurer - visitorCfgs []v1.VisitorConfigurer - clientSpec *msg.ClientSpec + cfgMu sync.RWMutex + common *v1.ClientCommonConfig + // reloadCommon is used for filtering/defaulting during config-source reloads. + // It can be updated by /api/reload without mutating startup-only common behavior. + reloadCommon *v1.ClientCommonConfig + proxyCfgs []v1.ProxyConfigurer + visitorCfgs []v1.VisitorConfigurer + clientSpec *msg.ClientSpec + + // aggregator manages multiple configuration sources. + // When set, the service watches for config changes and reloads automatically. + aggregator *source.Aggregator + configSource *source.ConfigSource + storeSource *source.StoreSource unsafeFeatures *security.UnsafeFeatures @@ -160,19 +173,39 @@ func NewService(options ServiceOptions) (*Service, error) { return nil, err } + if options.ConfigSourceAggregator == nil { + return nil, fmt.Errorf("config source aggregator is required") + } + + configSource := options.ConfigSourceAggregator.ConfigSource() + storeSource := options.ConfigSourceAggregator.StoreSource() + + proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load() + if loadErr != nil { + return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr) + } + proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs) + proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs) + visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs) + s := &Service{ ctx: context.Background(), auth: authRuntime, webServer: webServer, common: options.Common, + reloadCommon: options.Common, configFilePath: options.ConfigFilePath, unsafeFeatures: options.UnsafeFeatures, - proxyCfgs: options.ProxyCfgs, - visitorCfgs: options.VisitorCfgs, + proxyCfgs: proxyCfgs, + visitorCfgs: visitorCfgs, clientSpec: options.ClientSpec, + aggregator: options.ConfigSourceAggregator, + configSource: configSource, + storeSource: storeSource, connectorCreator: options.ConnectorCreator, handleWorkConnCb: options.HandleWorkConnCb, } + if webServer != nil { webServer.RouteRegister(s.registerRouteHandlers) } @@ -403,6 +436,33 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC return nil } +func (svr *Service) UpdateConfigSource( + common *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, +) error { + cfgSource := svr.configSource + if cfgSource == nil { + return fmt.Errorf("config source is not available") + } + + // Update reloadCommon before ReplaceAll so the subsequent reload uses the + // same common config as /api/reload validation. + svr.cfgMu.Lock() + prevReloadCommon := svr.reloadCommon + svr.reloadCommon = common + svr.cfgMu.Unlock() + + if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { + svr.cfgMu.Lock() + svr.reloadCommon = prevReloadCommon + svr.cfgMu.Unlock() + return err + } + + return svr.reloadConfigFromSources() +} + func (svr *Service) Close() { svr.GracefulClose(time.Duration(0)) } @@ -423,6 +483,11 @@ func (svr *Service) stop() { svr.webServer.Close() svr.webServer = nil } + if svr.aggregator != nil { + svr.aggregator = nil + } + svr.configSource = nil + svr.storeSource = nil } func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) { @@ -453,3 +518,28 @@ type statusExporterImpl struct { func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) { return s.getProxyStatusFunc(name) } + +func (svr *Service) reloadConfigFromSources() error { + if svr.aggregator == nil { + return errors.New("config aggregator is not initialized") + } + + svr.cfgMu.RLock() + reloadCommon := svr.reloadCommon + svr.cfgMu.RUnlock() + + proxies, visitors, err := svr.aggregator.Load() + if err != nil { + return fmt.Errorf("reload config from sources failed: %w", err) + } + + proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors) + proxies = config.CompleteProxyConfigurers(proxies) + visitors = config.CompleteVisitorConfigurers(visitors) + + // Atomically replace the entire configuration + if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil { + return err + } + return nil +} diff --git a/client/visitor/stcp.go b/client/visitor/stcp.go index 31f6f174..d0cc4edc 100644 --- a/client/visitor/stcp.go +++ b/client/visitor/stcp.go @@ -103,9 +103,10 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) { defer visitorConn.Close() now := time.Now().Unix() + targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName) newVisitorConnMsg := &msg.NewVisitorConn{ RunID: sv.helper.RunID(), - ProxyName: sv.cfg.ServerName, + ProxyName: targetProxyName, SignKey: util.GetAuthKey(sv.cfg.SecretKey, now), Timestamp: now, UseEncryption: sv.cfg.Transport.UseEncryption, diff --git a/client/visitor/sudp.go b/client/visitor/sudp.go index 284aee10..3c763fb1 100644 --- a/client/visitor/sudp.go +++ b/client/visitor/sudp.go @@ -205,9 +205,10 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) { } now := time.Now().Unix() + targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName) newVisitorConnMsg := &msg.NewVisitorConn{ RunID: sv.helper.RunID(), - ProxyName: sv.cfg.ServerName, + ProxyName: targetProxyName, SignKey: util.GetAuthKey(sv.cfg.SecretKey, now), Timestamp: now, UseEncryption: sv.cfg.Transport.UseEncryption, diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index cdfeb1ab..59b80208 100644 --- a/client/visitor/xtcp.go +++ b/client/visitor/xtcp.go @@ -280,8 +280,9 @@ func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) { // 4. Create a tunnel session using an underlying UDP connection. func (sv *XTCPVisitor) makeNatHole() { xl := xlog.FromContextSafe(sv.ctx) + targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName) xl.Tracef("makeNatHole start") - if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), sv.cfg.ServerName, 5*time.Second); err != nil { + if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil { xl.Warnf("nathole precheck error: %v", err) return } @@ -310,7 +311,7 @@ func (sv *XTCPVisitor) makeNatHole() { transactionID := nathole.NewTransactionID() natHoleVisitorMsg := &msg.NatHoleVisitor{ TransactionID: transactionID, - ProxyName: sv.cfg.ServerName, + ProxyName: targetProxyName, Protocol: sv.cfg.Protocol, SignKey: util.GetAuthKey(sv.cfg.SecretKey, now), Timestamp: now, diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index ef7fe67f..8651f0b3 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" + "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" @@ -86,13 +87,14 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm os.Exit(1) } - c.Complete(clientCfg.User) c.GetBaseConfig().Type = name - if err := validation.ValidateProxyConfigurerForClient(c); err != nil { + c.Complete() + proxyCfg := c + if err := validation.ValidateProxyConfigurerForClient(proxyCfg); err != nil { fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "") + err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) @@ -117,13 +119,14 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client os.Exit(1) } - c.Complete(clientCfg) c.GetBaseConfig().Type = name - if err := validation.ValidateVisitorConfigurer(c); err != nil { + c.Complete() + visitorCfg := c + if err := validation.ValidateVisitorConfigurer(visitorCfg); err != nil { fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "") + err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) @@ -131,3 +134,18 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client }, } } + +func startService( + cfg *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, + unsafeFeatures *security.UnsafeFeatures, + cfgFile string, +) error { + configSource := source.NewConfigSource() + if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { + return fmt.Errorf("failed to set config source: %w", err) + } + aggregator := source.NewAggregator(configSource) + return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile) +} diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 1c2d8d5e..5d83cabf 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -30,6 +30,7 @@ import ( "github.com/fatedier/frp/client" "github.com/fatedier/frp/pkg/config" + "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/featuregate" @@ -120,22 +121,64 @@ func handleTermSignal(svr *client.Service) { } func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error { - cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) + // Load configuration + result, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode) if err != nil { return err } - if isLegacyFormat { + if result.IsLegacyFormat { fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " + "please use yaml/json/toml format instead!\n") } - if len(cfg.FeatureGates) > 0 { - if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil { + if len(result.Common.FeatureGates) > 0 { + if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil { return err } } - warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures) + return runClientWithAggregator(result, unsafeFeatures, cfgFilePath) +} + +// runClientWithAggregator runs the client using the internal source aggregator. +func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatures *security.UnsafeFeatures, cfgFilePath string) error { + configSource := source.NewConfigSource() + if err := configSource.ReplaceAll(result.Proxies, result.Visitors); err != nil { + return fmt.Errorf("failed to set config source: %w", err) + } + + var storeSource *source.StoreSource + + if result.Common.Store.IsEnabled() { + storePath := result.Common.Store.Path + if storePath != "" && cfgFilePath != "" && !filepath.IsAbs(storePath) { + storePath = filepath.Join(filepath.Dir(cfgFilePath), storePath) + } + + s, err := source.NewStoreSource(source.StoreSourceConfig{ + Path: storePath, + }) + if err != nil { + return fmt.Errorf("failed to create store source: %w", err) + } + storeSource = s + } + + aggregator := source.NewAggregator(configSource) + if storeSource != nil { + aggregator.SetStoreSource(storeSource) + } + + proxyCfgs, visitorCfgs, err := aggregator.Load() + if err != nil { + return fmt.Errorf("failed to load config from sources: %w", err) + } + + proxyCfgs, visitorCfgs = config.FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs) + proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs) + visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs) + + warning, err := validation.ValidateAllClientConfig(result.Common, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } @@ -143,35 +186,32 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro return err } - return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath) + return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath) } -func startService( +func startServiceWithAggregator( cfg *v1.ClientCommonConfig, - proxyCfgs []v1.ProxyConfigurer, - visitorCfgs []v1.VisitorConfigurer, + aggregator *source.Aggregator, unsafeFeatures *security.UnsafeFeatures, cfgFile string, ) error { log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) if cfgFile != "" { - log.Infof("start frpc service for config file [%s]", cfgFile) + log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile) defer log.Infof("frpc service for config file [%s] stopped", cfgFile) } svr, err := client.NewService(client.ServiceOptions{ - Common: cfg, - ProxyCfgs: proxyCfgs, - VisitorCfgs: visitorCfgs, - UnsafeFeatures: unsafeFeatures, - ConfigFilePath: cfgFile, + Common: cfg, + ConfigSourceAggregator: aggregator, + UnsafeFeatures: unsafeFeatures, + ConfigFilePath: cfgFile, }) if err != nil { return err } shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" - // Capture the exit signal if we use kcp or quic. if shouldGracefulClose { go handleTermSignal(svr) } diff --git a/pkg/config/load.go b/pkg/config/load.go index 6e8c251d..e80ff840 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -180,7 +180,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1. } configurer.UnmarshalFromMsg(m) - configurer.Complete("") + configurer.Complete() if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil { return nil, err @@ -219,60 +219,131 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error) return svrCfg, isLegacyFormat, nil } +// ClientConfigLoadResult contains the result of loading a client configuration file. +type ClientConfigLoadResult struct { + // Common contains the common client configuration. + Common *v1.ClientCommonConfig + + // Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles. + // These are NOT completed (user prefix not added). + Proxies []v1.ProxyConfigurer + + // Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles. + // These are NOT completed. + Visitors []v1.VisitorConfigurer + + // IsLegacyFormat indicates whether the config file is in legacy INI format. + IsLegacyFormat bool +} + +// LoadClientConfigResult loads and parses a client configuration file. +// It returns the raw configuration without completing proxies/visitors. +// The caller should call Complete on the configs manually for legacy behavior. +func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) { + result := &ClientConfigLoadResult{ + Proxies: make([]v1.ProxyConfigurer, 0), + Visitors: make([]v1.VisitorConfigurer, 0), + } + + if DetectLegacyINIFormatFromFile(path) { + legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path) + if err != nil { + return nil, err + } + result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon) + for _, c := range legacyProxyCfgs { + result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c)) + } + for _, c := range legacyVisitorCfgs { + result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c)) + } + result.IsLegacyFormat = true + } else { + allCfg := v1.ClientConfig{} + if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil { + return nil, err + } + result.Common = &allCfg.ClientCommonConfig + for _, c := range allCfg.Proxies { + result.Proxies = append(result.Proxies, c.ProxyConfigurer) + } + for _, c := range allCfg.Visitors { + result.Visitors = append(result.Visitors, c.VisitorConfigurer) + } + } + + // Load additional config from includes. + // legacy ini format already handle this in ParseClientConfig. + if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat { + extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict) + if err != nil { + return nil, err + } + result.Proxies = append(result.Proxies, extProxyCfgs...) + result.Visitors = append(result.Visitors, extVisitorCfgs...) + } + + // Complete the common config + if result.Common != nil { + if err := result.Common.Complete(); err != nil { + return nil, err + } + } + + return result, nil +} + func LoadClientConfig(path string, strict bool) ( *v1.ClientCommonConfig, []v1.ProxyConfigurer, []v1.VisitorConfigurer, bool, error, ) { - var ( - cliCfg *v1.ClientCommonConfig - proxyCfgs = make([]v1.ProxyConfigurer, 0) - visitorCfgs = make([]v1.VisitorConfigurer, 0) - isLegacyFormat bool - ) - - if DetectLegacyINIFormatFromFile(path) { - legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path) - if err != nil { - return nil, nil, nil, true, err - } - cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon) - for _, c := range legacyProxyCfgs { - proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c)) - } - for _, c := range legacyVisitorCfgs { - visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c)) - } - isLegacyFormat = true - } else { - allCfg := v1.ClientConfig{} - if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil { - return nil, nil, nil, false, err - } - cliCfg = &allCfg.ClientCommonConfig - for _, c := range allCfg.Proxies { - proxyCfgs = append(proxyCfgs, c.ProxyConfigurer) - } - for _, c := range allCfg.Visitors { - visitorCfgs = append(visitorCfgs, c.VisitorConfigurer) - } + result, err := LoadClientConfigResult(path, strict) + if err != nil { + return nil, nil, nil, result != nil && result.IsLegacyFormat, err } - // Load additional config from includes. - // legacy ini format already handle this in ParseClientConfig. - if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat { - extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict) - if err != nil { - return nil, nil, nil, isLegacyFormat, err - } - proxyCfgs = append(proxyCfgs, extProxyCfgs...) - visitorCfgs = append(visitorCfgs, extVisitorCfgs...) + proxyCfgs := result.Proxies + visitorCfgs := result.Visitors + + proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs) + proxyCfgs = CompleteProxyConfigurers(proxyCfgs) + visitorCfgs = CompleteVisitorConfigurers(visitorCfgs) + return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil +} + +func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer { + proxyCfgs := proxies + for _, c := range proxyCfgs { + c.Complete() } + return proxyCfgs +} + +func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer { + visitorCfgs := visitors + for _, c := range visitorCfgs { + c.Complete() + } + return visitorCfgs +} + +func FilterClientConfigurers( + common *v1.ClientCommonConfig, + proxies []v1.ProxyConfigurer, + visitors []v1.VisitorConfigurer, +) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) { + if common == nil { + common = &v1.ClientCommonConfig{} + } + + proxyCfgs := proxies + visitorCfgs := visitors // Filter by start - if len(cliCfg.Start) > 0 { - startSet := sets.New(cliCfg.Start...) + if len(common.Start) > 0 { + startSet := sets.New(common.Start...) proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { return startSet.Has(c.GetBaseConfig().Name) }) @@ -291,19 +362,7 @@ func LoadClientConfig(path string, strict bool) ( enabled := c.GetBaseConfig().Enabled return enabled == nil || *enabled }) - - if cliCfg != nil { - if err := cliCfg.Complete(); err != nil { - return nil, nil, nil, isLegacyFormat, err - } - } - for _, c := range proxyCfgs { - c.Complete(cliCfg.User) - } - for _, c := range visitorCfgs { - c.Complete(cliCfg) - } - return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil + return proxyCfgs, visitorCfgs } func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 95d6101e..2675f636 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -15,6 +15,7 @@ package config import ( + "encoding/json" "fmt" "strings" "testing" @@ -273,6 +274,169 @@ proxies: require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type) } +func TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) { + require := require.New(t) + + enabled := true + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy-raw" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + proxyCfg.Enabled = &enabled + + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor-raw" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server-raw" + visitorCfg.FallbackTo = "fallback-raw" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + visitorCfg.Enabled = &enabled + + common := &v1.ClientCommonConfig{ + User: "alice", + } + + proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg}) + require.Len(proxies, 1) + require.Len(visitors, 1) + + p := proxies[0].GetBaseConfig() + require.Equal("proxy-raw", p.Name) + require.Empty(p.LocalIP) + + v := visitors[0].GetBaseConfig() + require.Equal("visitor-raw", v.Name) + require.Equal("server-raw", v.ServerName) + require.Empty(v.BindAddr) + + xtcp := visitors[0].(*v1.XTCPVisitorConfig) + require.Equal("fallback-raw", xtcp.FallbackTo) + require.Empty(xtcp.Protocol) +} + +func TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) { + require := require.New(t) + + enabled := true + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy-raw" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + proxyCfg.Enabled = &enabled + + proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg}) + require.Len(proxies, 1) + + p := proxies[0].GetBaseConfig() + require.Equal("proxy-raw", p.Name) + require.Equal("127.0.0.1", p.LocalIP) +} + +func TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) { + require := require.New(t) + + enabled := true + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor-raw" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server-raw" + visitorCfg.FallbackTo = "fallback-raw" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + visitorCfg.Enabled = &enabled + + visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg}) + require.Len(visitors, 1) + + v := visitors[0].GetBaseConfig() + require.Equal("visitor-raw", v.Name) + require.Equal("server-raw", v.ServerName) + require.Equal("127.0.0.1", v.BindAddr) + + xtcp := visitors[0].(*v1.XTCPVisitorConfig) + require.Equal("fallback-raw", xtcp.FallbackTo) + require.Equal("quic", xtcp.Protocol) +} + +func TestCompleteProxyConfigurers_Idempotent(t *testing.T) { + require := require.New(t) + + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + + proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg}) + firstProxyJSON, err := json.Marshal(proxies[0]) + require.NoError(err) + + proxies = CompleteProxyConfigurers(proxies) + secondProxyJSON, err := json.Marshal(proxies[0]) + require.NoError(err) + + require.Equal(string(firstProxyJSON), string(secondProxyJSON)) +} + +func TestCompleteVisitorConfigurers_Idempotent(t *testing.T) { + require := require.New(t) + + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + + visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg}) + firstVisitorJSON, err := json.Marshal(visitors[0]) + require.NoError(err) + + visitors = CompleteVisitorConfigurers(visitors) + secondVisitorJSON, err := json.Marshal(visitors[0]) + require.NoError(err) + + require.Equal(string(firstVisitorJSON), string(secondVisitorJSON)) +} + +func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) { + require := require.New(t) + + enabled := true + disabled := false + + proxyKeep := &v1.TCPProxyConfig{} + proxyKeep.Name = "keep" + proxyKeep.Type = "tcp" + proxyKeep.LocalPort = 10080 + proxyKeep.Enabled = &enabled + + proxyDropByStart := &v1.TCPProxyConfig{} + proxyDropByStart.Name = "drop-by-start" + proxyDropByStart.Type = "tcp" + proxyDropByStart.LocalPort = 10081 + proxyDropByStart.Enabled = &enabled + + proxyDropByEnabled := &v1.TCPProxyConfig{} + proxyDropByEnabled.Name = "drop-by-enabled" + proxyDropByEnabled.Type = "tcp" + proxyDropByEnabled.LocalPort = 10082 + proxyDropByEnabled.Enabled = &disabled + + common := &v1.ClientCommonConfig{ + Start: []string{"keep"}, + } + + proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{ + proxyKeep, + proxyDropByStart, + proxyDropByEnabled, + }, nil) + require.Len(visitors, 0) + require.Len(proxies, 1) + require.Equal("keep", proxies[0].GetBaseConfig().Name) +} + // TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types func TestYAMLEdgeCases(t *testing.T) { require := require.New(t) diff --git a/pkg/config/source/aggregator.go b/pkg/config/source/aggregator.go new file mode 100644 index 00000000..73dd0e6f --- /dev/null +++ b/pkg/config/source/aggregator.go @@ -0,0 +1,125 @@ +// 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 source + +import ( + "errors" + "fmt" + "sort" + "sync" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +type sourceEntry struct { + source Source +} + +type Aggregator struct { + mu sync.RWMutex + + configSource *ConfigSource + storeSource *StoreSource +} + +func NewAggregator(configSource *ConfigSource) *Aggregator { + if configSource == nil { + configSource = NewConfigSource() + } + return &Aggregator{ + configSource: configSource, + } +} + +func (a *Aggregator) SetStoreSource(storeSource *StoreSource) { + a.mu.Lock() + defer a.mu.Unlock() + + a.storeSource = storeSource +} + +func (a *Aggregator) ConfigSource() *ConfigSource { + return a.configSource +} + +func (a *Aggregator) StoreSource() *StoreSource { + return a.storeSource +} + +func (a *Aggregator) getSourcesLocked() []sourceEntry { + sources := make([]sourceEntry, 0, 2) + if a.configSource != nil { + sources = append(sources, sourceEntry{ + source: a.configSource, + }) + } + if a.storeSource != nil { + sources = append(sources, sourceEntry{ + source: a.storeSource, + }) + } + return sources +} + +func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { + a.mu.RLock() + entries := a.getSourcesLocked() + a.mu.RUnlock() + + if len(entries) == 0 { + return nil, nil, errors.New("no sources configured") + } + + proxyMap := make(map[string]v1.ProxyConfigurer) + visitorMap := make(map[string]v1.VisitorConfigurer) + + for _, entry := range entries { + proxies, visitors, err := entry.source.Load() + if err != nil { + return nil, nil, fmt.Errorf("load source: %w", err) + } + for _, p := range proxies { + proxyMap[p.GetBaseConfig().Name] = p + } + for _, v := range visitors { + visitorMap[v.GetBaseConfig().Name] = v + } + } + proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap) + return proxies, visitors, nil +} + +func (a *Aggregator) mapsToSortedSlices( + proxyMap map[string]v1.ProxyConfigurer, + visitorMap map[string]v1.VisitorConfigurer, +) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) { + proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap)) + for _, p := range proxyMap { + proxies = append(proxies, p) + } + sort.Slice(proxies, func(i, j int) bool { + return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name + }) + + visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap)) + for _, v := range visitorMap { + visitors = append(visitors, v) + } + sort.Slice(visitors, func(i, j int) bool { + return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name + }) + + return proxies, visitors +} diff --git a/pkg/config/source/aggregator_test.go b/pkg/config/source/aggregator_test.go new file mode 100644 index 00000000..bebe3ae9 --- /dev/null +++ b/pkg/config/source/aggregator_test.go @@ -0,0 +1,217 @@ +// 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 source + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +// mockProxy creates a TCP proxy config for testing +func mockProxy(name string) v1.ProxyConfigurer { + cfg := &v1.TCPProxyConfig{} + cfg.Name = name + cfg.Type = "tcp" + cfg.LocalPort = 8080 + cfg.RemotePort = 9090 + return cfg +} + +// mockVisitor creates a STCP visitor config for testing +func mockVisitor(name string) v1.VisitorConfigurer { + cfg := &v1.STCPVisitorConfig{} + cfg.Name = name + cfg.Type = "stcp" + cfg.ServerName = "test-server" + return cfg +} + +func newTestStoreSource(t *testing.T) *StoreSource { + t.Helper() + + path := filepath.Join(t.TempDir(), "store.json") + storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) + require.NoError(t, err) + return storeSource +} + +func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator { + t.Helper() + + configSource := NewConfigSource() + agg := NewAggregator(configSource) + if storeSource != nil { + agg.SetStoreSource(storeSource) + } + return agg +} + +func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) { + require := require.New(t) + + agg := NewAggregator(nil) + require.NotNil(agg) + require.NotNil(agg.ConfigSource()) + require.Nil(agg.StoreSource()) +} + +func TestNewAggregator_WithoutStore(t *testing.T) { + require := require.New(t) + + configSource := NewConfigSource() + agg := NewAggregator(configSource) + require.NotNil(agg) + require.Same(configSource, agg.ConfigSource()) + require.Nil(agg.StoreSource()) +} + +func TestNewAggregator_WithStore(t *testing.T) { + require := require.New(t) + + storeSource := newTestStoreSource(t) + configSource := NewConfigSource() + agg := NewAggregator(configSource) + agg.SetStoreSource(storeSource) + + require.Same(configSource, agg.ConfigSource()) + require.Same(storeSource, agg.StoreSource()) +} + +func TestAggregator_SetStoreSource_Overwrite(t *testing.T) { + require := require.New(t) + + agg := newTestAggregator(t, nil) + first := newTestStoreSource(t) + second := newTestStoreSource(t) + + agg.SetStoreSource(first) + require.Same(first, agg.StoreSource()) + + agg.SetStoreSource(second) + require.Same(second, agg.StoreSource()) + + agg.SetStoreSource(nil) + require.Nil(agg.StoreSource()) +} + +func TestAggregator_MergeBySourceOrder(t *testing.T) { + require := require.New(t) + + storeSource := newTestStoreSource(t) + agg := newTestAggregator(t, storeSource) + + configSource := agg.ConfigSource() + + configShared := mockProxy("shared").(*v1.TCPProxyConfig) + configShared.LocalPort = 1111 + configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig) + configOnly.LocalPort = 1112 + + err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil) + require.NoError(err) + + storeShared := mockProxy("shared").(*v1.TCPProxyConfig) + storeShared.LocalPort = 2222 + storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig) + storeOnly.LocalPort = 2223 + err = storeSource.AddProxy(storeShared) + require.NoError(err) + err = storeSource.AddProxy(storeOnly) + require.NoError(err) + + proxies, visitors, err := agg.Load() + require.NoError(err) + require.Len(visitors, 0) + require.Len(proxies, 3) + + var sharedProxy *v1.TCPProxyConfig + for _, p := range proxies { + if p.GetBaseConfig().Name == "shared" { + sharedProxy = p.(*v1.TCPProxyConfig) + break + } + } + require.NotNil(sharedProxy) + require.Equal(2222, sharedProxy.LocalPort) +} + +func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) { + require := require.New(t) + + storeSource := newTestStoreSource(t) + agg := newTestAggregator(t, storeSource) + configSource := agg.ConfigSource() + + lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig) + lowProxy.LocalPort = 1111 + err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil) + require.NoError(err) + + disabled := false + highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig) + highProxy.LocalPort = 2222 + highProxy.Enabled = &disabled + err = storeSource.AddProxy(highProxy) + require.NoError(err) + + proxies, visitors, err := agg.Load() + require.NoError(err) + require.Len(proxies, 1) + require.Len(visitors, 0) + + proxy := proxies[0].(*v1.TCPProxyConfig) + require.Equal("shared-proxy", proxy.Name) + require.Equal(1111, proxy.LocalPort) +} + +func TestAggregator_VisitorMerge(t *testing.T) { + require := require.New(t) + + storeSource := newTestStoreSource(t) + agg := newTestAggregator(t, storeSource) + + err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")}) + require.NoError(err) + err = storeSource.AddVisitor(mockVisitor("visitor2")) + require.NoError(err) + + _, visitors, err := agg.Load() + require.NoError(err) + require.Len(visitors, 2) +} + +func TestAggregator_Load_ReturnsSharedReferences(t *testing.T) { + require := require.New(t) + + agg := newTestAggregator(t, nil) + err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil) + require.NoError(err) + + proxies, _, err := agg.Load() + require.NoError(err) + require.Len(proxies, 1) + require.Equal("ssh", proxies[0].GetBaseConfig().Name) + + proxies[0].GetBaseConfig().Name = "alice.ssh" + + proxies2, _, err := agg.Load() + require.NoError(err) + require.Len(proxies2, 1) + require.Equal("alice.ssh", proxies2[0].GetBaseConfig().Name) +} diff --git a/pkg/config/source/base_source.go b/pkg/config/source/base_source.go new file mode 100644 index 00000000..1fea02f6 --- /dev/null +++ b/pkg/config/source/base_source.go @@ -0,0 +1,65 @@ +// 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 source + +import ( + "sync" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +// baseSource provides shared state and behavior for Source implementations. +// It manages proxy/visitor storage. +// Concrete types (ConfigSource, StoreSource) embed this struct. +type baseSource struct { + mu sync.RWMutex + + proxies map[string]v1.ProxyConfigurer + visitors map[string]v1.VisitorConfigurer +} + +func newBaseSource() baseSource { + return baseSource{ + proxies: make(map[string]v1.ProxyConfigurer), + visitors: make(map[string]v1.VisitorConfigurer), + } +} + +// Load returns all enabled proxy and visitor configurations. +// Configurations with Enabled explicitly set to false are filtered out. +func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies)) + for _, p := range s.proxies { + // Filter out disabled proxies (nil or true means enabled) + if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled { + continue + } + proxies = append(proxies, p) + } + + visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors)) + for _, v := range s.visitors { + // Filter out disabled visitors (nil or true means enabled) + if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled { + continue + } + visitors = append(visitors, v) + } + + return proxies, visitors, nil +} diff --git a/pkg/config/source/config_source.go b/pkg/config/source/config_source.go new file mode 100644 index 00000000..ea8a2af6 --- /dev/null +++ b/pkg/config/source/config_source.go @@ -0,0 +1,65 @@ +// 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 source + +import ( + "fmt" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +// ConfigSource implements Source for in-memory configuration. +// All operations are thread-safe. +type ConfigSource struct { + baseSource +} + +func NewConfigSource() *ConfigSource { + return &ConfigSource{ + baseSource: newBaseSource(), + } +} + +// ReplaceAll replaces all proxy and visitor configurations atomically. +func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error { + s.mu.Lock() + defer s.mu.Unlock() + + nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies)) + for _, p := range proxies { + if p == nil { + return fmt.Errorf("proxy cannot be nil") + } + name := p.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("proxy name cannot be empty") + } + nextProxies[name] = p + } + nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors)) + for _, v := range visitors { + if v == nil { + return fmt.Errorf("visitor cannot be nil") + } + name := v.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("visitor name cannot be empty") + } + nextVisitors[name] = v + } + s.proxies = nextProxies + s.visitors = nextVisitors + return nil +} diff --git a/pkg/config/source/config_source_test.go b/pkg/config/source/config_source_test.go new file mode 100644 index 00000000..793284e1 --- /dev/null +++ b/pkg/config/source/config_source_test.go @@ -0,0 +1,173 @@ +// 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 source + +import ( + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func TestNewConfigSource(t *testing.T) { + require := require.New(t) + + src := NewConfigSource() + require.NotNil(src) +} + +func TestConfigSource_ReplaceAll(t *testing.T) { + require := require.New(t) + + src := NewConfigSource() + + err := src.ReplaceAll( + []v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")}, + []v1.VisitorConfigurer{mockVisitor("visitor1")}, + ) + require.NoError(err) + + proxies, visitors, err := src.Load() + require.NoError(err) + require.Len(proxies, 2) + require.Len(visitors, 1) + + // ReplaceAll again should replace everything + err = src.ReplaceAll( + []v1.ProxyConfigurer{mockProxy("proxy3")}, + nil, + ) + require.NoError(err) + + proxies, visitors, err = src.Load() + require.NoError(err) + require.Len(proxies, 1) + require.Len(visitors, 0) + require.Equal("proxy3", proxies[0].GetBaseConfig().Name) + + // ReplaceAll with nil proxy should fail + err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil) + require.Error(err) + + // ReplaceAll with empty name proxy should fail + err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil) + require.Error(err) +} + +func TestConfigSource_Load(t *testing.T) { + require := require.New(t) + + src := NewConfigSource() + + err := src.ReplaceAll( + []v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")}, + []v1.VisitorConfigurer{mockVisitor("visitor1")}, + ) + require.NoError(err) + + proxies, visitors, err := src.Load() + require.NoError(err) + require.Len(proxies, 2) + require.Len(visitors, 1) +} + +// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out +// proxies and visitors with Enabled explicitly set to false. +func TestConfigSource_Load_FiltersDisabled(t *testing.T) { + require := require.New(t) + + src := NewConfigSource() + + disabled := false + enabled := true + + // Create enabled proxy (nil Enabled = enabled by default) + enabledProxy := mockProxy("enabled-proxy") + + // Create disabled proxy + disabledProxy := &v1.TCPProxyConfig{} + disabledProxy.Name = "disabled-proxy" + disabledProxy.Type = "tcp" + disabledProxy.Enabled = &disabled + + // Create explicitly enabled proxy + explicitEnabledProxy := &v1.TCPProxyConfig{} + explicitEnabledProxy.Name = "explicit-enabled-proxy" + explicitEnabledProxy.Type = "tcp" + explicitEnabledProxy.Enabled = &enabled + + // Create enabled visitor (nil Enabled = enabled by default) + enabledVisitor := mockVisitor("enabled-visitor") + + // Create disabled visitor + disabledVisitor := &v1.STCPVisitorConfig{} + disabledVisitor.Name = "disabled-visitor" + disabledVisitor.Type = "stcp" + disabledVisitor.Enabled = &disabled + + err := src.ReplaceAll( + []v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy}, + []v1.VisitorConfigurer{enabledVisitor, disabledVisitor}, + ) + require.NoError(err) + + // Load should filter out disabled configs + proxies, visitors, err := src.Load() + require.NoError(err) + require.Len(proxies, 2, "Should have 2 enabled proxies") + require.Len(visitors, 1, "Should have 1 enabled visitor") + + // Verify the correct proxies are returned + proxyNames := make([]string, 0, len(proxies)) + for _, p := range proxies { + proxyNames = append(proxyNames, p.GetBaseConfig().Name) + } + require.Contains(proxyNames, "enabled-proxy") + require.Contains(proxyNames, "explicit-enabled-proxy") + require.NotContains(proxyNames, "disabled-proxy") + + // Verify the correct visitor is returned + require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name) +} + +func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) { + require := require.New(t) + + src := NewConfigSource() + + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy1" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor1" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server1" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + + err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg}) + require.NoError(err) + + proxies, visitors, err := src.Load() + require.NoError(err) + require.Len(proxies, 1) + require.Len(visitors, 1) + require.Empty(proxies[0].GetBaseConfig().LocalIP) + require.Empty(visitors[0].GetBaseConfig().BindAddr) + require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol) +} diff --git a/pkg/config/source/source.go b/pkg/config/source/source.go new file mode 100644 index 00000000..a6cff226 --- /dev/null +++ b/pkg/config/source/source.go @@ -0,0 +1,37 @@ +// 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 source + +import ( + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +// Source is the interface for configuration sources. +// A Source provides proxy and visitor configurations from various backends. +// Aggregator currently uses the built-in config source as base and an optional +// store source as higher-priority overlay. +type Source interface { + // Load loads the proxy and visitor configurations from this source. + // Returns the loaded configurations and any error encountered. + // A disabled entry in one source is source-local filtering, not a cross-source + // tombstone for entries from lower-priority sources. + // + // Error handling contract with Aggregator: + // - When err is nil, returned slices are consumed. + // - When err is non-nil, Aggregator aborts the merge and returns the error. + // - To publish best-effort or partial results, return those results with + // err set to nil. + Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error) +} diff --git a/pkg/config/source/store.go b/pkg/config/source/store.go new file mode 100644 index 00000000..cb94fb31 --- /dev/null +++ b/pkg/config/source/store.go @@ -0,0 +1,351 @@ +// 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 source + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +type StoreSourceConfig struct { + Path string `json:"path"` +} + +type storeData struct { + Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"` + Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"` +} + +type StoreSource struct { + baseSource + config StoreSourceConfig +} + +func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) { + if cfg.Path == "" { + return nil, fmt.Errorf("path is required") + } + + s := &StoreSource{ + baseSource: newBaseSource(), + config: cfg, + } + + if err := s.loadFromFile(); err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load existing data: %w", err) + } + } + + return s, nil +} + +func (s *StoreSource) loadFromFile() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.loadFromFileUnlocked() +} + +func (s *StoreSource) loadFromFileUnlocked() error { + data, err := os.ReadFile(s.config.Path) + if err != nil { + return err + } + + var stored storeData + if err := json.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 _, 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 + } + } + + return nil +} + +func (s *StoreSource) saveToFileUnlocked() error { + stored := storeData{ + Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)), + Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)), + } + + for _, p := range s.proxies { + stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p}) + } + for _, v := range s.visitors { + stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v}) + } + + data, err := json.MarshalIndent(stored, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + dir := filepath.Dir(s.config.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + tmpPath := s.config.Path + ".tmp" + + f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := f.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tmpPath, s.config.Path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} + +func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error { + if proxy == nil { + return fmt.Errorf("proxy cannot be nil") + } + + name := proxy.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("proxy name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.proxies[name]; exists { + return fmt.Errorf("proxy %q already exists", name) + } + + s.proxies[name] = proxy + + if err := s.saveToFileUnlocked(); err != nil { + delete(s.proxies, name) + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error { + if proxy == nil { + return fmt.Errorf("proxy cannot be nil") + } + + name := proxy.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("proxy name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + oldProxy, exists := s.proxies[name] + if !exists { + return fmt.Errorf("proxy %q does not exist", name) + } + + s.proxies[name] = proxy + + if err := s.saveToFileUnlocked(); err != nil { + s.proxies[name] = oldProxy + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) RemoveProxy(name string) error { + if name == "" { + return fmt.Errorf("proxy name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + oldProxy, exists := s.proxies[name] + if !exists { + return fmt.Errorf("proxy %q does not exist", name) + } + + delete(s.proxies, name) + + if err := s.saveToFileUnlocked(); err != nil { + s.proxies[name] = oldProxy + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer { + s.mu.RLock() + defer s.mu.RUnlock() + + p, exists := s.proxies[name] + if !exists { + return nil + } + return p +} + +func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error { + if visitor == nil { + return fmt.Errorf("visitor cannot be nil") + } + + name := visitor.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("visitor name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.visitors[name]; exists { + return fmt.Errorf("visitor %q already exists", name) + } + + s.visitors[name] = visitor + + if err := s.saveToFileUnlocked(); err != nil { + delete(s.visitors, name) + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error { + if visitor == nil { + return fmt.Errorf("visitor cannot be nil") + } + + name := visitor.GetBaseConfig().Name + if name == "" { + return fmt.Errorf("visitor name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + oldVisitor, exists := s.visitors[name] + if !exists { + return fmt.Errorf("visitor %q does not exist", name) + } + + s.visitors[name] = visitor + + if err := s.saveToFileUnlocked(); err != nil { + s.visitors[name] = oldVisitor + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) RemoveVisitor(name string) error { + if name == "" { + return fmt.Errorf("visitor name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + oldVisitor, exists := s.visitors[name] + if !exists { + return fmt.Errorf("visitor %q does not exist", name) + } + + delete(s.visitors, name) + + if err := s.saveToFileUnlocked(); err != nil { + s.visitors[name] = oldVisitor + return fmt.Errorf("failed to persist: %w", err) + } + return nil +} + +func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer { + s.mu.RLock() + defer s.mu.RUnlock() + + v, exists := s.visitors[name] + if !exists { + return nil + } + return v +} + +func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]v1.ProxyConfigurer, 0, len(s.proxies)) + for _, p := range s.proxies { + result = append(result, p) + } + return result, nil +} + +func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]v1.VisitorConfigurer, 0, len(s.visitors)) + for _, v := range s.visitors { + result = append(result, v) + } + return result, nil +} diff --git a/pkg/config/source/store_test.go b/pkg/config/source/store_test.go new file mode 100644 index 00000000..fdfef3bd --- /dev/null +++ b/pkg/config/source/store_test.go @@ -0,0 +1,99 @@ +// 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 source + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) { + require := require.New(t) + + path := filepath.Join(t.TempDir(), "store.json") + storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) + require.NoError(err) + + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy1" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor1" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server1" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + + err = storeSource.AddProxy(proxyCfg) + require.NoError(err) + err = storeSource.AddVisitor(visitorCfg) + require.NoError(err) + + gotProxy := storeSource.GetProxy("proxy1") + require.NotNil(gotProxy) + require.Empty(gotProxy.GetBaseConfig().LocalIP) + + gotVisitor := storeSource.GetVisitor("visitor1") + require.NotNil(gotVisitor) + require.Empty(gotVisitor.GetBaseConfig().BindAddr) + require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) +} + +func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) { + require := require.New(t) + + path := filepath.Join(t.TempDir(), "store.json") + + proxyCfg := &v1.TCPProxyConfig{} + proxyCfg.Name = "proxy1" + proxyCfg.Type = "tcp" + proxyCfg.LocalPort = 10080 + + visitorCfg := &v1.XTCPVisitorConfig{} + visitorCfg.Name = "visitor1" + visitorCfg.Type = "xtcp" + visitorCfg.ServerName = "server1" + visitorCfg.SecretKey = "secret" + visitorCfg.BindPort = 10081 + + stored := storeData{ + Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}}, + Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}}, + } + data, err := json.Marshal(stored) + require.NoError(err) + err = os.WriteFile(path, data, 0o600) + require.NoError(err) + + storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) + require.NoError(err) + + gotProxy := storeSource.GetProxy("proxy1") + require.NotNil(gotProxy) + require.Empty(gotProxy.GetBaseConfig().LocalIP) + + gotVisitor := storeSource.GetVisitor("visitor1") + require.NotNil(gotVisitor) + require.Empty(gotVisitor.GetBaseConfig().BindAddr) + require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) +} diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index bb95b6cd..783eee8e 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -77,6 +77,9 @@ type ClientCommonConfig struct { // Include other config files for proxies. IncludeConfigFiles []string `json:"includes,omitempty"` + + // Store config enables the built-in store source (not configurable via sources list). + Store StoreConfig `json:"store,omitempty"` } func (c *ClientCommonConfig) Complete() error { diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index 1bbe5ac3..8ddd1cd8 100644 --- a/pkg/config/v1/proxy.go +++ b/pkg/config/v1/proxy.go @@ -21,8 +21,6 @@ import ( "fmt" "reflect" - "github.com/samber/lo" - "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/util" @@ -126,8 +124,7 @@ func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig { return c } -func (c *ProxyBaseConfig) Complete(namePrefix string) { - c.Name = lo.Ternary(namePrefix == "", "", namePrefix+".") + c.Name +func (c *ProxyBaseConfig) Complete() { c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1") c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient) @@ -207,7 +204,7 @@ func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) { } type ProxyConfigurer interface { - Complete(namePrefix string) + Complete() GetBaseConfig() *ProxyBaseConfig // MarshalToMsg marshals this config into a msg.NewProxy message. This // function will be called on the frpc side. diff --git a/pkg/config/v1/store.go b/pkg/config/v1/store.go new file mode 100644 index 00000000..7f992c3b --- /dev/null +++ b/pkg/config/v1/store.go @@ -0,0 +1,26 @@ +// 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 + +// StoreConfig configures the built-in store source. +type StoreConfig struct { + // Path is the store file path. + Path string `json:"path,omitempty"` +} + +// IsEnabled returns true if the store is configured with a valid path. +func (c *StoreConfig) IsEnabled() bool { + return c.Path != "" +} diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index 5017f57d..01dc8f1b 100644 --- a/pkg/config/v1/visitor.go +++ b/pkg/config/v1/visitor.go @@ -21,8 +21,6 @@ import ( "fmt" "reflect" - "github.com/samber/lo" - "github.com/fatedier/frp/pkg/util/util" ) @@ -56,26 +54,14 @@ func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig { return c } -func (c *VisitorBaseConfig) Complete(g *ClientCommonConfig) { +func (c *VisitorBaseConfig) Complete() { if c.BindAddr == "" { c.BindAddr = "127.0.0.1" } - - namePrefix := "" - if g.User != "" { - namePrefix = g.User + "." - } - c.Name = namePrefix + c.Name - - if c.ServerUser != "" { - c.ServerName = c.ServerUser + "." + c.ServerName - } else { - c.ServerName = namePrefix + c.ServerName - } } type VisitorConfigurer interface { - Complete(*ClientCommonConfig) + Complete() GetBaseConfig() *VisitorBaseConfig } @@ -168,15 +154,11 @@ type XTCPVisitorConfig struct { NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"` } -func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) { - c.VisitorBaseConfig.Complete(g) +func (c *XTCPVisitorConfig) Complete() { + c.VisitorBaseConfig.Complete() c.Protocol = util.EmptyOr(c.Protocol, "quic") c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8) c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90) c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000) - - if c.FallbackTo != "" { - c.FallbackTo = lo.Ternary(g.User == "", "", g.User+".") + c.FallbackTo - } } diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go index 378c6098..bffc40bc 100644 --- a/pkg/ssh/server.go +++ b/pkg/ssh/server.go @@ -112,7 +112,7 @@ func (s *TunnelServer) Run() error { if sshConn.Permissions != nil { clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User) } - pc.Complete(clientCfg.User) + pc.Complete() vc, err := virtual.NewClient(virtual.ClientOptions{ Common: clientCfg, diff --git a/pkg/util/util/names.go b/pkg/util/util/names.go new file mode 100644 index 00000000..e41bbac9 --- /dev/null +++ b/pkg/util/util/names.go @@ -0,0 +1,33 @@ +package util + +import "strings" + +// AddUserPrefix builds the wire-level proxy name for frps by prefixing user. +func AddUserPrefix(user, name string) string { + if user == "" { + return name + } + return user + "." + name +} + +// StripUserPrefix converts a wire-level proxy name to an internal raw name. +// It strips only one exact "{user}." prefix. +func StripUserPrefix(user, name string) string { + if user == "" { + return name + } + prefix := user + "." + if strings.HasPrefix(name, prefix) { + return strings.TrimPrefix(name, prefix) + } + return name +} + +// BuildTargetServerProxyName resolves visitor target proxy name for wire-level +// protocol messages. serverUser overrides local user when set. +func BuildTargetServerProxyName(localUser, serverUser, serverName string) string { + if serverUser != "" { + return AddUserPrefix(serverUser, serverName) + } + return AddUserPrefix(localUser, serverName) +} diff --git a/pkg/util/util/util_test.go b/pkg/util/util/util_test.go index 0a63ba6d..53786374 100644 --- a/pkg/util/util/util_test.go +++ b/pkg/util/util/util_test.go @@ -41,3 +41,23 @@ func TestParseRangeNumbers(t *testing.T) { _, err = ParseRangeNumbers("3-a") require.Error(err) } + +func TestAddUserPrefix(t *testing.T) { + require := require.New(t) + require.Equal("test", AddUserPrefix("", "test")) + require.Equal("alice.test", AddUserPrefix("alice", "test")) +} + +func TestStripUserPrefix(t *testing.T) { + require := require.New(t) + require.Equal("test", StripUserPrefix("", "test")) + require.Equal("test", StripUserPrefix("alice", "alice.test")) + require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test")) + require.Equal("bob.test", StripUserPrefix("alice", "bob.test")) +} + +func TestBuildTargetServerProxyName(t *testing.T) { + require := require.New(t) + require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test")) + require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test")) +} diff --git a/pkg/virtual/client.go b/pkg/virtual/client.go index 8fec28c8..c7006ce7 100644 --- a/pkg/virtual/client.go +++ b/pkg/virtual/client.go @@ -19,6 +19,7 @@ import ( "net" "github.com/fatedier/frp/client" + "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" netpkg "github.com/fatedier/frp/pkg/util/net" @@ -43,10 +44,13 @@ func NewClient(options ClientOptions) (*Client, error) { } ln := netpkg.NewInternalListener() + configSource := source.NewConfigSource() + aggregator := source.NewAggregator(configSource) serviceOptions := client.ServiceOptions{ - Common: options.Common, - ClientSpec: options.Spec, + Common: options.Common, + ConfigSourceAggregator: aggregator, + ClientSpec: options.Spec, ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector { return &pipeConnector{ peerListener: ln, diff --git a/server/api/controller.go b/server/api/controller.go index 861316c4..f691fcad 100644 --- a/server/api/controller.go +++ b/server/api/controller.go @@ -236,9 +236,6 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) { return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error") } proxyInfo.Status = "online" - c.fillProxyClientInfo(&proxyClientInfo{ - clientVersion: &proxyInfo.ClientVersion, - }, pxy) } else { proxyInfo.Status = "offline" } @@ -277,9 +274,6 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS continue } proxyInfo.Status = "online" - c.fillProxyClientInfo(&proxyClientInfo{ - clientVersion: &proxyInfo.ClientVersion, - }, pxy) } else { proxyInfo.Status = "offline" } @@ -339,6 +333,7 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { User: info.User, ClientID: info.ClientID(), RunID: info.RunID, + Version: info.Version, Hostname: info.Hostname, ClientIP: info.IP, FirstConnectedAt: toUnix(info.FirstConnectedAt), @@ -351,37 +346,6 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { return resp } -type proxyClientInfo struct { - user *string - clientID *string - clientVersion *string -} - -func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) { - loginMsg := pxy.GetLoginMsg() - if loginMsg == nil { - return - } - if proxyInfo.user != nil { - *proxyInfo.user = loginMsg.User - } - if proxyInfo.clientVersion != nil { - *proxyInfo.clientVersion = loginMsg.Version - } - if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok { - if proxyInfo.clientID != nil { - *proxyInfo.clientID = info.ClientID() - } - return - } - if proxyInfo.clientID != nil { - *proxyInfo.clientID = loginMsg.ClientID - if *proxyInfo.clientID == "" { - *proxyInfo.clientID = loginMsg.RunID - } - } -} - func toUnix(t time.Time) int64 { if t.IsZero() { return 0 diff --git a/server/api/types.go b/server/api/types.go index b91422be..0a72af1e 100644 --- a/server/api/types.go +++ b/server/api/types.go @@ -45,6 +45,7 @@ type ClientInfoResp struct { User string `json:"user"` ClientID string `json:"clientID"` RunID string `json:"runID"` + Version string `json:"version,omitempty"` Hostname string `json:"hostname"` ClientIP string `json:"clientIP,omitempty"` FirstConnectedAt int64 `json:"firstConnectedAt"` @@ -100,7 +101,6 @@ type ProxyStatsInfo struct { Conf any `json:"conf"` User string `json:"user,omitempty"` ClientID string `json:"clientID,omitempty"` - ClientVersion string `json:"clientVersion,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` CurConns int64 `json:"curConns"` @@ -119,7 +119,6 @@ type GetProxyStatsResp struct { Conf any `json:"conf"` User string `json:"user,omitempty"` ClientID string `json:"clientID,omitempty"` - ClientVersion string `json:"clientVersion,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` CurConns int64 `json:"curConns"` diff --git a/server/registry/registry.go b/server/registry/registry.go index 24751bf6..01c44947 100644 --- a/server/registry/registry.go +++ b/server/registry/registry.go @@ -28,6 +28,7 @@ type ClientInfo struct { RunID string Hostname string IP string + Version string FirstConnectedAt time.Time LastConnectedAt time.Time DisconnectedAt time.Time @@ -50,7 +51,7 @@ func NewClientRegistry() *ClientRegistry { } // Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client. -func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) { +func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) { if runID == "" { return "", false } @@ -86,6 +87,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd info.RunID = runID info.Hostname = hostname info.IP = remoteAddr + info.Version = version if info.FirstConnectedAt.IsZero() { info.FirstConnectedAt = now } @@ -151,22 +153,6 @@ func (info ClientInfo) ClientID() string { return info.RunID } -// GetByRunID retrieves a client by its run ID. -func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) { - cr.mu.RLock() - defer cr.mu.RUnlock() - - key, ok := cr.runIndex[runID] - if !ok { - return ClientInfo{}, false - } - info, ok := cr.clients[key] - if !ok { - return ClientInfo{}, false - } - return *info, true -} - func (cr *ClientRegistry) composeClientKey(user, id string) string { switch { case user == "": diff --git a/server/service.go b/server/service.go index 6106dad6..62f9ac8e 100644 --- a/server/service.go +++ b/server/service.go @@ -622,7 +622,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter if host, _, err := net.SplitHostPort(remoteAddr); err == nil { remoteAddr = host } - _, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr) + _, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr) if conflict { svr.ctlManager.Del(loginMsg.RunID, ctl) ctl.Close() diff --git a/test/e2e/v1/features/store.go b/test/e2e/v1/features/store.go new file mode 100644 index 00000000..6d56819f --- /dev/null +++ b/test/e2e/v1/features/store.go @@ -0,0 +1,230 @@ +package features + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/onsi/ginkgo/v2" + + "github.com/fatedier/frp/test/e2e/framework" + "github.com/fatedier/frp/test/e2e/framework/consts" + "github.com/fatedier/frp/test/e2e/pkg/request" +) + +var _ = ginkgo.Describe("[Feature: Store]", func() { + f := framework.NewDefaultFramework() + + ginkgo.Describe("Store API", func() { + ginkgo.It("create proxy via API and verify connection", 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) + + proxyConfig := map[string]any{ + "name": "test-tcp", + "type": "tcp", + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + } + proxyBody, _ := json.Marshal(proxyConfig) + + 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(proxyBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(time.Second) + + framework.NewRequestExpect(f).Port(remotePort).Ensure() + }) + + ginkgo.It("update proxy via API", func() { + adminPort := f.AllocPort() + remotePort1 := f.AllocPort() + remotePort2 := 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) + + proxyConfig := map[string]any{ + "name": "test-tcp", + "type": "tcp", + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort1, + } + proxyBody, _ := json.Marshal(proxyConfig) + + 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(proxyBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(remotePort1).Ensure() + + proxyConfig["remotePort"] = remotePort2 + proxyBody, _ = json.Marshal(proxyConfig) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("PUT", "", "/api/store/proxies/test-tcp", map[string]string{ + "Content-Type": "application/json", + }).Body(proxyBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(remotePort2).Ensure() + framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true) + }) + + ginkgo.It("delete proxy via API", 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) + + proxyConfig := map[string]any{ + "name": "test-tcp", + "type": "tcp", + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + } + proxyBody, _ := json.Marshal(proxyConfig) + + 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(proxyBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(remotePort).Ensure() + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("DELETE", "", "/api/store/proxies/test-tcp", nil) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(remotePort).ExpectError(true) + }) + + ginkgo.It("list and get proxy via API", 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) + + proxyConfig := map[string]any{ + "name": "test-tcp", + "type": "tcp", + "localIP": "127.0.0.1", + "localPort": f.PortByName(framework.TCPEchoServerPort), + "remotePort": remotePort, + } + proxyBody, _ := json.Marshal(proxyConfig) + + 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(proxyBody) + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 + }) + + time.Sleep(500 * time.Millisecond) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp") + }) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp") + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp") + }) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/nonexistent") + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 404 + }) + }) + + ginkgo.It("store disabled returns 404", func() { + adminPort := f.AllocPort() + + serverConf := consts.DefaultServerConfig + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + webServer.addr = "127.0.0.1" + webServer.port = %d + `, adminPort) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + time.Sleep(500 * time.Millisecond) + + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") + }).Ensure(func(resp *request.Response) bool { + return resp.Code == 404 + }) + }) + }) +}) diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..ef3397df --- /dev/null +++ b/todo.md @@ -0,0 +1,14 @@ +# TODO + +## Frontend + +- [ ] Disabled proxy 在前端不显示的问题 + - 当前行为:`enabled: false` 的代理在 `pkg/config/load.go` 中被过滤,不会加载到 proxy manager,前端无法看到 + - 需要考虑:是否应该在前端显示 disabled 的代理(以灰色或其他方式标识),并允许用户启用/禁用 + +- [ ] Store proxy 删除后前端列表没有及时刷新 + - 原因:`RemoveProxy` 通过 `notifyChangeUnlocked()` 异步通知变更,前端立即调用 `fetchData()` 时 proxy manager 可能还没处理完 + - 可能的解决方案: + 1. 后端删除 API 等待 proxy manager 更新完成后再返回 + 2. 前端乐观更新,先从列表移除再后台刷新 + 3. 前端适当延迟后再刷新(不优雅) diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts index f9d4522a..10f5397c 100644 --- a/web/frpc/components.d.ts +++ b/web/frpc/components.d.ts @@ -10,13 +10,23 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] - ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] + KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/web/frpc/src/App.vue b/web/frpc/src/App.vue index 2bfded92..f2bddf3e 100644 --- a/web/frpc/src/App.vue +++ b/web/frpc/src/App.vue @@ -65,6 +65,10 @@ const isDark = useDark() const currentRouteName = computed(() => { if (route.path === '/') return 'Overview' if (route.path === '/configure') return 'Configure' + if (route.path === '/proxies/create') return 'Create Proxy' + if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit')) return 'Edit Proxy' + if (route.path === '/visitors/create') return 'Create Visitor' + if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit')) return 'Edit Visitor' return '' }) diff --git a/web/frpc/src/api/frpc.ts b/web/frpc/src/api/frpc.ts index 63aeeb31..bacfdcdc 100644 --- a/web/frpc/src/api/frpc.ts +++ b/web/frpc/src/api/frpc.ts @@ -1,5 +1,11 @@ import { http } from './http' -import type { StatusResponse } from '../types/proxy' +import type { + StatusResponse, + StoreProxyListResp, + StoreProxyConfig, + StoreVisitorListResp, + StoreVisitorConfig, +} from '../types/proxy' export const getStatus = () => { return http.get('/api/status') @@ -16,3 +22,58 @@ export const putConfig = (content: string) => { export const reloadConfig = () => { return http.get('/api/reload') } + +// Store API - Proxies +export const listStoreProxies = () => { + return http.get('/api/store/proxies') +} + +export const getStoreProxy = (name: string) => { + return http.get( + `/api/store/proxies/${encodeURIComponent(name)}`, + ) +} + +export const createStoreProxy = (config: Record) => { + return http.post('/api/store/proxies', config) +} + +export const updateStoreProxy = (name: string, config: Record) => { + return http.put( + `/api/store/proxies/${encodeURIComponent(name)}`, + config, + ) +} + +export const deleteStoreProxy = (name: string) => { + return http.delete(`/api/store/proxies/${encodeURIComponent(name)}`) +} + +// Store API - Visitors +export const listStoreVisitors = () => { + return http.get('/api/store/visitors') +} + +export const getStoreVisitor = (name: string) => { + return http.get( + `/api/store/visitors/${encodeURIComponent(name)}`, + ) +} + +export const createStoreVisitor = (config: Record) => { + return http.post('/api/store/visitors', config) +} + +export const updateStoreVisitor = ( + name: string, + config: Record, +) => { + return http.put( + `/api/store/visitors/${encodeURIComponent(name)}`, + config, + ) +} + +export const deleteStoreVisitor = (name: string) => { + return http.delete(`/api/store/visitors/${encodeURIComponent(name)}`) +} diff --git a/web/frpc/src/components/KeyValueEditor.vue b/web/frpc/src/components/KeyValueEditor.vue new file mode 100644 index 00000000..7b478e64 --- /dev/null +++ b/web/frpc/src/components/KeyValueEditor.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/web/frpc/src/components/ProxyCard.vue b/web/frpc/src/components/ProxyCard.vue index 70246f8c..2f027df4 100644 --- a/web/frpc/src/components/ProxyCard.vue +++ b/web/frpc/src/components/ProxyCard.vue @@ -1,23 +1,46 @@ @@ -68,10 +102,46 @@ v-for="proxy in filteredStatus" :key="proxy.name" :proxy="proxy" + @edit="handleEdit" + @delete="handleDelete" />
- +
+
+ + + + + + +
+

No proxies configured

+

+ Add proxies in your configuration file or use Store to create + dynamic proxies +

+ + Create First Proxy + +
@@ -90,7 +160,9 @@ v-for="(count, type) in proxyTypeCounts" :key="type" class="proxy-type-item" + :class="{ active: filterType === type }" v-show="count > 0" + @click="toggleTypeFilter(String(type))" >
{{ String(type).toUpperCase() }} @@ -125,24 +197,139 @@
+ + + + +
+ + +
+
+ + + + + + +
+
+
+
+ {{ visitor.name }} + {{ visitor.type.toUpperCase() }} +
+
+ + Edit + + + Delete + +
+
+
+ + Server: {{ visitor.config.serverName }} + + + Bind: {{ visitor.config.bindAddr || '127.0.0.1' }}:{{ visitor.config.bindPort }} + +
+
+
+
+
+
+ @@ -222,19 +532,22 @@ fetchData() .proxy-list-card, .types-card, -.status-summary-card { +.status-summary-card, +.store-status-card { border-radius: 12px; border: 1px solid #e4e7ed; } html.dark .proxy-list-card, html.dark .types-card, -html.dark .status-summary-card { +html.dark .status-summary-card, +html.dark .store-status-card { border-color: #3a3d5c; background: #27293d; } -.status-summary-card { +.status-summary-card, +.store-status-card { margin-top: 20px; } @@ -255,7 +568,7 @@ html.dark .status-summary-card { .header-actions { display: flex; align-items: center; - gap: 12px; + gap: 8px; } .card-title { @@ -268,8 +581,12 @@ html.dark .card-title { color: #e5e7eb; } +.filter-select { + width: 100px; +} + .search-input { - width: 200px; + width: 180px; } .proxy-list-content { @@ -282,8 +599,45 @@ html.dark .card-title { gap: 12px; } +/* Empty State */ .empty-state { - padding: 40px 0; + padding: 48px 24px; +} + +.empty-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.empty-icon { + width: 80px; + height: 80px; + margin-bottom: 20px; + color: #c0c4cc; +} + +html.dark .empty-icon { + color: #4b5563; +} + +.empty-text { + font-size: 16px; + font-weight: 500; + color: #606266; + margin: 0 0 8px; +} + +html.dark .empty-text { + color: #9ca3af; +} + +.empty-hint { + font-size: 14px; + color: #909399; + margin: 0 0 20px; + max-width: 320px; } /* Proxy Types Grid */ @@ -303,6 +657,7 @@ html.dark .card-title { background: #f8f9fa; border-radius: 8px; transition: all 0.2s; + cursor: pointer; } .proxy-type-item:hover { @@ -310,6 +665,19 @@ html.dark .card-title { transform: translateY(-2px); } +.proxy-type-item.active { + background: var(--el-color-primary-light-8); + box-shadow: 0 0 0 2px var(--el-color-primary-light-5); +} + +.proxy-type-item.active .proxy-type-name { + color: var(--el-color-primary); +} + +.proxy-type-item.active .proxy-type-count { + color: var(--el-color-primary); +} + html.dark .proxy-type-item { background: #1e1e2d; } @@ -318,6 +686,11 @@ html.dark .proxy-type-item:hover { background: #2a2a3c; } +html.dark .proxy-type-item.active { + background: var(--el-color-primary-dark-2); + box-shadow: 0 0 0 2px var(--el-color-primary); +} + .proxy-type-name { font-size: 11px; color: #909399; @@ -410,6 +783,150 @@ html.dark .status-item:hover { color: var(--el-text-color-primary); } +/* Store Status Card */ +.store-info { + min-height: 60px; +} + +.store-stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: linear-gradient( + 135deg, + rgba(102, 126, 234, 0.08) 0%, + rgba(118, 75, 162, 0.08) 100% + ); + border-radius: 8px; + margin-bottom: 12px; +} + +html.dark .store-stat { + background: linear-gradient( + 135deg, + rgba(129, 140, 248, 0.12) 0%, + rgba(167, 139, 250, 0.12) 100% + ); +} + +.store-stat-label { + font-size: 14px; + color: #606266; + font-weight: 500; +} + +html.dark .store-stat-label { + color: #9ca3af; +} + +.store-stat-value { + font-size: 24px; + font-weight: 600; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +html.dark .store-stat-value { + background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.store-hint { + font-size: 12px; + color: #909399; + margin: 0; + line-height: 1.5; +} + +.store-disabled-text { + font-size: 13px; + color: #909399; + margin: 0; + line-height: 1.6; +} + +/* Visitors Card */ +.visitors-card { + border-radius: 12px; + border: 1px solid #e4e7ed; + margin-top: 20px; +} + +html.dark .visitors-card { + border-color: #3a3d5c; + background: #27293d; +} + +.visitor-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.visitor-card { + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + transition: all 0.2s; +} + +.visitor-card:hover { + background: #f0f2f5; +} + +html.dark .visitor-card { + background: #1e1e2d; +} + +html.dark .visitor-card:hover { + background: #2a2a3c; +} + +.visitor-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.visitor-info { + display: flex; + align-items: center; + gap: 12px; +} + +.visitor-name { + font-size: 15px; + font-weight: 600; + color: #303133; +} + +html.dark .visitor-name { + color: #e5e7eb; +} + +.visitor-actions { + display: flex; + gap: 8px; +} + +.visitor-card-body { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: #606266; +} + +html.dark .visitor-card-body { + color: #9ca3af; +} + @media (max-width: 768px) { .card-header { flex-direction: column; @@ -435,7 +952,8 @@ html.dark .status-item:hover { } @media (max-width: 992px) { - .status-summary-card { + .status-summary-card, + .store-status-card { margin-top: 0; } } diff --git a/web/frpc/src/views/ProxyEdit.vue b/web/frpc/src/views/ProxyEdit.vue new file mode 100644 index 00000000..35725e9d --- /dev/null +++ b/web/frpc/src/views/ProxyEdit.vue @@ -0,0 +1,1005 @@ + + + + + + + diff --git a/web/frpc/src/views/VisitorEdit.vue b/web/frpc/src/views/VisitorEdit.vue new file mode 100644 index 00000000..83a3e90b --- /dev/null +++ b/web/frpc/src/views/VisitorEdit.vue @@ -0,0 +1,518 @@ + + + + + + + diff --git a/web/frps/src/components/ClientCard.vue b/web/frps/src/components/ClientCard.vue index d44ef36a..90663bd5 100644 --- a/web/frps/src/components/ClientCard.vue +++ b/web/frps/src/components/ClientCard.vue @@ -13,6 +13,7 @@ {{ client.hostname }} + v{{ client.version }}
diff --git a/web/frps/src/types/client.ts b/web/frps/src/types/client.ts index e33c4479..31d846a7 100644 --- a/web/frps/src/types/client.ts +++ b/web/frps/src/types/client.ts @@ -3,6 +3,7 @@ export interface ClientInfoData { user: string clientID: string runID: string + version?: string hostname: string clientIP?: string metas?: Record diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts index eec1cb43..f5bc8e65 100644 --- a/web/frps/src/types/proxy.ts +++ b/web/frps/src/types/proxy.ts @@ -3,7 +3,6 @@ export interface ProxyStatsInfo { conf: any user: string clientID: string - clientVersion: string todayTrafficIn: number todayTrafficOut: number curConns: number diff --git a/web/frps/src/utils/client.ts b/web/frps/src/utils/client.ts index 272361a1..d7f95a3f 100644 --- a/web/frps/src/utils/client.ts +++ b/web/frps/src/utils/client.ts @@ -6,6 +6,7 @@ export class Client { user: string clientID: string runID: string + version: string hostname: string ip: string metas: Map @@ -19,6 +20,7 @@ export class Client { this.user = data.user this.clientID = data.clientID this.runID = data.runID + this.version = data.version || '' this.hostname = data.hostname this.ip = data.clientIP || '' this.metas = new Map() diff --git a/web/frps/src/utils/proxy.ts b/web/frps/src/utils/proxy.ts index a2285c62..a883f097 100644 --- a/web/frps/src/utils/proxy.ts +++ b/web/frps/src/utils/proxy.ts @@ -12,7 +12,6 @@ class BaseProxy { status: string user: string clientID: string - clientVersion: string addr: string port: number @@ -49,7 +48,6 @@ class BaseProxy { this.status = proxyStats.status this.user = proxyStats.user || '' this.clientID = proxyStats.clientID || '' - this.clientVersion = proxyStats.clientVersion this.addr = '' this.port = 0 diff --git a/web/frps/src/views/ClientDetail.vue b/web/frps/src/views/ClientDetail.vue index aa064579..4ae45dc6 100644 --- a/web/frps/src/views/ClientDetail.vue +++ b/web/frps/src/views/ClientDetail.vue @@ -22,7 +22,10 @@ {{ client.displayName.charAt(0).toUpperCase() }}
-

{{ client.displayName }}

+
+

{{ client.displayName }}

+ v{{ client.version }} +
{{ client.ip @@ -354,11 +357,18 @@ onMounted(() => { min-width: 0; } +.client-name-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; +} + .client-name { font-size: 20px; font-weight: 500; color: var(--text-primary); - margin: 0 0 4px 0; + margin: 0; line-height: 1.3; }