11 İşlemeler 773169e0c4 ... de690a55c8

Yazar SHA1 Mesaj Tarih
  fatedier de690a55c8 update golangci-lint version (#4817) 3 hafta önce
  fatedier 1ced733340 update test package (#4814) 3 hafta önce
  fatedier ce366ee17f add proxy protocol support for UDP proxies (#4810) 4 hafta önce
  fatedier 3128350dd6 feat: support YAML merge in strict configuration mode (#4809) 4 hafta önce
  scientificworld 3be6efdd28 fix: type error in server_plugin doc (#4799) 1 ay önce
  CrynTox 6cbb26283c build: add x64 openbsd (#4780) 1 ay önce
  fatedier 75edea3370 update release notes (#4772) 1 ay önce
  fatedier e687aef37e vnet: fix issues (#4771) 1 ay önce
  fatedier a23455a737 update feature gates doc (#4755) 2 ay önce
  fatedier e208043323 vnet: update tun_unsupported function (#4752) 2 ay önce
  fatedier a78814a2e9 virtual-net: initial (#4751) 2 ay önce
86 değiştirilmiş dosya ile 2595 ekleme ve 493 silme
  1. 2 8
      .github/workflows/golangci-lint.yml
  2. 1 1
      .github/workflows/stale.yml
  3. 92 120
      .golangci.yml
  4. 1 1
      Makefile.cross-compiles
  5. 39 6
      README.md
  6. 0 5
      README_zh.md
  7. 3 6
      Release.md
  8. 6 6
      client/admin_api.go
  9. 7 3
      client/control.go
  10. 20 27
      client/proxy/proxy.go
  11. 5 1
      client/proxy/proxy_manager.go
  12. 6 1
      client/proxy/proxy_wrapper.go
  13. 1 1
      client/proxy/sudp.go
  14. 3 1
      client/proxy/udp.go
  15. 30 11
      client/service.go
  16. 4 0
      client/visitor/stcp.go
  17. 31 3
      client/visitor/visitor.go
  18. 13 1
      client/visitor/visitor_manager.go
  19. 7 3
      client/visitor/xtcp.go
  20. 7 0
      cmd/frpc/sub/root.go
  21. 26 0
      conf/frpc_full_example.toml
  22. 1 1
      doc/server_plugin.md
  23. 73 0
      doc/virtual_net.md
  24. 22 19
      go.mod
  25. 54 43
      go.sum
  26. 3 3
      hack/run-e2e.sh
  27. 1 1
      package.sh
  28. 2 5
      pkg/config/legacy/client.go
  29. 22 22
      pkg/config/legacy/conversion.go
  30. 1 1
      pkg/config/legacy/proxy.go
  31. 32 2
      pkg/config/load.go
  32. 119 0
      pkg/config/load_test.go
  33. 12 3
      pkg/config/v1/client.go
  34. 1 1
      pkg/config/v1/proxy.go
  35. 32 24
      pkg/config/v1/proxy_plugin.go
  36. 8 0
      pkg/config/v1/validation/client.go
  37. 3 0
      pkg/config/v1/visitor.go
  38. 86 0
      pkg/config/v1/visitor_plugin.go
  39. 219 0
      pkg/featuregate/feature_gate.go
  40. 1 1
      pkg/metrics/mem/server.go
  41. 4 6
      pkg/plugin/client/http2http.go
  42. 4 6
      pkg/plugin/client/http2https.go
  43. 4 4
      pkg/plugin/client/http_proxy.go
  44. 6 8
      pkg/plugin/client/https2http.go
  45. 6 8
      pkg/plugin/client/https2https.go
  46. 17 9
      pkg/plugin/client/plugin.go
  47. 5 6
      pkg/plugin/client/socks5.go
  48. 4 6
      pkg/plugin/client/static_file.go
  49. 4 5
      pkg/plugin/client/tls2raw.go
  50. 6 7
      pkg/plugin/client/unix_domain_socket.go
  51. 92 0
      pkg/plugin/client/virtual_net.go
  52. 1 1
      pkg/plugin/server/http.go
  53. 1 1
      pkg/plugin/server/manager.go
  54. 1 1
      pkg/plugin/server/plugin.go
  55. 1 1
      pkg/plugin/server/tracer.go
  56. 1 1
      pkg/plugin/server/types.go
  57. 58 0
      pkg/plugin/visitor/plugin.go
  58. 192 0
      pkg/plugin/visitor/virtual_net.go
  59. 15 1
      pkg/proto/udp/udp.go
  60. 4 4
      pkg/proto/udp/udp_test.go
  61. 6 6
      pkg/util/metric/counter_test.go
  62. 9 9
      pkg/util/metric/date_counter_test.go
  63. 1 1
      pkg/util/net/conn.go
  64. 45 0
      pkg/util/net/proxyprotocol.go
  65. 178 0
      pkg/util/net/proxyprotocol_test.go
  66. 16 20
      pkg/util/util/util_test.go
  67. 1 1
      pkg/util/version/version.go
  68. 2 6
      pkg/util/vhost/http.go
  69. 2 6
      pkg/util/vhost/vhost.go
  70. 386 0
      pkg/vnet/controller.go
  71. 81 0
      pkg/vnet/message.go
  72. 109 0
      pkg/vnet/tun.go
  73. 85 0
      pkg/vnet/tun_darwin.go
  74. 131 0
      pkg/vnet/tun_linux.go
  75. 29 0
      pkg/vnet/tun_unsupported.go
  76. 1 1
      server/control.go
  77. 10 10
      server/dashboard_api.go
  78. 9 9
      server/service.go
  79. 1 1
      test/e2e/e2e.go
  80. 3 3
      test/e2e/framework/request.go
  81. 5 3
      test/e2e/legacy/basic/client_server.go
  82. 2 2
      test/e2e/legacy/features/real_ip.go
  83. 2 2
      test/e2e/legacy/plugin/server.go
  84. 5 3
      test/e2e/v1/basic/client_server.go
  85. 52 2
      test/e2e/v1/features/real_ip.go
  86. 2 2
      test/e2e/v1/plugin/server.go

+ 2 - 8
.github/workflows/golangci-lint.yml

@@ -20,10 +20,10 @@ jobs:
         go-version: '1.23'
         cache: false
     - name: golangci-lint
-      uses: golangci/golangci-lint-action@v4
+      uses: golangci/golangci-lint-action@v8
       with:
         # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
-        version: v1.61
+        version: v2.1
 
         # Optional: golangci-lint command line arguments.
         # args: --issues-exit-code=0
@@ -34,9 +34,3 @@ jobs:
         # Optional: if set to true then the all caching functionality will be complete disabled,
         #           takes precedence over all other caching options.
         # skip-cache: true
-
-        # Optional: if set to true then the action don't cache or restore ~/go/pkg.
-        # skip-pkg-cache: true
-
-        # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
-        # skip-build-cache: true

+ 1 - 1
.github/workflows/stale.yml

@@ -1,4 +1,4 @@
-name: "Close stale issues"
+name: "Close stale issues and PRs"
 on:
   schedule:
   - cron: "20 0 * * *"

+ 92 - 120
.golangci.yml

@@ -1,139 +1,111 @@
-service:
-  golangci-lint-version: 1.61.x # use the fixed version to not introduce new linters unexpectedly
-
+version: "2"
 run:
   concurrency: 4
-  # timeout for analysis, e.g. 30s, 5m, default is 1m
-  timeout: 20m
   build-tags:
   - integ
   - integfuzz
-
 linters:
-  disable-all: true
+  default: none
   enable:
-  - unused
-  - errcheck
+  - asciicheck
   - copyloopvar
+  - errcheck
   - gocritic
-  - gofumpt
-  - goimports
-  - revive
-  - gosimple
+  - gosec
   - govet
   - ineffassign
   - lll
+  - makezero
   - misspell
+  - prealloc
+  - predeclared
+  - revive
   - staticcheck
-  - stylecheck
-  - typecheck
   - unconvert
   - unparam
+  - unused
+  settings:
+    errcheck:
+      check-type-assertions: false
+      check-blank: false
+    gocritic:
+      disabled-checks:
+      - exitAfterDefer
+    gosec:
+      excludes:
+      - G401
+      - G402
+      - G404
+      - G501
+      - G115
+      severity: low
+      confidence: low
+    govet:
+      disable:
+      - shadow
+    lll:
+      line-length: 160
+      tab-width: 1
+    misspell:
+      locale: US
+      ignore-rules:
+      - cancelled
+      - marshalled
+    unparam:
+      check-exported: false
+  exclusions:
+    generated: lax
+    presets:
+    - comments
+    - common-false-positives
+    - legacy
+    - std-error-handling
+    rules:
+    - linters:
+      - errcheck
+      - maligned
+      path: _test\.go$|^tests/|^samples/
+    - linters:
+      - revive
+      - staticcheck
+      text: use underscores in Go names
+    - linters:
+      - revive
+      text: unused-parameter
+    - linters:
+      - unparam
+      text: is always false
+    paths:
+    - .*\.pb\.go
+    - .*\.gen\.go
+    - genfiles$
+    - vendor$
+    - bin$
+    - third_party$
+    - builtin$
+    - examples$
+formatters:
+  enable:
   - gci
-  - gosec
-  - asciicheck
-  - prealloc
-  - predeclared
-  - makezero
-  fast: false
-
-linters-settings:
-  errcheck:
-    # report about not checking of errors in type assetions: `a := b.(MyStruct)`;
-    # default is false: such cases aren't reported by default.
-    check-type-assertions: false
-
-    # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
-    # default is false: such cases aren't reported by default.
-    check-blank: false
-  govet:
-    # report about shadowed variables
-    disable:
-    - shadow
-  maligned:
-    # print struct with more effective memory layout or not, false by default
-    suggest-new: true
-  misspell:
-    # Correct spellings using locale preferences for US or UK.
-    # Default is to use a neutral variety of English.
-    # Setting locale to US will correct the British spelling of 'colour' to 'color'.
-    locale: US
-    ignore-words:
-    - cancelled
-    - marshalled
-  lll:
-    # max line length, lines longer will be reported. Default is 120.
-    # '\t' is counted as 1 character by default, and can be changed with the tab-width option
-    line-length: 160
-    # tab width in spaces. Default to 1.
-    tab-width: 1
-  gocritic:
-    disabled-checks:
-    - exitAfterDefer
-  unused:
-    check-exported: false
-  unparam:
-    # Inspect exported functions, default is false. Set to true if no external program/library imports your code.
-    # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
-    # if it's called for subdir of a project it can't find external interfaces. All text editor integrations
-    # with golangci-lint call it on a directory with the changed file.
-    check-exported: false
-  gci:
-    sections:
-    - standard
-    - default
-    - prefix(github.com/fatedier/frp/)
-  gosec:
-    severity: "low"
-    confidence: "low"
-    excludes:
-    - G401
-    - G402
-    - G404
-    - G501
-    - G115 # integer overflow conversion
-
+  - gofumpt
+  - goimports
+  settings:
+    gci:
+      sections:
+      - standard
+      - default
+      - prefix(github.com/fatedier/frp/)
+  exclusions:
+    generated: lax
+    paths:
+    - .*\.pb\.go
+    - .*\.gen\.go
+    - genfiles$
+    - vendor$
+    - bin$
+    - third_party$
+    - builtin$
+    - examples$
 issues:
-  # List of regexps of issue texts to exclude, empty list by default.
-  # But independently from this option we use default exclude patterns,
-  # it can be disabled by `exclude-use-default: false`. To list all
-  # excluded by default patterns execute `golangci-lint run --help`
-  # exclude:
-  #  - composite literal uses unkeyed fields
-
-  exclude-rules:
-  # Exclude some linters from running on test files.
-  - path: _test\.go$|^tests/|^samples/
-    linters:
-    - errcheck
-    - maligned
-  - linters:
-    - revive
-    - stylecheck
-    text: "use underscores in Go names"
-  - linters:
-    - revive
-    text: "unused-parameter"
-  - linters:
-    - unparam
-    text: "is always false"
-
-  exclude-dirs:
-  - genfiles$
-  - vendor$
-  - bin$
-  exclude-files:
-  - ".*\\.pb\\.go"
-  - ".*\\.gen\\.go"
-
-  # Independently from option `exclude` we use default exclude patterns,
-  # it can be disabled by this option. To list all
-  # excluded by default patterns execute `golangci-lint run --help`.
-  # Default value for this option is true.
-  exclude-use-default: true
-
-  # Maximum issues count per one linter. Set to 0 to disable. Default is 50.
-  max-per-linter: 0
-
-  # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
+  max-issues-per-linter: 0
   max-same-issues: 0

+ 1 - 1
Makefile.cross-compiles

@@ -2,7 +2,7 @@ export PATH := $(PATH):`go env GOPATH`/bin
 export GO111MODULE=on
 LDFLAGS := -s -w
 
-os-archs=darwin:amd64 darwin:arm64 freebsd:amd64 linux:amd64 linux:arm:7 linux:arm:5 linux:arm64 windows:amd64 windows:arm64 linux:mips64 linux:mips64le linux:mips:softfloat linux:mipsle:softfloat linux:riscv64 linux:loong64 android:arm64
+os-archs=darwin:amd64 darwin:arm64 freebsd:amd64 openbsd:amd64 linux:amd64 linux:arm:7 linux:arm:5 linux:arm64 windows:amd64 windows:arm64 linux:mips64 linux:mips64le linux:mips:softfloat linux:mipsle:softfloat linux:riscv64 linux:loong64 android:arm64
 
 all: build
 

+ 39 - 6
README.md

@@ -18,11 +18,6 @@ frp is an open source project with its ongoing development made possible entirel
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
   </a>
 </p>
-<p align="center">
-  <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank">
-    <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png">
-  </a>
-</p>
 <p align="center">
   <a href="https://github.com/daytonaio/daytona" target="_blank">
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
@@ -97,6 +92,11 @@ frp also offers a P2P connect mode.
     * [Client Plugins](#client-plugins)
     * [Server Manage Plugins](#server-manage-plugins)
     * [SSH Tunnel Gateway](#ssh-tunnel-gateway)
+    * [Virtual Network (VirtualNet)](#virtual-network-virtualnet)
+* [Feature Gates](#feature-gates)
+    * [Available Feature Gates](#available-feature-gates)
+    * [Enabling Feature Gates](#enabling-feature-gates)
+    * [Feature Lifecycle](#feature-lifecycle)
 * [Related Projects](#related-projects)
 * [Contributing](#contributing)
 * [Donation](#donation)
@@ -1025,7 +1025,7 @@ You can get user's real IP from HTTP request headers `X-Forwarded-For`.
 
 #### Proxy Protocol
 
-frp supports Proxy Protocol to send user's real IP to local services. It support all types except UDP.
+frp supports Proxy Protocol to send user's real IP to local services.
 
 Here is an example for https service:
 
@@ -1260,6 +1260,39 @@ frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote
 
 Please refer to this [document](/doc/ssh_tunnel_gateway.md) for more information.
 
+### Virtual Network (VirtualNet)
+
+*Alpha feature added in v0.62.0*
+
+The VirtualNet feature enables frp to create and manage virtual network connections between clients and visitors through a TUN interface. This allows for IP-level routing between machines, extending frp beyond simple port forwarding to support full network connectivity.
+
+For detailed information about configuration and usage, please refer to the [VirtualNet documentation](/doc/virtual_net.md).
+
+## Feature Gates
+
+frp supports feature gates to enable or disable experimental features. This allows users to try out new features before they're considered stable.
+
+### Available Feature Gates
+
+| Name | Stage | Default | Description |
+|------|-------|---------|-------------|
+| VirtualNet | ALPHA | false | Virtual network capabilities for frp |
+
+### Enabling Feature Gates
+
+To enable an experimental feature, add the feature gate to your configuration:
+
+```toml
+featureGates = { VirtualNet = true }
+```
+
+### Feature Lifecycle
+
+Features typically go through three stages:
+1. **ALPHA**: Disabled by default, may be unstable
+2. **BETA**: May be enabled by default, more stable but still evolving
+3. **GA (Generally Available)**: Enabled by default, ready for production use
+
 ## Related Projects
 
 * [gofrp/plugin](https://github.com/gofrp/plugin) - A repository for frp plugins that contains a variety of plugins implemented based on the frp extension mechanism, meeting the customization needs of different scenarios.

+ 0 - 5
README_zh.md

@@ -20,11 +20,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
   </a>
 </p>
-<p align="center">
-  <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank">
-    <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png">
-  </a>
-</p>
 <p align="center">
   <a href="https://github.com/daytonaio/daytona" target="_blank">
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">

+ 3 - 6
Release.md

@@ -1,7 +1,4 @@
-### Features
+## Features
 
-* Support metadatas and annotations in frpc proxy commands.
-
-### Fixes
-
-* Properly release resources in service.Close() to prevent resource leaks when used as a library.
+* Support for YAML merge functionality (anchors and references with dot-prefixed fields) in strict configuration mode without requiring `--strict-config=false` parameter.
+* Support for proxy protocol in UDP proxies to preserve real client IP addresses.

+ 6 - 6
client/admin_api.go

@@ -165,9 +165,9 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
 		res StatusResp = make(map[string][]ProxyStatusResp)
 	)
 
-	log.Infof("Http request [/api/status]")
+	log.Infof("http request [/api/status]")
 	defer func() {
-		log.Infof("Http response [/api/status]")
+		log.Infof("http response [/api/status]")
 		buf, _ = json.Marshal(&res)
 		_, _ = w.Write(buf)
 	}()
@@ -198,9 +198,9 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
 func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
 	res := GeneralResponse{Code: 200}
 
-	log.Infof("Http get request [/api/config]")
+	log.Infof("http get request [/api/config]")
 	defer func() {
-		log.Infof("Http get response [/api/config], code [%d]", res.Code)
+		log.Infof("http get response [/api/config], code [%d]", res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
@@ -228,9 +228,9 @@ func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
 func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) {
 	res := GeneralResponse{Code: 200}
 
-	log.Infof("Http put request [/api/config]")
+	log.Infof("http put request [/api/config]")
 	defer func() {
-		log.Infof("Http put response [/api/config], code [%d]", res.Code)
+		log.Infof("http put response [/api/config], code [%d]", res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))

+ 7 - 3
client/control.go

@@ -29,6 +29,7 @@ import (
 	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"
 )
 
 type SessionContext struct {
@@ -46,6 +47,8 @@ type SessionContext struct {
 	AuthSetter auth.Setter
 	// Connector is used to create new connections, which could be real TCP connections or virtual streams.
 	Connector Connector
+	// Virtual net controller
+	VnetController *vnet.Controller
 }
 
 type Control struct {
@@ -99,8 +102,9 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
 	ctl.registerMsgHandlers()
 	ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel())
 
-	ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter)
-	ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.connectServer, ctl.msgTransporter)
+	ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController)
+	ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
+		ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
 	return ctl, nil
 }
 
@@ -185,7 +189,7 @@ func (ctl *Control) handlePong(m msg.Message) {
 	inMsg := m.(*msg.Pong)
 
 	if inMsg.Error != "" {
-		xl.Errorf("Pong message contains error: %s", inMsg.Error)
+		xl.Errorf("pong message contains error: %s", inMsg.Error)
 		ctl.closeSession()
 		return
 	}

+ 20 - 27
client/proxy/proxy.go

@@ -20,13 +20,11 @@ import (
 	"net"
 	"reflect"
 	"strconv"
-	"strings"
 	"sync"
 	"time"
 
 	libio "github.com/fatedier/golib/io"
 	libnet "github.com/fatedier/golib/net"
-	pp "github.com/pires/go-proxyproto"
 	"golang.org/x/time/rate"
 
 	"github.com/fatedier/frp/pkg/config/types"
@@ -35,7 +33,9 @@ import (
 	plugin "github.com/fatedier/frp/pkg/plugin/client"
 	"github.com/fatedier/frp/pkg/transport"
 	"github.com/fatedier/frp/pkg/util/limit"
+	netpkg "github.com/fatedier/frp/pkg/util/net"
 	"github.com/fatedier/frp/pkg/util/xlog"
+	"github.com/fatedier/frp/pkg/vnet"
 )
 
 var proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy{}
@@ -58,6 +58,7 @@ func NewProxy(
 	pxyConf v1.ProxyConfigurer,
 	clientCfg *v1.ClientCommonConfig,
 	msgTransporter transport.MessageTransporter,
+	vnetController *vnet.Controller,
 ) (pxy Proxy) {
 	var limiter *rate.Limiter
 	limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
@@ -70,6 +71,7 @@ func NewProxy(
 		clientCfg:      clientCfg,
 		limiter:        limiter,
 		msgTransporter: msgTransporter,
+		vnetController: vnetController,
 		xl:             xlog.FromContextSafe(ctx),
 		ctx:            ctx,
 	}
@@ -85,6 +87,7 @@ type BaseProxy struct {
 	baseCfg        *v1.ProxyBaseConfig
 	clientCfg      *v1.ClientCommonConfig
 	msgTransporter transport.MessageTransporter
+	vnetController *vnet.Controller
 	limiter        *rate.Limiter
 	// proxyPlugin is used to handle connections instead of dialing to local service.
 	// It's only validate for TCP protocol now.
@@ -98,7 +101,10 @@ type BaseProxy struct {
 
 func (pxy *BaseProxy) Run() error {
 	if pxy.baseCfg.Plugin.Type != "" {
-		p, err := plugin.Create(pxy.baseCfg.Plugin.Type, pxy.baseCfg.Plugin.ClientPluginOptions)
+		p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{
+			Name:           pxy.baseCfg.Name,
+			VnetController: pxy.vnetController,
+		}, pxy.baseCfg.Plugin.ClientPluginOptions)
 		if err != nil {
 			return err
 		}
@@ -157,42 +163,29 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
 	}
 
 	// check if we need to send proxy protocol info
-	var extraInfo plugin.ExtraInfo
+	var connInfo plugin.ConnectionInfo
 	if m.SrcAddr != "" && m.SrcPort != 0 {
 		if m.DstAddr == "" {
 			m.DstAddr = "127.0.0.1"
 		}
 		srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
 		dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
-		extraInfo.SrcAddr = srcAddr
-		extraInfo.DstAddr = dstAddr
+		connInfo.SrcAddr = srcAddr
+		connInfo.DstAddr = dstAddr
 	}
 
 	if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
-		h := &pp.Header{
-			Command:         pp.PROXY,
-			SourceAddr:      extraInfo.SrcAddr,
-			DestinationAddr: extraInfo.DstAddr,
-		}
-
-		if strings.Contains(m.SrcAddr, ".") {
-			h.TransportProtocol = pp.TCPv4
-		} else {
-			h.TransportProtocol = pp.TCPv6
-		}
-
-		if baseCfg.Transport.ProxyProtocolVersion == "v1" {
-			h.Version = 1
-		} else if baseCfg.Transport.ProxyProtocolVersion == "v2" {
-			h.Version = 2
-		}
-		extraInfo.ProxyProtocolHeader = h
+		// Use the common proxy protocol builder function
+		header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)
+		connInfo.ProxyProtocolHeader = header
 	}
+	connInfo.Conn = remote
+	connInfo.UnderlyingConn = workConn
 
 	if pxy.proxyPlugin != nil {
 		// if plugin is set, let plugin handle connection first
 		xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name())
-		pxy.proxyPlugin.Handle(pxy.ctx, remote, workConn, &extraInfo)
+		pxy.proxyPlugin.Handle(pxy.ctx, &connInfo)
 		xl.Debugf("handle by plugin finished")
 		return
 	}
@@ -210,8 +203,8 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
 	xl.Debugf("join connections, localConn(l[%s] r[%s]) workConn(l[%s] r[%s])", localConn.LocalAddr().String(),
 		localConn.RemoteAddr().String(), workConn.LocalAddr().String(), workConn.RemoteAddr().String())
 
-	if extraInfo.ProxyProtocolHeader != nil {
-		if _, err := extraInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
+	if connInfo.ProxyProtocolHeader != nil {
+		if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
 			workConn.Close()
 			xl.Errorf("write proxy protocol header to local conn error: %v", err)
 			return

+ 5 - 1
client/proxy/proxy_manager.go

@@ -28,12 +28,14 @@ import (
 	"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"
 )
 
 type Manager struct {
 	proxies            map[string]*Wrapper
 	msgTransporter     transport.MessageTransporter
 	inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
+	vnetController     *vnet.Controller
 
 	closed bool
 	mu     sync.RWMutex
@@ -47,10 +49,12 @@ func NewManager(
 	ctx context.Context,
 	clientCfg *v1.ClientCommonConfig,
 	msgTransporter transport.MessageTransporter,
+	vnetController *vnet.Controller,
 ) *Manager {
 	return &Manager{
 		proxies:        make(map[string]*Wrapper),
 		msgTransporter: msgTransporter,
+		vnetController: vnetController,
 		closed:         false,
 		clientCfg:      clientCfg,
 		ctx:            ctx,
@@ -159,7 +163,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
 	for _, cfg := range proxyCfgs {
 		name := cfg.GetBaseConfig().Name
 		if _, ok := pm.proxies[name]; !ok {
-			pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter)
+			pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
 			if pm.inWorkConnCallback != nil {
 				pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
 			}

+ 6 - 1
client/proxy/proxy_wrapper.go

@@ -31,6 +31,7 @@ import (
 	"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"
 )
 
 const (
@@ -73,6 +74,8 @@ type Wrapper struct {
 	handler event.Handler
 
 	msgTransporter transport.MessageTransporter
+	// vnet controller
+	vnetController *vnet.Controller
 
 	health           uint32
 	lastSendStartMsg time.Time
@@ -91,6 +94,7 @@ func NewWrapper(
 	clientCfg *v1.ClientCommonConfig,
 	eventHandler event.Handler,
 	msgTransporter transport.MessageTransporter,
+	vnetController *vnet.Controller,
 ) *Wrapper {
 	baseInfo := cfg.GetBaseConfig()
 	xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
@@ -105,6 +109,7 @@ func NewWrapper(
 		healthNotifyCh: make(chan struct{}),
 		handler:        eventHandler,
 		msgTransporter: msgTransporter,
+		vnetController: vnetController,
 		xl:             xl,
 		ctx:            xlog.NewContext(ctx, xl),
 	}
@@ -117,7 +122,7 @@ func NewWrapper(
 		xl.Tracef("enable health check monitor")
 	}
 
-	pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter)
+	pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter, pw.vnetController)
 	return pw
 }
 

+ 1 - 1
client/proxy/sudp.go

@@ -205,5 +205,5 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
 	go workConnReaderFn(workConn, readCh)
 	go heartbeatFn(sendCh)
 
-	udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize))
+	udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
 }

+ 3 - 1
client/proxy/udp.go

@@ -171,5 +171,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
 	go workConnSenderFn(pxy.workConn, pxy.sendCh)
 	go workConnReaderFn(pxy.workConn, pxy.readCh)
 	go heartbeatFn(pxy.sendCh)
-	udp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize))
+
+	// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
+	udp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
 }

+ 30 - 11
client/service.go

@@ -37,6 +37,7 @@ import (
 	"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"
 )
 
 func init() {
@@ -110,6 +111,8 @@ type Service struct {
 	// web server for admin UI and apis
 	webServer *httppkg.Server
 
+	vnetController *vnet.Controller
+
 	cfgMu       sync.RWMutex
 	common      *v1.ClientCommonConfig
 	proxyCfgs   []v1.ProxyConfigurer
@@ -156,6 +159,9 @@ func NewService(options ServiceOptions) (*Service, error) {
 	if webServer != nil {
 		webServer.RouteRegister(s.registerRouteHandlers)
 	}
+	if options.Common.VirtualNet.Address != "" {
+		s.vnetController = vnet.NewController(options.Common.VirtualNet)
+	}
 	return s, nil
 }
 
@@ -169,6 +175,19 @@ func (svr *Service) Run(ctx context.Context) error {
 		netpkg.SetDefaultDNSAddress(svr.common.DNSServer)
 	}
 
+	if svr.vnetController != nil {
+		if err := svr.vnetController.Init(); err != nil {
+			log.Errorf("init virtual network controller error: %v", err)
+			return err
+		}
+		go func() {
+			log.Infof("virtual network controller start...")
+			if err := svr.vnetController.Run(); err != nil {
+				log.Warnf("virtual network controller exit with error: %v", err)
+			}
+		}()
+	}
+
 	if svr.webServer != nil {
 		go func() {
 			log.Infof("admin server listen on %s", svr.webServer.Address())
@@ -306,22 +325,22 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
 		proxyCfgs := svr.proxyCfgs
 		visitorCfgs := svr.visitorCfgs
 		svr.cfgMu.RUnlock()
-		connEncrypted := true
-		if svr.clientSpec != nil && svr.clientSpec.Type == "ssh-tunnel" {
-			connEncrypted = false
-		}
+
+		connEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != "ssh-tunnel"
+
 		sessionCtx := &SessionContext{
-			Common:        svr.common,
-			RunID:         svr.runID,
-			Conn:          conn,
-			ConnEncrypted: connEncrypted,
-			AuthSetter:    svr.authSetter,
-			Connector:     connector,
+			Common:         svr.common,
+			RunID:          svr.runID,
+			Conn:           conn,
+			ConnEncrypted:  connEncrypted,
+			AuthSetter:     svr.authSetter,
+			Connector:      connector,
+			VnetController: svr.vnetController,
 		}
 		ctl, err := NewControl(svr.ctx, sessionCtx)
 		if err != nil {
 			conn.Close()
-			xl.Errorf("NewControl error: %v", err)
+			xl.Errorf("new control error: %v", err)
 			return false, err
 		}
 		ctl.SetInWorkConnCallback(svr.handleWorkConnCb)

+ 4 - 0
client/visitor/stcp.go

@@ -44,6 +44,10 @@ func (sv *STCPVisitor) Run() (err error) {
 	}
 
 	go sv.internalConnWorker()
+
+	if sv.plugin != nil {
+		sv.plugin.Start()
+	}
 	return
 }
 

+ 31 - 3
client/visitor/visitor.go

@@ -20,9 +20,11 @@ import (
 	"sync"
 
 	v1 "github.com/fatedier/frp/pkg/config/v1"
+	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/xlog"
+	"github.com/fatedier/frp/pkg/vnet"
 )
 
 // Helper wraps some functions for visitor to use.
@@ -34,6 +36,8 @@ type Helper interface {
 	// MsgTransporter returns the message transporter that is used to send and receive messages
 	// to the frp server through the controller.
 	MsgTransporter() transport.MessageTransporter
+	// VNetController returns the vnet controller that is used to manage the virtual network.
+	VNetController() *vnet.Controller
 	// RunID returns the run id of current controller.
 	RunID() string
 }
@@ -50,14 +54,34 @@ func NewVisitor(
 	cfg v1.VisitorConfigurer,
 	clientCfg *v1.ClientCommonConfig,
 	helper Helper,
-) (visitor Visitor) {
+) (Visitor, error) {
 	xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(cfg.GetBaseConfig().Name)
+	ctx = xlog.NewContext(ctx, xl)
+	var visitor Visitor
 	baseVisitor := BaseVisitor{
 		clientCfg:  clientCfg,
 		helper:     helper,
-		ctx:        xlog.NewContext(ctx, xl),
+		ctx:        ctx,
 		internalLn: netpkg.NewInternalListener(),
 	}
+	if cfg.GetBaseConfig().Plugin.Type != "" {
+		p, err := plugin.Create(
+			cfg.GetBaseConfig().Plugin.Type,
+			plugin.PluginContext{
+				Name:           cfg.GetBaseConfig().Name,
+				Ctx:            ctx,
+				VnetController: helper.VNetController(),
+				HandleConn: func(conn net.Conn) {
+					_ = baseVisitor.AcceptConn(conn)
+				},
+			},
+			cfg.GetBaseConfig().Plugin.VisitorPluginOptions,
+		)
+		if err != nil {
+			return nil, err
+		}
+		baseVisitor.plugin = p
+	}
 	switch cfg := cfg.(type) {
 	case *v1.STCPVisitorConfig:
 		visitor = &STCPVisitor{
@@ -77,7 +101,7 @@ func NewVisitor(
 			checkCloseCh: make(chan struct{}),
 		}
 	}
-	return
+	return visitor, nil
 }
 
 type BaseVisitor struct {
@@ -85,6 +109,7 @@ type BaseVisitor struct {
 	helper     Helper
 	l          net.Listener
 	internalLn *netpkg.InternalListener
+	plugin     plugin.Plugin
 
 	mu  sync.RWMutex
 	ctx context.Context
@@ -101,4 +126,7 @@ func (v *BaseVisitor) Close() {
 	if v.internalLn != nil {
 		v.internalLn.Close()
 	}
+	if v.plugin != nil {
+		v.plugin.Close()
+	}
 }

+ 13 - 1
client/visitor/visitor_manager.go

@@ -27,6 +27,7 @@ import (
 	v1 "github.com/fatedier/frp/pkg/config/v1"
 	"github.com/fatedier/frp/pkg/transport"
 	"github.com/fatedier/frp/pkg/util/xlog"
+	"github.com/fatedier/frp/pkg/vnet"
 )
 
 type Manager struct {
@@ -50,6 +51,7 @@ func NewManager(
 	clientCfg *v1.ClientCommonConfig,
 	connectServer func() (net.Conn, error),
 	msgTransporter transport.MessageTransporter,
+	vnetController *vnet.Controller,
 ) *Manager {
 	m := &Manager{
 		clientCfg:     clientCfg,
@@ -62,6 +64,7 @@ func NewManager(
 	m.helper = &visitorHelperImpl{
 		connectServerFn: connectServer,
 		msgTransporter:  msgTransporter,
+		vnetController:  vnetController,
 		transferConnFn:  m.TransferConn,
 		runID:           runID,
 	}
@@ -112,7 +115,11 @@ func (vm *Manager) Close() {
 func (vm *Manager) startVisitor(cfg v1.VisitorConfigurer) (err error) {
 	xl := xlog.FromContextSafe(vm.ctx)
 	name := cfg.GetBaseConfig().Name
-	visitor := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper)
+	visitor, err := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper)
+	if err != nil {
+		xl.Warnf("new visitor error: %v", err)
+		return
+	}
 	err = visitor.Run()
 	if err != nil {
 		xl.Warnf("start error: %v", err)
@@ -187,6 +194,7 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error {
 type visitorHelperImpl struct {
 	connectServerFn func() (net.Conn, error)
 	msgTransporter  transport.MessageTransporter
+	vnetController  *vnet.Controller
 	transferConnFn  func(name string, conn net.Conn) error
 	runID           string
 }
@@ -203,6 +211,10 @@ func (v *visitorHelperImpl) MsgTransporter() transport.MessageTransporter {
 	return v.msgTransporter
 }
 
+func (v *visitorHelperImpl) VNetController() *vnet.Controller {
+	return v.vnetController
+}
+
 func (v *visitorHelperImpl) RunID() string {
 	return v.runID
 }

+ 7 - 3
client/visitor/xtcp.go

@@ -73,6 +73,10 @@ func (sv *XTCPVisitor) Run() (err error) {
 		sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)
 		go sv.keepTunnelOpenWorker()
 	}
+
+	if sv.plugin != nil {
+		sv.plugin.Start()
+	}
 	return
 }
 
@@ -157,9 +161,9 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
 
 func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 	xl := xlog.FromContextSafe(sv.ctx)
-	isConnTrasfered := false
+	isConnTransfered := false
 	defer func() {
-		if !isConnTrasfered {
+		if !isConnTransfered {
 			userConn.Close()
 		}
 	}()
@@ -187,7 +191,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 			xl.Errorf("transfer connection to visitor %s error: %v", sv.cfg.FallbackTo, err)
 			return
 		}
-		isConnTrasfered = true
+		isConnTransfered = true
 		return
 	}
 

+ 7 - 0
cmd/frpc/sub/root.go

@@ -31,6 +31,7 @@ import (
 	"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/featuregate"
 	"github.com/fatedier/frp/pkg/util/log"
 	"github.com/fatedier/frp/pkg/util/version"
 )
@@ -120,6 +121,12 @@ func runClient(cfgFilePath string) error {
 			"please use yaml/json/toml format instead!\n")
 	}
 
+	if len(cfg.FeatureGates) > 0 {
+		if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
+			return err
+		}
+	}
+
 	warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs)
 	if warning != nil {
 		fmt.Printf("WARNING: %v\n", warning)

+ 26 - 0
conf/frpc_full_example.toml

@@ -129,6 +129,15 @@ transport.tls.enable = true
 # It affects the udp and sudp proxy.
 udpPacketSize = 1500
 
+# Feature gates allows you to enable or disable experimental features
+# Format is a map of feature names to boolean values
+# You can enable specific features:
+#featureGates = { VirtualNet = true }
+
+# VirtualNet settings for experimental virtual network capabilities
+# The virtual network feature requires enabling the VirtualNet feature gate above
+# virtualNet.address = "100.86.1.1/24"
+
 # Additional metadatas for client.
 metadatas.var1 = "abc"
 metadatas.var2 = "123"
@@ -358,6 +367,13 @@ localPort = 22
 # Otherwise, visitors from same user can connect. '*' means allow all users.
 allowUsers = ["user1", "user2"]
 
+[[proxies]]
+name = "vnet-server"
+type = "stcp"
+secretKey = "your-secret-key"
+[proxies.plugin]
+type = "virtual_net"
+
 # frpc role visitor -> frps -> frpc role server
 [[visitors]]
 name = "secret_tcp_visitor"
@@ -389,3 +405,13 @@ maxRetriesAnHour = 8
 minRetryInterval = 90
 # fallbackTo = "stcp_visitor"
 # fallbackTimeoutMs = 500
+
+[[visitors]]
+name = "vnet-visitor"
+type = "stcp"
+serverName = "vnet-server"
+secretKey = "your-secret-key"
+bindPort = -1
+[visitors.plugin]
+type = "virtual_net"
+destinationIP = "100.86.0.1"

+ 1 - 1
doc/server_plugin.md

@@ -121,7 +121,7 @@ Create new proxy
         // http and https only
         "custom_domains": []<string>,
         "subdomain": <string>,
-        "locations": <string>,
+        "locations": []<string>,
         "http_user": <string>,
         "http_pwd": <string>,
         "host_header_rewrite": <string>,

+ 73 - 0
doc/virtual_net.md

@@ -0,0 +1,73 @@
+# Virtual Network (VirtualNet)
+
+*Alpha feature added in v0.62.0*
+
+The VirtualNet feature enables frp to create and manage virtual network connections between clients and visitors through a TUN interface. This allows for IP-level routing between machines, extending frp beyond simple port forwarding to support full network connectivity.
+
+> **Note**: VirtualNet is an Alpha stage feature and is currently unstable. Its configuration methods and functionality may be adjusted and changed at any time in subsequent versions. Do not use this feature in production environments; it is only recommended for testing and evaluation purposes.
+
+## Enabling VirtualNet
+
+Since VirtualNet is currently an alpha feature, you need to enable it with feature gates in your configuration:
+
+```toml
+# frpc.toml
+featureGates = { VirtualNet = true }
+```
+
+## Basic Configuration
+
+To use the virtual network capabilities:
+
+1. First, configure your frpc with a virtual network address:
+
+```toml
+# frpc.toml
+serverAddr = "x.x.x.x"
+serverPort = 7000
+featureGates = { VirtualNet = true }
+
+# Configure the virtual network interface
+virtualNet.address = "100.86.0.1/24"
+```
+
+2. For client proxies, use the `virtual_net` plugin:
+
+```toml
+# frpc.toml (server side)
+[[proxies]]
+name = "vnet-server"
+type = "stcp"
+secretKey = "your-secret-key"
+[proxies.plugin]
+type = "virtual_net"
+```
+
+3. For visitor connections, configure the `virtual_net` visitor plugin:
+
+```toml
+# frpc.toml (client side)
+serverAddr = "x.x.x.x"
+serverPort = 7000
+featureGates = { VirtualNet = true }
+
+# Configure the virtual network interface
+virtualNet.address = "100.86.0.2/24"
+
+[[visitors]]
+name = "vnet-visitor"
+type = "stcp"
+serverName = "vnet-server"
+secretKey = "your-secret-key"
+bindPort = -1
+[visitors.plugin]
+type = "virtual_net"
+destinationIP = "100.86.0.1"
+```
+
+## Requirements and Limitations
+
+- **Permissions**: Creating a TUN interface requires elevated permissions (root/admin)
+- **Platform Support**: Currently supported on Linux and macOS
+- **Default Status**: As an alpha feature, VirtualNet is disabled by default
+- **Configuration**: A valid IP/CIDR must be provided for each endpoint in the virtual network 

+ 22 - 19
go.mod

@@ -4,14 +4,14 @@ go 1.23.0
 
 require (
 	github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
-	github.com/coreos/go-oidc/v3 v3.10.0
+	github.com/coreos/go-oidc/v3 v3.14.1
 	github.com/fatedier/golib v0.5.1
 	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.1
-	github.com/onsi/ginkgo/v2 v2.22.0
-	github.com/onsi/gomega v1.34.2
+	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/pires/go-proxyproto v0.7.0
@@ -19,16 +19,19 @@ require (
 	github.com/quic-go/quic-go v0.48.2
 	github.com/rodaine/table v1.2.0
 	github.com/samber/lo v1.47.0
+	github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
 	github.com/spf13/cobra v1.8.0
 	github.com/spf13/pflag v1.0.5
-	github.com/stretchr/testify v1.9.0
+	github.com/stretchr/testify v1.10.0
 	github.com/tidwall/gjson v1.17.1
+	github.com/vishvananda/netlink v1.3.0
 	github.com/xtaci/kcp-go/v5 v5.6.13
-	golang.org/x/crypto v0.30.0
-	golang.org/x/net v0.32.0
-	golang.org/x/oauth2 v0.16.0
-	golang.org/x/sync v0.10.0
+	golang.org/x/crypto v0.37.0
+	golang.org/x/net v0.39.0
+	golang.org/x/oauth2 v0.28.0
+	golang.org/x/sync v0.13.0
 	golang.org/x/time v0.5.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
@@ -39,17 +42,15 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/go-jose/go-jose/v4 v4.0.1 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
-	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
-	github.com/google/go-cmp v0.6.0 // indirect
-	github.com/google/pprof v0.0.0-20241206021119-61a79c692802 // indirect
+	github.com/google/go-cmp v0.7.0 // indirect
+	github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.6 // indirect
 	github.com/klauspost/reedsolomon v1.12.0 // indirect
-	github.com/kr/text v0.2.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
@@ -64,14 +65,16 @@ require (
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.0 // indirect
 	github.com/tjfoc/gmsm v1.4.1 // indirect
+	github.com/vishvananda/netns v0.0.4 // indirect
+	go.uber.org/automaxprocs v1.6.0 // indirect
 	go.uber.org/mock v0.5.0 // indirect
 	golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
-	golang.org/x/mod v0.22.0 // indirect
-	golang.org/x/sys v0.28.0 // indirect
-	golang.org/x/text v0.21.0 // indirect
-	golang.org/x/tools v0.28.0 // indirect
-	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/protobuf v1.34.1 // indirect
+	golang.org/x/mod v0.24.0 // indirect
+	golang.org/x/sys v0.32.0 // indirect
+	golang.org/x/text v0.24.0 // indirect
+	golang.org/x/tools v0.31.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

+ 54 - 43
go.sum

@@ -11,10 +11,9 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
-github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
+github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
+github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -25,8 +24,8 @@ github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
 github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
 github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d h1:ynk1ra0RUqDWQfvFi5KtMiSobkVQ3cNc0ODb8CfIETo=
 github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
-github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
-github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -42,21 +41,19 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
 github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
-github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
+github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/pprof v0.0.0-20241206021119-61a79c692802 h1:US08AXzP0bLurpzFUV3Poa9ZijrRdd1zAIOVtoHEiS8=
-github.com/google/pprof v0.0.0-20241206021119-61a79c692802/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -75,10 +72,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
-github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
-github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
-github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
+github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
+github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
+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=
@@ -97,6 +94,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
 github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -117,6 +116,8 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
 github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
+github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
+github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
 github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
 github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -129,8 +130,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
 github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
 github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
@@ -143,11 +145,17 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
 github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
+github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
+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/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.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -156,8 +164,8 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh
 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.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
-golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
 golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
@@ -166,8 +174,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
 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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
-golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
+golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -181,18 +189,18 @@ 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.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
-golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
-golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
+golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
+golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
 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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 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=
@@ -201,29 +209,30 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 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.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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
 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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -234,14 +243,16 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
 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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
-golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
+golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
+golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
 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=
+golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
+golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
-google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -254,10 +265,8 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
 google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
 google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
-google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -268,6 +277,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
+gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 k8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ=

+ 3 - 3
hack/run-e2e.sh

@@ -3,10 +3,10 @@
 SCRIPT=$(readlink -f "$0")
 ROOT=$(unset CDPATH && cd "$(dirname "$SCRIPT")/.." && pwd)
 
-ginkgo_command=$(which ginkgo 2>/dev/null)
-if [ -z "$ginkgo_command" ]; then
+# Check if ginkgo is available
+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.17.1
+    go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4
 fi
 
 debug=false

+ 1 - 1
package.sh

@@ -17,7 +17,7 @@ make -f ./Makefile.cross-compiles
 rm -rf ./release/packages
 mkdir -p ./release/packages
 
-os_all='linux windows darwin freebsd android'
+os_all='linux windows darwin freebsd openbsd android'
 arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'
 extra_all='_ hf'
 

+ 2 - 5
pkg/config/legacy/client.go

@@ -194,7 +194,7 @@ func UnmarshalClientConfFromIni(source any) (ClientCommonConf, error) {
 	}
 
 	common.Metas = GetMapWithoutPrefix(s.KeysHash(), "meta_")
-	common.ClientConfig.OidcAdditionalEndpointParams = GetMapWithoutPrefix(s.KeysHash(), "oidc_additional_")
+	common.OidcAdditionalEndpointParams = GetMapWithoutPrefix(s.KeysHash(), "oidc_additional_")
 
 	return common, nil
 }
@@ -229,10 +229,7 @@ func LoadAllProxyConfsFromIni(
 		startProxy[s] = struct{}{}
 	}
 
-	startAll := true
-	if len(startProxy) > 0 {
-		startAll = false
-	}
+	startAll := len(startProxy) == 0
 
 	// Build template sections from range section And append to ini.File.
 	rangeSections := make([]*ini.Section, 0)

+ 22 - 22
pkg/config/legacy/conversion.go

@@ -26,20 +26,20 @@ import (
 func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConfig {
 	out := &v1.ClientCommonConfig{}
 	out.User = conf.User
-	out.Auth.Method = v1.AuthMethod(conf.ClientConfig.AuthenticationMethod)
-	out.Auth.Token = conf.ClientConfig.Token
-	if conf.ClientConfig.AuthenticateHeartBeats {
+	out.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod)
+	out.Auth.Token = conf.Token
+	if conf.AuthenticateHeartBeats {
 		out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats)
 	}
-	if conf.ClientConfig.AuthenticateNewWorkConns {
+	if conf.AuthenticateNewWorkConns {
 		out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns)
 	}
-	out.Auth.OIDC.ClientID = conf.ClientConfig.OidcClientID
-	out.Auth.OIDC.ClientSecret = conf.ClientConfig.OidcClientSecret
-	out.Auth.OIDC.Audience = conf.ClientConfig.OidcAudience
-	out.Auth.OIDC.Scope = conf.ClientConfig.OidcScope
-	out.Auth.OIDC.TokenEndpointURL = conf.ClientConfig.OidcTokenEndpointURL
-	out.Auth.OIDC.AdditionalEndpointParams = conf.ClientConfig.OidcAdditionalEndpointParams
+	out.Auth.OIDC.ClientID = conf.OidcClientID
+	out.Auth.OIDC.ClientSecret = conf.OidcClientSecret
+	out.Auth.OIDC.Audience = conf.OidcAudience
+	out.Auth.OIDC.Scope = conf.OidcScope
+	out.Auth.OIDC.TokenEndpointURL = conf.OidcTokenEndpointURL
+	out.Auth.OIDC.AdditionalEndpointParams = conf.OidcAdditionalEndpointParams
 
 	out.ServerAddr = conf.ServerAddr
 	out.ServerPort = conf.ServerPort
@@ -59,10 +59,10 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
 	out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
 	out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
 	out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
-	out.Transport.TLS.TLSConfig.CertFile = conf.TLSCertFile
-	out.Transport.TLS.TLSConfig.KeyFile = conf.TLSKeyFile
-	out.Transport.TLS.TLSConfig.TrustedCaFile = conf.TLSTrustedCaFile
-	out.Transport.TLS.TLSConfig.ServerName = conf.TLSServerName
+	out.Transport.TLS.CertFile = conf.TLSCertFile
+	out.Transport.TLS.KeyFile = conf.TLSKeyFile
+	out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
+	out.Transport.TLS.ServerName = conf.TLSServerName
 
 	out.Log.To = conf.LogFile
 	out.Log.Level = conf.LogLevel
@@ -87,18 +87,18 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
 
 func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
 	out := &v1.ServerConfig{}
-	out.Auth.Method = v1.AuthMethod(conf.ServerConfig.AuthenticationMethod)
-	out.Auth.Token = conf.ServerConfig.Token
-	if conf.ServerConfig.AuthenticateHeartBeats {
+	out.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod)
+	out.Auth.Token = conf.Token
+	if conf.AuthenticateHeartBeats {
 		out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats)
 	}
-	if conf.ServerConfig.AuthenticateNewWorkConns {
+	if conf.AuthenticateNewWorkConns {
 		out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns)
 	}
-	out.Auth.OIDC.Audience = conf.ServerConfig.OidcAudience
-	out.Auth.OIDC.Issuer = conf.ServerConfig.OidcIssuer
-	out.Auth.OIDC.SkipExpiryCheck = conf.ServerConfig.OidcSkipExpiryCheck
-	out.Auth.OIDC.SkipIssuerCheck = conf.ServerConfig.OidcSkipIssuerCheck
+	out.Auth.OIDC.Audience = conf.OidcAudience
+	out.Auth.OIDC.Issuer = conf.OidcIssuer
+	out.Auth.OIDC.SkipExpiryCheck = conf.OidcSkipExpiryCheck
+	out.Auth.OIDC.SkipIssuerCheck = conf.OidcSkipIssuerCheck
 
 	out.BindAddr = conf.BindAddr
 	out.BindPort = conf.BindPort

+ 1 - 1
pkg/config/legacy/proxy.go

@@ -206,7 +206,7 @@ func (cfg *BaseProxyConf) decorate(_ string, name string, section *ini.Section)
 	}
 
 	// plugin_xxx
-	cfg.LocalSvrConf.PluginParams = GetMapByPrefix(section.KeysHash(), "plugin_")
+	cfg.PluginParams = GetMapByPrefix(section.KeysHash(), "plugin_")
 	return nil
 }
 

+ 32 - 2
pkg/config/load.go

@@ -111,6 +111,33 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
 	return LoadConfigure(content, c, strict)
 }
 
+// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
+// This function handles both cases efficiently: with or without dot fields
+func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
+	var temp any
+	if err := yaml.Unmarshal(content, &temp); err != nil {
+		return err
+	}
+
+	// Remove dot fields if it's a map
+	if tempMap, ok := temp.(map[string]any); ok {
+		for key := range tempMap {
+			if strings.HasPrefix(key, ".") {
+				delete(tempMap, key)
+			}
+		}
+	}
+
+	// Convert to JSON and decode with strict validation
+	jsonBytes, err := json.Marshal(temp)
+	if err != nil {
+		return err
+	}
+	decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
+	decoder.DisallowUnknownFields()
+	return decoder.Decode(target)
+}
+
 // 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 {
@@ -134,10 +161,13 @@ func LoadConfigure(b []byte, c any, strict bool) error {
 		}
 		return decoder.Decode(c)
 	}
-	// It wasn't JSON. Unmarshal as YAML.
+
+	// Handle YAML content
 	if strict {
-		return yaml.UnmarshalStrict(b, c)
+		// In strict mode, always use our custom handler to support YAML merge
+		return parseYAMLWithDotFieldsHandling(b, c)
 	}
+	// Non-strict mode, parse normally
 	return yaml.Unmarshal(b, c)
 }
 

+ 119 - 0
pkg/config/load_test.go

@@ -187,3 +187,122 @@ unixPath = "/tmp/uds.sock"
 	err = LoadConfigure([]byte(pluginStr), &clientCfg, true)
 	require.Error(err)
 }
+
+// TestYAMLMergeInStrictMode tests that YAML merge functionality works
+// even in strict mode by properly handling dot-prefixed fields
+func TestYAMLMergeInStrictMode(t *testing.T) {
+	require := require.New(t)
+
+	yamlContent := `
+serverAddr: "127.0.0.1"
+serverPort: 7000
+
+.common: &common
+  type: stcp
+  secretKey: "test-secret"
+  localIP: 127.0.0.1
+  transport:
+    useEncryption: true
+    useCompression: true
+
+proxies:
+- name: ssh
+  localPort: 22
+  <<: *common
+- name: web
+  localPort: 80
+  <<: *common
+`
+
+	clientCfg := v1.ClientConfig{}
+	// This should work in strict mode
+	err := LoadConfigure([]byte(yamlContent), &clientCfg, true)
+	require.NoError(err)
+
+	// Verify the merge worked correctly
+	require.Equal("127.0.0.1", clientCfg.ServerAddr)
+	require.Equal(7000, clientCfg.ServerPort)
+	require.Len(clientCfg.Proxies, 2)
+
+	// Check first proxy
+	sshProxy := clientCfg.Proxies[0].ProxyConfigurer
+	require.Equal("ssh", sshProxy.GetBaseConfig().Name)
+	require.Equal("stcp", sshProxy.GetBaseConfig().Type)
+
+	// Check second proxy
+	webProxy := clientCfg.Proxies[1].ProxyConfigurer
+	require.Equal("web", webProxy.GetBaseConfig().Name)
+	require.Equal("stcp", webProxy.GetBaseConfig().Type)
+}
+
+// TestOptimizedYAMLProcessing tests the optimization logic for YAML processing
+func TestOptimizedYAMLProcessing(t *testing.T) {
+	require := require.New(t)
+
+	yamlWithDotFields := []byte(`
+serverAddr: "127.0.0.1"
+.common: &common
+  type: stcp
+proxies:
+- name: test
+  <<: *common
+`)
+
+	yamlWithoutDotFields := []byte(`
+serverAddr: "127.0.0.1"
+proxies:
+- name: test
+  type: tcp
+  localPort: 22
+`)
+
+	// Test that YAML without dot fields works in strict mode
+	clientCfg := v1.ClientConfig{}
+	err := LoadConfigure(yamlWithoutDotFields, &clientCfg, true)
+	require.NoError(err)
+	require.Equal("127.0.0.1", clientCfg.ServerAddr)
+	require.Len(clientCfg.Proxies, 1)
+	require.Equal("test", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name)
+
+	// Test that YAML with dot fields still works in strict mode
+	err = LoadConfigure(yamlWithDotFields, &clientCfg, true)
+	require.NoError(err)
+	require.Equal("127.0.0.1", clientCfg.ServerAddr)
+	require.Len(clientCfg.Proxies, 1)
+	require.Equal("test", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name)
+	require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)
+}
+
+// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
+func TestYAMLEdgeCases(t *testing.T) {
+	require := require.New(t)
+
+	// Test array at root (should fail for frp config)
+	arrayYAML := []byte(`
+- item1
+- item2
+`)
+	clientCfg := v1.ClientConfig{}
+	err := LoadConfigure(arrayYAML, &clientCfg, true)
+	require.Error(err) // Should fail because ClientConfig expects an object
+
+	// Test scalar at root (should fail for frp config)
+	scalarYAML := []byte(`"just a string"`)
+	err = LoadConfigure(scalarYAML, &clientCfg, true)
+	require.Error(err) // Should fail because ClientConfig expects an object
+
+	// Test empty object (should work)
+	emptyYAML := []byte(`{}`)
+	err = LoadConfigure(emptyYAML, &clientCfg, true)
+	require.NoError(err)
+
+	// Test nested structure without dots (should work)
+	nestedYAML := []byte(`
+serverAddr: "127.0.0.1"
+serverPort: 7000
+`)
+	err = LoadConfigure(nestedYAML, &clientCfg, true)
+	require.NoError(err)
+	require.Equal("127.0.0.1", clientCfg.ServerAddr)
+	require.Equal(7000, clientCfg.ServerPort)
+}

+ 12 - 3
pkg/config/v1/client.go

@@ -58,9 +58,14 @@ type ClientCommonConfig struct {
 	// set.
 	Start []string `json:"start,omitempty"`
 
-	Log       LogConfig             `json:"log,omitempty"`
-	WebServer WebServerConfig       `json:"webServer,omitempty"`
-	Transport ClientTransportConfig `json:"transport,omitempty"`
+	Log        LogConfig             `json:"log,omitempty"`
+	WebServer  WebServerConfig       `json:"webServer,omitempty"`
+	Transport  ClientTransportConfig `json:"transport,omitempty"`
+	VirtualNet VirtualNetConfig      `json:"virtualNet,omitempty"`
+
+	// FeatureGates specifies a set of feature gates to enable or disable.
+	// This can be used to enable alpha/beta features or disable default features.
+	FeatureGates map[string]bool `json:"featureGates,omitempty"`
 
 	// UDPPacketSize specifies the udp packet size
 	// By default, this value is 1500
@@ -204,3 +209,7 @@ type AuthOIDCClientConfig struct {
 	// this field will be transfer to map[string][]string in OIDC token generator.
 	AdditionalEndpointParams map[string]string `json:"additionalEndpointParams,omitempty"`
 }
+
+type VirtualNetConfig struct {
+	Address string `json:"address,omitempty"`
+}

+ 1 - 1
pkg/config/v1/proxy.go

@@ -129,7 +129,7 @@ func (c *ProxyBaseConfig) Complete(namePrefix string) {
 	c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
 
 	if c.Plugin.ClientPluginOptions != nil {
-		c.Plugin.ClientPluginOptions.Complete()
+		c.Plugin.Complete()
 	}
 }
 

+ 32 - 24
pkg/config/v1/plugin.go → pkg/config/v1/proxy_plugin.go

@@ -26,6 +26,32 @@ import (
 	"github.com/fatedier/frp/pkg/util/util"
 )
 
+const (
+	PluginHTTP2HTTPS       = "http2https"
+	PluginHTTPProxy        = "http_proxy"
+	PluginHTTPS2HTTP       = "https2http"
+	PluginHTTPS2HTTPS      = "https2https"
+	PluginHTTP2HTTP        = "http2http"
+	PluginSocks5           = "socks5"
+	PluginStaticFile       = "static_file"
+	PluginUnixDomainSocket = "unix_domain_socket"
+	PluginTLS2Raw          = "tls2raw"
+	PluginVirtualNet       = "virtual_net"
+)
+
+var clientPluginOptionsTypeMap = map[string]reflect.Type{
+	PluginHTTP2HTTPS:       reflect.TypeOf(HTTP2HTTPSPluginOptions{}),
+	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{}),
+}
+
 type ClientPluginOptions interface {
 	Complete()
 }
@@ -74,30 +100,6 @@ func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
 	return json.Marshal(c.ClientPluginOptions)
 }
 
-const (
-	PluginHTTP2HTTPS       = "http2https"
-	PluginHTTPProxy        = "http_proxy"
-	PluginHTTPS2HTTP       = "https2http"
-	PluginHTTPS2HTTPS      = "https2https"
-	PluginHTTP2HTTP        = "http2http"
-	PluginSocks5           = "socks5"
-	PluginStaticFile       = "static_file"
-	PluginUnixDomainSocket = "unix_domain_socket"
-	PluginTLS2Raw          = "tls2raw"
-)
-
-var clientPluginOptionsTypeMap = map[string]reflect.Type{
-	PluginHTTP2HTTPS:       reflect.TypeOf(HTTP2HTTPSPluginOptions{}),
-	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{}),
-}
-
 type HTTP2HTTPSPluginOptions struct {
 	Type              string           `json:"type,omitempty"`
 	LocalAddr         string           `json:"localAddr,omitempty"`
@@ -185,3 +187,9 @@ type TLS2RawPluginOptions struct {
 }
 
 func (o *TLS2RawPluginOptions) Complete() {}
+
+type VirtualNetPluginOptions struct {
+	Type string `json:"type,omitempty"`
+}
+
+func (o *VirtualNetPluginOptions) Complete() {}

+ 8 - 0
pkg/config/v1/validation/client.go

@@ -23,6 +23,7 @@ import (
 	"github.com/samber/lo"
 
 	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/featuregate"
 )
 
 func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
@@ -30,6 +31,13 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
 		warnings Warning
 		errs     error
 	)
+	// validate feature gates
+	if c.VirtualNet.Address != "" {
+		if !featuregate.Enabled(featuregate.VirtualNet) {
+			return warnings, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
+		}
+	}
+
 	if !slices.Contains(SupportedAuthMethods, c.Auth.Method) {
 		errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods))
 	}

+ 3 - 0
pkg/config/v1/visitor.go

@@ -44,6 +44,9 @@ type VisitorBaseConfig struct {
 	// It can be less than 0, it means don't bind to the port and only receive connections redirected from
 	// other visitors. (This is not supported for SUDP now)
 	BindPort int `json:"bindPort,omitempty"`
+
+	// Plugin specifies what plugin should be used.
+	Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"`
 }
 
 func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {

+ 86 - 0
pkg/config/v1/visitor_plugin.go

@@ -0,0 +1,86 @@
+// 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 v1
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+)
+
+const (
+	VisitorPluginVirtualNet = "virtual_net"
+)
+
+var visitorPluginOptionsTypeMap = map[string]reflect.Type{
+	VisitorPluginVirtualNet: reflect.TypeOf(VirtualNetVisitorPluginOptions{}),
+}
+
+type VisitorPluginOptions interface {
+	Complete()
+}
+
+type TypedVisitorPluginOptions struct {
+	Type string `json:"type"`
+	VisitorPluginOptions
+}
+
+func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
+	if len(b) == 4 && string(b) == "null" {
+		return nil
+	}
+
+	typeStruct := struct {
+		Type string `json:"type"`
+	}{}
+	if err := json.Unmarshal(b, &typeStruct); err != nil {
+		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
+	return nil
+}
+
+func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
+	return json.Marshal(c.VisitorPluginOptions)
+}
+
+type VirtualNetVisitorPluginOptions struct {
+	Type          string `json:"type"`
+	DestinationIP string `json:"destinationIP"`
+}
+
+func (o *VirtualNetVisitorPluginOptions) Complete() {}

+ 219 - 0
pkg/featuregate/feature_gate.go

@@ -0,0 +1,219 @@
+// 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 featuregate
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"sync"
+	"sync/atomic"
+)
+
+// Feature represents a feature gate name
+type Feature string
+
+// FeatureStage represents the maturity level of a feature
+type FeatureStage string
+
+const (
+	// Alpha means the feature is experimental and disabled by default
+	Alpha FeatureStage = "ALPHA"
+	// Beta means the feature is more stable but still might change and is disabled by default
+	Beta FeatureStage = "BETA"
+	// GA means the feature is generally available and enabled by default
+	GA FeatureStage = ""
+)
+
+// FeatureSpec describes a feature and its properties
+type FeatureSpec struct {
+	// Default is the default enablement state for the feature
+	Default bool
+	// LockToDefault indicates the feature cannot be changed from its default
+	LockToDefault bool
+	// Stage indicates the maturity level of the feature
+	Stage FeatureStage
+}
+
+// Define all available features here
+var (
+	VirtualNet = Feature("VirtualNet")
+)
+
+// defaultFeatures defines default features with their specifications
+var defaultFeatures = map[Feature]FeatureSpec{
+	// Actual features
+	VirtualNet: {Default: false, Stage: Alpha},
+}
+
+// FeatureGate indicates whether a given feature is enabled or not
+type FeatureGate interface {
+	// Enabled returns true if the key is enabled
+	Enabled(key Feature) bool
+	// KnownFeatures returns a slice of strings describing the known features
+	KnownFeatures() []string
+}
+
+// MutableFeatureGate allows for dynamic feature gate configuration
+type MutableFeatureGate interface {
+	FeatureGate
+
+	// SetFromMap sets feature gate values from a map[string]bool
+	SetFromMap(m map[string]bool) error
+	// Add adds features to the feature gate
+	Add(features map[Feature]FeatureSpec) error
+	// String returns a string representing the feature gate configuration
+	String() string
+}
+
+// featureGate implements the FeatureGate and MutableFeatureGate interfaces
+type featureGate struct {
+	// lock guards writes to known, enabled, and reads/writes of closed
+	lock sync.Mutex
+	// known holds a map[Feature]FeatureSpec
+	known atomic.Value
+	// enabled holds a map[Feature]bool
+	enabled atomic.Value
+	// closed is set to true once the feature gates are considered immutable
+	closed bool
+}
+
+// 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
+	}
+
+	f := &featureGate{}
+	f.known.Store(known)
+	f.enabled.Store(map[Feature]bool{})
+	return f
+}
+
+// SetFromMap sets feature gate values from a map[string]bool
+func (f *featureGate) SetFromMap(m map[string]bool) error {
+	f.lock.Lock()
+	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
+	}
+
+	// Apply the new settings
+	for k, v := range m {
+		k := Feature(k)
+		featureSpec, ok := known[k]
+		if !ok {
+			return fmt.Errorf("unrecognized feature gate: %s", k)
+		}
+		if featureSpec.LockToDefault && featureSpec.Default != v {
+			return fmt.Errorf("cannot set feature gate %v to %v, feature is locked to %v", k, v, featureSpec.Default)
+		}
+		enabled[k] = v
+	}
+
+	// Persist the changes
+	f.known.Store(known)
+	f.enabled.Store(enabled)
+	return nil
+}
+
+// Add adds features to the feature gate
+func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
+	f.lock.Lock()
+	defer f.lock.Unlock()
+
+	if f.closed {
+		return fmt.Errorf("cannot add feature gates after the feature gate is closed")
+	}
+
+	// Copy existing state
+	known := map[Feature]FeatureSpec{}
+	for k, v := range f.known.Load().(map[Feature]FeatureSpec) {
+		known[k] = v
+	}
+
+	// Add new features
+	for name, spec := range features {
+		if existingSpec, found := known[name]; found {
+			if existingSpec == spec {
+				continue
+			}
+			return fmt.Errorf("feature gate %q with different spec already exists: %v", name, existingSpec)
+		}
+		known[name] = spec
+	}
+
+	// Persist changes
+	f.known.Store(known)
+
+	return nil
+}
+
+// String returns a string containing all enabled feature gates, formatted as "key1=value1,key2=value2,..."
+func (f *featureGate) String() string {
+	pairs := []string{}
+	for k, v := range f.enabled.Load().(map[Feature]bool) {
+		pairs = append(pairs, fmt.Sprintf("%s=%t", k, v))
+	}
+	sort.Strings(pairs)
+	return strings.Join(pairs, ",")
+}
+
+// Enabled returns true if the key is enabled
+func (f *featureGate) Enabled(key Feature) bool {
+	if v, ok := f.enabled.Load().(map[Feature]bool)[key]; ok {
+		return v
+	}
+	if v, ok := f.known.Load().(map[Feature]FeatureSpec)[key]; ok {
+		return v.Default
+	}
+	return false
+}
+
+// KnownFeatures returns a slice of strings describing the FeatureGate's known features
+// GA features are hidden from the list
+func (f *featureGate) KnownFeatures() []string {
+	knownFeatures := f.known.Load().(map[Feature]FeatureSpec)
+	known := make([]string, 0, len(knownFeatures))
+	for k, v := range knownFeatures {
+		if v.Stage == GA {
+			continue
+		}
+		known = append(known, fmt.Sprintf("%s=true|false (%s - default=%t)", k, v.Stage, v.Default))
+	}
+	sort.Strings(known)
+	return known
+}
+
+// Default feature gates instance
+var DefaultFeatureGates = NewFeatureGate()
+
+// Enabled checks if a feature is enabled in the default feature gates
+func Enabled(name Feature) bool {
+	return DefaultFeatureGates.Enabled(name)
+}
+
+// SetFromMap sets feature gate values from a map in the default feature gates
+func SetFromMap(featureMap map[string]bool) error {
+	return DefaultFeatureGates.SetFromMap(featureMap)
+}

+ 1 - 1
pkg/metrics/mem/server.go

@@ -109,7 +109,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) {
 	m.info.ProxyTypeCounts[proxyType] = counter
 
 	proxyStats, ok := m.info.ProxyStatistics[name]
-	if !(ok && proxyStats.ProxyType == proxyType) {
+	if !ok || proxyStats.ProxyType != proxyType {
 		proxyStats = &ProxyStatistics{
 			Name:       name,
 			ProxyType:  proxyType,

+ 4 - 6
pkg/plugin/client/http2http.go

@@ -14,13 +14,11 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
-	"io"
 	stdlog "log"
-	"net"
 	"net/http"
 	"net/http/httputil"
 
@@ -42,7 +40,7 @@ type HTTP2HTTPPlugin struct {
 	s *http.Server
 }
 
-func NewHTTP2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.HTTP2HTTPPluginOptions)
 
 	listener := NewProxyListener()
@@ -80,8 +78,8 @@ func NewHTTP2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return p, nil
 }
 
-func (p *HTTP2HTTPPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 	_ = p.l.PutConn(wrapConn)
 }
 

+ 4 - 6
pkg/plugin/client/http2https.go

@@ -14,14 +14,12 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
 	"crypto/tls"
-	"io"
 	stdlog "log"
-	"net"
 	"net/http"
 	"net/http/httputil"
 
@@ -43,7 +41,7 @@ type HTTP2HTTPSPlugin struct {
 	s *http.Server
 }
 
-func NewHTTP2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.HTTP2HTTPSPluginOptions)
 
 	listener := NewProxyListener()
@@ -89,8 +87,8 @@ func NewHTTP2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return p, nil
 }
 
-func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 	_ = p.l.PutConn(wrapConn)
 }
 

+ 4 - 4
pkg/plugin/client/http_proxy.go

@@ -14,7 +14,7 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"bufio"
@@ -45,7 +45,7 @@ type HTTPProxy struct {
 	s *http.Server
 }
 
-func NewHTTPProxyPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.HTTPProxyPluginOptions)
 	listener := NewProxyListener()
 
@@ -69,8 +69,8 @@ func (hp *HTTPProxy) Name() string {
 	return v1.PluginHTTPProxy
 }
 
-func (hp *HTTPProxy) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 
 	sc, rd := libnet.NewSharedConn(wrapConn)
 	firstBytes := make([]byte, 7)

+ 6 - 8
pkg/plugin/client/https2http.go

@@ -14,15 +14,13 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
 	"crypto/tls"
 	"fmt"
-	"io"
 	stdlog "log"
-	"net"
 	"net/http"
 	"net/http/httputil"
 	"time"
@@ -48,7 +46,7 @@ type HTTPS2HTTPPlugin struct {
 	s *http.Server
 }
 
-func NewHTTPS2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.HTTPS2HTTPPluginOptions)
 	listener := NewProxyListener()
 
@@ -106,10 +104,10 @@ func NewHTTPS2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return p, nil
 }
 
-func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
-	if extra.SrcAddr != nil {
-		wrapConn.SetRemoteAddr(extra.SrcAddr)
+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)
 }

+ 6 - 8
pkg/plugin/client/https2https.go

@@ -14,15 +14,13 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
 	"crypto/tls"
 	"fmt"
-	"io"
 	stdlog "log"
-	"net"
 	"net/http"
 	"net/http/httputil"
 	"time"
@@ -48,7 +46,7 @@ type HTTPS2HTTPSPlugin struct {
 	s *http.Server
 }
 
-func NewHTTPS2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.HTTPS2HTTPSPluginOptions)
 
 	listener := NewProxyListener()
@@ -112,10 +110,10 @@ func NewHTTPS2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return p, nil
 }
 
-func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
-	if extra.SrcAddr != nil {
-		wrapConn.SetRemoteAddr(extra.SrcAddr)
+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)
 }

+ 17 - 9
pkg/plugin/client/plugin.go

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package plugin
+package client
 
 import (
 	"context"
@@ -25,13 +25,18 @@ import (
 	pp "github.com/pires/go-proxyproto"
 
 	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/vnet"
 )
 
+type PluginContext struct {
+	Name           string
+	VnetController *vnet.Controller
+}
+
 // Creators is used for create plugins to handle connections.
 var creators = make(map[string]CreatorFn)
 
-// params has prefix "plugin_"
-type CreatorFn func(options v1.ClientPluginOptions) (Plugin, error)
+type CreatorFn func(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error)
 
 func Register(name string, fn CreatorFn) {
 	if _, exist := creators[name]; exist {
@@ -40,16 +45,19 @@ func Register(name string, fn CreatorFn) {
 	creators[name] = fn
 }
 
-func Create(name string, options v1.ClientPluginOptions) (p Plugin, err error) {
-	if fn, ok := creators[name]; ok {
-		p, err = fn(options)
+func Create(pluginName string, pluginCtx PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {
+	if fn, ok := creators[pluginName]; ok {
+		p, err = fn(pluginCtx, options)
 	} else {
-		err = fmt.Errorf("plugin [%s] is not registered", name)
+		err = fmt.Errorf("plugin [%s] is not registered", pluginName)
 	}
 	return
 }
 
-type ExtraInfo struct {
+type ConnectionInfo struct {
+	Conn           io.ReadWriteCloser
+	UnderlyingConn net.Conn
+
 	ProxyProtocolHeader *pp.Header
 	SrcAddr             net.Addr
 	DstAddr             net.Addr
@@ -58,7 +66,7 @@ type ExtraInfo struct {
 type Plugin interface {
 	Name() string
 
-	Handle(ctx context.Context, conn io.ReadWriteCloser, realConn net.Conn, extra *ExtraInfo)
+	Handle(ctx context.Context, connInfo *ConnectionInfo)
 	Close() error
 }
 

+ 5 - 6
pkg/plugin/client/socks5.go

@@ -14,13 +14,12 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
 	"io"
 	"log"
-	"net"
 
 	gosocks5 "github.com/armon/go-socks5"
 
@@ -36,7 +35,7 @@ type Socks5Plugin struct {
 	Server *gosocks5.Server
 }
 
-func NewSocks5Plugin(options v1.ClientPluginOptions) (p Plugin, err error) {
+func NewSocks5Plugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {
 	opts := options.(*v1.Socks5PluginOptions)
 
 	cfg := &gosocks5.Config{
@@ -51,9 +50,9 @@ func NewSocks5Plugin(options v1.ClientPluginOptions) (p Plugin, err error) {
 	return
 }
 
-func (sp *Socks5Plugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
-	defer conn.Close()
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+func (sp *Socks5Plugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
+	defer connInfo.Conn.Close()
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 	_ = sp.Server.ServeConn(wrapConn)
 }
 

+ 4 - 6
pkg/plugin/client/static_file.go

@@ -14,12 +14,10 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
-	"io"
-	"net"
 	"net/http"
 	"time"
 
@@ -40,7 +38,7 @@ type StaticFilePlugin struct {
 	s *http.Server
 }
 
-func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewStaticFilePlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.StaticFilePluginOptions)
 
 	listener := NewProxyListener()
@@ -70,8 +68,8 @@ func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return sp, nil
 }
 
-func (sp *StaticFilePlugin) Handle(_ context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+func (sp *StaticFilePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 	_ = sp.l.PutConn(wrapConn)
 }
 

+ 4 - 5
pkg/plugin/client/tls2raw.go

@@ -14,12 +14,11 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
 	"crypto/tls"
-	"io"
 	"net"
 
 	libio "github.com/fatedier/golib/io"
@@ -40,7 +39,7 @@ type TLS2RawPlugin struct {
 	tlsConfig *tls.Config
 }
 
-func NewTLS2RawPlugin(options v1.ClientPluginOptions) (Plugin, error) {
+func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
 	opts := options.(*v1.TLS2RawPluginOptions)
 
 	p := &TLS2RawPlugin{
@@ -55,10 +54,10 @@ func NewTLS2RawPlugin(options v1.ClientPluginOptions) (Plugin, error) {
 	return p, nil
 }
 
-func (p *TLS2RawPlugin) Handle(ctx context.Context, conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) {
+func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
 	xl := xlog.FromContextSafe(ctx)
 
-	wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn)
+	wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
 	tlsConn := tls.Server(wrapConn, p.tlsConfig)
 
 	if err := tlsConn.Handshake(); err != nil {

+ 6 - 7
pkg/plugin/client/unix_domain_socket.go

@@ -14,11 +14,10 @@
 
 //go:build !frps
 
-package plugin
+package client
 
 import (
 	"context"
-	"io"
 	"net"
 
 	libio "github.com/fatedier/golib/io"
@@ -35,7 +34,7 @@ type UnixDomainSocketPlugin struct {
 	UnixAddr *net.UnixAddr
 }
 
-func NewUnixDomainSocketPlugin(options v1.ClientPluginOptions) (p Plugin, err error) {
+func NewUnixDomainSocketPlugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {
 	opts := options.(*v1.UnixDomainSocketPluginOptions)
 
 	unixAddr, errRet := net.ResolveUnixAddr("unix", opts.UnixPath)
@@ -50,20 +49,20 @@ func NewUnixDomainSocketPlugin(options v1.ClientPluginOptions) (p Plugin, err er
 	return
 }
 
-func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, conn io.ReadWriteCloser, _ net.Conn, extra *ExtraInfo) {
+func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
 	xl := xlog.FromContextSafe(ctx)
 	localConn, err := net.DialUnix("unix", nil, uds.UnixAddr)
 	if err != nil {
 		xl.Warnf("dial to uds %s error: %v", uds.UnixAddr, err)
 		return
 	}
-	if extra.ProxyProtocolHeader != nil {
-		if _, err := extra.ProxyProtocolHeader.WriteTo(localConn); err != nil {
+	if connInfo.ProxyProtocolHeader != nil {
+		if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
 			return
 		}
 	}
 
-	libio.Join(localConn, conn)
+	libio.Join(localConn, connInfo.Conn)
 }
 
 func (uds *UnixDomainSocketPlugin) Name() string {

+ 92 - 0
pkg/plugin/client/virtual_net.go

@@ -0,0 +1,92 @@
+// 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.
+
+//go:build !frps
+
+package client
+
+import (
+	"context"
+	"io"
+	"sync"
+
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func init() {
+	Register(v1.PluginVirtualNet, NewVirtualNetPlugin)
+}
+
+type VirtualNetPlugin struct {
+	pluginCtx PluginContext
+	opts      *v1.VirtualNetPluginOptions
+	mu        sync.Mutex
+	conns     map[io.ReadWriteCloser]struct{}
+}
+
+func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
+	opts := options.(*v1.VirtualNetPluginOptions)
+
+	p := &VirtualNetPlugin{
+		pluginCtx: pluginCtx,
+		opts:      opts,
+	}
+	return p, nil
+}
+
+func (p *VirtualNetPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
+	// Verify if virtual network controller is available
+	if p.pluginCtx.VnetController == nil {
+		return
+	}
+
+	// Add the connection before starting the read loop to avoid race condition
+	// where RemoveConn might be called before the connection is added.
+	p.mu.Lock()
+	if p.conns == nil {
+		p.conns = make(map[io.ReadWriteCloser]struct{})
+	}
+	p.conns[connInfo.Conn] = struct{}{}
+	p.mu.Unlock()
+
+	// Register the connection with the controller and pass the cleanup function
+	p.pluginCtx.VnetController.StartServerConnReadLoop(ctx, connInfo.Conn, func() {
+		p.RemoveConn(connInfo.Conn)
+	})
+}
+
+func (p *VirtualNetPlugin) RemoveConn(conn io.ReadWriteCloser) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	// Check if the map exists, as Close might have set it to nil concurrently
+	if p.conns != nil {
+		delete(p.conns, conn)
+	}
+}
+
+func (p *VirtualNetPlugin) Name() string {
+	return v1.PluginVirtualNet
+}
+
+func (p *VirtualNetPlugin) Close() error {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	// Close any remaining connections
+	for conn := range p.conns {
+		_ = conn.Close()
+	}
+	p.conns = nil
+	return nil
+}

+ 1 - 1
pkg/plugin/server/http.go

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package plugin
+package server
 
 import (
 	"bytes"

+ 1 - 1
pkg/plugin/server/manager.go

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package plugin
+package server
 
 import (
 	"context"

+ 1 - 1
pkg/plugin/server/plugin.go

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package plugin
+package server
 
 import (
 	"context"

+ 1 - 1
pkg/plugin/server/tracer.go

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package plugin
+package server
 
 import (
 	"context"

+ 1 - 1
pkg/plugin/server/types.go

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

+ 58 - 0
pkg/plugin/visitor/plugin.go

@@ -0,0 +1,58 @@
+// 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 visitor
+
+import (
+	"context"
+	"fmt"
+	"net"
+
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/vnet"
+)
+
+type PluginContext struct {
+	Name           string
+	Ctx            context.Context
+	VnetController *vnet.Controller
+	HandleConn     func(net.Conn)
+}
+
+// Creators is used for create plugins to handle connections.
+var creators = make(map[string]CreatorFn)
+
+type CreatorFn func(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error)
+
+func Register(name string, fn CreatorFn) {
+	if _, exist := creators[name]; exist {
+		panic(fmt.Sprintf("plugin [%s] is already registered", name))
+	}
+	creators[name] = fn
+}
+
+func Create(pluginName string, pluginCtx PluginContext, options v1.VisitorPluginOptions) (p Plugin, err error) {
+	if fn, ok := creators[pluginName]; ok {
+		p, err = fn(pluginCtx, options)
+	} else {
+		err = fmt.Errorf("plugin [%s] is not registered", pluginName)
+	}
+	return
+}
+
+type Plugin interface {
+	Name() string
+	Start()
+	Close() error
+}

+ 192 - 0
pkg/plugin/visitor/virtual_net.go

@@ -0,0 +1,192 @@
+// 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.
+
+//go:build !frps
+
+package visitor
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"sync"
+	"time"
+
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+	netutil "github.com/fatedier/frp/pkg/util/net"
+	"github.com/fatedier/frp/pkg/util/xlog"
+)
+
+func init() {
+	Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
+}
+
+type VirtualNetPlugin struct {
+	pluginCtx PluginContext
+
+	routes []net.IPNet
+
+	mu             sync.Mutex
+	controllerConn net.Conn
+	closeSignal    chan struct{}
+
+	ctx    context.Context
+	cancel context.CancelFunc
+}
+
+func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
+	opts := options.(*v1.VirtualNetVisitorPluginOptions)
+
+	p := &VirtualNetPlugin{
+		pluginCtx: pluginCtx,
+		routes:    make([]net.IPNet, 0),
+	}
+
+	p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
+
+	if opts.DestinationIP == "" {
+		return nil, errors.New("destinationIP is required")
+	}
+
+	// Parse DestinationIP and create a host route.
+	ip := net.ParseIP(opts.DestinationIP)
+	if ip == nil {
+		return nil, fmt.Errorf("invalid destination IP address [%s]", opts.DestinationIP)
+	}
+
+	var mask net.IPMask
+	if ip.To4() != nil {
+		mask = net.CIDRMask(32, 32) // /32 for IPv4
+	} else {
+		mask = net.CIDRMask(128, 128) // /128 for IPv6
+	}
+	p.routes = append(p.routes, net.IPNet{IP: ip, Mask: mask})
+
+	return p, nil
+}
+
+func (p *VirtualNetPlugin) Name() string {
+	return v1.VisitorPluginVirtualNet
+}
+
+func (p *VirtualNetPlugin) Start() {
+	xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
+	if p.pluginCtx.VnetController == nil {
+		return
+	}
+
+	routeStr := "unknown"
+	if len(p.routes) > 0 {
+		routeStr = p.routes[0].String()
+	}
+	xl.Infof("starting VirtualNetPlugin for visitor [%s], attempting to register routes for %s", p.pluginCtx.Name, routeStr)
+
+	go p.run()
+}
+
+func (p *VirtualNetPlugin) run() {
+	xl := xlog.FromContextSafe(p.ctx)
+	reconnectDelay := 10 * time.Second
+
+	for {
+		currentCloseSignal := make(chan struct{})
+
+		p.mu.Lock()
+		p.closeSignal = currentCloseSignal
+		p.mu.Unlock()
+
+		select {
+		case <-p.ctx.Done():
+			xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
+			p.cleanupControllerConn(xl)
+			return
+		default:
+		}
+
+		controllerConn, pluginConn := net.Pipe()
+
+		p.mu.Lock()
+		p.controllerConn = controllerConn
+		p.mu.Unlock()
+
+		pluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func() {
+			close(currentCloseSignal) // Signal the run loop on close.
+		})
+
+		xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
+		p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
+		xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
+
+		// Pass the CloseNotifyConn to HandleConn.
+		// HandleConn is responsible for calling Close() on pluginNotifyConn.
+		p.pluginCtx.HandleConn(pluginNotifyConn)
+
+		// Wait for context cancellation or connection close.
+		select {
+		case <-p.ctx.Done():
+			xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
+			p.cleanupControllerConn(xl)
+			return
+		case <-currentCloseSignal:
+			xl.Infof("detected connection closed via CloseNotifyConn for visitor [%s].", p.pluginCtx.Name)
+			// HandleConn closed the plugin side. Close the controller side.
+			p.cleanupControllerConn(xl)
+
+			xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
+			select {
+			case <-time.After(reconnectDelay):
+			case <-p.ctx.Done():
+				xl.Infof("VirtualNetPlugin reconnection delay interrupted for visitor [%s]", p.pluginCtx.Name)
+				return
+			}
+		}
+
+		xl.Infof("re-establishing virtual connection for visitor [%s]...", p.pluginCtx.Name)
+	}
+}
+
+// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
+func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	if p.controllerConn != nil {
+		xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
+		p.controllerConn.Close()
+		p.controllerConn = nil
+	}
+	p.closeSignal = nil
+}
+
+// Close initiates the plugin shutdown.
+func (p *VirtualNetPlugin) Close() error {
+	xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
+	xl.Infof("closing VirtualNetPlugin for visitor [%s]", p.pluginCtx.Name)
+
+	// Signal the run loop goroutine to stop.
+	p.cancel()
+
+	// Unregister the route from the controller.
+	if p.pluginCtx.VnetController != nil {
+		p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
+		xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
+	}
+
+	// Explicitly close the controller side of the pipe.
+	// This ensures the pipe is broken even if the run loop is stuck or HandleConn hasn't closed its end.
+	p.cleanupControllerConn(xl)
+	xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
+
+	return nil
+}

+ 15 - 1
pkg/proto/udp/udp.go

@@ -24,6 +24,7 @@ import (
 	"github.com/fatedier/golib/pool"
 
 	"github.com/fatedier/frp/pkg/msg"
+	netpkg "github.com/fatedier/frp/pkg/util/net"
 )
 
 func NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket {
@@ -69,7 +70,7 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
 	}
 }
 
-func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<- msg.Message, bufSize int) {
+func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<- msg.Message, bufSize int, proxyProtocolVersion string) {
 	var mu sync.RWMutex
 	udpConnMap := make(map[string]*net.UDPConn)
 
@@ -110,6 +111,7 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
 			if err != nil {
 				continue
 			}
+
 			mu.Lock()
 			udpConn, ok := udpConnMap[udpMsg.RemoteAddr.String()]
 			if !ok {
@@ -122,6 +124,18 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
 			}
 			mu.Unlock()
 
+			// Add proxy protocol header if configured
+			if proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
+				ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)
+				if err == nil {
+					// Prepend proxy protocol header to the UDP payload
+					finalBuf := make([]byte, len(ppBuf)+len(buf))
+					copy(finalBuf, ppBuf)
+					copy(finalBuf[len(ppBuf):], buf)
+					buf = finalBuf
+				}
+			}
+
 			_, err = udpConn.Write(buf)
 			if err != nil {
 				udpConn.Close()

+ 4 - 4
pkg/proto/udp/udp_test.go

@@ -3,16 +3,16 @@ package udp
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestUdpPacket(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 
 	buf := []byte("hello world")
 	udpMsg := NewUDPPacket(buf, nil, nil)
 
 	newBuf, err := GetContent(udpMsg)
-	assert.NoError(err)
-	assert.EqualValues(buf, newBuf)
+	require.NoError(err)
+	require.EqualValues(buf, newBuf)
 }

+ 6 - 6
pkg/util/metric/counter_test.go

@@ -3,21 +3,21 @@ package metric
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestCounter(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 	c := NewCounter()
 	c.Inc(10)
-	assert.EqualValues(10, c.Count())
+	require.EqualValues(10, c.Count())
 
 	c.Dec(5)
-	assert.EqualValues(5, c.Count())
+	require.EqualValues(5, c.Count())
 
 	cTmp := c.Snapshot()
-	assert.EqualValues(5, cTmp.Count())
+	require.EqualValues(5, cTmp.Count())
 
 	c.Clear()
-	assert.EqualValues(0, c.Count())
+	require.EqualValues(0, c.Count())
 }

+ 9 - 9
pkg/util/metric/date_counter_test.go

@@ -3,25 +3,25 @@ package metric
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestDateCounter(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 
 	dc := NewDateCounter(3)
 	dc.Inc(10)
-	assert.EqualValues(10, dc.TodayCount())
+	require.EqualValues(10, dc.TodayCount())
 
 	dc.Dec(5)
-	assert.EqualValues(5, dc.TodayCount())
+	require.EqualValues(5, dc.TodayCount())
 
 	counts := dc.GetLastDaysCount(3)
-	assert.EqualValues(3, len(counts))
-	assert.EqualValues(5, counts[0])
-	assert.EqualValues(0, counts[1])
-	assert.EqualValues(0, counts[2])
+	require.EqualValues(3, len(counts))
+	require.EqualValues(5, counts[0])
+	require.EqualValues(0, counts[1])
+	require.EqualValues(0, counts[2])
 
 	dcTmp := dc.Snapshot()
-	assert.EqualValues(5, dcTmp.TodayCount())
+	require.EqualValues(5, dcTmp.TodayCount())
 }

+ 1 - 1
pkg/util/net/conn.go

@@ -223,7 +223,7 @@ func (conn *wrapQuicStream) RemoteAddr() net.Addr {
 }
 
 func (conn *wrapQuicStream) Close() error {
-	conn.Stream.CancelRead(0)
+	conn.CancelRead(0)
 	return conn.Stream.Close()
 }
 

+ 45 - 0
pkg/util/net/proxyprotocol.go

@@ -0,0 +1,45 @@
+// 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 net
+
+import (
+	"bytes"
+	"fmt"
+	"net"
+
+	pp "github.com/pires/go-proxyproto"
+)
+
+func BuildProxyProtocolHeaderStruct(srcAddr, dstAddr net.Addr, version string) *pp.Header {
+	var versionByte byte
+	if version == "v1" {
+		versionByte = 1
+	} else {
+		versionByte = 2 // default to v2
+	}
+	return pp.HeaderProxyFromAddrs(versionByte, srcAddr, dstAddr)
+}
+
+func BuildProxyProtocolHeader(srcAddr, dstAddr net.Addr, version string) ([]byte, error) {
+	h := BuildProxyProtocolHeaderStruct(srcAddr, dstAddr, version)
+
+	// Convert header to bytes using a buffer
+	var buf bytes.Buffer
+	_, err := h.WriteTo(&buf)
+	if err != nil {
+		return nil, fmt.Errorf("failed to write proxy protocol header: %v", err)
+	}
+	return buf.Bytes(), nil
+}

+ 178 - 0
pkg/util/net/proxyprotocol_test.go

@@ -0,0 +1,178 @@
+package net
+
+import (
+	"net"
+	"testing"
+
+	pp "github.com/pires/go-proxyproto"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBuildProxyProtocolHeader(t *testing.T) {
+	require := require.New(t)
+
+	tests := []struct {
+		name        string
+		srcAddr     net.Addr
+		dstAddr     net.Addr
+		version     string
+		expectError bool
+	}{
+		{
+			name:        "UDP IPv4 v2",
+			srcAddr:     &net.UDPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			dstAddr:     &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306},
+			version:     "v2",
+			expectError: false,
+		},
+		{
+			name:        "TCP IPv4 v1",
+			srcAddr:     &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			dstAddr:     &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80},
+			version:     "v1",
+			expectError: false,
+		},
+		{
+			name:        "UDP IPv6 v2",
+			srcAddr:     &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
+			dstAddr:     &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306},
+			version:     "v2",
+			expectError: false,
+		},
+		{
+			name:        "TCP IPv6 v1",
+			srcAddr:     &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345},
+			dstAddr:     &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80},
+			version:     "v1",
+			expectError: false,
+		},
+		{
+			name:        "nil source address",
+			srcAddr:     nil,
+			dstAddr:     &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306},
+			version:     "v2",
+			expectError: false,
+		},
+		{
+			name:        "nil destination address",
+			srcAddr:     &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			dstAddr:     nil,
+			version:     "v2",
+			expectError: false,
+		},
+		{
+			name:        "unsupported address type",
+			srcAddr:     &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"},
+			dstAddr:     &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306},
+			version:     "v2",
+			expectError: false,
+		},
+	}
+
+	for _, tt := range tests {
+		header, err := BuildProxyProtocolHeader(tt.srcAddr, tt.dstAddr, tt.version)
+
+		if tt.expectError {
+			require.Error(err, "test case: %s", tt.name)
+			continue
+		}
+
+		require.NoError(err, "test case: %s", tt.name)
+		require.NotEmpty(header, "test case: %s", tt.name)
+	}
+}
+
+func TestBuildProxyProtocolHeaderStruct(t *testing.T) {
+	require := require.New(t)
+
+	tests := []struct {
+		name               string
+		srcAddr            net.Addr
+		dstAddr            net.Addr
+		version            string
+		expectedProtocol   pp.AddressFamilyAndProtocol
+		expectedVersion    byte
+		expectedCommand    pp.ProtocolVersionAndCommand
+		expectedSourceAddr net.Addr
+		expectedDestAddr   net.Addr
+	}{
+		{
+			name:               "TCP IPv4 v2",
+			srcAddr:            &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			dstAddr:            &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80},
+			version:            "v2",
+			expectedProtocol:   pp.TCPv4,
+			expectedVersion:    2,
+			expectedCommand:    pp.PROXY,
+			expectedSourceAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			expectedDestAddr:   &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80},
+		},
+		{
+			name:               "UDP IPv6 v1",
+			srcAddr:            &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
+			dstAddr:            &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306},
+			version:            "v1",
+			expectedProtocol:   pp.UDPv6,
+			expectedVersion:    1,
+			expectedCommand:    pp.PROXY,
+			expectedSourceAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
+			expectedDestAddr:   &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306},
+		},
+		{
+			name:               "TCP IPv6 default version",
+			srcAddr:            &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345},
+			dstAddr:            &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80},
+			version:            "",
+			expectedProtocol:   pp.TCPv6,
+			expectedVersion:    2, // default to v2
+			expectedCommand:    pp.PROXY,
+			expectedSourceAddr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345},
+			expectedDestAddr:   &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80},
+		},
+		{
+			name:               "nil source address",
+			srcAddr:            nil,
+			dstAddr:            &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306},
+			version:            "v2",
+			expectedProtocol:   pp.UNSPEC,
+			expectedVersion:    2,
+			expectedCommand:    pp.LOCAL,
+			expectedSourceAddr: nil, // go-proxyproto sets both to nil when srcAddr is nil
+			expectedDestAddr:   nil,
+		},
+		{
+			name:               "nil destination address",
+			srcAddr:            &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345},
+			dstAddr:            nil,
+			version:            "v2",
+			expectedProtocol:   pp.UNSPEC,
+			expectedVersion:    2,
+			expectedCommand:    pp.LOCAL,
+			expectedSourceAddr: nil, // go-proxyproto sets both to nil when dstAddr is nil
+			expectedDestAddr:   nil,
+		},
+		{
+			name:               "unsupported address type",
+			srcAddr:            &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"},
+			dstAddr:            &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306},
+			version:            "v2",
+			expectedProtocol:   pp.UNSPEC,
+			expectedVersion:    2,
+			expectedCommand:    pp.LOCAL,
+			expectedSourceAddr: nil, // go-proxyproto sets both to nil for unsupported types
+			expectedDestAddr:   nil,
+		},
+	}
+
+	for _, tt := range tests {
+		header := BuildProxyProtocolHeaderStruct(tt.srcAddr, tt.dstAddr, tt.version)
+
+		require.NotNil(header, "test case: %s", tt.name)
+
+		require.Equal(tt.expectedCommand, header.Command, "test case: %s", tt.name)
+		require.Equal(tt.expectedSourceAddr, header.SourceAddr, "test case: %s", tt.name)
+		require.Equal(tt.expectedDestAddr, header.DestinationAddr, "test case: %s", tt.name)
+		require.Equal(tt.expectedProtocol, header.TransportProtocol, "test case: %s", tt.name)
+		require.Equal(tt.expectedVersion, header.Version, "test case: %s", tt.name)
+	}
+}

+ 16 - 20
pkg/util/util/util_test.go

@@ -3,45 +3,41 @@ package util
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestRandId(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 	id, err := RandID()
-	assert.NoError(err)
+	require.NoError(err)
 	t.Log(id)
-	assert.Equal(16, len(id))
+	require.Equal(16, len(id))
 }
 
 func TestGetAuthKey(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 	key := GetAuthKey("1234", 1488720000)
-	assert.Equal("6df41a43725f0c770fd56379e12acf8c", key)
+	require.Equal("6df41a43725f0c770fd56379e12acf8c", key)
 }
 
 func TestParseRangeNumbers(t *testing.T) {
-	assert := assert.New(t)
+	require := require.New(t)
 	numbers, err := ParseRangeNumbers("2-5")
-	if assert.NoError(err) {
-		assert.Equal([]int64{2, 3, 4, 5}, numbers)
-	}
+	require.NoError(err)
+	require.Equal([]int64{2, 3, 4, 5}, numbers)
 
 	numbers, err = ParseRangeNumbers("1")
-	if assert.NoError(err) {
-		assert.Equal([]int64{1}, numbers)
-	}
+	require.NoError(err)
+	require.Equal([]int64{1}, numbers)
 
 	numbers, err = ParseRangeNumbers("3-5,8")
-	if assert.NoError(err) {
-		assert.Equal([]int64{3, 4, 5, 8}, numbers)
-	}
+	require.NoError(err)
+	require.Equal([]int64{3, 4, 5, 8}, numbers)
 
 	numbers, err = ParseRangeNumbers(" 3-5,8, 10-12 ")
-	if assert.NoError(err) {
-		assert.Equal([]int64{3, 4, 5, 8, 10, 11, 12}, numbers)
-	}
+	require.NoError(err)
+	require.Equal([]int64{3, 4, 5, 8, 10, 11, 12}, numbers)
 
 	_, err = ParseRangeNumbers("3-a")
-	assert.Error(err)
+	require.Error(err)
 }

+ 1 - 1
pkg/util/version/version.go

@@ -14,7 +14,7 @@
 
 package version
 
-var version = "0.61.2"
+var version = "0.63.0"
 
 func Full() string {
 	return version

+ 2 - 6
pkg/util/vhost/http.go

@@ -162,7 +162,7 @@ func (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) {
 func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig {
 	vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
 	if ok {
-		log.Debugf("get new HTTP request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
+		log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
 		return vr.payload.(*RouteConfig)
 	}
 	return nil
@@ -225,11 +225,7 @@ func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (
 	// *.example.com
 	// *.com
 	domainSplit := strings.Split(domain, ".")
-	for {
-		if len(domainSplit) < 3 {
-			break
-		}
-
+	for len(domainSplit) >= 3 {
 		domainSplit[0] = "*"
 		domain = strings.Join(domainSplit, ".")
 		vr, ok = findRouter(domain, location, routeByHTTPUser)

+ 2 - 6
pkg/util/vhost/vhost.go

@@ -169,11 +169,7 @@ func (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) {
 	}
 
 	domainSplit := strings.Split(name, ".")
-	for {
-		if len(domainSplit) < 3 {
-			break
-		}
-
+	for len(domainSplit) >= 3 {
 		domainSplit[0] = "*"
 		name = strings.Join(domainSplit, ".")
 
@@ -275,7 +271,7 @@ func (l *Listener) Accept() (net.Conn, error) {
 	xl := xlog.FromContextSafe(l.ctx)
 	conn, ok := <-l.accept
 	if !ok {
-		return nil, fmt.Errorf("Listener closed")
+		return nil, fmt.Errorf("listener closed")
 	}
 
 	// if rewriteHost func is exist

+ 386 - 0
pkg/vnet/controller.go

@@ -0,0 +1,386 @@
+// 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 vnet
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"net"
+	"sync"
+
+	"github.com/fatedier/golib/pool"
+	"github.com/songgao/water/waterutil"
+	"golang.org/x/net/ipv4"
+	"golang.org/x/net/ipv6"
+
+	v1 "github.com/fatedier/frp/pkg/config/v1"
+	"github.com/fatedier/frp/pkg/util/log"
+	"github.com/fatedier/frp/pkg/util/xlog"
+)
+
+const (
+	maxPacketSize = 1420
+)
+
+type Controller struct {
+	addr string
+
+	tun          io.ReadWriteCloser
+	clientRouter *clientRouter // Route based on destination IP (client mode)
+	serverRouter *serverRouter // Route based on source IP (server mode)
+}
+
+func NewController(cfg v1.VirtualNetConfig) *Controller {
+	return &Controller{
+		addr:         cfg.Address,
+		clientRouter: newClientRouter(),
+		serverRouter: newServerRouter(),
+	}
+}
+
+func (c *Controller) Init() error {
+	tunDevice, err := OpenTun(context.Background(), c.addr)
+	if err != nil {
+		return err
+	}
+	c.tun = tunDevice
+	return nil
+}
+
+func (c *Controller) Run() error {
+	conn := c.tun
+
+	for {
+		buf := pool.GetBuf(maxPacketSize)
+		n, err := conn.Read(buf)
+		if err != nil {
+			pool.PutBuf(buf)
+			log.Warnf("vnet read from tun error: %v", err)
+			return err
+		}
+
+		c.handlePacket(buf[:n])
+		pool.PutBuf(buf)
+	}
+}
+
+// handlePacket processes a single packet. The caller is responsible for managing the buffer.
+func (c *Controller) handlePacket(buf []byte) {
+	log.Tracef("vnet read from tun [%d]: %s", len(buf), base64.StdEncoding.EncodeToString(buf))
+
+	var src, dst net.IP
+	switch {
+	case waterutil.IsIPv4(buf):
+		header, err := ipv4.ParseHeader(buf)
+		if err != nil {
+			log.Warnf("parse ipv4 header error: %v", err)
+			return
+		}
+		src = header.Src
+		dst = header.Dst
+		log.Tracef("%s >> %s %d/%-4d %-4x %d",
+			header.Src, header.Dst,
+			header.Len, header.TotalLen, header.ID, header.Flags)
+	case waterutil.IsIPv6(buf):
+		header, err := ipv6.ParseHeader(buf)
+		if err != nil {
+			log.Warnf("parse ipv6 header error: %v", err)
+			return
+		}
+		src = header.Src
+		dst = header.Dst
+		log.Tracef("%s >> %s %d %d",
+			header.Src, header.Dst,
+			header.PayloadLen, header.TrafficClass)
+	default:
+		log.Tracef("unknown packet, discarded(%d)", len(buf))
+		return
+	}
+
+	targetConn, err := c.clientRouter.findConn(dst)
+	if err == nil {
+		if err := WriteMessage(targetConn, buf); err != nil {
+			log.Warnf("write to client target conn error: %v", err)
+		}
+		return
+	}
+
+	targetConn, err = c.serverRouter.findConnBySrc(dst)
+	if err == nil {
+		if err := WriteMessage(targetConn, buf); err != nil {
+			log.Warnf("write to server target conn error: %v", err)
+		}
+		return
+	}
+
+	log.Tracef("no route found for packet from %s to %s", src, dst)
+}
+
+func (c *Controller) Stop() error {
+	return c.tun.Close()
+}
+
+// Client connection read loop
+func (c *Controller) readLoopClient(ctx context.Context, conn io.ReadWriteCloser) {
+	xl := xlog.FromContextSafe(ctx)
+	defer func() {
+		// Remove the route when read loop ends (connection closed)
+		c.clientRouter.removeConnRoute(conn)
+		conn.Close()
+	}()
+
+	for {
+		data, err := ReadMessage(conn)
+		if err != nil {
+			xl.Warnf("client read error: %v", err)
+			return
+		}
+
+		if len(data) == 0 {
+			continue
+		}
+
+		switch {
+		case waterutil.IsIPv4(data):
+			header, err := ipv4.ParseHeader(data)
+			if err != nil {
+				xl.Warnf("parse ipv4 header error: %v", err)
+				continue
+			}
+			xl.Tracef("%s >> %s %d/%-4d %-4x %d",
+				header.Src, header.Dst,
+				header.Len, header.TotalLen, header.ID, header.Flags)
+		case waterutil.IsIPv6(data):
+			header, err := ipv6.ParseHeader(data)
+			if err != nil {
+				xl.Warnf("parse ipv6 header error: %v", err)
+				continue
+			}
+			xl.Tracef("%s >> %s %d %d",
+				header.Src, header.Dst,
+				header.PayloadLen, header.TrafficClass)
+		default:
+			xl.Tracef("unknown packet, discarded(%d)", len(data))
+			continue
+		}
+
+		xl.Tracef("vnet write to tun (client) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data))
+		_, err = c.tun.Write(data)
+		if err != nil {
+			xl.Warnf("client write tun error: %v", err)
+		}
+	}
+}
+
+// Server connection read loop
+func (c *Controller) readLoopServer(ctx context.Context, conn io.ReadWriteCloser, onClose func()) {
+	xl := xlog.FromContextSafe(ctx)
+	defer func() {
+		// Clean up all IP mappings associated with this connection when it closes
+		c.serverRouter.cleanupConnIPs(conn)
+		// Call the provided callback upon closure
+		if onClose != nil {
+			onClose()
+		}
+		conn.Close()
+	}()
+
+	for {
+		data, err := ReadMessage(conn)
+		if err != nil {
+			xl.Warnf("server read error: %v", err)
+			return
+		}
+
+		if len(data) == 0 {
+			continue
+		}
+
+		// Register source IP to connection mapping
+		if waterutil.IsIPv4(data) || waterutil.IsIPv6(data) {
+			var src net.IP
+			if waterutil.IsIPv4(data) {
+				header, err := ipv4.ParseHeader(data)
+				if err == nil {
+					src = header.Src
+					c.serverRouter.registerSrcIP(src, conn)
+				}
+			} else {
+				header, err := ipv6.ParseHeader(data)
+				if err == nil {
+					src = header.Src
+					c.serverRouter.registerSrcIP(src, conn)
+				}
+			}
+		}
+
+		xl.Tracef("vnet write to tun (server) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data))
+		_, err = c.tun.Write(data)
+		if err != nil {
+			xl.Warnf("server write tun error: %v", err)
+		}
+	}
+}
+
+// RegisterClientRoute registers a client route (based on destination IP CIDR)
+// and starts the read loop
+func (c *Controller) RegisterClientRoute(ctx context.Context, name string, routes []net.IPNet, conn io.ReadWriteCloser) {
+	c.clientRouter.addRoute(name, routes, conn)
+	go c.readLoopClient(ctx, conn)
+}
+
+// UnregisterClientRoute Remove client route from routing table
+func (c *Controller) UnregisterClientRoute(name string) {
+	c.clientRouter.delRoute(name)
+}
+
+// StartServerConnReadLoop starts the read loop for a server connection
+// (dynamically associates with source IPs)
+func (c *Controller) StartServerConnReadLoop(ctx context.Context, conn io.ReadWriteCloser, onClose func()) {
+	go c.readLoopServer(ctx, conn, onClose)
+}
+
+// ParseRoutes Convert route strings to IPNet objects
+func ParseRoutes(routeStrings []string) ([]net.IPNet, error) {
+	routes := make([]net.IPNet, 0, len(routeStrings))
+	for _, r := range routeStrings {
+		_, ipNet, err := net.ParseCIDR(r)
+		if err != nil {
+			return nil, fmt.Errorf("parse route %s error: %v", r, err)
+		}
+		routes = append(routes, *ipNet)
+	}
+	return routes, nil
+}
+
+// Client router (based on destination IP routing)
+type clientRouter struct {
+	routes map[string]*routeElement
+	mu     sync.RWMutex
+}
+
+func newClientRouter() *clientRouter {
+	return &clientRouter{
+		routes: make(map[string]*routeElement),
+	}
+}
+
+func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWriteCloser) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.routes[name] = &routeElement{
+		name:   name,
+		routes: routes,
+		conn:   conn,
+	}
+}
+
+func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	for _, re := range r.routes {
+		for _, route := range re.routes {
+			if route.Contains(dst) {
+				return re.conn, nil
+			}
+		}
+	}
+	return nil, fmt.Errorf("no route found for destination %s", dst)
+}
+
+func (r *clientRouter) delRoute(name string) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	delete(r.routes, name)
+}
+
+func (r *clientRouter) removeConnRoute(conn io.Writer) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	for name, re := range r.routes {
+		if re.conn == conn {
+			delete(r.routes, name)
+			return
+		}
+	}
+}
+
+// Server router (based solely on source IP routing)
+type serverRouter struct {
+	srcIPConns map[string]io.Writer // Source IP string to connection mapping
+	mu         sync.RWMutex
+}
+
+func newServerRouter() *serverRouter {
+	return &serverRouter{
+		srcIPConns: make(map[string]io.Writer),
+	}
+}
+
+func (r *serverRouter) findConnBySrc(src net.IP) (io.Writer, error) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	conn, exists := r.srcIPConns[src.String()]
+	if !exists {
+		return nil, fmt.Errorf("no route found for source %s", src)
+	}
+	return conn, nil
+}
+
+func (r *serverRouter) registerSrcIP(src net.IP, conn io.Writer) {
+	key := src.String()
+
+	r.mu.RLock()
+	existingConn, ok := r.srcIPConns[key]
+	r.mu.RUnlock()
+
+	// If the entry exists and the connection is the same, no need to do anything.
+	if ok && existingConn == conn {
+		return
+	}
+
+	// Acquire write lock to update the map.
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	// Double-check after acquiring the write lock to handle potential race conditions.
+	existingConn, ok = r.srcIPConns[key]
+	if ok && existingConn == conn {
+		return
+	}
+
+	r.srcIPConns[key] = conn
+}
+
+// cleanupConnIPs removes all IP mappings associated with the specified connection
+func (r *serverRouter) cleanupConnIPs(conn io.Writer) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	// Find and delete all IP mappings pointing to this connection
+	for ip, mappedConn := range r.srcIPConns {
+		if mappedConn == conn {
+			delete(r.srcIPConns, ip)
+		}
+	}
+}
+
+type routeElement struct {
+	name   string
+	routes []net.IPNet
+	conn   io.ReadWriteCloser
+}

+ 81 - 0
pkg/vnet/message.go

@@ -0,0 +1,81 @@
+// 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 vnet
+
+import (
+	"encoding/binary"
+	"fmt"
+	"io"
+)
+
+// Maximum message size
+const (
+	maxMessageSize = 1024 * 1024 // 1MB
+)
+
+// Format: [length(4 bytes)][data(length bytes)]
+
+// ReadMessage reads a framed message from the reader
+func ReadMessage(r io.Reader) ([]byte, error) {
+	// Read length (4 bytes)
+	var length uint32
+	err := binary.Read(r, binary.LittleEndian, &length)
+	if err != nil {
+		return nil, fmt.Errorf("read message length error: %w", err)
+	}
+
+	// Check length to prevent DoS
+	if length == 0 {
+		return nil, fmt.Errorf("message length is 0")
+	}
+	if length > maxMessageSize {
+		return nil, fmt.Errorf("message too large: %d > %d", length, maxMessageSize)
+	}
+
+	// Read message data
+	data := make([]byte, length)
+	_, err = io.ReadFull(r, data)
+	if err != nil {
+		return nil, fmt.Errorf("read message data error: %w", err)
+	}
+
+	return data, nil
+}
+
+// WriteMessage writes a framed message to the writer
+func WriteMessage(w io.Writer, data []byte) error {
+	// Get data length
+	length := uint32(len(data))
+	if length == 0 {
+		return fmt.Errorf("message data length is 0")
+	}
+	if length > maxMessageSize {
+		return fmt.Errorf("message too large: %d > %d", length, maxMessageSize)
+	}
+
+	// Write length
+	err := binary.Write(w, binary.LittleEndian, length)
+	if err != nil {
+		return fmt.Errorf("write message length error: %w", err)
+	}
+
+	// Write message data
+	_, err = w.Write(data)
+	if err != nil {
+		return fmt.Errorf("write message data error: %w", err)
+	}
+
+	return nil
+}

+ 109 - 0
pkg/vnet/tun.go

@@ -0,0 +1,109 @@
+// 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 vnet
+
+import (
+	"context"
+	"io"
+
+	"github.com/fatedier/golib/pool"
+	"golang.zx2c4.com/wireguard/tun"
+)
+
+const (
+	offset            = 16
+	defaultPacketSize = 1420
+)
+
+type TunDevice interface {
+	io.ReadWriteCloser
+}
+
+func OpenTun(ctx context.Context, addr string) (TunDevice, error) {
+	td, err := openTun(ctx, addr)
+	if err != nil {
+		return nil, err
+	}
+
+	mtu, err := td.MTU()
+	if err != nil {
+		mtu = defaultPacketSize
+	}
+
+	bufferSize := max(mtu, defaultPacketSize)
+	batchSize := td.BatchSize()
+
+	device := &tunDeviceWrapper{
+		dev:         td,
+		bufferSize:  bufferSize,
+		readBuffers: make([][]byte, batchSize),
+		sizeBuffer:  make([]int, batchSize),
+	}
+
+	for i := range device.readBuffers {
+		device.readBuffers[i] = make([]byte, offset+bufferSize)
+	}
+
+	return device, nil
+}
+
+type tunDeviceWrapper struct {
+	dev           tun.Device
+	bufferSize    int
+	readBuffers   [][]byte
+	packetBuffers [][]byte
+	sizeBuffer    []int
+}
+
+func (d *tunDeviceWrapper) Read(p []byte) (int, error) {
+	if len(d.packetBuffers) > 0 {
+		n := copy(p, d.packetBuffers[0])
+		d.packetBuffers = d.packetBuffers[1:]
+		return n, nil
+	}
+
+	n, err := d.dev.Read(d.readBuffers, d.sizeBuffer, offset)
+	if err != nil {
+		return 0, err
+	}
+	if n == 0 {
+		return 0, io.EOF
+	}
+
+	for i := range n {
+		if d.sizeBuffer[i] <= 0 {
+			continue
+		}
+		d.packetBuffers = append(d.packetBuffers, d.readBuffers[i][offset:offset+d.sizeBuffer[i]])
+	}
+
+	dataSize := copy(p, d.packetBuffers[0])
+	d.packetBuffers = d.packetBuffers[1:]
+
+	return dataSize, nil
+}
+
+func (d *tunDeviceWrapper) Write(p []byte) (int, error) {
+	buf := pool.GetBuf(offset + d.bufferSize)
+	defer pool.PutBuf(buf)
+
+	n := copy(buf[offset:], p)
+	_, err := d.dev.Write([][]byte{buf[:offset+n]}, offset)
+	return n, err
+}
+
+func (d *tunDeviceWrapper) Close() error {
+	return d.dev.Close()
+}

+ 85 - 0
pkg/vnet/tun_darwin.go

@@ -0,0 +1,85 @@
+// 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 vnet
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"os/exec"
+
+	"golang.zx2c4.com/wireguard/tun"
+)
+
+const (
+	defaultTunName = "utun"
+	defaultMTU     = 1420
+)
+
+func openTun(_ context.Context, addr string) (tun.Device, error) {
+	dev, err := tun.CreateTUN(defaultTunName, defaultMTU)
+	if err != nil {
+		return nil, err
+	}
+
+	name, err := dev.Name()
+	if err != nil {
+		return nil, err
+	}
+
+	ip, ipNet, err := net.ParseCIDR(addr)
+	if err != nil {
+		return nil, err
+	}
+
+	// Calculate a peer IP for the point-to-point tunnel
+	peerIP := generatePeerIP(ip)
+
+	// Configure the interface with proper point-to-point addressing
+	if err = exec.Command("ifconfig", name, "inet", ip.String(), peerIP.String(), "mtu", fmt.Sprint(defaultMTU), "up").Run(); err != nil {
+		return nil, err
+	}
+
+	// Add default route for the tunnel subnet
+	routes := []net.IPNet{*ipNet}
+	if err = addRoutes(name, routes); err != nil {
+		return nil, err
+	}
+	return dev, nil
+}
+
+// generatePeerIP creates a peer IP for the point-to-point tunnel
+// by incrementing the last octet of the IP
+func generatePeerIP(ip net.IP) net.IP {
+	// Make a copy to avoid modifying the original
+	peerIP := make(net.IP, len(ip))
+	copy(peerIP, ip)
+
+	// Increment the last octet
+	peerIP[len(peerIP)-1]++
+
+	return peerIP
+}
+
+// addRoutes configures system routes for the TUN interface
+func addRoutes(ifName string, routes []net.IPNet) error {
+	for _, route := range routes {
+		routeStr := route.String()
+		if err := exec.Command("route", "add", "-net", routeStr, "-interface", ifName).Run(); err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 131 - 0
pkg/vnet/tun_linux.go

@@ -0,0 +1,131 @@
+// 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 vnet
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"net"
+	"strconv"
+	"strings"
+
+	"github.com/vishvananda/netlink"
+	"golang.zx2c4.com/wireguard/tun"
+)
+
+const (
+	baseTunName = "utun"
+	defaultMTU  = 1420
+)
+
+func openTun(_ context.Context, addr string) (tun.Device, error) {
+	name, err := findNextTunName(baseTunName)
+	if err != nil {
+		name = getFallbackTunName(baseTunName, addr)
+	}
+
+	tunDevice, err := tun.CreateTUN(name, defaultMTU)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create TUN device '%s': %w", name, err)
+	}
+
+	actualName, err := tunDevice.Name()
+	if err != nil {
+		return nil, err
+	}
+
+	ifn, err := net.InterfaceByName(actualName)
+	if err != nil {
+		return nil, err
+	}
+
+	link, err := netlink.LinkByName(actualName)
+	if err != nil {
+		return nil, err
+	}
+
+	ip, cidr, err := net.ParseCIDR(addr)
+	if err != nil {
+		return nil, err
+	}
+	if err := netlink.AddrAdd(link, &netlink.Addr{
+		IPNet: &net.IPNet{
+			IP:   ip,
+			Mask: cidr.Mask,
+		},
+	}); err != nil {
+		return nil, err
+	}
+
+	if err := netlink.LinkSetUp(link); err != nil {
+		return nil, err
+	}
+
+	if err = addRoutes(ifn, cidr); err != nil {
+		return nil, err
+	}
+	return tunDevice, nil
+}
+
+func findNextTunName(basename string) (string, error) {
+	interfaces, err := net.Interfaces()
+	if err != nil {
+		return "", fmt.Errorf("failed to get network interfaces: %w", err)
+	}
+	maxSuffix := -1
+
+	for _, iface := range interfaces {
+		name := iface.Name
+		if strings.HasPrefix(name, basename) {
+			suffix := name[len(basename):]
+			if suffix == "" {
+				continue
+			}
+
+			numSuffix, err := strconv.Atoi(suffix)
+			if err == nil && numSuffix > maxSuffix {
+				maxSuffix = numSuffix
+			}
+		}
+	}
+
+	nextSuffix := maxSuffix + 1
+	name := fmt.Sprintf("%s%d", basename, nextSuffix)
+	return name, nil
+}
+
+func addRoutes(ifn *net.Interface, cidr *net.IPNet) error {
+	r := netlink.Route{
+		Dst:       cidr,
+		LinkIndex: ifn.Index,
+	}
+	if err := netlink.RouteReplace(&r); err != nil {
+		return fmt.Errorf("add route to %v error: %v", r.Dst, err)
+	}
+	return nil
+}
+
+// getFallbackTunName generates a deterministic fallback TUN device name
+// based on the base name and the provided address string using a hash.
+func getFallbackTunName(baseName, addr string) string {
+	hasher := sha256.New()
+	hasher.Write([]byte(addr))
+	hashBytes := hasher.Sum(nil)
+	// Use first 4 bytes -> 8 hex chars for brevity, respecting IFNAMSIZ limit.
+	shortHash := hex.EncodeToString(hashBytes[:4])
+	return fmt.Sprintf("%s%s", baseName, shortHash)
+}

+ 29 - 0
pkg/vnet/tun_unsupported.go

@@ -0,0 +1,29 @@
+// 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.
+
+//go:build !darwin && !linux
+
+package vnet
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+
+	"golang.zx2c4.com/wireguard/tun"
+)
+
+func openTun(_ context.Context, _ string) (tun.Device, error) {
+	return nil, fmt.Errorf("virtual net is not supported on this platform (%s/%s)", runtime.GOOS, runtime.GOARCH)
+}

+ 1 - 1
server/control.go

@@ -224,7 +224,7 @@ func (ctl *Control) Close() error {
 
 func (ctl *Control) Replaced(newCtl *Control) {
 	xl := ctl.xl
-	xl.Infof("Replaced by client [%s]", newCtl.runID)
+	xl.Infof("replaced by client [%s]", newCtl.runID)
 	ctl.runID = ""
 	ctl.conn.Close()
 }

+ 10 - 10
server/dashboard_api.go

@@ -97,14 +97,14 @@ func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
 func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
 	res := GeneralResponse{Code: 200}
 	defer func() {
-		log.Infof("Http response [%s]: code [%d]", r.URL.Path, res.Code)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
 		}
 	}()
 
-	log.Infof("Http request: [%s]", r.URL.Path)
+	log.Infof("http request: [%s]", r.URL.Path)
 	serverStats := mem.StatsCollector.GetServer()
 	svrResp := serverInfoResp{
 		Version:               version.Full(),
@@ -218,13 +218,13 @@ func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
 	proxyType := params["type"]
 
 	defer func() {
-		log.Infof("Http response [%s]: code [%d]", r.URL.Path, res.Code)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
 		}
 	}()
-	log.Infof("Http request: [%s]", r.URL.Path)
+	log.Infof("http request: [%s]", r.URL.Path)
 
 	proxyInfoResp := GetProxyInfoResp{}
 	proxyInfoResp.Proxies = svr.getProxyStatsByType(proxyType)
@@ -290,13 +290,13 @@ func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request
 	name := params["name"]
 
 	defer func() {
-		log.Infof("Http response [%s]: code [%d]", r.URL.Path, res.Code)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
 		}
 	}()
-	log.Infof("Http request: [%s]", r.URL.Path)
+	log.Infof("http request: [%s]", r.URL.Path)
 
 	var proxyStatsResp GetProxyStatsResp
 	proxyStatsResp, res.Code, res.Msg = svr.getProxyStatsByTypeAndName(proxyType, name)
@@ -358,13 +358,13 @@ func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
 	name := params["name"]
 
 	defer func() {
-		log.Infof("Http response [%s]: code [%d]", r.URL.Path, res.Code)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
 		}
 	}()
-	log.Infof("Http request: [%s]", r.URL.Path)
+	log.Infof("http request: [%s]", r.URL.Path)
 
 	trafficResp := GetProxyTrafficResp{}
 	trafficResp.Name = name
@@ -386,9 +386,9 @@ func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
 func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
 	res := GeneralResponse{Code: 200}
 
-	log.Infof("Http request: [%s]", r.URL.Path)
+	log.Infof("http request: [%s]", r.URL.Path)
 	defer func() {
-		log.Infof("Http response [%s]: code [%d]", r.URL.Path, res.Code)
+		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))

+ 9 - 9
server/service.go

@@ -427,7 +427,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
 
 	_ = conn.SetReadDeadline(time.Now().Add(connReadTimeout))
 	if rawMsg, err = msg.ReadMsg(conn); err != nil {
-		log.Tracef("Failed to read message: %v", err)
+		log.Tracef("failed to read message: %v", err)
 		conn.Close()
 		return
 	}
@@ -475,7 +475,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
 			})
 		}
 	default:
