8 Achegas b41d8f8e40 ... af6bc6369d

Autor SHA1 Mensaxe Data
  fatedier af6bc6369d Merge pull request #4849 from fatedier/dev hai 1 mes
  fatedier c777891f75 update .golangci.yml (#4848) hai 1 mes
  fatedier 43cf1688e4 update golangci-lint version (#4817) hai 2 meses
  fatedier 720c09c06b update test package (#4814) hai 2 meses
  fatedier 3fa76b72f3 add proxy protocol support for UDP proxies (#4810) hai 2 meses
  fatedier 8eb525a648 feat: support YAML merge in strict configuration mode (#4809) hai 2 meses
  scientificworld 077ba80ba3 fix: type error in server_plugin doc (#4799) hai 3 meses
  CrynTox c99986fa28 build: add x64 openbsd (#4780) hai 3 meses

+ 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 * * *"

+ 3 - 0
.gitignore

@@ -39,3 +39,6 @@ client.key
 
 # Cache
 *.swp
+
+# AI
+CLAUDE.md

+ 92 - 119
.golangci.yml

@@ -1,139 +1,112 @@
-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
 

+ 1 - 1
README.md

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

+ 3 - 2
Release.md

@@ -1,3 +1,4 @@
-### Bug Fixes
+## Features
 
-*   **VirtualNet:** Resolved various issues related to connection handling, TUN device management, and stability in the virtual network feature.
+* 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.

+ 4 - 20
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,6 +33,7 @@ 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"
 )
@@ -176,24 +175,9 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
 	}
 
 	if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
-		h := &pp.Header{
-			Command:         pp.PROXY,
-			SourceAddr:      connInfo.SrcAddr,
-			DestinationAddr: connInfo.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
-		}
-		connInfo.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

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

+ 3 - 4
client/service.go

@@ -325,10 +325,9 @@ 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,

+ 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>,

+ 8 - 8
go.mod

@@ -10,8 +10,8 @@ require (
 	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
@@ -46,12 +46,11 @@ require (
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // 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
@@ -67,14 +66,15 @@ require (
 	github.com/tidwall/pretty v1.2.0 // indirect
 	github.com/tjfoc/gmsm v1.4.1 // indirect
 	github.com/vishvananda/netns v0.0.4 // indirect
+	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/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.28.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.34.1 // 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

+ 18 - 14
go.sum

@@ -14,7 +14,6 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
 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=
@@ -50,10 +49,11 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
 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.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=
@@ -72,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=
@@ -94,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=
@@ -152,6 +154,8 @@ github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu
 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=
@@ -170,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=
@@ -239,8 +243,8 @@ 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=
@@ -261,8 +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.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=

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

+ 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()
 	}
 }
 

+ 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,

+ 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.62.1"
+var version = "0.63.0"
 
 func Full() string {
 	return version

+ 1 - 5
pkg/util/vhost/http.go

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

+ 1 - 5
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, ".")
 

+ 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/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) {

+ 50 - 0
test/e2e/v1/features/real_ip.go

@@ -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(`

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