## Recall.ai - API for meeting recordings
@@ -80,6 +71,7 @@ frp also offers a P2P connect mode.
* [Split Configures Into Different Files](#split-configures-into-different-files)
* [Server Dashboard](#server-dashboard)
* [Client Admin UI](#client-admin-ui)
+ * [Dynamic Proxy Management (Store)](#dynamic-proxy-management-store)
* [Monitor](#monitor)
* [Prometheus](#prometheus)
* [Authenticating the Client](#authenticating-the-client)
@@ -148,7 +140,9 @@ We sincerely appreciate your support for frp.
## Architecture
-
+
## Example Usage
@@ -592,7 +586,7 @@ Then visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS conn
### Client Admin UI
-The Client Admin UI helps you check and manage frpc's configuration.
+The Client Admin UI helps you check and manage frpc's configuration and proxies.
Configure an address for admin UI to enable this feature:
@@ -605,6 +599,19 @@ webServer.password = "admin"
Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`.
+#### Dynamic Proxy Management (Store)
+
+You can dynamically create, update, and delete proxies and visitors at runtime through the Web UI or API, without restarting frpc.
+
+To enable this feature, configure `store.path` to specify a file for persisting the configurations:
+
+```toml
+[store]
+path = "./db.json"
+```
+
+Proxies and visitors managed through the Store are saved to disk and automatically restored on frpc restart. They work alongside proxies defined in the configuration file — Store entries take precedence when names conflict.
+
### Monitor
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
@@ -800,6 +807,14 @@ Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to l
**Note that global client parameters won't be modified except 'start'.**
+`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
+If `start` is non-empty, any proxy or visitor not listed there will not be started, including
+entries created via Store API.
+
+`start` is kept mainly for compatibility and is generally not recommended for new configurations.
+Prefer per-proxy/per-visitor `enabled`, and keep `start` empty unless you explicitly want this
+global allowlist behavior.
+
You can run command `frpc verify -c ./frpc.toml` before reloading to check if there are config errors.
### Get proxy status from client
diff --git a/README_zh.md b/README_zh.md
index 210bfb0e..a08d4401 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -15,16 +15,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
## Recall.ai - API for meeting recordings
diff --git a/Release.md b/Release.md
index 69032dcc..2549a736 100644
--- a/Release.md
+++ b/Release.md
@@ -1,8 +1,9 @@
## Features
-* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
-* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
+* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
+* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
+* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
-## Fixes
+## Compatibility Notes
-* Fixed UDP proxy protocol sending header on every packet instead of only the first packet of each session.
+* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
diff --git a/assets/assets.go b/assets/assets.go
index 2d7a164e..713087ad 100644
--- a/assets/assets.go
+++ b/assets/assets.go
@@ -29,14 +29,23 @@ var (
prefixPath string
)
+type emptyFS struct{}
+
+func (emptyFS) Open(name string) (http.File, error) {
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+}
+
// if path is empty, load assets in memory
// or set FileSystem using disk files
func Load(path string) {
prefixPath = path
- if prefixPath != "" {
+ switch {
+ case prefixPath != "":
FileSystem = http.Dir(prefixPath)
- } else {
+ case content != nil:
FileSystem = http.FS(content)
+ default:
+ FileSystem = emptyFS{}
}
}
diff --git a/client/api/controller.go b/client/api/controller.go
deleted file mode 100644
index 6874b724..00000000
--- a/client/api/controller.go
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright 2025 The frp Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package api
-
-import (
- "cmp"
- "fmt"
- "net"
- "net/http"
- "os"
- "slices"
- "strconv"
- "time"
-
- "github.com/fatedier/frp/client/proxy"
- "github.com/fatedier/frp/pkg/config"
- v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/config/v1/validation"
- "github.com/fatedier/frp/pkg/policy/security"
- httppkg "github.com/fatedier/frp/pkg/util/http"
- "github.com/fatedier/frp/pkg/util/log"
-)
-
-// Controller handles HTTP API requests for frpc.
-type Controller struct {
- // getProxyStatus returns the current proxy status.
- // Returns nil if the control connection is not established.
- getProxyStatus func() []*proxy.WorkingStatus
-
- // serverAddr is the frps server address for display.
- serverAddr string
-
- // configFilePath is the path to the configuration file.
- configFilePath string
-
- // unsafeFeatures is used for validation.
- unsafeFeatures *security.UnsafeFeatures
-
- // updateConfig updates proxy and visitor configurations.
- updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
-
- // gracefulClose gracefully stops the service.
- gracefulClose func(d time.Duration)
-}
-
-// ControllerParams contains parameters for creating an APIController.
-type ControllerParams struct {
- GetProxyStatus func() []*proxy.WorkingStatus
- ServerAddr string
- ConfigFilePath string
- UnsafeFeatures *security.UnsafeFeatures
- UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
- GracefulClose func(d time.Duration)
-}
-
-// NewController creates a new Controller.
-func NewController(params ControllerParams) *Controller {
- return &Controller{
- getProxyStatus: params.GetProxyStatus,
- serverAddr: params.ServerAddr,
- configFilePath: params.ConfigFilePath,
- unsafeFeatures: params.UnsafeFeatures,
- updateConfig: params.UpdateConfig,
- gracefulClose: params.GracefulClose,
- }
-}
-
-// Reload handles GET /api/reload
-func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
- strictConfigMode := false
- strictStr := ctx.Query("strictConfig")
- if strictStr != "" {
- strictConfigMode, _ = strconv.ParseBool(strictStr)
- }
-
- cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
- if err != nil {
- log.Warnf("reload frpc proxy config error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
- }
-
- if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
- log.Warnf("reload frpc proxy config error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
- }
-
- if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
- log.Warnf("reload frpc proxy config error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
- }
-
- log.Infof("success reload conf")
- return nil, nil
-}
-
-// Stop handles POST /api/stop
-func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
- go c.gracefulClose(100 * time.Millisecond)
- return nil, nil
-}
-
-// Status handles GET /api/status
-func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
- res := make(StatusResp)
- ps := c.getProxyStatus()
- if ps == nil {
- return res, nil
- }
-
- for _, status := range ps {
- res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
- }
-
- for _, arrs := range res {
- if len(arrs) <= 1 {
- continue
- }
- slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
- return cmp.Compare(a.Name, b.Name)
- })
- }
- return res, nil
-}
-
-// GetConfig handles GET /api/config
-func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
- if c.configFilePath == "" {
- return nil, httppkg.NewError(http.StatusBadRequest, "frpc has no config file path")
- }
-
- content, err := os.ReadFile(c.configFilePath)
- if err != nil {
- log.Warnf("load frpc config file error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
- }
- return string(content), nil
-}
-
-// PutConfig handles PUT /api/config
-func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
- body, err := ctx.Body()
- if err != nil {
- return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
- }
-
- if len(body) == 0 {
- return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
- }
-
- if err := os.WriteFile(c.configFilePath, body, 0o600); err != nil {
- return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("write content to frpc config file error: %v", err))
- }
- return nil, nil
-}
-
-// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
-func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
- psr := ProxyStatusResp{
- Name: status.Name,
- Type: status.Type,
- Status: status.Phase,
- Err: status.Err,
- }
- baseCfg := status.Cfg.GetBaseConfig()
- if baseCfg.LocalPort != 0 {
- psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
- }
- psr.Plugin = baseCfg.Plugin.Type
-
- if status.Err == "" {
- psr.RemoteAddr = status.RemoteAddr
- if slices.Contains([]string{"tcp", "udp"}, status.Type) {
- psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
- }
- }
- return psr
-}
diff --git a/client/admin_api.go b/client/api_router.go
similarity index 55%
rename from client/admin_api.go
rename to client/api_router.go
index 09936352..82b73046 100644
--- a/client/admin_api.go
+++ b/client/api_router.go
@@ -17,7 +17,7 @@ package client
import (
"net/http"
- "github.com/fatedier/frp/client/api"
+ adminapi "github.com/fatedier/frp/client/http"
"github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -38,6 +38,22 @@ 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)
+ subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)
+ subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)
+
+ 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))),
@@ -51,14 +67,11 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
-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,
+func newAPIController(svr *Service) *adminapi.Controller {
+ manager := newServiceConfigManager(svr)
+ return adminapi.NewController(adminapi.ControllerParams{
+ ServerAddr: svr.common.ServerAddr,
+ Manager: manager,
})
}
diff --git a/client/config_manager.go b/client/config_manager.go
new file mode 100644
index 00000000..24512e9f
--- /dev/null
+++ b/client/config_manager.go
@@ -0,0 +1,464 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "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/util/log"
+)
+
+type serviceConfigManager struct {
+ svr *Service
+}
+
+func newServiceConfigManager(svr *Service) configmgmt.ConfigManager {
+ return &serviceConfigManager{svr: svr}
+}
+
+func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
+ if m.svr.configFilePath == "" {
+ return fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
+ }
+
+ result, err := config.LoadClientConfigResult(m.svr.configFilePath, strict)
+ if err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
+ result.Common,
+ result.Proxies,
+ result.Visitors,
+ )
+ proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
+ visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
+
+ if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, m.svr.unsafeFeatures); err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ log.Infof("success reload conf")
+ return nil
+}
+
+func (m *serviceConfigManager) ReadConfigFile() (string, error) {
+ if m.svr.configFilePath == "" {
+ return "", fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
+ }
+
+ content, err := os.ReadFile(m.svr.configFilePath)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+ return string(content), nil
+}
+
+func (m *serviceConfigManager) WriteConfigFile(content []byte) error {
+ if len(content) == 0 {
+ return fmt.Errorf("%w: body can't be empty", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := os.WriteFile(m.svr.configFilePath, content, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
+ return m.svr.getAllProxyStatus()
+}
+
+func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
+ // Try running proxy manager first
+ ws, ok := m.svr.getProxyStatus(name)
+ if ok {
+ return ws.Cfg, true
+ }
+
+ // Fallback to store
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource != nil {
+ cfg := storeSource.GetProxy(name)
+ if cfg != nil {
+ return cfg, true
+ }
+ }
+ return nil, false
+}
+
+func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
+ // Try running visitor manager first
+ cfg, ok := m.svr.getVisitorCfg(name)
+ if ok {
+ return cfg, true
+ }
+
+ // Fallback to store
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource != nil {
+ vcfg := storeSource.GetVisitor(name)
+ if vcfg != nil {
+ return vcfg, true
+ }
+ }
+ return nil, false
+}
+
+func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource == nil {
+ return false
+ }
+
+ cfg := storeSource.GetProxy(name)
+ if cfg == nil {
+ return false
+ }
+ enabled := cfg.GetBaseConfig().Enabled
+ return enabled == nil || *enabled
+}
+
+func (m *serviceConfigManager) StoreEnabled() bool {
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+ return storeSource != nil
+}
+
+func (m *serviceConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+ return storeSource.GetAllProxies()
+}
+
+func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := storeSource.GetProxy(name)
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: proxy %q", configmgmt.ErrNotFound, name)
+ }
+ return cfg, nil
+}
+
+func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if err := m.validateStoreProxyConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ name := cfg.GetBaseConfig().Name
+ persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.AddProxy(cfg); err != nil {
+ if errors.Is(err, source.ErrAlreadyExists) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ log.Infof("store: created proxy %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
+ }
+ bodyName := cfg.GetBaseConfig().Name
+ if bodyName != name {
+ return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
+ }
+ if err := m.validateStoreProxyConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.UpdateProxy(cfg); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: updated proxy %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) DeleteStoreProxy(name string) error {
+ if name == "" {
+ return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
+ if err := storeSource.RemoveProxy(name); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ log.Infof("store: deleted proxy %q", name)
+ return nil
+}
+
+func (m *serviceConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+ return storeSource.GetAllVisitors()
+}
+
+func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := storeSource.GetVisitor(name)
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: visitor %q", configmgmt.ErrNotFound, name)
+ }
+ return cfg, nil
+}
+
+func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ name := cfg.GetBaseConfig().Name
+ persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.AddVisitor(cfg); err != nil {
+ if errors.Is(err, source.ErrAlreadyExists) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: created visitor %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
+ }
+ bodyName := cfg.GetBaseConfig().Name
+ if bodyName != name {
+ return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
+ }
+ if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.UpdateVisitor(cfg); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: updated visitor %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) DeleteStoreVisitor(name string) error {
+ if name == "" {
+ return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
+ if err := storeSource.RemoveVisitor(name); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ log.Infof("store: deleted visitor %q", name)
+ return nil
+}
+
+func (m *serviceConfigManager) GracefulClose(d time.Duration) {
+ m.svr.GracefulClose(d)
+}
+
+func (m *serviceConfigManager) storeSourceOrError() (*source.StoreSource, error) {
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+ return storeSource, nil
+}
+
+func (m *serviceConfigManager) withStoreMutationAndReload(
+ fn func(storeSource *source.StoreSource) error,
+) error {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return err
+ }
+
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+ return nil
+}
+
+func (m *serviceConfigManager) withStoreProxyMutationAndReload(
+ name string,
+ fn func(storeSource *source.StoreSource) error,
+) (v1.ProxyConfigurer, error) {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return nil, err
+ }
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ persisted := storeSource.GetProxy(name)
+ if persisted == nil {
+ return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
+ }
+ return persisted.Clone(), nil
+}
+
+func (m *serviceConfigManager) withStoreVisitorMutationAndReload(
+ name string,
+ fn func(storeSource *source.StoreSource) error,
+) (v1.VisitorConfigurer, error) {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return nil, err
+ }
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ persisted := storeSource.GetVisitor(name)
+ if persisted == nil {
+ return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
+ }
+ return persisted.Clone(), nil
+}
+
+func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {
+ if cfg == nil {
+ return fmt.Errorf("invalid proxy config")
+ }
+ runtimeCfg := cfg.Clone()
+ if runtimeCfg == nil {
+ return fmt.Errorf("invalid proxy config")
+ }
+ runtimeCfg.Complete()
+ return validation.ValidateProxyConfigurerForClient(runtimeCfg)
+}
+
+func (m *serviceConfigManager) validateStoreVisitorConfigurer(cfg v1.VisitorConfigurer) error {
+ if cfg == nil {
+ return fmt.Errorf("invalid visitor config")
+ }
+ runtimeCfg := cfg.Clone()
+ if runtimeCfg == nil {
+ return fmt.Errorf("invalid visitor config")
+ }
+ runtimeCfg.Complete()
+ return validation.ValidateVisitorConfigurer(runtimeCfg)
+}
diff --git a/client/config_manager_test.go b/client/config_manager_test.go
new file mode 100644
index 00000000..07ae3297
--- /dev/null
+++ b/client/config_manager_test.go
@@ -0,0 +1,137 @@
+package client
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/pkg/config/source"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
+ return &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: name,
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 10080,
+ },
+ },
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+ if err := storeSource.AddProxy(newTestRawTCPProxyConfig("p1")); err != nil {
+ t.Fatalf("seed proxy: %v", err)
+ }
+
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected conflict error")
+ }
+ if !errors.Is(err, configmgmt.ErrConflict) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected apply config error")
+ }
+ if !errors.Is(err, configmgmt.ErrApplyConfig) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if storeSource.GetProxy("p1") == nil {
+ t.Fatal("proxy should remain in store after reload failure")
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) {
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected store disabled error")
+ }
+ if !errors.Is(err, configmgmt.ErrStoreDisabled) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if persisted == nil {
+ t.Fatal("expected persisted proxy to be returned")
+ }
+
+ got := storeSource.GetProxy("raw-proxy")
+ if got == nil {
+ t.Fatal("proxy not found in store")
+ }
+ if got.GetBaseConfig().LocalIP != "" {
+ t.Fatalf("localIP was persisted with runtime default: %q", got.GetBaseConfig().LocalIP)
+ }
+ if got.GetBaseConfig().Transport.BandwidthLimitMode != "" {
+ t.Fatalf("bandwidthLimitMode was persisted with runtime default: %q", got.GetBaseConfig().Transport.BandwidthLimitMode)
+ }
+}
diff --git a/client/configmgmt/types.go b/client/configmgmt/types.go
new file mode 100644
index 00000000..5a51a5e5
--- /dev/null
+++ b/client/configmgmt/types.go
@@ -0,0 +1,45 @@
+package configmgmt
+
+import (
+ "errors"
+ "time"
+
+ "github.com/fatedier/frp/client/proxy"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+var (
+ ErrInvalidArgument = errors.New("invalid argument")
+ ErrNotFound = errors.New("not found")
+ ErrConflict = errors.New("conflict")
+ ErrStoreDisabled = errors.New("store disabled")
+ ErrApplyConfig = errors.New("apply config failed")
+)
+
+type ConfigManager interface {
+ ReloadFromFile(strict bool) error
+
+ ReadConfigFile() (string, error)
+ WriteConfigFile(content []byte) error
+
+ GetProxyStatus() []*proxy.WorkingStatus
+ IsStoreProxyEnabled(name string) bool
+ StoreEnabled() bool
+
+ GetProxyConfig(name string) (v1.ProxyConfigurer, bool)
+ GetVisitorConfig(name string) (v1.VisitorConfigurer, bool)
+
+ ListStoreProxies() ([]v1.ProxyConfigurer, error)
+ GetStoreProxy(name string) (v1.ProxyConfigurer, error)
+ CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ DeleteStoreProxy(name string) error
+
+ ListStoreVisitors() ([]v1.VisitorConfigurer, error)
+ GetStoreVisitor(name string) (v1.VisitorConfigurer, error)
+ CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ DeleteStoreVisitor(name string) error
+
+ GracefulClose(d time.Duration)
+}
diff --git a/client/connector.go b/client/connector.go
index 03d7fc29..34b24f0d 100644
--- a/client/connector.go
+++ b/client/connector.go
@@ -29,6 +29,8 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -41,6 +43,39 @@ type Connector interface {
Close() error
}
+type MessageConnector interface {
+ Connect() (*msg.Conn, error)
+ Close() error
+}
+
+type messageConnector struct {
+ connector Connector
+ wireProtocol string
+}
+
+func newMessageConnector(connector Connector, wireProtocol string) *messageConnector {
+ return &messageConnector{
+ connector: connector,
+ wireProtocol: wireProtocol,
+ }
+}
+
+func (c *messageConnector) Connect() (*msg.Conn, error) {
+ conn, err := c.connector.Connect()
+ if err != nil {
+ return nil, err
+ }
+ if err = wire.WriteMagicIfV2(conn, c.wireProtocol); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return msg.NewConn(conn, msg.NewReadWriter(conn, c.wireProtocol)), nil
+}
+
+func (c *messageConnector) Close() error {
+ return c.connector.Close()
+}
+
// defaultConnectorImpl is the default implementation of Connector for normal frpc.
type defaultConnectorImpl struct {
ctx context.Context
@@ -119,6 +154,7 @@ func (c *defaultConnectorImpl) Open() error {
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
session, err := fmux.Client(conn, fmuxCfg)
if err != nil {
+ conn.Close()
return err
}
c.muxSession = session
diff --git a/client/control.go b/client/control.go
index 4e9a49e9..bf1f6e0b 100644
--- a/client/control.go
+++ b/client/control.go
@@ -27,8 +27,8 @@ import (
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/transport"
- netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -42,13 +42,11 @@ type SessionContext struct {
// It should be attached to the login message when reconnecting.
RunID string
// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.
- Conn net.Conn
- // Indicates whether the connection is encrypted.
- ConnEncrypted bool
+ Conn *msg.Conn
// Auth runtime used for login, heartbeats, and encryption.
Auth *auth.ClientAuth
- // Connector is used to create new connections, which could be real TCP connections or virtual streams.
- Connector Connector
+ // Connector is used to create message connections to frps.
+ Connector MessageConnector
// Virtual net controller
VnetController *vnet.Controller
}
@@ -92,15 +90,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
}
ctl.lastPong.Store(time.Now())
- if sessionCtx.ConnEncrypted {
- cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())
- if err != nil {
- return nil, err
- }
- ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
- } else {
- ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
- }
+ ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
@@ -140,14 +130,14 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
workConn.Close()
return
}
- if err = msg.WriteMsg(workConn, m); err != nil {
+ if err = workConn.WriteMsg(m); err != nil {
xl.Warnf("work connection write to server error: %v", err)
workConn.Close()
return
}
var startMsg msg.StartWorkConn
- if err = msg.ReadMsgInto(workConn, &startMsg); err != nil {
+ if err = workConn.ReadMsgInto(&startMsg); err != nil {
xl.Tracef("work connection closed before response StartWorkConn message: %v", err)
workConn.Close()
return
@@ -158,6 +148,8 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
return
}
+ startMsg.ProxyName = naming.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)
+
// dispatch this work connection to related proxy
ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)
}
@@ -167,14 +159,15 @@ 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 := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
+ err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil {
- xl.Warnf("[%s] 启动失败: %v", inMsg.ProxyName, err)
+ xl.Warnf("[%s] 启动失败: %v", proxyName, err)
} else {
- xl.Infof("[%s] 成功启动隧道", inMsg.ProxyName)
+ xl.Infof("[%s] 成功启动隧道", proxyName)
if inMsg.RemoteAddr != "" {
// Get proxy type to format access message
- if status, ok := ctl.pm.GetProxyStatus(inMsg.ProxyName); ok {
+ if status, ok := ctl.pm.GetProxyStatus(proxyName); ok {
proxyType := status.Type
remoteAddr := inMsg.RemoteAddr
var accessMsg string
@@ -202,9 +195,9 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
accessMsg = fmt.Sprintf("您可通过 %s 访问您的服务", remoteAddr)
}
- xl.Infof("[%s] %s", inMsg.ProxyName, accessMsg)
+ xl.Infof("[%s] %s", proxyName, accessMsg)
} else {
- xl.Infof("[%s] 您可通过 %s 访问您的服务", inMsg.ProxyName, inMsg.RemoteAddr)
+ xl.Infof("[%s] 您可通过 %s 访问您的服务", proxyName, inMsg.RemoteAddr)
}
}
}
@@ -260,7 +253,7 @@ func (ctl *Control) Done() <-chan struct{} {
}
// connectServer return a new connection to frps
-func (ctl *Control) connectServer() (net.Conn, error) {
+func (ctl *Control) connectServer() (*msg.Conn, error) {
return ctl.sessionCtx.Connector.Connect()
}
diff --git a/client/control_session.go b/client/control_session.go
new file mode 100644
index 00000000..d533ba2d
--- /dev/null
+++ b/client/control_session.go
@@ -0,0 +1,220 @@
+// 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 client
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "runtime"
+ "time"
+
+ "github.com/samber/lo"
+
+ "github.com/fatedier/frp/pkg/auth"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+ netpkg "github.com/fatedier/frp/pkg/util/net"
+ "github.com/fatedier/frp/pkg/util/version"
+ "github.com/fatedier/frp/pkg/vnet"
+)
+
+type controlSessionDialer struct {
+ ctx context.Context
+
+ common *v1.ClientCommonConfig
+ auth *auth.ClientAuth
+ clientSpec *msg.ClientSpec
+ vnetController *vnet.Controller
+
+ connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
+}
+
+func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, error) {
+ connector := d.connectorCreator(d.ctx, d.common)
+ if err := connector.Open(); err != nil {
+ return nil, err
+ }
+
+ success := false
+ defer func() {
+ if !success {
+ _ = connector.Close()
+ }
+ }()
+
+ conn, err := connector.Connect()
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if !success {
+ _ = conn.Close()
+ }
+ }()
+
+ loginMsg, err := d.buildLoginMsg(previousRunID)
+ if err != nil {
+ return nil, err
+ }
+
+ loginResult, err := d.exchangeLogin(conn, loginMsg)
+ if err != nil {
+ return nil, err
+ }
+ loginRespMsg := loginResult.resp
+ if loginRespMsg.Error != "" {
+ return nil, errors.New(loginRespMsg.Error)
+ }
+
+ var controlRW io.ReadWriter = conn
+ if d.clientSpec == nil || d.clientSpec.Type != "ssh-tunnel" {
+ controlRW, err = d.newControlReadWriter(conn, loginResult.crypto)
+ if err != nil {
+ return nil, fmt.Errorf("create control crypto read writer: %w", err)
+ }
+ }
+
+ success = true
+ return &SessionContext{
+ Common: d.common,
+ RunID: loginRespMsg.RunID,
+ Conn: msg.NewConn(conn, msg.NewReadWriter(controlRW, d.common.Transport.WireProtocol)),
+ Auth: d.auth,
+ Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
+ VnetController: d.vnetController,
+ }, nil
+}
+
+func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login, error) {
+ hostname, _ := os.Hostname()
+ loginMsg := &msg.Login{
+ Arch: runtime.GOARCH,
+ Os: runtime.GOOS,
+ Hostname: hostname,
+ PoolCount: d.common.Transport.PoolCount,
+ User: d.common.User,
+ ClientID: d.common.ClientID,
+ Version: version.Full(),
+ Timestamp: time.Now().Unix(),
+ RunID: previousRunID,
+ Metas: d.common.Metadatas,
+ }
+ if d.clientSpec != nil {
+ loginMsg.ClientSpec = *d.clientSpec
+ }
+
+ if err := d.auth.Setter.SetLogin(loginMsg); err != nil {
+ return nil, err
+ }
+ return loginMsg, nil
+}
+
+type loginExchangeResult struct {
+ resp *msg.LoginResp
+ crypto *wire.CryptoContext
+}
+
+func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
+ rw := msg.NewV1ReadWriter(conn)
+ var wireConn *wire.Conn
+ var clientHello wire.ClientHello
+ var clientHelloPayload []byte
+
+ if d.common.Transport.WireProtocol == wire.ProtocolV2 {
+ if err := wire.WriteMagic(conn); err != nil {
+ return nil, err
+ }
+
+ wireConn = wire.NewConn(conn)
+ rw = msg.NewV2ReadWriterWithConn(wireConn)
+ var err error
+ clientHello, err = wire.NewClientHello(wire.BootstrapInfo{
+ Transport: d.common.Transport.Protocol,
+ TLS: lo.FromPtr(d.common.Transport.TLS.Enable) || d.common.Transport.Protocol == "wss" || d.common.Transport.Protocol == "quic",
+ TCPMux: lo.FromPtr(d.common.Transport.TCPMux),
+ })
+ if err != nil {
+ return nil, err
+ }
+ clientHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeClientHello, clientHello)
+ if err != nil {
+ return nil, err
+ }
+ if err := wireConn.WriteFrame(clientHelloFrame); err != nil {
+ return nil, err
+ }
+ clientHelloPayload = clientHelloFrame.Payload
+ }
+ if err := rw.WriteMsg(loginMsg); err != nil {
+ return nil, err
+ }
+
+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ defer func() {
+ _ = conn.SetReadDeadline(time.Time{})
+ }()
+
+ var cryptoContext *wire.CryptoContext
+ if wireConn != nil {
+ serverHelloFrame, err := wireConn.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+ if serverHelloFrame.Type != wire.FrameTypeServerHello {
+ return nil, fmt.Errorf("unexpected frame type %d, want %d", serverHelloFrame.Type, wire.FrameTypeServerHello)
+ }
+ var serverHello wire.ServerHello
+ if err := wireConn.UnmarshalFrame(serverHelloFrame, &serverHello); err != nil {
+ return nil, err
+ }
+ if serverHello.Error != "" {
+ return nil, errors.New(serverHello.Error)
+ }
+ cryptoContext, err = wire.NewClientCryptoContext(clientHelloPayload, serverHelloFrame.Payload)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ var loginRespMsg msg.LoginResp
+ if err := rw.ReadMsgInto(&loginRespMsg); err != nil {
+ return nil, err
+ }
+ return &loginExchangeResult{
+ resp: &loginRespMsg,
+ crypto: cryptoContext,
+ }, nil
+}
+
+func (d *controlSessionDialer) newControlReadWriter(conn net.Conn, cryptoContext *wire.CryptoContext) (io.ReadWriter, error) {
+ if d.common.Transport.WireProtocol == wire.ProtocolV2 {
+ if cryptoContext == nil {
+ return nil, errors.New("missing v2 crypto negotiation")
+ }
+ return netpkg.NewAEADCryptoReadWriter(
+ conn,
+ d.auth.EncryptionKey(),
+ netpkg.AEADCryptoRoleClient,
+ cryptoContext.Algorithm,
+ cryptoContext.TranscriptHash,
+ )
+ }
+ return netpkg.NewCryptoReadWriter(conn, d.auth.EncryptionKey())
+}
diff --git a/client/control_session_test.go b/client/control_session_test.go
new file mode 100644
index 00000000..a0778fba
--- /dev/null
+++ b/client/control_session_test.go
@@ -0,0 +1,297 @@
+// 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 client
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/auth"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+ netpkg "github.com/fatedier/frp/pkg/util/net"
+)
+
+type testConnector struct {
+ conn net.Conn
+ closed atomic.Bool
+}
+
+func (c *testConnector) Open() error {
+ return nil
+}
+
+func (c *testConnector) Connect() (net.Conn, error) {
+ return c.conn, nil
+}
+
+func (c *testConnector) Close() error {
+ c.closed.Store(true)
+ return nil
+}
+
+type trackingConn struct {
+ net.Conn
+ closed atomic.Bool
+}
+
+func (c *trackingConn) Close() error {
+ c.closed.Store(true)
+ return c.Conn.Close()
+}
+
+func newTestControlSessionDialer(t *testing.T, protocol string, connector Connector, clientSpec *msg.ClientSpec) *controlSessionDialer {
+ t.Helper()
+
+ authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
+ Method: v1.AuthMethodToken,
+ Token: "token",
+ })
+ require.NoError(t, err)
+
+ return &controlSessionDialer{
+ ctx: context.Background(),
+ common: &v1.ClientCommonConfig{
+ User: "test-user",
+ Transport: v1.ClientTransportConfig{
+ Protocol: "tcp",
+ WireProtocol: protocol,
+ },
+ },
+ auth: authRuntime,
+ clientSpec: clientSpec,
+ connectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
+ return connector
+ },
+ }
+}
+
+func TestControlSessionDialerDialV1(t *testing.T) {
+ clientRaw, serverRaw := net.Pipe()
+ defer serverRaw.Close()
+
+ connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
+ serverErrCh := make(chan error, 1)
+ go func() {
+ rw := msg.NewV1ReadWriter(serverRaw)
+ var loginMsg msg.Login
+ if err := rw.ReadMsgInto(&loginMsg); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if loginMsg.RunID != "previous-run-id" {
+ serverErrCh <- fmt.Errorf("unexpected previous run id: %s", loginMsg.RunID)
+ return
+ }
+ if loginMsg.User != "test-user" {
+ serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
+ return
+ }
+ serverErrCh <- rw.WriteMsg(&msg.LoginResp{RunID: "run-v1"})
+ }()
+
+ dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
+ sessionCtx, err := dialer.Dial("previous-run-id")
+ require.NoError(t, err)
+ defer sessionCtx.Conn.Close()
+ defer sessionCtx.Connector.Close()
+
+ require.Equal(t, "run-v1", sessionCtx.RunID)
+ require.NotNil(t, sessionCtx.Conn)
+ require.NotNil(t, sessionCtx.Connector)
+ require.False(t, connector.closed.Load())
+ require.NoError(t, <-serverErrCh)
+}
+
+func TestControlSessionDialerDialV2(t *testing.T) {
+ clientRaw, serverRaw := net.Pipe()
+ defer serverRaw.Close()
+
+ connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
+ serverErrCh := make(chan error, 1)
+ go func() {
+ magic := make([]byte, len(wire.MagicV2))
+ if _, err := io.ReadFull(serverRaw, magic); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if string(magic) != wire.MagicV2 {
+ serverErrCh <- fmt.Errorf("unexpected magic: %q", string(magic))
+ return
+ }
+
+ wireConn := wire.NewConn(serverRaw)
+ clientHelloFrame, err := wireConn.ReadFrame()
+ if err != nil {
+ serverErrCh <- err
+ return
+ }
+ if clientHelloFrame.Type != wire.FrameTypeClientHello {
+ serverErrCh <- fmt.Errorf("unexpected frame type %d, want %d", clientHelloFrame.Type, wire.FrameTypeClientHello)
+ return
+ }
+ var hello wire.ClientHello
+ if err := wireConn.UnmarshalFrame(clientHelloFrame, &hello); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if err := wire.ValidateClientHello(hello); err != nil {
+ serverErrCh <- err
+ return
+ }
+
+ rw := msg.NewV2ReadWriterWithConn(wireConn)
+ var loginMsg msg.Login
+ if err := rw.ReadMsgInto(&loginMsg); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if loginMsg.User != "test-user" {
+ serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
+ return
+ }
+ serverHello, err := wire.NewServerHello(hello)
+ if err != nil {
+ serverErrCh <- err
+ return
+ }
+ serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
+ if err != nil {
+ serverErrCh <- err
+ return
+ }
+ cryptoContext := wire.NewCryptoContext(
+ serverHello.Selected.Crypto.Algorithm,
+ clientHelloFrame.Payload,
+ serverHelloFrame.Payload,
+ )
+ if err := wireConn.WriteFrame(serverHelloFrame); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-v2"}); err != nil {
+ serverErrCh <- err
+ return
+ }
+
+ controlRW, err := netpkg.NewAEADCryptoReadWriter(
+ serverRaw,
+ []byte("token"),
+ netpkg.AEADCryptoRoleServer,
+ cryptoContext.Algorithm,
+ cryptoContext.TranscriptHash,
+ )
+ if err != nil {
+ serverErrCh <- err
+ return
+ }
+ controlMsgRW := msg.NewReadWriter(controlRW, wire.ProtocolV2)
+ var ping msg.Ping
+ if err := controlMsgRW.ReadMsgInto(&ping); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if ping.PrivilegeKey != "v2-ping" || ping.Timestamp != 12345 {
+ serverErrCh <- fmt.Errorf("unexpected ping: %+v", ping)
+ return
+ }
+ serverErrCh <- nil
+ }()
+
+ dialer := newTestControlSessionDialer(t, wire.ProtocolV2, connector, nil)
+ sessionCtx, err := dialer.Dial("")
+ require.NoError(t, err)
+ defer sessionCtx.Conn.Close()
+ defer sessionCtx.Connector.Close()
+
+ require.Equal(t, "run-v2", sessionCtx.RunID)
+ require.NotNil(t, sessionCtx.Conn)
+ require.NotNil(t, sessionCtx.Connector)
+ require.False(t, connector.closed.Load())
+ require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{PrivilegeKey: "v2-ping", Timestamp: 12345}))
+ require.NoError(t, <-serverErrCh)
+}
+
+func TestControlSessionDialerDialLoginErrorClosesResources(t *testing.T) {
+ clientRaw, serverRaw := net.Pipe()
+ defer serverRaw.Close()
+
+ clientConn := &trackingConn{Conn: clientRaw}
+ connector := &testConnector{conn: clientConn}
+ serverErrCh := make(chan error, 1)
+ go func() {
+ rw := msg.NewV1ReadWriter(serverRaw)
+ var loginMsg msg.Login
+ if err := rw.ReadMsgInto(&loginMsg); err != nil {
+ serverErrCh <- err
+ return
+ }
+ serverErrCh <- rw.WriteMsg(&msg.LoginResp{Error: "login denied"})
+ }()
+
+ dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
+ sessionCtx, err := dialer.Dial("")
+ require.Nil(t, sessionCtx)
+ require.ErrorContains(t, err, "login denied")
+ require.True(t, clientConn.closed.Load())
+ require.True(t, connector.closed.Load())
+ require.NoError(t, <-serverErrCh)
+}
+
+func TestControlSessionDialerDialSSHTunnelSkipsControlEncryption(t *testing.T) {
+ clientRaw, serverRaw := net.Pipe()
+ defer serverRaw.Close()
+
+ connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
+ serverErrCh := make(chan error, 1)
+ go func() {
+ rw := msg.NewV1ReadWriter(serverRaw)
+ var loginMsg msg.Login
+ if err := rw.ReadMsgInto(&loginMsg); err != nil {
+ serverErrCh <- err
+ return
+ }
+ if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-ssh-tunnel"}); err != nil {
+ serverErrCh <- err
+ return
+ }
+
+ _ = serverRaw.SetReadDeadline(time.Now().Add(time.Second))
+ var ping msg.Ping
+ if err := rw.ReadMsgInto(&ping); err != nil {
+ serverErrCh <- err
+ return
+ }
+ serverErrCh <- nil
+ }()
+
+ dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, &msg.ClientSpec{Type: "ssh-tunnel"})
+ sessionCtx, err := dialer.Dial("")
+ require.NoError(t, err)
+ defer sessionCtx.Conn.Close()
+ defer sessionCtx.Connector.Close()
+
+ require.Equal(t, "run-ssh-tunnel", sessionCtx.RunID)
+ require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{}))
+ require.NoError(t, <-serverErrCh)
+}
diff --git a/client/http/controller.go b/client/http/controller.go
new file mode 100644
index 00000000..57e8165d
--- /dev/null
+++ b/client/http/controller.go
@@ -0,0 +1,433 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/client/http/model"
+ "github.com/fatedier/frp/client/proxy"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+// Controller handles HTTP API requests for frpc.
+type Controller struct {
+ serverAddr string
+ manager configmgmt.ConfigManager
+}
+
+// ControllerParams contains parameters for creating an APIController.
+type ControllerParams struct {
+ ServerAddr string
+ Manager configmgmt.ConfigManager
+}
+
+func NewController(params ControllerParams) *Controller {
+ return &Controller{
+ serverAddr: params.ServerAddr,
+ manager: params.Manager,
+ }
+}
+
+func (c *Controller) toHTTPError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ code := http.StatusInternalServerError
+ switch {
+ case errors.Is(err, configmgmt.ErrInvalidArgument):
+ code = http.StatusBadRequest
+ case errors.Is(err, configmgmt.ErrNotFound), errors.Is(err, configmgmt.ErrStoreDisabled):
+ code = http.StatusNotFound
+ case errors.Is(err, configmgmt.ErrConflict):
+ code = http.StatusConflict
+ }
+ return httppkg.NewError(code, err.Error())
+}
+
+// Reload handles GET /api/reload
+func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
+ strictConfigMode := false
+ strictStr := ctx.Query("strictConfig")
+ if strictStr != "" {
+ strictConfigMode, _ = strconv.ParseBool(strictStr)
+ }
+
+ if err := c.manager.ReloadFromFile(strictConfigMode); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+// Stop handles POST /api/stop
+func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
+ go c.manager.GracefulClose(100 * time.Millisecond)
+ return nil, nil
+}
+
+// Status handles GET /api/status
+func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
+ res := make(model.StatusResp)
+ ps := c.manager.GetProxyStatus()
+ if ps == nil {
+ return res, nil
+ }
+
+ for _, status := range ps {
+ res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
+ }
+
+ for _, arrs := range res {
+ if len(arrs) <= 1 {
+ continue
+ }
+ slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ }
+ return res, nil
+}
+
+// GetConfig handles GET /api/config
+func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
+ content, err := c.manager.ReadConfigFile()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return content, nil
+}
+
+// PutConfig handles PUT /api/config
+func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
+ body, err := ctx.Body()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
+ }
+
+ if len(body) == 0 {
+ return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
+ }
+
+ if err := c.manager.WriteConfigFile(body); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {
+ psr := model.ProxyStatusResp{
+ Name: status.Name,
+ Type: status.Type,
+ Status: status.Phase,
+ Err: status.Err,
+ }
+ baseCfg := status.Cfg.GetBaseConfig()
+ if baseCfg.LocalPort != 0 {
+ psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
+ }
+ psr.Plugin = baseCfg.Plugin.Type
+
+ if status.Err == "" {
+ psr.RemoteAddr = status.RemoteAddr
+ if slices.Contains([]string{"tcp", "udp"}, status.Type) {
+ psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
+ }
+ }
+
+ if c.manager.IsStoreProxyEnabled(status.Name) {
+ psr.Source = model.SourceStore
+ }
+ return psr
+}
+
+// GetProxyConfig handles GET /api/proxy/{name}/config
+func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
+ }
+
+ cfg, ok := c.manager.GetProxyConfig(name)
+ if !ok {
+ return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
+ }
+
+ payload, err := model.ProxyDefinitionFromConfigurer(cfg)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return payload, nil
+}
+
+// GetVisitorConfig handles GET /api/visitor/{name}/config
+func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
+ }
+
+ cfg, ok := c.manager.GetVisitorConfig(name)
+ if !ok {
+ return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
+ }
+
+ payload, err := model.VisitorDefinitionFromConfigurer(cfg)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return payload, nil
+}
+
+func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
+ proxies, err := c.manager.ListStoreProxies()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))}
+ for _, p := range proxies {
+ payload, err := model.ProxyDefinitionFromConfigurer(p)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ resp.Proxies = append(resp.Proxies, payload)
+ }
+ slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ return resp, nil
+}
+
+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, err := c.manager.GetStoreProxy(name)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ payload, err := model.ProxyDefinitionFromConfigurer(p)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+
+ return payload, 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 payload model.ProxyDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate("", false); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ created, err := c.manager.CreateStoreProxy(cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.ProxyDefinitionFromConfigurer(created)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, nil
+}
+
+func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
+ 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 payload model.ProxyDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate(name, true); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ updated, err := c.manager.UpdateStoreProxy(name, cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.ProxyDefinitionFromConfigurer(updated)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, nil
+}
+
+func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
+ }
+
+ if err := c.manager.DeleteStoreProxy(name); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
+ visitors, err := c.manager.ListStoreVisitors()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))}
+ for _, v := range visitors {
+ payload, err := model.VisitorDefinitionFromConfigurer(v)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ resp.Visitors = append(resp.Visitors, payload)
+ }
+ slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ return resp, nil
+}
+
+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, err := c.manager.GetStoreVisitor(name)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ payload, err := model.VisitorDefinitionFromConfigurer(v)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+
+ return payload, 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 payload model.VisitorDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate("", false); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ created, err := c.manager.CreateStoreVisitor(cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.VisitorDefinitionFromConfigurer(created)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, nil
+}
+
+func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
+ 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 payload model.VisitorDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate(name, true); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ updated, err := c.manager.UpdateStoreVisitor(name, cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.VisitorDefinitionFromConfigurer(updated)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, nil
+}
+
+func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
+ }
+
+ if err := c.manager.DeleteStoreVisitor(name); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
diff --git a/client/http/controller_test.go b/client/http/controller_test.go
new file mode 100644
index 00000000..719fbcd4
--- /dev/null
+++ b/client/http/controller_test.go
@@ -0,0 +1,662 @@
+package http
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/client/http/model"
+ "github.com/fatedier/frp/client/proxy"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+)
+
+type fakeConfigManager struct {
+ reloadFromFileFn func(strict bool) error
+ readConfigFileFn func() (string, error)
+ writeConfigFileFn func(content []byte) error
+ getProxyStatusFn func() []*proxy.WorkingStatus
+ isStoreProxyEnabledFn func(name string) bool
+ storeEnabledFn func() bool
+ getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool)
+ getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool)
+
+ listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
+ getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
+ createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ deleteStoreProxyFn func(name string) error
+ listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
+ getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
+ createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ deleteStoreVisitFn func(name string) error
+ gracefulCloseFn func(d time.Duration)
+}
+
+func (m *fakeConfigManager) ReloadFromFile(strict bool) error {
+ if m.reloadFromFileFn != nil {
+ return m.reloadFromFileFn(strict)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) ReadConfigFile() (string, error) {
+ if m.readConfigFileFn != nil {
+ return m.readConfigFileFn()
+ }
+ return "", nil
+}
+
+func (m *fakeConfigManager) WriteConfigFile(content []byte) error {
+ if m.writeConfigFileFn != nil {
+ return m.writeConfigFileFn(content)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
+ if m.getProxyStatusFn != nil {
+ return m.getProxyStatusFn()
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {
+ if m.isStoreProxyEnabledFn != nil {
+ return m.isStoreProxyEnabledFn(name)
+ }
+ return false
+}
+
+func (m *fakeConfigManager) StoreEnabled() bool {
+ if m.storeEnabledFn != nil {
+ return m.storeEnabledFn()
+ }
+ return false
+}
+
+func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
+ if m.getProxyConfigFn != nil {
+ return m.getProxyConfigFn(name)
+ }
+ return nil, false
+}
+
+func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
+ if m.getVisitorConfigFn != nil {
+ return m.getVisitorConfigFn(name)
+ }
+ return nil, false
+}
+
+func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
+ if m.listStoreProxiesFn != nil {
+ return m.listStoreProxiesFn()
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
+ if m.getStoreProxyFn != nil {
+ return m.getStoreProxyFn(name)
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if m.createStoreProxyFn != nil {
+ return m.createStoreProxyFn(cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if m.updateStoreProxyFn != nil {
+ return m.updateStoreProxyFn(name, cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
+ if m.deleteStoreProxyFn != nil {
+ return m.deleteStoreProxyFn(name)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
+ if m.listStoreVisitorsFn != nil {
+ return m.listStoreVisitorsFn()
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
+ if m.getStoreVisitorFn != nil {
+ return m.getStoreVisitorFn(name)
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if m.createStoreVisitFn != nil {
+ return m.createStoreVisitFn(cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if m.updateStoreVisitFn != nil {
+ return m.updateStoreVisitFn(name, cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
+ if m.deleteStoreVisitFn != nil {
+ return m.deleteStoreVisitFn(name)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) GracefulClose(d time.Duration) {
+ if m.gracefulCloseFn != nil {
+ m.gracefulCloseFn(d)
+ }
+}
+
+func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
+ return &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: name,
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 10080,
+ },
+ },
+ }
+}
+
+func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
+ status := &proxy.WorkingStatus{
+ Name: "shared-proxy",
+ Type: "tcp",
+ Phase: proxy.ProxyPhaseRunning,
+ RemoteAddr: ":8080",
+ Cfg: newRawTCPProxyConfig("shared-proxy"),
+ }
+
+ controller := &Controller{
+ serverAddr: "127.0.0.1",
+ manager: &fakeConfigManager{
+ isStoreProxyEnabledFn: func(name string) bool {
+ return name == "shared-proxy"
+ },
+ },
+ }
+
+ resp := controller.buildProxyStatusResp(status)
+ if resp.Source != "store" {
+ t.Fatalf("unexpected source: %q", resp.Source)
+ }
+ if resp.RemoteAddr != "127.0.0.1:8080" {
+ t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr)
+ }
+}
+
+func TestReloadErrorMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expectedCode int
+ }{
+ {name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest},
+ {name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},
+ }
+ ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil))
+ _, err := controller.Reload(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, tc.expectedCode)
+ })
+ }
+}
+
+func TestStoreProxyErrorMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expectedCode int
+ }{
+ {name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound},
+ {name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict},
+ {name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ return nil, tc.err
+ },
+ },
+ }
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, tc.expectedCode)
+ })
+ }
+}
+
+func TestStoreVisitorErrorMapping(t *testing.T) {
+ body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
+ req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ deleteStoreVisitFn: func(string) error {
+ return fmtError(configmgmt.ErrStoreDisabled, "disabled")
+ },
+ },
+ }
+
+ _, err := controller.DeleteStoreVisitor(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
+
+func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
+ var gotName string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ gotName = cfg.GetBaseConfig().Name
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if gotName != "raw-proxy" {
+ t.Fatalf("unexpected proxy name: %q", gotName)
+ }
+
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Type != "tcp" || payload.TCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
+ var gotName string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ gotName = cfg.GetBaseConfig().Name
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{
+ "name":"raw-visitor","type":"xtcp","unexpected":"value",
+ "xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
+ }`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreVisitor(ctx)
+ if err != nil {
+ t.Fatalf("create store visitor: %v", err)
+ }
+ if gotName != "raw-visitor" {
+ t.Fatalf("unexpected visitor name: %q", gotName)
+ }
+
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Type != "xtcp" || payload.XTCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
+ var gotPluginType string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ gotPluginType = cfg.GetBaseConfig().Plugin.Type
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if gotPluginType != "http2https" {
+ t.Fatalf("unexpected plugin type: %q", gotPluginType)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.TCP == nil {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+ pluginType := payload.TCP.Plugin.Type
+
+ if pluginType != "http2https" {
+ t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
+ }
+}
+
+func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
+ var gotPluginType string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ gotPluginType = cfg.GetBaseConfig().Plugin.Type
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{
+ "name":"plugin-visitor","type":"stcp",
+ "stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
+ }`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreVisitor(ctx)
+ if err != nil {
+ t.Fatalf("create store visitor: %v", err)
+ }
+ if gotPluginType != "virtual_net" {
+ t.Fatalf("unexpected plugin type: %q", gotPluginType)
+ }
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.STCP == nil {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+ pluginType := payload.STCP.Plugin.Type
+
+ if pluginType != "virtual_net" {
+ t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
+ }
+}
+
+func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
+ controller := &Controller{manager: &fakeConfigManager{}}
+ body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "p1"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusBadRequest)
+}
+
+func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
+ controller := &Controller{manager: &fakeConfigManager{}}
+ body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "p1"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusBadRequest)
+}
+
+func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
+ b := newRawTCPProxyConfig("b")
+ a := newRawTCPProxyConfig("a")
+ return []v1.ProxyConfigurer{b, a}, nil
+ },
+ },
+ }
+ ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
+
+ resp, err := controller.ListStoreProxies(ctx)
+ if err != nil {
+ t.Fatalf("list store proxies: %v", err)
+ }
+ out, ok := resp.(model.ProxyListResp)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if len(out.Proxies) != 2 {
+ t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
+ }
+ if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
+ t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
+ }
+}
+
+func fmtError(sentinel error, msg string) error {
+ return fmt.Errorf("%w: %s", sentinel, msg)
+}
+
+func assertHTTPCode(t *testing.T, err error, expected int) {
+ t.Helper()
+ var httpErr *httppkg.Error
+ if !errors.As(err, &httpErr) {
+ t.Fatalf("unexpected error type: %T", err)
+ }
+ if httpErr.Code != expected {
+ t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
+ }
+}
+
+func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ return cfg, nil
+ },
+ },
+ }
+
+ body := map[string]any{
+ "name": "shared-proxy",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localPort": 10080,
+ "remotePort": 7000,
+ },
+ }
+ data, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal request: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.UpdateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("update store proxy: %v", err)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+}
+
+func TestGetProxyConfigFromManager(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
+ if name == "ssh" {
+ cfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "ssh",
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 22,
+ },
+ },
+ }
+ return cfg, true
+ }
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "ssh"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.GetProxyConfig(ctx)
+ if err != nil {
+ t.Fatalf("get proxy config: %v", err)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestGetProxyConfigNotFound(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "missing"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.GetProxyConfig(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
+
+func TestGetVisitorConfigFromManager(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
+ if name == "my-stcp" {
+ cfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "my-stcp",
+ Type: "stcp",
+ ServerName: "server1",
+ BindPort: 9000,
+ },
+ }
+ return cfg, true
+ }
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.GetVisitorConfig(ctx)
+ if err != nil {
+ t.Fatalf("get visitor config: %v", err)
+ }
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestGetVisitorConfigNotFound(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "missing"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.GetVisitorConfig(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
diff --git a/client/http/model/proxy_definition.go b/client/http/model/proxy_definition.go
new file mode 100644
index 00000000..dae4b4b8
--- /dev/null
+++ b/client/http/model/proxy_definition.go
@@ -0,0 +1,148 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type ProxyDefinition struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+
+ TCP *v1.TCPProxyConfig `json:"tcp,omitempty"`
+ UDP *v1.UDPProxyConfig `json:"udp,omitempty"`
+ HTTP *v1.HTTPProxyConfig `json:"http,omitempty"`
+ HTTPS *v1.HTTPSProxyConfig `json:"https,omitempty"`
+ TCPMux *v1.TCPMuxProxyConfig `json:"tcpmux,omitempty"`
+ STCP *v1.STCPProxyConfig `json:"stcp,omitempty"`
+ SUDP *v1.SUDPProxyConfig `json:"sudp,omitempty"`
+ XTCP *v1.XTCPProxyConfig `json:"xtcp,omitempty"`
+}
+
+func (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error {
+ if strings.TrimSpace(p.Name) == "" {
+ return fmt.Errorf("proxy name is required")
+ }
+ if !IsProxyType(p.Type) {
+ return fmt.Errorf("invalid proxy type: %s", p.Type)
+ }
+ if isUpdate && pathName != "" && pathName != p.Name {
+ return fmt.Errorf("proxy name in URL must match name in body")
+ }
+
+ _, blockType, blockCount := p.activeBlock()
+ if blockCount != 1 {
+ return fmt.Errorf("exactly one proxy type block is required")
+ }
+ if blockType != p.Type {
+ return fmt.Errorf("proxy type block %q does not match type %q", blockType, p.Type)
+ }
+ return nil
+}
+
+func (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) {
+ block, _, _ := p.activeBlock()
+ if block == nil {
+ return nil, fmt.Errorf("exactly one proxy type block is required")
+ }
+
+ cfg := block
+ cfg.GetBaseConfig().Name = p.Name
+ cfg.GetBaseConfig().Type = p.Type
+ return cfg, nil
+}
+
+func ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) {
+ if cfg == nil {
+ return ProxyDefinition{}, fmt.Errorf("proxy config is nil")
+ }
+
+ base := cfg.GetBaseConfig()
+ payload := ProxyDefinition{
+ Name: base.Name,
+ Type: base.Type,
+ }
+
+ switch c := cfg.(type) {
+ case *v1.TCPProxyConfig:
+ payload.TCP = c
+ case *v1.UDPProxyConfig:
+ payload.UDP = c
+ case *v1.HTTPProxyConfig:
+ payload.HTTP = c
+ case *v1.HTTPSProxyConfig:
+ payload.HTTPS = c
+ case *v1.TCPMuxProxyConfig:
+ payload.TCPMux = c
+ case *v1.STCPProxyConfig:
+ payload.STCP = c
+ case *v1.SUDPProxyConfig:
+ payload.SUDP = c
+ case *v1.XTCPProxyConfig:
+ payload.XTCP = c
+ default:
+ return ProxyDefinition{}, fmt.Errorf("unsupported proxy configurer type %T", cfg)
+ }
+
+ return payload, nil
+}
+
+func (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) {
+ count := 0
+ var block v1.ProxyConfigurer
+ var blockType string
+
+ if p.TCP != nil {
+ count++
+ block = p.TCP
+ blockType = "tcp"
+ }
+ if p.UDP != nil {
+ count++
+ block = p.UDP
+ blockType = "udp"
+ }
+ if p.HTTP != nil {
+ count++
+ block = p.HTTP
+ blockType = "http"
+ }
+ if p.HTTPS != nil {
+ count++
+ block = p.HTTPS
+ blockType = "https"
+ }
+ if p.TCPMux != nil {
+ count++
+ block = p.TCPMux
+ blockType = "tcpmux"
+ }
+ if p.STCP != nil {
+ count++
+ block = p.STCP
+ blockType = "stcp"
+ }
+ if p.SUDP != nil {
+ count++
+ block = p.SUDP
+ blockType = "sudp"
+ }
+ if p.XTCP != nil {
+ count++
+ block = p.XTCP
+ blockType = "xtcp"
+ }
+
+ return block, blockType, count
+}
+
+func IsProxyType(typ string) bool {
+ switch typ {
+ case "tcp", "udp", "http", "https", "tcpmux", "stcp", "sudp", "xtcp":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/client/api/types.go b/client/http/model/types.go
similarity index 72%
rename from client/api/types.go
rename to client/http/model/types.go
index d7c930d0..63704850 100644
--- a/client/api/types.go
+++ b/client/http/model/types.go
@@ -12,7 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package model
+
+const SourceStore = "store"
// StatusResp is the response for GET /api/status
type StatusResp map[string][]ProxyStatusResp
@@ -26,4 +28,15 @@ 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"
+}
+
+// ProxyListResp is the response for GET /api/store/proxies
+type ProxyListResp struct {
+ Proxies []ProxyDefinition `json:"proxies"`
+}
+
+// VisitorListResp is the response for GET /api/store/visitors
+type VisitorListResp struct {
+ Visitors []VisitorDefinition `json:"visitors"`
}
diff --git a/client/http/model/visitor_definition.go b/client/http/model/visitor_definition.go
new file mode 100644
index 00000000..a108982d
--- /dev/null
+++ b/client/http/model/visitor_definition.go
@@ -0,0 +1,107 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type VisitorDefinition struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+
+ STCP *v1.STCPVisitorConfig `json:"stcp,omitempty"`
+ SUDP *v1.SUDPVisitorConfig `json:"sudp,omitempty"`
+ XTCP *v1.XTCPVisitorConfig `json:"xtcp,omitempty"`
+}
+
+func (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error {
+ if strings.TrimSpace(p.Name) == "" {
+ return fmt.Errorf("visitor name is required")
+ }
+ if !IsVisitorType(p.Type) {
+ return fmt.Errorf("invalid visitor type: %s", p.Type)
+ }
+ if isUpdate && pathName != "" && pathName != p.Name {
+ return fmt.Errorf("visitor name in URL must match name in body")
+ }
+
+ _, blockType, blockCount := p.activeBlock()
+ if blockCount != 1 {
+ return fmt.Errorf("exactly one visitor type block is required")
+ }
+ if blockType != p.Type {
+ return fmt.Errorf("visitor type block %q does not match type %q", blockType, p.Type)
+ }
+ return nil
+}
+
+func (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) {
+ block, _, _ := p.activeBlock()
+ if block == nil {
+ return nil, fmt.Errorf("exactly one visitor type block is required")
+ }
+
+ cfg := block
+ cfg.GetBaseConfig().Name = p.Name
+ cfg.GetBaseConfig().Type = p.Type
+ return cfg, nil
+}
+
+func VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) {
+ if cfg == nil {
+ return VisitorDefinition{}, fmt.Errorf("visitor config is nil")
+ }
+
+ base := cfg.GetBaseConfig()
+ payload := VisitorDefinition{
+ Name: base.Name,
+ Type: base.Type,
+ }
+
+ switch c := cfg.(type) {
+ case *v1.STCPVisitorConfig:
+ payload.STCP = c
+ case *v1.SUDPVisitorConfig:
+ payload.SUDP = c
+ case *v1.XTCPVisitorConfig:
+ payload.XTCP = c
+ default:
+ return VisitorDefinition{}, fmt.Errorf("unsupported visitor configurer type %T", cfg)
+ }
+
+ return payload, nil
+}
+
+func (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) {
+ count := 0
+ var block v1.VisitorConfigurer
+ var blockType string
+
+ if p.STCP != nil {
+ count++
+ block = p.STCP
+ blockType = "stcp"
+ }
+ if p.SUDP != nil {
+ count++
+ block = p.SUDP
+ blockType = "sudp"
+ }
+ if p.XTCP != nil {
+ count++
+ block = p.XTCP
+ blockType = "xtcp"
+ }
+ return block, blockType, count
+}
+
+func IsVisitorType(typ string) bool {
+ switch typ {
+ case "stcp", "sudp", "xtcp":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go
index 3bafbcc6..4a0f4cef 100644
--- a/client/proxy/proxy.go
+++ b/client/proxy/proxy.go
@@ -16,6 +16,7 @@ package proxy
import (
"context"
+ "fmt"
"io"
"net"
"reflect"
@@ -160,6 +161,33 @@ func (pxy *BaseProxy) Close() {
}
}
+// wrapWorkConn applies rate limiting, encryption, and compression
+// to a work connection based on the proxy's transport configuration.
+// The returned recycle function should be called when the stream is no longer in use
+// to return compression resources to the pool. It is safe to not call recycle,
+// in which case resources will be garbage collected normally.
+func (pxy *BaseProxy) wrapWorkConn(conn net.Conn, encKey []byte) (io.ReadWriteCloser, func(), error) {
+ var rwc io.ReadWriteCloser = conn
+ if pxy.limiter != nil {
+ rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
+ return conn.Close()
+ })
+ }
+ if pxy.baseCfg.Transport.UseEncryption {
+ var err error
+ rwc, err = libio.WithEncryption(rwc, encKey)
+ if err != nil {
+ conn.Close()
+ return nil, nil, fmt.Errorf("create encryption stream error: %w", err)
+ }
+ }
+ var recycleFn func()
+ if pxy.baseCfg.Transport.UseCompression {
+ rwc, recycleFn = libio.WithCompressionFromPool(rwc)
+ }
+ return rwc, recycleFn, nil
+}
+
func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {
pxy.inWorkConnCallback = cb
}
@@ -177,30 +205,14 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) {
xl := pxy.xl
baseCfg := pxy.baseCfg
- var (
- remote io.ReadWriteCloser
- err error
- )
- remote = workConn
- if pxy.limiter != nil {
- remote = libio.WrapReadWriteCloser(limit.NewReader(workConn, pxy.limiter), limit.NewWriter(workConn, pxy.limiter), func() error {
- return workConn.Close()
- })
- }
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
- if baseCfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, encKey)
- if err != nil {
- workConn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- var compressionResourceRecycleFn func()
- if baseCfg.Transport.UseCompression {
- remote, compressionResourceRecycleFn = libio.WithCompressionFromPool(remote)
+
+ remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
// check if we need to send proxy protocol info
@@ -216,7 +228,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
}
if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
- // Use the common proxy protocol builder function
header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)
connInfo.ProxyProtocolHeader = header
}
@@ -225,12 +236,18 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if pxy.proxyPlugin != nil {
// if plugin is set, let plugin handle connection first
+ // Don't recycle compression resources here because plugins may
+ // retain the connection after Handle returns.
xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name())
pxy.proxyPlugin.Handle(pxy.ctx, &connInfo)
xl.Debugf("handle by plugin finished")
return
}
+ if recycleFn != nil {
+ defer recycleFn()
+ }
+
localConn, err := libnet.Dial(
net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)),
libnet.WithTimeout(10*time.Second),
@@ -247,6 +264,7 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
workConn.Close()
+ localConn.Close()
xl.Errorf("write proxy protocol header to local conn error: %v", err)
return
}
@@ -257,7 +275,4 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if len(errs) > 0 {
xl.Tracef("join connections errors: %v", errs)
}
- if compressionResourceRecycleFn != nil {
- compressionResourceRecycleFn()
- }
}
diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go
index 4698320a..718c02e6 100644
--- a/client/proxy/proxy_wrapper.go
+++ b/client/proxy/proxy_wrapper.go
@@ -29,6 +29,7 @@ import (
"github.com/fatedier/frp/client/health"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/transport"
"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: naming.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/sudp.go b/client/proxy/sudp.go
index 3a7af19c..7db6d991 100644
--- a/client/proxy/sudp.go
+++ b/client/proxy/sudp.go
@@ -17,7 +17,6 @@
package proxy
import (
- "io"
"net"
"reflect"
"strconv"
@@ -25,17 +24,15 @@ import (
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
- "github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.SUDPProxyConfig{}), NewSUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)
}
type SUDPProxy struct {
@@ -83,27 +80,14 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl
xl.Infof("incoming a new work connection for sudp proxy, %s", conn.RemoteAddr().String())
- var rwc io.ReadWriteCloser = conn
- var err error
- if pxy.limiter != nil {
- rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
- return conn.Close()
- })
+ remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
- if pxy.cfg.Transport.UseEncryption {
- rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
- if err != nil {
- conn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- if pxy.cfg.Transport.UseCompression {
- rwc = libio.WithCompression(rwc)
- }
- conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
- workConn := conn
+ workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
+ payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
@@ -126,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
- workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
+ workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
defer closeFn()
for {
@@ -139,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
var udpMsg msg.UDPPacket
- if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
+ if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for sudp error: %v", errRet)
return
}
@@ -154,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service -> frpc -> frps -> frpc visitor -> user
- workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
+ workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
defer func() {
closeFn()
xl.Infof("writer goroutine for sudp work connection closed")
@@ -165,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
- m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
+ m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
case *msg.Ping:
xl.Tracef("frpc send ping message to frpc visitor")
}
- if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
+ if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("sudp work write error: %v", errRet)
return
}
@@ -201,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
- go workConnSenderFn(workConn, sendCh)
- go workConnReaderFn(workConn, readCh)
+ go workConnSenderFn(payloadConn, sendCh)
+ go workConnReaderFn(payloadConn, readCh)
go heartbeatFn(sendCh)
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
diff --git a/client/proxy/udp.go b/client/proxy/udp.go
index 5302f7b1..60071560 100644
--- a/client/proxy/udp.go
+++ b/client/proxy/udp.go
@@ -17,24 +17,21 @@
package proxy
import (
- "io"
"net"
"reflect"
"strconv"
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
- "github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.UDPProxyConfig{}), NewUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)
}
type UDPProxy struct {
@@ -94,42 +91,30 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
// close resources related with old workConn
pxy.Close()
- var rwc io.ReadWriteCloser = conn
- var err error
- if pxy.limiter != nil {
- rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
- return conn.Close()
- })
+ remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
- if pxy.cfg.Transport.UseEncryption {
- rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
- if err != nil {
- conn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- if pxy.cfg.Transport.UseCompression {
- rwc = libio.WithCompression(rwc)
- }
- conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
pxy.mu.Lock()
- pxy.workConn = conn
+ pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
+ // Plain UDP payload follows the configured wire protocol for message framing.
+ payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
pxy.mu.Unlock()
- workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
+ workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
for {
var udpMsg msg.UDPPacket
- if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
+ if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
return
}
if errRet := errors.PanicToError(func() {
- xl.Tracef("get udp package from workConn: %s", udpMsg.Content)
+ xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content))
readCh <- &udpMsg
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
@@ -137,7 +122,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
}
- workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
+ workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
defer func() {
xl.Infof("writer goroutine for udp work connection closed")
}()
@@ -145,11 +130,11 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
for rawMsg := range sendCh {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
- xl.Tracef("send udp package to workConn: %s", m.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(m.Content))
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}
- if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
+ if errRet = rw.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("udp work write error: %v", errRet)
return
}
@@ -168,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
- go workConnSenderFn(pxy.workConn, pxy.sendCh)
- go workConnReaderFn(pxy.workConn, pxy.readCh)
+ go workConnSenderFn(payloadRW, pxy.sendCh)
+ go workConnReaderFn(payloadRW, pxy.readCh)
go heartbeatFn(pxy.sendCh)
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go
index 6e1deac3..aef66780 100644
--- a/client/proxy/xtcp.go
+++ b/client/proxy/xtcp.go
@@ -27,13 +27,14 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/nathole"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.XTCPProxyConfig{}), NewXTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)
}
type XTCPProxy struct {
@@ -56,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
defer conn.Close()
- var natHoleSidMsg msg.NatHoleSid
- err := msg.ReadMsgInto(conn, &natHoleSidMsg)
+ natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
if err != nil {
xl.Errorf("xtcp read from workConn error: %v", err)
return
@@ -85,7 +85,7 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
transactionID := nathole.NewTransactionID()
natHoleClientMsg := &msg.NatHoleClient{
TransactionID: transactionID,
- ProxyName: pxy.cfg.Name,
+ ProxyName: naming.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
Sid: natHoleSidMsg.Sid,
MappedAddrs: prepareResult.Addrs,
AssistedAddrs: prepareResult.AssistedAddrs,
@@ -130,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
}
+func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
+ workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
+ var natHoleSidMsg msg.NatHoleSid
+ if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
+ return nil, err
+ }
+ return &natHoleSidMsg, nil
+}
+
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
listenConn.Close()
diff --git a/client/proxy/xtcp_test.go b/client/proxy/xtcp_test.go
new file mode 100644
index 00000000..bd295a07
--- /dev/null
+++ b/client/proxy/xtcp_test.go
@@ -0,0 +1,66 @@
+// 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.
+
+//go:build !frps
+
+package proxy
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ wireProtocol string
+ }{
+ {name: "v2", wireProtocol: wire.ProtocolV2},
+ {name: "v1", wireProtocol: wire.ProtocolV1},
+ {name: "default", wireProtocol: ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+ setPipeDeadline(t, client, server)
+
+ errCh := make(chan error, 1)
+ go func() {
+ writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
+ errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
+ }()
+
+ out, err := readNatHoleSid(client, tc.wireProtocol)
+ require.NoError(t, err)
+ require.Equal(t, "sid", out.Sid)
+ require.NoError(t, <-errCh)
+ })
+ }
+}
+
+func setPipeDeadline(t *testing.T, conns ...net.Conn) {
+ t.Helper()
+
+ deadline := time.Now().Add(time.Second)
+ for _, conn := range conns {
+ require.NoError(t, conn.SetDeadline(deadline))
+ }
+}
diff --git a/client/service.go b/client/service.go
index 5239030d..96a8192b 100644
--- a/client/service.go
+++ b/client/service.go
@@ -19,8 +19,8 @@ import (
"errors"
"fmt"
"net"
+ "net/http"
"os"
- "runtime"
"sync"
"time"
@@ -29,13 +29,14 @@ 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"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
- "github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -61,9 +62,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 +122,23 @@ type Service struct {
vnetController *vnet.Controller
- cfgMu sync.RWMutex
- common *v1.ClientCommonConfig
- proxyCfgs []v1.ProxyConfigurer
- visitorCfgs []v1.VisitorConfigurer
- clientSpec *msg.ClientSpec
+ cfgMu sync.RWMutex
+ // reloadMu serializes reload transactions to keep reloadCommon and applied
+ // config in sync across concurrent API operations.
+ reloadMu sync.Mutex
+ 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
@@ -146,6 +161,28 @@ func NewService(options ServiceOptions) (*Service, error) {
return nil, err
}
+ authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
+ if err != nil {
+ 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)
+
+ // Create the web server after all fallible steps so its listener is not
+ // leaked when an earlier error causes NewService to return.
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
@@ -155,24 +192,24 @@ func NewService(options ServiceOptions) (*Service, error) {
webServer = ws
}
- authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
- if err != nil {
- return nil, err
- }
-
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)
}
@@ -193,22 +230,25 @@ func (svr *Service) Run(ctx context.Context) error {
}
if svr.vnetController != nil {
+ vnetController := svr.vnetController
if err := svr.vnetController.Init(); err != nil {
log.Errorf("init virtual network controller error: %v", err)
+ svr.stop()
return err
}
go func() {
log.Infof("virtual network controller start...")
- if err := svr.vnetController.Run(); err != nil {
+ if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Warnf("virtual network controller exit with error: %v", err)
}
}()
}
if svr.webServer != nil {
+ webServer := svr.webServer
go func() {
- log.Infof("admin server listen on %s", svr.webServer.Address())
- if err := svr.webServer.Run(); err != nil {
+ log.Infof("admin server listen on %s", webServer.Address())
+ if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warnf("admin server exit with error: %v", err)
}
}()
@@ -219,6 +259,7 @@ func (svr *Service) Run(ctx context.Context) error {
if svr.ctl == nil {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
+ svr.stop()
return fmt.Errorf("登录服务器失败: %v. 启用 loginFailExit 后,将不再尝试重试", cancelCause.Err)
}
@@ -260,80 +301,20 @@ func (svr *Service) keepControllerWorking() {
), true, svr.ctx.Done())
}
-// login creates a connection to frps and registers it self as a client
-// conn: control connection
-// session: if it's not nil, using tcp mux
-func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
- xl := xlog.FromContextSafe(svr.ctx)
- connector = svr.connectorCreator(svr.ctx, svr.common)
- if err = connector.Open(); err != nil {
- return nil, nil, err
- }
-
- defer func() {
- if err != nil {
- connector.Close()
- }
- }()
-
- conn, err = connector.Connect()
- if err != nil {
- return
- }
-
- hostname, _ := os.Hostname()
-
- loginMsg := &msg.Login{
- Arch: runtime.GOARCH,
- Os: runtime.GOOS,
- Hostname: hostname,
- PoolCount: svr.common.Transport.PoolCount,
- User: svr.common.User,
- ClientID: svr.common.ClientID,
- Version: version.Full(),
- Timestamp: time.Now().Unix(),
- RunID: svr.runID,
- Metas: svr.common.Metadatas,
- }
- if svr.clientSpec != nil {
- loginMsg.ClientSpec = *svr.clientSpec
- }
-
- // Add auth
- if err = svr.auth.Setter.SetLogin(loginMsg); err != nil {
- return
- }
-
- if err = msg.WriteMsg(conn, loginMsg); err != nil {
- return
- }
-
- var loginRespMsg msg.LoginResp
- _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
- if err = msg.ReadMsgInto(conn, &loginRespMsg); err != nil {
- return
- }
- _ = conn.SetReadDeadline(time.Time{})
-
- if loginRespMsg.Error != "" {
- err = fmt.Errorf("%s", loginRespMsg.Error)
- xl.Errorf("%s", loginRespMsg.Error)
- return
- }
-
- svr.runID = loginRespMsg.RunID
- xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
-
- xl.Infof("登录服务器成功, 获取 run id [%s]", loginRespMsg.RunID)
- return
-}
-
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
xl := xlog.FromContextSafe(svr.ctx)
loginFunc := func() (bool, error) {
xl.Infof("尝试连接到服务器...")
- conn, connector, err := svr.login()
+ dialer := &controlSessionDialer{
+ ctx: svr.ctx,
+ common: svr.common,
+ auth: svr.auth,
+ clientSpec: svr.clientSpec,
+ vnetController: svr.vnetController,
+ connectorCreator: svr.connectorCreator,
+ }
+ sessionCtx, err := dialer.Dial(svr.runID)
if err != nil {
xl.Warnf("连接服务器错误: %v", err)
if firstLoginExit {
@@ -342,25 +323,19 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
return false, err
}
+ svr.runID = sessionCtx.RunID
+ xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
+ xl.Infof("login to server success, get run id [%s]", svr.runID)
+
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
- connEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != "ssh-tunnel"
-
- sessionCtx := &SessionContext{
- Common: svr.common,
- RunID: svr.runID,
- Conn: conn,
- ConnEncrypted: connEncrypted,
- Auth: svr.auth,
- Connector: connector,
- VnetController: svr.vnetController,
- }
ctl, err := NewControl(svr.ctx, sessionCtx)
if err != nil {
- conn.Close()
+ sessionCtx.Conn.Close()
+ sessionCtx.Connector.Close()
xl.Errorf("new control error: %v", err)
return false, err
}
@@ -403,6 +378,35 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
return nil
}
+func (svr *Service) UpdateConfigSource(
+ common *v1.ClientCommonConfig,
+ proxyCfgs []v1.ProxyConfigurer,
+ visitorCfgs []v1.VisitorConfigurer,
+) error {
+ svr.reloadMu.Lock()
+ defer svr.reloadMu.Unlock()
+
+ cfgSource := svr.configSource
+ if cfgSource == nil {
+ return fmt.Errorf("config source is not available")
+ }
+
+ if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
+ return err
+ }
+
+ // Non-atomic update semantics: source has been updated at this point.
+ // Even if reload fails below, keep this common config for subsequent reloads.
+ svr.cfgMu.Lock()
+ svr.reloadCommon = common
+ svr.cfgMu.Unlock()
+
+ if err := svr.reloadConfigFromSourcesLocked(); err != nil {
+ return err
+ }
+ return nil
+}
+
func (svr *Service) Close() {
svr.GracefulClose(time.Duration(0))
}
@@ -413,6 +417,15 @@ func (svr *Service) GracefulClose(d time.Duration) {
}
func (svr *Service) stop() {
+ // Coordinate shutdown with reload/update paths that read source pointers.
+ svr.reloadMu.Lock()
+ if svr.aggregator != nil {
+ svr.aggregator = nil
+ }
+ svr.configSource = nil
+ svr.storeSource = nil
+ svr.reloadMu.Unlock()
+
svr.ctlMu.Lock()
defer svr.ctlMu.Unlock()
if svr.ctl != nil {
@@ -423,6 +436,10 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
+ if svr.vnetController != nil {
+ _ = svr.vnetController.Stop()
+ svr.vnetController = nil
+ }
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
@@ -436,6 +453,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return ctl.pm.GetProxyStatus(name)
}
+func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
+ svr.ctlMu.RLock()
+ ctl := svr.ctl
+ svr.ctlMu.RUnlock()
+
+ if ctl == nil {
+ return nil, false
+ }
+ return ctl.vm.GetVisitorCfg(name)
+}
+
func (svr *Service) StatusExporter() StatusExporter {
return &statusExporterImpl{
getProxyStatusFunc: svr.getProxyStatus,
@@ -453,3 +481,35 @@ type statusExporterImpl struct {
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return s.getProxyStatusFunc(name)
}
+
+func (svr *Service) reloadConfigFromSources() error {
+ svr.reloadMu.Lock()
+ defer svr.reloadMu.Unlock()
+ return svr.reloadConfigFromSourcesLocked()
+}
+
+func (svr *Service) reloadConfigFromSourcesLocked() error {
+ aggregator := svr.aggregator
+ if aggregator == nil {
+ return errors.New("config aggregator is not initialized")
+ }
+
+ svr.cfgMu.RLock()
+ reloadCommon := svr.reloadCommon
+ svr.cfgMu.RUnlock()
+
+ proxies, visitors, err := 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/service_test.go b/client/service_test.go
new file mode 100644
index 00000000..29f141a1
--- /dev/null
+++ b/client/service_test.go
@@ -0,0 +1,246 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "net"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/samber/lo"
+
+ "github.com/fatedier/frp/pkg/config/source"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type failingConnector struct {
+ err error
+}
+
+func (c *failingConnector) Open() error {
+ return c.err
+}
+
+func (c *failingConnector) Connect() (net.Conn, error) {
+ return nil, c.err
+}
+
+func (c *failingConnector) Close() error {
+ return nil
+}
+
+func getFreeTCPPort(t *testing.T) int {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen on ephemeral port: %v", err)
+ }
+ defer ln.Close()
+
+ return ln.Addr().(*net.TCPAddr).Port
+}
+
+func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
+ port := getFreeTCPPort(t)
+ agg := source.NewAggregator(source.NewConfigSource())
+
+ svr, err := NewService(ServiceOptions{
+ Common: &v1.ClientCommonConfig{
+ LoginFailExit: lo.ToPtr(true),
+ WebServer: v1.WebServerConfig{
+ Addr: "127.0.0.1",
+ Port: port,
+ },
+ },
+ ConfigSourceAggregator: agg,
+ ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
+ return &failingConnector{err: errors.New("login boom")}
+ },
+ })
+ if err != nil {
+ t.Fatalf("new service: %v", err)
+ }
+
+ err = svr.Run(context.Background())
+ if err == nil {
+ t.Fatal("expected run error, got nil")
+ }
+ if !strings.Contains(err.Error(), "login boom") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if svr.webServer != nil {
+ t.Fatal("expected web server to be cleaned up after initial login failure")
+ }
+
+ ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+ if err != nil {
+ t.Fatalf("expected admin port to be released: %v", err)
+ }
+ _ = ln.Close()
+}
+
+func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
+ port := getFreeTCPPort(t)
+ agg := source.NewAggregator(source.NewConfigSource())
+
+ _, err := NewService(ServiceOptions{
+ Common: &v1.ClientCommonConfig{
+ Auth: v1.AuthClientConfig{
+ Method: v1.AuthMethodOIDC,
+ OIDC: v1.AuthOIDCClientConfig{
+ TokenEndpointURL: "://bad",
+ },
+ },
+ WebServer: v1.WebServerConfig{
+ Addr: "127.0.0.1",
+ Port: port,
+ },
+ },
+ ConfigSourceAggregator: agg,
+ })
+ if err == nil {
+ t.Fatal("expected new service error, got nil")
+ }
+ if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+ if err != nil {
+ t.Fatalf("expected admin port to remain free: %v", err)
+ }
+ _ = ln.Close()
+}
+
+func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
+ prevCommon := &v1.ClientCommonConfig{User: "old-user"}
+ newCommon := &v1.ClientCommonConfig{User: "new-user"}
+
+ svr := &Service{
+ configSource: source.NewConfigSource(),
+ reloadCommon: prevCommon,
+ }
+
+ invalidProxy := &v1.TCPProxyConfig{}
+ err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{invalidProxy}, nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "proxy name cannot be empty") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if svr.reloadCommon != prevCommon {
+ t.Fatalf("reloadCommon should roll back on ReplaceAll failure")
+ }
+}
+
+func TestUpdateConfigSourceKeepsReloadCommonOnReloadFailure(t *testing.T) {
+ prevCommon := &v1.ClientCommonConfig{User: "old-user"}
+ newCommon := &v1.ClientCommonConfig{User: "new-user"}
+
+ svr := &Service{
+ // Keep configSource valid so ReplaceAll succeeds first.
+ configSource: source.NewConfigSource(),
+ reloadCommon: prevCommon,
+ // Keep aggregator nil to force reload failure.
+ aggregator: nil,
+ }
+
+ validProxy := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "p1",
+ Type: "tcp",
+ },
+ }
+ err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{validProxy}, nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "config aggregator is not initialized") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if svr.reloadCommon != newCommon {
+ t.Fatalf("reloadCommon should keep new value on reload failure")
+ }
+}
+
+func TestReloadConfigFromSourcesDoesNotMutateStoreConfigs(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+
+ proxyCfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "store-proxy",
+ Type: "tcp",
+ },
+ }
+ visitorCfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "store-visitor",
+ Type: "stcp",
+ },
+ }
+ if err := storeSource.AddProxy(proxyCfg); err != nil {
+ t.Fatalf("add proxy to store: %v", err)
+ }
+ if err := storeSource.AddVisitor(visitorCfg); err != nil {
+ t.Fatalf("add visitor to store: %v", err)
+ }
+
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+ svr := &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ }
+
+ if err := svr.reloadConfigFromSources(); err != nil {
+ t.Fatalf("reload config from sources: %v", err)
+ }
+
+ gotProxy := storeSource.GetProxy("store-proxy")
+ if gotProxy == nil {
+ t.Fatalf("proxy not found in store")
+ }
+ if gotProxy.GetBaseConfig().LocalIP != "" {
+ t.Fatalf("store proxy localIP should stay empty, got %q", gotProxy.GetBaseConfig().LocalIP)
+ }
+
+ gotVisitor := storeSource.GetVisitor("store-visitor")
+ if gotVisitor == nil {
+ t.Fatalf("visitor not found in store")
+ }
+ if gotVisitor.GetBaseConfig().BindAddr != "" {
+ t.Fatalf("store visitor bindAddr should stay empty, got %q", gotVisitor.GetBaseConfig().BindAddr)
+ }
+
+ svr.cfgMu.RLock()
+ defer svr.cfgMu.RUnlock()
+
+ if len(svr.proxyCfgs) != 1 {
+ t.Fatalf("expected 1 runtime proxy, got %d", len(svr.proxyCfgs))
+ }
+ if svr.proxyCfgs[0].GetBaseConfig().LocalIP != "127.0.0.1" {
+ t.Fatalf("runtime proxy localIP should be defaulted, got %q", svr.proxyCfgs[0].GetBaseConfig().LocalIP)
+ }
+
+ if len(svr.visitorCfgs) != 1 {
+ t.Fatalf("expected 1 runtime visitor, got %d", len(svr.visitorCfgs))
+ }
+ if svr.visitorCfgs[0].GetBaseConfig().BindAddr != "127.0.0.1" {
+ t.Fatalf("runtime visitor bindAddr should be defaulted, got %q", svr.visitorCfgs[0].GetBaseConfig().BindAddr)
+ }
+}
diff --git a/client/visitor/stcp.go b/client/visitor/stcp.go
index 31f6f174..03ec51fe 100644
--- a/client/visitor/stcp.go
+++ b/client/visitor/stcp.go
@@ -15,17 +15,12 @@
package visitor
import (
- "fmt"
- "io"
"net"
"strconv"
- "time"
libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/msg"
- "github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -41,10 +36,10 @@ func (sv *STCPVisitor) Run() (err error) {
if err != nil {
return
}
- go sv.worker()
+ go sv.acceptLoop(sv.l, "stcp local", sv.handleConn)
}
- go sv.internalConnWorker()
+ go sv.acceptLoop(sv.internalLn, "stcp internal", sv.handleConn)
if sv.plugin != nil {
sv.plugin.Start()
@@ -56,35 +51,10 @@ func (sv *STCPVisitor) Close() {
sv.BaseVisitor.Close()
}
-func (sv *STCPVisitor) worker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.l.Accept()
- if err != nil {
- xl.Warnf("stcp local listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
-func (sv *STCPVisitor) internalConnWorker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.internalLn.Accept()
- if err != nil {
- xl.Warnf("stcp internal listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
func (sv *STCPVisitor) handleConn(userConn net.Conn) {
xl := xlog.FromContextSafe(sv.ctx)
var tunnelErr error
defer func() {
- // If there was an error and connection supports CloseWithError, use it
if tunnelErr != nil {
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
_ = eConn.CloseWithError(tunnelErr)
@@ -95,61 +65,21 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
}()
xl.Debugf("get a new stcp user connection")
- visitorConn, err := sv.helper.ConnectServer()
+ visitorConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
if err != nil {
+ xl.Warnf("dialRawVisitorConn error: %v", err)
tunnelErr = err
return
}
defer visitorConn.Close()
- now := time.Now().Unix()
- newVisitorConnMsg := &msg.NewVisitorConn{
- RunID: sv.helper.RunID(),
- ProxyName: sv.cfg.ServerName,
- SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
- Timestamp: now,
- UseEncryption: sv.cfg.Transport.UseEncryption,
- UseCompression: sv.cfg.Transport.UseCompression,
- }
- err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
+ remote, recycleFn, err := wrapVisitorConn(visitorConn, sv.cfg.GetBaseConfig())
if err != nil {
- xl.Warnf("send newVisitorConnMsg to server error: %v", err)
+ xl.Warnf("wrapVisitorConn error: %v", err)
tunnelErr = err
return
}
-
- var newVisitorConnRespMsg msg.NewVisitorConnResp
- _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
- err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
- if err != nil {
- xl.Warnf("get newVisitorConnRespMsg error: %v", err)
- tunnelErr = err
- return
- }
- _ = visitorConn.SetReadDeadline(time.Time{})
-
- if newVisitorConnRespMsg.Error != "" {
- xl.Warnf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
- tunnelErr = fmt.Errorf("%s", newVisitorConnRespMsg.Error)
- return
- }
-
- var remote io.ReadWriteCloser
- remote = visitorConn
- if sv.cfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- tunnelErr = err
- return
- }
- }
-
- if sv.cfg.Transport.UseCompression {
- var recycleFn func()
- remote, recycleFn = libio.WithCompressionFromPool(remote)
- defer recycleFn()
- }
+ defer recycleFn()
libio.Join(userConn, remote)
}
diff --git a/client/visitor/sudp.go b/client/visitor/sudp.go
index 284aee10..91d57da6 100644
--- a/client/visitor/sudp.go
+++ b/client/visitor/sudp.go
@@ -16,20 +16,17 @@ package visitor
import (
"fmt"
- "io"
"net"
"strconv"
"sync"
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
netpkg "github.com/fatedier/frp/pkg/util/net"
- "github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -75,6 +72,7 @@ func (sv *SUDPVisitor) dispatcher() {
var (
visitorConn net.Conn
+ recycleFn func()
err error
firstPacket *msg.UDPPacket
@@ -92,14 +90,17 @@ func (sv *SUDPVisitor) dispatcher() {
return
}
- visitorConn, err = sv.getNewVisitorConn()
+ visitorConn, recycleFn, err = sv.getNewVisitorConn()
if err != nil {
xl.Warnf("newVisitorConn to frps error: %v, try to reconnect", err)
continue
}
// visitorConn always be closed when worker done.
- sv.worker(visitorConn, firstPacket)
+ func() {
+ defer recycleFn()
+ sv.worker(visitorConn, firstPacket)
+ }()
select {
case <-sv.checkCloseCh:
@@ -112,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
+ payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
wg := &sync.WaitGroup{}
wg.Add(2)
closeCh := make(chan struct{})
// udp service -> frpc -> frps -> frpc visitor -> user
- workConnReaderFn := func(conn net.Conn) {
+ workConnReaderFn := func(payloadConn *msg.Conn) {
defer func() {
- conn.Close()
+ payloadConn.Close()
close(closeCh)
wg.Done()
}()
@@ -132,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
)
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
- _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
- if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
+ _ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
+ if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
return
}
- _ = conn.SetReadDeadline(time.Time{})
+ _ = payloadConn.SetReadDeadline(time.Time{})
switch m := rawMsg.(type) {
case *msg.Ping:
xl.Debugf("frpc visitor get ping message from frpc")
@@ -146,7 +148,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
sv.readCh <- m
- xl.Tracef("frpc visitor get udp packet from workConn: %s", m.Content)
+ xl.Tracef("frpc visitor get udp packet from workConn, len: %d", len(m.Content))
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed")
return
@@ -156,19 +158,19 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
- workConnSenderFn := func(conn net.Conn) {
+ workConnSenderFn := func(payloadConn *msg.Conn) {
defer func() {
- conn.Close()
+ payloadConn.Close()
wg.Done()
}()
var errRet error
if firstPacket != nil {
- if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
+ if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
- xl.Tracef("send udp package to workConn: %s", firstPacket.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(firstPacket.Content))
}
for {
@@ -179,70 +181,35 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
return
}
- if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
+ if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
- xl.Tracef("send udp package to workConn: %s", udpMsg.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(udpMsg.Content))
case <-closeCh:
return
}
}
}
- go workConnReaderFn(workConn)
- go workConnSenderFn(workConn)
+ go workConnReaderFn(payloadConn)
+ go workConnSenderFn(payloadConn)
wg.Wait()
xl.Infof("sudp worker is closed")
}
-func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
- xl := xlog.FromContextSafe(sv.ctx)
- visitorConn, err := sv.helper.ConnectServer()
+func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, func(), error) {
+ rawConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
if err != nil {
- return nil, fmt.Errorf("frpc connect frps error: %v", err)
+ return nil, func() {}, err
}
-
- now := time.Now().Unix()
- newVisitorConnMsg := &msg.NewVisitorConn{
- RunID: sv.helper.RunID(),
- ProxyName: sv.cfg.ServerName,
- SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
- Timestamp: now,
- UseEncryption: sv.cfg.Transport.UseEncryption,
- UseCompression: sv.cfg.Transport.UseCompression,
- }
- err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
+ rwc, recycleFn, err := wrapVisitorConn(rawConn, sv.cfg.GetBaseConfig())
if err != nil {
- return nil, fmt.Errorf("frpc send newVisitorConnMsg to frps error: %v", err)
+ rawConn.Close()
+ return nil, func() {}, err
}
-
- var newVisitorConnRespMsg msg.NewVisitorConnResp
- _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
- err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
- if err != nil {
- return nil, fmt.Errorf("frpc read newVisitorConnRespMsg error: %v", err)
- }
- _ = visitorConn.SetReadDeadline(time.Time{})
-
- if newVisitorConnRespMsg.Error != "" {
- return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
- }
-
- var remote io.ReadWriteCloser
- remote = visitorConn
- if sv.cfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- return nil, err
- }
- }
- if sv.cfg.Transport.UseCompression {
- remote = libio.WithCompression(remote)
- }
- return netpkg.WrapReadWriteCloserToConn(remote, visitorConn), nil
+ return netpkg.WrapReadWriteCloserToConn(rwc, rawConn), recycleFn, nil
}
func (sv *SUDPVisitor) Close() {
diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go
index 87e4f29f..dff0bb94 100644
--- a/client/visitor/visitor.go
+++ b/client/visitor/visitor.go
@@ -16,13 +16,21 @@ package visitor
import (
"context"
+ "fmt"
+ "io"
"net"
"sync"
+ "time"
+
+ libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
plugin "github.com/fatedier/frp/pkg/plugin/visitor"
"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/xlog"
"github.com/fatedier/frp/pkg/vnet"
)
@@ -30,7 +38,7 @@ import (
// Helper wraps some functions for visitor to use.
type Helper interface {
// ConnectServer directly connects to the frp server.
- ConnectServer() (net.Conn, error)
+ ConnectServer() (*msg.Conn, error)
// TransferConn transfers the connection to another visitor.
TransferConn(string, net.Conn) error
// MsgTransporter returns the message transporter that is used to send and receive messages
@@ -119,6 +127,18 @@ func (v *BaseVisitor) AcceptConn(conn net.Conn) error {
return v.internalLn.PutConn(conn)
}
+func (v *BaseVisitor) acceptLoop(l net.Listener, name string, handleConn func(net.Conn)) {
+ xl := xlog.FromContextSafe(v.ctx)
+ for {
+ conn, err := l.Accept()
+ if err != nil {
+ xl.Warnf("%s listener closed", name)
+ return
+ }
+ go handleConn(conn)
+ }
+}
+
func (v *BaseVisitor) Close() {
if v.l != nil {
v.l.Close()
@@ -130,3 +150,57 @@ func (v *BaseVisitor) Close() {
v.plugin.Close()
}
}
+
+func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, error) {
+ visitorConn, err := v.helper.ConnectServer()
+ if err != nil {
+ return nil, fmt.Errorf("connect to server error: %v", err)
+ }
+
+ now := time.Now().Unix()
+ targetProxyName := naming.BuildTargetServerProxyName(v.clientCfg.User, cfg.ServerUser, cfg.ServerName)
+ newVisitorConnMsg := &msg.NewVisitorConn{
+ RunID: v.helper.RunID(),
+ ProxyName: targetProxyName,
+ SignKey: util.GetAuthKey(cfg.SecretKey, now),
+ Timestamp: now,
+ UseEncryption: cfg.Transport.UseEncryption,
+ UseCompression: cfg.Transport.UseCompression,
+ }
+ err = visitorConn.WriteMsg(newVisitorConnMsg)
+ if err != nil {
+ visitorConn.Close()
+ return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err)
+ }
+
+ _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ var newVisitorConnRespMsg msg.NewVisitorConnResp
+ err = visitorConn.ReadMsgInto(&newVisitorConnRespMsg)
+ if err != nil {
+ visitorConn.Close()
+ return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err)
+ }
+ _ = visitorConn.SetReadDeadline(time.Time{})
+
+ if newVisitorConnRespMsg.Error != "" {
+ visitorConn.Close()
+ return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
+ }
+ return visitorConn, nil
+}
+
+func wrapVisitorConn(conn io.ReadWriteCloser, cfg *v1.VisitorBaseConfig) (io.ReadWriteCloser, func(), error) {
+ rwc := conn
+ if cfg.Transport.UseEncryption {
+ var err error
+ rwc, err = libio.WithEncryption(rwc, []byte(cfg.SecretKey))
+ if err != nil {
+ return nil, func() {}, fmt.Errorf("create encryption stream error: %v", err)
+ }
+ }
+ recycleFn := func() {}
+ if cfg.Transport.UseCompression {
+ rwc, recycleFn = libio.WithCompressionFromPool(rwc)
+ }
+ return rwc, recycleFn, nil
+}
diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go
index b3539c69..1ca194bd 100644
--- a/client/visitor/visitor_manager.go
+++ b/client/visitor/visitor_manager.go
@@ -25,6 +25,7 @@ import (
"github.com/samber/lo"
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/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -49,7 +50,7 @@ func NewManager(
ctx context.Context,
runID string,
clientCfg *v1.ClientCommonConfig,
- connectServer func() (net.Conn, error),
+ connectServer func() (*msg.Conn, error),
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
) *Manager {
@@ -191,15 +192,22 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error {
return v.AcceptConn(conn)
}
+func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
+ vm.mu.RLock()
+ defer vm.mu.RUnlock()
+ cfg, ok := vm.cfgs[name]
+ return cfg, ok
+}
+
type visitorHelperImpl struct {
- connectServerFn func() (net.Conn, error)
+ connectServerFn func() (*msg.Conn, error)
msgTransporter transport.MessageTransporter
vnetController *vnet.Controller
transferConnFn func(name string, conn net.Conn) error
runID string
}
-func (v *visitorHelperImpl) ConnectServer() (net.Conn, error) {
+func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
return v.connectServerFn()
}
diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go
index cdfeb1ab..e7a60895 100644
--- a/client/visitor/xtcp.go
+++ b/client/visitor/xtcp.go
@@ -31,6 +31,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/nathole"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -64,10 +65,10 @@ func (sv *XTCPVisitor) Run() (err error) {
if err != nil {
return
}
- go sv.worker()
+ go sv.acceptLoop(sv.l, "xtcp local", sv.handleConn)
}
- go sv.internalConnWorker()
+ go sv.acceptLoop(sv.internalLn, "xtcp internal", sv.handleConn)
go sv.processTunnelStartEvents()
if sv.cfg.KeepTunnelOpen {
sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)
@@ -92,30 +93,6 @@ func (sv *XTCPVisitor) Close() {
}
}
-func (sv *XTCPVisitor) worker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.l.Accept()
- if err != nil {
- xl.Warnf("xtcp local listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
-func (sv *XTCPVisitor) internalConnWorker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.internalLn.Accept()
- if err != nil {
- xl.Warnf("xtcp internal listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
func (sv *XTCPVisitor) processTunnelStartEvents() {
for {
select {
@@ -205,20 +182,14 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
return
}
- var muxConnRWCloser io.ReadWriteCloser = tunnelConn
- if sv.cfg.Transport.UseEncryption {
- muxConnRWCloser, err = libio.WithEncryption(muxConnRWCloser, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- tunnelErr = err
- return
- }
- }
- if sv.cfg.Transport.UseCompression {
- var recycleFn func()
- muxConnRWCloser, recycleFn = libio.WithCompressionFromPool(muxConnRWCloser)
- defer recycleFn()
+ muxConnRWCloser, recycleFn, err := wrapVisitorConn(tunnelConn, sv.cfg.GetBaseConfig())
+ if err != nil {
+ xl.Errorf("%v", err)
+ tunnelConn.Close()
+ tunnelErr = err
+ return
}
+ defer recycleFn()
_, _, errs := libio.Join(userConn, muxConnRWCloser)
xl.Debugf("join connections closed")
@@ -280,8 +251,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 := naming.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 +282,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,
@@ -371,6 +343,7 @@ func (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) er
}
remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())
if err != nil {
+ lConn.Close()
return fmt.Errorf("create kcp connection from udp connection error: %v", err)
}
diff --git a/cmd/frpc/sub/nathole.go b/cmd/frpc/sub/nathole.go
index bcfdb02b..13eb6114 100644
--- a/cmd/frpc/sub/nathole.go
+++ b/cmd/frpc/sub/nathole.go
@@ -47,16 +47,18 @@ var natholeDiscoveryCmd = &cobra.Command{
Use: "discover",
Short: "Discover nathole information from stun server",
RunE: func(cmd *cobra.Command, args []string) error {
- // ignore error here, because we can use command line pameters
+ // ignore error here, because we can use command line parameters
var cfg *v1.ClientCommonConfig
if len(cfgFiles) > 0 && cfgFiles[0] != "" {
- _, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
+ loaded, _, _, _, err := config.LoadClientConfig(cfgFiles[0], strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
os.Exit(1)
}
+ } else {
+ cfg = loaded
}
} else {
cfg = &v1.ClientCommonConfig{}
diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go
index e8b5c0f1..ef19f1d5 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,20 @@ 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,
+ nodeName string,
+ tunnelRemark 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, nodeName, tunnelRemark)
+}
diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go
index 23a2faa6..0428d412 100644
--- a/cmd/frpc/sub/root.go
+++ b/cmd/frpc/sub/root.go
@@ -33,6 +33,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"
@@ -165,22 +166,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)
}
@@ -188,13 +231,12 @@ 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,
nodeName string,
@@ -218,18 +260,16 @@ func startService(
defer log.Infof("frpc 服务 [%s] 已停止", 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)
}
@@ -401,13 +441,9 @@ func runClientWithConfig(configBytes []byte, unsafeFeatures *security.UnsafeFeat
return fmt.Errorf("failed to complete config: %v", err)
}
- // Call Complete for all proxies to add name prefix (e.g., user.tunnel_name)
- for _, c := range proxyCfgs {
- c.Complete(cfg.User)
- }
- for _, c := range visitorCfgs {
- c.Complete(cfg)
- }
+ proxyCfgs, visitorCfgs = config.FilterClientConfigurers(cfg, proxyCfgs, visitorCfgs)
+ proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
+ visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
if len(cfg.FeatureGates) > 0 {
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml
index 54bbf73e..e4e15605 100644
--- a/conf/frpc_full_example.toml
+++ b/conf/frpc_full_example.toml
@@ -103,6 +103,10 @@ transport.poolCount = 5
# supports tcp, kcp, quic, websocket and wss now, default is tcp
transport.protocol = "tcp"
+# FRP wire protocol used inside the selected transport.
+# supports v1 and v2, default is v1. v2 requires frps support and must be enabled explicitly.
+# transport.wireProtocol = "v1"
+
# set client binding ip when connect server, default is empty.
# only when protocol = tcp or websocket, the value will be used.
transport.connectServerLocalIP = "0.0.0.0"
@@ -143,6 +147,9 @@ transport.tls.enable = true
# Proxy names you want to start.
# Default is empty, means all proxies.
+# This list is a global allowlist after config + store are merged, so entries
+# created via Store API are also filtered by this list.
+# If start is non-empty, any proxy/visitor not listed here will not be started.
# start = ["ssh", "dns"]
# Alternative to 'start': You can control each proxy individually using the 'enabled' field.
diff --git a/doc/agents/release.md b/doc/agents/release.md
new file mode 100644
index 00000000..d353a088
--- /dev/null
+++ b/doc/agents/release.md
@@ -0,0 +1,124 @@
+# Release Process
+
+## 1. Update Release Notes
+
+Edit `Release.md` in the project root with the changes for this version:
+
+```markdown
+## Features
+* ...
+
+## Improvements
+* ...
+
+## Fixes
+* ...
+```
+
+This file is used by GoReleaser as the GitHub Release body.
+
+## 2. Bump Version
+
+Update the version string in `pkg/util/version/version.go`:
+
+```go
+var version = "0.X.0"
+```
+
+Commit and push to `dev`:
+
+```bash
+git add pkg/util/version/version.go Release.md
+git commit -m "bump version to vX.Y.Z"
+git push origin dev
+```
+
+## 3. Pre-release Validation
+
+Run the standard e2e suite locally:
+
+```bash
+make e2e
+```
+
+For releases that touch compatibility-sensitive areas such as login, control
+connections, work connections, visitors, transport, or wire protocol handling,
+also run the manual compatibility e2e suite:
+
+```bash
+make e2e-compatibility
+make e2e-compatibility-floor
+```
+
+`make e2e-compatibility` builds the current `frps` and `frpc`, resolves the
+recent stable release baselines from GitHub, downloads or reuses their binaries,
+and tests current binaries against those historical releases. The default number
+of recent baselines is controlled by `FRP_COMPAT_BASELINE_COUNT` in the
+`Makefile`.
+
+Downloaded release binaries are cached under:
+
+```text
+.cache/e2e-compat/
/_/
+```
+
+For a release validation run that must be exactly reproducible, pass an explicit
+baseline matrix instead of using the floating recent-release list:
+
+```bash
+FRP_COMPAT_BASELINE_VERSIONS="0.X.0 0.Y.0" make e2e-compatibility
+```
+
+Use `make e2e-compatibility-smoke` for a quick single-baseline check while
+iterating locally. If GitHub release metadata requests are rate-limited, set
+`GITHUB_TOKEN` or use `FRP_COMPAT_BASELINE_VERSIONS`.
+
+The compatibility floor is a support-policy decision, not a value that should
+change every release. Update `FRP_COMPAT_FLOOR_VERSION` only when the declared
+compatibility window changes.
+
+## 4. Merge dev → master
+
+Create a PR from `dev` to `master`:
+
+```bash
+gh pr create --base master --head dev --title "bump version"
+```
+
+Wait for CI to pass, then merge using **merge commit** (not squash).
+
+## 5. Tag the Release
+
+```bash
+git checkout master
+git pull origin master
+git tag -a vX.Y.Z -m "bump version"
+git push origin vX.Y.Z
+```
+
+## 6. Trigger GoReleaser
+
+Manually trigger the `goreleaser` workflow in GitHub Actions:
+
+```bash
+gh workflow run goreleaser --ref master
+```
+
+GoReleaser will:
+1. Run `package.sh` to cross-compile all platforms and create archives
+2. Create a GitHub Release with all packages, using `Release.md` as release notes
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `pkg/util/version/version.go` | Version string |
+| `Release.md` | Release notes (read by GoReleaser) |
+| `.goreleaser.yml` | GoReleaser config |
+| `package.sh` | Cross-compile and packaging script |
+| `.github/workflows/goreleaser.yml` | GitHub Actions workflow (manual trigger) |
+
+## Versioning
+
+- Minor release: `v0.X.0`
+- Patch release: `v0.X.Y` (e.g., `v0.62.1`)
diff --git a/doc/deprecations.md b/doc/deprecations.md
new file mode 100644
index 00000000..a5d394c5
--- /dev/null
+++ b/doc/deprecations.md
@@ -0,0 +1,38 @@
+# Deprecations
+
+This document tracks deprecated features and APIs that are still shipped but scheduled for removal. Maintainers should review this list before each release to decide whether any items are due for removal.
+
+For the version compatibility policy that bounds these support windows, see the latest `Release.md`.
+
+## Active
+
+### Wire protocol v1
+
+- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
+- **Removal target:** v0.78.0 or later. v0.69.0 (the last release where v1 is the default) is supported until v0.78.0 is released, so v0.77.0 is the last release that must keep v1 support.
+- **Replacement:** wire protocol v2 (`transport.wireProtocol = "v2"` in frpc).
+- **Code references:** v1 message types and codec under `pkg/msg/` and the protocol negotiation path in `client/` and `server/`.
+- **Notes:** Removing v1 will also drop compatibility with any frpc/frps that does not negotiate v2.
+
+### INI configuration format
+
+- **Deprecated since:** predates this document; startup warning has been in place for several releases.
+- **Removal target:** TBD.
+- **Replacement:** YAML / JSON / TOML.
+- **Code references:**
+ - `cmd/frpc/sub/root.go` — frpc startup warning.
+ - `cmd/frps/root.go` — frps startup warning.
+ - `pkg/config/legacy/` — legacy INI parser; remove together with the warnings.
+
+### Visitor connections without `runID`
+
+- **Deprecated since:** v0.50.0 (when `runID` was introduced).
+- **Removal target:** TBD.
+- **Replacement:** require `runID` on every visitor connection.
+- **Code references:**
+ - `server/service.go` — `RegisterVisitorConn` still accepts empty `runID` for backward compatibility.
+- **Notes:** Removal will break frpc clients released before v0.50.0. Schedule for a release where dropping pre-v0.50.0 frpc is acceptable.
+
+## Removed
+
+_None yet._
diff --git a/doc/pic/architecture.jpg b/doc/pic/architecture.jpg
new file mode 100644
index 00000000..5d9b78b3
Binary files /dev/null and b/doc/pic/architecture.jpg differ
diff --git a/doc/pic/architecture.png b/doc/pic/architecture.png
deleted file mode 100644
index 2ee54fc2..00000000
Binary files a/doc/pic/architecture.png and /dev/null differ
diff --git a/dockerfiles/Dockerfile-for-frpc b/dockerfiles/Dockerfile-for-frpc
index 881b0cc2..6760bb16 100644
--- a/dockerfiles/Dockerfile-for-frpc
+++ b/dockerfiles/Dockerfile-for-frpc
@@ -1,8 +1,11 @@
FROM node:22 AS web-builder
-WORKDIR /web/frpc
-COPY web/frpc/ ./
+COPY web/package.json /web/package.json
+COPY web/shared/ /web/shared/
+COPY web/frpc/ /web/frpc/
+WORKDIR /web
RUN npm install
+WORKDIR /web/frpc
RUN npm run build
FROM golang:1.25 AS building
diff --git a/dockerfiles/Dockerfile-for-frps b/dockerfiles/Dockerfile-for-frps
index 3df253e2..8ef7dff4 100644
--- a/dockerfiles/Dockerfile-for-frps
+++ b/dockerfiles/Dockerfile-for-frps
@@ -1,8 +1,11 @@
FROM node:22 AS web-builder
-WORKDIR /web/frps
-COPY web/frps/ ./
+COPY web/package.json /web/package.json
+COPY web/shared/ /web/shared/
+COPY web/frps/ /web/frps/
+WORKDIR /web
RUN npm install
+WORKDIR /web/frps
RUN npm run build
FROM golang:1.25 AS building
diff --git a/go.mod b/go.mod
index 2b07b8e4..0db215eb 100644
--- a/go.mod
+++ b/go.mod
@@ -7,15 +7,15 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/coreos/go-oidc/v3 v3.14.1
- github.com/fatedier/golib v0.5.1
+ github.com/fatedier/golib v0.7.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
- github.com/hashicorp/yamux v0.1.2
+ github.com/hashicorp/yamux v0.1.1
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/pelletier/go-toml/v2 v2.2.0
- github.com/pion/stun/v2 v2.0.0
+ github.com/pion/stun/v3 v3.1.1
github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.55.0
@@ -32,11 +32,13 @@ require (
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.20.0
- golang.org/x/time v0.5.0
+ golang.org/x/sys v0.42.0
+ golang.org/x/time v0.10.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
gopkg.in/ini.v1 v1.67.0
k8s.io/apimachinery v0.28.8
k8s.io/client-go v0.28.8
+ k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
)
require (
@@ -63,10 +65,9 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
- github.com/pion/dtls/v2 v2.2.7 // indirect
- github.com/pion/logging v0.2.2 // indirect
- github.com/pion/transport/v2 v2.2.1 // indirect
- github.com/pion/transport/v3 v3.0.1 // indirect
+ github.com/pion/dtls/v3 v3.0.10 // indirect
+ github.com/pion/logging v0.2.4 // indirect
+ github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
@@ -79,23 +80,20 @@ require (
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
+ github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.33.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
-
-replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.7.3
diff --git a/go.sum b/go.sum
index e92a32f1..ddb9e32a 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/Lolia-FRP/golib v0.7.3 h1:70f2vjqWJPcNSbfu8twVOUybrzXrFQ0je7T/PuoQUXc=
-github.com/Lolia-FRP/golib v0.7.3/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -36,6 +34,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
+github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
@@ -101,16 +101,14 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
-github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
-github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
-github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
-github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
-github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
-github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
-github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
-github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
-github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
-github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
+github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
+github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
+github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
+github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
+github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -152,7 +150,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -173,13 +170,14 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
@@ -187,9 +185,6 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
-golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -198,8 +193,6 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQz
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -207,14 +200,7 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -223,56 +209,33 @@ golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
diff --git a/hack/run-e2e-compatibility.sh b/hack/run-e2e-compatibility.sh
new file mode 100755
index 00000000..988d89b4
--- /dev/null
+++ b/hack/run-e2e-compatibility.sh
@@ -0,0 +1,162 @@
+#!/bin/sh
+
+set -eu
+
+SCRIPT=$(readlink -f "$0")
+ROOT=$(unset CDPATH && cd "$(dirname "$SCRIPT")/.." && pwd)
+
+if ! command -v ginkgo >/dev/null 2>&1; then
+ echo "ginkgo not found, try to install..."
+ go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4
+fi
+
+debug=false
+if [ "x${DEBUG:-}" = "xtrue" ]; then
+ debug=true
+fi
+logLevel=debug
+if [ "${LOG_LEVEL:-}" ]; then
+ logLevel="${LOG_LEVEL}"
+fi
+
+currentFrpsPath=${CURRENT_FRPS_PATH:-${ROOT}/bin/frps}
+currentFrpcPath=${CURRENT_FRPC_PATH:-${ROOT}/bin/frpc}
+baselineCount=${FRP_COMPAT_BASELINE_COUNT:-8}
+targetOS=${TARGET_OS:-$(go env GOOS)}
+targetArch=${TARGET_ARCH:-$(go env GOARCH)}
+targetPlatform="${targetOS}_${targetArch}"
+cacheRoot=${FRP_COMPAT_CACHE_DIR:-${ROOT}/.cache/e2e-compat}
+
+check_file() {
+ if [ ! -f "$2" ]; then
+ echo "$1 not found: $2"
+ exit 1
+ fi
+}
+
+check_file "current frps" "${currentFrpsPath}"
+check_file "current frpc" "${currentFrpcPath}"
+
+run_current_current=true
+
+run_compatibility() {
+ baselineVersion=$1
+ baselineFrpsPath=$2
+ baselineFrpcPath=$3
+
+ check_file "baseline frps" "${baselineFrpsPath}"
+ check_file "baseline frpc" "${baselineFrpcPath}"
+
+ echo "Running compatibility e2e with baseline ${baselineVersion}"
+ ginkgo -nodes=1 --poll-progress-after=60s "${ROOT}/test/e2e/compatibility" -- \
+ -current-frps-path="${currentFrpsPath}" \
+ -current-frpc-path="${currentFrpcPath}" \
+ -baseline-frps-path="${baselineFrpsPath}" \
+ -baseline-frpc-path="${baselineFrpcPath}" \
+ -baseline-version="${baselineVersion}" \
+ -run-current-current="${run_current_current}" \
+ -log-level="${logLevel}" \
+ -debug="${debug}"
+ run_current_current=false
+}
+
+github_api_curl() {
+ if [ "${GITHUB_TOKEN:-}" ]; then
+ curl -fsSL \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${GITHUB_TOKEN}" \
+ "$1"
+ else
+ curl -fsSL "$1"
+ fi
+}
+
+resolve_versions() {
+ if [ "${FRP_COMPAT_BASELINE_VERSIONS:-}" ]; then
+ printf "%s\n" "${FRP_COMPAT_BASELINE_VERSIONS}"
+ return
+ fi
+
+ case "${baselineCount}" in
+ '' | *[!0-9]*)
+ echo "FRP_COMPAT_BASELINE_COUNT must be a positive integer: ${baselineCount}" >&2
+ exit 1
+ ;;
+ esac
+ if [ "${baselineCount}" -eq 0 ]; then
+ echo "FRP_COMPAT_BASELINE_COUNT must be greater than 0" >&2
+ exit 1
+ fi
+ if [ "${baselineCount}" -gt 100 ]; then
+ echo "FRP_COMPAT_BASELINE_COUNT must be less than or equal to 100" >&2
+ exit 1
+ fi
+
+ releaseURL="https://api.github.com/repos/fatedier/frp/releases?per_page=100"
+ resolvedVersions=""
+ if releases=$(github_api_curl "${releaseURL}" 2>/dev/null); then
+ resolvedVersions=$(printf "%s\n" "${releases}" |
+ sed -n 's/.*"tag_name":[[:space:]]*"v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' |
+ awk '!seen[$0]++' |
+ head -n "${baselineCount}" |
+ tr '\n' ' ' |
+ sed 's/[[:space:]]*$//')
+ else
+ echo "Failed to fetch release metadata from GitHub API, falling back to GitHub releases page." >&2
+ fi
+
+ if [ -z "${resolvedVersions}" ]; then
+ releasesPageURL="https://github.com/fatedier/frp/releases"
+ if ! releases=$(curl -fsSL "${releasesPageURL}"); then
+ echo "Failed to fetch release metadata from GitHub: ${releasesPageURL}" >&2
+ echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
+ exit 1
+ fi
+ resolvedVersions=$(printf "%s\n" "${releases}" |
+ grep -o 'releases/tag/v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' |
+ sed 's#.*/v##; s/"$//' |
+ awk '!seen[$0]++' |
+ head -n "${baselineCount}" |
+ tr '\n' ' ' |
+ sed 's/[[:space:]]*$//')
+ fi
+
+ set -- ${resolvedVersions}
+ if [ "$#" -lt "${baselineCount}" ]; then
+ echo "Only resolved $# stable release versions from GitHub, expected ${baselineCount}." >&2
+ echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
+ exit 1
+ fi
+
+ printf "%s\n" "${resolvedVersions}"
+}
+
+if [ "${BASELINE_FRPS_PATH:-}" ] || [ "${BASELINE_FRPC_PATH:-}" ]; then
+ if [ -z "${BASELINE_FRPS_PATH:-}" ] || [ -z "${BASELINE_FRPC_PATH:-}" ]; then
+ echo "BASELINE_FRPS_PATH and BASELINE_FRPC_PATH must be set together"
+ exit 1
+ fi
+ run_compatibility "${FRP_COMPAT_BASELINE_VERSION:-custom}" "${BASELINE_FRPS_PATH}" "${BASELINE_FRPC_PATH}"
+ exit 0
+fi
+
+versions=$(resolve_versions)
+echo "Compatibility baseline versions: ${versions}"
+
+mkdir -p "${cacheRoot}"
+for version in ${versions}; do
+ baselineDir="${cacheRoot}/${version}/${targetPlatform}"
+ if [ ! -f "${baselineDir}/frps" ] || [ ! -f "${baselineDir}/frpc" ]; then
+ tmpDir="${cacheRoot}/.download-${version}-${targetPlatform}"
+ rm -rf "${tmpDir}"
+ (
+ cd "${cacheRoot}"
+ FRP_VERSION="${version}" TARGET_DIRNAME="$(basename "${tmpDir}")" "${ROOT}/hack/download.sh"
+ )
+ mkdir -p "$(dirname "${baselineDir}")"
+ rm -rf "${baselineDir}"
+ mv "${tmpDir}" "${baselineDir}"
+ fi
+
+ run_compatibility "${version}" "${baselineDir}/frps" "${baselineDir}/frpc"
+done
diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go
index d9377f32..826a6715 100644
--- a/pkg/auth/oidc.go
+++ b/pkg/auth/oidc.go
@@ -23,12 +23,14 @@ import (
"net/url"
"os"
"slices"
+ "sync"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
)
@@ -74,14 +76,64 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
return &http.Client{Transport: transport}, nil
}
+// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
+// token on every call. This is used as a fallback when the OIDC provider
+// does not return expires_in, which would cause a caching TokenSource to
+// hold onto a stale token forever.
+type nonCachingTokenSource struct {
+ cfg *clientcredentials.Config
+ ctx context.Context
+}
+
+func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
+ return s.cfg.Token(s.ctx)
+}
+
+// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
+// successful Token() call, checks whether the provider returns an expiry.
+// If not, it permanently switches to nonCachingTokenSource so that a fresh
+// token is fetched every time. This avoids an eager network call at
+// construction time, letting the login retry loop handle transient IdP
+// outages.
+type oidcTokenSource struct {
+ mu sync.Mutex
+ initialized bool
+ source oauth2.TokenSource
+ fallbackCfg *clientcredentials.Config
+ fallbackCtx context.Context
+}
+
+func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
+ s.mu.Lock()
+ if !s.initialized {
+ token, err := s.source.Token()
+ if err != nil {
+ s.mu.Unlock()
+ return nil, err
+ }
+ if token.Expiry.IsZero() {
+ s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
+ }
+ s.initialized = true
+ s.mu.Unlock()
+ return token, nil
+ }
+ source := s.source
+ s.mu.Unlock()
+ return source.Token()
+}
+
type OidcAuthProvider struct {
additionalAuthScopes []v1.AuthScope
- tokenGenerator *clientcredentials.Config
- httpClient *http.Client
+ tokenSource oauth2.TokenSource
}
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
+ if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
+ return nil, err
+ }
+
eps := make(map[string][]string)
for k, v := range cfg.AdditionalEndpointParams {
eps[k] = []string{v}
@@ -99,30 +151,42 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
EndpointParams: eps,
}
- // Create custom HTTP client if needed
- var httpClient *http.Client
+ // Build the context that TokenSource will use for all future HTTP requests.
+ // context.Background() is appropriate here because the token source is
+ // long-lived and outlives any single request.
+ ctx := context.Background()
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
- var err error
- httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
+ httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
}
+ ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
}
+ // Create a persistent TokenSource that caches the token and refreshes
+ // it before expiry. This avoids making a new HTTP request to the OIDC
+ // provider on every heartbeat/ping.
+ //
+ // We wrap it in an oidcTokenSource so that the first Token() call
+ // (deferred to SetLogin inside the login retry loop) probes whether the
+ // provider returns expires_in. If not, it switches to a non-caching
+ // source. This avoids an eager network call at construction time, which
+ // would prevent loopLoginUntilSuccess from retrying on transient IdP
+ // outages.
+ cachingSource := tokenGenerator.TokenSource(ctx)
+
return &OidcAuthProvider{
additionalAuthScopes: additionalAuthScopes,
- tokenGenerator: tokenGenerator,
- httpClient: httpClient,
+ tokenSource: &oidcTokenSource{
+ source: cachingSource,
+ fallbackCfg: tokenGenerator,
+ fallbackCtx: ctx,
+ },
}, nil
}
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
- ctx := context.Background()
- if auth.httpClient != nil {
- ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
- }
-
- tokenObj, err := auth.tokenGenerator.Token(ctx)
+ tokenObj, err := auth.tokenSource.Token()
if err != nil {
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
}
@@ -205,7 +269,8 @@ type OidcAuthConsumer struct {
additionalAuthScopes []v1.AuthScope
verifier TokenVerifier
- subjectsFromLogin []string
+ mu sync.RWMutex
+ subjectsFromLogin map[string]struct{}
}
func NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier {
@@ -226,7 +291,7 @@ func NewOidcAuthVerifier(additionalAuthScopes []v1.AuthScope, verifier TokenVeri
return &OidcAuthConsumer{
additionalAuthScopes: additionalAuthScopes,
verifier: verifier,
- subjectsFromLogin: []string{},
+ subjectsFromLogin: make(map[string]struct{}),
}
}
@@ -235,9 +300,9 @@ func (auth *OidcAuthConsumer) VerifyLogin(loginMsg *msg.Login) (err error) {
if err != nil {
return fmt.Errorf("invalid OIDC token in login: %v", err)
}
- if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
- auth.subjectsFromLogin = append(auth.subjectsFromLogin, token.Subject)
- }
+ auth.mu.Lock()
+ auth.subjectsFromLogin[token.Subject] = struct{}{}
+ auth.mu.Unlock()
return nil
}
@@ -246,11 +311,13 @@ func (auth *OidcAuthConsumer) verifyPostLoginToken(privilegeKey string) (err err
if err != nil {
return fmt.Errorf("invalid OIDC token in ping: %v", err)
}
- if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
+ auth.mu.RLock()
+ _, ok := auth.subjectsFromLogin[token.Subject]
+ auth.mu.RUnlock()
+ if !ok {
return fmt.Errorf("received different OIDC subject in login and ping. "+
- "original subjects: %s, "+
"new subject: %s",
- auth.subjectsFromLogin, token.Subject)
+ token.Subject)
}
return nil
}
diff --git a/pkg/auth/oidc_test.go b/pkg/auth/oidc_test.go
index 58054186..70e59883 100644
--- a/pkg/auth/oidc_test.go
+++ b/pkg/auth/oidc_test.go
@@ -2,6 +2,10 @@ package auth_test
import (
"context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
"testing"
"time"
@@ -62,3 +66,188 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
r.Error(err)
r.Contains(err.Error(), "received different OIDC subject in login and ping")
}
+
+func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ requestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "fresh-test-token",
+ "token_type": "Bearer",
+ })
+ }))
+ defer tokenServer.Close()
+
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+
+ // Constructor no longer fetches a token eagerly.
+ // The first SetLogin triggers the adaptive probe.
+ r.Equal(int32(0), requestCount.Load())
+
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
+
+ for range 3 {
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
+ }
+
+ // 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
+ r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
+}
+
+func TestOidcAuthProviderCachesToken(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ requestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "cached-test-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ })
+ }))
+ defer tokenServer.Close()
+
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+
+ // Constructor no longer fetches eagerly; first SetLogin triggers the probe.
+ r.Equal(int32(0), requestCount.Load())
+
+ // SetLogin triggers the adaptive probe and caches the token.
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("cached-test-token", loginMsg.PrivilegeKey)
+ r.Equal(int32(1), requestCount.Load())
+
+ // Subsequent calls should also reuse the cached token
+ for range 5 {
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("cached-test-token", pingMsg.PrivilegeKey)
+ }
+ r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
+}
+
+func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ n := requestCount.Add(1)
+ // The oauth2 library retries once internally, so we need two
+ // consecutive failures to surface an error to the caller.
+ if n <= 2 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "temporarily_unavailable",
+ "error_description": "service is starting up",
+ })
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "retry-test-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ })
+ }))
+ defer tokenServer.Close()
+
+ // Constructor succeeds even though the IdP is "down".
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+ r.Equal(int32(0), requestCount.Load())
+
+ // First SetLogin hits the IdP, which returns an error (after internal retry).
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.Error(err)
+ r.Equal(int32(2), requestCount.Load())
+
+ // Second SetLogin retries and succeeds.
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("retry-test-token", loginMsg.PrivilegeKey)
+ r.Equal(int32(3), requestCount.Load())
+
+ // Subsequent calls use cached token.
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("retry-test-token", pingMsg.PrivilegeKey)
+ r.Equal(int32(3), requestCount.Load())
+}
+
+func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
+ r := require.New(t)
+ tokenServer := httptest.NewServer(http.NotFoundHandler())
+ defer tokenServer.Close()
+
+ _, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: "://bad",
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ TokenEndpointURL: tokenServer.URL,
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.clientID is required")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "scope": "profile",
+ },
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ Audience: "api",
+ AdditionalEndpointParams: map[string]string{"audience": "override"},
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+}
diff --git a/pkg/config/legacy/conversion.go b/pkg/config/legacy/conversion.go
index 4ae54f88..ec8d0698 100644
--- a/pkg/config/legacy/conversion.go
+++ b/pkg/config/legacy/conversion.go
@@ -171,15 +171,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
func transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations {
out := v1.HeaderOperations{}
for k, v := range params {
- if !strings.HasPrefix(k, "plugin_header_") {
+ k, ok := strings.CutPrefix(k, "plugin_header_")
+ if !ok || k == "" {
continue
}
- if k = strings.TrimPrefix(k, "plugin_header_"); k != "" {
- if out.Set == nil {
- out.Set = make(map[string]string)
- }
- out.Set[k] = v
+ if out.Set == nil {
+ out.Set = make(map[string]string)
}
+ out.Set[k] = v
}
return out
}
diff --git a/pkg/config/legacy/proxy.go b/pkg/config/legacy/proxy.go
index 0c461a1a..8d7cf9a0 100644
--- a/pkg/config/legacy/proxy.go
+++ b/pkg/config/legacy/proxy.go
@@ -39,14 +39,14 @@ const (
// Proxy
var (
proxyConfTypeMap = map[ProxyType]reflect.Type{
- ProxyTypeTCP: reflect.TypeOf(TCPProxyConf{}),
- ProxyTypeUDP: reflect.TypeOf(UDPProxyConf{}),
- ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConf{}),
- ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConf{}),
- ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConf{}),
- ProxyTypeSTCP: reflect.TypeOf(STCPProxyConf{}),
- ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConf{}),
- ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConf{}),
+ ProxyTypeTCP: reflect.TypeFor[TCPProxyConf](),
+ ProxyTypeUDP: reflect.TypeFor[UDPProxyConf](),
+ ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConf](),
+ ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConf](),
+ ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConf](),
+ ProxyTypeSTCP: reflect.TypeFor[STCPProxyConf](),
+ ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConf](),
+ ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConf](),
}
)
diff --git a/pkg/config/legacy/utils.go b/pkg/config/legacy/utils.go
index 9366f8c7..ad53f1c5 100644
--- a/pkg/config/legacy/utils.go
+++ b/pkg/config/legacy/utils.go
@@ -22,8 +22,8 @@ func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string
m := make(map[string]string)
for key, value := range set {
- if strings.HasPrefix(key, prefix) {
- m[strings.TrimPrefix(key, prefix)] = value
+ if trimmed, ok := strings.CutPrefix(key, prefix); ok {
+ m[trimmed] = value
}
}
diff --git a/pkg/config/legacy/visitor.go b/pkg/config/legacy/visitor.go
index 031c29bf..110a214d 100644
--- a/pkg/config/legacy/visitor.go
+++ b/pkg/config/legacy/visitor.go
@@ -32,9 +32,9 @@ const (
// Visitor
var (
visitorConfTypeMap = map[VisitorType]reflect.Type{
- VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConf{}),
- VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConf{}),
- VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConf{}),
+ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConf](),
+ VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConf](),
+ VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConf](),
}
)
diff --git a/pkg/config/load.go b/pkg/config/load.go
index 6e8c251d..38634fc5 100644
--- a/pkg/config/load.go
+++ b/pkg/config/load.go
@@ -17,6 +17,7 @@ package config
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -33,6 +34,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
- return LoadConfigure(content, c, strict)
+ return LoadConfigure(content, c, strict, detectFormatFromPath(path))
+}
+
+// detectFormatFromPath returns a format hint based on the file extension.
+func detectFormatFromPath(path string) string {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".toml":
+ return "toml"
+ case ".yaml", ".yml":
+ return "yaml"
+ case ".json":
+ return "json"
+ default:
+ return ""
+ }
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -129,48 +145,136 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
}
// Convert to JSON and decode with strict validation
- jsonBytes, err := json.Marshal(temp)
+ jsonBytes, err := jsonx.Marshal(temp)
if err != nil {
return err
}
- decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
- decoder.DisallowUnknownFields()
- return decoder.Decode(target)
+ return decodeJSONContent(jsonBytes, target, true)
+}
+
+func decodeJSONContent(content []byte, target any, strict bool) error {
+ if clientCfg, ok := target.(*v1.ClientConfig); ok {
+ decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{
+ DisallowUnknownFields: strict,
+ })
+ if err != nil {
+ return err
+ }
+ *clientCfg = decoded
+ return nil
+ }
+
+ return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{
+ RejectUnknownMembers: strict,
+ })
}
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
-func LoadConfigure(b []byte, c any, strict bool) error {
- v1.DisallowUnknownFieldsMu.Lock()
- defer v1.DisallowUnknownFieldsMu.Unlock()
- v1.DisallowUnknownFields = strict
+// An optional format hint (e.g. "toml", "yaml", "json") can be provided
+// to enable better error messages with line number information.
+func LoadConfigure(b []byte, c any, strict bool, formats ...string) error {
+ format := ""
+ if len(formats) > 0 {
+ format = formats[0]
+ }
+
+ originalBytes := b
+ parsedFromTOML := false
var tomlObj any
- // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
- if err := toml.Unmarshal(b, &tomlObj); err == nil {
- b, err = json.Marshal(&tomlObj)
+ tomlErr := toml.Unmarshal(b, &tomlObj)
+ if tomlErr == nil {
+ parsedFromTOML = true
+ var err error
+ b, err = jsonx.Marshal(&tomlObj)
if err != nil {
return err
}
+ } else if format == "toml" {
+ // File is known to be TOML but has syntax errors.
+ return formatTOMLError(tomlErr)
}
+
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if strict {
- decoder.DisallowUnknownFields()
+ if err := decodeJSONContent(b, c, strict); err != nil {
+ return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
- return decoder.Decode(c)
+ return nil
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
- return parseYAMLWithDotFieldsHandling(b, c)
+ if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
+ return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
+ }
+ return nil
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
}
+// formatTOMLError extracts line/column information from TOML decode errors.
+func formatTOMLError(err error) error {
+ var decErr *toml.DecodeError
+ if errors.As(err, &decErr) {
+ row, col := decErr.Position()
+ return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error())
+ }
+ var strictErr *toml.StrictMissingError
+ if errors.As(err, &strictErr) {
+ return strictErr
+ }
+ return err
+}
+
+// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.
+func enhanceDecodeError(err error, originalContent []byte, includeLine bool) error {
+ var typeErr *json.UnmarshalTypeError
+ if errors.As(err, &typeErr) && typeErr.Field != "" {
+ if includeLine {
+ line := findFieldLineInContent(originalContent, typeErr.Field)
+ if line > 0 {
+ return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type)
+ }
+ }
+ return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type)
+ }
+ return err
+}
+
+// findFieldLineInContent searches the original config content for a field name
+// and returns the 1-indexed line number where it appears, or 0 if not found.
+func findFieldLineInContent(content []byte, fieldPath string) int {
+ if fieldPath == "" {
+ return 0
+ }
+
+ // Use the last component of the field path (e.g. "proxies" from "proxies" or
+ // "protocol" from "transport.protocol").
+ parts := strings.Split(fieldPath, ".")
+ searchKey := parts[len(parts)-1]
+
+ lines := bytes.Split(content, []byte("\n"))
+ for i, line := range lines {
+ trimmed := bytes.TrimSpace(line)
+ // Match TOML key assignments like: key = ...
+ if bytes.HasPrefix(trimmed, []byte(searchKey)) {
+ rest := bytes.TrimSpace(trimmed[len(searchKey):])
+ if len(rest) > 0 && rest[0] == '=' {
+ return i + 1
+ }
+ }
+ // Match TOML table array headers like: [[proxies]]
+ if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) {
+ return i + 1
+ }
+ }
+ return 0
+}
+
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))
@@ -180,7 +284,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 +323,132 @@ 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{}
}
- // Filter by start
- if len(cliCfg.Start) > 0 {
- startSet := sets.New(cliCfg.Start...)
+ proxyCfgs := proxies
+ visitorCfgs := visitors
+
+ // Filter by start across merged configurers from all sources.
+ // For example, store entries are also filtered by this set.
+ 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 +467,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..b711a5c1 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"
@@ -188,6 +189,31 @@ unixPath = "/tmp/uds.sock"
require.Error(err)
}
+func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) {
+ require := require.New(t)
+
+ content := `
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+localPort = 6000
+[proxies.plugin]
+type = "http2https"
+localAddr = "127.0.0.1:8080"
+unknownInPlugin = "value"
+`
+
+ clientCfg := v1.ClientConfig{}
+
+ err := LoadConfigure([]byte(content), &clientCfg, false)
+ require.NoError(err)
+
+ err = LoadConfigure([]byte(content), &clientCfg, true)
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
// TestYAMLMergeInStrictMode tests that YAML merge functionality works
// even in strict mode by properly handling dot-prefixed fields
func TestYAMLMergeInStrictMode(t *testing.T) {
@@ -273,6 +299,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)
@@ -306,3 +495,111 @@ serverPort: 7000
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
}
+
+func TestTOMLSyntaxErrorWithPosition(t *testing.T) {
+ require := require.New(t)
+
+ // TOML with syntax error (unclosed table array header)
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]
+name = "test"
+`
+
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.Error(err)
+ require.Contains(err.Error(), "toml")
+ require.Contains(err.Error(), "line")
+ require.Contains(err.Error(), "column")
+}
+
+func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {
+ require := require.New(t)
+
+ // TOML with wrong type: proxies should be a table array, not a string
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+proxies = "this should be a table array"
+`
+
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.Error(err)
+ // The error should contain field info
+ errMsg := err.Error()
+ require.Contains(errMsg, "proxies")
+ require.NotContains(errMsg, "line")
+}
+
+func TestFindFieldLineInContent(t *testing.T) {
+ content := []byte(`serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+remotePort = 6000
+`)
+
+ tests := []struct {
+ fieldPath string
+ wantLine int
+ }{
+ {"serverAddr", 1},
+ {"serverPort", 2},
+ {"name", 5},
+ {"type", 6},
+ {"remotePort", 7},
+ {"nonexistent", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.fieldPath, func(t *testing.T) {
+ got := findFieldLineInContent(content, tt.fieldPath)
+ require.Equal(t, tt.wantLine, got)
+ })
+ }
+}
+
+func TestFormatDetection(t *testing.T) {
+ tests := []struct {
+ path string
+ format string
+ }{
+ {"config.toml", "toml"},
+ {"config.TOML", "toml"},
+ {"config.yaml", "yaml"},
+ {"config.yml", "yaml"},
+ {"config.json", "json"},
+ {"config.ini", ""},
+ {"config", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ require.Equal(t, tt.format, detectFormatFromPath(tt.path))
+ })
+ }
+}
+
+func TestValidTOMLStillWorks(t *testing.T) {
+ require := require.New(t)
+
+ // Valid TOML with format hint should work fine
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+remotePort = 6000
+`
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.NoError(err)
+ require.Equal("127.0.0.1", clientCfg.ServerAddr)
+ require.Equal(7000, clientCfg.ServerPort)
+ require.Len(clientCfg.Proxies, 1)
+}
diff --git a/pkg/config/source/aggregator.go b/pkg/config/source/aggregator.go
new file mode 100644
index 00000000..58496932
--- /dev/null
+++ b/pkg/config/source/aggregator.go
@@ -0,0 +1,109 @@
+// 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 (
+ "cmp"
+ "errors"
+ "fmt"
+ "maps"
+ "slices"
+ "sync"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+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() []Source {
+ sources := make([]Source, 0, 2)
+ if a.configSource != nil {
+ sources = append(sources, a.configSource)
+ }
+ if a.storeSource != nil {
+ sources = append(sources, 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 _, src := range entries {
+ proxies, visitors, err := src.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 := slices.SortedFunc(maps.Values(proxyMap), func(x, y v1.ProxyConfigurer) int {
+ return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)
+ })
+ visitors := slices.SortedFunc(maps.Values(visitorMap), func(x, y v1.VisitorConfigurer) int {
+ return cmp.Compare(x.GetBaseConfig().Name, y.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..380c05cf
--- /dev/null
+++ b/pkg/config/source/aggregator_test.go
@@ -0,0 +1,238 @@
+// 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_ReturnsSortedByName(t *testing.T) {
+ require := require.New(t)
+
+ agg := newTestAggregator(t, nil)
+ err := agg.ConfigSource().ReplaceAll(
+ []v1.ProxyConfigurer{mockProxy("charlie"), mockProxy("alice"), mockProxy("bob")},
+ []v1.VisitorConfigurer{mockVisitor("zulu"), mockVisitor("alpha")},
+ )
+ require.NoError(err)
+
+ proxies, visitors, err := agg.Load()
+ require.NoError(err)
+ require.Len(proxies, 3)
+ require.Equal("alice", proxies[0].GetBaseConfig().Name)
+ require.Equal("bob", proxies[1].GetBaseConfig().Name)
+ require.Equal("charlie", proxies[2].GetBaseConfig().Name)
+ require.Len(visitors, 2)
+ require.Equal("alpha", visitors[0].GetBaseConfig().Name)
+ require.Equal("zulu", visitors[1].GetBaseConfig().Name)
+}
+
+func TestAggregator_Load_ReturnsDefensiveCopies(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("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..ab1f59fe
--- /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 cloneConfigurers(proxies, visitors)
+}
diff --git a/pkg/config/source/base_source_test.go b/pkg/config/source/base_source_test.go
new file mode 100644
index 00000000..34ea9d9d
--- /dev/null
+++ b/pkg/config/source/base_source_test.go
@@ -0,0 +1,48 @@
+package source
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) {
+ require := require.New(t)
+
+ src := NewConfigSource()
+
+ proxyCfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "proxy1",
+ Type: "tcp",
+ },
+ }
+ visitorCfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "visitor1",
+ Type: "stcp",
+ },
+ }
+
+ err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
+ require.NoError(err)
+
+ firstProxies, firstVisitors, err := src.Load()
+ require.NoError(err)
+ require.Len(firstProxies, 1)
+ require.Len(firstVisitors, 1)
+
+ // Mutate loaded objects as runtime completion would do.
+ firstProxies[0].Complete()
+ firstVisitors[0].Complete()
+
+ secondProxies, secondVisitors, err := src.Load()
+ require.NoError(err)
+ require.Len(secondProxies, 1)
+ require.Len(secondVisitors, 1)
+
+ require.Empty(secondProxies[0].GetBaseConfig().LocalIP)
+ require.Empty(secondVisitors[0].GetBaseConfig().BindAddr)
+}
diff --git a/pkg/config/source/clone.go b/pkg/config/source/clone.go
new file mode 100644
index 00000000..cacd5dbb
--- /dev/null
+++ b/pkg/config/source/clone.go
@@ -0,0 +1,43 @@
+// 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"
+)
+
+func cloneConfigurers(
+ proxies []v1.ProxyConfigurer,
+ visitors []v1.VisitorConfigurer,
+) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
+ clonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies))
+ clonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors))
+
+ for _, cfg := range proxies {
+ if cfg == nil {
+ return nil, nil, fmt.Errorf("proxy cannot be nil")
+ }
+ clonedProxies = append(clonedProxies, cfg.Clone())
+ }
+ for _, cfg := range visitors {
+ if cfg == nil {
+ return nil, nil, fmt.Errorf("visitor cannot be nil")
+ }
+ clonedVisitors = append(clonedVisitors, cfg.Clone())
+ }
+ return clonedProxies, clonedVisitors, nil
+}
diff --git a/pkg/config/source/config_source.go b/pkg/config/source/config_source.go
new file mode 100644
index 00000000..95c8cfa1
--- /dev/null
+++ b/pkg/config/source/config_source.go
@@ -0,0 +1,55 @@
+// 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"
+
+// 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 {
+ name, err := validateProxyName(p)
+ if err != nil {
+ return err
+ }
+ nextProxies[name] = p
+ }
+ nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
+ for _, v := range visitors {
+ name, err := validateVisitorName(v)
+ if err != nil {
+ return err
+ }
+ 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..f568f885
--- /dev/null
+++ b/pkg/config/source/store.go
@@ -0,0 +1,349 @@
+// 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"
+ "os"
+ "path/filepath"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+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
+}
+
+var (
+ ErrAlreadyExists = errors.New("already exists")
+ ErrNotFound = errors.New("not found")
+)
+
+const (
+ storeKindProxy = "proxy"
+ storeKindVisitor = "visitor"
+)
+
+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
+ }
+
+ type rawStoreData struct {
+ Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
+ Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
+ }
+ stored := rawStoreData{}
+ if err := jsonx.Unmarshal(data, &stored); err != nil {
+ return fmt.Errorf("failed to parse JSON: %w", err)
+ }
+
+ s.proxies = make(map[string]v1.ProxyConfigurer)
+ s.visitors = make(map[string]v1.VisitorConfigurer)
+
+ for i, proxyData := range stored.Proxies {
+ proxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{
+ DisallowUnknownFields: false,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to decode proxy at index %d: %w", i, err)
+ }
+ name := proxyCfg.GetBaseConfig().Name
+ if name == "" {
+ return fmt.Errorf("proxy name cannot be empty")
+ }
+ s.proxies[name] = proxyCfg
+ }
+
+ for i, visitorData := range stored.Visitors {
+ visitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{
+ DisallowUnknownFields: false,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to decode visitor at index %d: %w", i, err)
+ }
+ name := visitorCfg.GetBaseConfig().Name
+ if name == "" {
+ return fmt.Errorf("visitor name cannot be empty")
+ }
+ s.visitors[name] = visitorCfg
+ }
+
+ return nil
+}
+
+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 := jsonx.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) persistOrRollbackUnlocked(rollback func()) error {
+ if err := s.saveToFileUnlocked(); err != nil {
+ rollback()
+ return fmt.Errorf("failed to persist: %w", err)
+ }
+ return nil
+}
+
+// Store map selectors return the target map for generic helpers.
+func proxyStoreEntries(s *StoreSource) map[string]v1.ProxyConfigurer {
+ return s.proxies
+}
+
+func visitorStoreEntries(s *StoreSource) map[string]v1.VisitorConfigurer {
+ return s.visitors
+}
+
+// Store entry helpers share mutation, persistence, and rollback for proxy and visitor maps.
+// T is intentionally limited by callers to v1.ProxyConfigurer or v1.VisitorConfigurer.
+func addStoreEntry[T any](
+ s *StoreSource,
+ entriesFn func(*StoreSource) map[string]T,
+ kind string,
+ name string,
+ value T,
+) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entries := entriesFn(s)
+ if _, exists := entries[name]; exists {
+ return fmt.Errorf("%w: %s %q", ErrAlreadyExists, kind, name)
+ }
+
+ entries[name] = value
+ return s.persistOrRollbackUnlocked(func() {
+ delete(entries, name)
+ })
+}
+
+func updateStoreEntry[T any](
+ s *StoreSource,
+ entriesFn func(*StoreSource) map[string]T,
+ kind string,
+ name string,
+ value T,
+) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entries := entriesFn(s)
+ old, exists := entries[name]
+ if !exists {
+ return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
+ }
+
+ entries[name] = value
+ return s.persistOrRollbackUnlocked(func() {
+ entries[name] = old
+ })
+}
+
+func removeStoreEntry[T any](
+ s *StoreSource,
+ entriesFn func(*StoreSource) map[string]T,
+ kind string,
+ name string,
+) error {
+ if name == "" {
+ return fmt.Errorf("%s name cannot be empty", kind)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entries := entriesFn(s)
+ old, exists := entries[name]
+ if !exists {
+ return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
+ }
+
+ delete(entries, name)
+ return s.persistOrRollbackUnlocked(func() {
+ entries[name] = old
+ })
+}
+
+func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
+ name, err := validateProxyName(proxy)
+ if err != nil {
+ return err
+ }
+ return addStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
+}
+
+func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
+ name, err := validateProxyName(proxy)
+ if err != nil {
+ return err
+ }
+ return updateStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
+}
+
+func (s *StoreSource) RemoveProxy(name string) error {
+ return removeStoreEntry(s, proxyStoreEntries, storeKindProxy, name)
+}
+
+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 {
+ name, err := validateVisitorName(visitor)
+ if err != nil {
+ return err
+ }
+ return addStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
+}
+
+func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
+ name, err := validateVisitorName(visitor)
+ if err != nil {
+ return err
+ }
+ return updateStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
+}
+
+func (s *StoreSource) RemoveVisitor(name string) error {
+ return removeStoreEntry(s, visitorStoreEntries, storeKindVisitor, name)
+}
+
+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..8bb107c5
--- /dev/null
+++ b/pkg/config/source/store_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 (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+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_UpdateAndRemoveProxyAndVisitor(t *testing.T) {
+ require := require.New(t)
+
+ storeSource := newTestStoreSource(t)
+
+ proxyCfg := mockProxy("proxy1")
+ visitorCfg := mockVisitor("visitor1")
+
+ require.NoError(storeSource.AddProxy(proxyCfg))
+ require.NoError(storeSource.AddVisitor(visitorCfg))
+ require.ErrorIs(storeSource.AddProxy(proxyCfg), ErrAlreadyExists)
+ require.ErrorIs(storeSource.AddVisitor(visitorCfg), ErrAlreadyExists)
+ require.ErrorContains(storeSource.RemoveProxy(""), "proxy name cannot be empty")
+ require.ErrorContains(storeSource.RemoveVisitor(""), "visitor name cannot be empty")
+
+ updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
+ updatedProxy.RemotePort = 19090
+ require.NoError(storeSource.UpdateProxy(updatedProxy))
+ require.Equal(19090, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
+
+ updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
+ updatedVisitor.ServerName = "updated-server"
+ require.NoError(storeSource.UpdateVisitor(updatedVisitor))
+ require.Equal("updated-server", storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
+
+ require.NoError(storeSource.RemoveProxy("proxy1"))
+ require.Nil(storeSource.GetProxy("proxy1"))
+ require.ErrorIs(storeSource.RemoveProxy("proxy1"), ErrNotFound)
+
+ require.NoError(storeSource.RemoveVisitor("visitor1"))
+ require.Nil(storeSource.GetVisitor("visitor1"))
+ require.ErrorIs(storeSource.RemoveVisitor("visitor1"), ErrNotFound)
+
+ require.ErrorIs(storeSource.UpdateProxy(updatedProxy), ErrNotFound)
+ require.ErrorIs(storeSource.UpdateVisitor(updatedVisitor), ErrNotFound)
+}
+
+func TestStoreSource_MutationRollsBackOnPersistFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod does not make directories unwritable on Windows")
+ }
+ if os.Getuid() == 0 {
+ t.Skip("chmod does not block writes for uid 0")
+ }
+
+ require := require.New(t)
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "store.json")
+ storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
+ require.NoError(err)
+
+ proxyCfg := mockProxy("proxy1")
+ visitorCfg := mockVisitor("visitor1")
+ originalRemotePort := proxyCfg.(*v1.TCPProxyConfig).RemotePort
+ originalServerName := visitorCfg.(*v1.STCPVisitorConfig).ServerName
+ require.NoError(storeSource.AddProxy(proxyCfg))
+ require.NoError(storeSource.AddVisitor(visitorCfg))
+
+ require.NoError(os.Chmod(dir, 0o500))
+ t.Cleanup(func() {
+ _ = os.Chmod(dir, 0o700)
+ })
+
+ requirePersistError := func(err error) {
+ t.Helper()
+ require.Error(err)
+ require.ErrorContains(err, "failed to persist")
+ require.NotErrorIs(err, ErrAlreadyExists)
+ require.NotErrorIs(err, ErrNotFound)
+ }
+
+ requirePersistError(storeSource.AddProxy(mockProxy("proxy2")))
+ require.Nil(storeSource.GetProxy("proxy2"))
+
+ updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
+ updatedProxy.RemotePort = 19090
+ requirePersistError(storeSource.UpdateProxy(updatedProxy))
+ require.Equal(originalRemotePort, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
+
+ requirePersistError(storeSource.RemoveProxy("proxy1"))
+ require.NotNil(storeSource.GetProxy("proxy1"))
+
+ requirePersistError(storeSource.AddVisitor(mockVisitor("visitor2")))
+ require.Nil(storeSource.GetVisitor("visitor2"))
+
+ updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
+ updatedVisitor.ServerName = "updated-server"
+ requirePersistError(storeSource.UpdateVisitor(updatedVisitor))
+ require.Equal(originalServerName, storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
+
+ requirePersistError(storeSource.RemoveVisitor("visitor1"))
+ require.NotNil(storeSource.GetVisitor("visitor1"))
+}
+
+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 := jsonx.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)
+}
+
+func TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) {
+ require := require.New(t)
+
+ path := filepath.Join(t.TempDir(), "store.json")
+ raw := []byte(`{
+ "proxies": [
+ {"name":"proxy1","type":"tcp","localPort":10080,"unexpected":"value"}
+ ],
+ "visitors": [
+ {"name":"visitor1","type":"xtcp","serverName":"server1","secretKey":"secret","bindPort":10081,"unexpected":"value"}
+ ]
+ }`)
+ err := os.WriteFile(path, raw, 0o600)
+ require.NoError(err)
+
+ storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
+ require.NoError(err)
+
+ require.NotNil(storeSource.GetProxy("proxy1"))
+ require.NotNil(storeSource.GetVisitor("visitor1"))
+}
diff --git a/pkg/config/source/validation.go b/pkg/config/source/validation.go
new file mode 100644
index 00000000..55bc4220
--- /dev/null
+++ b/pkg/config/source/validation.go
@@ -0,0 +1,43 @@
+// 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"
+)
+
+func validateProxyName(proxy v1.ProxyConfigurer) (string, 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")
+ }
+ return name, nil
+}
+
+func validateVisitorName(visitor v1.VisitorConfigurer) (string, 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")
+ }
+ return name, nil
+}
diff --git a/pkg/config/template.go b/pkg/config/template.go
index 44bc456d..16fe069f 100644
--- a/pkg/config/template.go
+++ b/pkg/config/template.go
@@ -38,7 +38,7 @@ func parseNumberRangePair(firstRangeStr, secondRangeStr string) ([]NumberPair, e
return nil, fmt.Errorf("first and second range numbers are not in pairs")
}
pairs := make([]NumberPair, 0, len(firstRangeNumbers))
- for i := 0; i < len(firstRangeNumbers); i++ {
+ for i := range firstRangeNumbers {
pairs = append(pairs, NumberPair{
First: firstRangeNumbers[i],
Second: secondRangeNumbers[i],
diff --git a/pkg/config/types/types.go b/pkg/config/types/types.go
index 8fa3105a..972908ed 100644
--- a/pkg/config/types/types.go
+++ b/pkg/config/types/types.go
@@ -70,24 +70,18 @@ func (q *BandwidthQuantity) UnmarshalString(s string) error {
f float64
err error
)
- switch {
- case strings.HasSuffix(s, "MB"):
+ if fstr, ok := strings.CutSuffix(s, "MB"); ok {
base = MB
- fstr := strings.TrimSuffix(s, "MB")
f, err = strconv.ParseFloat(fstr, 64)
- if err != nil {
- return err
- }
- case strings.HasSuffix(s, "KB"):
+ } else if fstr, ok := strings.CutSuffix(s, "KB"); ok {
base = KB
- fstr := strings.TrimSuffix(s, "KB")
f, err = strconv.ParseFloat(fstr, 64)
- if err != nil {
- return err
- }
- default:
+ } else {
return errors.New("unit not support")
}
+ if err != nil {
+ return err
+ }
q.s = s
q.i = int64(f * float64(base))
@@ -143,8 +137,8 @@ func (p PortsRangeSlice) String() string {
func NewPortsRangeSliceFromString(str string) ([]PortsRange, error) {
str = strings.TrimSpace(str)
out := []PortsRange{}
- numRanges := strings.Split(str, ",")
- for _, numRangeStr := range numRanges {
+ numRanges := strings.SplitSeq(str, ",")
+ for numRangeStr := range numRanges {
// 1000-2000 or 2001
numArray := strings.Split(numRangeStr, "-")
// length: only 1 or 2 is correct
diff --git a/pkg/config/types/types_test.go b/pkg/config/types/types_test.go
index 8843de5a..c05ac9ee 100644
--- a/pkg/config/types/types_test.go
+++ b/pkg/config/types/types_test.go
@@ -39,6 +39,31 @@ func TestBandwidthQuantity(t *testing.T) {
require.Equal(`{"b":"1KB","int":5}`, string(buf))
}
+func TestBandwidthQuantity_MB(t *testing.T) {
+ require := require.New(t)
+
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"2MB","int":1}`), &w)
+ require.NoError(err)
+ require.EqualValues(2*MB, w.B.Bytes())
+
+ buf, err := json.Marshal(&w)
+ require.NoError(err)
+ require.Equal(`{"b":"2MB","int":1}`, string(buf))
+}
+
+func TestBandwidthQuantity_InvalidUnit(t *testing.T) {
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"1GB","int":1}`), &w)
+ require.Error(t, err)
+}
+
+func TestBandwidthQuantity_InvalidNumber(t *testing.T) {
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"abcKB","int":1}`), &w)
+ require.Error(t, err)
+}
+
func TestPortsRangeSlice2String(t *testing.T) {
require := require.New(t)
diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go
index c0d40f33..d4528e8c 100644
--- a/pkg/config/v1/client.go
+++ b/pkg/config/v1/client.go
@@ -78,6 +78,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 {
@@ -102,6 +105,9 @@ type ClientTransportConfig struct {
// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
// is "tcp".
Protocol string `json:"protocol,omitempty"`
+ // WireProtocol specifies the frpc/frps internal wire protocol version.
+ // Valid values are "v1" and "v2". By default, this value is "v1".
+ WireProtocol string `json:"wireProtocol,omitempty"`
// The maximum amount of time a dial to server will wait for a connect to complete.
DialServerTimeout int64 `json:"dialServerTimeout,omitempty"`
// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.
@@ -141,6 +147,7 @@ type ClientTransportConfig struct {
func (c *ClientTransportConfig) Complete() {
c.Protocol = util.EmptyOr(c.Protocol, "tcp")
+ c.WireProtocol = util.EmptyOr(c.WireProtocol, "v1")
c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
diff --git a/pkg/config/v1/client_test.go b/pkg/config/v1/client_test.go
index 5473a5f6..67214a5d 100644
--- a/pkg/config/v1/client_test.go
+++ b/pkg/config/v1/client_test.go
@@ -29,6 +29,7 @@ func TestClientConfigComplete(t *testing.T) {
require.EqualValues("token", c.Auth.Method)
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
+ require.Equal("v1", c.Transport.WireProtocol)
require.Equal(true, lo.FromPtr(c.LoginFailExit))
require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
diff --git a/pkg/config/v1/clone_test.go b/pkg/config/v1/clone_test.go
new file mode 100644
index 00000000..9b108528
--- /dev/null
+++ b/pkg/config/v1/clone_test.go
@@ -0,0 +1,109 @@
+package v1
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxyCloneDeepCopy(t *testing.T) {
+ require := require.New(t)
+
+ enabled := true
+ pluginHTTP2 := true
+ cfg := &HTTPProxyConfig{
+ ProxyBaseConfig: ProxyBaseConfig{
+ Name: "p1",
+ Type: "http",
+ Enabled: &enabled,
+ Annotations: map[string]string{"a": "1"},
+ Metadatas: map[string]string{"m": "1"},
+ HealthCheck: HealthCheckConfig{
+ Type: "http",
+ HTTPHeaders: []HTTPHeader{
+ {Name: "X-Test", Value: "v1"},
+ },
+ },
+ ProxyBackend: ProxyBackend{
+ Plugin: TypedClientPluginOptions{
+ Type: PluginHTTPS2HTTP,
+ ClientPluginOptions: &HTTPS2HTTPPluginOptions{
+ Type: PluginHTTPS2HTTP,
+ EnableHTTP2: &pluginHTTP2,
+ RequestHeaders: HeaderOperations{Set: map[string]string{"k": "v"}},
+ },
+ },
+ },
+ },
+ DomainConfig: DomainConfig{
+ CustomDomains: []string{"a.example.com"},
+ SubDomain: "a",
+ },
+ Locations: []string{"/api"},
+ RequestHeaders: HeaderOperations{Set: map[string]string{"h1": "v1"}},
+ ResponseHeaders: HeaderOperations{Set: map[string]string{"h2": "v2"}},
+ }
+
+ cloned := cfg.Clone().(*HTTPProxyConfig)
+
+ *cloned.Enabled = false
+ cloned.Annotations["a"] = "changed"
+ cloned.Metadatas["m"] = "changed"
+ cloned.HealthCheck.HTTPHeaders[0].Value = "changed"
+ cloned.CustomDomains[0] = "b.example.com"
+ cloned.Locations[0] = "/new"
+ cloned.RequestHeaders.Set["h1"] = "changed"
+ cloned.ResponseHeaders.Set["h2"] = "changed"
+ clientPlugin := cloned.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
+ *clientPlugin.EnableHTTP2 = false
+ clientPlugin.RequestHeaders.Set["k"] = "changed"
+
+ require.True(*cfg.Enabled)
+ require.Equal("1", cfg.Annotations["a"])
+ require.Equal("1", cfg.Metadatas["m"])
+ require.Equal("v1", cfg.HealthCheck.HTTPHeaders[0].Value)
+ require.Equal("a.example.com", cfg.CustomDomains[0])
+ require.Equal("/api", cfg.Locations[0])
+ require.Equal("v1", cfg.RequestHeaders.Set["h1"])
+ require.Equal("v2", cfg.ResponseHeaders.Set["h2"])
+
+ origPlugin := cfg.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
+ require.True(*origPlugin.EnableHTTP2)
+ require.Equal("v", origPlugin.RequestHeaders.Set["k"])
+}
+
+func TestVisitorCloneDeepCopy(t *testing.T) {
+ require := require.New(t)
+
+ enabled := true
+ cfg := &XTCPVisitorConfig{
+ VisitorBaseConfig: VisitorBaseConfig{
+ Name: "v1",
+ Type: "xtcp",
+ Enabled: &enabled,
+ ServerName: "server",
+ BindPort: 7000,
+ Plugin: TypedVisitorPluginOptions{
+ Type: VisitorPluginVirtualNet,
+ VisitorPluginOptions: &VirtualNetVisitorPluginOptions{
+ Type: VisitorPluginVirtualNet,
+ DestinationIP: "10.0.0.1",
+ },
+ },
+ },
+ NatTraversal: &NatTraversalConfig{
+ DisableAssistedAddrs: true,
+ },
+ }
+
+ cloned := cfg.Clone().(*XTCPVisitorConfig)
+ *cloned.Enabled = false
+ cloned.NatTraversal.DisableAssistedAddrs = false
+ visitorPlugin := cloned.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
+ visitorPlugin.DestinationIP = "10.0.0.2"
+
+ require.True(*cfg.Enabled)
+ require.True(cfg.NatTraversal.DisableAssistedAddrs)
+ origPlugin := cfg.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
+ require.Equal("10.0.0.1", origPlugin.DestinationIP)
+}
diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go
index 89898554..41bd9a4c 100644
--- a/pkg/config/v1/common.go
+++ b/pkg/config/v1/common.go
@@ -15,23 +15,11 @@
package v1
import (
- "sync"
+ "maps"
"github.com/fatedier/frp/pkg/util/util"
)
-// TODO(fatedier): Due to the current implementation issue of the go json library, the UnmarshalJSON method
-// of a custom struct cannot access the DisallowUnknownFields parameter of the parent decoder.
-// Here, a global variable is temporarily used to control whether unknown fields are allowed.
-// Once the v2 version is implemented by the community, we can switch to a standardized approach.
-//
-// https://github.com/golang/go/issues/41144
-// https://github.com/golang/go/discussions/63397
-var (
- DisallowUnknownFields = false
- DisallowUnknownFieldsMu sync.Mutex
-)
-
type AuthScope string
const (
@@ -104,6 +92,14 @@ type NatTraversalConfig struct {
DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
}
+func (c *NatTraversalConfig) Clone() *NatTraversalConfig {
+ if c == nil {
+ return nil
+ }
+ out := *c
+ return &out
+}
+
type LogConfig struct {
// This is destination where frp should write the logs.
// If "console" is used, logs will be printed to stdout, otherwise,
@@ -138,6 +134,12 @@ type HeaderOperations struct {
Set map[string]string `json:"set,omitempty"`
}
+func (o HeaderOperations) Clone() HeaderOperations {
+ return HeaderOperations{
+ Set: maps.Clone(o.Set),
+ }
+}
+
type HTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`
diff --git a/pkg/config/v1/decode.go b/pkg/config/v1/decode.go
new file mode 100644
index 00000000..427cea7f
--- /dev/null
+++ b/pkg/config/v1/decode.go
@@ -0,0 +1,195 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+type DecodeOptions struct {
+ DisallowUnknownFields bool
+}
+
+func decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error {
+ return jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{
+ RejectUnknownMembers: options.DisallowUnknownFields,
+ })
+}
+
+func isJSONNull(b []byte) bool {
+ return len(b) == 0 || string(b) == "null"
+}
+
+type typedEnvelope struct {
+ Type string `json:"type"`
+ Plugin jsonx.RawMessage `json:"plugin,omitempty"`
+}
+
+func DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) {
+ if isJSONNull(b) {
+ return nil, errors.New("type is required")
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return nil, err
+ }
+
+ configurer := NewProxyConfigurerByType(ProxyType(env.Type))
+ if configurer == nil {
+ return nil, fmt.Errorf("unknown proxy type: %s", env.Type)
+ }
+ if err := decodeJSONWithOptions(b, configurer, options); err != nil {
+ return nil, fmt.Errorf("unmarshal ProxyConfig error: %v", err)
+ }
+
+ if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
+ plugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal proxy plugin error: %v", err)
+ }
+ configurer.GetBaseConfig().Plugin = plugin
+ }
+ return configurer, nil
+}
+
+func DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) {
+ if isJSONNull(b) {
+ return nil, errors.New("type is required")
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return nil, err
+ }
+
+ configurer := NewVisitorConfigurerByType(VisitorType(env.Type))
+ if configurer == nil {
+ return nil, fmt.Errorf("unknown visitor type: %s", env.Type)
+ }
+ if err := decodeJSONWithOptions(b, configurer, options); err != nil {
+ return nil, fmt.Errorf("unmarshal VisitorConfig error: %v", err)
+ }
+
+ if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
+ plugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal visitor plugin error: %v", err)
+ }
+ configurer.GetBaseConfig().Plugin = plugin
+ }
+ return configurer, nil
+}
+
+func DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) {
+ if isJSONNull(b) {
+ return TypedClientPluginOptions{}, nil
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return TypedClientPluginOptions{}, err
+ }
+ if env.Type == "" {
+ return TypedClientPluginOptions{}, errors.New("plugin type is empty")
+ }
+
+ v, ok := clientPluginOptionsTypeMap[env.Type]
+ if !ok {
+ return TypedClientPluginOptions{}, fmt.Errorf("unknown plugin type: %s", env.Type)
+ }
+ optionsStruct := reflect.New(v).Interface().(ClientPluginOptions)
+ if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
+ return TypedClientPluginOptions{}, fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
+ }
+ return TypedClientPluginOptions{
+ Type: env.Type,
+ ClientPluginOptions: optionsStruct,
+ }, nil
+}
+
+func DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) {
+ if isJSONNull(b) {
+ return TypedVisitorPluginOptions{}, nil
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return TypedVisitorPluginOptions{}, err
+ }
+ if env.Type == "" {
+ return TypedVisitorPluginOptions{}, errors.New("visitor plugin type is empty")
+ }
+
+ v, ok := visitorPluginOptionsTypeMap[env.Type]
+ if !ok {
+ return TypedVisitorPluginOptions{}, fmt.Errorf("unknown visitor plugin type: %s", env.Type)
+ }
+ optionsStruct := reflect.New(v).Interface().(VisitorPluginOptions)
+ if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
+ return TypedVisitorPluginOptions{}, fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
+ }
+ return TypedVisitorPluginOptions{
+ Type: env.Type,
+ VisitorPluginOptions: optionsStruct,
+ }, nil
+}
+
+func DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) {
+ type rawClientConfig struct {
+ ClientCommonConfig
+ Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
+ Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
+ }
+
+ raw := rawClientConfig{}
+ if err := decodeJSONWithOptions(b, &raw, options); err != nil {
+ return ClientConfig{}, err
+ }
+
+ cfg := ClientConfig{
+ ClientCommonConfig: raw.ClientCommonConfig,
+ Proxies: make([]TypedProxyConfig, 0, len(raw.Proxies)),
+ Visitors: make([]TypedVisitorConfig, 0, len(raw.Visitors)),
+ }
+
+ for i, proxyData := range raw.Proxies {
+ proxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options)
+ if err != nil {
+ return ClientConfig{}, fmt.Errorf("decode proxy at index %d: %w", i, err)
+ }
+ cfg.Proxies = append(cfg.Proxies, TypedProxyConfig{
+ Type: proxyCfg.GetBaseConfig().Type,
+ ProxyConfigurer: proxyCfg,
+ })
+ }
+
+ for i, visitorData := range raw.Visitors {
+ visitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options)
+ if err != nil {
+ return ClientConfig{}, fmt.Errorf("decode visitor at index %d: %w", i, err)
+ }
+ cfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{
+ Type: visitorCfg.GetBaseConfig().Type,
+ VisitorConfigurer: visitorCfg,
+ })
+ }
+
+ return cfg, nil
+}
diff --git a/pkg/config/v1/decode_test.go b/pkg/config/v1/decode_test.go
new file mode 100644
index 00000000..cba433ad
--- /dev/null
+++ b/pkg/config/v1/decode_test.go
@@ -0,0 +1,86 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "name":"p1",
+ "type":"tcp",
+ "localPort":10080,
+ "plugin":{
+ "type":"http2https",
+ "localAddr":"127.0.0.1:8080",
+ "unknownInPlugin":"value"
+ }
+ }`)
+
+ _, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
+func TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "name":"v1",
+ "type":"stcp",
+ "serverName":"server",
+ "bindPort":10081,
+ "plugin":{
+ "type":"virtual_net",
+ "destinationIP":"10.0.0.1",
+ "unknownInPlugin":"value"
+ }
+ }`)
+
+ _, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
+func TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "serverPort":7000,
+ "proxies":[
+ {
+ "name":"p1",
+ "type":"tcp",
+ "localPort":10080,
+ "unknownField":"value"
+ }
+ ]
+ }`)
+
+ _, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownField")
+}
diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go
index 1bbe5ac3..e7d1a6b7 100644
--- a/pkg/config/v1/proxy.go
+++ b/pkg/config/v1/proxy.go
@@ -15,16 +15,13 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
+ "maps"
"reflect"
-
- "github.com/samber/lo"
+ "slices"
"github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -102,11 +99,23 @@ type HealthCheckConfig struct {
HTTPHeaders []HTTPHeader `json:"httpHeaders,omitempty"`
}
+func (c HealthCheckConfig) Clone() HealthCheckConfig {
+ out := c
+ out.HTTPHeaders = slices.Clone(c.HTTPHeaders)
+ return out
+}
+
type DomainConfig struct {
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
}
+func (c DomainConfig) Clone() DomainConfig {
+ out := c
+ out.CustomDomains = slices.Clone(c.CustomDomains)
+ return out
+}
+
type ProxyBaseConfig struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -122,12 +131,27 @@ type ProxyBaseConfig struct {
ProxyBackend
}
+func (c ProxyBaseConfig) Clone() ProxyBaseConfig {
+ out := c
+ out.Enabled = util.ClonePtr(c.Enabled)
+ out.Annotations = maps.Clone(c.Annotations)
+ out.Metadatas = maps.Clone(c.Metadatas)
+ out.HealthCheck = c.HealthCheck.Clone()
+ out.ProxyBackend = c.ProxyBackend.Clone()
+ return out
+}
+
+func (c ProxyBackend) Clone() ProxyBackend {
+ out := c
+ out.Plugin = c.Plugin.Clone()
+ return out
+}
+
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)
@@ -175,40 +199,24 @@ type TypedProxyConfig struct {
}
func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return errors.New("type is required")
- }
-
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+ configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
- c.Type = typeStruct.Type
- configurer := NewProxyConfigurerByType(ProxyType(typeStruct.Type))
- if configurer == nil {
- return fmt.Errorf("unknown proxy type: %s", typeStruct.Type)
- }
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
- if err := decoder.Decode(configurer); err != nil {
- return fmt.Errorf("unmarshal ProxyConfig error: %v", err)
- }
+ c.Type = configurer.GetBaseConfig().Type
c.ProxyConfigurer = configurer
return nil
}
func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.ProxyConfigurer)
+ return jsonx.Marshal(c.ProxyConfigurer)
}
type ProxyConfigurer interface {
- Complete(namePrefix string)
+ Complete()
GetBaseConfig() *ProxyBaseConfig
+ Clone() ProxyConfigurer
// MarshalToMsg marshals this config into a msg.NewProxy message. This
// function will be called on the frpc side.
MarshalToMsg(*msg.NewProxy)
@@ -231,14 +239,14 @@ const (
)
var proxyConfigTypeMap = map[ProxyType]reflect.Type{
- ProxyTypeTCP: reflect.TypeOf(TCPProxyConfig{}),
- ProxyTypeUDP: reflect.TypeOf(UDPProxyConfig{}),
- ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConfig{}),
- ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConfig{}),
- ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConfig{}),
- ProxyTypeSTCP: reflect.TypeOf(STCPProxyConfig{}),
- ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConfig{}),
- ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConfig{}),
+ ProxyTypeTCP: reflect.TypeFor[TCPProxyConfig](),
+ ProxyTypeUDP: reflect.TypeFor[UDPProxyConfig](),
+ ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConfig](),
+ ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConfig](),
+ ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConfig](),
+ ProxyTypeSTCP: reflect.TypeFor[STCPProxyConfig](),
+ ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConfig](),
+ ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConfig](),
}
func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer {
@@ -271,6 +279,12 @@ func (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
+func (c *TCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &UDPProxyConfig{}
type UDPProxyConfig struct {
@@ -291,6 +305,12 @@ func (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
+func (c *UDPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &HTTPProxyConfig{}
type HTTPProxyConfig struct {
@@ -334,6 +354,16 @@ func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
+func (c *HTTPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ out.Locations = slices.Clone(c.Locations)
+ out.RequestHeaders = c.RequestHeaders.Clone()
+ out.ResponseHeaders = c.ResponseHeaders.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
@@ -355,6 +385,13 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.SubDomain = m.SubDomain
}
+func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ return &out
+}
+
type TCPMultiplexerType string
const (
@@ -395,6 +432,13 @@ func (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
+func (c *TCPMuxProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &STCPProxyConfig{}
type STCPProxyConfig struct {
@@ -418,6 +462,13 @@ func (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
+func (c *STCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ return &out
+}
+
var _ ProxyConfigurer = &XTCPProxyConfig{}
type XTCPProxyConfig struct {
@@ -444,6 +495,14 @@ func (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
+func (c *XTCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ out.NatTraversal = c.NatTraversal.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &SUDPProxyConfig{}
type SUDPProxyConfig struct {
@@ -466,3 +525,10 @@ func (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.Secretkey = m.Sk
c.AllowUsers = m.AllowUsers
}
+
+func (c *SUDPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ return &out
+}
diff --git a/pkg/config/v1/proxy_plugin.go b/pkg/config/v1/proxy_plugin.go
index 42c89341..be820457 100644
--- a/pkg/config/v1/proxy_plugin.go
+++ b/pkg/config/v1/proxy_plugin.go
@@ -15,14 +15,12 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
+ "slices"
"github.com/samber/lo"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -41,21 +39,22 @@ const (
)
var clientPluginOptionsTypeMap = map[string]reflect.Type{
- PluginHTTP2HTTPS: reflect.TypeOf(HTTP2HTTPSPluginOptions{}),
- PluginHTTP2HTTPSRedirect: reflect.TypeOf(HTTP2HTTPSRedirectPluginOptions{}),
- PluginHTTPProxy: reflect.TypeOf(HTTPProxyPluginOptions{}),
- PluginHTTPS2HTTP: reflect.TypeOf(HTTPS2HTTPPluginOptions{}),
- PluginHTTPS2HTTPS: reflect.TypeOf(HTTPS2HTTPSPluginOptions{}),
- PluginHTTP2HTTP: reflect.TypeOf(HTTP2HTTPPluginOptions{}),
- PluginSocks5: reflect.TypeOf(Socks5PluginOptions{}),
- PluginStaticFile: reflect.TypeOf(StaticFilePluginOptions{}),
- PluginUnixDomainSocket: reflect.TypeOf(UnixDomainSocketPluginOptions{}),
- PluginTLS2Raw: reflect.TypeOf(TLS2RawPluginOptions{}),
- PluginVirtualNet: reflect.TypeOf(VirtualNetPluginOptions{}),
+ PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
+ PluginHTTP2HTTPSRedirect: reflect.TypeFor[HTTP2HTTPSRedirectPluginOptions](),
+ PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
+ PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
+ PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
+ PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
+ PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
+ PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
+ PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
+ PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
+ PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
}
type ClientPluginOptions interface {
Complete()
+ Clone() ClientPluginOptions
}
type TypedClientPluginOptions struct {
@@ -63,43 +62,48 @@ type TypedClientPluginOptions struct {
ClientPluginOptions
}
-func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return nil
+func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
+ out := c
+ if c.ClientPluginOptions != nil {
+ out.ClientPluginOptions = c.ClientPluginOptions.Clone()
}
+ return out
+}
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
+ decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
-
- c.Type = typeStruct.Type
- if c.Type == "" {
- return errors.New("plugin type is empty")
- }
-
- v, ok := clientPluginOptionsTypeMap[typeStruct.Type]
- if !ok {
- return fmt.Errorf("unknown plugin type: %s", typeStruct.Type)
- }
- options := reflect.New(v).Interface().(ClientPluginOptions)
-
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
-
- if err := decoder.Decode(options); err != nil {
- return fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
- }
- c.ClientPluginOptions = options
+ *c = decoded
return nil
}
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.ClientPluginOptions)
+ return jsonx.Marshal(c.ClientPluginOptions)
+}
+
+// AutoTLSOptions configures automatic certificate provisioning (ACME) for plugins
+// that terminate TLS locally.
+type AutoTLSOptions struct {
+ Enable bool `json:"enable,omitempty"`
+ // Contact email for certificate expiration and important notices.
+ Email string `json:"email,omitempty"`
+ // Directory used to cache ACME account and certificates.
+ CacheDir string `json:"cacheDir,omitempty"`
+ // ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
+ CADirURL string `json:"caDirURL,omitempty"`
+ // Restrict certificate issuance to the listed domains.
+ HostAllowList []string `json:"hostAllowList,omitempty"`
+}
+
+func (o *AutoTLSOptions) Clone() *AutoTLSOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.HostAllowList = slices.Clone(o.HostAllowList)
+ return &out
}
type HTTP2HTTPSPluginOptions struct {
@@ -111,6 +115,15 @@ type HTTP2HTTPSPluginOptions struct {
func (o *HTTP2HTTPSPluginOptions) Complete() {}
+func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ return &out
+}
+
type HTTP2HTTPSRedirectPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPSPort int `json:"httpsPort,omitempty"`
@@ -118,6 +131,14 @@ type HTTP2HTTPSRedirectPluginOptions struct {
func (o *HTTP2HTTPSRedirectPluginOptions) Complete() {}
+func (o *HTTP2HTTPSRedirectPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -126,16 +147,12 @@ type HTTPProxyPluginOptions struct {
func (o *HTTPProxyPluginOptions) Complete() {}
-type AutoTLSOptions struct {
- Enable bool `json:"enable,omitempty"`
- // Contact email for certificate expiration and important notices.
- Email string `json:"email,omitempty"`
- // Directory used to cache ACME account and certificates.
- CacheDir string `json:"cacheDir,omitempty"`
- // ACME directory URL, e.g. Let's Encrypt staging/prod endpoint.
- CADirURL string `json:"caDirURL,omitempty"`
- // Restrict certificate issuance to the listed domains.
- HostAllowList []string `json:"hostAllowList,omitempty"`
+func (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
}
type HTTPS2HTTPPluginOptions struct {
@@ -153,6 +170,17 @@ func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
+func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
+ out.AutoTLS = o.AutoTLS.Clone()
+ return &out
+}
+
type HTTPS2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -168,6 +196,17 @@ func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
+func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
+ out.AutoTLS = o.AutoTLS.Clone()
+ return &out
+}
+
type HTTP2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -177,6 +216,15 @@ type HTTP2HTTPPluginOptions struct {
func (o *HTTP2HTTPPluginOptions) Complete() {}
+func (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ return &out
+}
+
type Socks5PluginOptions struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
@@ -185,6 +233,14 @@ type Socks5PluginOptions struct {
func (o *Socks5PluginOptions) Complete() {}
+func (o *Socks5PluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type StaticFilePluginOptions struct {
Type string `json:"type,omitempty"`
LocalPath string `json:"localPath,omitempty"`
@@ -195,6 +251,14 @@ type StaticFilePluginOptions struct {
func (o *StaticFilePluginOptions) Complete() {}
+func (o *StaticFilePluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type UnixDomainSocketPluginOptions struct {
Type string `json:"type,omitempty"`
UnixPath string `json:"unixPath,omitempty"`
@@ -202,6 +266,14 @@ type UnixDomainSocketPluginOptions struct {
func (o *UnixDomainSocketPluginOptions) Complete() {}
+func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -212,8 +284,25 @@ type TLS2RawPluginOptions struct {
func (o *TLS2RawPluginOptions) Complete() {}
+func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.AutoTLS = o.AutoTLS.Clone()
+ return &out
+}
+
type VirtualNetPluginOptions struct {
Type string `json:"type,omitempty"`
}
func (o *VirtualNetPluginOptions) Complete() {}
+
+func (o *VirtualNetPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
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/validation/auth.go b/pkg/config/v1/validation/auth.go
new file mode 100644
index 00000000..c70235dc
--- /dev/null
+++ b/pkg/config/v1/validation/auth.go
@@ -0,0 +1,43 @@
+// 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 validation
+
+import (
+ "fmt"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/policy/security"
+)
+
+func (v *ConfigValidator) validateAuthTokenSource(token string, tokenSource *v1.ValueSource) error {
+ var errs error
+ // Preserve the previous client/server validation order for joined errors.
+ if token != "" && tokenSource != nil {
+ errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
+ }
+ if tokenSource == nil {
+ return errs
+ }
+
+ if tokenSource.Type == "exec" {
+ if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
+ errs = AppendError(errs, err)
+ }
+ }
+ if err := tokenSource.Validate(); err != nil {
+ errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
+ }
+ return errs
+}
diff --git a/pkg/config/v1/validation/auth_test.go b/pkg/config/v1/validation/auth_test.go
new file mode 100644
index 00000000..4e90b44c
--- /dev/null
+++ b/pkg/config/v1/validation/auth_test.go
@@ -0,0 +1,228 @@
+// 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 validation
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/policy/security"
+)
+
+const (
+ tokenSourceConflictErr = "cannot specify both auth.token and auth.tokenSource"
+ tokenSourceExecErr = "unsafe feature \"TokenSourceExec\" is not enabled. To enable it, ensure it is allowed in the configuration or command line flags"
+ invalidFileSourceErr = "invalid auth.tokenSource: file configuration is required when type is 'file'"
+ unsupportedSourceErr = "invalid auth.tokenSource: unsupported value source type: env (only 'file' and 'exec' are supported)"
+)
+
+func TestValidateAuthTokenSource(t *testing.T) {
+ for _, tc := range authTokenSourceTestCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
+ err := validator.validateAuthTokenSource(tc.token, tc.tokenSource())
+ requireValidationErrors(t, err, tc.wantErrs)
+ })
+ }
+}
+
+func TestValidateClientAuthTokenSource(t *testing.T) {
+ for _, tc := range authTokenSourceTestCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := v1.AuthClientConfig{
+ Method: v1.AuthMethodToken,
+ Token: tc.token,
+ TokenSource: tc.tokenSource(),
+ }
+ validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
+ _, err := validator.ValidateClientCommonConfig(validClientConfigWithAuth(auth))
+ requireValidationErrors(t, err, tc.wantErrs)
+ })
+ }
+}
+
+func TestValidateServerAuthTokenSource(t *testing.T) {
+ for _, tc := range authTokenSourceTestCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := v1.AuthServerConfig{
+ Method: v1.AuthMethodToken,
+ Token: tc.token,
+ TokenSource: tc.tokenSource(),
+ }
+ validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
+ _, err := validator.ValidateServerConfig(validServerConfigWithAuth(auth))
+ requireValidationErrors(t, err, tc.wantErrs)
+ })
+ }
+}
+
+type authTokenSourceTestCase struct {
+ name string
+ token string
+ tokenSource func() *v1.ValueSource
+ unsafeAllowed bool
+ wantErrs []string
+}
+
+func authTokenSourceTestCases() []authTokenSourceTestCase {
+ return []authTokenSourceTestCase{
+ {
+ name: "empty token config",
+ tokenSource: nilTokenSource,
+ },
+ {
+ name: "valid file tokenSource",
+ tokenSource: validFileTokenSource,
+ },
+ {
+ name: "literal token without tokenSource",
+ token: "token",
+ tokenSource: nilTokenSource,
+ },
+ {
+ name: "literal token conflicts with file tokenSource",
+ token: "token",
+ tokenSource: validFileTokenSource,
+ wantErrs: []string{tokenSourceConflictErr},
+ },
+ {
+ name: "exec tokenSource requires unsafe feature",
+ tokenSource: validExecTokenSource,
+ wantErrs: []string{tokenSourceExecErr},
+ },
+ {
+ name: "exec tokenSource with unsafe feature allowed",
+ tokenSource: validExecTokenSource,
+ unsafeAllowed: true,
+ },
+ {
+ name: "literal token conflicts with exec tokenSource and unsafe feature disabled",
+ token: "token",
+ tokenSource: validExecTokenSource,
+ wantErrs: []string{
+ tokenSourceConflictErr,
+ tokenSourceExecErr,
+ },
+ },
+ {
+ name: "literal token conflicts with exec tokenSource and unsafe feature allowed",
+ token: "token",
+ tokenSource: validExecTokenSource,
+ unsafeAllowed: true,
+ wantErrs: []string{tokenSourceConflictErr},
+ },
+ {
+ name: "invalid file tokenSource is wrapped",
+ tokenSource: invalidFileTokenSource,
+ wantErrs: []string{invalidFileSourceErr},
+ },
+ {
+ name: "unsupported tokenSource type is wrapped",
+ tokenSource: unsupportedTokenSource,
+ wantErrs: []string{unsupportedSourceErr},
+ },
+ }
+}
+
+func newAuthTokenSourceValidator(unsafeAllowed bool) *ConfigValidator {
+ if !unsafeAllowed {
+ return NewConfigValidator(nil)
+ }
+ return NewConfigValidator(security.NewUnsafeFeatures([]string{security.TokenSourceExec}))
+}
+
+func requireValidationErrors(t *testing.T, err error, wantErrs []string) {
+ t.Helper()
+ if len(wantErrs) == 0 {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ // Client/server validators may wrap joined errors in another join layer; compare leaf errors.
+ gotErrs := unwrapValidationErrors(err)
+ require.Len(t, gotErrs, len(wantErrs))
+ for i, wantErr := range wantErrs {
+ require.EqualError(t, gotErrs[i], wantErr)
+ }
+}
+
+func unwrapValidationErrors(err error) []error {
+ type joinedError interface {
+ Unwrap() []error
+ }
+ joined, ok := err.(joinedError)
+ if !ok {
+ return []error{err}
+ }
+
+ var errs []error
+ for _, err := range joined.Unwrap() {
+ errs = append(errs, unwrapValidationErrors(err)...)
+ }
+ return errs
+}
+
+// nilTokenSource keeps the shared table shape uniform for cases without a tokenSource.
+func nilTokenSource() *v1.ValueSource {
+ return nil
+}
+
+func validFileTokenSource() *v1.ValueSource {
+ return &v1.ValueSource{
+ Type: "file",
+ File: &v1.FileSource{Path: "token.txt"},
+ }
+}
+
+func validExecTokenSource() *v1.ValueSource {
+ return &v1.ValueSource{
+ Type: "exec",
+ Exec: &v1.ExecSource{Command: "print-token"},
+ }
+}
+
+func invalidFileTokenSource() *v1.ValueSource {
+ return &v1.ValueSource{
+ Type: "file",
+ }
+}
+
+func unsupportedTokenSource() *v1.ValueSource {
+ return &v1.ValueSource{Type: "env"}
+}
+
+func validClientConfigWithAuth(auth v1.AuthClientConfig) *v1.ClientCommonConfig {
+ return &v1.ClientCommonConfig{
+ Auth: auth,
+ Log: v1.LogConfig{
+ Level: "info",
+ },
+ Transport: v1.ClientTransportConfig{
+ Protocol: "tcp",
+ WireProtocol: "v1",
+ },
+ }
+}
+
+func validServerConfigWithAuth(auth v1.AuthServerConfig) *v1.ServerConfig {
+ return &v1.ServerConfig{
+ Auth: auth,
+ Log: v1.LogConfig{
+ Level: "info",
+ },
+ }
+}
diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go
index eb4a0253..77004317 100644
--- a/pkg/config/v1/validation/client.go
+++ b/pkg/config/v1/validation/client.go
@@ -68,26 +68,16 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
- // Validate token/tokenSource mutual exclusivity
- if c.Token != "" && c.TokenSource != nil {
- errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
- }
-
- // Validate tokenSource if specified
- if c.TokenSource != nil {
- if c.TokenSource.Type == "exec" {
- if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
- errs = AppendError(errs, err)
- }
- }
- if err := c.TokenSource.Validate(); err != nil {
- errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
- }
- }
+ errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
}
+ if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
+ if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
+ errs = AppendError(errs, err)
+ }
+ }
return nil, errs
}
@@ -141,6 +131,9 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
}
+ if !slices.Contains(SupportedWireProtocols, c.WireProtocol) {
+ errs = AppendError(errs, fmt.Errorf("invalid transport.wireProtocol, optional values are %v", SupportedWireProtocols))
+ }
return warnings, errs
}
diff --git a/pkg/config/v1/validation/oidc.go b/pkg/config/v1/validation/oidc.go
new file mode 100644
index 00000000..c905e8e5
--- /dev/null
+++ b/pkg/config/v1/validation/oidc.go
@@ -0,0 +1,57 @@
+// 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 validation
+
+import (
+ "errors"
+ "net/url"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
+ var errs []string
+
+ if c.ClientID == "" {
+ errs = append(errs, "auth.oidc.clientID is required")
+ }
+
+ if c.TokenEndpointURL == "" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL is required")
+ } else {
+ tokenURL, err := url.Parse(c.TokenEndpointURL)
+ if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
+ } else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
+ }
+ }
+
+ if _, ok := c.AdditionalEndpointParams["scope"]; ok {
+ errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+ }
+
+ if c.Audience != "" {
+ if _, ok := c.AdditionalEndpointParams["audience"]; ok {
+ errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+ }
+ }
+
+ if len(errs) == 0 {
+ return nil
+ }
+ return errors.New(strings.Join(errs, "; "))
+}
diff --git a/pkg/config/v1/validation/oidc_test.go b/pkg/config/v1/validation/oidc_test.go
new file mode 100644
index 00000000..bc21da6e
--- /dev/null
+++ b/pkg/config/v1/validation/oidc_test.go
@@ -0,0 +1,78 @@
+// 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 validation
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
+ tokenServer := httptest.NewServer(http.NotFoundHandler())
+ defer tokenServer.Close()
+
+ t.Run("valid", func(t *testing.T) {
+ require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "resource": "api",
+ },
+ }))
+ })
+
+ t.Run("invalid token endpoint url", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: "://bad",
+ })
+ require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
+ })
+
+ t.Run("missing client id", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ TokenEndpointURL: tokenServer.URL,
+ })
+ require.ErrorContains(t, err, "auth.oidc.clientID is required")
+ })
+
+ t.Run("scope endpoint param is not allowed", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "scope": "email",
+ },
+ })
+ require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+ })
+
+ t.Run("audience conflict", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ Audience: "api",
+ AdditionalEndpointParams: map[string]string{
+ "audience": "override",
+ },
+ })
+ require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+ })
+}
diff --git a/pkg/config/v1/validation/proxy.go b/pkg/config/v1/validation/proxy.go
index d8e3d01e..744620f0 100644
--- a/pkg/config/v1/validation/proxy.go
+++ b/pkg/config/v1/validation/proxy.go
@@ -81,7 +81,7 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
for _, domain := range c.CustomDomains {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
- if strings.Contains(domain, s.SubDomainHost) {
+ if strings.HasSuffix(domain, "."+s.SubDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}
diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go
index 338ecc82..8be740aa 100644
--- a/pkg/config/v1/validation/server.go
+++ b/pkg/config/v1/validation/server.go
@@ -21,7 +21,6 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/policy/security"
)
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
@@ -36,22 +35,7 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
- // Validate token/tokenSource mutual exclusivity
- if c.Auth.Token != "" && c.Auth.TokenSource != nil {
- errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
- }
-
- // Validate tokenSource if specified
- if c.Auth.TokenSource != nil {
- if c.Auth.TokenSource.Type == "exec" {
- if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
- errs = AppendError(errs, err)
- }
- }
- if err := c.Auth.TokenSource.Validate(); err != nil {
- errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
- }
- }
+ errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
diff --git a/pkg/config/v1/validation/validation.go b/pkg/config/v1/validation/validation.go
index 4ca6b67f..417cec0f 100644
--- a/pkg/config/v1/validation/validation.go
+++ b/pkg/config/v1/validation/validation.go
@@ -29,6 +29,10 @@ var (
"websocket",
"wss",
}
+ SupportedWireProtocols = []string{
+ "v1",
+ "v2",
+ }
SupportedAuthMethods = []v1.AuthMethod{
"token",
diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go
index 5017f57d..d1035d4f 100644
--- a/pkg/config/v1/visitor.go
+++ b/pkg/config/v1/visitor.go
@@ -15,14 +15,9 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
- "github.com/samber/lo"
-
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -52,31 +47,27 @@ type VisitorBaseConfig struct {
Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"`
}
+func (c VisitorBaseConfig) Clone() VisitorBaseConfig {
+ out := c
+ out.Enabled = util.ClonePtr(c.Enabled)
+ out.Plugin = c.Plugin.Clone()
+ return out
+}
+
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
+ Clone() VisitorConfigurer
}
type VisitorType string
@@ -88,9 +79,9 @@ const (
)
var visitorConfigTypeMap = map[VisitorType]reflect.Type{
- VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConfig{}),
- VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConfig{}),
- VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConfig{}),
+ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConfig](),
+ VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConfig](),
+ VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConfig](),
}
type TypedVisitorConfig struct {
@@ -99,35 +90,18 @@ type TypedVisitorConfig struct {
}
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return errors.New("type is required")
- }
-
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+ configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
- c.Type = typeStruct.Type
- configurer := NewVisitorConfigurerByType(VisitorType(typeStruct.Type))
- if configurer == nil {
- return fmt.Errorf("unknown visitor type: %s", typeStruct.Type)
- }
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
- if err := decoder.Decode(configurer); err != nil {
- return fmt.Errorf("unmarshal VisitorConfig error: %v", err)
- }
+ c.Type = configurer.GetBaseConfig().Type
c.VisitorConfigurer = configurer
return nil
}
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.VisitorConfigurer)
+ return jsonx.Marshal(c.VisitorConfigurer)
}
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {
@@ -146,12 +120,24 @@ type STCPVisitorConfig struct {
VisitorBaseConfig
}
+func (c *STCPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ return &out
+}
+
var _ VisitorConfigurer = &SUDPVisitorConfig{}
type SUDPVisitorConfig struct {
VisitorBaseConfig
}
+func (c *SUDPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ return &out
+}
+
var _ VisitorConfigurer = &XTCPVisitorConfig{}
type XTCPVisitorConfig struct {
@@ -168,15 +154,18 @@ 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
- }
+}
+
+func (c *XTCPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ out.NatTraversal = c.NatTraversal.Clone()
+ return &out
}
diff --git a/pkg/config/v1/visitor_plugin.go b/pkg/config/v1/visitor_plugin.go
index 5a4909bd..6541e3de 100644
--- a/pkg/config/v1/visitor_plugin.go
+++ b/pkg/config/v1/visitor_plugin.go
@@ -15,11 +15,9 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
+
+ "github.com/fatedier/frp/pkg/util/jsonx"
)
const (
@@ -27,11 +25,12 @@ const (
)
var visitorPluginOptionsTypeMap = map[string]reflect.Type{
- VisitorPluginVirtualNet: reflect.TypeOf(VirtualNetVisitorPluginOptions{}),
+ VisitorPluginVirtualNet: reflect.TypeFor[VirtualNetVisitorPluginOptions](),
}
type VisitorPluginOptions interface {
Complete()
+ Clone() VisitorPluginOptions
}
type TypedVisitorPluginOptions struct {
@@ -39,43 +38,25 @@ type TypedVisitorPluginOptions struct {
VisitorPluginOptions
}
-func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return nil
+func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
+ out := c
+ if c.VisitorPluginOptions != nil {
+ out.VisitorPluginOptions = c.VisitorPluginOptions.Clone()
}
+ return out
+}
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
+ decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
-
- c.Type = typeStruct.Type
- if c.Type == "" {
- return errors.New("visitor plugin type is empty")
- }
-
- v, ok := visitorPluginOptionsTypeMap[typeStruct.Type]
- if !ok {
- return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type)
- }
- options := reflect.New(v).Interface().(VisitorPluginOptions)
-
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
-
- if err := decoder.Decode(options); err != nil {
- return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
- }
- c.VisitorPluginOptions = options
+ *c = decoded
return nil
}
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.VisitorPluginOptions)
+ return jsonx.Marshal(c.VisitorPluginOptions)
}
type VirtualNetVisitorPluginOptions struct {
@@ -84,3 +65,11 @@ type VirtualNetVisitorPluginOptions struct {
}
func (o *VirtualNetVisitorPluginOptions) Complete() {}
+
+func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
diff --git a/pkg/metrics/mem/server.go b/pkg/metrics/mem/server.go
index 6759a9b6..999719fd 100644
--- a/pkg/metrics/mem/server.go
+++ b/pkg/metrics/mem/server.go
@@ -18,6 +18,8 @@ import (
"sync"
"time"
+ "k8s.io/utils/clock"
+
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/metric"
server "github.com/fatedier/frp/server/metrics"
@@ -37,12 +39,21 @@ func init() {
}
type serverMetrics struct {
- info *ServerStatistics
- mu sync.Mutex
+ info *ServerStatistics
+ clock clock.WithTicker
+ mu sync.Mutex
}
func newServerMetrics() *serverMetrics {
+ return newServerMetricsWithClock(clock.RealClock{})
+}
+
+func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
return &serverMetrics{
+ clock: clk,
info: &ServerStatistics{
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
@@ -57,14 +68,23 @@ func newServerMetrics() *serverMetrics {
}
func (m *serverMetrics) run() {
- go func() {
- for {
- time.Sleep(12 * time.Hour)
- start := time.Now()
+ go m.runUntil(nil)
+}
+
+func (m *serverMetrics) runUntil(stopCh <-chan struct{}) {
+ ticker := m.clock.NewTicker(12 * time.Hour)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C():
+ start := m.clock.Now()
count, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour)
- log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, time.Since(start))
+ log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, m.clock.Since(start))
+ case <-stopCh:
+ return
}
- }()
+ }
}
func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {
@@ -77,7 +97,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
- time.Since(data.LastCloseTime) > continuousOfflineDuration {
+ m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
delete(m.info.ProxyStatistics, name)
count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -121,7 +141,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
}
proxyStats.User = user
proxyStats.ClientID = clientID
- proxyStats.LastStartTime = time.Now()
+ proxyStats.LastStartTime = m.clock.Now()
}
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
@@ -131,7 +151,7 @@ func (m *serverMetrics) CloseProxy(name string, proxyType string) {
counter.Dec(1)
}
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
- proxyStats.LastCloseTime = time.Now()
+ proxyStats.LastCloseTime = m.clock.Now()
}
}
@@ -143,7 +163,6 @@ func (m *serverMetrics) OpenConnection(name string, _ string) {
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.CurConns.Inc(1)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -155,7 +174,6 @@ func (m *serverMetrics) CloseConnection(name string, _ string) {
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.CurConns.Dec(1)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -168,7 +186,6 @@ func (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64)
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.TrafficIn.Inc(trafficBytes)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -181,7 +198,6 @@ func (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64)
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.TrafficOut.Inc(trafficBytes)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -203,6 +219,25 @@ func (m *serverMetrics) GetServer() *ServerStats {
return s
}
+func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
+ ps := &ProxyStats{
+ Name: name,
+ Type: proxyStats.ProxyType,
+ User: proxyStats.User,
+ ClientID: proxyStats.ClientID,
+ TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
+ TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
+ CurConns: int64(proxyStats.CurConns.Count()),
+ }
+ if !proxyStats.LastStartTime.IsZero() {
+ ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
+ }
+ if !proxyStats.LastCloseTime.IsZero() {
+ ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
+ }
+ return ps
+}
+
func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
res := make([]*ProxyStats, 0)
m.mu.Lock()
@@ -213,23 +248,7 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
if !filterAll && proxyStats.ProxyType != proxyType {
continue
}
-
- ps := &ProxyStats{
- Name: name,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
- res = append(res, ps)
+ res = append(res, toProxyStats(name, proxyStats))
}
return res
}
@@ -238,32 +257,14 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
m.mu.Lock()
defer m.mu.Unlock()
- filterAll := proxyType == "" || proxyType == "all"
- for name, proxyStats := range m.info.ProxyStatistics {
- if !filterAll && proxyStats.ProxyType != proxyType {
- continue
+ proxyStats, ok := m.info.ProxyStatistics[proxyName]
+ if ok {
+ // filterAll allows the "all proxies" API to look up a proxy by name without
+ // knowing its type (proxyType == "" or "all").
+ filterAll := proxyType == "" || proxyType == "all"
+ if filterAll || proxyStats.ProxyType == proxyType {
+ res = toProxyStats(proxyName, proxyStats)
}
-
- if name != proxyName {
- continue
- }
-
- res = &ProxyStats{
- Name: name,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
- break
}
return
}
@@ -274,21 +275,7 @@ func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
- res = &ProxyStats{
- Name: proxyName,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
+ res = toProxyStats(proxyName, proxyStats)
}
return
}
diff --git a/pkg/metrics/mem/server_test.go b/pkg/metrics/mem/server_test.go
new file mode 100644
index 00000000..fe9f9984
--- /dev/null
+++ b/pkg/metrics/mem/server_test.go
@@ -0,0 +1,83 @@
+package mem
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ clocktesting "k8s.io/utils/clock/testing"
+)
+
+func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
+ require := require.New(t)
+
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ metrics := newServerMetricsWithClock(clk)
+
+ metrics.NewProxy("proxy", "tcp", "user", "client-id")
+ require.Equal(start, metrics.info.ProxyStatistics["proxy"].LastStartTime)
+
+ closedAt := start.Add(time.Minute)
+ clk.SetTime(closedAt)
+ metrics.CloseProxy("proxy", "tcp")
+ require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
+}
+
+func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
+ require := require.New(t)
+
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start.Add(25 * time.Hour))
+ metrics := newServerMetricsWithClock(clk)
+ metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
+ Name: "proxy",
+ LastStartTime: start.Add(-time.Hour),
+ LastCloseTime: start,
+ }
+
+ count, total := metrics.clearUselessInfo(24 * time.Hour)
+
+ require.Equal(1, count)
+ require.Equal(1, total)
+ require.Empty(metrics.info.ProxyStatistics)
+}
+
+func TestServerMetricsRunUsesClockTicker(t *testing.T) {
+ require := require.New(t)
+
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ metrics := newServerMetricsWithClock(clk)
+ metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
+ Name: "proxy",
+ LastStartTime: start.Add(-time.Hour),
+ LastCloseTime: start,
+ }
+
+ stopCh := make(chan struct{})
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ metrics.runUntil(stopCh)
+ }()
+ t.Cleanup(func() {
+ close(stopCh)
+ <-done
+ })
+
+ require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
+ clk.Step(8 * 24 * time.Hour)
+
+ require.Eventually(func() bool {
+ return !metrics.hasProxyStatistics("proxy")
+ }, time.Second, time.Millisecond)
+}
+
+func (m *serverMetrics) hasProxyStatistics(name string) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ _, ok := m.info.ProxyStatistics[name]
+ return ok
+}
diff --git a/pkg/msg/conn_test.go b/pkg/msg/conn_test.go
new file mode 100644
index 00000000..f3498f03
--- /dev/null
+++ b/pkg/msg/conn_test.go
@@ -0,0 +1,56 @@
+// 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 msg
+
+import (
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestConnReadWriteMsg(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ }{
+ {name: "v1", protocol: wire.ProtocolV1},
+ {name: "v2", protocol: wire.ProtocolV2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+
+ clientConn := NewConn(client, NewReadWriter(client, tt.protocol))
+ serverConn := NewConn(server, NewReadWriter(server, tt.protocol))
+
+ in := &Ping{PrivilegeKey: "key", Timestamp: 123}
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- clientConn.WriteMsg(in)
+ }()
+
+ out, err := serverConn.ReadMsg()
+ require.NoError(t, err)
+ require.Equal(t, in, out)
+ require.NoError(t, <-errCh)
+ })
+ }
+}
diff --git a/pkg/msg/handler.go b/pkg/msg/handler.go
index 243e599a..1f2775c9 100644
--- a/pkg/msg/handler.go
+++ b/pkg/msg/handler.go
@@ -15,10 +15,90 @@
package msg
import (
+ "context"
"io"
+ "net"
"reflect"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
)
+type ReadWriter interface {
+ ReadMsg() (Message, error)
+ ReadMsgInto(Message) error
+ WriteMsg(Message) error
+}
+
+type Conn struct {
+ net.Conn
+ rw ReadWriter
+}
+
+func NewConn(conn net.Conn, rw ReadWriter) *Conn {
+ return &Conn{
+ Conn: conn,
+ rw: rw,
+ }
+}
+
+func (c *Conn) ReadMsg() (Message, error) {
+ return c.rw.ReadMsg()
+}
+
+func (c *Conn) ReadMsgInto(m Message) error {
+ return c.rw.ReadMsgInto(m)
+}
+
+func (c *Conn) WriteMsg(m Message) error {
+ return c.rw.WriteMsg(m)
+}
+
+func (c *Conn) Context() context.Context {
+ if getter, ok := c.Conn.(interface{ Context() context.Context }); ok {
+ return getter.Context()
+ }
+ return context.Background()
+}
+
+func (c *Conn) WithContext(ctx context.Context) {
+ if setter, ok := c.Conn.(interface{ WithContext(context.Context) }); ok {
+ setter.WithContext(ctx)
+ }
+}
+
+type V1ReadWriter struct {
+ rw io.ReadWriter
+}
+
+func NewV1ReadWriter(rw io.ReadWriter) ReadWriter {
+ return &V1ReadWriter{rw: rw}
+}
+
+// NewReadWriter wraps rw with the message codec for the selected wire protocol.
+// An empty protocol keeps the historical v1 behavior for tests and older call sites.
+func NewReadWriter(rw io.ReadWriter, wireProtocol string) ReadWriter {
+ switch wireProtocol {
+ case wire.ProtocolV2:
+ return NewV2ReadWriter(rw)
+ case "", wire.ProtocolV1:
+ return NewV1ReadWriter(rw)
+ default:
+ return NewV1ReadWriter(rw)
+ }
+}
+
+func (rw *V1ReadWriter) ReadMsg() (Message, error) {
+ return ReadMsg(rw.rw)
+}
+
+func (rw *V1ReadWriter) ReadMsgInto(m Message) error {
+ return ReadMsgInto(rw.rw, m)
+}
+
+func (rw *V1ReadWriter) WriteMsg(m Message) error {
+ return WriteMsg(rw.rw, m)
+}
+
func AsyncHandler(f func(Message)) func(Message) {
return func(m Message) {
go f(m)
@@ -27,15 +107,14 @@ func AsyncHandler(f func(Message)) func(Message) {
// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn.
type Dispatcher struct {
- rw io.ReadWriter
+ rw ReadWriter
- sendCh chan Message
- doneCh chan struct{}
- msgHandlers map[reflect.Type]func(Message)
- defaultHandler func(Message)
+ sendCh chan Message
+ doneCh chan struct{}
+ msgHandlers map[reflect.Type]func(Message)
}
-func NewDispatcher(rw io.ReadWriter) *Dispatcher {
+func NewDispatcher(rw ReadWriter) *Dispatcher {
return &Dispatcher{
rw: rw,
sendCh: make(chan Message, 100),
@@ -56,14 +135,14 @@ func (d *Dispatcher) sendLoop() {
case <-d.doneCh:
return
case m := <-d.sendCh:
- _ = WriteMsg(d.rw, m)
+ _ = d.rw.WriteMsg(m)
}
}
}
func (d *Dispatcher) readLoop() {
for {
- m, err := ReadMsg(d.rw)
+ m, err := d.rw.ReadMsg()
if err != nil {
close(d.doneCh)
return
@@ -71,8 +150,6 @@ func (d *Dispatcher) readLoop() {
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
handler(m)
- } else if d.defaultHandler != nil {
- d.defaultHandler(m)
}
}
}
@@ -90,10 +167,6 @@ func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
d.msgHandlers[reflect.TypeOf(msg)] = handler
}
-func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {
- d.defaultHandler = handler
-}
-
func (d *Dispatcher) Done() chan struct{} {
return d.doneCh
}
diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go
index 7b36c2aa..52948bd5 100644
--- a/pkg/msg/msg.go
+++ b/pkg/msg/msg.go
@@ -20,24 +20,24 @@ import (
)
const (
- TypeLogin = 'o'
- TypeLoginResp = '1'
- TypeNewProxy = 'p'
- TypeNewProxyResp = '2'
- TypeCloseProxy = 'c'
- TypeNewWorkConn = 'w'
- TypeReqWorkConn = 'r'
- TypeStartWorkConn = 's'
- TypeNewVisitorConn = 'v'
- TypeNewVisitorConnResp = '3'
- TypePing = 'h'
- TypePong = '4'
- TypeUDPPacket = 'u'
- TypeNatHoleVisitor = 'i'
- TypeNatHoleClient = 'n'
- TypeNatHoleResp = 'm'
- TypeNatHoleSid = '5'
- TypeNatHoleReport = '6'
+ TypeLogin byte = 'o'
+ TypeLoginResp byte = '1'
+ TypeNewProxy byte = 'p'
+ TypeNewProxyResp byte = '2'
+ TypeCloseProxy byte = 'c'
+ TypeNewWorkConn byte = 'w'
+ TypeReqWorkConn byte = 'r'
+ TypeStartWorkConn byte = 's'
+ TypeNewVisitorConn byte = 'v'
+ TypeNewVisitorConnResp byte = '3'
+ TypePing byte = 'h'
+ TypePong byte = '4'
+ TypeUDPPacket byte = 'u'
+ TypeNatHoleVisitor byte = 'i'
+ TypeNatHoleClient byte = 'n'
+ TypeNatHoleResp byte = 'm'
+ TypeNatHoleSid byte = '5'
+ TypeNatHoleReport byte = '6'
)
var msgTypeMap = map[byte]any{
@@ -61,7 +61,7 @@ var msgTypeMap = map[byte]any{
TypeNatHoleReport: NatHoleReport{},
}
-var TypeNameNatHoleResp = reflect.TypeOf(&NatHoleResp{}).Elem().Name()
+var TypeNameNatHoleResp = reflect.TypeFor[NatHoleResp]().Name()
type ClientSpec struct {
// Due to the support of VirtualClient, frps needs to know the client type in order to
@@ -184,7 +184,7 @@ type Pong struct {
}
type UDPPacket struct {
- Content string `json:"c,omitempty"`
+ Content []byte `json:"c,omitempty"`
LocalAddr *net.UDPAddr `json:"l,omitempty"`
RemoteAddr *net.UDPAddr `json:"r,omitempty"`
}
diff --git a/pkg/msg/msg_test.go b/pkg/msg/msg_test.go
new file mode 100644
index 00000000..06272faf
--- /dev/null
+++ b/pkg/msg/msg_test.go
@@ -0,0 +1,55 @@
+// 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 msg
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestV1MessageTypeIDsAreStable(t *testing.T) {
+ require.Equal(t, byte('o'), TypeLogin)
+ require.Equal(t, byte('1'), TypeLoginResp)
+ require.Equal(t, byte('p'), TypeNewProxy)
+ require.Equal(t, byte('2'), TypeNewProxyResp)
+ require.Equal(t, byte('c'), TypeCloseProxy)
+ require.Equal(t, byte('w'), TypeNewWorkConn)
+ require.Equal(t, byte('r'), TypeReqWorkConn)
+ require.Equal(t, byte('s'), TypeStartWorkConn)
+ require.Equal(t, byte('v'), TypeNewVisitorConn)
+ require.Equal(t, byte('3'), TypeNewVisitorConnResp)
+ require.Equal(t, byte('h'), TypePing)
+ require.Equal(t, byte('4'), TypePong)
+ require.Equal(t, byte('u'), TypeUDPPacket)
+ require.Equal(t, byte('i'), TypeNatHoleVisitor)
+ require.Equal(t, byte('n'), TypeNatHoleClient)
+ require.Equal(t, byte('m'), TypeNatHoleResp)
+ require.Equal(t, byte('5'), TypeNatHoleSid)
+ require.Equal(t, byte('6'), TypeNatHoleReport)
+}
+
+func TestMessageTypeMapIsCompleteAndUnique(t *testing.T) {
+ require.Len(t, msgTypeMap, 18)
+
+ msgTypes := make(map[reflect.Type]struct{}, len(msgTypeMap))
+
+ for _, m := range msgTypeMap {
+ msgType := reflect.TypeOf(m)
+ require.NotContains(t, msgTypes, msgType)
+ msgTypes[msgType] = struct{}{}
+ }
+}
diff --git a/pkg/msg/wire_v2.go b/pkg/msg/wire_v2.go
new file mode 100644
index 00000000..8d2cd88d
--- /dev/null
+++ b/pkg/msg/wire_v2.go
@@ -0,0 +1,192 @@
+// 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 msg
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+const (
+ V2TypeLogin uint16 = 1
+ V2TypeLoginResp uint16 = 2
+ V2TypeNewProxy uint16 = 3
+ V2TypeNewProxyResp uint16 = 4
+ V2TypeCloseProxy uint16 = 5
+ V2TypeNewWorkConn uint16 = 6
+ V2TypeReqWorkConn uint16 = 7
+ V2TypeStartWorkConn uint16 = 8
+ V2TypeNewVisitorConn uint16 = 9
+ V2TypeNewVisitorConnResp uint16 = 10
+ V2TypePing uint16 = 11
+ V2TypePong uint16 = 12
+ V2TypeUDPPacket uint16 = 13
+ V2TypeNatHoleVisitor uint16 = 14
+ V2TypeNatHoleClient uint16 = 15
+ V2TypeNatHoleResp uint16 = 16
+ V2TypeNatHoleSid uint16 = 17
+ V2TypeNatHoleReport uint16 = 18
+)
+
+var v2MsgTypeMap = map[uint16]any{
+ V2TypeLogin: Login{},
+ V2TypeLoginResp: LoginResp{},
+ V2TypeNewProxy: NewProxy{},
+ V2TypeNewProxyResp: NewProxyResp{},
+ V2TypeCloseProxy: CloseProxy{},
+ V2TypeNewWorkConn: NewWorkConn{},
+ V2TypeReqWorkConn: ReqWorkConn{},
+ V2TypeStartWorkConn: StartWorkConn{},
+ V2TypeNewVisitorConn: NewVisitorConn{},
+ V2TypeNewVisitorConnResp: NewVisitorConnResp{},
+ V2TypePing: Ping{},
+ V2TypePong: Pong{},
+ V2TypeUDPPacket: UDPPacket{},
+ V2TypeNatHoleVisitor: NatHoleVisitor{},
+ V2TypeNatHoleClient: NatHoleClient{},
+ V2TypeNatHoleResp: NatHoleResp{},
+ V2TypeNatHoleSid: NatHoleSid{},
+ V2TypeNatHoleReport: NatHoleReport{},
+}
+
+var v2MsgReflectTypeMap, v2MsgTypeIDMap = buildV2MsgTypeMaps()
+
+func buildV2MsgTypeMaps() (map[uint16]reflect.Type, map[reflect.Type]uint16) {
+ reflectTypeMap := make(map[uint16]reflect.Type, len(v2MsgTypeMap))
+ typeIDMap := make(map[reflect.Type]uint16, len(v2MsgTypeMap))
+ for typeID, m := range v2MsgTypeMap {
+ t := reflect.TypeOf(m)
+ reflectTypeMap[typeID] = t
+ typeIDMap[t] = typeID
+ }
+ return reflectTypeMap, typeIDMap
+}
+
+type V2ReadWriter struct {
+ conn *wire.Conn
+}
+
+func NewV2ReadWriter(rw io.ReadWriter) *V2ReadWriter {
+ return NewV2ReadWriterWithConn(wire.NewConn(rw))
+}
+
+func NewV2ReadWriterWithConn(conn *wire.Conn) *V2ReadWriter {
+ return &V2ReadWriter{conn: conn}
+}
+
+func (rw *V2ReadWriter) WireConn() *wire.Conn {
+ return rw.conn
+}
+
+func (rw *V2ReadWriter) ReadMsg() (Message, error) {
+ f, err := rw.conn.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+ return DecodeV2MessageFrame(f)
+}
+
+func (rw *V2ReadWriter) ReadMsgInto(m Message) error {
+ f, err := rw.conn.ReadFrame()
+ if err != nil {
+ return err
+ }
+ return DecodeV2MessageFrameInto(f, m)
+}
+
+func (rw *V2ReadWriter) WriteMsg(m Message) error {
+ f, err := EncodeV2MessageFrame(m)
+ if err != nil {
+ return err
+ }
+ return rw.conn.WriteFrame(f)
+}
+
+func DecodeV2MessageFrame(f *wire.Frame) (Message, error) {
+ if f.Type != wire.FrameTypeMessage {
+ return nil, fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
+ }
+ if len(f.Payload) < 2 {
+ return nil, fmt.Errorf("message frame payload too short")
+ }
+ typeID := binary.BigEndian.Uint16(f.Payload[:2])
+ t, ok := v2MsgReflectTypeMap[typeID]
+ if !ok {
+ return nil, fmt.Errorf("unknown v2 message type %d", typeID)
+ }
+ m := reflect.New(t).Interface()
+ if err := json.Unmarshal(f.Payload[2:], m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func DecodeV2MessageFrameInto(f *wire.Frame, out Message) error {
+ if f.Type != wire.FrameTypeMessage {
+ return fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
+ }
+ if len(f.Payload) < 2 {
+ return fmt.Errorf("message frame payload too short")
+ }
+
+ typeID := binary.BigEndian.Uint16(f.Payload[:2])
+ outType := reflect.TypeOf(out)
+ if outType == nil || outType.Kind() != reflect.Pointer {
+ return fmt.Errorf("message target must be a pointer")
+ }
+ elemType := outType.Elem()
+ expectedTypeID, ok := v2MsgTypeIDMap[elemType]
+ if !ok {
+ return fmt.Errorf("unknown v2 message type %s", elemType.String())
+ }
+ if typeID != expectedTypeID {
+ actualType, ok := v2MsgReflectTypeMap[typeID]
+ if !ok {
+ return fmt.Errorf("unknown v2 message type %d", typeID)
+ }
+ return fmt.Errorf("unexpected message type %s, want %s", actualType.String(), elemType.String())
+ }
+ return json.Unmarshal(f.Payload[2:], out)
+}
+
+func EncodeV2MessageFrame(m Message) (*wire.Frame, error) {
+ t := reflect.TypeOf(m)
+ if t == nil {
+ return nil, fmt.Errorf("nil message")
+ }
+ if t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ typeID, ok := v2MsgTypeIDMap[t]
+ if !ok {
+ return nil, fmt.Errorf("unknown v2 message type %s", t.String())
+ }
+ content, err := json.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ payload := make([]byte, 2+len(content))
+ binary.BigEndian.PutUint16(payload[:2], typeID)
+ copy(payload[2:], content)
+ return &wire.Frame{
+ Type: wire.FrameTypeMessage,
+ Payload: payload,
+ }, nil
+}
diff --git a/pkg/msg/wire_v2_test.go b/pkg/msg/wire_v2_test.go
new file mode 100644
index 00000000..f6f25e55
--- /dev/null
+++ b/pkg/msg/wire_v2_test.go
@@ -0,0 +1,140 @@
+// 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 msg
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestV2ReadWriterRoundTrip(t *testing.T) {
+ var buf bytes.Buffer
+ rw := NewV2ReadWriter(&buf)
+
+ in := &Login{
+ Version: "test-version",
+ RunID: "run-id",
+ User: "user",
+ }
+ require.NoError(t, rw.WriteMsg(in))
+
+ out, err := rw.ReadMsg()
+ require.NoError(t, err)
+ require.Equal(t, in, out)
+}
+
+func TestNewReadWriter(t *testing.T) {
+ require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
+ require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
+ require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
+ require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
+}
+
+func TestNewReadWriterEncoding(t *testing.T) {
+ for _, wireProtocol := range []string{"", wire.ProtocolV1} {
+ var legacy bytes.Buffer
+ legacyRW := NewReadWriter(&legacy, wireProtocol)
+ require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
+ require.NotEmpty(t, legacy.Bytes())
+ require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
+ }
+
+ var v2 bytes.Buffer
+ v2RW := NewReadWriter(&v2, wire.ProtocolV2)
+ require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
+ frame, err := wire.NewConn(&v2).ReadFrame()
+ require.NoError(t, err)
+ require.Equal(t, wire.FrameTypeMessage, frame.Type)
+ require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
+}
+
+func TestV2MessageTypeIDsAreStable(t *testing.T) {
+ require.Equal(t, uint16(1), V2TypeLogin)
+ require.Equal(t, uint16(2), V2TypeLoginResp)
+ require.Equal(t, uint16(3), V2TypeNewProxy)
+ require.Equal(t, uint16(4), V2TypeNewProxyResp)
+ require.Equal(t, uint16(5), V2TypeCloseProxy)
+ require.Equal(t, uint16(6), V2TypeNewWorkConn)
+ require.Equal(t, uint16(7), V2TypeReqWorkConn)
+ require.Equal(t, uint16(8), V2TypeStartWorkConn)
+ require.Equal(t, uint16(9), V2TypeNewVisitorConn)
+ require.Equal(t, uint16(10), V2TypeNewVisitorConnResp)
+ require.Equal(t, uint16(11), V2TypePing)
+ require.Equal(t, uint16(12), V2TypePong)
+ require.Equal(t, uint16(13), V2TypeUDPPacket)
+ require.Equal(t, uint16(14), V2TypeNatHoleVisitor)
+ require.Equal(t, uint16(15), V2TypeNatHoleClient)
+ require.Equal(t, uint16(16), V2TypeNatHoleResp)
+ require.Equal(t, uint16(17), V2TypeNatHoleSid)
+ require.Equal(t, uint16(18), V2TypeNatHoleReport)
+}
+
+func TestV2MessageFrameEncoding(t *testing.T) {
+ frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
+ require.NoError(t, err)
+ require.Equal(t, wire.FrameTypeMessage, frame.Type)
+ require.Len(t, frame.Payload, 4)
+ require.Equal(t, V2TypeReqWorkConn, binary.BigEndian.Uint16(frame.Payload[:2]))
+
+ out, err := DecodeV2MessageFrame(frame)
+ require.NoError(t, err)
+ require.IsType(t, &ReqWorkConn{}, out)
+}
+
+func TestDecodeV2MessageFrameInto(t *testing.T) {
+ in := &StartWorkConn{ProxyName: "tcp", SrcAddr: "127.0.0.1", SrcPort: 1234}
+ frame, err := EncodeV2MessageFrame(in)
+ require.NoError(t, err)
+
+ var out StartWorkConn
+ require.NoError(t, DecodeV2MessageFrameInto(frame, &out))
+ require.Equal(t, *in, out)
+}
+
+func TestDecodeV2MessageFrameRejectsInvalidFrame(t *testing.T) {
+ _, err := DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeClientHello})
+ require.ErrorContains(t, err, "unexpected frame type")
+
+ _, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: []byte{0}})
+ require.ErrorContains(t, err, "payload too short")
+
+ payload := make([]byte, 4)
+ binary.BigEndian.PutUint16(payload[:2], 65535)
+ copy(payload[2:], []byte("{}"))
+ _, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
+ require.ErrorContains(t, err, "unknown v2 message type")
+}
+
+func TestDecodeV2MessageFrameIntoRejectsWrongTarget(t *testing.T) {
+ frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
+ require.NoError(t, err)
+
+ var out StartWorkConn
+ err = DecodeV2MessageFrameInto(frame, &out)
+ require.ErrorContains(t, err, "unexpected message type")
+
+ err = DecodeV2MessageFrameInto(frame, StartWorkConn{})
+ require.ErrorContains(t, err, "must be a pointer")
+}
+
+func TestEncodeV2MessageFrameRejectsUnknownMessage(t *testing.T) {
+ _, err := EncodeV2MessageFrame(struct{}{})
+ require.ErrorContains(t, err, "unknown v2 message type")
+}
diff --git a/pkg/naming/names.go b/pkg/naming/names.go
new file mode 100644
index 00000000..3ca4dd9c
--- /dev/null
+++ b/pkg/naming/names.go
@@ -0,0 +1,32 @@
+package naming
+
+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
+ }
+ if trimmed, ok := strings.CutPrefix(name, user+"."); ok {
+ return trimmed
+ }
+ 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/naming/names_test.go b/pkg/naming/names_test.go
new file mode 100644
index 00000000..3792af42
--- /dev/null
+++ b/pkg/naming/names_test.go
@@ -0,0 +1,27 @@
+package naming
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+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/nathole/analysis.go b/pkg/nathole/analysis.go
index 6be0be55..2ff28e0e 100644
--- a/pkg/nathole/analysis.go
+++ b/pkg/nathole/analysis.go
@@ -21,6 +21,7 @@ import (
"time"
"github.com/samber/lo"
+ "k8s.io/utils/clock"
)
var (
@@ -144,19 +145,19 @@ func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, Recomman
return behaviors[index].A, behaviors[index].B
}
-func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {
+func getBehaviorScoresByMode(mode int, defaultScore int) []*behaviorScore {
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
}
-func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
+func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*behaviorScore {
behaviors := getBehaviorByMode(mode)
- scores := make([]*BehaviorScore, 0, len(behaviors))
- for i := 0; i < len(behaviors); i++ {
+ scores := make([]*behaviorScore, 0, len(behaviors))
+ for i := range behaviors {
score := receiverScore
if behaviors[i].A.Role == DetectRoleSender {
score = senderScore
}
- scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})
+ scores = append(scores, &behaviorScore{Mode: mode, Index: i, Score: score})
}
return scores
}
@@ -170,14 +171,18 @@ type RecommandBehavior struct {
ListenRandomPorts int
}
-type MakeHoleRecords struct {
+type makeHoleRecords struct {
mu sync.Mutex
- scores []*BehaviorScore
- LastUpdateTime time.Time
+ scores []*behaviorScore
+ clock clock.PassiveClock
+ lastUpdateTime time.Time
}
-func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
- scores := []*BehaviorScore{}
+func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *makeHoleRecords {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
+ scores := []*behaviorScore{}
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
appendMode0 := func() {
switch {
@@ -212,13 +217,17 @@ func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
}
- return &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()}
+ return &makeHoleRecords{
+ scores: scores,
+ clock: clk,
+ lastUpdateTime: clk.Now(),
+ }
}
-func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
+func (mhr *makeHoleRecords) reportSuccess(mode int, index int) {
mhr.mu.Lock()
defer mhr.mu.Unlock()
- mhr.LastUpdateTime = time.Now()
+ mhr.lastUpdateTime = mhr.clock.Now()
for i := range mhr.scores {
score := mhr.scores[i]
if score.Mode != mode || score.Index != index {
@@ -231,22 +240,22 @@ func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
}
}
-func (mhr *MakeHoleRecords) Recommand() (mode, index int) {
+func (mhr *makeHoleRecords) recommand() (mode, index int) {
mhr.mu.Lock()
defer mhr.mu.Unlock()
if len(mhr.scores) == 0 {
return 0, 0
}
- maxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int {
+ maxScore := slices.MaxFunc(mhr.scores, func(a, b *behaviorScore) int {
return cmp.Compare(a.Score, b.Score)
})
maxScore.Score--
- mhr.LastUpdateTime = time.Now()
+ mhr.lastUpdateTime = mhr.clock.Now()
return maxScore.Mode, maxScore.Index
}
-type BehaviorScore struct {
+type behaviorScore struct {
Mode int
Index int
// between -10 and 10
@@ -255,16 +264,25 @@ type BehaviorScore struct {
type Analyzer struct {
// key is client ip + visitor ip
- records map[string]*MakeHoleRecords
+ records map[string]*makeHoleRecords
dataReserveDuration time.Duration
+ clock clock.PassiveClock
mu sync.Mutex
}
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
+ return newAnalyzerWithClock(dataReserveDuration, clock.RealClock{})
+}
+
+func newAnalyzerWithClock(dataReserveDuration time.Duration, clk clock.PassiveClock) *Analyzer {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
return &Analyzer{
- records: make(map[string]*MakeHoleRecords),
+ records: make(map[string]*makeHoleRecords),
dataReserveDuration: dataReserveDuration,
+ clock: clk,
}
}
@@ -272,12 +290,12 @@ func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, in
a.mu.Lock()
records, ok := a.records[key]
if !ok {
- records = NewMakeHoleRecords(c, v)
+ records = newMakeHoleRecordsWithClock(c, v, a.clock)
a.records[key] = records
}
a.mu.Unlock()
- mode, index = records.Recommand()
+ mode, index = records.recommand()
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
switch mode {
@@ -307,11 +325,11 @@ func (a *Analyzer) ReportSuccess(key string, mode, index int) {
if !ok {
return
}
- records.ReportSuccess(mode, index)
+ records.reportSuccess(mode, index)
}
func (a *Analyzer) Clean() (int, int) {
- now := time.Now()
+ now := a.clock.Now()
total := 0
count := 0
@@ -321,7 +339,7 @@ func (a *Analyzer) Clean() (int, int) {
total = len(a.records)
// clean up records that have not been used for a period of time.
for key, records := range a.records {
- if now.Sub(records.LastUpdateTime) > a.dataReserveDuration {
+ if now.Sub(records.lastUpdateTime) > a.dataReserveDuration {
delete(a.records, key)
count++
}
diff --git a/pkg/nathole/analysis_test.go b/pkg/nathole/analysis_test.go
new file mode 100644
index 00000000..05d0f8dc
--- /dev/null
+++ b/pkg/nathole/analysis_test.go
@@ -0,0 +1,33 @@
+package nathole
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ clocktesting "k8s.io/utils/clock/testing"
+)
+
+func TestAnalyzerUsesClockForRecordTimestamps(t *testing.T) {
+ require := require.New(t)
+
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ analyzer := newAnalyzerWithClock(time.Hour, clk)
+ clientFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
+ visitorFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
+
+ mode, index, _, _ := analyzer.GetRecommandBehaviors("key", clientFeature, visitorFeature)
+ require.Equal(start, analyzer.records["key"].lastUpdateTime)
+
+ updatedAt := start.Add(time.Minute)
+ clk.SetTime(updatedAt)
+ analyzer.ReportSuccess("key", mode, index)
+ require.Equal(updatedAt, analyzer.records["key"].lastUpdateTime)
+
+ clk.SetTime(start.Add(2 * time.Hour))
+ count, total := analyzer.Clean()
+ require.Equal(1, count)
+ require.Equal(1, total)
+ require.Empty(analyzer.records)
+}
diff --git a/pkg/nathole/classify.go b/pkg/nathole/classify.go
index 5abb0e23..6e00a583 100644
--- a/pkg/nathole/classify.go
+++ b/pkg/nathole/classify.go
@@ -70,12 +70,8 @@ func ClassifyNATFeature(addresses []string, localIPs []string) (*NatFeature, err
continue
}
- if portNum > portMax {
- portMax = portNum
- }
- if portNum < portMin {
- portMin = portNum
- }
+ portMax = max(portMax, portNum)
+ portMin = min(portMin, portNum)
if baseIP != ip {
ipChanged = true
}
diff --git a/pkg/nathole/controller.go b/pkg/nathole/controller.go
index d7f38020..efbdf4c4 100644
--- a/pkg/nathole/controller.go
+++ b/pkg/nathole/controller.go
@@ -152,7 +152,9 @@ func (c *Controller) GenSid() string {
func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) {
if m.PreCheck {
+ c.mu.RLock()
cfg, ok := c.clientCfgs[m.ProxyName]
+ c.mu.RUnlock()
if !ok {
_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp server for [%s] doesn't exist", m.ProxyName)))
return
@@ -324,40 +326,16 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
}
protocol := vm.Protocol
- vResp := &msg.NatHoleResp{
- TransactionID: vm.TransactionID,
- Sid: session.sid,
- Protocol: protocol,
- CandidateAddrs: slices.Compact(cm.MappedAddrs),
- AssistedAddrs: slices.Compact(cm.AssistedAddrs),
- DetectBehavior: msg.NatHoleDetectBehavior{
- Mode: mode,
- Role: vBehavior.Role,
- TTL: vBehavior.TTL,
- SendDelayMs: vBehavior.SendDelayMs,
- ReadTimeoutMs: timeoutMs - vBehavior.SendDelayMs,
- SendRandomPorts: vBehavior.PortsRandomNumber,
- ListenRandomPorts: vBehavior.ListenRandomPorts,
- CandidatePorts: getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber),
- },
- }
- cResp := &msg.NatHoleResp{
- TransactionID: cm.TransactionID,
- Sid: session.sid,
- Protocol: protocol,
- CandidateAddrs: slices.Compact(vm.MappedAddrs),
- AssistedAddrs: slices.Compact(vm.AssistedAddrs),
- DetectBehavior: msg.NatHoleDetectBehavior{
- Mode: mode,
- Role: cBehavior.Role,
- TTL: cBehavior.TTL,
- SendDelayMs: cBehavior.SendDelayMs,
- ReadTimeoutMs: timeoutMs - cBehavior.SendDelayMs,
- SendRandomPorts: cBehavior.PortsRandomNumber,
- ListenRandomPorts: cBehavior.ListenRandomPorts,
- CandidatePorts: getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber),
- },
- }
+ vResp := newNatHoleResponse(
+ vm.TransactionID, session.sid, protocol, mode,
+ cm.MappedAddrs, cm.AssistedAddrs, vBehavior,
+ timeoutMs-vBehavior.SendDelayMs, cNatFeature.PortsDifference,
+ )
+ cResp := newNatHoleResponse(
+ cm.TransactionID, session.sid, protocol, mode,
+ vm.MappedAddrs, vm.AssistedAddrs, cBehavior,
+ timeoutMs-cBehavior.SendDelayMs, vNatFeature.PortsDifference,
+ )
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
@@ -366,6 +344,38 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
return vResp, cResp, nil
}
+func newNatHoleResponse(
+ transactionID string,
+ sid string,
+ protocol string,
+ mode int,
+ candidateAddrs []string,
+ assistedAddrs []string,
+ behavior RecommandBehavior,
+ readTimeoutMs int,
+ portsDifference int,
+) *msg.NatHoleResp {
+ compactCandidateAddrs := slices.Compact(candidateAddrs)
+ compactAssistedAddrs := slices.Compact(assistedAddrs)
+ return &msg.NatHoleResp{
+ TransactionID: transactionID,
+ Sid: sid,
+ Protocol: protocol,
+ CandidateAddrs: compactCandidateAddrs,
+ AssistedAddrs: compactAssistedAddrs,
+ DetectBehavior: msg.NatHoleDetectBehavior{
+ Mode: mode,
+ Role: behavior.Role,
+ TTL: behavior.TTL,
+ SendDelayMs: behavior.SendDelayMs,
+ ReadTimeoutMs: readTimeoutMs,
+ SendRandomPorts: behavior.PortsRandomNumber,
+ ListenRandomPorts: behavior.ListenRandomPorts,
+ CandidatePorts: getRangePorts(candidateAddrs, portsDifference, behavior.PortsRangeNumber),
+ },
+ }
+}
+
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if maxNumber <= 0 {
return nil
diff --git a/pkg/nathole/discovery.go b/pkg/nathole/discovery.go
index f25cd463..6fa0140f 100644
--- a/pkg/nathole/discovery.go
+++ b/pkg/nathole/discovery.go
@@ -19,7 +19,7 @@ import (
"net"
"time"
- "github.com/pion/stun/v2"
+ "github.com/pion/stun/v3"
)
var responseTimeout = 3 * time.Second
diff --git a/pkg/nathole/nathole.go b/pkg/nathole/nathole.go
index 72522fac..bea3d371 100644
--- a/pkg/nathole/nathole.go
+++ b/pkg/nathole/nathole.go
@@ -298,11 +298,13 @@ func waitDetectMessage(
n, raddr, err := conn.ReadFromUDP(buf)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
+ pool.PutBuf(buf)
return nil, err
}
xl.Debugf("get udp message local %s, from %s", conn.LocalAddr(), raddr)
var m msg.NatHoleSid
if err := DecodeMessageInto(buf[:n], key, &m); err != nil {
+ pool.PutBuf(buf)
xl.Warnf("decode sid message error: %v", err)
continue
}
@@ -408,7 +410,7 @@ func sendSidMessageToRandomPorts(
xl := xlog.FromContextSafe(ctx)
used := sets.New[int]()
getUnusedPort := func() int {
- for i := 0; i < 10; i++ {
+ for range 10 {
port := rand.IntN(65535-1024) + 1024
if !used.Has(port) {
used.Insert(port)
@@ -418,7 +420,7 @@ func sendSidMessageToRandomPorts(
return 0
}
- for i := 0; i < count; i++ {
+ for range count {
select {
case <-ctx.Done():
return
diff --git a/pkg/nathole/utils.go b/pkg/nathole/utils.go
index 5f32142e..2a882830 100644
--- a/pkg/nathole/utils.go
+++ b/pkg/nathole/utils.go
@@ -21,7 +21,7 @@ import (
"strconv"
"github.com/fatedier/golib/crypto"
- "github.com/pion/stun/v2"
+ "github.com/pion/stun/v3"
"github.com/fatedier/frp/pkg/msg"
)
diff --git a/pkg/plugin/client/http2http.go b/pkg/plugin/client/http2http.go
index 889a10f6..f92daddd 100644
--- a/pkg/plugin/client/http2http.go
+++ b/pkg/plugin/client/http2http.go
@@ -17,16 +17,9 @@
package client
import (
- "context"
- stdlog "log"
- "net/http"
"net/http/httputil"
- "github.com/fatedier/golib/pool"
-
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/util/log"
- netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -36,57 +29,28 @@ func init() {
type HTTP2HTTPPlugin struct {
opts *v1.HTTP2HTTPPluginOptions
- l *Listener
- s *http.Server
+ *httpBridgePlugin
}
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPPluginOptions)
- listener := NewProxyListener()
-
p := &HTTP2HTTPPlugin{
opts: opts,
- l: listener,
}
- rp := &httputil.ReverseProxy{
- Rewrite: func(r *httputil.ProxyRequest) {
+ rp := newHTTPBridgeReverseProxy(
+ func(r *httputil.ProxyRequest) {
req := r.Out
- req.URL.Scheme = "http"
- req.URL.Host = p.opts.LocalAddr
- if p.opts.HostHeaderRewrite != "" {
- req.Host = p.opts.HostHeaderRewrite
- }
- for k, v := range p.opts.RequestHeaders.Set {
- req.Header.Set(k, v)
- }
+ rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
- BufferPool: pool.NewBuffer(32 * 1024),
- ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
- }
-
- p.s = &http.Server{
- Handler: rp,
- ReadHeaderTimeout: 0,
- }
-
- go func() {
- _ = p.s.Serve(listener)
- }()
+ nil,
+ )
+ p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
return p, nil
}
-func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
- wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
- _ = p.l.PutConn(wrapConn)
-}
-
func (p *HTTP2HTTPPlugin) Name() string {
return v1.PluginHTTP2HTTP
}
-
-func (p *HTTP2HTTPPlugin) Close() error {
- return p.s.Close()
-}
diff --git a/pkg/plugin/client/http2https.go b/pkg/plugin/client/http2https.go
index 538f2850..f5a61602 100644
--- a/pkg/plugin/client/http2https.go
+++ b/pkg/plugin/client/http2https.go
@@ -17,17 +17,11 @@
package client
import (
- "context"
"crypto/tls"
- stdlog "log"
"net/http"
"net/http/httputil"
- "github.com/fatedier/golib/pool"
-
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/util/log"
- netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -37,65 +31,35 @@ func init() {
type HTTP2HTTPSPlugin struct {
opts *v1.HTTP2HTTPSPluginOptions
- l *Listener
- s *http.Server
+ *httpBridgePlugin
}
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSPluginOptions)
- listener := NewProxyListener()
-
p := &HTTP2HTTPSPlugin{
opts: opts,
- l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
- rp := &httputil.ReverseProxy{
- Rewrite: func(r *httputil.ProxyRequest) {
+ rp := newHTTPBridgeReverseProxy(
+ func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
req := r.Out
- req.URL.Scheme = "https"
- req.URL.Host = p.opts.LocalAddr
- if p.opts.HostHeaderRewrite != "" {
- req.Host = p.opts.HostHeaderRewrite
- }
- for k, v := range p.opts.RequestHeaders.Set {
- req.Header.Set(k, v)
- }
+ rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
- Transport: tr,
- BufferPool: pool.NewBuffer(32 * 1024),
- ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
- }
-
- p.s = &http.Server{
- Handler: rp,
- ReadHeaderTimeout: 0,
- }
-
- go func() {
- _ = p.s.Serve(listener)
- }()
+ tr,
+ )
+ p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
return p, nil
}
-func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
- wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
- _ = p.l.PutConn(wrapConn)
-}
-
func (p *HTTP2HTTPSPlugin) Name() string {
return v1.PluginHTTP2HTTPS
}
-
-func (p *HTTP2HTTPSPlugin) Close() error {
- return p.s.Close()
-}
diff --git a/pkg/plugin/client/http_common.go b/pkg/plugin/client/http_common.go
new file mode 100644
index 00000000..5e00dfc9
--- /dev/null
+++ b/pkg/plugin/client/http_common.go
@@ -0,0 +1,143 @@
+// 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.
+
+//go:build !frps
+
+package client
+
+import (
+ "context"
+ "crypto/tls"
+ stdlog "log"
+ "net/http"
+ "net/http/httputil"
+ "time"
+
+ "github.com/fatedier/golib/pool"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/plugin/client/internal/httpsserver"
+ "github.com/fatedier/frp/pkg/util/log"
+ netpkg "github.com/fatedier/frp/pkg/util/net"
+)
+
+const httpBridgeReadHeaderTimeout = 60 * time.Second
+
+func rewriteHTTPPluginRequest(
+ req *http.Request,
+ scheme string,
+ localAddr string,
+ hostHeaderRewrite string,
+ requestHeaders v1.HeaderOperations,
+) {
+ req.URL.Scheme = scheme
+ req.URL.Host = localAddr
+ if hostHeaderRewrite != "" {
+ req.Host = hostHeaderRewrite
+ }
+ for k, v := range requestHeaders.Set {
+ req.Header.Set(k, v)
+ }
+}
+
+type httpBridgePlugin struct {
+ l *Listener
+ s *http.Server
+
+ useSourceRemoteAddr bool
+}
+
+func newHTTPBridgePluginServer(handler http.Handler, useSourceRemoteAddr bool) *httpBridgePlugin {
+ listener := NewProxyListener()
+ p := &httpBridgePlugin{
+ l: listener,
+ useSourceRemoteAddr: useSourceRemoteAddr,
+ }
+ p.s = &http.Server{
+ Handler: handler,
+ ReadHeaderTimeout: httpBridgeReadHeaderTimeout,
+ }
+ go func() {
+ _ = p.s.Serve(listener)
+ }()
+ return p
+}
+
+func newHTTPSBridgePluginServer(
+ handler http.Handler,
+ crtPath string,
+ keyPath string,
+ enableHTTP2 *bool,
+ useSourceRemoteAddr bool,
+) (*httpBridgePlugin, error) {
+ server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
+ if err != nil {
+ return nil, err
+ }
+ return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr), nil
+}
+
+// newHTTPSBridgePluginServerWithTLSConfig builds an HTTPS bridge plugin from a pre-built
+// tls.Config. It is used by features such as autoTLS that supply their own certificate provider.
+func newHTTPSBridgePluginServerWithTLSConfig(
+ handler http.Handler,
+ tlsConfig *tls.Config,
+ enableHTTP2 *bool,
+ useSourceRemoteAddr bool,
+) *httpBridgePlugin {
+ server := httpsserver.NewWithTLSConfig(handler, tlsConfig, enableHTTP2)
+ return newHTTPBridgePluginFromServer(server, useSourceRemoteAddr)
+}
+
+func newHTTPBridgePluginFromServer(server *http.Server, useSourceRemoteAddr bool) *httpBridgePlugin {
+ listener := NewProxyListener()
+ p := &httpBridgePlugin{
+ l: listener,
+ s: server,
+ useSourceRemoteAddr: useSourceRemoteAddr,
+ }
+ go func() {
+ _ = p.s.ServeTLS(listener, "", "")
+ }()
+ return p
+}
+
+func newHTTPBridgeReverseProxy(
+ rewrite func(*httputil.ProxyRequest),
+ transport http.RoundTripper,
+) *httputil.ReverseProxy {
+ rp := &httputil.ReverseProxy{
+ Rewrite: rewrite,
+ BufferPool: pool.NewBuffer(32 * 1024),
+ ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
+ }
+ if transport != nil {
+ rp.Transport = transport
+ }
+ return rp
+}
+
+func (p *httpBridgePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
+ wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
+ if p.useSourceRemoteAddr && connInfo.SrcAddr != nil {
+ wrapConn.SetRemoteAddr(connInfo.SrcAddr)
+ }
+ _ = p.l.PutConn(wrapConn)
+}
+
+func (p *httpBridgePlugin) Close() error {
+ err := p.s.Close()
+ _ = p.l.Close()
+ return err
+}
diff --git a/pkg/plugin/client/http_proxy.go b/pkg/plugin/client/http_proxy.go
index 0f6b55f4..0145f444 100644
--- a/pkg/plugin/client/http_proxy.go
+++ b/pkg/plugin/client/http_proxy.go
@@ -45,6 +45,8 @@ type HTTPProxy struct {
s *http.Server
}
+const httpProxyReadHeaderTimeout = 60 * time.Second
+
func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPProxyPluginOptions)
listener := NewProxyListener()
@@ -56,7 +58,7 @@ func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin
hp.s = &http.Server{
Handler: hp,
- ReadHeaderTimeout: 60 * time.Second,
+ ReadHeaderTimeout: httpProxyReadHeaderTimeout,
}
go func() {
@@ -73,16 +75,19 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
sc, rd := libnet.NewSharedConn(wrapConn)
- firstBytes := make([]byte, 7)
- _, err := rd.Read(firstBytes)
+ firstBytes := make([]byte, len(http.MethodConnect))
+ _ = wrapConn.SetReadDeadline(time.Now().Add(httpProxyReadHeaderTimeout))
+ _, err := io.ReadFull(rd, firstBytes)
if err != nil {
+ _ = wrapConn.SetReadDeadline(time.Time{})
wrapConn.Close()
return
}
- if strings.ToUpper(string(firstBytes)) == "CONNECT" {
+ if strings.EqualFold(string(firstBytes), http.MethodConnect) {
bufRd := bufio.NewReader(sc)
request, err := http.ReadRequest(bufRd)
+ _ = wrapConn.SetReadDeadline(time.Time{})
if err != nil {
wrapConn.Close()
return
@@ -91,6 +96,7 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
return
}
+ _ = wrapConn.SetReadDeadline(time.Time{})
_ = hp.l.PutConn(sc)
}
@@ -107,13 +113,7 @@ func (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
- if req.Method == http.MethodConnect {
- // deprecated
- // Connect request is handled in Handle function.
- hp.ConnectHandler(rw, req)
- } else {
- hp.HTTPHandler(rw, req)
- }
+ hp.HTTPHandler(rw, req)
}
func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
@@ -135,33 +135,6 @@ func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
}
}
-// deprecated
-// Hijack needs to SetReadDeadline on the Conn of the request, but if we use stream compression here,
-// we may always get i/o timeout error.
-func (hp *HTTPProxy) ConnectHandler(rw http.ResponseWriter, req *http.Request) {
- hj, ok := rw.(http.Hijacker)
- if !ok {
- rw.WriteHeader(http.StatusInternalServerError)
- return
- }
-
- client, _, err := hj.Hijack()
- if err != nil {
- rw.WriteHeader(http.StatusInternalServerError)
- return
- }
-
- remote, err := net.Dial("tcp", req.URL.Host)
- if err != nil {
- http.Error(rw, "Failed", http.StatusBadRequest)
- client.Close()
- return
- }
- _, _ = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
-
- go libio.Join(remote, client)
-}
-
func (hp *HTTPProxy) Auth(req *http.Request) bool {
if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" {
return true
diff --git a/pkg/plugin/client/http_proxy_test.go b/pkg/plugin/client/http_proxy_test.go
new file mode 100644
index 00000000..c4557ece
--- /dev/null
+++ b/pkg/plugin/client/http_proxy_test.go
@@ -0,0 +1,107 @@
+// 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.
+
+//go:build !frps
+
+package client
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestHTTPProxyHandleFragmentedConnectMethod(t *testing.T) {
+ require := require.New(t)
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(err)
+ defer ln.Close()
+
+ const payload = "ping"
+ echoErr := make(chan error, 1)
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ echoErr <- err
+ return
+ }
+ defer conn.Close()
+
+ buf := make([]byte, len(payload))
+ if _, err = io.ReadFull(conn, buf); err != nil {
+ echoErr <- err
+ return
+ }
+ if string(buf) != payload {
+ echoErr <- fmt.Errorf("unexpected payload %q", string(buf))
+ return
+ }
+ _, err = conn.Write([]byte("echo:" + payload))
+ echoErr <- err
+ }()
+
+ hp := &HTTPProxy{
+ opts: &v1.HTTPProxyPluginOptions{},
+ l: NewProxyListener(),
+ }
+
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+
+ go hp.Handle(context.Background(), &ConnectionInfo{
+ Conn: serverConn,
+ UnderlyingConn: serverConn,
+ })
+
+ require.NoError(clientConn.SetDeadline(time.Now().Add(5 * time.Second)))
+
+ targetAddr := ln.Addr().String()
+ req := "CONNECT " + targetAddr + " HTTP/1.1\r\nHost: " + targetAddr + "\r\n\r\n"
+ _, err = clientConn.Write([]byte("CON"))
+ require.NoError(err)
+ _, err = clientConn.Write([]byte(req[len("CON"):]))
+ require.NoError(err)
+
+ rd := bufio.NewReader(clientConn)
+ status, err := rd.ReadString('\n')
+ require.NoError(err)
+ require.Equal("HTTP/1.1 200 OK\r\n", status)
+ line, err := rd.ReadString('\n')
+ require.NoError(err)
+ require.Equal("\r\n", line)
+
+ _, err = clientConn.Write([]byte(payload))
+ require.NoError(err)
+
+ got := make([]byte, len("echo:"+payload))
+ _, err = io.ReadFull(rd, got)
+ require.NoError(err)
+ require.Equal("echo:"+payload, string(got))
+
+ select {
+ case err := <-echoErr:
+ require.NoError(err)
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for echo server")
+ }
+}
diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go
index 05b8c4d6..3cf11090 100644
--- a/pkg/plugin/client/https2http.go
+++ b/pkg/plugin/client/https2http.go
@@ -17,22 +17,10 @@
package client
import (
- "context"
- "crypto/tls"
"fmt"
- stdlog "log"
- "net/http"
"net/http/httputil"
- "time"
-
- "github.com/fatedier/golib/pool"
- "github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/transport"
- httppkg "github.com/fatedier/frp/pkg/util/http"
- "github.com/fatedier/frp/pkg/util/log"
- netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -42,89 +30,44 @@ func init() {
type HTTPS2HTTPPlugin struct {
opts *v1.HTTPS2HTTPPluginOptions
- l *Listener
- s *http.Server
+ *httpBridgePlugin
}
func NewHTTPS2HTTPPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions)
- listener := NewProxyListener()
p := &HTTPS2HTTPPlugin{
opts: opts,
- l: listener,
}
- rp := &httputil.ReverseProxy{
- Rewrite: func(r *httputil.ProxyRequest) {
+ rp := newHTTPBridgeReverseProxy(
+ func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
- req.URL.Scheme = "http"
- req.URL.Host = p.opts.LocalAddr
- if p.opts.HostHeaderRewrite != "" {
- req.Host = p.opts.HostHeaderRewrite
- }
- for k, v := range p.opts.RequestHeaders.Set {
- req.Header.Set(k, v)
- }
+ rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
- BufferPool: pool.NewBuffer(32 * 1024),
- ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
- }
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.TLS != nil {
- tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
- host, _ := httppkg.CanonicalHost(r.Host)
- if tlsServerName != "" && tlsServerName != host {
- w.WriteHeader(http.StatusMisdirectedRequest)
- return
- }
- }
- rp.ServeHTTP(w, r)
- })
+ nil,
+ )
- var tlsConfig *tls.Config
- var err error
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
- tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
+ tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
- } else {
- tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
- if err != nil {
- return nil, fmt.Errorf("gen TLS config error: %v", err)
- }
+ p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
+ return p, nil
}
- p.s = &http.Server{
- Handler: handler,
- ReadHeaderTimeout: 60 * time.Second,
- TLSConfig: tlsConfig,
- }
- if !lo.FromPtr(opts.EnableHTTP2) {
- p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
+ server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
+ if err != nil {
+ return nil, err
}
+ p.httpBridgePlugin = server
- go func() {
- _ = p.s.ServeTLS(listener, "", "")
- }()
return p, nil
}
-func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
- wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
- if connInfo.SrcAddr != nil {
- wrapConn.SetRemoteAddr(connInfo.SrcAddr)
- }
- _ = p.l.PutConn(wrapConn)
-}
-
func (p *HTTPS2HTTPPlugin) Name() string {
return v1.PluginHTTPS2HTTP
}
-
-func (p *HTTPS2HTTPPlugin) Close() error {
- return p.s.Close()
-}
diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go
index 3b752b57..dc969c95 100644
--- a/pkg/plugin/client/https2https.go
+++ b/pkg/plugin/client/https2https.go
@@ -17,22 +17,12 @@
package client
import (
- "context"
"crypto/tls"
"fmt"
- stdlog "log"
"net/http"
"net/http/httputil"
- "time"
-
- "github.com/fatedier/golib/pool"
- "github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/transport"
- httppkg "github.com/fatedier/frp/pkg/util/http"
- "github.com/fatedier/frp/pkg/util/log"
- netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -42,95 +32,48 @@ func init() {
type HTTPS2HTTPSPlugin struct {
opts *v1.HTTPS2HTTPSPluginOptions
- l *Listener
- s *http.Server
+ *httpBridgePlugin
}
func NewHTTPS2HTTPSPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
- listener := NewProxyListener()
-
p := &HTTPS2HTTPSPlugin{
opts: opts,
- l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
- rp := &httputil.ReverseProxy{
- Rewrite: func(r *httputil.ProxyRequest) {
+ rp := newHTTPBridgeReverseProxy(
+ func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
- req.URL.Scheme = "https"
- req.URL.Host = p.opts.LocalAddr
- if p.opts.HostHeaderRewrite != "" {
- req.Host = p.opts.HostHeaderRewrite
- }
- for k, v := range p.opts.RequestHeaders.Set {
- req.Header.Set(k, v)
- }
+ rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
- Transport: tr,
- BufferPool: pool.NewBuffer(32 * 1024),
- ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
- }
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.TLS != nil {
- tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
- host, _ := httppkg.CanonicalHost(r.Host)
- if tlsServerName != "" && tlsServerName != host {
- w.WriteHeader(http.StatusMisdirectedRequest)
- return
- }
- }
- rp.ServeHTTP(w, r)
- })
+ tr,
+ )
- var tlsConfig *tls.Config
- var err error
if p.opts.AutoTLS != nil && p.opts.AutoTLS.Enable {
- tlsConfig, err = buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
+ tlsConfig, err := buildAutoTLSServerConfigWithHosts(pluginCtx.Name, p.opts.AutoTLS, pluginCtx.HostAllowList)
if err != nil {
return nil, fmt.Errorf("build autoTLS config error: %v", err)
}
- } else {
- tlsConfig, err = transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
- if err != nil {
- return nil, fmt.Errorf("gen TLS config error: %v", err)
- }
+ p.httpBridgePlugin = newHTTPSBridgePluginServerWithTLSConfig(rp, tlsConfig, opts.EnableHTTP2, true)
+ return p, nil
}
- p.s = &http.Server{
- Handler: handler,
- ReadHeaderTimeout: 60 * time.Second,
- TLSConfig: tlsConfig,
- }
- if !lo.FromPtr(opts.EnableHTTP2) {
- p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
+ server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
+ if err != nil {
+ return nil, err
}
+ p.httpBridgePlugin = server
- go func() {
- _ = p.s.ServeTLS(listener, "", "")
- }()
return p, nil
}
-func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
- wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
- if connInfo.SrcAddr != nil {
- wrapConn.SetRemoteAddr(connInfo.SrcAddr)
- }
- _ = p.l.PutConn(wrapConn)
-}
-
func (p *HTTPS2HTTPSPlugin) Name() string {
return v1.PluginHTTPS2HTTPS
}
-
-func (p *HTTPS2HTTPSPlugin) Close() error {
- return p.s.Close()
-}
diff --git a/pkg/plugin/client/internal/httpsserver/server.go b/pkg/plugin/client/internal/httpsserver/server.go
new file mode 100644
index 00000000..47be475d
--- /dev/null
+++ b/pkg/plugin/client/internal/httpsserver/server.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.
+
+//go:build !frps
+
+package httpsserver
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/samber/lo"
+
+ "github.com/fatedier/frp/pkg/transport"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+)
+
+func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
+ tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
+ if err != nil {
+ return nil, fmt.Errorf("gen TLS config error: %v", err)
+ }
+ return NewWithTLSConfig(handler, tlsConfig, enableHTTP2), nil
+}
+
+// NewWithTLSConfig builds an HTTPS server from a pre-built tls.Config.
+// It is used by features such as autoTLS that supply their own certificate provider.
+func NewWithTLSConfig(handler http.Handler, tlsConfig *tls.Config, enableHTTP2 *bool) *http.Server {
+ server := &http.Server{
+ Handler: withMisdirectedRequestCheck(handler),
+ ReadHeaderTimeout: 60 * time.Second,
+ TLSConfig: tlsConfig,
+ }
+ if !lo.FromPtr(enableHTTP2) {
+ server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
+ }
+ return server
+}
+
+func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.TLS != nil {
+ tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
+ host, _ := httppkg.CanonicalHost(r.Host)
+ if tlsServerName != "" && tlsServerName != host {
+ w.WriteHeader(http.StatusMisdirectedRequest)
+ return
+ }
+ }
+ handler.ServeHTTP(w, r)
+ })
+}
diff --git a/pkg/plugin/client/tls2raw.go b/pkg/plugin/client/tls2raw.go
index b59830d2..29176238 100644
--- a/pkg/plugin/client/tls2raw.go
+++ b/pkg/plugin/client/tls2raw.go
@@ -71,11 +71,13 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
if err := tlsConn.Handshake(); err != nil {
xl.Warnf("tls handshake error: %v", err)
+ tlsConn.Close()
return
}
rawConn, err := net.Dial("tcp", p.opts.LocalAddr)
if err != nil {
xl.Warnf("dial to local addr error: %v", err)
+ tlsConn.Close()
return
}
diff --git a/pkg/plugin/client/unix_domain_socket.go b/pkg/plugin/client/unix_domain_socket.go
index 52d9c652..3046aade 100644
--- a/pkg/plugin/client/unix_domain_socket.go
+++ b/pkg/plugin/client/unix_domain_socket.go
@@ -54,10 +54,13 @@ func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *Connect
localConn, err := net.DialUnix("unix", nil, uds.UnixAddr)
if err != nil {
xl.Warnf("dial to uds %s error: %v", uds.UnixAddr, err)
+ connInfo.Conn.Close()
return
}
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
+ localConn.Close()
+ connInfo.Conn.Close()
return
}
}
diff --git a/pkg/plugin/server/http.go b/pkg/plugin/server/http.go
index 196993ef..b05b909e 100644
--- a/pkg/plugin/server/http.go
+++ b/pkg/plugin/server/http.go
@@ -24,6 +24,7 @@ import (
"net/http"
"net/url"
"reflect"
+ "slices"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -64,12 +65,7 @@ func (p *httpPlugin) Name() string {
}
func (p *httpPlugin) IsSupport(op string) bool {
- for _, v := range p.options.Ops {
- if v == op {
- return true
- }
- }
- return false
+ return slices.Contains(p.options.Ops, op)
}
func (p *httpPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) {
diff --git a/pkg/plugin/server/manager.go b/pkg/plugin/server/manager.go
index dabfb46c..6f1061c6 100644
--- a/pkg/plugin/server/manager.go
+++ b/pkg/plugin/server/manager.go
@@ -44,6 +44,67 @@ func NewManager() *Manager {
}
}
+func newPluginRequestContext() (context.Context, *xlog.Logger) {
+ reqid, _ := util.RandID()
+ xl := xlog.New().AppendPrefix("reqid: " + reqid)
+ ctx := xlog.NewContext(context.Background(), xl)
+ return NewReqidContext(ctx, reqid), xl
+}
+
+type pluginErrorLogMode bool
+
+const (
+ // Warn is the zero value because it is the default for mutable plugin operations.
+ pluginErrorLogWarn pluginErrorLogMode = false
+ pluginErrorLogInfo pluginErrorLogMode = true
+)
+
+func logPluginError(xl *xlog.Logger, p Plugin, op string, err error, mode pluginErrorLogMode) {
+ if mode == pluginErrorLogInfo {
+ xl.Infof("send %s request to plugin [%s] error: %v", op, p.Name(), err)
+ return
+ }
+ xl.Warnf("send %s request to plugin [%s] error: %v", op, p.Name(), err)
+}
+
+func handleMutableContent[T any](
+ plugins []Plugin,
+ op string,
+ content *T,
+ logMode pluginErrorLogMode,
+) (*T, error) {
+ if len(plugins) == 0 {
+ return content, nil
+ }
+
+ var (
+ res = &Response{
+ Reject: false,
+ Unchange: true,
+ }
+ retContent any
+ err error
+ )
+ ctx, xl := newPluginRequestContext()
+
+ for _, p := range plugins {
+ res, retContent, err = p.Handle(ctx, op, *content)
+ if err != nil {
+ logPluginError(xl, p, op, err, logMode)
+ return nil, errors.New("send " + op + " request to plugin error")
+ }
+ if res.Reject {
+ return nil, fmt.Errorf("%s", res.RejectReason)
+ }
+ if !res.Unchange {
+ // Preserve the existing Plugin contract: changed content must be *T.
+ // Buggy Plugin implementations still panic here, by design.
+ content = retContent.(*T)
+ }
+ }
+ return content, nil
+}
+
func (m *Manager) Register(p Plugin) {
if p.IsSupport(OpLogin) {
m.loginPlugins = append(m.loginPlugins, p)
@@ -66,71 +127,11 @@ func (m *Manager) Register(p Plugin) {
}
func (m *Manager) Login(content *LoginContent) (*LoginContent, error) {
- if len(m.loginPlugins) == 0 {
- return content, nil
- }
-
- var (
- res = &Response{
- Reject: false,
- Unchange: true,
- }
- retContent any
- err error
- )
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
-
- for _, p := range m.loginPlugins {
- res, retContent, err = p.Handle(ctx, OpLogin, *content)
- if err != nil {
- xl.Warnf("send Login request to plugin [%s] error: %v", p.Name(), err)
- return nil, errors.New("send Login request to plugin error")
- }
- if res.Reject {
- return nil, fmt.Errorf("%s", res.RejectReason)
- }
- if !res.Unchange {
- content = retContent.(*LoginContent)
- }
- }
- return content, nil
+ return handleMutableContent(m.loginPlugins, OpLogin, content, pluginErrorLogWarn)
}
func (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) {
- if len(m.newProxyPlugins) == 0 {
- return content, nil
- }
-
- var (
- res = &Response{
- Reject: false,
- Unchange: true,
- }
- retContent any
- err error
- )
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
-
- for _, p := range m.newProxyPlugins {
- res, retContent, err = p.Handle(ctx, OpNewProxy, *content)
- if err != nil {
- xl.Warnf("send NewProxy request to plugin [%s] error: %v", p.Name(), err)
- return nil, errors.New("send NewProxy request to plugin error")
- }
- if res.Reject {
- return nil, fmt.Errorf("%s", res.RejectReason)
- }
- if !res.Unchange {
- content = retContent.(*NewProxyContent)
- }
- }
- return content, nil
+ return handleMutableContent(m.newProxyPlugins, OpNewProxy, content, pluginErrorLogWarn)
}
func (m *Manager) CloseProxy(content *CloseProxyContent) error {
@@ -139,10 +140,7 @@ func (m *Manager) CloseProxy(content *CloseProxyContent) error {
}
errs := make([]string, 0)
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
+ ctx, xl := newPluginRequestContext()
for _, p := range m.closeProxyPlugins {
_, _, err := p.Handle(ctx, OpCloseProxy, *content)
@@ -159,103 +157,14 @@ func (m *Manager) CloseProxy(content *CloseProxyContent) error {
}
func (m *Manager) Ping(content *PingContent) (*PingContent, error) {
- if len(m.pingPlugins) == 0 {
- return content, nil
- }
-
- var (
- res = &Response{
- Reject: false,
- Unchange: true,
- }
- retContent any
- err error
- )
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
-
- for _, p := range m.pingPlugins {
- res, retContent, err = p.Handle(ctx, OpPing, *content)
- if err != nil {
- xl.Warnf("send Ping request to plugin [%s] error: %v", p.Name(), err)
- return nil, errors.New("send Ping request to plugin error")
- }
- if res.Reject {
- return nil, fmt.Errorf("%s", res.RejectReason)
- }
- if !res.Unchange {
- content = retContent.(*PingContent)
- }
- }
- return content, nil
+ return handleMutableContent(m.pingPlugins, OpPing, content, pluginErrorLogWarn)
}
func (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) {
- if len(m.newWorkConnPlugins) == 0 {
- return content, nil
- }
-
- var (
- res = &Response{
- Reject: false,
- Unchange: true,
- }
- retContent any
- err error
- )
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
-
- for _, p := range m.newWorkConnPlugins {
- res, retContent, err = p.Handle(ctx, OpNewWorkConn, *content)
- if err != nil {
- xl.Warnf("send NewWorkConn request to plugin [%s] error: %v", p.Name(), err)
- return nil, errors.New("send NewWorkConn request to plugin error")
- }
- if res.Reject {
- return nil, fmt.Errorf("%s", res.RejectReason)
- }
- if !res.Unchange {
- content = retContent.(*NewWorkConnContent)
- }
- }
- return content, nil
+ return handleMutableContent(m.newWorkConnPlugins, OpNewWorkConn, content, pluginErrorLogWarn)
}
func (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) {
- if len(m.newUserConnPlugins) == 0 {
- return content, nil
- }
-
- var (
- res = &Response{
- Reject: false,
- Unchange: true,
- }
- retContent any
- err error
- )
- reqid, _ := util.RandID()
- xl := xlog.New().AppendPrefix("reqid: " + reqid)
- ctx := xlog.NewContext(context.Background(), xl)
- ctx = NewReqidContext(ctx, reqid)
-
- for _, p := range m.newUserConnPlugins {
- res, retContent, err = p.Handle(ctx, OpNewUserConn, *content)
- if err != nil {
- xl.Infof("send NewUserConn request to plugin [%s] error: %v", p.Name(), err)
- return nil, errors.New("send NewUserConn request to plugin error")
- }
- if res.Reject {
- return nil, fmt.Errorf("%s", res.RejectReason)
- }
- if !res.Unchange {
- content = retContent.(*NewUserConnContent)
- }
- }
- return content, nil
+ // Preserve the pre-refactor log level for NewUserConn plugin errors.
+ return handleMutableContent(m.newUserConnPlugins, OpNewUserConn, content, pluginErrorLogInfo)
}
diff --git a/pkg/plugin/server/manager_test.go b/pkg/plugin/server/manager_test.go
new file mode 100644
index 00000000..f0306fed
--- /dev/null
+++ b/pkg/plugin/server/manager_test.go
@@ -0,0 +1,364 @@
+// Copyright 2019 fatedier, fatedier@gmail.com
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+
+ charmlog "github.com/charmbracelet/log"
+
+ "github.com/fatedier/frp/pkg/msg"
+ frplog "github.com/fatedier/frp/pkg/util/log"
+)
+
+type testPlugin struct {
+ name string
+ ops map[string]bool
+ handler func(context.Context, string, any) (*Response, any, error)
+}
+
+// Log-capturing subtests serialize global logger swaps; do not use t.Parallel.
+var logCaptureMu sync.Mutex
+
+type logCapture struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (p testPlugin) Name() string {
+ return p.name
+}
+
+func (p testPlugin) IsSupport(op string) bool {
+ return p.ops[op]
+}
+
+func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) {
+ return p.handler(ctx, op, content)
+}
+
+func (w *logCapture) Write(p []byte) (int, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.Write(p)
+}
+
+func (w *logCapture) String() string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.String()
+}
+
+// levels parses the captured JSON log output and returns the level of each entry.
+func (w *logCapture) levels() []charmlog.Level {
+ var levels []charmlog.Level
+ for _, line := range strings.Split(strings.TrimSpace(w.String()), "\n") {
+ if line == "" {
+ continue
+ }
+ var entry struct {
+ Level string `json:"level"`
+ }
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ continue
+ }
+ if lvl, err := charmlog.ParseLevel(entry.Level); err == nil {
+ levels = append(levels, lvl)
+ }
+ }
+ return levels
+}
+
+func captureLogOutput(t *testing.T) *logCapture {
+ t.Helper()
+
+ logCaptureMu.Lock()
+ logOutput := &logCapture{}
+ oldLogger := frplog.Logger
+ frplog.Logger = charmlog.NewWithOptions(logOutput, charmlog.Options{
+ Level: charmlog.DebugLevel,
+ Formatter: charmlog.JSONFormatter,
+ })
+ t.Cleanup(func() {
+ frplog.Logger = oldLogger
+ logCaptureMu.Unlock()
+ })
+ return logOutput
+}
+
+var mutablePluginOps = []struct {
+ name string
+ op string
+}{
+ {name: "login", op: OpLogin},
+ {name: "new proxy", op: OpNewProxy},
+ {name: "ping", op: OpPing},
+ {name: "new work conn", op: OpNewWorkConn},
+ {name: "new user conn", op: OpNewUserConn},
+}
+
+func callMutableWithUser(m *Manager, op string, user string) (string, error) {
+ switch op {
+ case OpLogin:
+ got, err := m.Login(&LoginContent{Login: msg.Login{User: user}})
+ if got == nil {
+ return "", err
+ }
+ return got.User, err
+ case OpNewProxy:
+ got, err := m.NewProxy(&NewProxyContent{User: UserInfo{User: user}})
+ if got == nil {
+ return "", err
+ }
+ return got.User.User, err
+ case OpPing:
+ got, err := m.Ping(&PingContent{User: UserInfo{User: user}})
+ if got == nil {
+ return "", err
+ }
+ return got.User.User, err
+ case OpNewWorkConn:
+ got, err := m.NewWorkConn(&NewWorkConnContent{User: UserInfo{User: user}})
+ if got == nil {
+ return "", err
+ }
+ return got.User.User, err
+ case OpNewUserConn:
+ got, err := m.NewUserConn(&NewUserConnContent{User: UserInfo{User: user}})
+ if got == nil {
+ return "", err
+ }
+ return got.User.User, err
+ default:
+ panic("unsupported mutable op: " + op)
+ }
+}
+
+func mutableUser(t *testing.T, op string, content any) string {
+ t.Helper()
+
+ switch op {
+ case OpLogin:
+ return content.(LoginContent).User
+ case OpNewProxy:
+ return content.(NewProxyContent).User.User
+ case OpPing:
+ return content.(PingContent).User.User
+ case OpNewWorkConn:
+ return content.(NewWorkConnContent).User.User
+ case OpNewUserConn:
+ return content.(NewUserConnContent).User.User
+ default:
+ t.Fatalf("unsupported mutable op: %s", op)
+ return ""
+ }
+}
+
+func mutateMutableContent(t *testing.T, op string, content any, user string) any {
+ t.Helper()
+
+ switch op {
+ case OpLogin:
+ got := content.(LoginContent)
+ got.User = user
+ return &got
+ case OpNewProxy:
+ got := content.(NewProxyContent)
+ got.User.User = user
+ return &got
+ case OpPing:
+ got := content.(PingContent)
+ got.User.User = user
+ return &got
+ case OpNewWorkConn:
+ got := content.(NewWorkConnContent)
+ got.User.User = user
+ return &got
+ case OpNewUserConn:
+ got := content.(NewUserConnContent)
+ got.User.User = user
+ return &got
+ default:
+ t.Fatalf("unsupported mutable op: %s", op)
+ return nil
+ }
+}
+
+func TestManagerMutableContentAcrossOps(t *testing.T) {
+ for _, tt := range mutablePluginOps {
+ t.Run(tt.name, func(t *testing.T) {
+ m := NewManager()
+ m.Register(testPlugin{
+ name: "mutate",
+ ops: map[string]bool{tt.op: true},
+ handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
+ if op != tt.op {
+ t.Fatalf("unexpected op: %s", op)
+ }
+ if GetReqidFromContext(ctx) == "" {
+ t.Fatal("expected request id in context")
+ }
+ if got := mutableUser(t, tt.op, content); got != "initial" {
+ t.Fatalf("expected initial user, got %q", got)
+ }
+ return &Response{Unchange: false}, mutateMutableContent(t, tt.op, content, "mutated"), nil
+ },
+ })
+ m.Register(testPlugin{
+ name: "observe",
+ ops: map[string]bool{tt.op: true},
+ handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
+ if op != tt.op {
+ t.Fatalf("unexpected op: %s", op)
+ }
+ if GetReqidFromContext(ctx) == "" {
+ t.Fatal("expected request id in context")
+ }
+ if got := mutableUser(t, tt.op, content); got != "mutated" {
+ t.Fatalf("expected mutated user, got %q", got)
+ }
+ return &Response{Unchange: true}, mutateMutableContent(t, tt.op, content, "ignored"), nil
+ },
+ })
+
+ got, err := callMutableWithUser(m, tt.op, "initial")
+ if err != nil {
+ t.Fatalf("mutable op failed: %v", err)
+ }
+ if got != "mutated" {
+ t.Fatalf("expected mutated user, got %q", got)
+ }
+ })
+ }
+}
+
+func TestManagerMutableContentRejectStopsChain(t *testing.T) {
+ m := NewManager()
+
+ var called bool
+ m.Register(testPlugin{
+ name: "reject",
+ ops: map[string]bool{OpPing: true},
+ handler: func(context.Context, string, any) (*Response, any, error) {
+ return &Response{Reject: true, RejectReason: "blocked"}, nil, nil
+ },
+ })
+ m.Register(testPlugin{
+ name: "unused",
+ ops: map[string]bool{OpPing: true},
+ handler: func(context.Context, string, any) (*Response, any, error) {
+ called = true
+ return &Response{Unchange: true}, nil, nil
+ },
+ })
+
+ got, err := m.Ping(&PingContent{})
+ if err == nil {
+ t.Fatal("expected reject error")
+ }
+ if got != nil {
+ t.Fatalf("expected no returned content, got %#v", got)
+ }
+ if err.Error() != "blocked" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if called {
+ t.Fatal("expected plugin chain to stop after reject")
+ }
+}
+
+func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
+ tests := []struct {
+ name string
+ op string
+ level charmlog.Level
+ }{
+ {name: "default warning", op: OpLogin, level: charmlog.WarnLevel},
+ {name: "new user conn info", op: OpNewUserConn, level: charmlog.InfoLevel},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ logOutput := captureLogOutput(t)
+ m := NewManager()
+ m.Register(testPlugin{
+ name: "error",
+ ops: map[string]bool{tt.op: true},
+ handler: func(context.Context, string, any) (*Response, any, error) {
+ return nil, nil, errors.New("boom")
+ },
+ })
+
+ _, err := callMutableWithUser(m, tt.op, "initial")
+ if err == nil {
+ t.Fatal("expected plugin error")
+ }
+ if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ levels := logOutput.levels()
+ if len(levels) != 1 || levels[0] != tt.level {
+ t.Fatalf("expected log level %v, got %v in %q", tt.level, levels, logOutput.String())
+ }
+ })
+ }
+}
+
+func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
+ logOutput := captureLogOutput(t)
+ m := NewManager()
+
+ for _, name := range []string{"first", "second"} {
+ m.Register(testPlugin{
+ name: name,
+ ops: map[string]bool{OpCloseProxy: true},
+ handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
+ if GetReqidFromContext(ctx) == "" {
+ t.Fatal("expected request id in context")
+ }
+ if op != OpCloseProxy {
+ t.Fatalf("unexpected op: %s", op)
+ }
+ return nil, nil, errors.New(name + " error")
+ },
+ })
+ }
+
+ err := m.CloseProxy(&CloseProxyContent{})
+ if err == nil {
+ t.Fatal("expected close proxy error")
+ }
+ if !strings.HasPrefix(err.Error(), "send CloseProxy request to plugin errors: ") {
+ t.Fatalf("unexpected close proxy error prefix: %v", err)
+ }
+ if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
+ t.Fatalf("missing aggregated errors: %v", err)
+ }
+ levels := logOutput.levels()
+ if len(levels) != 2 {
+ t.Fatalf("expected two warning logs, got %v", levels)
+ }
+ for _, level := range levels {
+ if level != charmlog.WarnLevel {
+ t.Fatalf("expected warning log level, got %v", levels)
+ }
+ }
+}
diff --git a/pkg/plugin/visitor/virtual_net.go b/pkg/plugin/visitor/virtual_net.go
index 8193ce03..fa8c700e 100644
--- a/pkg/plugin/visitor/virtual_net.go
+++ b/pkg/plugin/visitor/virtual_net.go
@@ -153,10 +153,7 @@ func (p *VirtualNetPlugin) run() {
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
baseDelay := 60 * time.Second
- reconnectDelay = baseDelay * time.Duration(1< 300*time.Second {
- reconnectDelay = 300 * time.Second
- }
+ reconnectDelay = min(baseDelay*time.Duration(1< 0 {
diff --git a/pkg/policy/featuregate/feature_gate.go b/pkg/policy/featuregate/feature_gate.go
index 81a392b7..f27aecf3 100644
--- a/pkg/policy/featuregate/feature_gate.go
+++ b/pkg/policy/featuregate/feature_gate.go
@@ -16,6 +16,7 @@ package featuregate
import (
"fmt"
+ "maps"
"sort"
"strings"
"sync"
@@ -92,10 +93,7 @@ type featureGate struct {
// NewFeatureGate creates a new feature gate with the default features
func NewFeatureGate() MutableFeatureGate {
- known := map[Feature]FeatureSpec{}
- for k, v := range defaultFeatures {
- known[k] = v
- }
+ known := maps.Clone(defaultFeatures)
f := &featureGate{}
f.known.Store(known)
@@ -109,14 +107,8 @@ func (f *featureGate) SetFromMap(m map[string]bool) error {
defer f.lock.Unlock()
// Copy existing state
- known := map[Feature]FeatureSpec{}
- for k, v := range f.known.Load().(map[Feature]FeatureSpec) {
- known[k] = v
- }
- enabled := map[Feature]bool{}
- for k, v := range f.enabled.Load().(map[Feature]bool) {
- enabled[k] = v
- }
+ known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
+ enabled := maps.Clone(f.enabled.Load().(map[Feature]bool))
// Apply the new settings
for k, v := range m {
@@ -147,10 +139,7 @@ func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
}
// Copy existing state
- known := map[Feature]FeatureSpec{}
- for k, v := range f.known.Load().(map[Feature]FeatureSpec) {
- known[k] = v
- }
+ known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
// Add new features
for name, spec := range features {
diff --git a/pkg/proto/udp/udp.go b/pkg/proto/udp/udp.go
index 0ad8aae1..70b51900 100644
--- a/pkg/proto/udp/udp.go
+++ b/pkg/proto/udp/udp.go
@@ -15,7 +15,6 @@
package udp
import (
- "encoding/base64"
"net"
"sync"
"time"
@@ -28,16 +27,17 @@ import (
)
func NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket {
+ content := make([]byte, len(buf))
+ copy(content, buf)
return &msg.UDPPacket{
- Content: base64.StdEncoding.EncodeToString(buf),
+ Content: content,
LocalAddr: laddr,
RemoteAddr: raddr,
}
}
func GetContent(m *msg.UDPPacket) (buf []byte, err error) {
- buf, err = base64.StdEncoding.DecodeString(m.Content)
- return
+ return m.Content, nil
}
func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh chan<- *msg.UDPPacket, bufSize int) {
@@ -60,7 +60,7 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
if err != nil {
return
}
- // buf[:n] will be encoded to string, so the bytes can be reused
+ // NewUDPPacket copies buf[:n], so the read buffer can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {
@@ -85,6 +85,7 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
}()
buf := pool.GetBuf(bufSize)
+ defer pool.PutBuf(buf)
for {
_ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, _, err := udpConn.ReadFromUDP(buf)
@@ -139,6 +140,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
_, err = udpConn.Write(buf)
if err != nil {
udpConn.Close()
+ } else {
+ _ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))
}
if !ok {
diff --git a/pkg/proto/wire/crypto.go b/pkg/proto/wire/crypto.go
new file mode 100644
index 00000000..69aedc69
--- /dev/null
+++ b/pkg/proto/wire/crypto.go
@@ -0,0 +1,197 @@
+// 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 wire
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "hash"
+ "runtime"
+
+ "golang.org/x/sys/cpu"
+)
+
+const (
+ AEADAlgorithmAES256GCM = "aes-256-gcm"
+ AEADAlgorithmXChaCha20Poly1305 = "xchacha20-poly1305"
+
+ CryptoRandomSize = 32
+
+ cryptoTranscriptLabel = "frp wire v2 crypto transcript"
+)
+
+var supportedAEADAlgorithms = []string{
+ AEADAlgorithmAES256GCM,
+ AEADAlgorithmXChaCha20Poly1305,
+}
+
+type CryptoContext struct {
+ Algorithm string
+ TranscriptHash []byte
+}
+
+func NewClientHello(bootstrap BootstrapInfo) (ClientHello, error) {
+ clientRandom, err := newCryptoRandom()
+ if err != nil {
+ return ClientHello{}, err
+ }
+ return clientHelloWithCryptoRandom(bootstrap, clientRandom), nil
+}
+
+func NewServerHello(clientHello ClientHello) (ServerHello, error) {
+ if err := ValidateClientHello(clientHello); err != nil {
+ return ServerHello{}, err
+ }
+ algorithm, ok := SelectAEADAlgorithm(clientHello.Capabilities.Crypto.Algorithms)
+ if !ok {
+ return ServerHello{}, fmt.Errorf("no supported crypto algorithm")
+ }
+ serverRandom, err := newCryptoRandom()
+ if err != nil {
+ return ServerHello{}, err
+ }
+ return ServerHello{
+ Selected: ServerSelection{
+ Message: MessageSelection{
+ Codec: MessageCodecJSON,
+ },
+ Crypto: CryptoSelection{
+ Algorithm: algorithm,
+ ServerRandom: serverRandom,
+ },
+ },
+ }, nil
+}
+
+func ValidateCryptoCapabilities(c CryptoCapabilities) error {
+ if len(c.ClientRandom) != CryptoRandomSize {
+ return fmt.Errorf("invalid crypto client random length %d, want %d", len(c.ClientRandom), CryptoRandomSize)
+ }
+ if _, ok := SelectAEADAlgorithm(c.Algorithms); !ok {
+ return fmt.Errorf("no supported crypto algorithm")
+ }
+ return nil
+}
+
+func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHello) error {
+ if serverHello.Selected.Message.Codec != MessageCodecJSON {
+ return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
+ }
+ cryptoSelection := serverHello.Selected.Crypto
+ if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
+ return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
+ }
+ if !Supports(clientHello.Capabilities.Crypto.Algorithms, cryptoSelection.Algorithm) {
+ return fmt.Errorf("selected crypto algorithm was not advertised by client: %s", cryptoSelection.Algorithm)
+ }
+ if len(cryptoSelection.ServerRandom) != CryptoRandomSize {
+ return fmt.Errorf("invalid crypto server random length %d, want %d", len(cryptoSelection.ServerRandom), CryptoRandomSize)
+ }
+ return nil
+}
+
+func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
+ return &CryptoContext{
+ Algorithm: algorithm,
+ TranscriptHash: HashCryptoTranscript(clientHelloPayload, serverHelloPayload),
+ }
+}
+
+func NewClientCryptoContext(clientHelloPayload, serverHelloPayload []byte) (*CryptoContext, error) {
+ var clientHello ClientHello
+ if err := json.Unmarshal(clientHelloPayload, &clientHello); err != nil {
+ return nil, fmt.Errorf("decode ClientHello transcript: %w", err)
+ }
+ var serverHello ServerHello
+ if err := json.Unmarshal(serverHelloPayload, &serverHello); err != nil {
+ return nil, fmt.Errorf("decode ServerHello transcript: %w", err)
+ }
+ if err := ValidateServerHelloForClient(clientHello, serverHello); err != nil {
+ return nil, err
+ }
+
+ return NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload), nil
+}
+
+func HashCryptoTranscript(clientHelloPayload, serverHelloPayload []byte) []byte {
+ h := sha256.New()
+ _, _ = h.Write([]byte(cryptoTranscriptLabel))
+ writeCryptoTranscriptPart(h, "client hello", clientHelloPayload)
+ writeCryptoTranscriptPart(h, "server hello", serverHelloPayload)
+ return h.Sum(nil)
+}
+
+func writeCryptoTranscriptPart(h hash.Hash, label string, payload []byte) {
+ var length [8]byte
+ binary.BigEndian.PutUint64(length[:], uint64(len(payload)))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write([]byte(label))
+ _, _ = h.Write([]byte{0})
+ _, _ = h.Write(length[:])
+ _, _ = h.Write(payload)
+}
+
+func PreferredAEADAlgorithms() []string {
+ if hasFastAESGCM() {
+ return []string{AEADAlgorithmAES256GCM, AEADAlgorithmXChaCha20Poly1305}
+ }
+ return []string{AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
+}
+
+func SelectAEADAlgorithm(clientAlgorithms []string) (string, bool) {
+ for _, algorithm := range clientAlgorithms {
+ if IsSupportedAEADAlgorithm(algorithm) {
+ return algorithm, true
+ }
+ }
+ return "", false
+}
+
+func IsSupportedAEADAlgorithm(algorithm string) bool {
+ return Supports(supportedAEADAlgorithms, algorithm)
+}
+
+func newCryptoRandom() ([]byte, error) {
+ b := make([]byte, CryptoRandomSize)
+ if _, err := rand.Read(b); err != nil {
+ return nil, fmt.Errorf("generate crypto random: %w", err)
+ }
+ return b, nil
+}
+
+func hasFastAESGCM() bool {
+ switch runtime.GOARCH {
+ case "amd64":
+ return cpu.X86.HasAES &&
+ cpu.X86.HasPCLMULQDQ &&
+ cpu.X86.HasSSE41 &&
+ cpu.X86.HasSSSE3
+ case "arm64":
+ return cpu.ARM64.HasAES && cpu.ARM64.HasPMULL
+ case "s390x":
+ return cpu.S390X.HasAES &&
+ cpu.S390X.HasAESCTR &&
+ cpu.S390X.HasGHASH
+ case "ppc64", "ppc64le":
+ // Go's ppc64/ppc64le port targets POWER8+, which has AES instructions;
+ // x/sys/cpu does not expose a PPC64 AES feature flag.
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/proto/wire/wire.go b/pkg/proto/wire/wire.go
new file mode 100644
index 00000000..47bf5984
--- /dev/null
+++ b/pkg/proto/wire/wire.go
@@ -0,0 +1,246 @@
+// 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 wire
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "slices"
+
+ libnet "github.com/fatedier/golib/net"
+)
+
+const (
+ ProtocolV1 = "v1"
+ ProtocolV2 = "v2"
+
+ WireVersionV2 = 2
+
+ FrameTypeClientHello uint16 = 1
+ FrameTypeServerHello uint16 = 2
+ FrameTypeMessage uint16 = 16
+
+ MessageCodecJSON = "json"
+ DefaultMaxFramePayloadSize = 64 * 1024
+
+ MagicV2 = "FRP\x00\x02\r\n"
+)
+
+type Frame struct {
+ Type uint16
+ Flags uint16
+ Payload []byte
+}
+
+type Conn struct {
+ rw io.ReadWriter
+ maxFramePayloadSize uint32
+}
+
+func NewConn(rw io.ReadWriter) *Conn {
+ return &Conn{
+ rw: rw,
+ maxFramePayloadSize: DefaultMaxFramePayloadSize,
+ }
+}
+
+func (c *Conn) ReadFrame() (*Frame, error) {
+ header := make([]byte, 8)
+ if _, err := io.ReadFull(c.rw, header); err != nil {
+ return nil, err
+ }
+
+ frameType := binary.BigEndian.Uint16(header[0:2])
+ flags := binary.BigEndian.Uint16(header[2:4])
+ length := binary.BigEndian.Uint32(header[4:8])
+ if flags != 0 {
+ return nil, fmt.Errorf("unsupported frame flags: %d", flags)
+ }
+ if length > c.maxFramePayloadSize {
+ return nil, fmt.Errorf("frame payload length %d exceeds limit %d", length, c.maxFramePayloadSize)
+ }
+
+ payload := make([]byte, length)
+ if _, err := io.ReadFull(c.rw, payload); err != nil {
+ return nil, err
+ }
+ return &Frame{
+ Type: frameType,
+ Flags: flags,
+ Payload: payload,
+ }, nil
+}
+
+func (c *Conn) WriteFrame(f *Frame) error {
+ if f.Flags != 0 {
+ return fmt.Errorf("unsupported frame flags: %d", f.Flags)
+ }
+ if len(f.Payload) > int(c.maxFramePayloadSize) {
+ return fmt.Errorf("frame payload length %d exceeds limit %d", len(f.Payload), c.maxFramePayloadSize)
+ }
+
+ header := make([]byte, 8)
+ binary.BigEndian.PutUint16(header[0:2], f.Type)
+ binary.BigEndian.PutUint16(header[2:4], f.Flags)
+ binary.BigEndian.PutUint32(header[4:8], uint32(len(f.Payload)))
+ if _, err := c.rw.Write(header); err != nil {
+ return err
+ }
+ _, err := c.rw.Write(f.Payload)
+ return err
+}
+
+func (c *Conn) ReadJSONFrame(frameType uint16, out any) error {
+ f, err := c.ReadFrame()
+ if err != nil {
+ return err
+ }
+ if f.Type != frameType {
+ return fmt.Errorf("unexpected frame type %d, want %d", f.Type, frameType)
+ }
+ return c.UnmarshalFrame(f, out)
+}
+
+func (c *Conn) UnmarshalFrame(f *Frame, out any) error {
+ return json.Unmarshal(f.Payload, out)
+}
+
+func NewJSONFrame(frameType uint16, in any) (*Frame, error) {
+ payload, err := json.Marshal(in)
+ if err != nil {
+ return nil, err
+ }
+ return &Frame{
+ Type: frameType,
+ Payload: payload,
+ }, nil
+}
+
+func (c *Conn) WriteJSONFrame(frameType uint16, in any) error {
+ f, err := NewJSONFrame(frameType, in)
+ if err != nil {
+ return err
+ }
+ return c.WriteFrame(f)
+}
+
+func WriteMagic(w io.Writer) error {
+ _, err := io.WriteString(w, MagicV2)
+ return err
+}
+
+func WriteMagicIfV2(w io.Writer, wireProtocol string) error {
+ if wireProtocol != ProtocolV2 {
+ return nil
+ }
+ return WriteMagic(w)
+}
+
+func CheckMagic(conn net.Conn) (out net.Conn, isV2 bool, err error) {
+ sharedConn, r := libnet.NewSharedConnSize(conn, len(MagicV2))
+ buf := make([]byte, len(MagicV2))
+ if _, err = io.ReadFull(r, buf); err != nil {
+ return nil, false, err
+ }
+ for i := range MagicV2 {
+ if buf[i] != MagicV2[i] {
+ return sharedConn, false, nil
+ }
+ }
+ return conn, true, nil
+}
+
+type BootstrapInfo struct {
+ Transport string `json:"transport,omitempty"`
+ TLS bool `json:"tls,omitempty"`
+ TCPMux bool `json:"tcpMux,omitempty"`
+}
+
+type ClientHello struct {
+ Bootstrap BootstrapInfo `json:"bootstrap,omitempty"`
+ Capabilities ClientCapabilities `json:"capabilities,omitempty"`
+}
+
+type ClientCapabilities struct {
+ Message MessageCapabilities `json:"message,omitempty"`
+ Crypto CryptoCapabilities `json:"crypto,omitempty"`
+}
+
+type MessageCapabilities struct {
+ Codecs []string `json:"codecs,omitempty"`
+}
+
+type CryptoCapabilities struct {
+ Algorithms []string `json:"algorithms,omitempty"`
+ ClientRandom []byte `json:"clientRandom,omitempty"`
+}
+
+type ServerHello struct {
+ Selected ServerSelection `json:"selected,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type ServerSelection struct {
+ Message MessageSelection `json:"message,omitempty"`
+ Crypto CryptoSelection `json:"crypto,omitempty"`
+}
+
+type MessageSelection struct {
+ Codec string `json:"codec,omitempty"`
+}
+
+type CryptoSelection struct {
+ Algorithm string `json:"algorithm,omitempty"`
+ ServerRandom []byte `json:"serverRandom,omitempty"`
+}
+
+func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) ClientHello {
+ return ClientHello{
+ Bootstrap: bootstrap,
+ Capabilities: ClientCapabilities{
+ Message: MessageCapabilities{
+ Codecs: []string{MessageCodecJSON},
+ },
+ Crypto: CryptoCapabilities{
+ Algorithms: PreferredAEADAlgorithms(),
+ ClientRandom: clientRandom,
+ },
+ },
+ }
+}
+
+func DefaultServerHello() ServerHello {
+ return ServerHello{
+ Selected: ServerSelection{
+ Message: MessageSelection{
+ Codec: MessageCodecJSON,
+ },
+ },
+ }
+}
+
+func Supports(list []string, value string) bool {
+ return slices.Contains(list, value)
+}
+
+func ValidateClientHello(h ClientHello) error {
+ if !Supports(h.Capabilities.Message.Codecs, MessageCodecJSON) {
+ return fmt.Errorf("unsupported message codec")
+ }
+ return ValidateCryptoCapabilities(h.Capabilities.Crypto)
+}
diff --git a/pkg/proto/wire/wire_test.go b/pkg/proto/wire/wire_test.go
new file mode 100644
index 00000000..b564f712
--- /dev/null
+++ b/pkg/proto/wire/wire_test.go
@@ -0,0 +1,233 @@
+// 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 wire
+
+import (
+ "bytes"
+ "encoding/binary"
+ "io"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFrameRoundTrip(t *testing.T) {
+ var buf bytes.Buffer
+ conn := NewConn(&buf)
+
+ in := mustClientHello(t, BootstrapInfo{
+ Transport: "tcp",
+ TLS: true,
+ TCPMux: true,
+ })
+ require.NoError(t, conn.WriteJSONFrame(FrameTypeClientHello, in))
+
+ var out ClientHello
+ require.NoError(t, conn.ReadJSONFrame(FrameTypeClientHello, &out))
+ require.Equal(t, in, out)
+}
+
+func TestReadFrameRejectsUnsupportedFlags(t *testing.T) {
+ var buf bytes.Buffer
+ header := make([]byte, 8)
+ binary.BigEndian.PutUint16(header[0:2], FrameTypeMessage)
+ binary.BigEndian.PutUint16(header[2:4], 1)
+ binary.BigEndian.PutUint32(header[4:8], 0)
+ buf.Write(header)
+
+ _, err := NewConn(&buf).ReadFrame()
+ require.ErrorContains(t, err, "unsupported frame flags")
+}
+
+func TestReadFrameRejectsOversizedPayload(t *testing.T) {
+ var buf bytes.Buffer
+ header := make([]byte, 8)
+ binary.BigEndian.PutUint16(header[0:2], FrameTypeMessage)
+ binary.BigEndian.PutUint32(header[4:8], DefaultMaxFramePayloadSize+1)
+ buf.Write(header)
+
+ _, err := NewConn(&buf).ReadFrame()
+ require.ErrorContains(t, err, "exceeds limit")
+}
+
+func TestCheckMagicV2ConsumesMagic(t *testing.T) {
+ client, server := net.Pipe()
+ defer server.Close()
+
+ want := []byte("payload")
+ go func() {
+ defer client.Close()
+ _, _ = client.Write(append([]byte(MagicV2), want...))
+ }()
+
+ out, isV2, err := CheckMagic(server)
+ require.NoError(t, err)
+ require.True(t, isV2)
+
+ got := make([]byte, len(want))
+ _, err = io.ReadFull(out, got)
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+}
+
+func TestWriteMagicIfV2(t *testing.T) {
+ var buf bytes.Buffer
+ require.NoError(t, WriteMagicIfV2(&buf, ProtocolV1))
+ require.Empty(t, buf.Bytes())
+
+ require.NoError(t, WriteMagicIfV2(&buf, ProtocolV2))
+ require.Equal(t, []byte(MagicV2), buf.Bytes())
+}
+
+func TestCheckMagicV1PreservesReadBytes(t *testing.T) {
+ client, server := net.Pipe()
+ defer server.Close()
+
+ want := []byte("legacy payload")
+ go func() {
+ defer client.Close()
+ _, _ = client.Write(want)
+ }()
+
+ out, isV2, err := CheckMagic(server)
+ require.NoError(t, err)
+ require.False(t, isV2)
+
+ got, err := io.ReadAll(out)
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+}
+
+func TestValidateClientHello(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ require.NoError(t, ValidateClientHello(hello))
+ require.Len(t, hello.Capabilities.Crypto.ClientRandom, CryptoRandomSize)
+ require.ElementsMatch(t, []string{
+ AEADAlgorithmAES256GCM,
+ AEADAlgorithmXChaCha20Poly1305,
+ }, hello.Capabilities.Crypto.Algorithms)
+
+ hello.Capabilities.Message.Codecs = []string{"unknown"}
+ require.ErrorContains(t, ValidateClientHello(hello), "unsupported message codec")
+}
+
+func TestValidateClientHelloRejectsInvalidCrypto(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ hello.Capabilities.Crypto.ClientRandom = hello.Capabilities.Crypto.ClientRandom[:CryptoRandomSize-1]
+ require.ErrorContains(t, ValidateClientHello(hello), "invalid crypto client random length")
+
+ hello = mustClientHello(t, BootstrapInfo{})
+ hello.Capabilities.Crypto.Algorithms = []string{"unknown"}
+ require.ErrorContains(t, ValidateClientHello(hello), "no supported crypto algorithm")
+}
+
+func TestPreferredAEADAlgorithms(t *testing.T) {
+ require.ElementsMatch(t, []string{
+ AEADAlgorithmAES256GCM,
+ AEADAlgorithmXChaCha20Poly1305,
+ }, PreferredAEADAlgorithms())
+}
+
+func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ hello.Capabilities.Crypto.Algorithms = []string{"future-aead", AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
+
+ serverHello, err := NewServerHello(hello)
+ require.NoError(t, err)
+ require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
+ require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
+ require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
+}
+
+func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ serverHello, err := NewServerHello(hello)
+ require.NoError(t, err)
+ clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
+
+ ctx, err := NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
+ require.NoError(t, err)
+ require.Equal(t, serverHello.Selected.Crypto.Algorithm, ctx.Algorithm)
+ require.Len(t, ctx.TranscriptHash, 32)
+
+ tampered := serverHello
+ tampered.Selected.Crypto.ServerRandom = append([]byte(nil), serverHello.Selected.Crypto.ServerRandom...)
+ tampered.Selected.Crypto.ServerRandom[0] ^= 0xff
+ _, tamperedServerHelloPayload := mustCryptoTranscriptPayloads(t, hello, tampered)
+ tamperedCtx, err := NewClientCryptoContext(clientHelloPayload, tamperedServerHelloPayload)
+ require.NoError(t, err)
+ require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
+}
+
+func TestNewCryptoContextBindsFullClientHelloPayload(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{
+ Transport: "tcp",
+ TLS: true,
+ TCPMux: true,
+ })
+ serverHello, err := NewServerHello(hello)
+ require.NoError(t, err)
+ clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
+
+ ctx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload)
+
+ tamperedHello := hello
+ tamperedHello.Bootstrap.TLS = false
+ tamperedClientHelloPayload, _ := mustCryptoTranscriptPayloads(t, tamperedHello, serverHello)
+ tamperedCtx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, tamperedClientHelloPayload, serverHelloPayload)
+ require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
+}
+
+func TestNewClientCryptoContextRejectsUnknownServerSelection(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ serverHello, err := NewServerHello(hello)
+ require.NoError(t, err)
+
+ serverHello.Selected.Crypto.Algorithm = "unknown"
+ clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
+ _, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
+ require.ErrorContains(t, err, "unknown selected crypto algorithm")
+}
+
+func TestNewClientCryptoContextRejectsUnadvertisedServerSelection(t *testing.T) {
+ hello := mustClientHello(t, BootstrapInfo{})
+ hello.Capabilities.Crypto.Algorithms = []string{AEADAlgorithmAES256GCM}
+ serverHello, err := NewServerHello(hello)
+ require.NoError(t, err)
+
+ serverHello.Selected.Crypto.Algorithm = AEADAlgorithmXChaCha20Poly1305
+ clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
+ _, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
+ require.ErrorContains(t, err, "selected crypto algorithm was not advertised by client")
+}
+
+func mustClientHello(t *testing.T, bootstrap BootstrapInfo) ClientHello {
+ t.Helper()
+
+ hello, err := NewClientHello(bootstrap)
+ require.NoError(t, err)
+ return hello
+}
+
+func mustCryptoTranscriptPayloads(t *testing.T, hello ClientHello, serverHello ServerHello) ([]byte, []byte) {
+ t.Helper()
+
+ clientHelloFrame, err := NewJSONFrame(FrameTypeClientHello, hello)
+ require.NoError(t, err)
+ serverHelloFrame, err := NewJSONFrame(FrameTypeServerHello, serverHello)
+ require.NoError(t, err)
+ return clientHelloFrame.Payload, serverHelloFrame.Payload
+}
diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go
index dfad996d..088d3f8f 100644
--- a/pkg/sdk/client/client.go
+++ b/pkg/sdk/client/client.go
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
- "github.com/fatedier/frp/client/api"
+ "github.com/fatedier/frp/client/http/model"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
@@ -32,7 +32,7 @@ func (c *Client) SetAuth(user, pwd string) {
c.authPwd = pwd
}
-func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxyStatusResp, error) {
+func (c *Client) GetProxyStatus(ctx context.Context, name string) (*model.ProxyStatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -41,7 +41,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
if err != nil {
return nil, err
}
- allStatus := make(api.StatusResp)
+ allStatus := make(model.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}
@@ -55,7 +55,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
return nil, fmt.Errorf("no proxy status found")
}
-func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) {
+func (c *Client) GetAllProxyStatus(ctx context.Context) (model.StatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -64,7 +64,7 @@ func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error)
if err != nil {
return nil, err
}
- allStatus := make(api.StatusResp)
+ allStatus := make(model.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}
diff --git a/pkg/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/transport/tls.go b/pkg/transport/tls.go
index e8d2bf48..19ebca73 100644
--- a/pkg/transport/tls.go
+++ b/pkg/transport/tls.go
@@ -20,6 +20,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
+ "fmt"
"math/big"
"os"
"time"
@@ -85,7 +86,9 @@ func newCertPool(caPath string) (*x509.CertPool, error) {
return nil, err
}
- pool.AppendCertsFromPEM(caCrt)
+ if !pool.AppendCertsFromPEM(caCrt) {
+ return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
+ }
return pool, nil
}
diff --git a/pkg/util/http/handler.go b/pkg/util/http/handler.go
index d6c7be18..55de2932 100644
--- a/pkg/util/http/handler.go
+++ b/pkg/util/http/handler.go
@@ -26,6 +26,12 @@ type GeneralResponse struct {
Msg string
}
+type V2Response struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data any `json:"data"`
+}
+
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
}
}
}
+
+// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
+func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := NewContext(w, r)
+ res, err := handler(ctx)
+ if err != nil {
+ log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
+ code := http.StatusInternalServerError
+ if e, ok := err.(*Error); ok {
+ code = e.Code
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
+ }
+}
diff --git a/pkg/util/http/http.go b/pkg/util/http/http.go
index b85a46a3..8a763727 100644
--- a/pkg/util/http/http.go
+++ b/pkg/util/http/http.go
@@ -89,11 +89,11 @@ func ParseBasicAuth(auth string) (username, password string, ok bool) {
return
}
cs := string(c)
- s := strings.IndexByte(cs, ':')
- if s < 0 {
+ before, after, found := strings.Cut(cs, ":")
+ if !found {
return
}
- return cs[:s], cs[s+1:], true
+ return before, after, true
}
func BasicAuth(username, passwd string) string {
diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go
index 99bed364..0bca8993 100644
--- a/pkg/util/http/server.go
+++ b/pkg/util/http/server.go
@@ -100,7 +100,11 @@ func (s *Server) Run() error {
}
func (s *Server) Close() error {
- return s.hs.Close()
+ err := s.hs.Close()
+ if s.ln != nil {
+ _ = s.ln.Close()
+ }
+ return err
}
type RouterRegisterHelper struct {
diff --git a/pkg/util/jsonx/json_v1.go b/pkg/util/jsonx/json_v1.go
new file mode 100644
index 00000000..1fb98290
--- /dev/null
+++ b/pkg/util/jsonx/json_v1.go
@@ -0,0 +1,45 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jsonx
+
+import (
+ "bytes"
+ "encoding/json"
+)
+
+type DecodeOptions struct {
+ RejectUnknownMembers bool
+}
+
+func Marshal(v any) ([]byte, error) {
+ return json.Marshal(v)
+}
+
+func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
+ return json.MarshalIndent(v, prefix, indent)
+}
+
+func Unmarshal(data []byte, out any) error {
+ return json.Unmarshal(data, out)
+}
+
+func UnmarshalWithOptions(data []byte, out any, options DecodeOptions) error {
+ if !options.RejectUnknownMembers {
+ return json.Unmarshal(data, out)
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ return decoder.Decode(out)
+}
diff --git a/pkg/util/jsonx/raw_message.go b/pkg/util/jsonx/raw_message.go
new file mode 100644
index 00000000..3eda4628
--- /dev/null
+++ b/pkg/util/jsonx/raw_message.go
@@ -0,0 +1,36 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jsonx
+
+import "fmt"
+
+// RawMessage stores a raw encoded JSON value.
+// It is equivalent to encoding/json.RawMessage behavior.
+type RawMessage []byte
+
+func (m RawMessage) MarshalJSON() ([]byte, error) {
+ if m == nil {
+ return []byte("null"), nil
+ }
+ return m, nil
+}
+
+func (m *RawMessage) UnmarshalJSON(data []byte) error {
+ if m == nil {
+ return fmt.Errorf("jsonx.RawMessage: UnmarshalJSON on nil pointer")
+ }
+ *m = append((*m)[:0], data...)
+ return nil
+}
diff --git a/pkg/util/metric/date_counter.go b/pkg/util/metric/date_counter.go
index 4524fec8..5e6b889f 100644
--- a/pkg/util/metric/date_counter.go
+++ b/pkg/util/metric/date_counter.go
@@ -17,6 +17,8 @@ package metric
import (
"sync"
"time"
+
+ "k8s.io/utils/clock"
)
type DateCounter interface {
@@ -38,27 +40,33 @@ func NewDateCounter(reserveDays int64) DateCounter {
type StandardDateCounter struct {
reserveDays int64
counts []int64
+ clock clock.PassiveClock
lastUpdateDate time.Time
mu sync.Mutex
}
func newStandardDateCounter(reserveDays int64) *StandardDateCounter {
- now := time.Now()
- now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
- s := &StandardDateCounter{
+ return newStandardDateCounterWithClock(reserveDays, clock.RealClock{})
+}
+
+func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock) *StandardDateCounter {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
+ return &StandardDateCounter{
reserveDays: reserveDays,
counts: make([]int64, reserveDays),
- lastUpdateDate: now,
+ clock: clk,
+ lastUpdateDate: startOfDay(clk.Now()),
}
- return s
}
func (c *StandardDateCounter) TodayCount() int64 {
c.mu.Lock()
defer c.mu.Unlock()
- c.rotate(time.Now())
+ c.rotate(c.clock.Now())
return c.counts[0]
}
@@ -70,65 +78,61 @@ func (c *StandardDateCounter) GetLastDaysCount(lastdays int64) []int64 {
c.mu.Lock()
defer c.mu.Unlock()
- c.rotate(time.Now())
- for i := 0; i < int(lastdays); i++ {
- counts[i] = c.counts[i]
- }
+ c.rotate(c.clock.Now())
+ copy(counts, c.counts)
return counts
}
func (c *StandardDateCounter) Inc(count int64) {
c.mu.Lock()
defer c.mu.Unlock()
- c.rotate(time.Now())
+ c.rotate(c.clock.Now())
c.counts[0] += count
}
func (c *StandardDateCounter) Dec(count int64) {
c.mu.Lock()
defer c.mu.Unlock()
- c.rotate(time.Now())
+ c.rotate(c.clock.Now())
c.counts[0] -= count
}
func (c *StandardDateCounter) Snapshot() DateCounter {
c.mu.Lock()
defer c.mu.Unlock()
- tmp := newStandardDateCounter(c.reserveDays)
- for i := 0; i < int(c.reserveDays); i++ {
- tmp.counts[i] = c.counts[i]
- }
+ tmp := newStandardDateCounterWithClock(c.reserveDays, c.clock)
+ copy(tmp.counts, c.counts)
return tmp
}
func (c *StandardDateCounter) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
- for i := 0; i < int(c.reserveDays); i++ {
- c.counts[i] = 0
- }
+ clear(c.counts)
}
// rotate
// Must hold the lock before calling this function.
func (c *StandardDateCounter) rotate(now time.Time) {
- now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ now = startOfDay(now)
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
-
- defer func() {
- c.lastUpdateDate = now
- }()
+ reserveDays := int(c.reserveDays)
if days <= 0 {
return
- } else if days >= int(c.reserveDays) {
+ } else if days >= reserveDays {
c.counts = make([]int64, c.reserveDays)
+ c.lastUpdateDate = now
return
}
newCounts := make([]int64, c.reserveDays)
- for i := days; i < int(c.reserveDays); i++ {
- newCounts[i] = c.counts[i-days]
- }
+ copy(newCounts[days:], c.counts[:reserveDays-days])
c.counts = newCounts
+ c.lastUpdateDate = now
+}
+
+// startOfDay returns midnight in t's location.
+func startOfDay(t time.Time) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
diff --git a/pkg/util/metric/date_counter_test.go b/pkg/util/metric/date_counter_test.go
index 8752f198..cbd29386 100644
--- a/pkg/util/metric/date_counter_test.go
+++ b/pkg/util/metric/date_counter_test.go
@@ -1,9 +1,12 @@
package metric
import (
+ "sync"
"testing"
+ "time"
"github.com/stretchr/testify/require"
+ clocktesting "k8s.io/utils/clock/testing"
)
func TestDateCounter(t *testing.T) {
@@ -25,3 +28,107 @@ func TestDateCounter(t *testing.T) {
dcTmp := dc.Snapshot()
require.EqualValues(5, dcTmp.TodayCount())
}
+
+func TestDateCounterRotate(t *testing.T) {
+ loc := time.FixedZone("test", 8*60*60)
+ lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
+
+ tests := []struct {
+ name string
+ now time.Time
+ want []int64
+ wantLastUpdateDate time.Time
+ }{
+ {
+ name: "same day",
+ now: time.Date(2026, time.May, 8, 12, 30, 0, 0, loc),
+ want: []int64{10, 7, 3},
+ wantLastUpdateDate: lastUpdateDate,
+ },
+ {
+ name: "clock skew",
+ now: time.Date(2026, time.May, 7, 12, 30, 0, 0, loc),
+ want: []int64{10, 7, 3},
+ wantLastUpdateDate: lastUpdateDate,
+ },
+ {
+ name: "one day",
+ now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
+ want: []int64{0, 10, 7},
+ wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
+ },
+ {
+ name: "two days",
+ now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
+ want: []int64{0, 0, 10},
+ wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
+ },
+ {
+ name: "all reserved days elapsed",
+ now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
+ want: []int64{0, 0, 0},
+ wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require := require.New(t)
+
+ dc := newStandardDateCounter(3)
+ dc.counts = []int64{10, 7, 3}
+ dc.lastUpdateDate = lastUpdateDate
+
+ dc.mu.Lock()
+ dc.rotate(tt.now)
+ dc.mu.Unlock()
+
+ require.Equal(tt.want, dc.counts)
+ require.Equal(tt.wantLastUpdateDate, dc.lastUpdateDate)
+ })
+ }
+}
+
+func TestDateCounterGetLastDaysCountReturnsCopy(t *testing.T) {
+ require := require.New(t)
+
+ clk := clocktesting.NewFakeClock(time.Date(2026, time.May, 8, 12, 30, 0, 0, time.Local))
+ dc := newStandardDateCounterWithClock(3, clk)
+ dc.counts = []int64{10, 7, 3}
+
+ counts := dc.GetLastDaysCount(2)
+ require.Equal([]int64{10, 7}, counts)
+
+ counts[0] = 100
+ require.Equal([]int64{10, 7}, dc.GetLastDaysCount(2))
+}
+
+func TestDateCounterClear(t *testing.T) {
+ require := require.New(t)
+
+ dc := newStandardDateCounter(3)
+ dc.counts = []int64{10, 7, 3}
+
+ dc.Clear()
+
+ require.Equal([]int64{0, 0, 0}, dc.counts)
+}
+
+func TestDateCounterConcurrentAccess(t *testing.T) {
+ clk := clocktesting.NewFakeClock(time.Date(2026, time.May, 8, 12, 30, 0, 0, time.Local))
+ dc := newStandardDateCounterWithClock(3, clk)
+
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Go(func() {
+ for range 100 {
+ dc.Inc(1)
+ dc.Dec(1)
+ _ = dc.TodayCount()
+ _ = dc.GetLastDaysCount(3)
+ _ = dc.Snapshot()
+ }
+ })
+ }
+ wg.Wait()
+}
diff --git a/pkg/util/net/conn.go b/pkg/util/net/conn.go
index 914a7bb5..5dd605a5 100644
--- a/pkg/util/net/conn.go
+++ b/pkg/util/net/conn.go
@@ -16,14 +16,16 @@ package net
import (
"context"
+ "crypto/sha256"
"errors"
"io"
"net"
"sync/atomic"
"time"
- "github.com/fatedier/golib/crypto"
+ libcrypto "github.com/fatedier/golib/crypto"
quic "github.com/quic-go/quic-go"
+ "golang.org/x/crypto/hkdf"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -133,7 +135,7 @@ type CloseNotifyConn struct {
net.Conn
// 1 means closed
- closeFlag int32
+ closeFlag atomic.Int32
closeFn func(error)
}
@@ -147,7 +149,7 @@ func WrapCloseNotifyConn(c net.Conn, closeFn func(error)) *CloseNotifyConn {
}
func (cc *CloseNotifyConn) Close() (err error) {
- pflag := atomic.SwapInt32(&cc.closeFlag, 1)
+ pflag := cc.closeFlag.Swap(1)
if pflag == 0 {
err = cc.Conn.Close()
if cc.closeFn != nil {
@@ -159,7 +161,7 @@ func (cc *CloseNotifyConn) Close() (err error) {
// CloseWithError closes the connection and passes the error to the close callback.
func (cc *CloseNotifyConn) CloseWithError(err error) error {
- pflag := atomic.SwapInt32(&cc.closeFlag, 1)
+ pflag := cc.closeFlag.Swap(1)
if pflag == 0 {
closeErr := cc.Conn.Close()
if cc.closeFn != nil {
@@ -173,7 +175,7 @@ func (cc *CloseNotifyConn) CloseWithError(err error) error {
type StatsConn struct {
net.Conn
- closed int64 // 1 means closed
+ closed atomic.Int64 // 1 means closed
totalRead int64
totalWrite int64
statsFunc func(totalRead, totalWrite int64)
@@ -199,7 +201,7 @@ func (statsConn *StatsConn) Write(p []byte) (n int, err error) {
}
func (statsConn *StatsConn) Close() (err error) {
- old := atomic.SwapInt64(&statsConn.closed, 1)
+ old := statsConn.closed.Swap(1)
if old != 1 {
err = statsConn.Conn.Close()
if statsConn.statsFunc != nil {
@@ -241,8 +243,8 @@ func (conn *wrapQuicStream) Close() error {
}
func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
- encReader := crypto.NewReader(rw, key)
- encWriter, err := crypto.NewWriter(rw, key)
+ encReader := libcrypto.NewReader(rw, key)
+ encWriter, err := libcrypto.NewWriter(rw, key)
if err != nil {
return nil, err
}
@@ -254,3 +256,90 @@ func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
Writer: encWriter,
}, nil
}
+
+type AEADCryptoRole int
+
+const (
+ AEADCryptoRoleClient AEADCryptoRole = iota + 1
+ AEADCryptoRoleServer
+)
+
+const (
+ aeadControlHKDFInfoPrefix = "frp wire v2 control aead"
+ aeadDirectionClientToServer = "client-to-server"
+ aeadDirectionServerToClient = "server-to-client"
+)
+
+// NewAEADCryptoReadWriter wraps rw with framed AEAD encryption for the v2
+// control channel. Frames and their order are authenticated, but end-of-stream
+// is not: a clean EOF at a frame boundary is returned as normal EOF by the
+// underlying AEAD stream. Protocols that need truncation detection for finite
+// objects must add their own authenticated final message.
+func NewAEADCryptoReadWriter(
+ rw io.ReadWriter,
+ key []byte,
+ role AEADCryptoRole,
+ algorithm string,
+ transcriptHash []byte,
+) (io.ReadWriter, error) {
+ clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(key, algorithm, transcriptHash)
+ if err != nil {
+ return nil, err
+ }
+
+ var readKey, writeKey []byte
+ switch role {
+ case AEADCryptoRoleClient:
+ readKey = serverToClientKey
+ writeKey = clientToServerKey
+ case AEADCryptoRoleServer:
+ readKey = clientToServerKey
+ writeKey = serverToClientKey
+ default:
+ return nil, errors.New("invalid aead crypto role")
+ }
+
+ encReader, err := libcrypto.NewAEADStreamReader(rw, libcrypto.AEADStreamOptions{
+ Algorithm: libcrypto.AEADAlgorithm(algorithm),
+ Key: readKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ encWriter, err := libcrypto.NewAEADStreamWriter(rw, libcrypto.AEADStreamOptions{
+ Algorithm: libcrypto.AEADAlgorithm(algorithm),
+ Key: writeKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return struct {
+ io.Reader
+ io.Writer
+ }{
+ Reader: encReader,
+ Writer: encWriter,
+ }, nil
+}
+
+func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte) (clientToServerKey, serverToClientKey []byte, err error) {
+ clientToServerKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionClientToServer)
+ if err != nil {
+ return nil, nil, err
+ }
+ serverToClientKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionServerToClient)
+ if err != nil {
+ return nil, nil, err
+ }
+ return clientToServerKey, serverToClientKey, nil
+}
+
+func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
+ info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
+ reader := hkdf.New(sha256.New, key, transcriptHash, info)
+ out := make([]byte, libcrypto.AEADKeySize)
+ if _, err := io.ReadFull(reader, out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
diff --git a/pkg/util/net/conn_test.go b/pkg/util/net/conn_test.go
new file mode 100644
index 00000000..42d3c06b
--- /dev/null
+++ b/pkg/util/net/conn_test.go
@@ -0,0 +1,118 @@
+// 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 net
+
+import (
+ "bytes"
+ "io"
+ stdnet "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestNewAEADCryptoReadWriterRoundTrip(t *testing.T) {
+ clientConn, serverConn := stdnet.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+
+ key := []byte("token")
+ transcriptHash := bytes.Repeat([]byte{0x11}, 32)
+ clientRW, err := NewAEADCryptoReadWriter(
+ clientConn,
+ key,
+ AEADCryptoRoleClient,
+ wire.AEADAlgorithmXChaCha20Poly1305,
+ transcriptHash,
+ )
+ require.NoError(t, err)
+ serverRW, err := NewAEADCryptoReadWriter(
+ serverConn,
+ key,
+ AEADCryptoRoleServer,
+ wire.AEADAlgorithmXChaCha20Poly1305,
+ transcriptHash,
+ )
+ require.NoError(t, err)
+
+ clientErrCh := make(chan error, 1)
+ go func() {
+ if _, err := clientRW.Write([]byte("ping")); err != nil {
+ clientErrCh <- err
+ return
+ }
+ buf := make([]byte, len("pong"))
+ _, err := io.ReadFull(clientRW, buf)
+ clientErrCh <- err
+ }()
+
+ buf := make([]byte, len("ping"))
+ _, err = io.ReadFull(serverRW, buf)
+ require.NoError(t, err)
+ require.Equal(t, "ping", string(buf))
+ _, err = serverRW.Write([]byte("pong"))
+ require.NoError(t, err)
+ require.NoError(t, <-clientErrCh)
+}
+
+func TestNewAEADCryptoReadWriterRejectsDifferentTranscript(t *testing.T) {
+ clientConn, serverConn := stdnet.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+ require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
+ require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
+
+ key := []byte("token")
+ clientRW, err := NewAEADCryptoReadWriter(
+ clientConn,
+ key,
+ AEADCryptoRoleClient,
+ wire.AEADAlgorithmAES256GCM,
+ bytes.Repeat([]byte{0x22}, 32),
+ )
+ require.NoError(t, err)
+ serverRW, err := NewAEADCryptoReadWriter(
+ serverConn,
+ key,
+ AEADCryptoRoleServer,
+ wire.AEADAlgorithmAES256GCM,
+ bytes.Repeat([]byte{0x33}, 32),
+ )
+ require.NoError(t, err)
+
+ writeErrCh := make(chan error, 1)
+ go func() {
+ _, err := clientRW.Write([]byte("ping"))
+ writeErrCh <- err
+ }()
+
+ buf := make([]byte, len("ping"))
+ _, err = io.ReadFull(serverRW, buf)
+ require.Error(t, err)
+ require.NoError(t, <-writeErrCh)
+}
+
+func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
+ clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(
+ []byte("token"),
+ wire.AEADAlgorithmXChaCha20Poly1305,
+ bytes.Repeat([]byte{0x44}, 32),
+ )
+ require.NoError(t, err)
+ require.NotEqual(t, clientToServerKey, serverToClientKey)
+}
diff --git a/pkg/util/net/udp.go b/pkg/util/net/udp.go
index 79ebbb14..aa4d9834 100644
--- a/pkg/util/net/udp.go
+++ b/pkg/util/net/udp.go
@@ -86,11 +86,7 @@ func (c *FakeUDPConn) Read(b []byte) (n int, err error) {
c.lastActive = time.Now()
c.mu.Unlock()
- if len(b) < len(content) {
- n = len(b)
- } else {
- n = len(content)
- }
+ n = min(len(b), len(content))
copy(b, content)
return n, nil
}
@@ -168,11 +164,15 @@ func ListenUDP(bindAddr string, bindPort int) (l *UDPListener, err error) {
return l, err
}
readConn, err := net.ListenUDP("udp", udpAddr)
+ if err != nil {
+ return l, err
+ }
l = &UDPListener{
addr: udpAddr,
acceptCh: make(chan net.Conn),
writeCh: make(chan *UDPPacket, 1000),
+ readConn: readConn,
fakeConns: make(map[string]*FakeUDPConn),
}
diff --git a/pkg/util/net/websocket.go b/pkg/util/net/websocket.go
index 3ca8b332..6c2f39c4 100644
--- a/pkg/util/net/websocket.go
+++ b/pkg/util/net/websocket.go
@@ -26,6 +26,7 @@ type WebsocketListener struct {
// ln: tcp listener for websocket connections
func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
wl = &WebsocketListener{
+ ln: ln,
acceptCh: make(chan net.Conn),
}
diff --git a/pkg/util/tcpmux/httpconnect.go b/pkg/util/tcpmux/httpconnect.go
index 6be29a4a..d43b5904 100644
--- a/pkg/util/tcpmux/httpconnect.go
+++ b/pkg/util/tcpmux/httpconnect.go
@@ -39,11 +39,14 @@ type HTTPConnectTCPMuxer struct {
func NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) {
ret := &HTTPConnectTCPMuxer{passthrough: passthrough}
mux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout)
+ if err != nil {
+ return nil, err
+ }
mux.SetCheckAuthFunc(ret.auth).
SetSuccessHookFunc(ret.sendConnectResponse).
SetFailHookFunc(vhostFailed)
ret.Muxer = mux
- return ret, err
+ return ret, nil
}
func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) {
diff --git a/pkg/util/util/util.go b/pkg/util/util/util.go
index 774af2cf..31997485 100644
--- a/pkg/util/util/util.go
+++ b/pkg/util/util/util.go
@@ -68,8 +68,8 @@ func ParseRangeNumbers(rangeStr string) (numbers []int64, err error) {
rangeStr = strings.TrimSpace(rangeStr)
numbers = make([]int64, 0)
// e.g. 1000-2000,2001,2002,3000-4000
- numRanges := strings.Split(rangeStr, ",")
- for _, numRangeStr := range numRanges {
+ numRanges := strings.SplitSeq(rangeStr, ",")
+ for numRangeStr := range numRanges {
// 1000-2000 or 2001
numArray := strings.Split(numRangeStr, "-")
// length: only 1 or 2 is correct
@@ -134,3 +134,12 @@ func RandomSleep(duration time.Duration, minRatio, maxRatio float64) time.Durati
func ConstantTimeEqString(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+
+// ClonePtr returns a pointer to a copied value. If v is nil, it returns nil.
+func ClonePtr[T any](v *T) *T {
+ if v == nil {
+ return nil
+ }
+ out := *v
+ return &out
+}
diff --git a/pkg/util/util/util_test.go b/pkg/util/util/util_test.go
index 0a63ba6d..bd53f48e 100644
--- a/pkg/util/util/util_test.go
+++ b/pkg/util/util/util_test.go
@@ -41,3 +41,16 @@ func TestParseRangeNumbers(t *testing.T) {
_, err = ParseRangeNumbers("3-a")
require.Error(err)
}
+
+func TestClonePtr(t *testing.T) {
+ require := require.New(t)
+
+ var nilInt *int
+ require.Nil(ClonePtr(nilInt))
+
+ v := 42
+ cloned := ClonePtr(&v)
+ require.NotNil(cloned)
+ require.Equal(v, *cloned)
+ require.NotSame(&v, cloned)
+}
diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go
index 7103d0ae..ffc0aba9 100644
--- a/pkg/util/version/version.go
+++ b/pkg/util/version/version.go
@@ -14,7 +14,7 @@
package version
-var version = "LoliaFRP-CLI 0.67.5"
+var version = "LoliaFRP-CLI 0.69.1"
func Full() string {
return version
diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go
index 05ec174b..865ff9d5 100644
--- a/pkg/util/vhost/http.go
+++ b/pkg/util/vhost/http.go
@@ -24,7 +24,6 @@ import (
"net/http"
"net/http/httputil"
"net/url"
- "strings"
"time"
libio "github.com/fatedier/golib/io"
@@ -160,7 +159,7 @@ func (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) {
}
func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig {
- vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
+ vr, ok := rp.vhostRouter.getByRoute(domain, location, routeByHTTPUser)
if ok {
log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
return vr.payload.(*RouteConfig)
@@ -171,7 +170,7 @@ func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser str
// CreateConnection create a new connection by route config
func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {
host, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
- vr, ok := rp.getVhost(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
+ vr, ok := rp.vhostRouter.getByRoute(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
if ok {
if byEndpoint {
fn := vr.payload.(*RouteConfig).CreateConnByEndpointFn
@@ -187,60 +186,25 @@ func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byE
return nil, fmt.Errorf("%v: %s %s %s", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
}
-func (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, passwd string) bool {
- vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
- if ok {
- checkUser := vr.payload.(*RouteConfig).Username
- checkPasswd := vr.payload.(*RouteConfig).Password
- if (checkUser != "" || checkPasswd != "") && (checkUser != user || checkPasswd != passwd) {
+func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
+ if rc == nil {
+ return true
+ }
+ if rc.Username == "" && rc.Password == "" {
+ return true
+ }
+
+ if req.URL.Host != "" {
+ proxyAuth := req.Header.Get("Proxy-Authorization")
+ if proxyAuth == "" {
return false
}
- }
- return true
-}
-
-// getVhost tries to get vhost router by route policy.
-func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) {
- findRouter := func(inDomain, inLocation, inRouteByHTTPUser string) (*Router, bool) {
- vr, ok := rp.vhostRouter.Get(inDomain, inLocation, inRouteByHTTPUser)
- if ok {
- return vr, ok
- }
- // Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.
- vr, ok = rp.vhostRouter.Get(inDomain, inLocation, "")
- if ok {
- return vr, ok
- }
- return nil, false
+ user, passwd, ok := httppkg.ParseBasicAuth(proxyAuth)
+ return ok && user == rc.Username && passwd == rc.Password
}
- // First we check the full hostname
- // if not exist, then check the wildcard_domain such as *.example.com
- vr, ok := findRouter(domain, location, routeByHTTPUser)
- if ok {
- return vr, ok
- }
-
- // e.g. domain = test.example.com, try to match wildcard domains.
- // *.example.com
- // *.com
- domainSplit := strings.Split(domain, ".")
- for len(domainSplit) >= 3 {
- domainSplit[0] = "*"
- domain = strings.Join(domainSplit, ".")
- vr, ok = findRouter(domain, location, routeByHTTPUser)
- if ok {
- return vr, true
- }
- domainSplit = domainSplit[1:]
- }
-
- // Finally, try to check if there is one proxy that domain is "*" means match all domains.
- vr, ok = findRouter("*", location, routeByHTTPUser)
- if ok {
- return vr, true
- }
- return nil, false
+ user, passwd, ok := req.BasicAuth()
+ return ok && user == rc.Username && passwd == rc.Password
}
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
@@ -266,36 +230,25 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
go libio.Join(remote, client)
}
-func parseBasicAuth(auth string) (username, password string, ok bool) {
- const prefix = "Basic "
- // Case insensitive prefix match. See Issue 22736.
- if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
- return
+func getRequestRouteUser(req *http.Request) string {
+ if req.URL.Host != "" {
+ proxyAuth := req.Header.Get("Proxy-Authorization")
+ if proxyAuth == "" {
+ // Preserve legacy proxy-mode routing when clients send only Authorization,
+ // so requests still hit the matched route and return 407 instead of 404.
+ // Auth validation intentionally does not share this fallback.
+ user, _, _ := req.BasicAuth()
+ return user
+ }
+ user, _, _ := httppkg.ParseBasicAuth(proxyAuth)
+ return user
}
- c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
- if err != nil {
- return
- }
- cs := string(c)
- s := strings.IndexByte(cs, ':')
- if s < 0 {
- return
- }
- return cs[:s], cs[s+1:], true
+ user, _, _ := req.BasicAuth()
+ return user
}
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
- user := ""
- // If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header.
- if req.URL.Host != "" {
- proxyAuth := req.Header.Get("Proxy-Authorization")
- if proxyAuth != "" {
- user, _, _ = parseBasicAuth(proxyAuth)
- }
- }
- if user == "" {
- user, _, _ = req.BasicAuth()
- }
+ user := getRequestRouteUser(req)
reqRouteInfo := &RequestRouteInfo{
URL: req.URL.Path,
@@ -315,16 +268,19 @@ func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Requ
}
func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
- domain, _ := httppkg.CanonicalHost(req.Host)
- location := req.URL.Path
- user, passwd, _ := req.BasicAuth()
- if !rp.CheckAuth(domain, location, user, user, passwd) {
- rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ newreq := rp.injectRequestInfoToCtx(req)
+ rc := newreq.Context().Value(RouteConfigKey).(*RouteConfig)
+ if !checkRouteAuthByRequest(req, rc) {
+ if req.URL.Host != "" {
+ rw.Header().Set("Proxy-Authenticate", `Basic realm="Restricted"`)
+ http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired)
+ } else {
+ rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
+ http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ }
return
}
- newreq := rp.injectRequestInfoToCtx(req)
if req.Method == http.MethodConnect {
rp.connectHandler(rw, newreq)
} else {
diff --git a/pkg/util/vhost/http_test.go b/pkg/util/vhost/http_test.go
new file mode 100644
index 00000000..237ae903
--- /dev/null
+++ b/pkg/util/vhost/http_test.go
@@ -0,0 +1,102 @@
+package vhost
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+)
+
+func TestCheckRouteAuthByRequest(t *testing.T) {
+ rc := &RouteConfig{
+ Username: "alice",
+ Password: "secret",
+ }
+
+ t.Run("accepts nil route config", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ require.True(t, checkRouteAuthByRequest(req, nil))
+ })
+
+ t.Run("accepts route without credentials", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ require.True(t, checkRouteAuthByRequest(req, &RouteConfig{}))
+ })
+
+ t.Run("accepts authorization header", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.SetBasicAuth("alice", "secret")
+ require.True(t, checkRouteAuthByRequest(req, rc))
+ })
+
+ t.Run("accepts proxy authorization header", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
+ require.True(t, checkRouteAuthByRequest(req, rc))
+ })
+
+ t.Run("rejects authorization fallback for proxy request", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.SetBasicAuth("alice", "secret")
+ require.False(t, checkRouteAuthByRequest(req, rc))
+ })
+
+ t.Run("rejects wrong proxy authorization even when authorization matches", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.SetBasicAuth("alice", "secret")
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
+ require.False(t, checkRouteAuthByRequest(req, rc))
+ })
+
+ t.Run("rejects when neither header matches", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.SetBasicAuth("alice", "wrong")
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
+ require.False(t, checkRouteAuthByRequest(req, rc))
+ })
+
+ t.Run("rejects proxy authorization on direct request", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
+ require.False(t, checkRouteAuthByRequest(req, rc))
+ })
+}
+
+func TestGetRequestRouteUser(t *testing.T) {
+ t.Run("proxy request uses proxy authorization username", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.Host = "target.example.com"
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
+ req.SetBasicAuth("direct-user", "direct-pass")
+
+ require.Equal(t, "proxy-user", getRequestRouteUser(req))
+ })
+
+ t.Run("connect request keeps proxy authorization routing", func(t *testing.T) {
+ req := httptest.NewRequest("CONNECT", "http://target.example.com:443", nil)
+ req.Host = "target.example.com:443"
+ req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
+ req.SetBasicAuth("direct-user", "direct-pass")
+
+ require.Equal(t, "proxy-user", getRequestRouteUser(req))
+ })
+
+ t.Run("direct request uses authorization username", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Host = "example.com"
+ req.SetBasicAuth("direct-user", "direct-pass")
+
+ require.Equal(t, "direct-user", getRequestRouteUser(req))
+ })
+
+ t.Run("proxy request does not fall back when proxy authorization is invalid", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://target.example.com/", nil)
+ req.Host = "target.example.com"
+ req.Header.Set("Proxy-Authorization", "Basic !!!")
+ req.SetBasicAuth("direct-user", "direct-pass")
+
+ require.Empty(t, getRequestRouteUser(req))
+ })
+}
diff --git a/pkg/util/vhost/https.go b/pkg/util/vhost/https.go
index bcfdb81e..857a10d2 100644
--- a/pkg/util/vhost/https.go
+++ b/pkg/util/vhost/https.go
@@ -29,11 +29,11 @@ type HTTPSMuxer struct {
func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {
mux, err := NewMuxer(listener, GetHTTPSHostname, timeout)
- mux.SetFailHookFunc(vhostFailed)
if err != nil {
return nil, err
}
- return &HTTPSMuxer{mux}, err
+ mux.SetFailHookFunc(vhostFailed)
+ return &HTTPSMuxer{mux}, nil
}
func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {
diff --git a/pkg/util/vhost/https_test.go b/pkg/util/vhost/https_test.go
index 08a3f558..6c303687 100644
--- a/pkg/util/vhost/https_test.go
+++ b/pkg/util/vhost/https_test.go
@@ -16,23 +16,53 @@ func TestGetHTTPSHostname(t *testing.T) {
require.NoError(err)
defer l.Close()
- var conn net.Conn
+ connCh := make(chan net.Conn, 1)
+ acceptErrCh := make(chan error, 1)
go func() {
- conn, _ = l.Accept()
- require.NotNil(conn)
+ conn, err := l.Accept()
+ if err != nil {
+ acceptErrCh <- err
+ return
+ }
+ connCh <- conn
}()
+ clientErrCh := make(chan error, 1)
go func() {
time.Sleep(100 * time.Millisecond)
- tls.Dial("tcp", l.Addr().String(), &tls.Config{
+ conn, err := tls.Dial("tcp", l.Addr().String(), &tls.Config{
InsecureSkipVerify: true,
ServerName: "example.com",
})
+ if conn != nil {
+ _ = conn.Close()
+ }
+ clientErrCh <- err
}()
- time.Sleep(200 * time.Millisecond)
- _, infos, err := GetHTTPSHostname(conn)
+ var conn net.Conn
+ select {
+ case conn = <-connCh:
+ case err := <-acceptErrCh:
+ require.NoError(err)
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for accepted connection")
+ }
+ require.NotNil(conn)
+
+ serverConn, infos, err := GetHTTPSHostname(conn)
+ if serverConn != nil {
+ _ = serverConn.Close()
+ } else {
+ _ = conn.Close()
+ }
require.NoError(err)
require.Equal("example.com", infos["Host"])
require.Equal("https", infos["Scheme"])
+
+ select {
+ case <-clientErrCh:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for TLS client")
+ }
}
diff --git a/pkg/util/vhost/router.go b/pkg/util/vhost/router.go
index 315ff9e7..f8ec99d3 100644
--- a/pkg/util/vhost/router.go
+++ b/pkg/util/vhost/router.go
@@ -93,12 +93,19 @@ func (r *Routers) Del(domain, location, httpUser string) {
routersByHTTPUser[httpUser] = newVrs
}
+// Get returns the best location match for an exact host and exact HTTP user.
+// It does not apply all-users, wildcard-domain, or catch-all-domain fallback.
func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
host = strings.ToLower(host)
r.mutex.RLock()
defer r.mutex.RUnlock()
+ return r.getLocked(host, path, httpUser)
+}
+
+// getLocked performs an exact-host lookup; host must already be lower-cased.
+func (r *Routers) getLocked(host, path, httpUser string) (vr *Router, exist bool) {
routersByHTTPUser, found := r.indexByDomain[host]
if !found {
return
@@ -117,6 +124,49 @@ func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
return
}
+func (r *Routers) getByRoute(host, path, httpUser string) (*Router, bool) {
+ host = strings.ToLower(host)
+
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ // First we check the full hostname; if it doesn't exist, then check wildcard domains.
+ // For example, test.example.com checks *.example.com before falling back to "*".
+ vr, ok := r.getExactOrAllUsersLocked(host, path, httpUser)
+ if ok {
+ return vr, true
+ }
+
+ hostSplit := strings.Split(host, ".")
+ // Keep two-label hosts out of the wildcard walk, so example.com does not match *.com.
+ for len(hostSplit) >= 3 {
+ // Replace the leftmost remaining label with the wildcard marker.
+ hostSplit[0] = "*"
+ host = strings.Join(hostSplit, ".")
+ vr, ok = r.getExactOrAllUsersLocked(host, path, httpUser)
+ if ok {
+ return vr, true
+ }
+ hostSplit = hostSplit[1:]
+ }
+
+ // Finally, try to check if there is one proxy whose domain is "*", which means match all domains.
+ return r.getExactOrAllUsersLocked("*", path, httpUser)
+}
+
+func (r *Routers) getExactOrAllUsersLocked(host, path, httpUser string) (*Router, bool) {
+ vr, ok := r.getLocked(host, path, httpUser)
+ if ok {
+ return vr, true
+ }
+ // Try to check if there is one proxy that doesn't specify routeByHTTPUser, it means match all.
+ vr, ok = r.getLocked(host, path, "")
+ if ok {
+ return vr, true
+ }
+ return nil, false
+}
+
func (r *Routers) exist(host, path, httpUser string) (route *Router, exist bool) {
routersByHTTPUser, found := r.indexByDomain[host]
if !found {
diff --git a/pkg/util/vhost/router_test.go b/pkg/util/vhost/router_test.go
new file mode 100644
index 00000000..f7931edc
--- /dev/null
+++ b/pkg/util/vhost/router_test.go
@@ -0,0 +1,257 @@
+// Copyright 2017 fatedier, fatedier@gmail.com
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package vhost
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRoutersGet(t *testing.T) {
+ routers := NewRouters()
+ require.NoError(t, routers.Add("example.com", "/api", "alice", "exact-user"))
+ require.NoError(t, routers.Add("example.com", "/public", "", "exact-all-users"))
+ require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
+ require.NoError(t, routers.Add("*", "/api", "", "all-domains"))
+
+ t.Run("exact host and user match with normalized domain", func(t *testing.T) {
+ router, ok := routers.Get("EXAMPLE.COM", "/api/users", "alice")
+ require.True(t, ok)
+ require.Equal(t, "exact-user", router.payload)
+ })
+
+ t.Run("exact host and empty user match", func(t *testing.T) {
+ router, ok := routers.Get("EXAMPLE.COM", "/public/docs", "")
+ require.True(t, ok)
+ require.Equal(t, "exact-all-users", router.payload)
+ })
+
+ t.Run("does not fall back from named user to empty user", func(t *testing.T) {
+ // Get intentionally requires an exact HTTP user; route-level fallbacks live in getByRoute.
+ _, ok := routers.Get("EXAMPLE.COM", "/public/docs", "alice")
+ require.False(t, ok)
+ })
+
+ t.Run("does not fall back to wildcard domains", func(t *testing.T) {
+ _, ok := routers.Get("foo.example.com", "/api/users", "")
+ require.False(t, ok)
+ })
+
+ t.Run("does not fall back to catch-all domain", func(t *testing.T) {
+ _, ok := routers.Get("missing.test", "/api/users", "")
+ require.False(t, ok)
+ })
+}
+
+func TestRoutersGetByRoute(t *testing.T) {
+ routers := NewRouters()
+ require.NoError(t, routers.Add("example.com", "/api", "alice", "exact-user"))
+ require.NoError(t, routers.Add("example.com", "/api", "", "exact-all-users"))
+ require.NoError(t, routers.Add("exact.example.com", "/api", "", "exact-subdomain"))
+ require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
+ require.NoError(t, routers.Add("*.foo.example.com", "/api", "", "specific-wildcard"))
+ require.NoError(t, routers.Add("*.bar.com", "/api", "", "wildcard-parent-domain"))
+ require.NoError(t, routers.Add("*", "/admin", "root", "all-domains-user"))
+ require.NoError(t, routers.Add("*", "/", "", "all-domains"))
+
+ tests := []struct {
+ name string
+ domain string
+ location string
+ httpUser string
+ want string
+ }{
+ {
+ name: "exact domain and http user",
+ domain: "example.com",
+ location: "/api/users",
+ httpUser: "alice",
+ want: "exact-user",
+ },
+ {
+ name: "exact domain falls back to all users",
+ domain: "example.com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "exact-all-users",
+ },
+ {
+ name: "wildcard domain uses all users fallback",
+ domain: "foo.example.com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "wildcard-all-users",
+ },
+ {
+ name: "mixed-case domain is normalized",
+ domain: "Foo.Example.Com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "wildcard-all-users",
+ },
+ {
+ name: "exact domain wins over wildcard domain",
+ domain: "exact.example.com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "exact-subdomain",
+ },
+ {
+ name: "more specific wildcard wins over broader wildcard",
+ domain: "bar.foo.example.com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "specific-wildcard",
+ },
+ {
+ name: "wildcard walk checks parent domains",
+ domain: "a.b.bar.com",
+ location: "/api/users",
+ httpUser: "bob",
+ want: "wildcard-parent-domain",
+ },
+ {
+ name: "catch-all domain fallback",
+ domain: "foo.test.com",
+ location: "/other",
+ httpUser: "bob",
+ want: "all-domains",
+ },
+ {
+ name: "catch-all domain honors http user",
+ domain: "foo.test.com",
+ location: "/admin/panel",
+ httpUser: "root",
+ want: "all-domains-user",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router, ok := routers.getByRoute(tt.domain, tt.location, tt.httpUser)
+ require.True(t, ok)
+ require.Equal(t, tt.want, router.payload)
+ })
+ }
+}
+
+func TestRoutersGetByRouteNoMatch(t *testing.T) {
+ routers := NewRouters()
+ require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
+ require.NoError(t, routers.Add("*.com", "/api", "", "top-level-wildcard"))
+
+ tests := []struct {
+ name string
+ domain string
+ location string
+ }{
+ {
+ name: "two-label domain does not enter wildcard walk",
+ domain: "example.com",
+ location: "/api/users",
+ },
+ {
+ name: "missing catch-all remains no match",
+ domain: "foo.test.com",
+ location: "/api/users",
+ },
+ {
+ name: "wrong path remains no match",
+ domain: "foo.example.com",
+ location: "/other",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router, ok := routers.getByRoute(tt.domain, tt.location, "bob")
+ require.False(t, ok)
+ require.Nil(t, router)
+ })
+ }
+}
+
+func TestRoutersConcurrentGetByRouteAndAdd(t *testing.T) {
+ routers := NewRouters()
+ require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard"))
+
+ const readers = 8
+ const iterations = 200
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ start := make(chan struct{})
+ errCh := make(chan error, readers+1)
+ var wg sync.WaitGroup
+
+ for id := range readers {
+ wg.Go(func() {
+ <-start
+ for j := range iterations {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ router, ok := routers.getByRoute("foo.example.com", "/api/users", "")
+ if !ok || router == nil || router.payload != "wildcard" {
+ errCh <- fmt.Errorf("reader %d iteration %d got router=%v ok=%v", id, j, router, ok)
+ return
+ }
+ }
+ })
+ }
+
+ wg.Go(func() {
+ <-start
+ for i := range iterations {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ err := routers.Add(fmt.Sprintf("host-%d.example.com", i), "/api", "", i)
+ if err != nil {
+ errCh <- err
+ return
+ }
+ }
+ })
+
+ close(start)
+
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ cancel()
+ t.Fatal("concurrent route lookup and add timed out")
+ }
+
+ close(errCh)
+ for err := range errCh {
+ require.NoError(t, err)
+ }
+}
diff --git a/pkg/util/vhost/vhost.go b/pkg/util/vhost/vhost.go
index 007751d7..637c8dcf 100644
--- a/pkg/util/vhost/vhost.go
+++ b/pkg/util/vhost/vhost.go
@@ -148,41 +148,9 @@ func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err
}
func (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) {
- findRouter := func(inName, inPath, inHTTPUser string) (*Listener, bool) {
- vr, ok := v.registryRouter.Get(inName, inPath, inHTTPUser)
- if ok {
- return vr.payload.(*Listener), true
- }
- // Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.
- vr, ok = v.registryRouter.Get(inName, inPath, "")
- if ok {
- return vr.payload.(*Listener), true
- }
- return nil, false
- }
-
- // first we check the full hostname
- // if not exist, then check the wildcard_domain such as *.example.com
- l, ok := findRouter(name, path, httpUser)
+ vr, ok := v.registryRouter.getByRoute(name, path, httpUser)
if ok {
- return l, true
- }
-
- domainSplit := strings.Split(name, ".")
- for len(domainSplit) >= 3 {
- domainSplit[0] = "*"
- name = strings.Join(domainSplit, ".")
-
- l, ok = findRouter(name, path, httpUser)
- if ok {
- return l, true
- }
- domainSplit = domainSplit[1:]
- }
- // Finally, try to check if there is one proxy that domain is "*" means match all domains.
- l, ok = findRouter("*", path, httpUser)
- if ok {
- return l, true
+ return vr.payload.(*Listener), true
}
return nil, false
}
diff --git a/pkg/util/wait/backoff.go b/pkg/util/wait/backoff.go
index 4d01ace3..0ee5db71 100644
--- a/pkg/util/wait/backoff.go
+++ b/pkg/util/wait/backoff.go
@@ -18,6 +18,8 @@ import (
"math/rand/v2"
"time"
+ "k8s.io/utils/clock"
+
"github.com/fatedier/frp/pkg/util/util"
)
@@ -48,6 +50,7 @@ type FastBackoffOptions struct {
type fastBackoffImpl struct {
options FastBackoffOptions
+ clock clock.PassiveClock
lastCalledTime time.Time
consecutiveErrCount int
@@ -57,18 +60,26 @@ type fastBackoffImpl struct {
}
func NewFastBackoffManager(options FastBackoffOptions) BackoffManager {
+ return newFastBackoffManagerWithClock(options, clock.RealClock{})
+}
+
+func newFastBackoffManagerWithClock(options FastBackoffOptions, clk clock.PassiveClock) BackoffManager {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
return &fastBackoffImpl{
options: options,
+ clock: clk,
countsInFastRetryWindow: 1,
}
}
func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {
if f.lastCalledTime.IsZero() {
- f.lastCalledTime = time.Now()
+ f.lastCalledTime = f.clock.Now()
return f.options.Duration
}
- now := time.Now()
+ now := f.clock.Now()
f.lastCalledTime = now
if previousConditionError {
diff --git a/pkg/util/wait/backoff_test.go b/pkg/util/wait/backoff_test.go
new file mode 100644
index 00000000..edea3669
--- /dev/null
+++ b/pkg/util/wait/backoff_test.go
@@ -0,0 +1,27 @@
+package wait
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ clocktesting "k8s.io/utils/clock/testing"
+)
+
+func TestFastBackoffManagerUsesClock(t *testing.T) {
+ require := require.New(t)
+
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ backoff := newFastBackoffManagerWithClock(FastBackoffOptions{
+ Duration: time.Second,
+ }, clk).(*fastBackoffImpl)
+
+ require.Equal(time.Second, backoff.Backoff(0, false))
+ require.Equal(start, backoff.lastCalledTime)
+
+ next := start.Add(time.Minute)
+ clk.SetTime(next)
+ require.Equal(time.Second, backoff.Backoff(time.Second, false))
+ require.Equal(next, backoff.lastCalledTime)
+}
diff --git a/pkg/util/xlog/log_writer.go b/pkg/util/xlog/log_writer.go
index 3fff7324..28ddc22b 100644
--- a/pkg/util/xlog/log_writer.go
+++ b/pkg/util/xlog/log_writer.go
@@ -19,7 +19,6 @@ import "strings"
// LogWriter forwards writes to frp's logger at configurable level.
// It is safe for concurrent use as long as the underlying Logger is thread-safe.
type LogWriter struct {
- xl *Logger
logFunc func(string)
}
@@ -31,35 +30,30 @@ func (w LogWriter) Write(p []byte) (n int, err error) {
func NewTraceWriter(xl *Logger) LogWriter {
return LogWriter{
- xl: xl,
logFunc: func(msg string) { xl.Tracef("%s", msg) },
}
}
func NewDebugWriter(xl *Logger) LogWriter {
return LogWriter{
- xl: xl,
logFunc: func(msg string) { xl.Debugf("%s", msg) },
}
}
func NewInfoWriter(xl *Logger) LogWriter {
return LogWriter{
- xl: xl,
logFunc: func(msg string) { xl.Infof("%s", msg) },
}
}
func NewWarnWriter(xl *Logger) LogWriter {
return LogWriter{
- xl: xl,
logFunc: func(msg string) { xl.Warnf("%s", msg) },
}
}
func NewErrorWriter(xl *Logger) LogWriter {
return LogWriter{
- xl: xl,
logFunc: func(msg string) { xl.Errorf("%s", msg) },
}
}
diff --git a/pkg/util/xlog/xlog.go b/pkg/util/xlog/xlog.go
index 88d5d72f..d2ce7199 100644
--- a/pkg/util/xlog/xlog.go
+++ b/pkg/util/xlog/xlog.go
@@ -63,11 +63,12 @@ func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
if prefix.Priority <= 0 {
prefix.Priority = 10
}
- for _, p := range l.prefixes {
+ for i, p := range l.prefixes {
if p.Name == prefix.Name {
found = true
- p.Value = prefix.Value
- p.Priority = prefix.Priority
+ l.prefixes[i].Value = prefix.Value
+ l.prefixes[i].Priority = prefix.Priority
+ break
}
}
if !found {
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/pkg/vnet/controller.go b/pkg/vnet/controller.go
index ca71a8c3..348d50d2 100644
--- a/pkg/vnet/controller.go
+++ b/pkg/vnet/controller.go
@@ -131,6 +131,9 @@ func (c *Controller) handlePacket(buf []byte) {
}
func (c *Controller) Stop() error {
+ if c.tun == nil {
+ return nil
+ }
return c.tun.Close()
}
@@ -283,7 +286,6 @@ func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWri
r.mu.Lock()
defer r.mu.Unlock()
r.routes[name] = &routeElement{
- name: name,
routes: routes,
conn: conn,
}
@@ -380,7 +382,6 @@ func (r *serverRouter) cleanupConnIPs(conn io.Writer) {
}
type routeElement struct {
- name string
routes []net.IPNet
conn io.ReadWriteCloser
}
diff --git a/server/api_router.go b/server/api_router.go
new file mode 100644
index 00000000..e0ac31ed
--- /dev/null
+++ b/server/api_router.go
@@ -0,0 +1,71 @@
+// Copyright 2017 fatedier, fatedier@gmail.com
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ netpkg "github.com/fatedier/frp/pkg/util/net"
+ adminapi "github.com/fatedier/frp/server/http"
+)
+
+func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
+ helper.Router.HandleFunc("/healthz", healthz)
+ subRouter := helper.Router.NewRoute().Subrouter()
+
+ subRouter.Use(helper.AuthMiddleware)
+ subRouter.Use(httppkg.NewRequestLogger)
+
+ // metrics
+ if svr.cfg.EnablePrometheus {
+ subRouter.Handle("/metrics", promhttp.Handler())
+ }
+
+ apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
+
+ // apis
+ subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
+ subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
+ subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
+ subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
+ subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
+ subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
+ subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
+ subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
+ subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
+
+ subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
+
+ // view
+ subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
+ subRouter.PathPrefix("/static/").Handler(
+ netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
+ ).Methods("GET")
+
+ subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
+ })
+}
+
+func healthz(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(200)
+}
diff --git a/server/control.go b/server/control.go
index 549b22f7..eb496938 100644
--- a/server/control.go
+++ b/server/control.go
@@ -17,7 +17,6 @@ package server
import (
"context"
"fmt"
- "net"
"runtime/debug"
"sync"
"sync/atomic"
@@ -32,9 +31,7 @@ import (
"github.com/fatedier/frp/pkg/msg"
plugin "github.com/fatedier/frp/pkg/plugin/server"
"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/version"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/server/controller"
@@ -140,20 +137,33 @@ func (cm *ControlManager) KickByProxyName(proxyName string) error {
return target.Close()
}
-type Control struct {
+// SessionContext encapsulates the input parameters for creating a new Control.
+type SessionContext struct {
// all resource managers and controllers
- rc *controller.ResourceController
-
+ RC *controller.ResourceController
// proxy manager
- pxyManager *proxy.Manager
-
+ PxyManager *proxy.Manager
// plugin manager
- pluginManager *plugin.Manager
-
+ PluginManager *plugin.Manager
// verifies authentication based on selected method
- authVerifier auth.Verifier
+ AuthVerifier auth.Verifier
// key used for connection encryption
- encryptionKey []byte
+ EncryptionKey []byte
+ // control connection
+ Conn *msg.Conn
+ // login message
+ LoginMsg *msg.Login
+ // server configuration
+ ServerCfg *v1.ServerConfig
+ // client registry
+ ClientRegistry *registry.ClientRegistry
+ // negotiated wire protocol for this client session
+ WireProtocol string
+}
+
+type Control struct {
+ // session context
+ sessionCtx *SessionContext
// other components can use this to communicate with client
msgTransporter transport.MessageTransporter
@@ -162,14 +172,8 @@ type Control struct {
// It provides a channel for sending messages, and you can register handlers to process messages based on their respective types.
msgDispatcher *msg.Dispatcher
- // login message
- loginMsg *msg.Login
-
- // control connection
- conn net.Conn
-
// work connections
- workConnCh chan net.Conn
+ workConnCh chan *proxy.WorkConn
// proxies in one client
proxies map[string]proxy.Proxy
@@ -190,76 +194,34 @@ type Control struct {
mu sync.RWMutex
- // Server configuration information
- serverCfg *v1.ServerConfig
-
- clientRegistry *registry.ClientRegistry
-
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
}
-// TODO(fatedier): Referencing the implementation of frpc, encapsulate the input parameters as SessionContext.
-func NewControl(
- ctx context.Context,
- rc *controller.ResourceController,
- pxyManager *proxy.Manager,
- pluginManager *plugin.Manager,
- authVerifier auth.Verifier,
- encryptionKey []byte,
- ctlConn net.Conn,
- ctlConnEncrypted bool,
- loginMsg *msg.Login,
- serverCfg *v1.ServerConfig,
-) (*Control, error) {
- poolCount := loginMsg.PoolCount
- if poolCount > int(serverCfg.Transport.MaxPoolCount) {
- poolCount = int(serverCfg.Transport.MaxPoolCount)
- }
+func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
+ poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
ctl := &Control{
- rc: rc,
- pxyManager: pxyManager,
- pluginManager: pluginManager,
- authVerifier: authVerifier,
- encryptionKey: encryptionKey,
- conn: ctlConn,
- loginMsg: loginMsg,
- workConnCh: make(chan net.Conn, poolCount+10),
- proxies: make(map[string]proxy.Proxy),
- poolCount: poolCount,
- portsUsedNum: 0,
- runID: loginMsg.RunID,
- serverCfg: serverCfg,
- xl: xlog.FromContextSafe(ctx),
- ctx: ctx,
- doneCh: make(chan struct{}),
+ sessionCtx: sessionCtx,
+ workConnCh: make(chan *proxy.WorkConn, poolCount+10),
+ proxies: make(map[string]proxy.Proxy),
+ poolCount: poolCount,
+ portsUsedNum: 0,
+ runID: sessionCtx.LoginMsg.RunID,
+ xl: xlog.FromContextSafe(ctx),
+ ctx: ctx,
+ doneCh: make(chan struct{}),
}
ctl.lastPing.Store(time.Now())
- if ctlConnEncrypted {
- cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, ctl.encryptionKey)
- if err != nil {
- return nil, err
- }
- ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
- } else {
- ctl.msgDispatcher = msg.NewDispatcher(ctl.conn)
- }
+ ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
return ctl, nil
}
-// Start send a login success message to client and start working.
+// Start starts the control session workers after login succeeds.
func (ctl *Control) Start() {
- loginRespMsg := &msg.LoginResp{
- Version: version.Full(),
- RunID: ctl.runID,
- Error: "",
- }
- _ = msg.WriteMsg(ctl.conn, loginRespMsg)
-
go func() {
for i := 0; i < ctl.poolCount; i++ {
// ignore error here, that means that this control is closed
@@ -270,7 +232,7 @@ func (ctl *Control) Start() {
}
func (ctl *Control) Close() error {
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
return nil
}
@@ -278,10 +240,10 @@ func (ctl *Control) Replaced(newCtl *Control) {
xl := ctl.xl
xl.Infof("replaced by client [%s]", newCtl.runID)
ctl.runID = ""
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
}
-func (ctl *Control) RegisterWorkConn(conn net.Conn) error {
+func (ctl *Control) RegisterWorkConn(conn *proxy.WorkConn) error {
xl := ctl.xl
defer func() {
if err := recover(); err != nil {
@@ -304,7 +266,7 @@ func (ctl *Control) RegisterWorkConn(conn net.Conn) error {
// If no workConn available in the pool, send message to frpc to get one or more
// and wait until it is available.
// return an error if wait timeout
-func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
+func (ctl *Control) GetWorkConn() (workConn *proxy.WorkConn, err error) {
xl := ctl.xl
defer func() {
if err := recover(); err != nil {
@@ -336,7 +298,7 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
return
}
- case <-time.After(time.Duration(ctl.serverCfg.UserConnTimeout) * time.Second):
+ case <-time.After(time.Duration(ctl.sessionCtx.ServerCfg.UserConnTimeout) * time.Second):
err = fmt.Errorf("timeout trying to get work connection")
xl.Warnf("%v", err)
return
@@ -349,15 +311,15 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
}
func (ctl *Control) heartbeatWorker() {
- if ctl.serverCfg.Transport.HeartbeatTimeout <= 0 {
+ if ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout <= 0 {
return
}
xl := ctl.xl
go wait.Until(func() {
- if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.serverCfg.Transport.HeartbeatTimeout)*time.Second {
+ if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
xl.Warnf("heartbeat timeout")
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
return
}
}, time.Second, ctl.doneCh)
@@ -368,6 +330,30 @@ func (ctl *Control) WaitClosed() {
<-ctl.doneCh
}
+func (ctl *Control) loginUserInfo() plugin.UserInfo {
+ return plugin.UserInfo{
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
+ RunID: ctl.sessionCtx.LoginMsg.RunID,
+ }
+}
+
+func (ctl *Control) closeProxy(pxy proxy.Proxy) {
+ pxy.Close()
+ ctl.sessionCtx.PxyManager.Del(pxy.GetName())
+ metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
+
+ notifyContent := &plugin.CloseProxyContent{
+ User: ctl.loginUserInfo(),
+ CloseProxy: msg.CloseProxy{
+ ProxyName: pxy.GetName(),
+ },
+ }
+ go func() {
+ _ = ctl.sessionCtx.PluginManager.CloseProxy(notifyContent)
+ }()
+}
+
func (ctl *Control) worker() {
xl := ctl.xl
@@ -375,38 +361,23 @@ func (ctl *Control) worker() {
go ctl.msgDispatcher.Run()
<-ctl.msgDispatcher.Done()
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
ctl.mu.Lock()
- defer ctl.mu.Unlock()
-
close(ctl.workConnCh)
for workConn := range ctl.workConnCh {
workConn.Close()
}
+ proxies := ctl.proxies
+ ctl.proxies = make(map[string]proxy.Proxy)
+ ctl.mu.Unlock()
- for _, pxy := range ctl.proxies {
- pxy.Close()
- ctl.pxyManager.Del(pxy.GetName())
- metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
-
- notifyContent := &plugin.CloseProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
- CloseProxy: msg.CloseProxy{
- ProxyName: pxy.GetName(),
- },
- }
- go func() {
- _ = ctl.pluginManager.CloseProxy(notifyContent)
- }()
+ for _, pxy := range proxies {
+ ctl.closeProxy(pxy)
}
metrics.Server.CloseClient()
- ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
+ ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}
@@ -425,15 +396,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
inMsg := m.(*msg.NewProxy)
content := &plugin.NewProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
+ User: ctl.loginUserInfo(),
NewProxy: *inMsg,
}
var remoteAddr string
- retContent, err := ctl.pluginManager.NewProxy(content)
+ retContent, err := ctl.sessionCtx.PluginManager.NewProxy(content)
if err == nil {
inMsg = &retContent.NewProxy
remoteAddr, err = ctl.RegisterProxy(inMsg)
@@ -446,15 +413,15 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
if err != nil {
xl.Warnf("new proxy [%s] type [%s] error: %v", inMsg.ProxyName, inMsg.ProxyType, err)
resp.Error = util.GenerateResponseErrorString(fmt.Sprintf("new proxy [%s] error", inMsg.ProxyName),
- err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient))
+ err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient))
} else {
resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
- clientID := ctl.loginMsg.ClientID
+ clientID := ctl.sessionCtx.LoginMsg.ClientID
if clientID == "" {
- clientID = ctl.loginMsg.RunID
+ clientID = ctl.sessionCtx.LoginMsg.RunID
}
- metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
+ metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
}
_ = ctl.msgDispatcher.Send(resp)
}
@@ -464,22 +431,18 @@ func (ctl *Control) handlePing(m msg.Message) {
inMsg := m.(*msg.Ping)
content := &plugin.PingContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
+ User: ctl.loginUserInfo(),
Ping: *inMsg,
}
- retContent, err := ctl.pluginManager.Ping(content)
+ retContent, err := ctl.sessionCtx.PluginManager.Ping(content)
if err == nil {
inMsg = &retContent.Ping
- err = ctl.authVerifier.VerifyPing(inMsg)
+ err = ctl.sessionCtx.AuthVerifier.VerifyPing(inMsg)
}
if err != nil {
xl.Warnf("received invalid ping: %v", err)
_ = ctl.msgDispatcher.Send(&msg.Pong{
- Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)),
+ Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient)),
})
return
}
@@ -490,17 +453,17 @@ func (ctl *Control) handlePing(m msg.Message) {
func (ctl *Control) handleNatHoleVisitor(m msg.Message) {
inMsg := m.(*msg.NatHoleVisitor)
- ctl.rc.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.loginMsg.User)
+ ctl.sessionCtx.RC.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.sessionCtx.LoginMsg.User)
}
func (ctl *Control) handleNatHoleClient(m msg.Message) {
inMsg := m.(*msg.NatHoleClient)
- ctl.rc.NatHoleController.HandleClient(inMsg, ctl.msgTransporter)
+ ctl.sessionCtx.RC.NatHoleController.HandleClient(inMsg, ctl.msgTransporter)
}
func (ctl *Control) handleNatHoleReport(m msg.Message) {
inMsg := m.(*msg.NatHoleReport)
- ctl.rc.NatHoleController.HandleReport(inMsg)
+ ctl.sessionCtx.RC.NatHoleController.HandleReport(inMsg)
}
func (ctl *Control) handleCloseProxy(m msg.Message) {
@@ -513,15 +476,15 @@ func (ctl *Control) handleCloseProxy(m msg.Message) {
func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) {
var pxyConf v1.ProxyConfigurer
// Load configures from NewProxy message and validate.
- pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.serverCfg)
+ pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.sessionCtx.ServerCfg)
if err != nil {
return
}
// User info
userInfo := plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
RunID: ctl.runID,
}
@@ -529,22 +492,23 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
// In fact, it creates different proxies based on the proxy type. We just call run() here.
pxy, err := proxy.NewProxy(ctl.ctx, &proxy.Options{
UserInfo: userInfo,
- LoginMsg: ctl.loginMsg,
+ LoginMsg: ctl.sessionCtx.LoginMsg,
PoolCount: ctl.poolCount,
- ResourceController: ctl.rc,
+ ResourceController: ctl.sessionCtx.RC,
GetWorkConnFn: ctl.GetWorkConn,
Configurer: pxyConf,
- ServerCfg: ctl.serverCfg,
- EncryptionKey: ctl.encryptionKey,
+ ServerCfg: ctl.sessionCtx.ServerCfg,
+ EncryptionKey: ctl.sessionCtx.EncryptionKey,
+ WireProtocol: ctl.sessionCtx.WireProtocol,
})
if err != nil {
return remoteAddr, err
}
// Check ports used number in each client
- if ctl.serverCfg.MaxPortsPerClient > 0 {
+ if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {
ctl.mu.Lock()
- if ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.serverCfg.MaxPortsPerClient) {
+ if ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.sessionCtx.ServerCfg.MaxPortsPerClient) {
ctl.mu.Unlock()
err = fmt.Errorf("exceed the max_ports_per_client")
return
@@ -561,7 +525,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
}()
}
- if ctl.pxyManager.Exist(pxyMsg.ProxyName) {
+ if ctl.sessionCtx.PxyManager.Exist(pxyMsg.ProxyName) {
err = fmt.Errorf("proxy [%s] already exists", pxyMsg.ProxyName)
return
}
@@ -576,7 +540,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
}
}()
- err = ctl.pxyManager.Add(pxyMsg.ProxyName, pxy)
+ err = ctl.sessionCtx.PxyManager.Add(pxyMsg.ProxyName, pxy)
if err != nil {
return
}
@@ -595,28 +559,12 @@ func (ctl *Control) CloseProxy(closeMsg *msg.CloseProxy) (err error) {
return
}
- if ctl.serverCfg.MaxPortsPerClient > 0 {
+ if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {
ctl.portsUsedNum -= pxy.GetUsedPortsNum()
}
- pxy.Close()
- ctl.pxyManager.Del(pxy.GetName())
delete(ctl.proxies, closeMsg.ProxyName)
ctl.mu.Unlock()
- metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
-
- notifyContent := &plugin.CloseProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
- CloseProxy: msg.CloseProxy{
- ProxyName: pxy.GetName(),
- },
- }
- go func() {
- _ = ctl.pluginManager.CloseProxy(notifyContent)
- }()
+ ctl.closeProxy(pxy)
return
}
diff --git a/server/group/base.go b/server/group/base.go
new file mode 100644
index 00000000..5684d8ef
--- /dev/null
+++ b/server/group/base.go
@@ -0,0 +1,77 @@
+package group
+
+import (
+ "net"
+ "sync"
+
+ gerr "github.com/fatedier/golib/errors"
+)
+
+// baseGroup contains the shared plumbing for listener-based groups
+// (TCP, HTTPS, TCPMux). Each concrete group embeds this and provides
+// its own Listen method with protocol-specific validation.
+type baseGroup struct {
+ group string
+ groupKey string
+
+ acceptCh chan net.Conn
+ realLn net.Listener
+ lns []*Listener
+ mu sync.Mutex
+ cleanupFn func()
+}
+
+// initBase resets the baseGroup for a fresh listen cycle.
+// Must be called under mu when len(lns) == 0.
+func (bg *baseGroup) initBase(group, groupKey string, realLn net.Listener, cleanupFn func()) {
+ bg.group = group
+ bg.groupKey = groupKey
+ bg.realLn = realLn
+ bg.acceptCh = make(chan net.Conn)
+ bg.cleanupFn = cleanupFn
+}
+
+// worker reads from the real listener and fans out to acceptCh.
+// The parameters are captured at creation time so that the worker is
+// bound to a specific listen cycle and cannot observe a later initBase.
+func (bg *baseGroup) worker(realLn net.Listener, acceptCh chan<- net.Conn) {
+ for {
+ c, err := realLn.Accept()
+ if err != nil {
+ return
+ }
+ err = gerr.PanicToError(func() {
+ acceptCh <- c
+ })
+ if err != nil {
+ c.Close()
+ return
+ }
+ }
+}
+
+// newListener creates a new Listener wired to this baseGroup.
+// Must be called under mu.
+func (bg *baseGroup) newListener(addr net.Addr) *Listener {
+ ln := newListener(bg.acceptCh, addr, bg.closeListener)
+ bg.lns = append(bg.lns, ln)
+ return ln
+}
+
+// closeListener removes ln from the list. When the last listener is removed,
+// it closes acceptCh, closes the real listener, and calls cleanupFn.
+func (bg *baseGroup) closeListener(ln *Listener) {
+ bg.mu.Lock()
+ defer bg.mu.Unlock()
+ for i, l := range bg.lns {
+ if l == ln {
+ bg.lns = append(bg.lns[:i], bg.lns[i+1:]...)
+ break
+ }
+ }
+ if len(bg.lns) == 0 {
+ close(bg.acceptCh)
+ bg.realLn.Close()
+ bg.cleanupFn()
+ }
+}
diff --git a/server/group/base_test.go b/server/group/base_test.go
new file mode 100644
index 00000000..1b470841
--- /dev/null
+++ b/server/group/base_test.go
@@ -0,0 +1,169 @@
+package group
+
+import (
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeLn is a controllable net.Listener for tests.
+type fakeLn struct {
+ connCh chan net.Conn
+ closed chan struct{}
+ once sync.Once
+}
+
+func newFakeLn() *fakeLn {
+ return &fakeLn{
+ connCh: make(chan net.Conn, 8),
+ closed: make(chan struct{}),
+ }
+}
+
+func (f *fakeLn) Accept() (net.Conn, error) {
+ select {
+ case c := <-f.connCh:
+ return c, nil
+ case <-f.closed:
+ return nil, net.ErrClosed
+ }
+}
+
+func (f *fakeLn) Close() error {
+ f.once.Do(func() { close(f.closed) })
+ return nil
+}
+
+func (f *fakeLn) Addr() net.Addr { return fakeAddr("127.0.0.1:9999") }
+
+func (f *fakeLn) inject(c net.Conn) {
+ select {
+ case f.connCh <- c:
+ case <-f.closed:
+ }
+}
+
+func TestBaseGroup_WorkerFanOut(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ go bg.worker(fl, bg.acceptCh)
+
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ select {
+ case got := <-bg.acceptCh:
+ assert.Equal(t, c1, got)
+ got.Close()
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for connection on acceptCh")
+ }
+
+ fl.Close()
+}
+
+func TestBaseGroup_WorkerStopsOnListenerClose(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ done := make(chan struct{})
+ go func() {
+ bg.worker(fl, bg.acceptCh)
+ close(done)
+ }()
+
+ fl.Close()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("worker did not stop after listener close")
+ }
+}
+
+func TestBaseGroup_WorkerClosesConnOnClosedChannel(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ // Close acceptCh before worker sends.
+ close(bg.acceptCh)
+
+ done := make(chan struct{})
+ go func() {
+ bg.worker(fl, bg.acceptCh)
+ close(done)
+ }()
+
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("worker did not stop after panic recovery")
+ }
+
+ // c1 should have been closed by worker's panic recovery path.
+ buf := make([]byte, 1)
+ _, err := c1.Read(buf)
+ assert.Error(t, err, "connection should be closed by worker")
+}
+
+func TestBaseGroup_CloseLastListenerTriggersCleanup(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ cleanupCalled := 0
+ bg.initBase("g", "key", fl, func() { cleanupCalled++ })
+
+ bg.mu.Lock()
+ ln1 := bg.newListener(fl.Addr())
+ ln2 := bg.newListener(fl.Addr())
+ bg.mu.Unlock()
+
+ go bg.worker(fl, bg.acceptCh)
+
+ ln1.Close()
+ assert.Equal(t, 0, cleanupCalled, "cleanup should not run while listeners remain")
+
+ ln2.Close()
+ assert.Equal(t, 1, cleanupCalled, "cleanup should run after last listener closed")
+}
+
+func TestBaseGroup_CloseOneOfTwoListeners(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ cleanupCalled := 0
+ bg.initBase("g", "key", fl, func() { cleanupCalled++ })
+
+ bg.mu.Lock()
+ ln1 := bg.newListener(fl.Addr())
+ ln2 := bg.newListener(fl.Addr())
+ bg.mu.Unlock()
+
+ go bg.worker(fl, bg.acceptCh)
+
+ ln1.Close()
+ assert.Equal(t, 0, cleanupCalled)
+
+ // ln2 should still receive connections.
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ got, err := ln2.Accept()
+ require.NoError(t, err)
+ assert.Equal(t, c1, got)
+ got.Close()
+
+ ln2.Close()
+ assert.Equal(t, 1, cleanupCalled)
+}
diff --git a/server/group/group.go b/server/group/group.go
index ab38cf45..1fbedf5c 100644
--- a/server/group/group.go
+++ b/server/group/group.go
@@ -24,4 +24,6 @@ var (
ErrListenerClosed = errors.New("group listener closed")
ErrGroupDifferentPort = errors.New("group should have same remote port")
ErrProxyRepeated = errors.New("group proxy repeated")
+
+ errGroupStale = errors.New("stale group reference")
)
diff --git a/server/group/http.go b/server/group/http.go
index 26af595e..21bbfd29 100644
--- a/server/group/http.go
+++ b/server/group/http.go
@@ -9,53 +9,42 @@ import (
"github.com/fatedier/frp/pkg/util/vhost"
)
+// HTTPGroupController manages HTTP groups that use round-robin
+// callback routing (fundamentally different from listener-based groups).
type HTTPGroupController struct {
- // groups indexed by group name
- groups map[string]*HTTPGroup
-
- // register createConn for each group to vhostRouter.
- // createConn will get a connection from one proxy of the group
+ groupRegistry[*HTTPGroup]
vhostRouter *vhost.Routers
-
- mu sync.Mutex
}
func NewHTTPGroupController(vhostRouter *vhost.Routers) *HTTPGroupController {
return &HTTPGroupController{
- groups: make(map[string]*HTTPGroup),
- vhostRouter: vhostRouter,
+ groupRegistry: newGroupRegistry[*HTTPGroup](),
+ vhostRouter: vhostRouter,
}
}
func (ctl *HTTPGroupController) Register(
proxyName, group, groupKey string,
routeConfig vhost.RouteConfig,
-) (err error) {
- indexKey := group
- ctl.mu.Lock()
- g, ok := ctl.groups[indexKey]
- if !ok {
- g = NewHTTPGroup(ctl)
- ctl.groups[indexKey] = g
+) error {
+ for {
+ g := ctl.getOrCreate(group, func() *HTTPGroup {
+ return NewHTTPGroup(ctl)
+ })
+ err := g.Register(proxyName, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return err
}
- ctl.mu.Unlock()
-
- return g.Register(proxyName, group, groupKey, routeConfig)
}
func (ctl *HTTPGroupController) UnRegister(proxyName, group string, _ vhost.RouteConfig) {
- indexKey := group
- ctl.mu.Lock()
- defer ctl.mu.Unlock()
- g, ok := ctl.groups[indexKey]
+ g, ok := ctl.get(group)
if !ok {
return
}
-
- isEmpty := g.UnRegister(proxyName)
- if isEmpty {
- delete(ctl.groups, indexKey)
- }
+ g.UnRegister(proxyName)
}
type HTTPGroup struct {
@@ -68,7 +57,7 @@ type HTTPGroup struct {
// CreateConnFuncs indexed by proxy name
createFuncs map[string]vhost.CreateConnFunc
pxyNames []string
- index uint64
+ index atomic.Uint64
ctl *HTTPGroupController
mu sync.RWMutex
}
@@ -87,6 +76,9 @@ func (g *HTTPGroup) Register(
) (err error) {
g.mu.Lock()
defer g.mu.Unlock()
+ if !g.ctl.isCurrent(group, func(cur *HTTPGroup) bool { return cur == g }) {
+ return errGroupStale
+ }
if len(g.createFuncs) == 0 {
// the first proxy in this group
tmp := routeConfig // copy object
@@ -123,7 +115,7 @@ func (g *HTTPGroup) Register(
return nil
}
-func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
+func (g *HTTPGroup) UnRegister(proxyName string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.createFuncs, proxyName)
@@ -135,15 +127,16 @@ func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
}
if len(g.createFuncs) == 0 {
- isEmpty = true
g.ctl.vhostRouter.Del(g.domain, g.location, g.routeByHTTPUser)
+ g.ctl.removeIf(g.group, func(cur *HTTPGroup) bool {
+ return cur == g
+ })
}
- return
}
func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
var f vhost.CreateConnFunc
- newIndex := atomic.AddUint64(&g.index, 1)
+ newIndex := g.index.Add(1)
g.mu.RLock()
group := g.group
@@ -151,7 +144,7 @@ func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
location := g.location
routeByHTTPUser := g.routeByHTTPUser
if len(g.pxyNames) > 0 {
- name := g.pxyNames[int(newIndex)%len(g.pxyNames)]
+ name := g.pxyNames[newIndex%uint64(len(g.pxyNames))]
f = g.createFuncs[name]
}
g.mu.RUnlock()
@@ -165,7 +158,7 @@ func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
}
func (g *HTTPGroup) chooseEndpoint() (string, error) {
- newIndex := atomic.AddUint64(&g.index, 1)
+ newIndex := g.index.Add(1)
name := ""
g.mu.RLock()
@@ -174,7 +167,7 @@ func (g *HTTPGroup) chooseEndpoint() (string, error) {
location := g.location
routeByHTTPUser := g.routeByHTTPUser
if len(g.pxyNames) > 0 {
- name = g.pxyNames[int(newIndex)%len(g.pxyNames)]
+ name = g.pxyNames[newIndex%uint64(len(g.pxyNames))]
}
g.mu.RUnlock()
diff --git a/server/group/https.go b/server/group/https.go
index 4089b0cb..1ab97578 100644
--- a/server/group/https.go
+++ b/server/group/https.go
@@ -17,25 +17,19 @@ package group
import (
"context"
"net"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
"github.com/fatedier/frp/pkg/util/vhost"
)
type HTTPSGroupController struct {
- groups map[string]*HTTPSGroup
-
+ groupRegistry[*HTTPSGroup]
httpsMuxer *vhost.HTTPSMuxer
-
- mu sync.Mutex
}
func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController {
return &HTTPSGroupController{
- groups: make(map[string]*HTTPSGroup),
- httpsMuxer: httpsMuxer,
+ groupRegistry: newGroupRegistry[*HTTPSGroup](),
+ httpsMuxer: httpsMuxer,
}
}
@@ -44,41 +38,28 @@ func (ctl *HTTPSGroupController) Listen(
group, groupKey string,
routeConfig vhost.RouteConfig,
) (l net.Listener, err error) {
- indexKey := group
- ctl.mu.Lock()
- g, ok := ctl.groups[indexKey]
- if !ok {
- g = NewHTTPSGroup(ctl)
- ctl.groups[indexKey] = g
+ for {
+ g := ctl.getOrCreate(group, func() *HTTPSGroup {
+ return NewHTTPSGroup(ctl)
+ })
+ l, err = g.Listen(ctx, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return
}
- ctl.mu.Unlock()
-
- return g.Listen(ctx, group, groupKey, routeConfig)
-}
-
-func (ctl *HTTPSGroupController) RemoveGroup(group string) {
- ctl.mu.Lock()
- defer ctl.mu.Unlock()
- delete(ctl.groups, group)
}
type HTTPSGroup struct {
- group string
- groupKey string
- domain string
+ baseGroup
- acceptCh chan net.Conn
- httpsLn *vhost.Listener
- lns []*HTTPSGroupListener
- ctl *HTTPSGroupController
- mu sync.Mutex
+ domain string
+ ctl *HTTPSGroupController
}
func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup {
return &HTTPSGroup{
- lns: make([]*HTTPSGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
@@ -86,23 +67,27 @@ func (g *HTTPSGroup) Listen(
ctx context.Context,
group, groupKey string,
routeConfig vhost.RouteConfig,
-) (ln *HTTPSGroupListener, err error) {
+) (ln *Listener, err error) {
g.mu.Lock()
defer g.mu.Unlock()
+ if !g.ctl.isCurrent(group, func(cur *HTTPSGroup) bool { return cur == g }) {
+ return nil, errGroupStale
+ }
if len(g.lns) == 0 {
// the first listener, listen on the real address
httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig)
if errRet != nil {
return nil, errRet
}
- ln = newHTTPSGroupListener(group, g, httpsLn.Addr())
- g.group = group
- g.groupKey = groupKey
g.domain = routeConfig.Domain
- g.httpsLn = httpsLn
- g.lns = append(g.lns, ln)
- go g.worker()
+ g.initBase(group, groupKey, httpsLn, func() {
+ g.ctl.removeIf(g.group, func(cur *HTTPSGroup) bool {
+ return cur == g
+ })
+ })
+ ln = g.newListener(httpsLn.Addr())
+ go g.worker(httpsLn, g.acceptCh)
} else {
// route config in the same group must be equal
if g.group != group || g.domain != routeConfig.Domain {
@@ -111,87 +96,7 @@ func (g *HTTPSGroup) Listen(
if g.groupKey != groupKey {
return nil, ErrGroupAuthFailed
}
- ln = newHTTPSGroupListener(group, g, g.lns[0].Addr())
- g.lns = append(g.lns, ln)
+ ln = g.newListener(g.lns[0].Addr())
}
return
}
-
-func (g *HTTPSGroup) worker() {
- for {
- c, err := g.httpsLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- g.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (g *HTTPSGroup) Accept() <-chan net.Conn {
- return g.acceptCh
-}
-
-func (g *HTTPSGroup) CloseListener(ln *HTTPSGroupListener) {
- g.mu.Lock()
- defer g.mu.Unlock()
- for i, tmpLn := range g.lns {
- if tmpLn == ln {
- g.lns = append(g.lns[:i], g.lns[i+1:]...)
- break
- }
- }
- if len(g.lns) == 0 {
- close(g.acceptCh)
- if g.httpsLn != nil {
- g.httpsLn.Close()
- }
- g.ctl.RemoveGroup(g.group)
- }
-}
-
-type HTTPSGroupListener struct {
- groupName string
- group *HTTPSGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newHTTPSGroupListener(name string, group *HTTPSGroup, addr net.Addr) *HTTPSGroupListener {
- return &HTTPSGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-func (ln *HTTPSGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *HTTPSGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-func (ln *HTTPSGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from HTTPSGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/group/listener.go b/server/group/listener.go
new file mode 100644
index 00000000..33c5c0df
--- /dev/null
+++ b/server/group/listener.go
@@ -0,0 +1,49 @@
+package group
+
+import (
+ "net"
+ "sync"
+)
+
+// Listener is a per-proxy virtual listener that receives connections
+// from a shared group. It implements net.Listener.
+type Listener struct {
+ acceptCh <-chan net.Conn
+ addr net.Addr
+ closeCh chan struct{}
+ onClose func(*Listener)
+ once sync.Once
+}
+
+func newListener(acceptCh <-chan net.Conn, addr net.Addr, onClose func(*Listener)) *Listener {
+ return &Listener{
+ acceptCh: acceptCh,
+ addr: addr,
+ closeCh: make(chan struct{}),
+ onClose: onClose,
+ }
+}
+
+func (ln *Listener) Accept() (net.Conn, error) {
+ select {
+ case <-ln.closeCh:
+ return nil, ErrListenerClosed
+ case c, ok := <-ln.acceptCh:
+ if !ok {
+ return nil, ErrListenerClosed
+ }
+ return c, nil
+ }
+}
+
+func (ln *Listener) Addr() net.Addr {
+ return ln.addr
+}
+
+func (ln *Listener) Close() error {
+ ln.once.Do(func() {
+ close(ln.closeCh)
+ ln.onClose(ln)
+ })
+ return nil
+}
diff --git a/server/group/listener_test.go b/server/group/listener_test.go
new file mode 100644
index 00000000..4e3e30e6
--- /dev/null
+++ b/server/group/listener_test.go
@@ -0,0 +1,68 @@
+package group
+
+import (
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestListener_Accept(t *testing.T) {
+ acceptCh := make(chan net.Conn, 1)
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ c1, c2 := net.Pipe()
+ defer c1.Close()
+ defer c2.Close()
+
+ acceptCh <- c1
+ got, err := ln.Accept()
+ require.NoError(t, err)
+ assert.Equal(t, c1, got)
+}
+
+func TestListener_AcceptAfterChannelClose(t *testing.T) {
+ acceptCh := make(chan net.Conn)
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ close(acceptCh)
+ _, err := ln.Accept()
+ assert.ErrorIs(t, err, ErrListenerClosed)
+}
+
+func TestListener_AcceptAfterListenerClose(t *testing.T) {
+ acceptCh := make(chan net.Conn) // open, not closed
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ ln.Close()
+ _, err := ln.Accept()
+ assert.ErrorIs(t, err, ErrListenerClosed)
+}
+
+func TestListener_DoubleClose(t *testing.T) {
+ closeCalls := 0
+ ln := newListener(
+ make(chan net.Conn),
+ fakeAddr("127.0.0.1:1234"),
+ func(*Listener) { closeCalls++ },
+ )
+
+ assert.NotPanics(t, func() {
+ ln.Close()
+ ln.Close()
+ })
+ assert.Equal(t, 1, closeCalls, "onClose should be called exactly once")
+}
+
+func TestListener_Addr(t *testing.T) {
+ addr := fakeAddr("10.0.0.1:5555")
+ ln := newListener(make(chan net.Conn), addr, func(*Listener) {})
+ assert.Equal(t, addr, ln.Addr())
+}
+
+// fakeAddr implements net.Addr for testing.
+type fakeAddr string
+
+func (a fakeAddr) Network() string { return "tcp" }
+func (a fakeAddr) String() string { return string(a) }
diff --git a/server/group/registry.go b/server/group/registry.go
new file mode 100644
index 00000000..4064c535
--- /dev/null
+++ b/server/group/registry.go
@@ -0,0 +1,59 @@
+package group
+
+import (
+ "sync"
+)
+
+// groupRegistry is a concurrent map of named groups with
+// automatic creation on first access.
+type groupRegistry[G any] struct {
+ groups map[string]G
+ mu sync.Mutex
+}
+
+func newGroupRegistry[G any]() groupRegistry[G] {
+ return groupRegistry[G]{
+ groups: make(map[string]G),
+ }
+}
+
+func (r *groupRegistry[G]) getOrCreate(key string, newFn func() G) G {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ if !ok {
+ g = newFn()
+ r.groups[key] = g
+ }
+ return g
+}
+
+func (r *groupRegistry[G]) get(key string) (G, bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ return g, ok
+}
+
+// isCurrent returns true if key exists in the registry and matchFn
+// returns true for the stored value.
+func (r *groupRegistry[G]) isCurrent(key string, matchFn func(G) bool) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ return ok && matchFn(g)
+}
+
+// removeIf atomically looks up the group for key, calls fn on it,
+// and removes the entry if fn returns true.
+func (r *groupRegistry[G]) removeIf(key string, fn func(G) bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ if !ok {
+ return
+ }
+ if fn(g) {
+ delete(r.groups, key)
+ }
+}
diff --git a/server/group/registry_test.go b/server/group/registry_test.go
new file mode 100644
index 00000000..106d3998
--- /dev/null
+++ b/server/group/registry_test.go
@@ -0,0 +1,102 @@
+package group
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetOrCreate_New(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ called := 0
+ v := 42
+ got := r.getOrCreate("k", func() *int { called++; return &v })
+ assert.Equal(t, 1, called)
+ assert.Equal(t, &v, got)
+}
+
+func TestGetOrCreate_Existing(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 42
+ r.getOrCreate("k", func() *int { return &v })
+
+ called := 0
+ got := r.getOrCreate("k", func() *int { called++; return nil })
+ assert.Equal(t, 0, called)
+ assert.Equal(t, &v, got)
+}
+
+func TestGet_ExistingAndMissing(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+
+ got, ok := r.get("k")
+ assert.True(t, ok)
+ assert.Equal(t, &v, got)
+
+ _, ok = r.get("missing")
+ assert.False(t, ok)
+}
+
+func TestIsCurrent(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v1 := 1
+ v2 := 2
+ r.getOrCreate("k", func() *int { return &v1 })
+
+ assert.True(t, r.isCurrent("k", func(g *int) bool { return g == &v1 }))
+ assert.False(t, r.isCurrent("k", func(g *int) bool { return g == &v2 }))
+ assert.False(t, r.isCurrent("missing", func(g *int) bool { return true }))
+}
+
+func TestRemoveIf(t *testing.T) {
+ t.Run("removes when fn returns true", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+ r.removeIf("k", func(g *int) bool { return g == &v })
+ _, ok := r.get("k")
+ assert.False(t, ok)
+ })
+
+ t.Run("keeps when fn returns false", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+ r.removeIf("k", func(g *int) bool { return false })
+ _, ok := r.get("k")
+ assert.True(t, ok)
+ })
+
+ t.Run("noop on missing key", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ r.removeIf("missing", func(g *int) bool { return true }) // should not panic
+ })
+}
+
+func TestConcurrentGetOrCreateAndRemoveIf(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ const n = 100
+ var wg sync.WaitGroup
+ wg.Add(n * 2)
+ for i := range n {
+ v := i
+ go func() {
+ defer wg.Done()
+ r.getOrCreate("k", func() *int { return &v })
+ }()
+ go func() {
+ defer wg.Done()
+ r.removeIf("k", func(*int) bool { return true })
+ }()
+ }
+ wg.Wait()
+
+ // After all goroutines finish, accessing the key must not panic.
+ require.NotPanics(t, func() {
+ _, _ = r.get("k")
+ })
+}
diff --git a/server/group/tcp.go b/server/group/tcp.go
index c0dcd5f7..d6bfbcff 100644
--- a/server/group/tcp.go
+++ b/server/group/tcp.go
@@ -17,107 +17,91 @@ package group
import (
"net"
"strconv"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
"github.com/fatedier/frp/server/ports"
)
-// TCPGroupCtl manage all TCPGroups
+// TCPGroupCtl manages all TCPGroups.
type TCPGroupCtl struct {
- groups map[string]*TCPGroup
-
- // portManager is used to manage port
+ groupRegistry[*TCPGroup]
portManager *ports.Manager
- mu sync.Mutex
}
-// NewTCPGroupCtl return a new TcpGroupCtl
+// NewTCPGroupCtl returns a new TCPGroupCtl.
func NewTCPGroupCtl(portManager *ports.Manager) *TCPGroupCtl {
return &TCPGroupCtl{
- groups: make(map[string]*TCPGroup),
- portManager: portManager,
+ groupRegistry: newGroupRegistry[*TCPGroup](),
+ portManager: portManager,
}
}
-// Listen is the wrapper for TCPGroup's Listen
-// If there are no group, we will create one here
+// Listen is the wrapper for TCPGroup's Listen.
+// If there is no group, one will be created.
func (tgc *TCPGroupCtl) Listen(proxyName string, group string, groupKey string,
addr string, port int,
) (l net.Listener, realPort int, err error) {
- tgc.mu.Lock()
- tcpGroup, ok := tgc.groups[group]
- if !ok {
- tcpGroup = NewTCPGroup(tgc)
- tgc.groups[group] = tcpGroup
+ for {
+ tcpGroup := tgc.getOrCreate(group, func() *TCPGroup {
+ return NewTCPGroup(tgc)
+ })
+ l, realPort, err = tcpGroup.Listen(proxyName, group, groupKey, addr, port)
+ if err == errGroupStale {
+ continue
+ }
+ return
}
- tgc.mu.Unlock()
-
- return tcpGroup.Listen(proxyName, group, groupKey, addr, port)
}
-// RemoveGroup remove TCPGroup from controller
-func (tgc *TCPGroupCtl) RemoveGroup(group string) {
- tgc.mu.Lock()
- defer tgc.mu.Unlock()
- delete(tgc.groups, group)
-}
-
-// TCPGroup route connections to different proxies
+// TCPGroup routes connections to different proxies.
type TCPGroup struct {
- group string
- groupKey string
+ baseGroup
+
addr string
port int
realPort int
-
- acceptCh chan net.Conn
- tcpLn net.Listener
- lns []*TCPGroupListener
ctl *TCPGroupCtl
- mu sync.Mutex
}
-// NewTCPGroup return a new TCPGroup
+// NewTCPGroup returns a new TCPGroup.
func NewTCPGroup(ctl *TCPGroupCtl) *TCPGroup {
return &TCPGroup{
- lns: make([]*TCPGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
-// Listen will return a new TCPGroupListener
-// if TCPGroup already has a listener, just add a new TCPGroupListener to the queues
-// otherwise, listen on the real address
-func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *TCPGroupListener, realPort int, err error) {
+// Listen will return a new Listener.
+// If TCPGroup already has a listener, just add a new Listener to the queues,
+// otherwise listen on the real address.
+func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *Listener, realPort int, err error) {
tg.mu.Lock()
defer tg.mu.Unlock()
+ if !tg.ctl.isCurrent(group, func(cur *TCPGroup) bool { return cur == tg }) {
+ return nil, 0, errGroupStale
+ }
if len(tg.lns) == 0 {
// the first listener, listen on the real address
realPort, err = tg.ctl.portManager.Acquire(proxyName, port)
if err != nil {
return
}
- tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(port)))
+ tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(realPort)))
if errRet != nil {
+ tg.ctl.portManager.Release(realPort)
err = errRet
return
}
- ln = newTCPGroupListener(group, tg, tcpLn.Addr())
- tg.group = group
- tg.groupKey = groupKey
tg.addr = addr
tg.port = port
tg.realPort = realPort
- tg.tcpLn = tcpLn
- tg.lns = append(tg.lns, ln)
- if tg.acceptCh == nil {
- tg.acceptCh = make(chan net.Conn)
- }
- go tg.worker()
+ tg.initBase(group, groupKey, tcpLn, func() {
+ tg.ctl.portManager.Release(tg.realPort)
+ tg.ctl.removeIf(tg.group, func(cur *TCPGroup) bool {
+ return cur == tg
+ })
+ })
+ ln = tg.newListener(tcpLn.Addr())
+ go tg.worker(tcpLn, tg.acceptCh)
} else {
// address and port in the same group must be equal
if tg.group != group || tg.addr != addr {
@@ -132,92 +116,8 @@ func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr
err = ErrGroupAuthFailed
return
}
- ln = newTCPGroupListener(group, tg, tg.lns[0].Addr())
+ ln = tg.newListener(tg.lns[0].Addr())
realPort = tg.realPort
- tg.lns = append(tg.lns, ln)
}
return
}
-
-// worker is called when the real tcp listener has been created
-func (tg *TCPGroup) worker() {
- for {
- c, err := tg.tcpLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- tg.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (tg *TCPGroup) Accept() <-chan net.Conn {
- return tg.acceptCh
-}
-
-// CloseListener remove the TCPGroupListener from the TCPGroup
-func (tg *TCPGroup) CloseListener(ln *TCPGroupListener) {
- tg.mu.Lock()
- defer tg.mu.Unlock()
- for i, tmpLn := range tg.lns {
- if tmpLn == ln {
- tg.lns = append(tg.lns[:i], tg.lns[i+1:]...)
- break
- }
- }
- if len(tg.lns) == 0 {
- close(tg.acceptCh)
- tg.tcpLn.Close()
- tg.ctl.portManager.Release(tg.realPort)
- tg.ctl.RemoveGroup(tg.group)
- }
-}
-
-// TCPGroupListener
-type TCPGroupListener struct {
- groupName string
- group *TCPGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newTCPGroupListener(name string, group *TCPGroup, addr net.Addr) *TCPGroupListener {
- return &TCPGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-// Accept will accept connections from TCPGroup
-func (ln *TCPGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *TCPGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-// Close close the listener
-func (ln *TCPGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from TcpGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/group/tcpmux.go b/server/group/tcpmux.go
index 1712bc74..e17a152a 100644
--- a/server/group/tcpmux.go
+++ b/server/group/tcpmux.go
@@ -18,118 +18,100 @@ import (
"context"
"fmt"
"net"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/tcpmux"
"github.com/fatedier/frp/pkg/util/vhost"
)
-// TCPMuxGroupCtl manage all TCPMuxGroups
+// TCPMuxGroupCtl manages all TCPMuxGroups.
type TCPMuxGroupCtl struct {
- groups map[string]*TCPMuxGroup
-
- // portManager is used to manage port
+ groupRegistry[*TCPMuxGroup]
tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer
- mu sync.Mutex
}
-// NewTCPMuxGroupCtl return a new TCPMuxGroupCtl
+// NewTCPMuxGroupCtl returns a new TCPMuxGroupCtl.
func NewTCPMuxGroupCtl(tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer) *TCPMuxGroupCtl {
return &TCPMuxGroupCtl{
- groups: make(map[string]*TCPMuxGroup),
+ groupRegistry: newGroupRegistry[*TCPMuxGroup](),
tcpMuxHTTPConnectMuxer: tcpMuxHTTPConnectMuxer,
}
}
-// Listen is the wrapper for TCPMuxGroup's Listen
-// If there are no group, we will create one here
+// Listen is the wrapper for TCPMuxGroup's Listen.
+// If there is no group, one will be created.
func (tmgc *TCPMuxGroupCtl) Listen(
ctx context.Context,
multiplexer, group, groupKey string,
routeConfig vhost.RouteConfig,
) (l net.Listener, err error) {
- tmgc.mu.Lock()
- tcpMuxGroup, ok := tmgc.groups[group]
- if !ok {
- tcpMuxGroup = NewTCPMuxGroup(tmgc)
- tmgc.groups[group] = tcpMuxGroup
- }
- tmgc.mu.Unlock()
+ for {
+ tcpMuxGroup := tmgc.getOrCreate(group, func() *TCPMuxGroup {
+ return NewTCPMuxGroup(tmgc)
+ })
- switch v1.TCPMultiplexerType(multiplexer) {
- case v1.TCPMultiplexerHTTPConnect:
- return tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
- default:
- err = fmt.Errorf("unknown multiplexer [%s]", multiplexer)
- return
+ switch v1.TCPMultiplexerType(multiplexer) {
+ case v1.TCPMultiplexerHTTPConnect:
+ l, err = tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return
+ default:
+ return nil, fmt.Errorf("unknown multiplexer [%s]", multiplexer)
+ }
}
}
-// RemoveGroup remove TCPMuxGroup from controller
-func (tmgc *TCPMuxGroupCtl) RemoveGroup(group string) {
- tmgc.mu.Lock()
- defer tmgc.mu.Unlock()
- delete(tmgc.groups, group)
-}
-
-// TCPMuxGroup route connections to different proxies
+// TCPMuxGroup routes connections to different proxies.
type TCPMuxGroup struct {
- group string
- groupKey string
+ baseGroup
+
domain string
routeByHTTPUser string
username string
password string
-
- acceptCh chan net.Conn
- tcpMuxLn net.Listener
- lns []*TCPMuxGroupListener
- ctl *TCPMuxGroupCtl
- mu sync.Mutex
+ ctl *TCPMuxGroupCtl
}
-// NewTCPMuxGroup return a new TCPMuxGroup
+// NewTCPMuxGroup returns a new TCPMuxGroup.
func NewTCPMuxGroup(ctl *TCPMuxGroupCtl) *TCPMuxGroup {
return &TCPMuxGroup{
- lns: make([]*TCPMuxGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
-// Listen will return a new TCPMuxGroupListener
-// if TCPMuxGroup already has a listener, just add a new TCPMuxGroupListener to the queues
-// otherwise, listen on the real address
+// HTTPConnectListen will return a new Listener.
+// If TCPMuxGroup already has a listener, just add a new Listener to the queues,
+// otherwise listen on the real address.
func (tmg *TCPMuxGroup) HTTPConnectListen(
ctx context.Context,
group, groupKey string,
routeConfig vhost.RouteConfig,
-) (ln *TCPMuxGroupListener, err error) {
+) (ln *Listener, err error) {
tmg.mu.Lock()
defer tmg.mu.Unlock()
+ if !tmg.ctl.isCurrent(group, func(cur *TCPMuxGroup) bool { return cur == tmg }) {
+ return nil, errGroupStale
+ }
if len(tmg.lns) == 0 {
// the first listener, listen on the real address
tcpMuxLn, errRet := tmg.ctl.tcpMuxHTTPConnectMuxer.Listen(ctx, &routeConfig)
if errRet != nil {
return nil, errRet
}
- ln = newTCPMuxGroupListener(group, tmg, tcpMuxLn.Addr())
- tmg.group = group
- tmg.groupKey = groupKey
tmg.domain = routeConfig.Domain
tmg.routeByHTTPUser = routeConfig.RouteByHTTPUser
tmg.username = routeConfig.Username
tmg.password = routeConfig.Password
- tmg.tcpMuxLn = tcpMuxLn
- tmg.lns = append(tmg.lns, ln)
- if tmg.acceptCh == nil {
- tmg.acceptCh = make(chan net.Conn)
- }
- go tmg.worker()
+ tmg.initBase(group, groupKey, tcpMuxLn, func() {
+ tmg.ctl.removeIf(tmg.group, func(cur *TCPMuxGroup) bool {
+ return cur == tmg
+ })
+ })
+ ln = tmg.newListener(tcpMuxLn.Addr())
+ go tmg.worker(tcpMuxLn, tmg.acceptCh)
} else {
// route config in the same group must be equal
if tmg.group != group || tmg.domain != routeConfig.Domain ||
@@ -141,90 +123,7 @@ func (tmg *TCPMuxGroup) HTTPConnectListen(
if tmg.groupKey != groupKey {
return nil, ErrGroupAuthFailed
}
- ln = newTCPMuxGroupListener(group, tmg, tmg.lns[0].Addr())
- tmg.lns = append(tmg.lns, ln)
+ ln = tmg.newListener(tmg.lns[0].Addr())
}
return
}
-
-// worker is called when the real TCP listener has been created
-func (tmg *TCPMuxGroup) worker() {
- for {
- c, err := tmg.tcpMuxLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- tmg.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (tmg *TCPMuxGroup) Accept() <-chan net.Conn {
- return tmg.acceptCh
-}
-
-// CloseListener remove the TCPMuxGroupListener from the TCPMuxGroup
-func (tmg *TCPMuxGroup) CloseListener(ln *TCPMuxGroupListener) {
- tmg.mu.Lock()
- defer tmg.mu.Unlock()
- for i, tmpLn := range tmg.lns {
- if tmpLn == ln {
- tmg.lns = append(tmg.lns[:i], tmg.lns[i+1:]...)
- break
- }
- }
- if len(tmg.lns) == 0 {
- close(tmg.acceptCh)
- tmg.tcpMuxLn.Close()
- tmg.ctl.RemoveGroup(tmg.group)
- }
-}
-
-// TCPMuxGroupListener
-type TCPMuxGroupListener struct {
- groupName string
- group *TCPMuxGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newTCPMuxGroupListener(name string, group *TCPMuxGroup, addr net.Addr) *TCPMuxGroupListener {
- return &TCPMuxGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-// Accept will accept connections from TCPMuxGroup
-func (ln *TCPMuxGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *TCPMuxGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-// Close close the listener
-func (ln *TCPMuxGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from TcpMuxGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/api/controller.go b/server/http/controller.go
similarity index 68%
rename from server/api/controller.go
rename to server/http/controller.go
index 2d8c8e09..1a8db7dc 100644
--- a/server/api/controller.go
+++ b/server/http/controller.go
@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package http
import (
"cmp"
- "encoding/json"
"fmt"
"net/http"
"slices"
@@ -29,6 +28,7 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
+ "github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
@@ -66,7 +66,7 @@ func NewController(
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer()
- svrResp := ServerInfoResp{
+ svrResp := model.ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -87,22 +87,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
- // For API that returns struct, we can just return it.
- // But current GeneralResponse.Msg in legacy code expects a JSON string.
- // Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
- // The original code wraps it in GeneralResponse{Msg: string(json)}.
- // If we return svrResp, the response body will be the JSON of svrResp.
- // We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
- // Looking at previous code:
- // res := GeneralResponse{Code: 200}
- // buf, _ := json.Marshal(&svrResp)
- // res.Msg = string(buf)
- // Response body: {"code": 200, "msg": "{\"version\":...}"}
- // Wait, is it double encoded JSON? Yes it seems so!
- // Let's check dashboard_api.go original code again.
- // Yes: res.Msg = string(buf).
- // So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
- // This is kind of ugly, but we must preserve compatibility.
return svrResp, nil
}
@@ -119,7 +103,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
- items := make([]ClientInfoResp, 0, len(records))
+ items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
@@ -136,7 +120,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
items = append(items, buildClientInfoResp(info))
}
- slices.SortFunc(items, func(a, b ClientInfoResp) int {
+ slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
@@ -172,9 +156,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
- proxyInfoResp := GetProxyInfoResp{}
+ proxyInfoResp := model.GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
- slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
+ slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
@@ -198,7 +182,7 @@ func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
- trafficResp := GetProxyTrafficResp{}
+ trafficResp := model.GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
@@ -220,7 +204,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
- proxyInfo := GetProxyStatsResp{
+ proxyInfo := model.GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
@@ -232,20 +216,8 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
- return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
- return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
- c.fillProxyClientInfo(&proxyClientInfo{
- clientVersion: &proxyInfo.ClientVersion,
- }, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -282,29 +254,17 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
}
-func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
+func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
- proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
+ proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
- proxyInfo := &ProxyStatsInfo{
+ proxyInfo := &model.ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
- continue
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
- continue
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
- c.fillProxyClientInfo(&proxyClientInfo{
- clientVersion: &proxyInfo.ClientVersion,
- }, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -319,7 +279,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
return
}
-func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
+func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
@@ -329,20 +289,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
- code = 400
- msg = "parse conf error"
- return
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
- code = 400
- msg = "parse conf error"
- return
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -358,12 +305,14 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
return
}
-func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
- resp := ClientInfoResp{
+func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
+ resp := model.ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
RunID: info.RunID,
+ Version: info.Version,
+ WireProtocol: info.WireProtocol,
Hostname: info.Hostname,
ClientIP: info.IP,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
@@ -376,37 +325,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
@@ -427,23 +345,37 @@ func matchStatusFilter(online bool, filter string) bool {
}
}
-func getConfByType(proxyType string) any {
- switch v1.ProxyType(proxyType) {
- case v1.ProxyTypeTCP:
- return &TCPOutConf{}
- case v1.ProxyTypeTCPMUX:
- return &TCPMuxOutConf{}
- case v1.ProxyTypeUDP:
- return &UDPOutConf{}
- case v1.ProxyTypeHTTP:
- return &HTTPOutConf{}
- case v1.ProxyTypeHTTPS:
- return &HTTPSOutConf{}
- case v1.ProxyTypeSTCP:
- return &STCPOutConf{}
- case v1.ProxyTypeXTCP:
- return &XTCPOutConf{}
- default:
- return nil
+func getConfFromConfigurer(cfg v1.ProxyConfigurer) any {
+ outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}
+
+ switch c := cfg.(type) {
+ case *v1.TCPProxyConfig:
+ return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
+ case *v1.UDPProxyConfig:
+ return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
+ case *v1.HTTPProxyConfig:
+ return &model.HTTPOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ Locations: c.Locations,
+ HostHeaderRewrite: c.HostHeaderRewrite,
+ }
+ case *v1.HTTPSProxyConfig:
+ return &model.HTTPSOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ }
+ case *v1.TCPMuxProxyConfig:
+ return &model.TCPMuxOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ Multiplexer: c.Multiplexer,
+ RouteByHTTPUser: c.RouteByHTTPUser,
+ }
+ case *v1.STCPProxyConfig:
+ return &model.STCPOutConf{BaseOutConf: outBase}
+ case *v1.XTCPProxyConfig:
+ return &model.XTCPOutConf{BaseOutConf: outBase}
}
+ return outBase
}
diff --git a/server/http/controller_test.go b/server/http/controller_test.go
new file mode 100644
index 00000000..44509371
--- /dev/null
+++ b/server/http/controller_test.go
@@ -0,0 +1,95 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/proto/wire"
+ "github.com/fatedier/frp/server/registry"
+)
+
+func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) {
+ cfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "test-proxy",
+ Type: string(v1.ProxyTypeTCP),
+ ProxyBackend: v1.ProxyBackend{
+ Plugin: v1.TypedClientPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ ClientPluginOptions: &v1.HTTPProxyPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ HTTPUser: "user",
+ HTTPPassword: "password",
+ },
+ },
+ },
+ },
+ RemotePort: 6000,
+ }
+
+ content, err := json.Marshal(getConfFromConfigurer(cfg))
+ if err != nil {
+ t.Fatalf("marshal conf failed: %v", err)
+ }
+
+ var out map[string]any
+ if err := json.Unmarshal(content, &out); err != nil {
+ t.Fatalf("unmarshal conf failed: %v", err)
+ }
+
+ pluginValue, ok := out["plugin"]
+ if !ok {
+ t.Fatalf("plugin field missing in output: %v", out)
+ }
+ plugin, ok := pluginValue.(map[string]any)
+ if !ok {
+ t.Fatalf("plugin field should be object, got: %#v", pluginValue)
+ }
+
+ if got := plugin["type"]; got != v1.PluginHTTPProxy {
+ t.Fatalf("plugin type mismatch, want %q got %#v", v1.PluginHTTPProxy, got)
+ }
+ if got := plugin["httpUser"]; got != "user" {
+ t.Fatalf("plugin httpUser mismatch, want %q got %#v", "user", got)
+ }
+ if got := plugin["httpPassword"]; got != "password" {
+ t.Fatalf("plugin httpPassword mismatch, want %q got %#v", "password", got)
+ }
+}
+
+func TestBuildClientInfoRespIncludesWireProtocol(t *testing.T) {
+ info := registry.ClientInfo{
+ Key: "user.client",
+ User: "user",
+ RawClientID: "client",
+ RunID: "run-id",
+ Version: "1.0.0",
+ WireProtocol: wire.ProtocolV2,
+ Hostname: "host",
+ IP: "127.0.0.1",
+ FirstConnectedAt: time.Unix(1, 0),
+ LastConnectedAt: time.Unix(2, 0),
+ Online: true,
+ }
+
+ resp := buildClientInfoResp(info)
+ if resp.WireProtocol != wire.ProtocolV2 {
+ t.Fatalf("wire protocol mismatch, want %q got %q", wire.ProtocolV2, resp.WireProtocol)
+ }
+}
diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go
new file mode 100644
index 00000000..8a50a54f
--- /dev/null
+++ b/server/http/controller_v2.go
@@ -0,0 +1,394 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "cmp"
+ "fmt"
+ "math"
+ "net/http"
+ "slices"
+ "strconv"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/metrics/mem"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/server/http/model"
+)
+
+const (
+ defaultV2Page = 1
+ defaultV2PageSize = 50
+ maxV2PageSize = 200
+)
+
+var apiV2ProxyTypes = []string{
+ string(v1.ProxyTypeTCP),
+ string(v1.ProxyTypeUDP),
+ string(v1.ProxyTypeHTTP),
+ string(v1.ProxyTypeHTTPS),
+ string(v1.ProxyTypeTCPMUX),
+ string(v1.ProxyTypeSTCP),
+ string(v1.ProxyTypeXTCP),
+ string(v1.ProxyTypeSUDP),
+}
+
+// /api/v2/users
+func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if c.clientRegistry == nil {
+ return nil, fmt.Errorf("client registry unavailable")
+ }
+
+ userStats := make(map[string]*model.V2UserResp)
+ for _, info := range c.clientRegistry.List() {
+ item := getOrCreateV2User(userStats, info.User)
+ item.ClientCount++
+ }
+ for _, proxyInfo := range c.listV2ProxyStats("") {
+ item := getOrCreateV2User(userStats, proxyInfo.User)
+ item.ProxyCount++
+ }
+
+ q := strings.ToLower(ctx.Query("q"))
+ items := make([]model.V2UserResp, 0, len(userStats))
+ for _, item := range userStats {
+ if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
+ continue
+ }
+ items = append(items, *item)
+ }
+ slices.SortFunc(items, func(a, b model.V2UserResp) int {
+ return cmp.Compare(a.User, b.User)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/clients
+func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if c.clientRegistry == nil {
+ return nil, fmt.Errorf("client registry unavailable")
+ }
+ statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
+ if err != nil {
+ return nil, err
+ }
+
+ userFilter, filterByUser := queryValue(ctx, "user")
+ clientIDFilter := ctx.Query("clientID")
+ runIDFilter := ctx.Query("runID")
+ q := strings.ToLower(ctx.Query("q"))
+
+ records := c.clientRegistry.List()
+ items := make([]model.ClientInfoResp, 0, len(records))
+ for _, info := range records {
+ if filterByUser && info.User != userFilter {
+ continue
+ }
+ if clientIDFilter != "" && info.ClientID() != clientIDFilter {
+ continue
+ }
+ if runIDFilter != "" && info.RunID != runIDFilter {
+ continue
+ }
+ if !matchV2StatusFilter(info.Online, statusFilter) {
+ continue
+ }
+ resp := buildClientInfoResp(info)
+ if q != "" && !matchV2ClientQuery(resp, q) {
+ continue
+ }
+ items = append(items, resp)
+ }
+
+ slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
+ if v := cmp.Compare(a.User, b.User); v != 0 {
+ return v
+ }
+ if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
+ return v
+ }
+ return cmp.Compare(a.Key, b.Key)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/clients/{key}
+func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
+ return c.APIClientDetail(ctx)
+}
+
+// /api/v2/proxies
+func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
+ if err != nil {
+ return nil, err
+ }
+
+ proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
+ if err != nil {
+ return nil, err
+ }
+ userFilter, filterByUser := queryValue(ctx, "user")
+ clientIDFilter := ctx.Query("clientID")
+ q := strings.ToLower(ctx.Query("q"))
+
+ stats := c.listV2ProxyStats(proxyType)
+ items := make([]model.V2ProxyResp, 0, len(stats))
+ for _, ps := range stats {
+ resp := c.buildV2ProxyResp(ps)
+ if filterByUser && resp.User != userFilter {
+ continue
+ }
+ if clientIDFilter != "" && resp.ClientID != clientIDFilter {
+ continue
+ }
+ if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
+ continue
+ }
+ if q != "" && !matchV2ProxyQuery(resp, q) {
+ continue
+ }
+ items = append(items, resp)
+ }
+
+ slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
+ if v := cmp.Compare(a.Type, b.Type); v != 0 {
+ return v
+ }
+ return cmp.Compare(a.Name, b.Name)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/proxies/{name}
+func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, fmt.Errorf("missing proxy name")
+ }
+
+ ps := mem.StatsCollector.GetProxyByName(name)
+ if ps == nil {
+ return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
+ }
+ return c.buildV2ProxyResp(ps), nil
+}
+
+func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
+ item, ok := items[user]
+ if !ok {
+ item = &model.V2UserResp{User: user}
+ items[user] = item
+ }
+ return item
+}
+
+func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
+ page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
+ if err != nil {
+ return 0, 0, err
+ }
+ pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
+ if err != nil {
+ return 0, 0, err
+ }
+ if pageSize > maxV2PageSize {
+ return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
+ }
+ if page > math.MaxInt/pageSize {
+ return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
+ }
+ return page, pageSize, nil
+}
+
+func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
+ if raw == "" {
+ return defaultValue, nil
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < 1 {
+ return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
+ }
+ return value, nil
+}
+
+func parseV2StatusFilter(raw string) (string, error) {
+ status := strings.ToLower(raw)
+ switch status {
+ case "", "all", "online", "offline":
+ return status, nil
+ default:
+ return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
+ }
+}
+
+func parseV2ProxyTypeFilter(raw string) (string, error) {
+ proxyType := strings.ToLower(raw)
+ if proxyType == "" {
+ return "", nil
+ }
+ if slices.Contains(apiV2ProxyTypes, proxyType) {
+ return proxyType, nil
+ }
+ return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
+}
+
+func matchV2StatusFilter(online bool, filter string) bool {
+ switch filter {
+ case "", "all":
+ return true
+ case "online":
+ return online
+ case "offline":
+ return !online
+ default:
+ return true
+ }
+}
+
+func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
+ total := len(items)
+ return model.V2PageResp[T]{
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ Items: paginateV2Items(items, page, pageSize),
+ }
+}
+
+func paginateV2Items[T any](items []T, page, pageSize int) []T {
+ start := (page - 1) * pageSize
+ if start >= len(items) {
+ return []T{}
+ }
+ end := min(start+pageSize, len(items))
+ return items[start:end]
+}
+
+func queryValue(ctx *httppkg.Context, key string) (string, bool) {
+ values, ok := ctx.Req.URL.Query()[key]
+ if !ok {
+ return "", false
+ }
+ if len(values) == 0 {
+ return "", true
+ }
+ return values[0], true
+}
+
+func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
+ return containsV2Query(q,
+ item.Key,
+ item.User,
+ item.ClientID,
+ item.RunID,
+ item.Version,
+ item.WireProtocol,
+ item.Hostname,
+ item.ClientIP,
+ )
+}
+
+func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
+ values := []string{
+ item.Name,
+ item.Type,
+ item.User,
+ item.ClientID,
+ item.Status.State,
+ }
+
+ switch spec := item.Spec.(type) {
+ case *model.TCPOutConf:
+ values = append(values, strconv.Itoa(spec.RemotePort))
+ case *model.UDPOutConf:
+ values = append(values, strconv.Itoa(spec.RemotePort))
+ case *model.HTTPOutConf:
+ values = append(values, spec.CustomDomains...)
+ values = append(values, spec.SubDomain)
+ case *model.HTTPSOutConf:
+ values = append(values, spec.CustomDomains...)
+ values = append(values, spec.SubDomain)
+ case *model.TCPMuxOutConf:
+ values = append(values, spec.CustomDomains...)
+ values = append(values, spec.SubDomain)
+ }
+
+ return containsV2Query(q, values...)
+}
+
+func containsV2Query(q string, values ...string) bool {
+ for _, value := range values {
+ if strings.Contains(strings.ToLower(value), q) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
+ if proxyType != "" {
+ return mem.StatsCollector.GetProxiesByType(proxyType)
+ }
+
+ items := make([]*mem.ProxyStats, 0)
+ for _, t := range apiV2ProxyTypes {
+ items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
+ }
+ return items
+}
+
+func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
+ state := "offline"
+ var spec any
+ if c.pxyManager != nil {
+ if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
+ state = "online"
+ spec = getConfFromConfigurer(pxy.GetConfigurer())
+ }
+ }
+
+ return model.V2ProxyResp{
+ Name: ps.Name,
+ Type: ps.Type,
+ User: ps.User,
+ ClientID: ps.ClientID,
+ Spec: spec,
+ Status: model.V2ProxyStatusResp{
+ State: state,
+ TodayTrafficIn: ps.TodayTrafficIn,
+ TodayTrafficOut: ps.TodayTrafficOut,
+ CurConns: ps.CurConns,
+ LastStartTime: ps.LastStartTime,
+ LastCloseTime: ps.LastCloseTime,
+ },
+ }
+}
diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go
new file mode 100644
index 00000000..008012d8
--- /dev/null
+++ b/server/http/controller_v2_test.go
@@ -0,0 +1,374 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gorilla/mux"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/metrics/mem"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/server/http/model"
+ serverproxy "github.com/fatedier/frp/server/proxy"
+ "github.com/fatedier/frp/server/registry"
+)
+
+type v2EnvelopeForTest[T any] struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data T `json:"data"`
+}
+
+type fakeStatsCollector struct {
+ proxies map[string]*mem.ProxyStats
+}
+
+func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
+ return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
+}
+
+func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
+ items := make([]*mem.ProxyStats, 0)
+ for _, ps := range f.proxies {
+ if ps.Type == proxyType {
+ items = append(items, ps)
+ }
+ }
+ return items
+}
+
+func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
+ ps := f.proxies[proxyName]
+ if ps != nil && ps.Type == proxyType {
+ return ps
+ }
+ return nil
+}
+
+func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
+ return f.proxies[proxyName]
+}
+
+func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
+ return nil
+}
+
+func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
+ return 0, len(f.proxies)
+}
+
+func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+ pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
+ t.Fatalf("envelope mismatch: %#v", pageResp)
+ }
+ if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
+ t.Fatalf("page data mismatch: %#v", pageResp.Data)
+ }
+ if got := pageResp.Data.Items[0].User; got != "" {
+ t.Fatalf("first sorted user mismatch, want empty got %q", got)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
+ emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
+ t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
+ aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
+ t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?status=offline")
+ offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
+ t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
+ }
+}
+
+func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients?page=0")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
+ t.Fatalf("error envelope mismatch: %#v", errResp)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?pageSize=201")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+
+ resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+}
+
+func TestAPIV2ClientDetailEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients/alice.client-a")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+ detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
+ if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
+ t.Fatalf("client detail mismatch: %#v", detailResp.Data)
+ }
+}
+
+func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/proxies?type=invalid")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
+ t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
+ }
+
+ resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
+ proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
+ if proxyResp.Data.Total != 1 {
+ t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
+ }
+ proxyItem := proxyResp.Data.Items[0]
+ if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
+ t.Fatalf("proxy item mismatch: %#v", proxyItem)
+ }
+
+ resp = performRequest(router, "/api/v2/proxies/tcp-alice")
+ proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
+ if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
+ t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
+ userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
+ if userResp.Data.Total != 3 {
+ t.Fatalf("user total mismatch: %#v", userResp.Data)
+ }
+ for _, item := range userResp.Data.Items {
+ if item.ClientCount != 1 || item.ProxyCount != 1 {
+ t.Fatalf("user counts mismatch: %#v", item)
+ }
+ }
+}
+
+func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
+ tests := []struct {
+ name string
+ item model.V2ProxyResp
+ q string
+ want bool
+ }{
+ {
+ name: "tcp remote port",
+ item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
+ RemotePort: 6000,
+ }},
+ q: "6000",
+ want: true,
+ },
+ {
+ name: "udp remote port",
+ item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
+ RemotePort: 7000,
+ }},
+ q: "7000",
+ want: true,
+ },
+ {
+ name: "remote port does not match colon form",
+ item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
+ RemotePort: 6000,
+ }},
+ q: ":6000",
+ want: false,
+ },
+ {
+ name: "http custom domain",
+ item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
+ DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
+ }},
+ q: "app.example.com",
+ want: true,
+ },
+ {
+ name: "https subdomain",
+ item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
+ DomainConfig: v1.DomainConfig{SubDomain: "portal"},
+ }},
+ q: "portal",
+ want: true,
+ },
+ {
+ name: "subdomain does not match expanded host",
+ item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
+ DomainConfig: v1.DomainConfig{SubDomain: "portal"},
+ }},
+ q: "portal.example.com",
+ want: false,
+ },
+ {
+ name: "tcpmux custom domain",
+ item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
+ DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
+ }},
+ q: "mux.example.com",
+ want: true,
+ },
+ {
+ name: "nil spec does not match spec fields",
+ item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
+ q: "6000",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
+ t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestLegacyAPIResponsesRemainBare(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/clients")
+ var clients []model.ClientInfoResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
+ t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
+ }
+ if len(clients) != 3 {
+ t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
+ }
+
+ resp = performRequest(router, "/api/proxy/tcp")
+ var proxies model.GetProxyInfoResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
+ t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
+ }
+ if len(proxies.Proxies) != 2 {
+ t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
+ }
+ var envelope httppkg.V2Response
+ if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
+ t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
+ }
+}
+
+func newV2TestController(t *testing.T) *Controller {
+ t.Helper()
+
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ "tcp-empty": {
+ Name: "tcp-empty",
+ Type: "tcp",
+ User: "",
+ ClientID: "legacy-client",
+ TodayTrafficIn: 10,
+ TodayTrafficOut: 20,
+ CurConns: 1,
+ },
+ "tcp-alice": {
+ Name: "tcp-alice",
+ Type: "tcp",
+ User: "alice",
+ ClientID: "client-a",
+ TodayTrafficIn: 30,
+ TodayTrafficOut: 40,
+ },
+ "udp-bob": {
+ Name: "udp-bob",
+ Type: "udp",
+ User: "bob",
+ ClientID: "client-b",
+ },
+ },
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ clientRegistry := registry.NewClientRegistry()
+ clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
+ clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
+ clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
+ clientRegistry.MarkOfflineByRunID("run-b")
+
+ return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), nil)
+}
+
+func newV2TestRouter(controller *Controller) *mux.Router {
+ router := mux.NewRouter()
+ router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
+ router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
+ return router
+}
+
+func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ resp := httptest.NewRecorder()
+ handler.ServeHTTP(resp, req)
+ return resp
+}
+
+func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
+ t.Helper()
+
+ var out T
+ if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
+ }
+ return out
+}
diff --git a/server/api/types.go b/server/http/model/types.go
similarity index 97%
rename from server/api/types.go
rename to server/http/model/types.go
index b91422be..010c5079 100644
--- a/server/api/types.go
+++ b/server/http/model/types.go
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package model
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -45,6 +45,8 @@ type ClientInfoResp struct {
User string `json:"user"`
ClientID string `json:"clientID"`
RunID string `json:"runID"`
+ Version string `json:"version,omitempty"`
+ WireProtocol string `json:"wireProtocol,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"clientIP,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
@@ -100,7 +102,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 +120,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/http/model/v2.go b/server/http/model/v2.go
new file mode 100644
index 00000000..edd3212d
--- /dev/null
+++ b/server/http/model/v2.go
@@ -0,0 +1,46 @@
+// 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 model
+
+type V2PageResp[T any] struct {
+ Total int `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"pageSize"`
+ Items []T `json:"items"`
+}
+
+type V2UserResp struct {
+ User string `json:"user"`
+ ClientCount int `json:"clientCount"`
+ ProxyCount int `json:"proxyCount"`
+}
+
+type V2ProxyResp struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ User string `json:"user"`
+ ClientID string `json:"clientID"`
+ Spec any `json:"spec"`
+ Status V2ProxyStatusResp `json:"status"`
+}
+
+type V2ProxyStatusResp struct {
+ State string `json:"phase"`
+ TodayTrafficIn int64 `json:"todayTrafficIn"`
+ TodayTrafficOut int64 `json:"todayTrafficOut"`
+ CurConns int64 `json:"curConns"`
+ LastStartTime string `json:"lastStartTime"`
+ LastCloseTime string `json:"lastCloseTime"`
+}
diff --git a/server/ports/ports.go b/server/ports/ports.go
index 5a73fbc5..653715ea 100644
--- a/server/ports/ports.go
+++ b/server/ports/ports.go
@@ -7,6 +7,8 @@ import (
"sync"
"time"
+ "k8s.io/utils/clock"
+
"github.com/fatedier/frp/pkg/config/types"
)
@@ -38,16 +40,25 @@ type Manager struct {
bindAddr string
netType string
+ clock clock.WithTicker
mu sync.Mutex
}
func NewManager(netType string, bindAddr string, allowPorts []types.PortsRange) *Manager {
+ return newManagerWithClock(netType, bindAddr, allowPorts, clock.RealClock{})
+}
+
+func newManagerWithClock(netType string, bindAddr string, allowPorts []types.PortsRange, clk clock.WithTicker) *Manager {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
pm := &Manager{
reservedPorts: make(map[string]*PortCtx),
usedPorts: make(map[int]*PortCtx),
freePorts: make(map[int]struct{}),
bindAddr: bindAddr,
netType: netType,
+ clock: clk,
}
if len(allowPorts) > 0 {
for _, pair := range allowPorts {
@@ -72,7 +83,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
portCtx := &PortCtx{
ProxyName: name,
Closed: false,
- UpdateTime: time.Now(),
+ UpdateTime: pm.clock.Now(),
}
var ok bool
@@ -90,9 +101,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
if ctx, ok := pm.reservedPorts[name]; ok {
if pm.isPortAvailable(ctx.Port) {
realPort = ctx.Port
- pm.usedPorts[realPort] = portCtx
- pm.reservedPorts[name] = portCtx
- delete(pm.freePorts, realPort)
+ pm.markPortAcquiredLocked(name, realPort, portCtx)
return
}
}
@@ -109,9 +118,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
}
if pm.isPortAvailable(k) {
realPort = k
- pm.usedPorts[realPort] = portCtx
- pm.reservedPorts[name] = portCtx
- delete(pm.freePorts, realPort)
+ pm.markPortAcquiredLocked(name, realPort, portCtx)
break
}
}
@@ -123,9 +130,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
if _, ok = pm.freePorts[port]; ok {
if pm.isPortAvailable(port) {
realPort = port
- pm.usedPorts[realPort] = portCtx
- pm.reservedPorts[name] = portCtx
- delete(pm.freePorts, realPort)
+ pm.markPortAcquiredLocked(name, realPort, portCtx)
} else {
err = ErrPortUnAvailable
}
@@ -140,6 +145,13 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
return
}
+// markPortAcquiredLocked records a successful acquisition. pm.mu must be held.
+func (pm *Manager) markPortAcquiredLocked(name string, port int, portCtx *PortCtx) {
+ pm.usedPorts[port] = portCtx
+ pm.reservedPorts[name] = portCtx
+ delete(pm.freePorts, port)
+}
+
func (pm *Manager) isPortAvailable(port int) bool {
if pm.netType == "udp" {
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pm.bindAddr, strconv.Itoa(port)))
@@ -169,20 +181,36 @@ func (pm *Manager) Release(port int) {
pm.freePorts[port] = struct{}{}
delete(pm.usedPorts, port)
ctx.Closed = true
- ctx.UpdateTime = time.Now()
+ ctx.UpdateTime = pm.clock.Now()
}
}
// Release reserved port if it isn't used in last 24 hours.
func (pm *Manager) cleanReservedPortsWorker() {
+ pm.cleanReservedPortsWorkerUntil(nil)
+}
+
+func (pm *Manager) cleanReservedPortsWorkerUntil(stopCh <-chan struct{}) {
+ ticker := pm.clock.NewTicker(CleanReservedPortsInterval)
+ defer ticker.Stop()
+
for {
- time.Sleep(CleanReservedPortsInterval)
- pm.mu.Lock()
- for name, ctx := range pm.reservedPorts {
- if ctx.Closed && time.Since(ctx.UpdateTime) > MaxPortReservedDuration {
- delete(pm.reservedPorts, name)
- }
+ select {
+ case <-ticker.C():
+ pm.cleanReservedPortsOnce()
+ case <-stopCh:
+ return
+ }
+ }
+}
+
+func (pm *Manager) cleanReservedPortsOnce() {
+ pm.mu.Lock()
+ defer pm.mu.Unlock()
+
+ for name, ctx := range pm.reservedPorts {
+ if ctx.Closed && pm.clock.Since(ctx.UpdateTime) > MaxPortReservedDuration {
+ delete(pm.reservedPorts, name)
}
- pm.mu.Unlock()
}
}
diff --git a/server/ports/ports_test.go b/server/ports/ports_test.go
new file mode 100644
index 00000000..cd560b25
--- /dev/null
+++ b/server/ports/ports_test.go
@@ -0,0 +1,72 @@
+package ports
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ clocktesting "k8s.io/utils/clock/testing"
+
+ "github.com/fatedier/frp/pkg/config/types"
+)
+
+func TestManagerUsesClockForPortTimestamps(t *testing.T) {
+ require := require.New(t)
+
+ port := freeTCPPort(t)
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ pm := newManagerWithClock("tcp", "127.0.0.1", []types.PortsRange{{Single: port}}, clk)
+
+ realPort, err := pm.Acquire("proxy", port)
+ require.NoError(err)
+ require.Equal(port, realPort)
+ require.Equal(start, pm.usedPorts[port].UpdateTime)
+
+ releasedAt := start.Add(time.Minute)
+ clk.SetTime(releasedAt)
+ pm.Release(port)
+
+ require.Equal(releasedAt, pm.reservedPorts["proxy"].UpdateTime)
+}
+
+func TestManagerCleanReservedPortsWorkerUsesClockTicker(t *testing.T) {
+ require := require.New(t)
+
+ port := freeTCPPort(t)
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ pm := newManagerWithClock("tcp", "127.0.0.1", []types.PortsRange{{Single: port}}, clk)
+
+ realPort, err := pm.Acquire("proxy", port)
+ require.NoError(err)
+ require.Equal(port, realPort)
+ pm.Release(port)
+ require.True(pm.hasReservedPort("proxy"))
+
+ require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
+ clk.Step(MaxPortReservedDuration + CleanReservedPortsInterval + time.Minute)
+
+ require.Eventually(func() bool {
+ return !pm.hasReservedPort("proxy")
+ }, time.Second, time.Millisecond)
+}
+
+func (pm *Manager) hasReservedPort(name string) bool {
+ pm.mu.Lock()
+ defer pm.mu.Unlock()
+
+ _, ok := pm.reservedPorts[name]
+ return ok
+}
+
+func freeTCPPort(t *testing.T) int {
+ t.Helper()
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ defer listener.Close()
+
+ return listener.Addr().(*net.TCPAddr).Port
+}
diff --git a/server/proxy/http.go b/server/proxy/http.go
index 761fa805..86889841 100644
--- a/server/proxy/http.go
+++ b/server/proxy/http.go
@@ -31,7 +31,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.HTTPProxyConfig{}), NewHTTPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.HTTPProxyConfig](), NewHTTPProxy)
}
type HTTPProxy struct {
@@ -75,16 +75,13 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
}
}()
- addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+ addrs := make([]string, 0)
+ for _, domain := range domains {
routeConfig.Domain = domain
for _, location := range locations {
routeConfig.Location = location
-
tmpRouteConfig := routeConfig
// handle group
@@ -93,12 +90,10 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
if err != nil {
return
}
-
pxy.closeFuncs = append(pxy.closeFuncs, func() {
pxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig)
})
} else {
- // no group
err = pxy.rc.HTTPReverseProxy.Register(routeConfig)
if err != nil {
return
@@ -112,39 +107,6 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
routeConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)
}
}
-
- if pxy.cfg.SubDomain != "" {
- routeConfig.Domain = pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
- for _, location := range locations {
- routeConfig.Location = location
-
- tmpRouteConfig := routeConfig
-
- // handle group
- if pxy.cfg.LoadBalancer.Group != "" {
- err = pxy.rc.HTTPGroupCtl.Register(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, routeConfig)
- if err != nil {
- return
- }
-
- pxy.closeFuncs = append(pxy.closeFuncs, func() {
- pxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig)
- })
- } else {
- err = pxy.rc.HTTPReverseProxy.Register(routeConfig)
- if err != nil {
- return
- }
- pxy.closeFuncs = append(pxy.closeFuncs, func() {
- pxy.rc.HTTPReverseProxy.UnRegister(tmpRouteConfig)
- })
- }
- addrs = append(addrs, util.CanonicalAddr(tmpRouteConfig.Domain, pxy.serverCfg.VhostHTTPPort))
-
- xl.Infof("http proxy listen for host [%s] location [%s] group [%s], routeByHTTPUser [%s]",
- routeConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)
- }
- }
remoteAddr = strings.Join(addrs, ",")
return
}
@@ -168,6 +130,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
+ tmpConn.Close()
return
}
}
diff --git a/server/proxy/https.go b/server/proxy/https.go
index f137ea7a..e2efc736 100644
--- a/server/proxy/https.go
+++ b/server/proxy/https.go
@@ -25,7 +25,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.HTTPSProxyConfig{}), NewHTTPSProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.HTTPSProxyConfig](), NewHTTPSProxy)
}
type HTTPSProxy struct {
@@ -53,23 +53,10 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
pxy.Close()
}
}()
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+
addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
-
- l, err := pxy.listenForDomain(routeConfig, domain)
- if err != nil {
- return "", err
- }
- pxy.listeners = append(pxy.listeners, l)
- addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort))
- xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
- }
-
- if pxy.cfg.SubDomain != "" {
- domain := pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
+ for _, domain := range domains {
l, err := pxy.listenForDomain(routeConfig, domain)
if err != nil {
return "", err
diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go
index f3a30cc5..4e6ceb7a 100644
--- a/server/proxy/proxy.go
+++ b/server/proxy/proxy.go
@@ -16,6 +16,7 @@ package proxy
import (
"context"
+ "errors"
"fmt"
"io"
"net"
@@ -31,6 +32,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
plugin "github.com/fatedier/frp/pkg/plugin/server"
+ "github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -44,7 +46,26 @@ func RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy) P
proxyFactoryRegistry[proxyConfType] = factory
}
-type GetWorkConnFn func() (net.Conn, error)
+type WorkConn struct {
+ conn *msg.Conn
+}
+
+func NewWorkConn(conn *msg.Conn) *WorkConn {
+ return &WorkConn{conn: conn}
+}
+
+func (c *WorkConn) Start(m *msg.StartWorkConn) (net.Conn, error) {
+ if err := c.conn.WriteMsg(m); err != nil {
+ return nil, err
+ }
+ return c.conn, nil
+}
+
+func (c *WorkConn) Close() error {
+ return c.conn.Close()
+}
+
+type GetWorkConnFn func() (*WorkConn, error)
type Proxy interface {
Context() context.Context
@@ -73,6 +94,7 @@ type BaseProxy struct {
userInfo plugin.UserInfo
loginMsg *msg.Login
configurer v1.ProxyConfigurer
+ wireProtocol string
mu sync.RWMutex
xl *xlog.Logger
@@ -125,13 +147,13 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
xl := xlog.FromContextSafe(pxy.ctx)
// try all connections from the pool
for i := 0; i < pxy.poolCount+1; i++ {
- if workConn, err = pxy.getWorkConnFn(); err != nil {
+ var pxyWorkConn *WorkConn
+ if pxyWorkConn, err = pxy.getWorkConnFn(); err != nil {
xl.Warnf("failed to get work connection: %v", err)
return
}
- xl.Debugf("get a new work connection: [%s]", workConn.RemoteAddr().String())
+ xl.Debugf("get a new work connection: [%s]", pxyWorkConn.conn.RemoteAddr().String())
xl.Spawn().AppendPrefix(pxy.GetName())
- workConn = netpkg.NewContextConn(pxy.ctx, workConn)
var (
srcAddr string
@@ -150,7 +172,7 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
dstAddr, dstPortStr, _ = net.SplitHostPort(dst.String())
dstPort, _ = strconv.ParseUint(dstPortStr, 10, 16)
}
- err := msg.WriteMsg(workConn, &msg.StartWorkConn{
+ workConn, err = pxyWorkConn.Start(&msg.StartWorkConn{
ProxyName: pxy.GetName(),
SrcAddr: srcAddr,
SrcPort: uint16(srcPort),
@@ -160,8 +182,10 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
})
if err != nil {
xl.Warnf("failed to send message to work connection from pool: %v, times: %d", err, i)
- workConn.Close()
+ pxyWorkConn.Close()
+ workConn = nil
} else {
+ workConn = netpkg.NewContextConn(pxy.ctx, workConn)
break
}
}
@@ -173,6 +197,36 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
return
}
+// startVisitorListener sets up a VisitorManager listener for visitor-based proxies (STCP, SUDP).
+func (pxy *BaseProxy) startVisitorListener(secretKey string, allowUsers []string, proxyType string) error {
+ // if allowUsers is empty, only allow same user from proxy
+ if len(allowUsers) == 0 {
+ allowUsers = []string{pxy.GetUserInfo().User}
+ }
+ listener, err := pxy.rc.VisitorManager.Listen(pxy.GetName(), secretKey, allowUsers)
+ if err != nil {
+ return err
+ }
+ pxy.listeners = append(pxy.listeners, listener)
+ pxy.xl.Infof("%s proxy custom listen success", proxyType)
+ pxy.startCommonTCPListenersHandler()
+ return nil
+}
+
+// buildDomains constructs a list of domains from custom domains and subdomain configuration.
+func (pxy *BaseProxy) buildDomains(customDomains []string, subDomain string) []string {
+ domains := make([]string, 0, len(customDomains)+1)
+ for _, d := range customDomains {
+ if d != "" {
+ domains = append(domains, d)
+ }
+ }
+ if subDomain != "" {
+ domains = append(domains, subDomain+"."+pxy.serverCfg.SubDomainHost)
+ }
+ return domains
+}
+
// startCommonTCPListenersHandler start a goroutine handler for each listener.
func (pxy *BaseProxy) startCommonTCPListenersHandler() {
xl := xlog.FromContextSafe(pxy.ctx)
@@ -273,11 +327,147 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
userConn,
)
metrics.Server.OpenConnection(name, proxyType)
- _, _, _ = libio.Join(local, userConn)
+ // Traffic is counted incrementally via the counting wrappers above, so the
+ // byte totals returned by joinUserConnection are intentionally discarded here.
+ _, _, _ = pxy.joinUserConnection(local, userConn, proxyType, xl)
metrics.Server.CloseConnection(name, proxyType)
xl.Debugf("join connections closed")
}
+func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
+ visitorWireProtocol := wireProtocolFromConn(userConn)
+ if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
+ xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
+ normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
+ return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
+ }
+ return libio.Join(local, userConn)
+}
+
+type wireProtocolGetter interface {
+ WireProtocol() string
+}
+
+func wireProtocolFromConn(conn net.Conn) string {
+ if getter, ok := conn.(wireProtocolGetter); ok {
+ return getter.WireProtocol()
+ }
+ return ""
+}
+
+func isMixedWireProtocol(left, right string) bool {
+ return normalizeWireProtocol(left) != normalizeWireProtocol(right)
+}
+
+func normalizeWireProtocol(wireProtocol string) string {
+ if wireProtocol == wire.ProtocolV2 {
+ return wire.ProtocolV2
+ }
+ return wire.ProtocolV1
+}
+
+func joinSUDPMessageBridge(
+ proxyConn io.ReadWriteCloser,
+ visitorConn io.ReadWriteCloser,
+ proxyWireProtocol string,
+ visitorWireProtocol string,
+ xl *xlog.Logger,
+) (inCount int64, outCount int64, errs []error) {
+ // The mixed bridge decodes and re-encodes messages, so raw framed byte counts
+ // are not available. Count UDP payload bytes and ignore heartbeat traffic.
+ proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
+ visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
+
+ var (
+ once sync.Once
+ wait sync.WaitGroup
+ recordErrs = make([]error, 2)
+ )
+ closeBoth := func() {
+ _ = proxyConn.Close()
+ _ = visitorConn.Close()
+ }
+
+ wait.Add(2)
+ go func() {
+ defer wait.Done()
+ defer once.Do(closeBoth)
+ recordErrs[0] = bridgeSUDPProxyToVisitor(proxyRW, visitorRW, &outCount, xl)
+ }()
+ go func() {
+ defer wait.Done()
+ defer once.Do(closeBoth)
+ recordErrs[1] = bridgeSUDPVisitorToProxy(visitorRW, proxyRW, &inCount, xl)
+ }()
+ wait.Wait()
+
+ for _, err := range recordErrs {
+ if err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return
+}
+
+func bridgeSUDPProxyToVisitor(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
+ for {
+ rawMsg, err := from.ReadMsg()
+ if err != nil {
+ return normalizeSUDPBridgeError(err)
+ }
+
+ switch m := rawMsg.(type) {
+ case *msg.UDPPacket:
+ if err := to.WriteMsg(m); err != nil {
+ return normalizeSUDPBridgeError(err)
+ }
+ *count += int64(len(m.Content))
+ case *msg.Ping:
+ traceSUDPBridge(xl, "bridge SUDP ping from proxy to visitor")
+ if err := to.WriteMsg(m); err != nil {
+ return normalizeSUDPBridgeError(err)
+ }
+ default:
+ return fmt.Errorf("unexpected SUDP proxy message %T", rawMsg)
+ }
+ }
+}
+
+func bridgeSUDPVisitorToProxy(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
+ for {
+ rawMsg, err := from.ReadMsg()
+ if err != nil {
+ return normalizeSUDPBridgeError(err)
+ }
+
+ switch m := rawMsg.(type) {
+ case *msg.UDPPacket:
+ if err := to.WriteMsg(m); err != nil {
+ return normalizeSUDPBridgeError(err)
+ }
+ *count += int64(len(m.Content))
+ case *msg.Ping:
+ traceSUDPBridge(xl, "drop SUDP ping from visitor to proxy")
+ continue
+ default:
+ return fmt.Errorf("unexpected SUDP visitor message %T", rawMsg)
+ }
+ }
+}
+
+func normalizeSUDPBridgeError(err error) error {
+ if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ return err
+}
+
+func traceSUDPBridge(xl *xlog.Logger, format string, args ...any) {
+ if xl != nil {
+ xl.Tracef(format, args...)
+ }
+}
+
type Options struct {
UserInfo plugin.UserInfo
LoginMsg *msg.Login
@@ -287,6 +477,7 @@ type Options struct {
Configurer v1.ProxyConfigurer
ServerCfg *v1.ServerConfig
EncryptionKey []byte
+ WireProtocol string
}
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -313,6 +504,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
userInfo: options.UserInfo,
loginMsg: options.LoginMsg,
configurer: configurer,
+ wireProtocol: options.WireProtocol,
}
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
diff --git a/server/proxy/proxy_test.go b/server/proxy/proxy_test.go
new file mode 100644
index 00000000..381c743e
--- /dev/null
+++ b/server/proxy/proxy_test.go
@@ -0,0 +1,109 @@
+// 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 proxy
+
+import (
+ "context"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+
+ serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
+ clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
+ workConn := NewWorkConn(serverMsgConn)
+
+ in := &msg.StartWorkConn{ProxyName: "tcp", SrcAddr: "127.0.0.1", SrcPort: 1234}
+ type startResult struct {
+ conn net.Conn
+ err error
+ }
+ resultCh := make(chan startResult, 1)
+ go func() {
+ conn, err := workConn.Start(in)
+ resultCh <- startResult{conn: conn, err: err}
+ }()
+
+ out, err := clientMsgConn.ReadMsg()
+ require.NoError(t, err)
+ require.Equal(t, in, out)
+
+ result := <-resultCh
+ require.NoError(t, result.err)
+ require.Same(t, serverMsgConn, result.conn)
+}
+
+func TestGetWorkConnFromPoolStartWorkConnUnchangedForUDPWireV2(t *testing.T) {
+ startMsg := getStartWorkConnFromPool(t, &v1.UDPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{Name: "udp", Type: string(v1.ProxyTypeUDP)},
+ }, wire.ProtocolV2)
+
+ require.Equal(t, msg.StartWorkConn{ProxyName: "udp"}, startMsg)
+}
+
+func TestGetWorkConnFromPoolLeavesRawTCPPayloadUnframed(t *testing.T) {
+ startMsg := getStartWorkConnFromPool(t, &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{Name: "tcp", Type: string(v1.ProxyTypeTCP)},
+ }, wire.ProtocolV2)
+
+ require.Equal(t, msg.StartWorkConn{ProxyName: "tcp"}, startMsg)
+}
+
+func getStartWorkConnFromPool(t *testing.T, cfg v1.ProxyConfigurer, wireProtocol string) msg.StartWorkConn {
+ t.Helper()
+
+ client, server := net.Pipe()
+ t.Cleanup(func() {
+ client.Close()
+ server.Close()
+ })
+
+ serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
+ clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
+ pxy := &BaseProxy{
+ name: cfg.GetBaseConfig().Name,
+ configurer: cfg,
+ poolCount: 0,
+ ctx: context.Background(),
+ wireProtocol: wireProtocol,
+ getWorkConnFn: func() (*WorkConn, error) {
+ return NewWorkConn(serverMsgConn), nil
+ },
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ conn, err := pxy.GetWorkConnFromPool(nil, nil)
+ if conn != nil {
+ conn.Close()
+ }
+ errCh <- err
+ }()
+
+ var startMsg msg.StartWorkConn
+ require.NoError(t, clientMsgConn.ReadMsgInto(&startMsg))
+ require.NoError(t, <-errCh)
+ return startMsg
+}
diff --git a/server/proxy/stcp.go b/server/proxy/stcp.go
index 06b1b17f..f8c17a46 100644
--- a/server/proxy/stcp.go
+++ b/server/proxy/stcp.go
@@ -21,7 +21,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.STCPProxyConfig{}), NewSTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.STCPProxyConfig](), NewSTCPProxy)
}
type STCPProxy struct {
@@ -41,21 +41,7 @@ func NewSTCPProxy(baseProxy *BaseProxy) Proxy {
}
func (pxy *STCPProxy) Run() (remoteAddr string, err error) {
- xl := pxy.xl
- allowUsers := pxy.cfg.AllowUsers
- // if allowUsers is empty, only allow same user from proxy
- if len(allowUsers) == 0 {
- allowUsers = []string{pxy.GetUserInfo().User}
- }
- listener, errRet := pxy.rc.VisitorManager.Listen(pxy.GetName(), pxy.cfg.Secretkey, allowUsers)
- if errRet != nil {
- err = errRet
- return
- }
- pxy.listeners = append(pxy.listeners, listener)
- xl.Infof("stcp proxy custom listen success")
-
- pxy.startCommonTCPListenersHandler()
+ err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "stcp")
return
}
diff --git a/server/proxy/sudp.go b/server/proxy/sudp.go
index f37fb423..d409fed6 100644
--- a/server/proxy/sudp.go
+++ b/server/proxy/sudp.go
@@ -21,7 +21,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.SUDPProxyConfig{}), NewSUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)
}
type SUDPProxy struct {
@@ -41,21 +41,7 @@ func NewSUDPProxy(baseProxy *BaseProxy) Proxy {
}
func (pxy *SUDPProxy) Run() (remoteAddr string, err error) {
- xl := pxy.xl
- allowUsers := pxy.cfg.AllowUsers
- // if allowUsers is empty, only allow same user from proxy
- if len(allowUsers) == 0 {
- allowUsers = []string{pxy.GetUserInfo().User}
- }
- listener, errRet := pxy.rc.VisitorManager.Listen(pxy.GetName(), pxy.cfg.Secretkey, allowUsers)
- if errRet != nil {
- err = errRet
- return
- }
- pxy.listeners = append(pxy.listeners, listener)
- xl.Infof("sudp proxy custom listen success")
-
- pxy.startCommonTCPListenersHandler()
+ err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "sudp")
return
}
diff --git a/server/proxy/sudp_test.go b/server/proxy/sudp_test.go
new file mode 100644
index 00000000..e2986eed
--- /dev/null
+++ b/server/proxy/sudp_test.go
@@ -0,0 +1,141 @@
+// 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 proxy
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
+ var in, out bytes.Buffer
+ writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
+
+ var count int64
+ err := bridgeSUDPProxyToVisitor(
+ msg.NewReadWriter(&in, wire.ProtocolV1),
+ msg.NewReadWriter(&out, wire.ProtocolV2),
+ &count,
+ nil,
+ )
+ require.NoError(t, err)
+ require.Equal(t, int64(len("proxy-to-visitor")), count)
+
+ frame, err := wire.NewConn(&out).ReadFrame()
+ require.NoError(t, err)
+ require.Equal(t, wire.FrameTypeMessage, frame.Type)
+ require.GreaterOrEqual(t, len(frame.Payload), 2)
+ require.Equal(t, msg.V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
+
+ var got msg.UDPPacket
+ require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &got))
+ require.Equal(t, []byte("proxy-to-visitor"), got.Content)
+}
+
+func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
+ var in, out bytes.Buffer
+ writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
+
+ var count int64
+ err := bridgeSUDPVisitorToProxy(
+ msg.NewReadWriter(&in, wire.ProtocolV2),
+ msg.NewReadWriter(&out, wire.ProtocolV1),
+ &count,
+ nil,
+ )
+ require.NoError(t, err)
+ require.Equal(t, int64(len("visitor-to-proxy")), count)
+
+ reader := bufio.NewReader(&out)
+ typeByte, err := reader.ReadByte()
+ require.NoError(t, err)
+ require.Equal(t, msg.TypeUDPPacket, typeByte)
+ require.NoError(t, reader.UnreadByte())
+
+ var got msg.UDPPacket
+ require.NoError(t, msg.ReadMsgInto(reader, &got))
+ require.Equal(t, []byte("visitor-to-proxy"), got.Content)
+}
+
+func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
+ var in, out bytes.Buffer
+ writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
+
+ var count int64
+ err := bridgeSUDPProxyToVisitor(
+ msg.NewReadWriter(&in, wire.ProtocolV1),
+ msg.NewReadWriter(&out, wire.ProtocolV2),
+ &count,
+ nil,
+ )
+ require.NoError(t, err)
+ require.Zero(t, count)
+
+ rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
+ require.NoError(t, err)
+ require.IsType(t, &msg.Ping{}, rawMsg)
+}
+
+func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
+ var in, out bytes.Buffer
+ writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
+
+ var count int64
+ err := bridgeSUDPVisitorToProxy(
+ msg.NewReadWriter(&in, wire.ProtocolV2),
+ msg.NewReadWriter(&out, wire.ProtocolV1),
+ &count,
+ nil,
+ )
+ require.NoError(t, err)
+ require.Zero(t, count)
+ require.Empty(t, out.Bytes())
+}
+
+func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
+ var in, out bytes.Buffer
+ writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
+
+ var count int64
+ err := bridgeSUDPVisitorToProxy(
+ msg.NewReadWriter(&in, wire.ProtocolV2),
+ msg.NewReadWriter(&out, wire.ProtocolV1),
+ &count,
+ nil,
+ )
+ require.ErrorContains(t, err, "unexpected SUDP visitor message *msg.Pong")
+ require.Zero(t, count)
+ require.Empty(t, out.Bytes())
+}
+
+func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
+ require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
+ require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
+ require.True(t, isMixedWireProtocol("", wire.ProtocolV2))
+ require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
+}
+
+func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
+ t.Helper()
+
+ require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
+}
diff --git a/server/proxy/tcp.go b/server/proxy/tcp.go
index a6eae3a9..c305e779 100644
--- a/server/proxy/tcp.go
+++ b/server/proxy/tcp.go
@@ -24,7 +24,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.TCPProxyConfig{}), NewTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.TCPProxyConfig](), NewTCPProxy)
}
type TCPProxy struct {
diff --git a/server/proxy/tcpmux.go b/server/proxy/tcpmux.go
index 6e95b66b..0c421c8e 100644
--- a/server/proxy/tcpmux.go
+++ b/server/proxy/tcpmux.go
@@ -26,7 +26,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.TCPMuxProxyConfig{}), NewTCPMuxProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.TCPMuxProxyConfig](), NewTCPMuxProxy)
}
type TCPMuxProxy struct {
@@ -72,26 +72,16 @@ func (pxy *TCPMuxProxy) httpConnectListen(
}
func (pxy *TCPMuxProxy) httpConnectRun() (remoteAddr string, err error) {
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+
addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
-
+ for _, domain := range domains {
addrs, err = pxy.httpConnectListen(domain, pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs)
if err != nil {
return "", err
}
}
- if pxy.cfg.SubDomain != "" {
- addrs, err = pxy.httpConnectListen(pxy.cfg.SubDomain+"."+pxy.serverCfg.SubDomainHost,
- pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs)
- if err != nil {
- return "", err
- }
- }
-
pxy.startCommonTCPListenersHandler()
remoteAddr = strings.Join(addrs, ",")
return remoteAddr, err
diff --git a/server/proxy/udp.go b/server/proxy/udp.go
index 3751dc9b..609d9c42 100644
--- a/server/proxy/udp.go
+++ b/server/proxy/udp.go
@@ -35,7 +35,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.UDPProxyConfig{}), NewUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)
}
type UDPProxy struct {
@@ -108,7 +108,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
pxy.checkCloseCh = make(chan int)
// read message from workConn, if it returns any error, notify proxy to start a new workConn
- workConnReaderFn := func(conn net.Conn) {
+ workConnReaderFn := func(payloadConn *msg.Conn) {
for {
var (
rawMsg msg.Message
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
)
xl.Tracef("loop waiting message from udp workConn")
// client will send heartbeat in workConn for keeping alive
- _ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
- if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
+ _ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
+ if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
- _ = conn.Close()
+ _ = payloadConn.Close()
// notify proxy to start a new work connection
// ignore error here, it means the proxy is closed
_ = errors.PanicToError(func() {
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
})
return
}
- if err := conn.SetReadDeadline(time.Time{}); err != nil {
+ if err := payloadConn.SetReadDeadline(time.Time{}); err != nil {
xl.Warnf("set read deadline error: %v", err)
}
switch m := rawMsg.(type) {
@@ -136,7 +136,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
continue
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
- xl.Tracef("get udp message from workConn: %s", m.Content)
+ xl.Tracef("get udp message from workConn, len: %d", len(m.Content))
pxy.readCh <- m
metrics.Server.AddTrafficOut(
pxy.GetName(),
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
int64(len(m.Content)),
)
}); errRet != nil {
- conn.Close()
+ _ = payloadConn.Close()
xl.Infof("reader goroutine for udp work connection closed")
return
}
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
// send message to workConn
- workConnSenderFn := func(conn net.Conn, ctx context.Context) {
+ workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
var errRet error
for {
select {
@@ -162,12 +162,12 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
xl.Infof("sender goroutine for udp work connection closed")
return
}
- if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
+ if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
- conn.Close()
+ _ = payloadConn.Close()
return
}
- xl.Tracef("send message to udp workConn: %s", udpMsg.Content)
+ xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
metrics.Server.AddTrafficIn(
pxy.GetName(),
pxy.GetConfigurer().GetBaseConfig().Type,
@@ -223,9 +223,11 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
+ // Plain UDP payload follows the negotiated wire protocol for message framing.
+ payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
ctx, cancel := context.WithCancel(context.Background())
- go workConnReaderFn(pxy.workConn)
- go workConnSenderFn(pxy.workConn, ctx)
+ go workConnReaderFn(payloadConn)
+ go workConnSenderFn(payloadConn, ctx)
_, ok := <-pxy.checkCloseCh
cancel()
if !ok {
diff --git a/server/proxy/xtcp.go b/server/proxy/xtcp.go
index 1ccf331c..a6488d42 100644
--- a/server/proxy/xtcp.go
+++ b/server/proxy/xtcp.go
@@ -16,6 +16,7 @@ package proxy
import (
"fmt"
+ "net"
"reflect"
"sync"
@@ -24,7 +25,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.XTCPProxyConfig{}), NewXTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)
}
type XTCPProxy struct {
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
if errRet != nil {
continue
}
- m := &msg.NatHoleSid{
- Sid: sid,
- }
- errRet = msg.WriteMsg(workConn, m)
+ errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
if errRet != nil {
xl.Warnf("write nat hole sid package error, %v", errRet)
}
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
return
}
+func writeNatHoleSid(workConn net.Conn, wireProtocol string, sid string) error {
+ workMsgConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, wireProtocol))
+ return workMsgConn.WriteMsg(&msg.NatHoleSid{
+ Sid: sid,
+ })
+}
+
func (pxy *XTCPProxy) Close() {
pxy.closeOnce.Do(func() {
pxy.BaseProxy.Close()
diff --git a/server/proxy/xtcp_test.go b/server/proxy/xtcp_test.go
new file mode 100644
index 00000000..b2e6c1ad
--- /dev/null
+++ b/server/proxy/xtcp_test.go
@@ -0,0 +1,93 @@
+// 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 proxy
+
+import (
+ "bufio"
+ "encoding/binary"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestWriteNatHoleSidUsesWireV2MessageFrame(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+ setPipeDeadline(t, client, server)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- writeNatHoleSid(server, wire.ProtocolV2, "sid-v2")
+ }()
+
+ frame, err := wire.NewConn(client).ReadFrame()
+ require.NoError(t, err)
+ require.Equal(t, wire.FrameTypeMessage, frame.Type)
+ require.GreaterOrEqual(t, len(frame.Payload), 2)
+ require.Equal(t, msg.V2TypeNatHoleSid, binary.BigEndian.Uint16(frame.Payload[:2]))
+
+ var out msg.NatHoleSid
+ require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &out))
+ require.Equal(t, "sid-v2", out.Sid)
+ require.NoError(t, <-errCh)
+}
+
+func TestWriteNatHoleSidUsesLegacyCodecForWireV1AndDefault(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ wireProtocol string
+ }{
+ {name: "default", wireProtocol: ""},
+ {name: "v1", wireProtocol: wire.ProtocolV1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+ setPipeDeadline(t, client, server)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- writeNatHoleSid(server, tc.wireProtocol, "sid-legacy")
+ }()
+
+ reader := bufio.NewReader(client)
+ typeByte, err := reader.ReadByte()
+ require.NoError(t, err)
+ require.Equal(t, msg.TypeNatHoleSid, typeByte)
+ require.NoError(t, reader.UnreadByte())
+
+ var out msg.NatHoleSid
+ require.NoError(t, msg.ReadMsgInto(reader, &out))
+ require.Equal(t, "sid-legacy", out.Sid)
+ require.NoError(t, <-errCh)
+ })
+ }
+}
+
+func setPipeDeadline(t *testing.T, conns ...net.Conn) {
+ t.Helper()
+
+ deadline := time.Now().Add(time.Second)
+ for _, conn := range conns {
+ require.NoError(t, conn.SetDeadline(deadline))
+ }
+}
diff --git a/server/registry/registry.go b/server/registry/registry.go
index 24751bf6..21771bce 100644
--- a/server/registry/registry.go
+++ b/server/registry/registry.go
@@ -18,6 +18,8 @@ import (
"fmt"
"sync"
"time"
+
+ "k8s.io/utils/clock"
)
// ClientInfo captures metadata about a connected frpc instance.
@@ -28,6 +30,8 @@ type ClientInfo struct {
RunID string
Hostname string
IP string
+ Version string
+ WireProtocol string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
@@ -40,17 +44,26 @@ type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
+ clock clock.PassiveClock
}
func NewClientRegistry() *ClientRegistry {
+ return newClientRegistryWithClock(clock.RealClock{})
+}
+
+func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
+ if clk == nil {
+ clk = clock.RealClock{}
+ }
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
runIndex: make(map[string]string),
+ clock: clk,
}
}
// 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, wireProtocol string) (key string, conflict bool) {
if runID == "" {
return "", false
}
@@ -62,7 +75,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd
key = cr.composeClientKey(user, effectiveID)
enforceUnique := rawClientID != ""
- now := time.Now()
+ now := cr.clock.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
@@ -86,6 +99,8 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd
info.RunID = runID
info.Hostname = hostname
info.IP = remoteAddr
+ info.Version = version
+ info.WireProtocol = wireProtocol
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
@@ -112,7 +127,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
} else {
info.RunID = ""
info.Online = false
- now := time.Now()
+ now := cr.clock.Now()
info.DisconnectedAt = now
}
}
@@ -151,22 +166,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/registry/registry_test.go b/server/registry/registry_test.go
new file mode 100644
index 00000000..0ff083b8
--- /dev/null
+++ b/server/registry/registry_test.go
@@ -0,0 +1,74 @@
+// 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 registry
+
+import (
+ "testing"
+ "time"
+
+ clocktesting "k8s.io/utils/clock/testing"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+)
+
+func TestClientRegistryRegisterStoresWireProtocol(t *testing.T) {
+ registry := NewClientRegistry()
+ key, conflict := registry.Register("user", "client-id", "run-id", "host", "1.0.0", "127.0.0.1", wire.ProtocolV2)
+ if conflict {
+ t.Fatal("unexpected client conflict")
+ }
+
+ info, ok := registry.GetByKey(key)
+ if !ok {
+ t.Fatalf("client %q not found", key)
+ }
+ if info.WireProtocol != wire.ProtocolV2 {
+ t.Fatalf("wire protocol mismatch, want %q got %q", wire.ProtocolV2, info.WireProtocol)
+ }
+}
+
+func TestClientRegistryUsesClockForTimestamps(t *testing.T) {
+ start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
+ clk := clocktesting.NewFakeClock(start)
+ registry := newClientRegistryWithClock(clk)
+
+ key, conflict := registry.Register("user", "client-id", "run-id", "host", "1.0.0", "127.0.0.1", wire.ProtocolV2)
+ if conflict {
+ t.Fatal("unexpected client conflict")
+ }
+
+ info, ok := registry.GetByKey(key)
+ if !ok {
+ t.Fatalf("client %q not found", key)
+ }
+ if !info.FirstConnectedAt.Equal(start) {
+ t.Fatalf("first connected time mismatch, want %s got %s", start, info.FirstConnectedAt)
+ }
+ if !info.LastConnectedAt.Equal(start) {
+ t.Fatalf("last connected time mismatch, want %s got %s", start, info.LastConnectedAt)
+ }
+
+ disconnectedAt := start.Add(time.Minute)
+ clk.SetTime(disconnectedAt)
+ registry.MarkOfflineByRunID("run-id")
+
+ info, ok = registry.GetByKey(key)
+ if !ok {
+ t.Fatalf("client %q not found after disconnect", key)
+ }
+ if !info.DisconnectedAt.Equal(disconnectedAt) {
+ t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
+ }
+}
diff --git a/server/service.go b/server/service.go
index 35b9246b..f3f9e323 100644
--- a/server/service.go
+++ b/server/service.go
@@ -19,6 +19,7 @@ import (
"context"
"crypto/tls"
"fmt"
+ "io"
"net"
"net/http"
"os"
@@ -28,7 +29,6 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
- "github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -38,6 +38,7 @@ import (
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/nathole"
plugin "github.com/fatedier/frp/pkg/plugin/server"
+ "github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/ssh"
"github.com/fatedier/frp/pkg/transport"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -48,7 +49,6 @@ import (
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog"
- "github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics"
@@ -60,6 +60,7 @@ import (
const (
connReadTimeout time.Duration = 10 * time.Second
+ connWriteTimeout time.Duration = 5 * time.Second
vhostReadWriteTimeout time.Duration = 30 * time.Second
)
@@ -195,7 +196,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
if err != nil {
return nil, fmt.Errorf("create vhost tcpMuxer error, %v", err)
}
- log.Infof("tcpmux httpconnect multiplexer listen on %s, passthough: %v", address, cfg.TCPMuxPassthrough)
+ log.Infof("tcpmux httpconnect multiplexer listen on %s, passthrough: %v", address, cfg.TCPMuxPassthrough)
}
// Init all plugins
@@ -434,20 +435,15 @@ func (svr *Service) Close() error {
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
xl := xlog.FromContextSafe(ctx)
- var (
- rawMsg msg.Message
- err error
- )
-
- _ = conn.SetReadDeadline(time.Now().Add(connReadTimeout))
- if rawMsg, err = msg.ReadMsg(conn); err != nil {
- log.Tracef("failed to read message: %v", err)
+ acceptedConn, err := svr.acceptConnection(ctx, conn)
+ if err != nil {
+ log.Tracef("failed to accept frp connection: %v", err)
conn.Close()
return
}
- _ = conn.SetReadDeadline(time.Time{})
+ conn = acceptedConn.conn
- switch m := rawMsg.(type) {
+ switch m := acceptedConn.firstMsg.(type) {
case *msg.Login:
// server plugin hook
content := &plugin.LoginContent{
@@ -455,35 +451,72 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
ClientAddress: conn.RemoteAddr().String(),
}
retContent, err := svr.pluginManager.Login(content)
+ var ctl *Control
if err == nil {
m = &retContent.Login
- err = svr.RegisterControl(conn, m, internal)
+ controlConn := acceptedConn.conn
+ if !internal {
+ var controlRW io.ReadWriter
+ controlRW, err = acceptedConn.newControlReadWriter(conn, svr.auth.EncryptionKey())
+ if err == nil {
+ controlConn = acceptedConn.messageConnFor(controlRW)
+ }
+ }
+ if err == nil {
+ ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol)
+ }
}
- // If login failed, send error message there.
- // Otherwise send success message in control's work goroutine.
if err != nil {
xl.Warnf("register control error: %v", err)
- _ = msg.WriteMsg(conn, &msg.LoginResp{
+ if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
+ return acceptedConn.conn.WriteMsg(&msg.LoginResp{
+ Version: version.Full(),
+ Error: util.GenerateResponseErrorString("register control error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
+ })
+ }); writeErr != nil {
+ xl.Warnf("write login error response error: %v", writeErr)
+ }
+ conn.Close()
+ return
+ }
+ if err = writeWithDeadline(conn, connWriteTimeout, func() error {
+ return acceptedConn.conn.WriteMsg(&msg.LoginResp{
Version: version.Full(),
- Error: util.GenerateResponseErrorString("register control error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
+ RunID: ctl.runID,
+ Error: "",
+ })
+ }); err != nil {
+ xl.Warnf("write login response error: %v", err)
+ svr.ctlManager.Del(m.RunID, ctl)
+ svr.clientRegistry.MarkOfflineByRunID(m.RunID)
+ conn.Close()
+ return
+ }
+ ctl.Start()
+ metrics.Server.NewClient()
+ go func() {
+ // block until control closed
+ ctl.WaitClosed()
+ svr.ctlManager.Del(m.RunID, ctl)
+ }()
+ case *msg.NewWorkConn:
+ if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
+ _ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
+ Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
})
conn.Close()
}
- case *msg.NewWorkConn:
- if err := svr.RegisterWorkConn(conn, m); err != nil {
- conn.Close()
- }
case *msg.NewVisitorConn:
- if err = svr.RegisterVisitorConn(conn, m); err != nil {
+ if err = svr.RegisterVisitorConn(conn, m, acceptedConn.wireProtocol); err != nil {
xl.Warnf("register visitor conn error: %v", err)
- _ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{
+ _ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
ProxyName: m.ProxyName,
Error: util.GenerateResponseErrorString("register visitor conn error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
})
conn.Close()
} else {
- _ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{
+ _ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
ProxyName: m.ProxyName,
Error: "",
})
@@ -494,6 +527,129 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
}
}
+type acceptedConnection struct {
+ conn *msg.Conn
+ wireProtocol string
+ cryptoContext *wire.CryptoContext
+ firstMsg msg.Message
+}
+
+func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) {
+ _ = conn.SetReadDeadline(time.Now().Add(connReadTimeout))
+ checkedConn, isV2, err := wire.CheckMagic(conn)
+ if err != nil {
+ return nil, fmt.Errorf("read wire protocol magic: %w", err)
+ }
+
+ wireProtocol := wire.ProtocolV1
+ if isV2 {
+ wireProtocol = wire.ProtocolV2
+ }
+
+ conn = netpkg.NewContextConn(ctx, checkedConn)
+ acceptedConn := &acceptedConnection{wireProtocol: wireProtocol}
+ if isV2 {
+ wireConn := wire.NewConn(conn)
+ rw := msg.NewV2ReadWriterWithConn(wireConn)
+ acceptedConn.conn = msg.NewConn(conn, rw)
+ acceptedConn.firstMsg, err = acceptedConn.readFirstV2Msg(conn, wireConn)
+ } else {
+ rw := msg.NewV1ReadWriter(conn)
+ acceptedConn.conn = msg.NewConn(conn, rw)
+ acceptedConn.firstMsg, err = acceptedConn.conn.ReadMsg()
+ }
+ if err != nil {
+ return nil, err
+ }
+ _ = conn.SetReadDeadline(time.Time{})
+ return acceptedConn, nil
+}
+
+func writeWithDeadline(conn net.Conn, timeout time.Duration, writeFn func() error) error {
+ _ = conn.SetWriteDeadline(time.Now().Add(timeout))
+ defer func() {
+ _ = conn.SetWriteDeadline(time.Time{})
+ }()
+ return writeFn()
+}
+
+func (ac *acceptedConnection) messageConnFor(rw io.ReadWriter) *msg.Conn {
+ return msg.NewConn(ac.conn, msg.NewReadWriter(rw, ac.wireProtocol))
+}
+
+func (ac *acceptedConnection) newControlReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
+ if ac.wireProtocol == wire.ProtocolV2 {
+ if ac.cryptoContext == nil {
+ return nil, fmt.Errorf("missing v2 crypto negotiation")
+ }
+ return netpkg.NewAEADCryptoReadWriter(
+ rw,
+ key,
+ netpkg.AEADCryptoRoleServer,
+ ac.cryptoContext.Algorithm,
+ ac.cryptoContext.TranscriptHash,
+ )
+ }
+ return netpkg.NewCryptoReadWriter(rw, key)
+}
+
+func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn) (msg.Message, error) {
+ frame, err := wireConn.ReadFrame()
+ if err != nil {
+ return nil, fmt.Errorf("read v2 frame: %w", err)
+ }
+ if frame.Type == wire.FrameTypeClientHello {
+ if err := ac.handleClientHello(conn, wireConn, frame); err != nil {
+ return nil, err
+ }
+ frame, err = wireConn.ReadFrame()
+ if err != nil {
+ return nil, fmt.Errorf("read first v2 message frame: %w", err)
+ }
+ }
+
+ m, err := msg.DecodeV2MessageFrame(frame)
+ if err != nil {
+ return nil, fmt.Errorf("decode v2 message: %w", err)
+ }
+ return m, nil
+}
+
+func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Conn, frame *wire.Frame) error {
+ var hello wire.ClientHello
+ if err := wireConn.UnmarshalFrame(frame, &hello); err != nil {
+ return fmt.Errorf("decode ClientHello: %w", err)
+ }
+
+ serverHello, err := wire.NewServerHello(hello)
+ if err != nil {
+ serverHello = wire.DefaultServerHello()
+ serverHello.Error = err.Error()
+ if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
+ return wireConn.WriteJSONFrame(wire.FrameTypeServerHello, serverHello)
+ }); writeErr != nil {
+ return fmt.Errorf("%w; write ServerHello error: %v", err, writeErr)
+ }
+ return err
+ }
+ serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
+ if err != nil {
+ return fmt.Errorf("encode ServerHello: %w", err)
+ }
+ cryptoContext := wire.NewCryptoContext(
+ serverHello.Selected.Crypto.Algorithm,
+ frame.Payload,
+ serverHelloFrame.Payload,
+ )
+ if err := writeWithDeadline(conn, connWriteTimeout, func() error {
+ return wireConn.WriteFrame(serverHelloFrame)
+ }); err != nil {
+ return fmt.Errorf("write ServerHello: %w", err)
+ }
+ ac.cryptoContext = cryptoContext
+ return nil
+}
+
// HandleListener accepts connections from client and call handleConnection to handle them.
// If internal is true, it means that this listener is used for internal communication like ssh tunnel gateway.
// TODO(fatedier): Pass some parameters of listener/connection through context to avoid passing too many parameters.
@@ -579,14 +735,19 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
}
}
-func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, internal bool) error {
+func (svr *Service) RegisterControl(
+ ctlConn *msg.Conn,
+ loginMsg *msg.Login,
+ internal bool,
+ wireProtocol string,
+) (*Control, error) {
// If client's RunID is empty, it's a new client, we just create a new controller.
// Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one.
var err error
if loginMsg.RunID == "" {
loginMsg.RunID, err = util.RandID()
if err != nil {
- return err
+ return nil, err
}
}
@@ -603,15 +764,25 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
authVerifier = auth.AlwaysPassVerifier
}
if err := authVerifier.VerifyLogin(loginMsg); err != nil {
- return err
+ return nil, err
}
- // TODO(fatedier): use SessionContext
- ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, svr.auth.EncryptionKey(), ctlConn, !internal, loginMsg, svr.cfg)
+ ctl, err := NewControl(ctx, &SessionContext{
+ RC: svr.rc,
+ PxyManager: svr.pxyManager,
+ PluginManager: svr.pluginManager,
+ AuthVerifier: authVerifier,
+ EncryptionKey: svr.auth.EncryptionKey(),
+ Conn: ctlConn,
+ LoginMsg: loginMsg,
+ ServerCfg: svr.cfg,
+ ClientRegistry: svr.clientRegistry,
+ WireProtocol: wireProtocol,
+ })
if err != nil {
xl.Warnf("create new controller error: %v", err)
// don't return detailed errors to client
- return fmt.Errorf("unexpected error when creating new controller")
+ return nil, fmt.Errorf("unexpected error when creating new controller")
}
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
@@ -622,41 +793,30 @@ 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, wireProtocol)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
- ctl.Close()
- return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
+ return nil, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
- ctl.clientRegistry = svr.clientRegistry
- ctl.Start()
-
- // for statistics
- metrics.Server.NewClient()
-
- go func() {
- // block until control closed
- ctl.WaitClosed()
- svr.ctlManager.Del(loginMsg.RunID, ctl)
- }()
- return nil
+ return ctl, nil
}
// RegisterWorkConn register a new work connection to control and proxies need it.
-func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn) error {
+func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
xl := netpkg.NewLogFromConn(workConn)
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
if !exist {
xl.Warnf("no client control found for run id [%s]", newMsg.RunID)
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
}
+
// server plugin hook
content := &plugin.NewWorkConnContent{
User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
+ RunID: ctl.sessionCtx.LoginMsg.RunID,
},
NewWorkConn: *newMsg,
}
@@ -664,19 +824,16 @@ func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn)
if err == nil {
newMsg = &retContent.NewWorkConn
// Check auth.
- err = ctl.authVerifier.VerifyNewWorkConn(newMsg)
+ err = ctl.sessionCtx.AuthVerifier.VerifyNewWorkConn(newMsg)
}
if err != nil {
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
- _ = msg.WriteMsg(workConn, &msg.StartWorkConn{
- Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
- })
- return fmt.Errorf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
+ return err
}
- return ctl.RegisterWorkConn(workConn)
+ return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
}
-func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {
+func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
visitorUser := ""
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
// If runID is required, it is not compatible with versions prior to v0.50.0.
@@ -685,48 +842,8 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
if !exist {
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
}
- visitorUser = ctl.loginMsg.User
+ visitorUser = ctl.sessionCtx.LoginMsg.User
}
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
- newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
-}
-
-func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
- helper.Router.HandleFunc("/healthz", healthz)
- subRouter := helper.Router.NewRoute().Subrouter()
-
- subRouter.Use(helper.AuthMiddleware)
- subRouter.Use(httppkg.NewRequestLogger)
-
- // metrics
- if svr.cfg.EnablePrometheus {
- subRouter.Handle("/metrics", promhttp.Handler())
- }
-
- apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager, svr.ctlManager)
-
- // apis
- subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
- subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
- subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
- subRouter.HandleFunc("/api/proxy/{name}/close", httppkg.MakeHTTPHandlerFunc(apiController.APICloseProxyByName)).Methods("POST")
- subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
- subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
- subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
- subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
- subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
-
- // view
- subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
- subRouter.PathPrefix("/static/").Handler(
- netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
- ).Methods("GET")
-
- subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
- })
-}
-
-func healthz(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(200)
+ newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
}
diff --git a/server/service_test.go b/server/service_test.go
new file mode 100644
index 00000000..cf5eec5e
--- /dev/null
+++ b/server/service_test.go
@@ -0,0 +1,63 @@
+// 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 server
+
+import (
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
+ serverConn, clientConn := net.Pipe()
+ defer serverConn.Close()
+ defer clientConn.Close()
+
+ err := writeWithDeadline(serverConn, 50*time.Millisecond, func() error {
+ _, writeErr := serverConn.Write([]byte("x"))
+ return writeErr
+ })
+ require.Error(t, err)
+
+ var netErr net.Error
+ require.True(t, errors.As(err, &netErr))
+ require.True(t, netErr.Timeout())
+
+ readCh := make(chan byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ buf := make([]byte, 1)
+ if _, readErr := clientConn.Read(buf); readErr != nil {
+ errCh <- readErr
+ return
+ }
+ readCh <- buf[0]
+ }()
+
+ _, err = serverConn.Write([]byte("y"))
+ require.NoError(t, err)
+
+ select {
+ case b := <-readCh:
+ require.Equal(t, byte('y'), b)
+ case err := <-errCh:
+ require.NoError(t, err)
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for write after deadline reset")
+ }
+}
diff --git a/server/visitor/visitor.go b/server/visitor/visitor.go
index 57194d98..11a44367 100644
--- a/server/visitor/visitor.go
+++ b/server/visitor/visitor.go
@@ -65,6 +65,7 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
useEncryption bool, useCompression bool, visitorUser string,
+ wireProtocol string,
) (err error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
if useCompression {
rwc = libio.WithCompression(rwc)
}
- err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
+ visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
+ err = l.l.PutConn(&wireProtocolConn{
+ Conn: visitorConn,
+ wireProtocol: wireProtocol,
+ })
} else {
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
return
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
return
}
+type wireProtocolConn struct {
+ net.Conn
+ wireProtocol string
+}
+
+func (c *wireProtocolConn) WireProtocol() string {
+ return c.wireProtocol
+}
+
func (vm *Manager) CloseListener(name string) {
vm.mu.Lock()
defer vm.mu.Unlock()
diff --git a/server/visitor/visitor_test.go b/server/visitor/visitor_test.go
new file mode 100644
index 00000000..67b907fd
--- /dev/null
+++ b/server/visitor/visitor_test.go
@@ -0,0 +1,61 @@
+// 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 visitor
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fatedier/frp/pkg/proto/wire"
+ "github.com/fatedier/frp/pkg/util/util"
+)
+
+func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
+ vm := NewManager()
+ listener, err := vm.Listen("sudp", "secret", []string{"*"})
+ require.NoError(t, err)
+ defer listener.Close()
+
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+
+ now := time.Now().Unix()
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- vm.NewConn(
+ "sudp",
+ server,
+ now,
+ util.GetAuthKey("secret", now),
+ false,
+ false,
+ "user",
+ wire.ProtocolV2,
+ )
+ }()
+
+ acceptedConn, err := listener.Accept()
+ require.NoError(t, err)
+ defer acceptedConn.Close()
+
+ getter, ok := acceptedConn.(interface{ WireProtocol() string })
+ require.True(t, ok)
+ require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
+ require.NoError(t, <-errCh)
+}
diff --git a/test/e2e/compatibility/compatibility_test.go b/test/e2e/compatibility/compatibility_test.go
new file mode 100644
index 00000000..246bcac1
--- /dev/null
+++ b/test/e2e/compatibility/compatibility_test.go
@@ -0,0 +1,286 @@
+package compatibility
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/onsi/ginkgo/v2"
+ "github.com/onsi/gomega"
+
+ "github.com/fatedier/frp/pkg/util/log"
+ "github.com/fatedier/frp/test/e2e/framework"
+ "github.com/fatedier/frp/test/e2e/framework/consts"
+ "github.com/fatedier/frp/test/e2e/pkg/port"
+ "github.com/fatedier/frp/test/e2e/pkg/process"
+)
+
+type compatTestContext struct {
+ CurrentFRPSPath string
+ CurrentFRPCPath string
+ BaselineFRPSPath string
+ BaselineFRPCPath string
+ BaselineVersion string
+ LogLevel string
+ Debug bool
+ RunCurrentCurrent bool
+}
+
+var compatCtx compatTestContext
+
+func registerFlags(flags *flag.FlagSet) {
+ flags.StringVar(&compatCtx.CurrentFRPSPath, "current-frps-path", "../../bin/frps", "The current frps binary to use.")
+ flags.StringVar(&compatCtx.CurrentFRPCPath, "current-frpc-path", "../../bin/frpc", "The current frpc binary to use.")
+ flags.StringVar(&compatCtx.BaselineFRPSPath, "baseline-frps-path", "", "The baseline frps binary to use.")
+ flags.StringVar(&compatCtx.BaselineFRPCPath, "baseline-frpc-path", "", "The baseline frpc binary to use.")
+ flags.StringVar(&compatCtx.BaselineVersion, "baseline-version", "custom", "The baseline version label for reporting.")
+ flags.StringVar(&compatCtx.LogLevel, "log-level", "debug", "Log level.")
+ flags.BoolVar(&compatCtx.Debug, "debug", false, "Enable debug mode to print detailed info.")
+ flags.BoolVar(&compatCtx.RunCurrentCurrent, "run-current-current", true, "Run current frps/current frpc sanity checks.")
+}
+
+func validateCompatContext(t *compatTestContext) error {
+ paths := map[string]string{
+ "current-frps-path": t.CurrentFRPSPath,
+ "current-frpc-path": t.CurrentFRPCPath,
+ "baseline-frps-path": t.BaselineFRPSPath,
+ "baseline-frpc-path": t.BaselineFRPCPath,
+ }
+ for name, path := range paths {
+ if path == "" {
+ return fmt.Errorf("%s can't be empty", name)
+ }
+ if _, err := os.Stat(path); err != nil {
+ return fmt.Errorf("load %s error: %v", name, err)
+ }
+ }
+ return nil
+}
+
+func TestMain(m *testing.M) {
+ registerFlags(flag.CommandLine)
+ flag.Parse()
+
+ if err := validateCompatContext(&compatCtx); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+
+ framework.TestContext.Debug = compatCtx.Debug
+ framework.TestContext.LogLevel = compatCtx.LogLevel
+ log.InitLogger("console", compatCtx.LogLevel, 0, true)
+ os.Exit(m.Run())
+}
+
+func TestCompatibilityE2E(t *testing.T) {
+ gomega.RegisterFailHandler(framework.Fail)
+
+ suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration()
+ suiteConfig.EmitSpecProgress = true
+ suiteConfig.RandomizeAllSpecs = true
+ ginkgo.RunSpecs(t, "frp compatibility e2e suite", suiteConfig, reporterConfig)
+}
+
+var _ = ginkgo.Describe("[Compatibility: WireProtocol]", func() {
+ f := framework.NewDefaultFramework()
+
+ ginkgo.It("current frps and current frpc support v1 and v2", func() {
+ if !compatCtx.RunCurrentCurrent {
+ ginkgo.Skip("current/current sanity checks already ran")
+ }
+
+ webPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+webServer.port = %d
+ `, webPort)
+
+ v1PortName := port.GenName("CompatCurrentV1")
+ v1ClientConf := tcpClientConfig("compat-current-v1", v1PortName, `
+clientID = "compat-current-v1"
+transport.wireProtocol = "v1"
+`)
+
+ v2PortName := port.GenName("CompatCurrentV2")
+ v2ClientConf := tcpClientConfig("compat-current-v2", v2PortName, `
+clientID = "compat-current-v2"
+transport.wireProtocol = "v2"
+`)
+
+ f.RunProcessesWithBinaries(
+ compatCtx.CurrentFRPSPath,
+ compatCtx.CurrentFRPCPath,
+ serverConf,
+ []string{v1ClientConf, v2ClientConf},
+ )
+
+ framework.NewRequestExpect(f).PortName(v1PortName).Ensure()
+ framework.NewRequestExpect(f).PortName(v2PortName).Ensure()
+ expectClientWireProtocol(webPort, "compat-current-v1", "v1")
+ expectClientWireProtocol(webPort, "compat-current-v2", "v2")
+ })
+
+ ginkgo.It("current frps accepts baseline frpc using v1", func() {
+ webPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+webServer.port = %d
+`, webPort)
+
+ portName := port.GenName("CompatBaselineFRPC")
+ clientConf := tcpClientConfig("tcp", portName, "")
+
+ f.RunProcessesWithBinaries(
+ compatCtx.CurrentFRPSPath,
+ compatCtx.BaselineFRPCPath,
+ serverConf,
+ []string{clientConf},
+ )
+
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+ expectSingleClientWireProtocol(webPort, "v1")
+ })
+
+ ginkgo.It("baseline frps accepts current frpc defaulting to v1", func() {
+ portName := port.GenName("CompatBaselineFRPS")
+ clientConf := tcpClientConfig("tcp", portName, "")
+
+ f.RunProcessesWithBinaries(
+ compatCtx.BaselineFRPSPath,
+ compatCtx.CurrentFRPCPath,
+ consts.DefaultServerConfig,
+ []string{clientConf},
+ )
+
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+ })
+
+ ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
+ portName := port.GenName("CompatBaselineFRPSForcedV2")
+ clientConf := tcpClientConfig("tcp", portName, `
+transport.wireProtocol = "v2"
+`)
+
+ _, clientProcesses := f.RunProcessesWithBinaries(
+ compatCtx.BaselineFRPSPath,
+ compatCtx.CurrentFRPCPath,
+ consts.DefaultServerConfig,
+ []string{clientConf},
+ )
+
+ // frp v0.69.0 added control connection wireProtocol v2 support, so
+ // baseline frps v0.69.0 and newer should accept a current frpc forced
+ // to v2. Older known baselines still must reject the unsupported protocol.
+ // For custom baselines, the version is unknown and the binary may be either
+ // side of the support boundary, so this versioned expectation is skipped.
+ supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
+ if !knownVersion {
+ ginkgo.Skip(fmt.Sprintf("baseline version %q is not semver; skip versioned forced-v2 expectation", compatCtx.BaselineVersion))
+ }
+ if supportsV2 {
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+ return
+ }
+
+ expectProcessExit(clientProcesses[0], 5*time.Second)
+ framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
+ })
+})
+
+func tcpClientConfig(proxyName string, remotePortName string, extra string) string {
+ return fmt.Sprintf(`
+serverAddr = "127.0.0.1"
+serverPort = {{ .%s }}
+loginFailExit = true
+log.level = "trace"
+%s
+
+[[proxies]]
+name = "%s"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
+}
+
+func expectProcessExit(p *process.Process, timeout time.Duration) {
+ select {
+ case <-p.Done():
+ case <-time.After(timeout):
+ framework.Failf("process did not exit within %s; output:\n%s", timeout, p.Output())
+ }
+}
+
+func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
+ version = strings.TrimPrefix(version, "v")
+ parts := strings.Split(version, ".")
+ if len(parts) != 3 {
+ return false, false
+ }
+
+ major, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return false, false
+ }
+ minor, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return false, false
+ }
+ patch, err := strconv.Atoi(parts[2])
+ if err != nil {
+ return false, false
+ }
+
+ return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
+}
+
+func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
+ if major != baseMajor {
+ return major - baseMajor
+ }
+ if minor != baseMinor {
+ return minor - baseMinor
+ }
+ return patch - basePatch
+}
+
+type wireClientInfo struct {
+ ClientID string `json:"clientID"`
+ WireProtocol string `json:"wireProtocol"`
+}
+
+func expectSingleClientWireProtocol(webPort int, wireProtocol string) {
+ clients := getWireClientInfos(webPort)
+ framework.ExpectEqual(len(clients), 1)
+ framework.ExpectEqual(clients[0].WireProtocol, wireProtocol)
+}
+
+func expectClientWireProtocol(webPort int, clientID string, wireProtocol string) {
+ for _, client := range getWireClientInfos(webPort) {
+ if client.ClientID == clientID {
+ framework.ExpectEqual(client.WireProtocol, wireProtocol)
+ return
+ }
+ }
+ framework.Failf("client %q not found in /api/clients response", clientID)
+}
+
+func getWireClientInfos(webPort int) []wireClientInfo {
+ client := http.Client{Timeout: consts.DefaultTimeout}
+ resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/clients", webPort))
+ framework.ExpectNoError(err)
+ defer resp.Body.Close()
+ framework.ExpectEqual(resp.StatusCode, http.StatusOK)
+
+ content, err := io.ReadAll(resp.Body)
+ framework.ExpectNoError(err)
+
+ var clients []wireClientInfo
+ framework.ExpectNoError(json.Unmarshal(content, &clients))
+ return clients
+}
diff --git a/test/e2e/examples.go b/test/e2e/examples.go
index 135ce706..9f0bc335 100644
--- a/test/e2e/examples.go
+++ b/test/e2e/examples.go
@@ -26,7 +26,7 @@ var _ = ginkgo.Describe("[Feature: Example]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
diff --git a/test/e2e/framework/cleanup.go b/test/e2e/framework/cleanup.go
deleted file mode 100644
index 15e1a9dd..00000000
--- a/test/e2e/framework/cleanup.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package framework
-
-import (
- "sync"
-)
-
-// CleanupActionHandle is an integer pointer type for handling cleanup action
-type CleanupActionHandle *int
-
-type cleanupFuncHandle struct {
- actionHandle CleanupActionHandle
- actionHook func()
-}
-
-var (
- cleanupActionsLock sync.Mutex
- cleanupHookList = []cleanupFuncHandle{}
-)
-
-// AddCleanupAction installs a function that will be called in the event of the
-// whole test being terminated. This allows arbitrary pieces of the overall
-// test to hook into SynchronizedAfterSuite().
-// The hooks are called in last-in-first-out order.
-func AddCleanupAction(fn func()) CleanupActionHandle {
- p := CleanupActionHandle(new(int))
- cleanupActionsLock.Lock()
- defer cleanupActionsLock.Unlock()
- c := cleanupFuncHandle{actionHandle: p, actionHook: fn}
- cleanupHookList = append([]cleanupFuncHandle{c}, cleanupHookList...)
- return p
-}
-
-// RemoveCleanupAction removes a function that was installed by
-// AddCleanupAction.
-func RemoveCleanupAction(p CleanupActionHandle) {
- cleanupActionsLock.Lock()
- defer cleanupActionsLock.Unlock()
- for i, item := range cleanupHookList {
- if item.actionHandle == p {
- cleanupHookList = append(cleanupHookList[:i], cleanupHookList[i+1:]...)
- break
- }
- }
-}
-
-// RunCleanupActions runs all functions installed by AddCleanupAction. It does
-// not remove them (see RemoveCleanupAction) but it does run unlocked, so they
-// may remove themselves.
-func RunCleanupActions() {
- list := []func(){}
- func() {
- cleanupActionsLock.Lock()
- defer cleanupActionsLock.Unlock()
- for _, p := range cleanupHookList {
- list = append(list, p.actionHook)
- }
- }()
- // Run unlocked.
- for _, fn := range list {
- fn()
- }
-}
diff --git a/test/e2e/framework/expect.go b/test/e2e/framework/expect.go
index 8e904489..ea80ecb8 100644
--- a/test/e2e/framework/expect.go
+++ b/test/e2e/framework/expect.go
@@ -23,11 +23,6 @@ func ExpectNotEqual(actual any, extra any, explain ...any) {
gomega.ExpectWithOffset(1, actual).NotTo(gomega.Equal(extra), explain...)
}
-// ExpectError expects an error happens, otherwise an exception raises
-func ExpectError(err error, explain ...any) {
- gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred(), explain...)
-}
-
func ExpectErrorWithOffset(offset int, err error, explain ...any) {
gomega.ExpectWithOffset(1+offset, err).To(gomega.HaveOccurred(), explain...)
}
@@ -47,11 +42,6 @@ func ExpectContainSubstring(actual, substr string, explain ...any) {
gomega.ExpectWithOffset(1, actual).To(gomega.ContainSubstring(substr), explain...)
}
-// ExpectConsistOf expects actual contains precisely the extra elements. The ordering of the elements does not matter.
-func ExpectConsistOf(actual any, extra any, explain ...any) {
- gomega.ExpectWithOffset(1, actual).To(gomega.ConsistOf(extra), explain...)
-}
-
func ExpectContainElements(actual any, extra any, explain ...any) {
gomega.ExpectWithOffset(1, actual).To(gomega.ContainElements(extra), explain...)
}
@@ -60,16 +50,6 @@ func ExpectNotContainElements(actual any, extra any, explain ...any) {
gomega.ExpectWithOffset(1, actual).NotTo(gomega.ContainElements(extra), explain...)
}
-// ExpectHaveKey expects the actual map has the key in the keyset
-func ExpectHaveKey(actual any, key any, explain ...any) {
- gomega.ExpectWithOffset(1, actual).To(gomega.HaveKey(key), explain...)
-}
-
-// ExpectEmpty expects actual is empty
-func ExpectEmpty(actual any, explain ...any) {
- gomega.ExpectWithOffset(1, actual).To(gomega.BeEmpty(), explain...)
-}
-
func ExpectTrue(actual any, explain ...any) {
gomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...)
}
diff --git a/test/e2e/framework/framework.go b/test/e2e/framework/framework.go
index efd098ab..34c0eb63 100644
--- a/test/e2e/framework/framework.go
+++ b/test/e2e/framework/framework.go
@@ -38,11 +38,6 @@ type Framework struct {
// Multiple default mock servers used for e2e testing.
mockServers *MockServers
- // To make sure that this framework cleans up after itself, no matter what,
- // we install a Cleanup action before each test and clear it after. If we
- // should abort, the AfterSuite hook should run all Cleanup actions.
- cleanupHandle CleanupActionHandle
-
// beforeEachStarted indicates that BeforeEach has started
beforeEachStarted bool
@@ -87,8 +82,6 @@ func NewFramework(opt Options) *Framework {
func (f *Framework) BeforeEach() {
f.beforeEachStarted = true
- f.cleanupHandle = AddCleanupAction(f.AfterEach)
-
dir, err := os.MkdirTemp(os.TempDir(), "frp-e2e-test-*")
ExpectNoError(err)
f.TempDirectory = dir
@@ -113,8 +106,6 @@ func (f *Framework) AfterEach() {
return
}
- RemoveCleanupAction(f.cleanupHandle)
-
// stop processor
for _, p := range f.serverProcesses {
_ = p.Stop()
@@ -241,8 +232,29 @@ func (f *Framework) AllocPort() int {
return port
}
-func (f *Framework) ReleasePort(port int) {
- f.portAllocator.Release(port)
+func (f *Framework) AllocPortExcludingRanges(ranges ...[2]int) int {
+ for range 1000 {
+ port := f.portAllocator.Get()
+ ExpectTrue(port > 0, "alloc port failed")
+
+ inExcludedRange := false
+ for _, portRange := range ranges {
+ if port >= portRange[0] && port <= portRange[1] {
+ inExcludedRange = true
+ break
+ }
+ }
+ if inExcludedRange {
+ f.portAllocator.Release(port)
+ continue
+ }
+
+ f.allocatedPorts = append(f.allocatedPorts, port)
+ return port
+ }
+
+ Failf("alloc port outside excluded ranges failed")
+ return 0
}
func (f *Framework) RunServer(portName string, s server.Server) {
diff --git a/test/e2e/framework/mockservers.go b/test/e2e/framework/mockservers.go
index 6b6c2868..65d9f621 100644
--- a/test/e2e/framework/mockservers.go
+++ b/test/e2e/framework/mockservers.go
@@ -75,11 +75,3 @@ func (m *MockServers) GetTemplateParams() map[string]any {
ret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort()
return ret
}
-
-func (m *MockServers) GetParam(key string) any {
- params := m.GetTemplateParams()
- if v, ok := params[key]; ok {
- return v
- }
- return nil
-}
diff --git a/test/e2e/framework/process.go b/test/e2e/framework/process.go
index 0b837e39..8d27e887 100644
--- a/test/e2e/framework/process.go
+++ b/test/e2e/framework/process.go
@@ -2,70 +2,95 @@ package framework
import (
"fmt"
+ "maps"
+ "net"
"os"
"path/filepath"
+ "strconv"
"time"
+ "github.com/fatedier/frp/pkg/config"
flog "github.com/fatedier/frp/pkg/util/log"
+ "github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/process"
)
-// RunProcesses run multiple processes from templates.
-// The first template should always be frps.
-func (f *Framework) RunProcesses(serverTemplates []string, clientTemplates []string) ([]*process.Process, []*process.Process) {
- templates := make([]string, 0, len(serverTemplates)+len(clientTemplates))
- templates = append(templates, serverTemplates...)
- templates = append(templates, clientTemplates...)
+// RunProcesses starts one frps and zero or more frpc processes from templates.
+func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) {
+ return f.RunProcessesWithBinaries(TestContext.FRPServerPath, TestContext.FRPClientPath, serverTemplate, clientTemplates)
+}
+
+// RunProcessesWithBinaries starts one frps and zero or more frpc processes with explicit binary paths.
+func (f *Framework) RunProcessesWithBinaries(
+ serverBinaryPath string,
+ clientBinaryPath string,
+ serverTemplate string,
+ clientTemplates []string,
+) (*process.Process, []*process.Process) {
+ templates := append([]string{serverTemplate}, clientTemplates...)
outs, ports, err := f.RenderTemplates(templates)
ExpectNoError(err)
- ExpectTrue(len(templates) > 0)
- for name, port := range ports {
- f.usedPorts[name] = port
+ maps.Copy(f.usedPorts, ports)
+
+ // Start frps.
+ serverPath := filepath.Join(f.TempDirectory, "frp-e2e-server-0")
+ err = os.WriteFile(serverPath, []byte(outs[0]), 0o600)
+ ExpectNoError(err)
+
+ if TestContext.Debug {
+ flog.Debugf("[%s] %s", serverPath, outs[0])
}
- currentServerProcesses := make([]*process.Process, 0, len(serverTemplates))
- for i := range serverTemplates {
- path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-server-%d", i))
- err = os.WriteFile(path, []byte(outs[i]), 0o600)
- ExpectNoError(err)
+ serverProcess := process.NewWithEnvs(serverBinaryPath, []string{"-c", serverPath}, f.osEnvs)
+ f.serverConfPaths = append(f.serverConfPaths, serverPath)
+ f.serverProcesses = append(f.serverProcesses, serverProcess)
+ err = serverProcess.Start()
+ ExpectNoError(err)
- if TestContext.Debug {
- flog.Debugf("[%s] %s", path, outs[i])
- }
-
- p := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", path}, f.osEnvs)
- f.serverConfPaths = append(f.serverConfPaths, path)
- f.serverProcesses = append(f.serverProcesses, p)
- currentServerProcesses = append(currentServerProcesses, p)
- err = p.Start()
- ExpectNoError(err)
- time.Sleep(500 * time.Millisecond)
+ if port, ok := ports[consts.PortServerName]; ok {
+ ExpectNoError(WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 5*time.Second))
+ } else {
+ time.Sleep(2 * time.Second)
}
- time.Sleep(2 * time.Second)
- currentClientProcesses := make([]*process.Process, 0, len(clientTemplates))
+ // Start frpc(s).
+ clientProcesses := make([]*process.Process, 0, len(clientTemplates))
for i := range clientTemplates {
- index := i + len(serverTemplates)
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i))
- err = os.WriteFile(path, []byte(outs[index]), 0o600)
+ err = os.WriteFile(path, []byte(outs[1+i]), 0o600)
ExpectNoError(err)
if TestContext.Debug {
- flog.Debugf("[%s] %s", path, outs[index])
+ flog.Debugf("[%s] %s", path, outs[1+i])
}
- p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs)
+ p := process.NewWithEnvs(clientBinaryPath, []string{"-c", path}, f.osEnvs)
f.clientConfPaths = append(f.clientConfPaths, path)
f.clientProcesses = append(f.clientProcesses, p)
- currentClientProcesses = append(currentClientProcesses, p)
+ clientProcesses = append(clientProcesses, p)
err = p.Start()
ExpectNoError(err)
- time.Sleep(500 * time.Millisecond)
}
- time.Sleep(3 * time.Second)
+ // Wait for each client's proxies to register with frps.
+ // If any client has no proxies (e.g. visitor-only), fall back to sleep
+ // for the remaining time since visitors have no deterministic readiness signal.
+ allConfirmed := len(clientProcesses) > 0
+ start := time.Now()
+ for i, p := range clientProcesses {
+ configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
+ if !waitForClientProxyReady(configPath, p, 5*time.Second) {
+ allConfirmed = false
+ }
+ }
+ if len(clientProcesses) > 0 && !allConfirmed {
+ remaining := 1500*time.Millisecond - time.Since(start)
+ if remaining > 0 {
+ time.Sleep(remaining)
+ }
+ }
- return currentServerProcesses, currentClientProcesses
+ return serverProcess, clientProcesses
}
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
@@ -73,11 +98,13 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
f.serverProcesses = append(f.serverProcesses, p)
err := p.Start()
if err != nil {
- return p, p.StdOutput(), err
+ return p, p.Output(), err
}
- // Give frps extra time to finish binding ports before proceeding.
- time.Sleep(4 * time.Second)
- return p, p.StdOutput(), nil
+ select {
+ case <-p.Done():
+ case <-time.After(2 * time.Second):
+ }
+ return p, p.Output(), nil
}
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
@@ -85,10 +112,13 @@ func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
f.clientProcesses = append(f.clientProcesses, p)
err := p.Start()
if err != nil {
- return p, p.StdOutput(), err
+ return p, p.Output(), err
}
- time.Sleep(2 * time.Second)
- return p, p.StdOutput(), nil
+ select {
+ case <-p.Done():
+ case <-time.After(1500 * time.Millisecond):
+ }
+ return p, p.Output(), nil
}
func (f *Framework) GenerateConfigFile(content string) string {
@@ -98,3 +128,74 @@ func (f *Framework) GenerateConfigFile(content string) string {
ExpectNoError(err)
return path
}
+
+// waitForClientProxyReady parses the client config to extract proxy names,
+// then waits for each proxy's "start proxy success" log in the process output.
+// Returns true only if proxies were expected and all registered successfully.
+func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {
+ _, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)
+ if err != nil || len(proxyCfgs) == 0 {
+ return false
+ }
+
+ // Use a single deadline so the total wait across all proxies does not exceed timeout.
+ deadline := time.Now().Add(timeout)
+ for _, cfg := range proxyCfgs {
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return false
+ }
+ name := cfg.GetBaseConfig().Name
+ pattern := fmt.Sprintf("[%s] start proxy success", name)
+ if err := p.WaitForOutput(pattern, 1, remaining); err != nil {
+ return false
+ }
+ }
+ return true
+}
+
+// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.
+func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {
+ if interval <= 0 {
+ return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr)
+ }
+ if timeout <= 0 {
+ return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr)
+ }
+ deadline := time.Now().Add(timeout)
+ for {
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr)
+ }
+ dialTimeout := min(interval, remaining)
+ conn, err := net.DialTimeout("tcp", addr, dialTimeout)
+ if err != nil {
+ return nil
+ }
+ conn.Close()
+ time.Sleep(min(interval, time.Until(deadline)))
+ }
+}
+
+// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
+func WaitForTCPReady(addr string, timeout time.Duration) error {
+ if timeout <= 0 {
+ return fmt.Errorf("invalid timeout for TCP readiness on %s: timeout must be positive", addr)
+ }
+ deadline := time.Now().Add(timeout)
+ var lastErr error
+ for time.Now().Before(deadline) {
+ conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
+ if err == nil {
+ conn.Close()
+ return nil
+ }
+ lastErr = err
+ time.Sleep(50 * time.Millisecond)
+ }
+ if lastErr == nil {
+ return fmt.Errorf("timeout waiting for TCP readiness on %s before any dial attempt", addr)
+ }
+ return fmt.Errorf("timeout waiting for TCP readiness on %s: %w", addr, lastErr)
+}
diff --git a/test/e2e/framework/request.go b/test/e2e/framework/request.go
index 599ff11b..a6ac565a 100644
--- a/test/e2e/framework/request.go
+++ b/test/e2e/framework/request.go
@@ -124,7 +124,3 @@ func (e *RequestExpect) Ensure(fns ...EnsureFunc) {
}
}
}
-
-func (e *RequestExpect) Do() (*request.Response, error) {
- return e.req.Do()
-}
diff --git a/test/e2e/legacy/basic/basic.go b/test/e2e/legacy/basic/basic.go
index 763d3353..0115c6cb 100644
--- a/test/e2e/legacy/basic/basic.go
+++ b/test/e2e/legacy/basic/basic.go
@@ -26,7 +26,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
localPortName := ""
protocol := "tcp"
@@ -78,10 +79,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).
@@ -102,7 +103,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
vhost_http_port = %d
`, vhostHTTPPort)
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
@@ -147,13 +149,13 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = test.proxyName + ".example.com"
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
framework.NewRequestExpect(f).
Explain(test.proxyName + "-" + domain).
@@ -185,7 +187,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
`, vhostHTTPSPort)
localPort := f.AllocPort()
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
[%s]
@@ -229,10 +232,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = test.proxyName + ".example.com"
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -244,7 +247,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
f.RunServer("", localServer)
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
framework.NewRequestExpect(f).
Explain(test.proxyName + "-" + domain).
@@ -282,9 +285,12 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() {
serverConf := consts.LegacyDefaultServerConfig
- clientServerConf := consts.LegacyDefaultClientConfig + "\nuser = user1"
- clientVisitorConf := consts.LegacyDefaultClientConfig + "\nuser = user1"
- clientUser2VisitorConf := consts.LegacyDefaultClientConfig + "\nuser = user2"
+ var clientServerConf strings.Builder
+ clientServerConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1")
+ var clientVisitorConf strings.Builder
+ clientVisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1")
+ var clientUser2VisitorConf strings.Builder
+ clientUser2VisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user2")
localPortName := ""
protocol := "tcp"
@@ -400,20 +406,20 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientServerConf += getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n"
+ clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n")
}
for _, test := range tests {
config := getProxyVisitorConf(
test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig,
) + "\n"
if test.deployUser2Client {
- clientUser2VisitorConf += config
+ clientUser2VisitorConf.WriteString(config)
} else {
- clientVisitorConf += config
+ clientVisitorConf.WriteString(config)
}
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientServerConf, clientVisitorConf, clientUser2VisitorConf})
+ f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests {
timeout := time.Second
@@ -440,7 +446,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
ginkgo.Describe("TCPMUX", func() {
ginkgo.It("Type tcpmux", func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
tcpmuxHTTPConnectPortName := port.GenName("TCPMUX")
serverConf += fmt.Sprintf(`
@@ -483,14 +490,14 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n")
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))
f.RunServer(port.GenName(test.proxyName), localServer)
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
// Request without HTTP connect should get error
framework.NewRequestExpect(f).
diff --git a/test/e2e/legacy/basic/client.go b/test/e2e/legacy/basic/client.go
index daed8b22..d9b9b6da 100644
--- a/test/e2e/legacy/basic/client.go
+++ b/test/e2e/legacy/basic/client.go
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
admin_pwd = admin
`, dashboardPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
@@ -116,7 +116,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remote_port = %d
`, adminPort, framework.TCPEchoServerPort, testPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure()
diff --git a/test/e2e/legacy/basic/client_server.go b/test/e2e/legacy/basic/client_server.go
index d85b5acc..330b9cfc 100644
--- a/test/e2e/legacy/basic/client_server.go
+++ b/test/e2e/legacy/basic/client_server.go
@@ -76,7 +76,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf)
}
- f.RunProcesses([]string{serverConf}, clientConfs)
+ f.RunProcesses(serverConf, clientConfs)
if configures.testDelay > 0 {
time.Sleep(configures.testDelay)
diff --git a/test/e2e/legacy/basic/config.go b/test/e2e/legacy/basic/config.go
index 6e9f3564..334cc062 100644
--- a/test/e2e/legacy/basic/config.go
+++ b/test/e2e/legacy/basic/config.go
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"})
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
diff --git a/test/e2e/legacy/basic/http.go b/test/e2e/legacy/basic/http.go
index 77ba1052..f1807ecd 100644
--- a/test/e2e/legacy/basic/http.go
+++ b/test/e2e/legacy/basic/http.go
@@ -56,7 +56,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = /bar
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tests := []struct {
path string
@@ -111,7 +111,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = normal.example.com
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
http_pwd = test
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -188,7 +188,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = *.example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -238,7 +238,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = bar
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -279,7 +279,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
header_X-From-Where = frp
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -312,7 +312,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
host_header_rewrite = rewrite.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -360,7 +360,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = 127.0.0.1
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
diff --git a/test/e2e/legacy/basic/server.go b/test/e2e/legacy/basic/server.go
index 4399439d..3407a165 100644
--- a/test/e2e/legacy/basic/server.go
+++ b/test/e2e/legacy/basic/server.go
@@ -20,6 +20,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
ginkgo.It("Ports Whitelist", func() {
serverConf := consts.LegacyDefaultServerConfig
clientConf := consts.LegacyDefaultClientConfig
+ tcpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
+ udpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
serverConf += `
allow_ports = 10000-11000,11002,12000-13000
@@ -28,7 +30,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
tcpPortName := port.GenName("TCP", port.WithRangePorts(10000, 11000))
udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000))
clientConf += fmt.Sprintf(`
- [tcp-allowded-in-range]
+ [tcp-allowed-in-range]
type = tcp
local_port = {{ .%s }}
remote_port = {{ .%s }}
@@ -37,8 +39,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
[tcp-port-not-allowed]
type = tcp
local_port = {{ .%s }}
- remote_port = 11001
- `, framework.TCPEchoServerPort)
+ remote_port = %d
+ `, framework.TCPEchoServerPort, tcpPortNotAllowed)
clientConf += fmt.Sprintf(`
[tcp-port-unavailable]
type = tcp
@@ -55,17 +57,17 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
[udp-port-not-allowed]
type = udp
local_port = {{ .%s }}
- remote_port = 11003
- `, framework.UDPEchoServerPort)
+ remote_port = %d
+ `, framework.UDPEchoServerPort, udpPortNotAllowed)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// TCP
// Allowed in range
framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
// Not Allowed
- framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()
+ framework.NewRequestExpect(f).Port(tcpPortNotAllowed).ExpectError(true).Ensure()
// Unavailable, already bind by frps
framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()
@@ -76,7 +78,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
// Not Allowed
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
- r.UDP().Port(11003)
+ r.UDP().Port(udpPortNotAllowed)
}).ExpectError(true).Ensure()
})
@@ -97,7 +99,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
local_port = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
@@ -138,7 +140,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com")
@@ -165,7 +167,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
diff --git a/test/e2e/legacy/basic/tcpmux.go b/test/e2e/legacy/basic/tcpmux.go
index 15477837..3872ea04 100644
--- a/test/e2e/legacy/basic/tcpmux.go
+++ b/test/e2e/legacy/basic/tcpmux.go
@@ -76,7 +76,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").
@@ -121,7 +121,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
http_pwd = test
`, fooPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Explain("no auth").
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/legacy/basic/xtcp.go b/test/e2e/legacy/basic/xtcp.go
index 3c47f577..6e72f2e1 100644
--- a/test/e2e/legacy/basic/xtcp.go
+++ b/test/e2e/legacy/basic/xtcp.go
@@ -41,7 +41,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallback_timeout_ms = 200
`, framework.TCPEchoServerPort, bindPortName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
r.Timeout(time.Second)
diff --git a/test/e2e/legacy/features/bandwidth_limit.go b/test/e2e/legacy/features/bandwidth_limit.go
index c94e473f..a54866b8 100644
--- a/test/e2e/legacy/features/bandwidth_limit.go
+++ b/test/e2e/legacy/features/bandwidth_limit.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
bandwidth_limit = 10KB
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
@@ -89,7 +89,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remote_port = %d
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
diff --git a/test/e2e/legacy/features/group.go b/test/e2e/legacy/features/group.go
index dc15f75c..57c143c5 100644
--- a/test/e2e/legacy/features/group.go
+++ b/test/e2e/legacy/features/group.go
@@ -48,12 +48,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
return true
})
}
- for i := 0; i < 10; i++ {
- wait.Add(1)
- go func() {
- defer wait.Done()
+ for range 10 {
+ wait.Go(func() {
expectFn()
- }()
+ })
}
wait.Wait()
@@ -90,11 +88,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
group_key = 123
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {
switch string(resp.Content) {
case "foo":
@@ -146,11 +144,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_interval_s = 1
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// check foo and bar is ok
results := []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -161,7 +159,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
// close bar server, check foo is ok
barServer.Close()
time.Sleep(2 * time.Second)
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
@@ -169,7 +167,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
f.RunServer("", barServer)
time.Sleep(2 * time.Second)
results = []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -215,7 +213,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_url = /healthz
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// send first HTTP request
var contents []string
diff --git a/test/e2e/legacy/features/heartbeat.go b/test/e2e/legacy/features/heartbeat.go
index 09bf0e6a..3c1758f2 100644
--- a/test/e2e/legacy/features/heartbeat.go
+++ b/test/e2e/legacy/features/heartbeat.go
@@ -38,7 +38,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
diff --git a/test/e2e/legacy/features/monitor.go b/test/e2e/legacy/features/monitor.go
index 75aa183b..0b19a85e 100644
--- a/test/e2e/legacy/features/monitor.go
+++ b/test/e2e/legacy/features/monitor.go
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond)
diff --git a/test/e2e/legacy/features/real_ip.go b/test/e2e/legacy/features/real_ip.go
index a79afb45..dae74e56 100644
--- a/test/e2e/legacy/features/real_ip.go
+++ b/test/e2e/legacy/features/real_ip.go
@@ -44,7 +44,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
custom_domains = normal.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -136,7 +136,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com")
diff --git a/test/e2e/legacy/plugin/client.go b/test/e2e/legacy/plugin/client.go
index b69c6aed..ca294280 100644
--- a/test/e2e/legacy/plugin/client.go
+++ b/test/e2e/legacy/plugin/client.go
@@ -4,6 +4,7 @@ import (
"crypto/tls"
"fmt"
"strconv"
+ "strings"
"github.com/onsi/ginkgo/v2"
@@ -22,7 +23,8 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ginkgo.Describe("UnixDomainSocket", func() {
ginkgo.It("Expose a unix domain socket echo server", func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, portName string, extra string) string {
return fmt.Sprintf(`
@@ -65,10 +67,10 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -90,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -122,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_passwd = 123
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -166,7 +168,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// from tcp proxy
framework.NewRequestExpect(f).Request(
@@ -200,7 +202,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_local_addr = 127.0.0.1:%d
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -244,7 +246,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -288,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
diff --git a/test/e2e/legacy/plugin/server.go b/test/e2e/legacy/plugin/server.go
index 9120b7d7..b00b1bac 100644
--- a/test/e2e/legacy/plugin/server.go
+++ b/test/e2e/legacy/plugin/server.go
@@ -71,7 +71,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort2)
- f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
+ f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -119,7 +119,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -153,7 +153,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = 0
`, framework.TCPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -195,7 +195,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clients := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -250,7 +250,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -297,7 +297,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -342,7 +342,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -389,7 +389,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
diff --git a/test/e2e/mock/server/httpserver/server.go b/test/e2e/mock/server/httpserver/server.go
index f9818a0d..fb80288f 100644
--- a/test/e2e/mock/server/httpserver/server.go
+++ b/test/e2e/mock/server/httpserver/server.go
@@ -31,13 +31,6 @@ func New(options ...Option) *Server {
return s
}
-func WithBindAddr(addr string) Option {
- return func(s *Server) *Server {
- s.bindAddr = addr
- return s
- }
-}
-
func WithBindPort(port int) Option {
return func(s *Server) *Server {
s.bindPort = port
diff --git a/test/e2e/mock/server/oidcserver/oidcserver.go b/test/e2e/mock/server/oidcserver/oidcserver.go
new file mode 100644
index 00000000..eb7ce2e4
--- /dev/null
+++ b/test/e2e/mock/server/oidcserver/oidcserver.go
@@ -0,0 +1,246 @@
+// 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 oidcserver provides a minimal mock OIDC server for e2e testing.
+// It implements three endpoints:
+// - /.well-known/openid-configuration (discovery)
+// - /jwks (JSON Web Key Set)
+// - /token (client_credentials grant)
+package oidcserver
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net"
+ "net/http"
+ "strconv"
+ "sync/atomic"
+ "time"
+)
+
+type Server struct {
+ bindAddr string
+ bindPort int
+ l net.Listener
+ hs *http.Server
+
+ privateKey *rsa.PrivateKey
+ kid string
+
+ clientID string
+ clientSecret string
+ audience string
+ subject string
+ expiresIn int // seconds; 0 means omit expires_in from token response
+
+ tokenRequestCount atomic.Int64
+}
+
+const maxTokenRequestBodySize = 1 << 20
+
+type Option func(*Server)
+
+func WithBindPort(port int) Option {
+ return func(s *Server) { s.bindPort = port }
+}
+
+func WithExpiresIn(seconds int) Option {
+ return func(s *Server) { s.expiresIn = seconds }
+}
+
+func New(options ...Option) *Server {
+ s := &Server{
+ bindAddr: "127.0.0.1",
+ kid: "test-key-1",
+ clientID: "test-client",
+ clientSecret: "test-secret",
+ audience: "frps",
+ subject: "test-service",
+ expiresIn: 3600,
+ }
+ for _, opt := range options {
+ opt(s)
+ }
+ return s
+}
+
+func (s *Server) Run() error {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return fmt.Errorf("generate RSA key: %w", err)
+ }
+ s.privateKey = key
+
+ s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))
+ if err != nil {
+ return err
+ }
+ s.bindPort = s.l.Addr().(*net.TCPAddr).Port
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
+ mux.HandleFunc("/jwks", s.handleJWKS)
+ mux.HandleFunc("/token", s.handleToken)
+
+ s.hs = &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: time.Minute,
+ }
+ go func() { _ = s.hs.Serve(s.l) }()
+ return nil
+}
+
+func (s *Server) Close() error {
+ if s.hs != nil {
+ return s.hs.Close()
+ }
+ return nil
+}
+
+func (s *Server) BindAddr() string { return s.bindAddr }
+func (s *Server) BindPort() int { return s.bindPort }
+
+func (s *Server) Issuer() string {
+ return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort)
+}
+
+func (s *Server) TokenEndpoint() string {
+ return s.Issuer() + "/token"
+}
+
+// TokenRequestCount returns the number of successful token requests served.
+func (s *Server) TokenRequestCount() int64 {
+ return s.tokenRequestCount.Load()
+}
+
+func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ issuer := s.Issuer()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "issuer": issuer,
+ "token_endpoint": issuer + "/token",
+ "jwks_uri": issuer + "/jwks",
+ "response_types_supported": []string{"code"},
+ "subject_types_supported": []string{"public"},
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ })
+}
+
+func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ pub := &s.privateKey.PublicKey
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {
+ "kty": "RSA",
+ "alg": "RS256",
+ "use": "sig",
+ "kid": s.kid,
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
+ },
+ },
+ })
+}
+
+func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxTokenRequestBodySize)
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "invalid_request",
+ })
+ return
+ }
+
+ if r.Form.Get("grant_type") != "client_credentials" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "unsupported_grant_type",
+ })
+ return
+ }
+
+ // Accept credentials from Basic Auth or form body.
+ clientID, clientSecret, ok := r.BasicAuth()
+ if !ok {
+ clientID = r.Form.Get("client_id")
+ clientSecret = r.Form.Get("client_secret")
+ }
+ if clientID != s.clientID || clientSecret != s.clientSecret {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "invalid_client",
+ })
+ return
+ }
+
+ token, err := s.signJWT()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ resp := map[string]any{
+ "access_token": token,
+ "token_type": "Bearer",
+ }
+ if s.expiresIn > 0 {
+ resp["expires_in"] = s.expiresIn
+ }
+
+ s.tokenRequestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) signJWT() (string, error) {
+ now := time.Now()
+ header, _ := json.Marshal(map[string]string{
+ "alg": "RS256",
+ "kid": s.kid,
+ "typ": "JWT",
+ })
+ claims, _ := json.Marshal(map[string]any{
+ "iss": s.Issuer(),
+ "sub": s.subject,
+ "aud": s.audience,
+ "iat": now.Unix(),
+ "exp": now.Add(1 * time.Hour).Unix(),
+ })
+
+ headerB64 := base64.RawURLEncoding.EncodeToString(header)
+ claimsB64 := base64.RawURLEncoding.EncodeToString(claims)
+ signingInput := headerB64 + "." + claimsB64
+
+ h := sha256.Sum256([]byte(signingInput))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])
+ if err != nil {
+ return "", err
+ }
+ return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
diff --git a/test/e2e/pkg/port/port.go b/test/e2e/pkg/port/port.go
index 49cc9a68..82ac8586 100644
--- a/test/e2e/pkg/port/port.go
+++ b/test/e2e/pkg/port/port.go
@@ -52,7 +52,7 @@ func (pa *Allocator) GetByName(portName string) int {
pa.mu.Lock()
defer pa.mu.Unlock()
- for i := 0; i < 20; i++ {
+ for range 20 {
port := pa.getByRange(builder.rangePortFrom, builder.rangePortTo)
if port == 0 {
return 0
diff --git a/test/e2e/pkg/process/process.go b/test/e2e/pkg/process/process.go
index e6721e1d..0e05b90d 100644
--- a/test/e2e/pkg/process/process.go
+++ b/test/e2e/pkg/process/process.go
@@ -3,21 +3,45 @@ package process
import (
"bytes"
"context"
+ "errors"
+ "fmt"
"os/exec"
+ "strings"
+ "sync"
+ "time"
)
+// SafeBuffer is a thread-safe wrapper around bytes.Buffer.
+// It is safe to call Write and String concurrently.
+type SafeBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *SafeBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *SafeBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
type Process struct {
cmd *exec.Cmd
cancel context.CancelFunc
- errorOutput *bytes.Buffer
- stdOutput *bytes.Buffer
+ errorOutput *SafeBuffer
+ stdOutput *SafeBuffer
- beforeStopHandler func()
- stopped bool
-}
+ done chan struct{}
+ closeOne sync.Once
+ waitErr error
-func New(path string, params []string) *Process {
- return NewWithEnvs(path, params, nil)
+ started bool
+ stopped bool
}
func NewWithEnvs(path string, params []string, envs []string) *Process {
@@ -27,30 +51,53 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
p := &Process{
cmd: cmd,
cancel: cancel,
+ done: make(chan struct{}),
}
- p.errorOutput = bytes.NewBufferString("")
- p.stdOutput = bytes.NewBufferString("")
+ p.errorOutput = &SafeBuffer{}
+ p.stdOutput = &SafeBuffer{}
cmd.Stderr = p.errorOutput
cmd.Stdout = p.stdOutput
return p
}
func (p *Process) Start() error {
- return p.cmd.Start()
+ if p.started {
+ return errors.New("process already started")
+ }
+ p.started = true
+
+ err := p.cmd.Start()
+ if err != nil {
+ p.waitErr = err
+ p.closeDone()
+ return err
+ }
+ go func() {
+ p.waitErr = p.cmd.Wait()
+ p.closeDone()
+ }()
+ return nil
+}
+
+func (p *Process) closeDone() {
+ p.closeOne.Do(func() { close(p.done) })
+}
+
+// Done returns a channel that is closed when the process exits.
+func (p *Process) Done() <-chan struct{} {
+ return p.done
}
func (p *Process) Stop() error {
- if p.stopped {
+ if p.stopped || !p.started {
return nil
}
defer func() {
p.stopped = true
}()
- if p.beforeStopHandler != nil {
- p.beforeStopHandler()
- }
p.cancel()
- return p.cmd.Wait()
+ <-p.done
+ return p.waitErr
}
func (p *Process) ErrorOutput() string {
@@ -61,6 +108,34 @@ func (p *Process) StdOutput() string {
return p.stdOutput.String()
}
-func (p *Process) SetBeforeStopHandler(fn func()) {
- p.beforeStopHandler = fn
+func (p *Process) Output() string {
+ return p.stdOutput.String() + p.errorOutput.String()
+}
+
+// CountOutput returns how many times pattern appears in the current accumulated output.
+func (p *Process) CountOutput(pattern string) int {
+ return strings.Count(p.Output(), pattern)
+}
+
+// WaitForOutput polls the combined process output until the pattern is found
+// count time(s) or the timeout is reached. It also returns early if the process exits.
+func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ output := p.Output()
+ if strings.Count(output, pattern) >= count {
+ return nil
+ }
+ select {
+ case <-p.Done():
+ // Process exited, check one last time.
+ output = p.Output()
+ if strings.Count(output, pattern) >= count {
+ return nil
+ }
+ return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern)
+ case <-time.After(25 * time.Millisecond):
+ }
+ }
+ return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern)
}
diff --git a/test/e2e/pkg/request/request.go b/test/e2e/pkg/request/request.go
index 211cc425..c3b72669 100644
--- a/test/e2e/pkg/request/request.go
+++ b/test/e2e/pkg/request/request.go
@@ -22,11 +22,10 @@ type Request struct {
protocol string
// for all protocol
- addr string
- port int
- body []byte
- timeout time.Duration
- resolver *net.Resolver
+ addr string
+ port int
+ body []byte
+ timeout time.Duration
// for http or https
method string
@@ -134,11 +133,6 @@ func (r *Request) Body(content []byte) *Request {
return r
}
-func (r *Request) Resolver(resolver *net.Resolver) *Request {
- r.resolver = resolver
- return r
-}
-
func (r *Request) Do() (*Response, error) {
var (
conn net.Conn
@@ -169,7 +163,7 @@ func (r *Request) Do() (*Response, error) {
return nil, err
}
} else {
- dialer := &net.Dialer{Resolver: r.resolver}
+ dialer := &net.Dialer{}
switch r.protocol {
case "tcp":
conn, err = dialer.Dial("tcp", addr)
@@ -225,7 +219,6 @@ func (r *Request) sendHTTPRequest(method, urlstr string, host string, headers ma
Timeout: time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
- Resolver: r.resolver,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
diff --git a/test/e2e/pkg/ssh/client.go b/test/e2e/pkg/ssh/client.go
index b45e39da..d2525871 100644
--- a/test/e2e/pkg/ssh/client.go
+++ b/test/e2e/pkg/ssh/client.go
@@ -75,11 +75,11 @@ func (c *TunnelClient) serveListener() {
if err != nil {
return
}
- go c.hanldeConn(conn)
+ go c.handleConn(conn)
}
}
-func (c *TunnelClient) hanldeConn(conn net.Conn) {
+func (c *TunnelClient) handleConn(conn net.Conn) {
defer conn.Close()
local, err := net.Dial("tcp", c.localAddr)
if err != nil {
diff --git a/test/e2e/v1/basic/annotations.go b/test/e2e/v1/basic/annotations.go
index 4f4a8a91..78e94a16 100644
--- a/test/e2e/v1/basic/annotations.go
+++ b/test/e2e/v1/basic/annotations.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Annotations]", func() {
"frp.e2e.test/bar" = "value2"
`, framework.TCPEchoServerPort, p1Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
diff --git a/test/e2e/v1/basic/basic.go b/test/e2e/v1/basic/basic.go
index 9d9d34cb..8994ba73 100644
--- a/test/e2e/v1/basic/basic.go
+++ b/test/e2e/v1/basic/basic.go
@@ -26,7 +26,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
localPortName := ""
protocol := "tcp"
@@ -79,10 +80,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).
@@ -103,7 +104,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
vhostHTTPPort = %d
`, vhostHTTPPort)
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
@@ -149,13 +151,13 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com")
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
domain = strings.TrimLeft(domain, "[\"")
domain = strings.TrimRight(domain, "]\"")
@@ -189,7 +191,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
`, vhostHTTPSPort)
localPort := f.AllocPort()
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
[[proxies]]
@@ -234,10 +237,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com")
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -249,7 +252,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
f.RunServer("", localServer)
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
domain = strings.TrimLeft(domain, "[\"")
domain = strings.TrimRight(domain, "]\"")
@@ -289,9 +292,12 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() {
serverConf := consts.DefaultServerConfig
- clientServerConf := consts.DefaultClientConfig + "\nuser = \"user1\""
- clientVisitorConf := consts.DefaultClientConfig + "\nuser = \"user1\""
- clientUser2VisitorConf := consts.DefaultClientConfig + "\nuser = \"user2\""
+ var clientServerConf strings.Builder
+ clientServerConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"")
+ var clientVisitorConf strings.Builder
+ clientVisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"")
+ var clientUser2VisitorConf strings.Builder
+ clientUser2VisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user2\"")
localPortName := ""
protocol := "tcp"
@@ -407,20 +413,20 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientServerConf += getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n"
+ clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n")
}
for _, test := range tests {
config := getProxyVisitorConf(
test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig,
) + "\n"
if test.deployUser2Client {
- clientUser2VisitorConf += config
+ clientUser2VisitorConf.WriteString(config)
} else {
- clientVisitorConf += config
+ clientVisitorConf.WriteString(config)
}
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientServerConf, clientVisitorConf, clientUser2VisitorConf})
+ f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests {
timeout := time.Second
@@ -447,7 +453,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
ginkgo.Describe("TCPMUX", func() {
ginkgo.It("Type tcpmux", func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
tcpmuxHTTPConnectPortName := port.GenName("TCPMUX")
serverConf += fmt.Sprintf(`
@@ -491,14 +498,14 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n")
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))
f.RunServer(port.GenName(test.proxyName), localServer)
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
// Request without HTTP connect should get error
framework.NewRequestExpect(f).
diff --git a/test/e2e/v1/basic/client.go b/test/e2e/v1/basic/client.go
index fd269d75..ec2011ea 100644
--- a/test/e2e/v1/basic/client.go
+++ b/test/e2e/v1/basic/client.go
@@ -51,7 +51,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
webServer.password = "admin"
`, dashboardPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
@@ -120,7 +120,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remotePort = %d
`, adminPort, framework.TCPEchoServerPort, testPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure()
diff --git a/test/e2e/v1/basic/client_server.go b/test/e2e/v1/basic/client_server.go
index 85dd1227..3a2fa28d 100644
--- a/test/e2e/v1/basic/client_server.go
+++ b/test/e2e/v1/basic/client_server.go
@@ -78,7 +78,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf)
}
- f.RunProcesses([]string{serverConf}, clientConfs)
+ f.RunProcesses(serverConf, clientConfs)
if configures.testDelay > 0 {
time.Sleep(configures.testDelay)
diff --git a/test/e2e/v1/basic/config.go b/test/e2e/v1/basic/config.go
index 314e7fc4..e290109e 100644
--- a/test/e2e/v1/basic/config.go
+++ b/test/e2e/v1/basic/config.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"})
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -69,7 +69,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
escapeTemplate("{{- end }}"),
)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
checkProxyFn := func(name string, localPort, remotePort int) {
@@ -149,7 +149,7 @@ proxies:
remotePort: %d
`, port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -161,7 +161,7 @@ proxies:
"proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`,
port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
})
diff --git a/test/e2e/v1/basic/http.go b/test/e2e/v1/basic/http.go
index c37e84e7..1a44ba21 100644
--- a/test/e2e/v1/basic/http.go
+++ b/test/e2e/v1/basic/http.go
@@ -59,7 +59,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = ["/bar"]
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tests := []struct {
path string
@@ -117,7 +117,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -144,6 +144,79 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
Ensure()
})
+ ginkgo.It("HTTP proxy mode uses proxy auth consistently", func() {
+ vhostHTTPPort := f.AllocPort()
+ serverConf := getDefaultServerConf(vhostHTTPPort)
+
+ backendPort := f.AllocPort()
+ f.RunServer("", newHTTPServer(backendPort, "PRIVATE"))
+
+ clientConf := consts.DefaultClientConfig
+ clientConf += fmt.Sprintf(`
+ [[proxies]]
+ name = "protected"
+ type = "http"
+ localPort = %d
+ customDomains = ["normal.example.com"]
+ routeByHTTPUser = "alice"
+ httpUser = "alice"
+ httpPassword = "secret"
+ `, backendPort)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+
+ proxyURLWithAuth := func(username, password string) string {
+ if username == "" {
+ return fmt.Sprintf("http://127.0.0.1:%d", vhostHTTPPort)
+ }
+ return fmt.Sprintf("http://%s:%s@127.0.0.1:%d", username, password, vhostHTTPPort)
+ }
+
+ framework.NewRequestExpect(f).Explain("direct no auth").Port(vhostHTTPPort).
+ RequestModify(func(r *request.Request) {
+ r.HTTP().HTTPHost("normal.example.com")
+ }).
+ Ensure(framework.ExpectResponseCode(http.StatusNotFound))
+
+ framework.NewRequestExpect(f).Explain("direct correct auth").Port(vhostHTTPPort).
+ RequestModify(func(r *request.Request) {
+ r.HTTP().HTTPHost("normal.example.com").HTTPAuth("alice", "secret")
+ }).
+ ExpectResp([]byte("PRIVATE")).
+ Ensure()
+
+ framework.NewRequestExpect(f).Explain("direct wrong auth").Port(vhostHTTPPort).
+ RequestModify(func(r *request.Request) {
+ r.HTTP().HTTPHost("normal.example.com").HTTPAuth("alice", "wrong")
+ }).
+ Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
+
+ framework.NewRequestExpect(f).Explain("proxy correct proxy auth").
+ RequestModify(func(r *request.Request) {
+ r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "secret"))
+ }).
+ ExpectResp([]byte("PRIVATE")).
+ Ensure()
+
+ framework.NewRequestExpect(f).Explain("proxy wrong proxy auth").
+ RequestModify(func(r *request.Request) {
+ r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "wrong"))
+ }).
+ Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
+
+ framework.NewRequestExpect(f).Explain("proxy request ignores authorization header").
+ RequestModify(func(r *request.Request) {
+ r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("", "")).HTTPAuth("alice", "secret")
+ }).
+ Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
+
+ framework.NewRequestExpect(f).Explain("proxy wrong proxy auth with correct authorization").
+ RequestModify(func(r *request.Request) {
+ r.HTTP().Addr("normal.example.com").Proxy(proxyURLWithAuth("alice", "wrong")).HTTPAuth("alice", "secret")
+ }).
+ Ensure(framework.ExpectResponseCode(http.StatusProxyAuthRequired))
+ })
+
ginkgo.It("HTTP Basic Auth", func() {
vhostHTTPPort := f.AllocPort()
serverConf := getDefaultServerConf(vhostHTTPPort)
@@ -159,7 +232,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
httpPassword = "test"
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -196,7 +269,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["*.example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -248,7 +321,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = "bar"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -290,7 +363,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
requestHeaders.set.x-from-where = "frp"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -323,7 +396,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
responseHeaders.set.x-from-where = "frp"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -357,7 +430,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
hostHeaderRewrite = "rewrite.example.com"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -406,7 +479,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["127.0.0.1"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
@@ -447,7 +520,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/v1/basic/oidc.go b/test/e2e/v1/basic/oidc.go
new file mode 100644
index 00000000..19539c76
--- /dev/null
+++ b/test/e2e/v1/basic/oidc.go
@@ -0,0 +1,192 @@
+// 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 basic
+
+import (
+ "fmt"
+ "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/mock/server/oidcserver"
+ "github.com/fatedier/frp/test/e2e/pkg/port"
+)
+
+var _ = ginkgo.Describe("[Feature: OIDC]", func() {
+ f := framework.NewDefaultFramework()
+
+ ginkgo.It("should work with OIDC authentication", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+ })
+
+ ginkgo.It("should authenticate heartbeats with OIDC", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ serverPort := f.AllocPort()
+ remotePort := f.AllocPort()
+
+ serverConf := fmt.Sprintf(`
+bindAddr = "0.0.0.0"
+bindPort = %d
+log.level = "trace"
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, serverPort, oidcSrv.Issuer())
+
+ clientConf := fmt.Sprintf(`
+serverAddr = "127.0.0.1"
+serverPort = %d
+loginFailExit = false
+log.level = "trace"
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+transport.heartbeatInterval = 1
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = %d
+remotePort = %d
+`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)
+
+ serverConfigPath := f.GenerateConfigFile(serverConf)
+ clientConfigPath := f.GenerateConfigFile(clientConf)
+
+ _, _, err := f.RunFrps("-c", serverConfigPath)
+ framework.ExpectNoError(err)
+ clientProcess, _, err := f.RunFrpc("-c", clientConfigPath)
+ framework.ExpectNoError(err)
+
+ // Wait for several authenticated heartbeat cycles instead of a fixed sleep.
+ err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second)
+ framework.ExpectNoError(err)
+
+ // Proxy should still work: heartbeat auth has not failed.
+ framework.NewRequestExpect(f).Port(remotePort).Ensure()
+ })
+
+ ginkgo.It("should work when token has no expires_in", func() {
+ oidcSrv := oidcserver.New(
+ oidcserver.WithBindPort(f.AllocPort()),
+ oidcserver.WithExpiresIn(0),
+ )
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+transport.heartbeatInterval = 1
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+
+ countAfterLogin := oidcSrv.TokenRequestCount()
+
+ // Wait for several heartbeat cycles instead of a fixed sleep.
+ // Each heartbeat fetches a fresh token in non-caching mode.
+ err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second)
+ framework.ExpectNoError(err)
+
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+
+ // Each heartbeat should have fetched a new token (non-caching mode).
+ countAfterHeartbeats := oidcSrv.TokenRequestCount()
+ framework.ExpectTrue(
+ countAfterHeartbeats > countAfterLogin,
+ "expected additional token requests for heartbeats, got %d before and %d after",
+ countAfterLogin, countAfterHeartbeats,
+ )
+ })
+
+ ginkgo.It("should reject invalid OIDC credentials", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "wrong-secret"
+auth.oidc.tokenEndpointURL = "%s"
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
+ })
+})
diff --git a/test/e2e/v1/basic/server.go b/test/e2e/v1/basic/server.go
index a3fe5992..04ae01b7 100644
--- a/test/e2e/v1/basic/server.go
+++ b/test/e2e/v1/basic/server.go
@@ -20,6 +20,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
ginkgo.It("Ports Whitelist", func() {
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig
+ tcpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
+ udpPortNotAllowed := f.AllocPortExcludingRanges([2]int{10000, 11000}, [2]int{11002, 11002}, [2]int{12000, 13000})
serverConf += `
allowPorts = [
@@ -33,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000))
clientConf += fmt.Sprintf(`
[[proxies]]
- name = "tcp-allowded-in-range"
+ name = "tcp-allowed-in-range"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
@@ -43,8 +45,8 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
name = "tcp-port-not-allowed"
type = "tcp"
localPort = {{ .%s }}
- remotePort = 11001
- `, framework.TCPEchoServerPort)
+ remotePort = %d
+ `, framework.TCPEchoServerPort, tcpPortNotAllowed)
clientConf += fmt.Sprintf(`
[[proxies]]
name = "tcp-port-unavailable"
@@ -64,17 +66,17 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
name = "udp-port-not-allowed"
type = "udp"
localPort = {{ .%s }}
- remotePort = 11003
- `, framework.UDPEchoServerPort)
+ remotePort = %d
+ `, framework.UDPEchoServerPort, udpPortNotAllowed)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// TCP
// Allowed in range
framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
// Not Allowed
- framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()
+ framework.NewRequestExpect(f).Port(tcpPortNotAllowed).ExpectError(true).Ensure()
// Unavailable, already bind by frps
framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()
@@ -85,7 +87,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
// Not Allowed
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
- r.UDP().Port(11003)
+ r.UDP().Port(udpPortNotAllowed)
}).ExpectError(true).Ensure()
})
@@ -108,7 +110,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
localPort = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
@@ -150,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com")
@@ -178,7 +180,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
diff --git a/test/e2e/v1/basic/tcpmux.go b/test/e2e/v1/basic/tcpmux.go
index 7ee58a79..9b839faa 100644
--- a/test/e2e/v1/basic/tcpmux.go
+++ b/test/e2e/v1/basic/tcpmux.go
@@ -79,7 +79,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").
@@ -125,7 +125,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
httpPassword = "test"
`, fooPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Explain("no auth").
@@ -209,7 +209,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/v1/basic/token_source.go b/test/e2e/v1/basic/token_source.go
index 4ed7051c..d12dd4de 100644
--- a/test/e2e/v1/basic/token_source.go
+++ b/test/e2e/v1/basic/token_source.go
@@ -16,8 +16,11 @@ package basic
import (
"fmt"
+ "net"
"os"
"path/filepath"
+ "strconv"
+ "time"
"github.com/onsi/ginkgo/v2"
@@ -73,7 +76,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenContent, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -109,7 +112,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -150,7 +153,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -190,7 +193,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// This should fail due to token mismatch - the client should not be able to connect
// We expect the request to fail because the proxy tunnel is not established
@@ -198,32 +201,27 @@ remotePort = {{ .%s }}
})
ginkgo.It("should fail with non-existent token file", func() {
- // This test verifies that server fails to start when tokenSource points to non-existent file
- // We'll verify this by checking that the configuration loading itself fails
-
- // Create a config that references a non-existent file
tmpDir := f.TempDirectory
nonExistentFile := filepath.Join(tmpDir, "non_existent_token")
- serverConf := consts.DefaultServerConfig
-
- // Server config with non-existent tokenSource file
- serverConf += fmt.Sprintf(`
+ serverPort := f.AllocPort()
+ serverConf := fmt.Sprintf(`
+bindAddr = "0.0.0.0"
+bindPort = %d
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
-`, nonExistentFile)
+`, serverPort, nonExistentFile)
- // The test expectation is that this will fail during the RunProcesses call
- // because the server cannot load the configuration due to missing token file
- defer func() {
- if r := recover(); r != nil {
- // Expected: server should fail to start due to missing file
- ginkgo.By(fmt.Sprintf("Server correctly failed to start: %v", r))
- }
- }()
+ serverConfigPath := f.GenerateConfigFile(serverConf)
- // This should cause a panic or error during server startup
- f.RunProcesses([]string{serverConf}, []string{})
+ _, _, _ = f.RunFrps("-c", serverConfigPath)
+
+ // Server should have failed to start, so the port should not be listening.
+ conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(serverPort)), 1*time.Second)
+ if err == nil {
+ conn.Close()
+ }
+ framework.ExpectTrue(err != nil, "server should not be listening on port %d", serverPort)
})
})
diff --git a/test/e2e/v1/basic/wire.go b/test/e2e/v1/basic/wire.go
new file mode 100644
index 00000000..051e6a6c
--- /dev/null
+++ b/test/e2e/v1/basic/wire.go
@@ -0,0 +1,230 @@
+package basic
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "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/port"
+)
+
+var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
+ f := framework.NewDefaultFramework()
+
+ ginkgo.It("v1 tcp and udp proxies", func() {
+ serverConf := consts.DefaultServerConfig
+ tcpPortName := port.GenName("WireV1TCP")
+ udpPortName := port.GenName("WireV1UDP")
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ transport.wireProtocol = "v1"
+
+ [[proxies]]
+ name = "tcp"
+ type = "tcp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+
+ [[proxies]]
+ name = "udp"
+ type = "udp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+ `, framework.TCPEchoServerPort, tcpPortName, framework.UDPEchoServerPort, udpPortName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+
+ framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
+ framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure()
+ })
+
+ ginkgo.It("v2 tcp and udp proxies", func() {
+ serverConf := consts.DefaultServerConfig
+ tcpPortName := port.GenName("WireV2TCP")
+ udpPortName := port.GenName("WireV2UDP")
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ transport.wireProtocol = "v2"
+
+ [[proxies]]
+ name = "tcp"
+ type = "tcp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+
+ [[proxies]]
+ name = "udp"
+ type = "udp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+ `, framework.TCPEchoServerPort, tcpPortName, framework.UDPEchoServerPort, udpPortName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+
+ framework.NewRequestExpect(f).PortName(tcpPortName).Ensure()
+ framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure()
+ })
+
+ ginkgo.It("v2 stcp visitor", func() {
+ serverConf := consts.DefaultServerConfig
+ bindPortName := port.GenName("WireV2STCP")
+ clientServerConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ user = "user1"
+ transport.wireProtocol = "v2"
+
+ [[proxies]]
+ name = "stcp"
+ type = "stcp"
+ secretKey = "abc"
+ localPort = {{ .%s }}
+ `, framework.TCPEchoServerPort)
+ clientVisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ user = "user1"
+ transport.wireProtocol = "v2"
+
+ [[visitors]]
+ name = "stcp-visitor"
+ type = "stcp"
+ serverName = "stcp"
+ secretKey = "abc"
+ bindPort = {{ .%s }}
+ `, bindPortName)
+
+ f.RunProcesses(serverConf, []string{clientServerConf, clientVisitorConf})
+
+ framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
+ })
+
+ for _, tc := range []struct {
+ name string
+ proxyWireConfig string
+ visitorWireConfig string
+ extraProxyConfig string
+ extraVisitorConfig string
+ }{
+ {
+ name: "default sudp visitor",
+ },
+ {
+ name: "v2 sudp visitor",
+ proxyWireConfig: `transport.wireProtocol = "v2"`,
+ visitorWireConfig: `transport.wireProtocol = "v2"`,
+ },
+ {
+ name: "mixed sudp proxy v1 visitor v2",
+ proxyWireConfig: `transport.wireProtocol = "v1"`,
+ visitorWireConfig: `transport.wireProtocol = "v2"`,
+ extraProxyConfig: `
+ transport.useEncryption = true
+ transport.useCompression = true
+ `,
+ extraVisitorConfig: `
+ transport.useEncryption = true
+ transport.useCompression = true
+ `,
+ },
+ {
+ name: "mixed sudp proxy v2 visitor v1",
+ proxyWireConfig: `transport.wireProtocol = "v2"`,
+ visitorWireConfig: `transport.wireProtocol = "v1"`,
+ },
+ } {
+ ginkgo.It(tc.name, func() {
+ serverConf := consts.DefaultServerConfig
+ bindPortName := port.GenName("WireSUDP")
+ clientServerConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ user = "user1"
+ %s
+
+ [[proxies]]
+ name = "sudp"
+ type = "sudp"
+ secretKey = "abc"
+ localPort = {{ .%s }}
+ %s
+ `, tc.proxyWireConfig, framework.UDPEchoServerPort, tc.extraProxyConfig)
+ clientVisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ user = "user1"
+ %s
+
+ [[visitors]]
+ name = "sudp-visitor"
+ type = "sudp"
+ serverName = "sudp"
+ secretKey = "abc"
+ bindPort = {{ .%s }}
+ %s
+ `, tc.visitorWireConfig, bindPortName, tc.extraVisitorConfig)
+
+ f.RunProcesses(serverConf, []string{clientServerConf, clientVisitorConf})
+
+ framework.NewRequestExpect(f).Protocol("udp").PortName(bindPortName).Ensure()
+ })
+ }
+
+ ginkgo.It("reports client wire protocol", func() {
+ webPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+ webServer.port = %d
+ `, webPort)
+
+ v1PortName := port.GenName("WireReportV1")
+ v1ClientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ clientID = "wire-v1"
+ transport.wireProtocol = "v1"
+
+ [[proxies]]
+ name = "v1"
+ type = "tcp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+ `, framework.TCPEchoServerPort, v1PortName)
+
+ v2PortName := port.GenName("WireReportV2")
+ v2ClientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ clientID = "wire-v2"
+ transport.wireProtocol = "v2"
+
+ [[proxies]]
+ name = "v2"
+ type = "tcp"
+ localPort = {{ .%s }}
+ remotePort = {{ .%s }}
+ `, framework.TCPEchoServerPort, v2PortName)
+
+ f.RunProcesses(serverConf, []string{v1ClientConf, v2ClientConf})
+
+ framework.NewRequestExpect(f).PortName(v1PortName).Ensure()
+ framework.NewRequestExpect(f).PortName(v2PortName).Ensure()
+ expectClientWireProtocol(webPort, "wire-v1", "v1")
+ expectClientWireProtocol(webPort, "wire-v2", "v2")
+ })
+})
+
+type wireClientInfo struct {
+ ClientID string `json:"clientID"`
+ WireProtocol string `json:"wireProtocol"`
+}
+
+func expectClientWireProtocol(webPort int, clientID string, wireProtocol string) {
+ resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/clients", webPort))
+ framework.ExpectNoError(err)
+ defer resp.Body.Close()
+ framework.ExpectEqual(resp.StatusCode, 200)
+
+ content, err := io.ReadAll(resp.Body)
+ framework.ExpectNoError(err)
+
+ var clients []wireClientInfo
+ framework.ExpectNoError(json.Unmarshal(content, &clients))
+ for _, client := range clients {
+ if client.ClientID == clientID {
+ framework.ExpectEqual(client.WireProtocol, wireProtocol)
+ return
+ }
+ }
+ framework.Failf("client %q not found in /api/clients response: %s", clientID, string(content))
+}
diff --git a/test/e2e/v1/basic/xtcp.go b/test/e2e/v1/basic/xtcp.go
index a5aaf67b..57905f0d 100644
--- a/test/e2e/v1/basic/xtcp.go
+++ b/test/e2e/v1/basic/xtcp.go
@@ -42,7 +42,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallbackTimeoutMs = 200
`, framework.TCPEchoServerPort, bindPortName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
r.Timeout(time.Second)
diff --git a/test/e2e/v1/features/bandwidth_limit.go b/test/e2e/v1/features/bandwidth_limit.go
index efcf38ed..11503adb 100644
--- a/test/e2e/v1/features/bandwidth_limit.go
+++ b/test/e2e/v1/features/bandwidth_limit.go
@@ -36,7 +36,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
transport.bandwidthLimit = "10KB"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remotePort = %d
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
diff --git a/test/e2e/v1/features/chaos.go b/test/e2e/v1/features/chaos.go
index f5f8b388..8c515251 100644
--- a/test/e2e/v1/features/chaos.go
+++ b/test/e2e/v1/features/chaos.go
@@ -41,24 +41,24 @@ var _ = ginkgo.Describe("[Feature: Chaos]", func() {
// 2. stop frps, expect request failed
_ = ps.Stop()
- time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 3. restart frps, expect request success
+ successCount := pc.CountOutput("[tcp] start proxy success")
_, _, err = f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).Ensure()
// 4. stop frpc, expect request failed
_ = pc.Stop()
- time.Sleep(200 * time.Millisecond)
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 5. restart frpc, expect request success
- _, _, err = f.RunFrpc("-c", clientConfigPath)
+ newPc, _, err := f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err)
- time.Sleep(time.Second)
+ framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
})
diff --git a/test/e2e/v1/features/group.go b/test/e2e/v1/features/group.go
index f6bb1856..85b938a9 100644
--- a/test/e2e/v1/features/group.go
+++ b/test/e2e/v1/features/group.go
@@ -50,12 +50,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
return true
})
}
- for i := 0; i < 10; i++ {
- wait.Add(1)
- go func() {
- defer wait.Done()
+ for range 10 {
+ wait.Go(func() {
expectFn()
- }()
+ })
}
wait.Wait()
@@ -94,11 +92,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {
switch string(resp.Content) {
case "foo":
@@ -159,11 +157,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).
Explain("times " + strconv.Itoa(i)).
Port(vhostHTTPSPort).
@@ -188,6 +186,68 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
})
+
+ ginkgo.It("TCPMux httpconnect", func() {
+ vhostPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+ tcpmuxHTTPConnectPort = %d
+ `, vhostPort)
+ clientConf := consts.DefaultClientConfig
+
+ fooPort := f.AllocPort()
+ fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo")))
+ f.RunServer("", fooServer)
+
+ barPort := f.AllocPort()
+ barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
+ f.RunServer("", barServer)
+
+ clientConf += fmt.Sprintf(`
+ [[proxies]]
+ name = "foo"
+ type = "tcpmux"
+ multiplexer = "httpconnect"
+ localPort = %d
+ customDomains = ["tcpmux-group.example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+
+ [[proxies]]
+ name = "bar"
+ type = "tcpmux"
+ multiplexer = "httpconnect"
+ localPort = %d
+ customDomains = ["tcpmux-group.example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+ `, fooPort, barPort)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+
+ proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort)
+ fooCount := 0
+ barCount := 0
+ for i := range 10 {
+ framework.NewRequestExpect(f).
+ Explain("times " + strconv.Itoa(i)).
+ RequestModify(func(r *request.Request) {
+ r.Addr("tcpmux-group.example.com").Proxy(proxyURL)
+ }).
+ Ensure(func(resp *request.Response) bool {
+ switch string(resp.Content) {
+ case "foo":
+ fooCount++
+ case "bar":
+ barCount++
+ default:
+ return false
+ }
+ return true
+ })
+ }
+
+ framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
+ })
})
ginkgo.Describe("Health Check", func() {
@@ -226,11 +286,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.intervalSeconds = 1
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
// check foo and bar is ok
results := []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -239,17 +299,19 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
+ failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
- time.Sleep(2 * time.Second)
- for i := 0; i < 10; i++ {
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
// resume bar server, check foo and bar is ok
+ successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
results = []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -297,7 +359,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.path = "/healthz"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
// send first HTTP request
var contents []string
@@ -327,15 +389,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
+ failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo"})
framework.ExpectNotContainElements(results, []string{"bar"})
// resume bar server, check foo and bar is ok
+ successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
})
diff --git a/test/e2e/v1/features/heartbeat.go b/test/e2e/v1/features/heartbeat.go
index 4f5409fe..08a1b5d9 100644
--- a/test/e2e/v1/features/heartbeat.go
+++ b/test/e2e/v1/features/heartbeat.go
@@ -37,7 +37,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
diff --git a/test/e2e/v1/features/monitor.go b/test/e2e/v1/features/monitor.go
index fe92203a..f70dce16 100644
--- a/test/e2e/v1/features/monitor.go
+++ b/test/e2e/v1/features/monitor.go
@@ -34,7 +34,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond)
diff --git a/test/e2e/v1/features/real_ip.go b/test/e2e/v1/features/real_ip.go
index a52cf0a2..6bebfc2b 100644
--- a/test/e2e/v1/features/real_ip.go
+++ b/test/e2e/v1/features/real_ip.go
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -112,7 +112,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -212,7 +212,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -262,7 +262,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -309,7 +309,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com")
diff --git a/test/e2e/v1/features/ssh_tunnel.go b/test/e2e/v1/features/ssh_tunnel.go
index 0b8725f6..c120eab0 100644
--- a/test/e2e/v1/features/ssh_tunnel.go
+++ b/test/e2e/v1/features/ssh_tunnel.go
@@ -3,6 +3,8 @@ package features
import (
"crypto/tls"
"fmt"
+ "net"
+ "strconv"
"time"
"github.com/onsi/ginkgo/v2"
@@ -25,7 +27,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort)
remotePort := f.AllocPort()
@@ -49,7 +52,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.HTTPSimpleServerPort)
tc := ssh.NewTunnelClient(
@@ -76,7 +80,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort()
testDomain := "test.example.com"
@@ -118,7 +123,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, tcpmuxPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort()
testDomain := "test.example.com"
@@ -173,7 +179,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
bindPort = %d
`, bindPort)
- f.RunProcesses([]string{serverConf}, []string{visitorConf})
+ f.RunProcesses(serverConf, []string{visitorConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort)
tc := ssh.NewTunnelClient(
diff --git a/test/e2e/v1/features/store.go b/test/e2e/v1/features/store.go
new file mode 100644
index 00000000..76a9b05a
--- /dev/null
+++ b/test/e2e/v1/features/store.go
@@ -0,0 +1,322 @@
+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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ }
+ proxyBody, _ := json.Marshal(proxyConfig)
+
+ 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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort1,
+ },
+ }
+ proxyBody, _ := json.Marshal(proxyConfig)
+
+ 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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort1).Ensure()
+
+ proxyConfig["tcp"].(map[string]any)["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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second))
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort2).Ensure()
+ framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ }
+ proxyBody, _ := json.Marshal(proxyConfig)
+
+ 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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ }
+ proxyBody, _ := json.Marshal(proxyConfig)
+
+ 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
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ 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
+ })
+ })
+
+ ginkgo.It("rejects mismatched type block", func() {
+ adminPort := f.AllocPort()
+
+ serverConf := consts.DefaultServerConfig
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ webServer.addr = "127.0.0.1"
+ webServer.port = %d
+
+ [store]
+ path = "%s/store.json"
+ `, adminPort, f.TempDirectory)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ invalidBody, _ := json.Marshal(map[string]any{
+ "name": "bad-proxy",
+ "type": "tcp",
+ "udp": map[string]any{
+ "localPort": 1234,
+ },
+ })
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
+ "Content-Type": "application/json",
+ }).Body(invalidBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 400
+ })
+ })
+
+ ginkgo.It("rejects path/body name mismatch on update", func() {
+ adminPort := f.AllocPort()
+ remotePort := f.AllocPort()
+
+ serverConf := consts.DefaultServerConfig
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+ webServer.addr = "127.0.0.1"
+ webServer.port = %d
+
+ [store]
+ path = "%s/store.json"
+ `, adminPort, f.TempDirectory)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ createBody, _ := json.Marshal(map[string]any{
+ "name": "proxy-a",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ })
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
+ "Content-Type": "application/json",
+ }).Body(createBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 200
+ })
+
+ updateBody, _ := json.Marshal(map[string]any{
+ "name": "proxy-b",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ })
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/proxy-a").HTTPParams("PUT", "", "/api/store/proxies/proxy-a", map[string]string{
+ "Content-Type": "application/json",
+ }).Body(updateBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 400
+ })
+ })
+ })
+})
diff --git a/test/e2e/v1/plugin/client.go b/test/e2e/v1/plugin/client.go
index 73e2d863..1ea25a6e 100644
--- a/test/e2e/v1/plugin/client.go
+++ b/test/e2e/v1/plugin/client.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strconv"
+ "strings"
"github.com/onsi/ginkgo/v2"
@@ -23,7 +24,8 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ginkgo.Describe("UnixDomainSocket", func() {
ginkgo.It("Expose a unix domain socket echo server", func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, portName string, extra string) string {
return fmt.Sprintf(`
@@ -69,10 +71,10 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -96,7 +98,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123"
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -130,7 +132,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
password = "123"
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -180,7 +182,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123"
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// from tcp proxy
framework.NewRequestExpect(f).Request(
@@ -216,7 +218,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -262,7 +264,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -308,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -348,7 +350,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
hostHeaderRewrite = "rewrite.test.com"
`, remotePort, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -383,7 +385,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
requestHeaders.set.x-from-where = "frp"
`, remotePort, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -429,7 +431,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
diff --git a/test/e2e/v1/plugin/server.go b/test/e2e/v1/plugin/server.go
index 6637650d..fd684ef5 100644
--- a/test/e2e/v1/plugin/server.go
+++ b/test/e2e/v1/plugin/server.go
@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort2)
- f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
+ f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -160,7 +160,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = 0
`, framework.TCPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clients := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -261,7 +261,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
diff --git a/web/frpc/.eslintrc.cjs b/web/frpc/.eslintrc.cjs
deleted file mode 100644
index d1ed4702..00000000
--- a/web/frpc/.eslintrc.cjs
+++ /dev/null
@@ -1,30 +0,0 @@
-/* eslint-env node */
-require('@rushstack/eslint-patch/modern-module-resolution')
-
-module.exports = {
- root: true,
- extends: [
- 'plugin:vue/vue3-essential',
- 'eslint:recommended',
- '@vue/eslint-config-typescript',
- '@vue/eslint-config-prettier',
- ],
- parserOptions: {
- ecmaVersion: 'latest',
- },
- rules: {
- '@typescript-eslint/no-unused-vars': [
- 'warn',
- {
- argsIgnorePattern: '^_',
- varsIgnorePattern: '^_',
- },
- ],
- 'vue/multi-word-component-names': [
- 'error',
- {
- ignores: ['Overview'],
- },
- ],
- },
-}
diff --git a/web/frpc/Makefile b/web/frpc/Makefile
index 6d2bdcf8..adf015e5 100644
--- a/web/frpc/Makefile
+++ b/web/frpc/Makefile
@@ -1,7 +1,7 @@
.PHONY: dist install build preview lint
install:
- @npm install
+ @cd .. && npm install
build: install
@npm run build
diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts
index f9d4522a..c58d0bca 100644
--- a/web/frpc/components.d.ts
+++ b/web/frpc/components.d.ts
@@ -7,20 +7,39 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
- 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']
+ ConfigField: typeof import('./src/components/ConfigField.vue')['default']
+ ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']
+ ElDialog: typeof import('element-plus/es')['ElDialog']
+ 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']
- ElRow: typeof import('element-plus/es')['ElRow']
+ ElPopover: typeof import('element-plus/es')['ElPopover']
+ ElRadio: typeof import('element-plus/es')['ElRadio']
+ ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
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']
+ ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default']
+ ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default']
+ ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
+ ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default']
+ ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default']
+ ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default']
+ ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default']
+ ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default']
+ ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default']
+ ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default']
+ ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
- StatCard: typeof import('./src/components/StatCard.vue')['default']
+ StatusPills: typeof import('./src/components/StatusPills.vue')['default']
+ StringListEditor: typeof import('./src/components/StringListEditor.vue')['default']
+ VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
+ VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
+ VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
+ VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
+ VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
diff --git a/web/frpc/embed.go b/web/frpc/embed.go
index ce06ff90..77b4863e 100644
--- a/web/frpc/embed.go
+++ b/web/frpc/embed.go
@@ -1,3 +1,5 @@
+//go:build !noweb
+
package frpc
import (
diff --git a/web/frpc/embed_stub.go b/web/frpc/embed_stub.go
new file mode 100644
index 00000000..591a770a
--- /dev/null
+++ b/web/frpc/embed_stub.go
@@ -0,0 +1,3 @@
+//go:build noweb
+
+package frpc
diff --git a/web/frpc/eslint.config.js b/web/frpc/eslint.config.js
new file mode 100644
index 00000000..a4cce6d4
--- /dev/null
+++ b/web/frpc/eslint.config.js
@@ -0,0 +1,36 @@
+import pluginVue from 'eslint-plugin-vue'
+import vueTsEslintConfig from '@vue/eslint-config-typescript'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+export default [
+ {
+ name: 'app/files-to-lint',
+ files: ['**/*.{ts,mts,tsx,vue}'],
+ },
+ {
+ name: 'app/files-to-ignore',
+ ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
+ },
+ ...pluginVue.configs['flat/essential'],
+ ...vueTsEslintConfig(),
+ {
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ },
+ ],
+ 'vue/multi-word-component-names': [
+ 'error',
+ {
+ ignores: ['Overview'],
+ },
+ ],
+ },
+ },
+ skipFormatting,
+]
diff --git a/web/frpc/index.html b/web/frpc/index.html
index 0c7caa7b..a893c9c1 100644
--- a/web/frpc/index.html
+++ b/web/frpc/index.html
@@ -3,6 +3,7 @@
+
frp client
diff --git a/web/frpc/package-lock.json b/web/frpc/package-lock.json
deleted file mode 100644
index 9b6626c0..00000000
--- a/web/frpc/package-lock.json
+++ /dev/null
@@ -1,5908 +0,0 @@
-{
- "name": "frpc-dashboard",
- "version": "0.0.1",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "frpc-dashboard",
- "version": "0.0.1",
- "dependencies": {
- "element-plus": "^2.13.0",
- "vue": "^3.5.26",
- "vue-router": "^4.6.4"
- },
- "devDependencies": {
- "@rushstack/eslint-patch": "^1.15.0",
- "@types/node": "24",
- "@vitejs/plugin-vue": "^6.0.3",
- "@vue/eslint-config-prettier": "^9.0.0",
- "@vue/eslint-config-typescript": "^12.0.0",
- "@vue/tsconfig": "^0.8.1",
- "@vueuse/core": "^14.1.0",
- "eslint": "^8.56.0",
- "eslint-plugin-vue": "^9.33.0",
- "npm-run-all": "^4.1.5",
- "prettier": "^3.7.4",
- "sass": "^1.97.2",
- "terser": "^5.44.1",
- "typescript": "^5.9.3",
- "unplugin-auto-import": "^0.17.5",
- "unplugin-element-plus": "^0.11.2",
- "unplugin-vue-components": "^0.26.0",
- "vite": "^7.3.0",
- "vite-svg-loader": "^5.1.0",
- "vue-tsc": "^3.2.2"
- }
- },
- "node_modules/@aashutoshrathi/word-wrap": {
- "version": "1.2.6",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/@antfu/utils": {
- "version": "0.7.7",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.28.5",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/types": "^7.28.5"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.28.5",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@ctrl/tinycolor": {
- "version": "3.6.0",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@element-plus/icons-vue": {
- "version": "2.3.2",
- "license": "MIT",
- "peerDependencies": {
- "vue": "^3.2.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
- "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
- "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
- "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
- "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
- "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.27.2",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
- "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
- "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
- "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
- "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
- "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
- "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
- "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
- "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
- "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
- "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
- "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
- "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
- "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
- "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
- "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
- "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
- "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
- "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.3.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.10.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/js": {
- "version": "8.56.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/@floating-ui/core": {
- "version": "1.2.1",
- "license": "MIT"
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.2.1",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.2.1"
- }
- },
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.11.14",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanwhocodes/object-schema": "^2.0.2",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.2",
- "dev": true,
- "license": "BSD-3-Clause"
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/source-map": {
- "version": "0.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@parcel/watcher": {
- "version": "2.5.1",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "detect-libc": "^1.0.3",
- "is-glob": "^4.0.3",
- "micromatch": "^4.0.5",
- "node-addon-api": "^7.0.0"
- },
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "@parcel/watcher-android-arm64": "2.5.1",
- "@parcel/watcher-darwin-arm64": "2.5.1",
- "@parcel/watcher-darwin-x64": "2.5.1",
- "@parcel/watcher-freebsd-x64": "2.5.1",
- "@parcel/watcher-linux-arm-glibc": "2.5.1",
- "@parcel/watcher-linux-arm-musl": "2.5.1",
- "@parcel/watcher-linux-arm64-glibc": "2.5.1",
- "@parcel/watcher-linux-arm64-musl": "2.5.1",
- "@parcel/watcher-linux-x64-glibc": "2.5.1",
- "@parcel/watcher-linux-x64-musl": "2.5.1",
- "@parcel/watcher-win32-arm64": "2.5.1",
- "@parcel/watcher-win32-ia32": "2.5.1",
- "@parcel/watcher-win32-x64": "2.5.1"
- }
- },
- "node_modules/@parcel/watcher-android-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
- "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-darwin-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
- "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-darwin-x64": {
- "version": "2.5.1",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-freebsd-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
- "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
- "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
- "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
- "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
- "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
- "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
- "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
- "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-ia32": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
- "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
- "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@pkgr/core": {
- "version": "0.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/@popperjs/core": {
- "name": "@sxzz/popperjs-es",
- "version": "2.11.7",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/popperjs"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.53",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/pluginutils": {
- "version": "5.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^2.0.2",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
- },
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
- }
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
- "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
- "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
- "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.55.1",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
- "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
- "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
- "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
- "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
- "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
- "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
- "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
- "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
- "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
- "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
- "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
- "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
- "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
- "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
- "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
- "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
- "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
- "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
- "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
- "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
- "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rushstack/eslint-patch": {
- "version": "1.15.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@trysound/sax": {
- "version": "0.2.0",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/lodash": {
- "version": "4.17.21",
- "license": "MIT"
- },
- "node_modules/@types/lodash-es": {
- "version": "4.17.12",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/lodash": "*"
- }
- },
- "node_modules/@types/node": {
- "version": "24.10.4",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "undici-types": "~7.16.0"
- }
- },
- "node_modules/@types/semver": {
- "version": "7.5.6",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/web-bluetooth": {
- "version": "0.0.21",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@typescript-eslint/eslint-plugin": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/regexpp": "^4.5.1",
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/type-utils": "6.20.0",
- "@typescript-eslint/utils": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.4",
- "natural-compare": "^1.4.0",
- "semver": "^7.5.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/parser": {
- "version": "6.20.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "peer": true,
- "dependencies": {
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/typescript-estree": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/scope-manager": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/type-utils": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/typescript-estree": "6.20.0",
- "@typescript-eslint/utils": "6.20.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/types": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree": {
- "version": "6.20.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "9.0.3",
- "semver": "^7.5.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/@typescript-eslint/utils": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "@types/json-schema": "^7.0.12",
- "@types/semver": "^7.5.0",
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/typescript-estree": "6.20.0",
- "semver": "^7.5.4"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/@vitejs/plugin-vue": {
- "version": "6.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.53"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
- "vue": "^3.2.25"
- }
- },
- "node_modules/@volar/language-core": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/source-map": "2.4.27"
- }
- },
- "node_modules/@volar/source-map": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@volar/typescript": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/language-core": "2.4.27",
- "path-browserify": "^1.0.1",
- "vscode-uri": "^3.0.8"
- }
- },
- "node_modules/@vue/compiler-core": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/shared": "3.5.26",
- "entities": "^7.0.0",
- "estree-walker": "^2.0.2",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-core/node_modules/entities": {
- "version": "7.0.0",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/@vue/compiler-dom": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-core": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/compiler-sfc": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/compiler-core": "3.5.26",
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26",
- "estree-walker": "^2.0.2",
- "magic-string": "^0.30.21",
- "postcss": "^8.5.6",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-ssr": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/devtools-api": {
- "version": "6.6.4",
- "license": "MIT"
- },
- "node_modules/@vue/eslint-config-prettier": {
- "version": "9.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-config-prettier": "^9.0.0",
- "eslint-plugin-prettier": "^5.0.0"
- },
- "peerDependencies": {
- "eslint": ">= 8.0.0",
- "prettier": ">= 3.0.0"
- }
- },
- "node_modules/@vue/eslint-config-typescript": {
- "version": "12.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/eslint-plugin": "^6.7.0",
- "@typescript-eslint/parser": "^6.7.0",
- "vue-eslint-parser": "^9.3.1"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0",
- "eslint-plugin-vue": "^9.0.0",
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@vue/language-core": {
- "version": "3.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/language-core": "2.4.27",
- "@vue/compiler-dom": "^3.5.0",
- "@vue/shared": "^3.5.0",
- "alien-signals": "^3.0.0",
- "muggle-string": "^0.4.1",
- "path-browserify": "^1.0.1",
- "picomatch": "^4.0.2"
- }
- },
- "node_modules/@vue/language-core/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/@vue/reactivity": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-core": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-dom": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/runtime-core": "3.5.26",
- "@vue/shared": "3.5.26",
- "csstype": "^3.2.3"
- }
- },
- "node_modules/@vue/server-renderer": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "vue": "3.5.26"
- }
- },
- "node_modules/@vue/shared": {
- "version": "3.5.26",
- "license": "MIT"
- },
- "node_modules/@vue/tsconfig": {
- "version": "0.8.1",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "typescript": "5.x",
- "vue": "^3.4.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- },
- "vue": {
- "optional": true
- }
- }
- },
- "node_modules/@vueuse/core": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/web-bluetooth": "^0.0.21",
- "@vueuse/metadata": "14.1.0",
- "@vueuse/shared": "14.1.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/@vueuse/metadata": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@vueuse/shared": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/acorn": {
- "version": "8.15.0",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.12.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/alien-signals": {
- "version": "3.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "3.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^1.9.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "dev": true,
- "license": "Python-2.0"
- },
- "node_modules/array-union": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/async-validator": {
- "version": "4.2.5",
- "license": "MIT"
- },
- "node_modules/available-typed-arrays": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/binary-extensions": {
- "version": "2.2.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/boolbase": {
- "version": "1.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/c12": {
- "version": "3.3.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^5.0.0",
- "confbox": "^0.2.2",
- "defu": "^6.1.4",
- "dotenv": "^17.2.3",
- "exsolve": "^1.0.8",
- "giget": "^2.0.0",
- "jiti": "^2.6.1",
- "ohash": "^2.0.11",
- "pathe": "^2.0.3",
- "perfect-debounce": "^2.0.0",
- "pkg-types": "^2.3.0",
- "rc9": "^2.1.2"
- },
- "peerDependencies": {
- "magicast": "*"
- },
- "peerDependenciesMeta": {
- "magicast": {
- "optional": true
- }
- }
- },
- "node_modules/c12/node_modules/chokidar": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "readdirp": "^5.0.0"
- },
- "engines": {
- "node": ">= 20.19.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/c12/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/c12/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/c12/node_modules/readdirp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 20.19.0"
- },
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/call-bind": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1",
- "get-intrinsic": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/callsites": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/chalk": {
- "version": "2.4.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^3.2.1",
- "escape-string-regexp": "^1.0.5",
- "supports-color": "^5.3.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/chalk/node_modules/escape-string-regexp": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.0"
- }
- },
- "node_modules/chokidar": {
- "version": "3.5.3",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/citty": {
- "version": "0.1.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "consola": "^3.2.3"
- }
- },
- "node_modules/color-convert": {
- "version": "1.9.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "1.1.3"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/commander": {
- "version": "2.20.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/confbox": {
- "version": "0.2.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/consola": {
- "version": "3.4.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^14.18.0 || >=16.10.0"
- }
- },
- "node_modules/cross-spawn": {
- "version": "6.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
- "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "nice-try": "^1.0.4",
- "path-key": "^2.0.1",
- "semver": "^5.5.0",
- "shebang-command": "^1.2.0",
- "which": "^1.2.9"
- },
- "engines": {
- "node": ">=4.8"
- }
- },
- "node_modules/cross-spawn/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/css-select": {
- "version": "5.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0",
- "css-what": "^6.1.0",
- "domhandler": "^5.0.2",
- "domutils": "^3.0.1",
- "nth-check": "^2.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/css-tree": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/css-what": {
- "version": "6.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">= 6"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/cssesc": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "cssesc": "bin/cssesc"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/csso": {
- "version": "5.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "css-tree": "~2.2.0"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
- "npm": ">=7.0.0"
- }
- },
- "node_modules/csso/node_modules/css-tree": {
- "version": "2.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.28",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
- "npm": ">=7.0.0"
- }
- },
- "node_modules/csso/node_modules/mdn-data": {
- "version": "2.0.28",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "license": "MIT"
- },
- "node_modules/dayjs": {
- "version": "1.11.19",
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.3.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "2.1.2"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/define-properties": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/defu": {
- "version": "6.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/destr": {
- "version": "2.0.5",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "1.0.3",
- "dev": true,
- "license": "Apache-2.0",
- "optional": true,
- "bin": {
- "detect-libc": "bin/detect-libc.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/dir-glob/node_modules/path-type": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/doctrine": {
- "version": "3.0.0",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/dom-serializer": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "domelementtype": "^2.3.0",
- "domhandler": "^5.0.2",
- "entities": "^4.2.0"
- },
- "funding": {
- "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
- }
- },
- "node_modules/domelementtype": {
- "version": "2.3.0",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fb55"
- }
- ],
- "license": "BSD-2-Clause"
- },
- "node_modules/domhandler": {
- "version": "5.0.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "domelementtype": "^2.3.0"
- },
- "engines": {
- "node": ">= 4"
- },
- "funding": {
- "url": "https://github.com/fb55/domhandler?sponsor=1"
- }
- },
- "node_modules/domutils": {
- "version": "3.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "dom-serializer": "^2.0.0",
- "domelementtype": "^2.3.0",
- "domhandler": "^5.0.3"
- },
- "funding": {
- "url": "https://github.com/fb55/domutils?sponsor=1"
- }
- },
- "node_modules/dotenv": {
- "version": "17.2.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/element-plus": {
- "version": "2.13.0",
- "license": "MIT",
- "dependencies": {
- "@ctrl/tinycolor": "^3.4.1",
- "@element-plus/icons-vue": "^2.3.2",
- "@floating-ui/dom": "^1.0.1",
- "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
- "@types/lodash": "^4.17.20",
- "@types/lodash-es": "^4.17.12",
- "@vueuse/core": "^10.11.0",
- "async-validator": "^4.2.5",
- "dayjs": "^1.11.19",
- "lodash": "^4.17.21",
- "lodash-es": "^4.17.21",
- "lodash-unified": "^1.0.3",
- "memoize-one": "^6.0.0",
- "normalize-wheel-es": "^1.2.0"
- },
- "peerDependencies": {
- "vue": "^3.3.0"
- }
- },
- "node_modules/element-plus/node_modules/@types/web-bluetooth": {
- "version": "0.0.20",
- "license": "MIT"
- },
- "node_modules/element-plus/node_modules/@vueuse/core": {
- "version": "10.11.1",
- "license": "MIT",
- "dependencies": {
- "@types/web-bluetooth": "^0.0.20",
- "@vueuse/metadata": "10.11.1",
- "@vueuse/shared": "10.11.1",
- "vue-demi": ">=0.14.8"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/core/node_modules/vue-demi": {
- "version": "0.14.10",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/metadata": {
- "version": "10.11.1",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/shared": {
- "version": "10.11.1",
- "license": "MIT",
- "dependencies": {
- "vue-demi": ">=0.14.8"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/shared/node_modules/vue-demi": {
- "version": "0.14.10",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/entities": {
- "version": "4.5.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/error-ex": {
- "version": "1.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-arrayish": "^0.2.1"
- }
- },
- "node_modules/errx": {
- "version": "0.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-abstract": {
- "version": "1.21.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "es-set-tostringtag": "^2.0.1",
- "es-to-primitive": "^1.2.1",
- "function-bind": "^1.1.1",
- "function.prototype.name": "^1.1.5",
- "get-intrinsic": "^1.1.3",
- "get-symbol-description": "^1.0.0",
- "globalthis": "^1.0.3",
- "gopd": "^1.0.1",
- "has": "^1.0.3",
- "has-property-descriptors": "^1.0.0",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "internal-slot": "^1.0.4",
- "is-array-buffer": "^3.0.1",
- "is-callable": "^1.2.7",
- "is-negative-zero": "^2.0.2",
- "is-regex": "^1.1.4",
- "is-shared-array-buffer": "^1.0.2",
- "is-string": "^1.0.7",
- "is-typed-array": "^1.1.10",
- "is-weakref": "^1.0.2",
- "object-inspect": "^1.12.2",
- "object-keys": "^1.1.1",
- "object.assign": "^4.1.4",
- "regexp.prototype.flags": "^1.4.3",
- "safe-regex-test": "^1.0.0",
- "string.prototype.trimend": "^1.0.6",
- "string.prototype.trimstart": "^1.0.6",
- "typed-array-length": "^1.0.4",
- "unbox-primitive": "^1.0.2",
- "which-typed-array": "^1.1.9"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.3",
- "has": "^1.0.3",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-to-primitive": {
- "version": "1.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-callable": "^1.1.4",
- "is-date-object": "^1.0.1",
- "is-symbol": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/esbuild": {
- "version": "0.27.2",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.2",
- "@esbuild/android-arm": "0.27.2",
- "@esbuild/android-arm64": "0.27.2",
- "@esbuild/android-x64": "0.27.2",
- "@esbuild/darwin-arm64": "0.27.2",
- "@esbuild/darwin-x64": "0.27.2",
- "@esbuild/freebsd-arm64": "0.27.2",
- "@esbuild/freebsd-x64": "0.27.2",
- "@esbuild/linux-arm": "0.27.2",
- "@esbuild/linux-arm64": "0.27.2",
- "@esbuild/linux-ia32": "0.27.2",
- "@esbuild/linux-loong64": "0.27.2",
- "@esbuild/linux-mips64el": "0.27.2",
- "@esbuild/linux-ppc64": "0.27.2",
- "@esbuild/linux-riscv64": "0.27.2",
- "@esbuild/linux-s390x": "0.27.2",
- "@esbuild/linux-x64": "0.27.2",
- "@esbuild/netbsd-arm64": "0.27.2",
- "@esbuild/netbsd-x64": "0.27.2",
- "@esbuild/openbsd-arm64": "0.27.2",
- "@esbuild/openbsd-x64": "0.27.2",
- "@esbuild/openharmony-arm64": "0.27.2",
- "@esbuild/sunos-x64": "0.27.2",
- "@esbuild/win32-arm64": "0.27.2",
- "@esbuild/win32-ia32": "0.27.2",
- "@esbuild/win32-x64": "0.27.2"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "8.56.0",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.56.0",
- "@humanwhocodes/config-array": "^0.11.13",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
- "debug": "^4.3.2",
- "doctrine": "^3.0.0",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-config-prettier": {
- "version": "9.1.0",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "bin": {
- "eslint-config-prettier": "bin/cli.js"
- },
- "peerDependencies": {
- "eslint": ">=7.0.0"
- }
- },
- "node_modules/eslint-plugin-prettier": {
- "version": "5.1.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prettier-linter-helpers": "^1.0.0",
- "synckit": "^0.8.6"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint-plugin-prettier"
- },
- "peerDependencies": {
- "@types/eslint": ">=8.0.0",
- "eslint": ">=8.0.0",
- "eslint-config-prettier": "*",
- "prettier": ">=3.0.0"
- },
- "peerDependenciesMeta": {
- "@types/eslint": {
- "optional": true
- },
- "eslint-config-prettier": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-plugin-vue": {
- "version": "9.33.0",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "globals": "^13.24.0",
- "natural-compare": "^1.4.0",
- "nth-check": "^2.1.1",
- "postcss-selector-parser": "^6.0.15",
- "semver": "^7.6.3",
- "vue-eslint-parser": "^9.4.3",
- "xml-name-validator": "^4.0.0"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
- }
- },
- "node_modules/eslint-scope": {
- "version": "7.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "3.3.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/eslint/node_modules/ansi-styles": {
- "version": "4.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/eslint/node_modules/chalk": {
- "version": "4.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/eslint/node_modules/color-convert": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/eslint/node_modules/color-name": {
- "version": "1.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/eslint/node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/eslint/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint/node_modules/glob-parent": {
- "version": "6.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/eslint/node_modules/has-flag": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/path-key": {
- "version": "3.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/shebang-command": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/shebang-regex": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/supports-color": {
- "version": "7.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/which": {
- "version": "2.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/espree": {
- "version": "9.6.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.5.0",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "license": "MIT"
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/exsolve": {
- "version": "1.0.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-diff": {
- "version": "1.2.0",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/fast-glob": {
- "version": "3.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fastq": {
- "version": "1.15.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
- "node_modules/file-entry-cache": {
- "version": "6.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^3.0.4"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
- },
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "3.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.1.0",
- "rimraf": "^3.0.2"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
- },
- "node_modules/flatted": {
- "version": "3.2.7",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/for-each": {
- "version": "0.3.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-callable": "^1.1.3"
- }
- },
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.2",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/function.prototype.name": {
- "version": "1.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3",
- "es-abstract": "^1.19.0",
- "functions-have-names": "^1.2.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/functions-have-names": {
- "version": "1.2.3",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-symbols": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-symbol-description": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/giget": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.0",
- "defu": "^6.1.4",
- "node-fetch-native": "^1.6.6",
- "nypm": "^0.6.0",
- "pathe": "^2.0.3"
- },
- "bin": {
- "giget": "dist/cli.mjs"
- }
- },
- "node_modules/giget/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/glob": {
- "version": "7.2.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/globals": {
- "version": "13.24.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "type-fest": "^0.20.2"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globalthis": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "define-properties": "^1.1.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/globby": {
- "version": "11.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globby/node_modules/fast-glob": {
- "version": "3.2.12",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/gopd": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.10",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/graphemer": {
- "version": "1.4.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/has": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/has-bigints": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-flag": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/has-property-descriptors": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-proto": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/hasown/node_modules/function-bind": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hosted-git-info": {
- "version": "2.8.9",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/ignore": {
- "version": "5.2.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/immutable": {
- "version": "5.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/import-fresh": {
- "version": "3.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/inflight": {
- "version": "1.0.6",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/internal-slot": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.2.0",
- "has": "^1.0.3",
- "side-channel": "^1.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/is-array-buffer": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "is-typed-array": "^1.1.10"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-arrayish": {
- "version": "0.2.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/is-bigint": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-bigints": "^1.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-boolean-object": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-callable": {
- "version": "1.2.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-core-module": {
- "version": "2.13.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hasown": "^2.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-date-object": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-negative-zero": {
- "version": "2.0.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/is-number-object": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-regex": {
- "version": "1.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-shared-array-buffer": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-string": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-symbol": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-typed-array": {
- "version": "1.1.10",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "gopd": "^1.0.1",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-weakref": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/jiti": {
- "version": "2.6.1",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
- "node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/json-parse-better-errors": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/jsonc-parser": {
- "version": "3.2.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/klona": {
- "version": "2.0.6",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/knitwork": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/load-json-file": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.1.2",
- "parse-json": "^4.0.0",
- "pify": "^3.0.0",
- "strip-bom": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/local-pkg": {
- "version": "0.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mlly": "^1.4.2",
- "pkg-types": "^1.0.3"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/lodash-es": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
- "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/lodash-unified": {
- "version": "1.0.3",
- "license": "MIT",
- "peerDependencies": {
- "@types/lodash-es": "*",
- "lodash": "*",
- "lodash-es": "*"
- }
- },
- "node_modules/lodash.merge": {
- "version": "4.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/mdn-data": {
- "version": "2.0.30",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/memoize-one": {
- "version": "6.0.0",
- "license": "MIT"
- },
- "node_modules/memorystream": {
- "version": "0.3.1",
- "dev": true,
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/merge2": {
- "version": "1.4.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/mlly": {
- "version": "1.8.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.15.0",
- "pathe": "^2.0.3",
- "pkg-types": "^1.3.1",
- "ufo": "^1.6.1"
- }
- },
- "node_modules/mlly/node_modules/confbox": {
- "version": "0.1.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mlly/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mlly/node_modules/pkg-types": {
- "version": "1.3.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.1.8",
- "mlly": "^1.7.4",
- "pathe": "^2.0.1"
- }
- },
- "node_modules/ms": {
- "version": "2.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/muggle-string": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nice-try": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/node-addon-api": {
- "version": "7.1.1",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/node-fetch-native": {
- "version": "1.6.7",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/normalize-package-data": {
- "version": "2.5.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "hosted-git-info": "^2.1.4",
- "resolve": "^1.10.0",
- "semver": "2 || 3 || 4 || 5",
- "validate-npm-package-license": "^3.0.1"
- }
- },
- "node_modules/normalize-package-data/node_modules/is-core-module": {
- "version": "2.11.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/normalize-package-data/node_modules/resolve": {
- "version": "1.22.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.9.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/normalize-package-data/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/normalize-wheel-es": {
- "version": "1.2.0",
- "license": "BSD-3-Clause"
- },
- "node_modules/npm-run-all": {
- "version": "4.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^3.2.1",
- "chalk": "^2.4.1",
- "cross-spawn": "^6.0.5",
- "memorystream": "^0.3.1",
- "minimatch": "^3.0.4",
- "pidtree": "^0.3.0",
- "read-pkg": "^3.0.0",
- "shell-quote": "^1.6.1",
- "string.prototype.padend": "^3.0.0"
- },
- "bin": {
- "npm-run-all": "bin/npm-run-all/index.js",
- "run-p": "bin/run-p/index.js",
- "run-s": "bin/run-s/index.js"
- },
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/nth-check": {
- "version": "2.1.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/fb55/nth-check?sponsor=1"
- }
- },
- "node_modules/nypm": {
- "version": "0.6.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.2",
- "pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "tinyexec": "^1.0.1"
- },
- "bin": {
- "nypm": "dist/cli.mjs"
- },
- "engines": {
- "node": "^14.16.0 || >=16.10.0"
- }
- },
- "node_modules/nypm/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nypm/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.12.3",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/object-keys": {
- "version": "1.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/object.assign": {
- "version": "4.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "has-symbols": "^1.0.3",
- "object-keys": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/ohash": {
- "version": "2.0.11",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/once": {
- "version": "1.4.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@aashutoshrathi/word-wrap": "^1.2.3",
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/parent-module": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/parse-json": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "error-ex": "^1.3.1",
- "json-parse-better-errors": "^1.0.1"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/path-browserify": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/path-key": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/path-parse": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/path-type": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pify": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/pathe": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/perfect-debounce": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/pidtree": {
- "version": "0.3.1",
- "dev": true,
- "license": "MIT",
- "bin": {
- "pidtree": "bin/pidtree.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/pify": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/pkg-types": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "jsonc-parser": "^3.2.0",
- "mlly": "^1.2.0",
- "pathe": "^1.1.0"
- }
- },
- "node_modules/pkg-types/node_modules/pathe": {
- "version": "1.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/postcss": {
- "version": "8.5.6",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/postcss-selector-parser": {
- "version": "6.0.15",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cssesc": "^3.0.0",
- "util-deprecate": "^1.0.2"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/prettier": {
- "version": "3.7.4",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/prettier-linter-helpers": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-diff": "^1.1.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/rc9": {
- "version": "2.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defu": "^6.1.4",
- "destr": "^2.0.3"
- }
- },
- "node_modules/read-pkg": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "load-json-file": "^4.0.0",
- "normalize-package-data": "^2.3.2",
- "path-type": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/regexp.prototype.flags": {
- "version": "1.4.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3",
- "functions-have-names": "^1.2.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/resolve": {
- "version": "1.22.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.13.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rimraf": {
- "version": "3.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/rolldown-string": {
- "version": "0.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "magic-string": "^0.30.21"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sxzz"
- }
- },
- "node_modules/rollup": {
- "version": "4.55.1",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.55.1",
- "@rollup/rollup-android-arm64": "4.55.1",
- "@rollup/rollup-darwin-arm64": "4.55.1",
- "@rollup/rollup-darwin-x64": "4.55.1",
- "@rollup/rollup-freebsd-arm64": "4.55.1",
- "@rollup/rollup-freebsd-x64": "4.55.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
- "@rollup/rollup-linux-arm64-gnu": "4.55.1",
- "@rollup/rollup-linux-arm64-musl": "4.55.1",
- "@rollup/rollup-linux-loong64-gnu": "4.55.1",
- "@rollup/rollup-linux-loong64-musl": "4.55.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
- "@rollup/rollup-linux-ppc64-musl": "4.55.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
- "@rollup/rollup-linux-riscv64-musl": "4.55.1",
- "@rollup/rollup-linux-s390x-gnu": "4.55.1",
- "@rollup/rollup-linux-x64-gnu": "4.55.1",
- "@rollup/rollup-linux-x64-musl": "4.55.1",
- "@rollup/rollup-openbsd-x64": "4.55.1",
- "@rollup/rollup-openharmony-arm64": "4.55.1",
- "@rollup/rollup-win32-arm64-msvc": "4.55.1",
- "@rollup/rollup-win32-ia32-msvc": "4.55.1",
- "@rollup/rollup-win32-x64-gnu": "4.55.1",
- "@rollup/rollup-win32-x64-msvc": "4.55.1",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
- "node_modules/safe-regex-test": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "is-regex": "^1.1.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/sass": {
- "version": "1.97.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^4.0.0",
- "immutable": "^5.0.2",
- "source-map-js": ">=0.6.2 <2.0.0"
- },
- "bin": {
- "sass": "sass.js"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "optionalDependencies": {
- "@parcel/watcher": "^2.4.1"
- }
- },
- "node_modules/sass/node_modules/chokidar": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "readdirp": "^4.0.1"
- },
- "engines": {
- "node": ">= 14.16.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/sass/node_modules/readdirp": {
- "version": "4.1.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.18.0"
- },
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/scule": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "7.7.3",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/shebang-command": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^1.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/shebang-regex": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/shell-quote": {
- "version": "1.8.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.0",
- "get-intrinsic": "^1.0.2",
- "object-inspect": "^1.9.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/slash": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/source-map": {
- "version": "0.6.1",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/spdx-correct": {
- "version": "3.1.1",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "spdx-expression-parse": "^3.0.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "node_modules/spdx-exceptions": {
- "version": "2.3.0",
- "dev": true,
- "license": "CC-BY-3.0"
- },
- "node_modules/spdx-expression-parse": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "spdx-exceptions": "^2.1.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "node_modules/spdx-license-ids": {
- "version": "3.0.12",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/string.prototype.padend": {
- "version": "3.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/string.prototype.trimend": {
- "version": "1.0.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/string.prototype.trimstart": {
- "version": "1.0.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-bom": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/strip-json-comments": {
- "version": "3.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-literal": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.10.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/supports-color": {
- "version": "5.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/supports-preserve-symlinks-flag": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/svgo": {
- "version": "3.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@trysound/sax": "0.2.0",
- "commander": "^7.2.0",
- "css-select": "^5.1.0",
- "css-tree": "^2.3.1",
- "css-what": "^6.1.0",
- "csso": "^5.0.5",
- "picocolors": "^1.0.0"
- },
- "bin": {
- "svgo": "bin/svgo"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/svgo"
- }
- },
- "node_modules/svgo/node_modules/commander": {
- "version": "7.2.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/synckit": {
- "version": "0.8.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@pkgr/core": "^0.1.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/terser": {
- "version": "5.44.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "peer": true,
- "dependencies": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.15.0",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "bin": {
- "terser": "bin/terser"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/text-table": {
- "version": "0.2.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.5.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/ts-api-utils": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=16.13.0"
- },
- "peerDependencies": {
- "typescript": ">=4.2.0"
- }
- },
- "node_modules/tslib": {
- "version": "2.6.2",
- "dev": true,
- "license": "0BSD"
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/type-fest": {
- "version": "0.20.2",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/typed-array-length": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "is-typed-array": "^1.1.9"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "devOptional": true,
- "license": "Apache-2.0",
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/ufo": {
- "version": "1.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unbox-primitive": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-bigints": "^1.0.2",
- "has-symbols": "^1.0.3",
- "which-boxed-primitive": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/unctx": {
- "version": "2.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.15.0",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21",
- "unplugin": "^2.3.11"
- }
- },
- "node_modules/unctx/node_modules/estree-walker": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/unctx/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/unctx/node_modules/unplugin": {
- "version": "2.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "acorn": "^8.15.0",
- "picomatch": "^4.0.3",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/undici-types": {
- "version": "7.16.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unimport": {
- "version": "3.7.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rollup/pluginutils": "^5.1.0",
- "acorn": "^8.11.2",
- "escape-string-regexp": "^5.0.0",
- "estree-walker": "^3.0.3",
- "fast-glob": "^3.3.2",
- "local-pkg": "^0.5.0",
- "magic-string": "^0.30.5",
- "mlly": "^1.4.2",
- "pathe": "^1.1.1",
- "pkg-types": "^1.0.3",
- "scule": "^1.1.1",
- "strip-literal": "^1.3.0",
- "unplugin": "^1.5.1"
- }
- },
- "node_modules/unimport/node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/unimport/node_modules/estree-walker": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/unplugin": {
- "version": "1.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.11.2",
- "chokidar": "^3.5.3",
- "webpack-sources": "^3.2.3",
- "webpack-virtual-modules": "^0.6.1"
- }
- },
- "node_modules/unplugin-auto-import": {
- "version": "0.17.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@antfu/utils": "^0.7.7",
- "@rollup/pluginutils": "^5.1.0",
- "fast-glob": "^3.3.2",
- "local-pkg": "^0.5.0",
- "magic-string": "^0.30.5",
- "minimatch": "^9.0.3",
- "unimport": "^3.7.1",
- "unplugin": "^1.6.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@nuxt/kit": "^3.2.2",
- "@vueuse/core": "*"
- },
- "peerDependenciesMeta": {
- "@nuxt/kit": {
- "optional": true
- },
- "@vueuse/core": {
- "optional": true
- }
- }
- },
- "node_modules/unplugin-auto-import/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/unplugin-auto-import/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/unplugin-element-plus": {
- "version": "0.11.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nuxt/kit": "^4.2.2",
- "es-module-lexer": "^2.0.0",
- "escape-string-regexp": "^5.0.0",
- "rolldown-string": "^0.2.1",
- "unplugin": "^2.3.11"
- },
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/@nuxt/kit": {
- "version": "4.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "c12": "^3.3.2",
- "consola": "^3.4.2",
- "defu": "^6.1.4",
- "destr": "^2.0.5",
- "errx": "^0.1.0",
- "exsolve": "^1.0.8",
- "ignore": "^7.0.5",
- "jiti": "^2.6.1",
- "klona": "^2.0.6",
- "mlly": "^1.8.0",
- "ohash": "^2.0.11",
- "pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "rc9": "^2.1.2",
- "scule": "^1.3.0",
- "semver": "^7.7.3",
- "tinyglobby": "^0.2.15",
- "ufo": "^1.6.1",
- "unctx": "^2.4.1",
- "untyped": "^2.0.0"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/ignore": {
- "version": "7.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unplugin-element-plus/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/unplugin": {
- "version": "2.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "acorn": "^8.15.0",
- "picomatch": "^4.0.3",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/unplugin-vue-components": {
- "version": "0.26.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@antfu/utils": "^0.7.6",
- "@rollup/pluginutils": "^5.0.4",
- "chokidar": "^3.5.3",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.1",
- "local-pkg": "^0.4.3",
- "magic-string": "^0.30.3",
- "minimatch": "^9.0.3",
- "resolve": "^1.22.4",
- "unplugin": "^1.4.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@babel/parser": "^7.15.8",
- "@nuxt/kit": "^3.2.2",
- "vue": "2 || 3"
- },
- "peerDependenciesMeta": {
- "@babel/parser": {
- "optional": true
- },
- "@nuxt/kit": {
- "optional": true
- }
- }
- },
- "node_modules/unplugin-vue-components/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/unplugin-vue-components/node_modules/local-pkg": {
- "version": "0.4.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/unplugin-vue-components/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/untyped": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "defu": "^6.1.4",
- "jiti": "^2.4.2",
- "knitwork": "^1.2.0",
- "scule": "^1.3.0"
- },
- "bin": {
- "untyped": "dist/cli.mjs"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/validate-npm-package-license": {
- "version": "3.0.4",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "spdx-correct": "^3.0.0",
- "spdx-expression-parse": "^3.0.0"
- }
- },
- "node_modules/vite": {
- "version": "7.3.1",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vite-svg-loader": {
- "version": "5.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "svgo": "^3.0.2"
- },
- "peerDependencies": {
- "vue": ">=3.2.13"
- }
- },
- "node_modules/vite/node_modules/fdir": {
- "version": "6.5.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/vite/node_modules/fsevents": {
- "version": "2.3.3",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/vscode-uri": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/vue": {
- "version": "3.5.26",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-sfc": "3.5.26",
- "@vue/runtime-dom": "3.5.26",
- "@vue/server-renderer": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/vue-eslint-parser": {
- "version": "9.4.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4",
- "eslint-scope": "^7.1.1",
- "eslint-visitor-keys": "^3.3.0",
- "espree": "^9.3.1",
- "esquery": "^1.4.0",
- "lodash": "^4.17.21",
- "semver": "^7.3.6"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/mysticatea"
- },
- "peerDependencies": {
- "eslint": ">=6.0.0"
- }
- },
- "node_modules/vue-router": {
- "version": "4.6.4",
- "license": "MIT",
- "dependencies": {
- "@vue/devtools-api": "^6.6.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/posva"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/vue-tsc": {
- "version": "3.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/typescript": "2.4.27",
- "@vue/language-core": "3.2.2"
- },
- "bin": {
- "vue-tsc": "bin/vue-tsc.js"
- },
- "peerDependencies": {
- "typescript": ">=5.0.0"
- }
- },
- "node_modules/webpack-sources": {
- "version": "3.2.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/webpack-virtual-modules": {
- "version": "0.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/which": {
- "version": "1.3.1",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "which": "bin/which"
- }
- },
- "node_modules/which-boxed-primitive": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-bigint": "^1.0.1",
- "is-boolean-object": "^1.1.0",
- "is-number-object": "^1.0.4",
- "is-string": "^1.0.5",
- "is-symbol": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/which-typed-array": {
- "version": "1.1.9",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "gopd": "^1.0.1",
- "has-tostringtag": "^1.0.0",
- "is-typed-array": "^1.1.10"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/xml-name-validator": {
- "version": "4.0.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- }
- }
-}
diff --git a/web/frpc/package.json b/web/frpc/package.json
index b41d048a..6e20d893 100644
--- a/web/frpc/package.json
+++ b/web/frpc/package.json
@@ -2,28 +2,29 @@
"name": "frpc-dashboard",
"version": "0.0.1",
"private": true,
+ "type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
- "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
+ "lint": "eslint --fix"
},
"dependencies": {
"element-plus": "^2.13.0",
+ "pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
- "@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
- "@vue/eslint-config-prettier": "^9.0.0",
- "@vue/eslint-config-typescript": "^12.0.0",
+ "@vue/eslint-config-prettier": "^10.2.0",
+ "@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
- "eslint": "^8.56.0",
+ "eslint": "^9.39.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",
@@ -37,4 +38,4 @@
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
}
-}
\ No newline at end of file
+}
diff --git a/web/frpc/src/App.vue b/web/frpc/src/App.vue
index 2bfded92..b75c8a69 100644
--- a/web/frpc/src/App.vue
+++ b/web/frpc/src/App.vue
@@ -2,134 +2,160 @@