-		log.Warnf("Error message type for the new connection [%s]", conn.RemoteAddr().String())
+		log.Warnf("error message type for the new connection [%s]", conn.RemoteAddr().String())
 		conn.Close()
 	}
 }
@@ -488,7 +488,7 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
 	for {
 		c, err := l.Accept()
 		if err != nil {
-			log.Warnf("Listener for incoming connections from client closed")
+			log.Warnf("listener for incoming connections from client closed")
 			return
 		}
 		// inject xlog object into net.Conn context
@@ -504,7 +504,7 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
 			var isTLS, custom bool
 			c, isTLS, custom, err = netpkg.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout)
 			if err != nil {
-				log.Warnf("CheckAndEnableTLSServerConnWithTimeout error: %v", err)
+				log.Warnf("checkAndEnableTLSServerConnWithTimeout error: %v", err)
 				originConn.Close()
 				continue
 			}
@@ -520,7 +520,7 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
 				fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
 				session, err := fmux.Server(frpConn, fmuxCfg)
 				if err != nil {
-					log.Warnf("Failed to create mux connection: %v", err)
+					log.Warnf("failed to create mux connection: %v", err)
 					frpConn.Close()
 					return
 				}
@@ -528,7 +528,7 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
 				for {
 					stream, err := session.AcceptStream()
 					if err != nil {
-						log.Debugf("Accept new mux stream error: %v", err)
+						log.Debugf("accept new mux stream error: %v", err)
 						session.Close()
 						return
 					}
@@ -546,7 +546,7 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
 	for {
 		c, err := l.Accept(context.Background())
 		if err != nil {
-			log.Warnf("QUICListener for incoming connections from client closed")
+			log.Warnf("quic listener for incoming connections from client closed")
 			return
 		}
 		// Start a new goroutine to handle connection.
@@ -554,7 +554,7 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
 			for {
 				stream, err := frpConn.AcceptStream(context.Background())
 				if err != nil {
-					log.Debugf("Accept new quic mux stream error: %v", err)
+					log.Debugf("accept new quic mux stream error: %v", err)
 					_ = frpConn.CloseWithError(0, "")
 					return
 				}
@@ -620,7 +620,7 @@ func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn)
 	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)
+		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

+ 1 - 1
test/e2e/e2e.go

@@ -38,7 +38,7 @@ func RunE2ETests(t *testing.T) {
 	// Randomize specs as well as suites
 	suiteConfig.RandomizeAllSpecs = true
 
-	log.Infof("Starting e2e run %q on Ginkgo node %d of total %d",
+	log.Infof("starting e2e run %q on Ginkgo node %d of total %d",
 		framework.RunID, suiteConfig.ParallelProcess, suiteConfig.ParallelTotal)
 	ginkgo.RunSpecs(t, "frp e2e suite", suiteConfig, reporterConfig)
 }

+ 3 - 3
test/e2e/framework/request.go

@@ -20,7 +20,7 @@ func ExpectResponseCode(code int) EnsureFunc {
 		if resp.Code == code {
 			return true
 		}
-		flog.Warnf("Expect code %d, but got %d", code, resp.Code)
+		flog.Warnf("expect code %d, but got %d", code, resp.Code)
 		return false
 	}
 }
@@ -111,14 +111,14 @@ func (e *RequestExpect) Ensure(fns ...EnsureFunc) {
 
 	if len(fns) == 0 {
 		if !bytes.Equal(e.expectResp, ret.Content) {
-			flog.Tracef("Response info: %+v", ret)
+			flog.Tracef("response info: %+v", ret)
 		}
 		ExpectEqualValuesWithOffset(1, string(ret.Content), string(e.expectResp), e.explain...)
 	} else {
 		for _, fn := range fns {
 			ok := fn(ret)
 			if !ok {
-				flog.Tracef("Response info: %+v", ret)
+				flog.Tracef("response info: %+v", ret)
 			}
 			ExpectTrueWithOffset(1, ok, e.explain...)
 		}

+ 5 - 3
test/e2e/legacy/basic/client_server.go

@@ -24,12 +24,14 @@ type generalTestConfigures struct {
 }
 
 func renderBindPortConfig(protocol string) string {
-	if protocol == "kcp" {
+	switch protocol {
+	case "kcp":
 		return fmt.Sprintf(`kcp_bind_port = {{ .%s }}`, consts.PortServerName)
-	} else if protocol == "quic" {
+	case "quic":
 		return fmt.Sprintf(`quic_bind_port = {{ .%s }}`, consts.PortServerName)
+	default:
+		return ""
 	}
-	return ""
 }
 
 func runClientServerTest(f *framework.Framework, configures *generalTestConfigures) {

+ 2 - 2
test/e2e/legacy/features/real_ip.go

@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
 			f.RunProcesses([]string{serverConf}, []string{clientConf})
 
 			framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
-				log.Tracef("ProxyProtocol get SourceAddr: %s", string(resp.Content))
+				log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
 				addr, err := net.ResolveTCPAddr("tcp", string(resp.Content))
 				if err != nil {
 					return false
@@ -142,7 +142,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
 				r.HTTP().HTTPHost("normal.example.com")
 			}).Ensure(framework.ExpectResponseCode(404))
 
-			log.Tracef("ProxyProtocol get SourceAddr: %s", srcAddrRecord)
+			log.Tracef("proxy protocol get SourceAddr: %s", srcAddrRecord)
 			addr, err := net.ResolveTCPAddr("tcp", srcAddrRecord)
 			framework.ExpectNoError(err, srcAddrRecord)
 			framework.ExpectEqualValues("127.0.0.1", addr.IP.String())

+ 2 - 2
test/e2e/legacy/plugin/server.go

@@ -223,7 +223,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
 			handler := func(req *plugin.Request) *plugin.Response {
 				var ret plugin.Response
 				content := req.Content.(*plugin.PingContent)
-				record = content.Ping.PrivilegeKey
+				record = content.PrivilegeKey
 				ret.Unchange = true
 				return &ret
 			}
@@ -273,7 +273,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
 			handler := func(req *plugin.Request) *plugin.Response {
 				var ret plugin.Response
 				content := req.Content.(*plugin.NewWorkConnContent)
-				record = content.NewWorkConn.RunID
+				record = content.RunID
 				ret.Unchange = true
 				return &ret
 			}

+ 5 - 3
test/e2e/v1/basic/client_server.go

@@ -24,12 +24,14 @@ type generalTestConfigures struct {
 }
 
 func renderBindPortConfig(protocol string) string {
-	if protocol == "kcp" {
+	switch protocol {
+	case "kcp":
 		return fmt.Sprintf(`kcpBindPort = {{ .%s }}`, consts.PortServerName)
-	} else if protocol == "quic" {
+	case "quic":
 		return fmt.Sprintf(`quicBindPort = {{ .%s }}`, consts.PortServerName)
+	default:
+		return ""
 	}
-	return ""
 }
 
 func runClientServerTest(f *framework.Framework, configures *generalTestConfigures) {

+ 52 - 2
test/e2e/v1/features/real_ip.go

@@ -215,7 +215,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
 			f.RunProcesses([]string{serverConf}, []string{clientConf})
 
 			framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
-				log.Tracef("ProxyProtocol get SourceAddr: %s", string(resp.Content))
+				log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
 				addr, err := net.ResolveTCPAddr("tcp", string(resp.Content))
 				if err != nil {
 					return false
@@ -227,6 +227,56 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
 			})
 		})
 
+		ginkgo.It("UDP", func() {
+			serverConf := consts.DefaultServerConfig
+			clientConf := consts.DefaultClientConfig
+
+			localPort := f.AllocPort()
+			localServer := streamserver.New(streamserver.UDP, streamserver.WithBindPort(localPort),
+				streamserver.WithCustomHandler(func(c net.Conn) {
+					defer c.Close()
+					rd := bufio.NewReader(c)
+					ppHeader, err := pp.Read(rd)
+					if err != nil {
+						log.Errorf("read proxy protocol error: %v", err)
+						return
+					}
+
+					// Read the actual UDP content after proxy protocol header
+					if _, err := rpc.ReadBytes(rd); err != nil {
+						return
+					}
+
+					buf := []byte(ppHeader.SourceAddr.String())
+					_, _ = rpc.WriteBytes(c, buf)
+				}))
+			f.RunServer("", localServer)
+
+			remotePort := f.AllocPort()
+			clientConf += fmt.Sprintf(`
+			[[proxies]]
+			name = "udp"
+			type = "udp"
+			localPort = %d
+			remotePort = %d
+			transport.proxyProtocolVersion = "v2"
+			`, localPort, remotePort)
+
+			f.RunProcesses([]string{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))
+				addr, err := net.ResolveUDPAddr("udp", string(resp.Content))
+				if err != nil {
+					return false
+				}
+				if addr.IP.String() != "127.0.0.1" {
+					return false
+				}
+				return true
+			})
+		})
+
 		ginkgo.It("HTTP", func() {
 			vhostHTTPPort := f.AllocPort()
 			serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
@@ -265,7 +315,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
 				r.HTTP().HTTPHost("normal.example.com")
 			}).Ensure(framework.ExpectResponseCode(404))
 
-			log.Tracef("ProxyProtocol get SourceAddr: %s", srcAddrRecord)
+			log.Tracef("proxy protocol get SourceAddr: %s", srcAddrRecord)
 			addr, err := net.ResolveTCPAddr("tcp", srcAddrRecord)
 			framework.ExpectNoError(err, srcAddrRecord)
 			framework.ExpectEqualValues("127.0.0.1", addr.IP.String())

+ 2 - 2
test/e2e/v1/plugin/server.go

@@ -232,7 +232,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
 			handler := func(req *plugin.Request) *plugin.Response {
 				var ret plugin.Response
 				content := req.Content.(*plugin.PingContent)
-				record = content.Ping.PrivilegeKey
+				record = content.PrivilegeKey
 				ret.Unchange = true
 				return &ret
 			}
@@ -284,7 +284,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
 			handler := func(req *plugin.Request) *plugin.Response {
 				var ret plugin.Response
 				content := req.Content.(*plugin.NewWorkConnContent)
-				record = content.NewWorkConn.RunID
+				record = content.RunID
 				ret.Unchange = true
 				return &ret
 			}