瀏覽代碼

Merge pull request #4999 from fatedier/dev

bump version
fatedier 1 月之前
父節點
當前提交
71f198465a

+ 1 - 1
.circleci/config.yml

@@ -2,7 +2,7 @@ version: 2
 jobs:
   go-version-latest:
     docker:
-    - image: cimg/go:1.23-node
+    - image: cimg/go:1.24-node
     resource_class: large
     steps:
     - checkout

+ 1 - 1
.github/workflows/golangci-lint.yml

@@ -17,7 +17,7 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/setup-go@v5
       with:
-        go-version: '1.23'
+        go-version: '1.24'
         cache: false
     - name: golangci-lint
       uses: golangci/golangci-lint-action@v8

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

@@ -15,7 +15,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v5
         with:
-          go-version: '1.23'
+          go-version: '1.24'
           
       - name: Make All
         run: |

+ 9 - 2
README.md

@@ -13,11 +13,18 @@ frp is an open source project with its ongoing development made possible entirel
 
 <h3 align="center">Gold Sponsors</h3>
 <!--gold sponsors start-->
+<p align="center">
+  <a href="https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
+    <b>Recall.ai - API for meeting recordings</b><br>
+    <br>
+    <sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
+  </a>
+</p>
 <p align="center">
   <a href="https://go.warp.dev/frp" target="_blank">
     <img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
     <br>
-    <b>Warp, the intelligent terminal</b>
+    <b>Warp, built for collaborating with AI Agents</b>
     <br>
 	<sub>Available for macOS, Linux and Windows</sub>
   </a>
@@ -519,7 +526,7 @@ name = "ssh"
 type = "tcp"
 localIP = "127.0.0.1"
 localPort = 22
-remotePort = "{{ .Envs.FRP_SSH_REMOTE_PORT }}"
+remotePort = {{ .Envs.FRP_SSH_REMOTE_PORT }}
 ```
 
 With the config above, variables can be passed into `frpc` program like this:

+ 24 - 0
README_zh.md

@@ -15,19 +15,43 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
 
 <h3 align="center">Gold Sponsors</h3>
 <!--gold sponsors start-->
+<p align="center">
+  <a href="https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
+    <b>Recall.ai - API for meeting recordings</b><br>
+    <br>
+    <sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
+  </a>
+</p>
+<p align="center">
+  <a href="https://go.warp.dev/frp" target="_blank">
+    <img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
+    <br>
+    <b>Warp, built for collaborating with AI Agents</b>
+    <br>
+	<sub>Available for macOS, Linux and Windows</sub>
+  </a>
+</p>
 <p align="center">
   <a href="https://jb.gg/frp" target="_blank">
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
+	<br>
+	<b>The complete IDE crafted for professional Go developers</b>
   </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">
+	<br>
+	<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
   </a>
 </p>
 <p align="center">
   <a href="https://github.com/beclab/Olares" target="_blank">
     <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
+	<br>
+	<b>The sovereign cloud that puts you in control</b>
+	<br>
+	<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
   </a>
 </p>
 <!--gold sponsors end-->

+ 3 - 5
Release.md

@@ -1,7 +1,5 @@
 ## Features
 
-* Support tokenSource for loading authentication tokens from files.
-
-## Fixes
-
-* Fix SSH tunnel gateway incorrectly binding to proxyBindAddr instead of bindAddr, which caused external connections to fail when proxyBindAddr was set to 127.0.0.1.
+* Add NAT traversal configuration options for XTCP proxies and visitors. Support disabling assisted addresses to avoid using slow VPN connections during NAT hole punching.
+* Enhanced OIDC client configuration with support for custom TLS certificate verification and proxy settings. Added `trustedCaFile`, `insecureSkipVerify`, and `proxyURL` options for OIDC token endpoint connections.
+* Added detailed Prometheus metrics with `proxy_counts_detailed` metric that includes both proxy type and proxy name labels, enabling monitoring of individual proxy connections instead of just aggregate counts.

+ 2 - 2
client/connector.go

@@ -17,7 +17,6 @@ package client
 import (
 	"context"
 	"crypto/tls"
-	"io"
 	"net"
 	"strconv"
 	"strings"
@@ -115,7 +114,8 @@ func (c *defaultConnectorImpl) Open() error {
 
 	fmuxCfg := fmux.DefaultConfig()
 	fmuxCfg.KeepAliveInterval = time.Duration(c.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second
-	fmuxCfg.LogOutput = io.Discard
+	// Use trace level for yamux logs
+	fmuxCfg.LogOutput = xlog.NewTraceWriter(xl)
 	fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
 	session, err := fmux.Client(conn, fmuxCfg)
 	if err != nil {

+ 2 - 0
client/control.go

@@ -276,10 +276,12 @@ func (ctl *Control) heartbeatWorker() {
 }
 
 func (ctl *Control) worker() {
+	xl := ctl.xl
 	go ctl.heartbeatWorker()
 	go ctl.msgDispatcher.Run()
 
 	<-ctl.msgDispatcher.Done()
+	xl.Debugf("control message dispatcher exited")
 	ctl.closeSession()
 
 	ctl.pm.Close()

+ 9 - 1
client/proxy/xtcp.go

@@ -64,11 +64,19 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
 	}
 
 	xl.Tracef("nathole prepare start")
-	prepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer})
+
+	// Prepare NAT traversal options
+	var opts nathole.PrepareOptions
+	if pxy.cfg.NatTraversal != nil && pxy.cfg.NatTraversal.DisableAssistedAddrs {
+		opts.DisableAssistedAddrs = true
+	}
+
+	prepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer}, opts)
 	if err != nil {
 		xl.Warnf("nathole prepare error: %v", err)
 		return
 	}
+
 	xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v",
 		prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)
 	defer prepareResult.ListenConn.Close()

+ 7 - 1
client/service.go

@@ -149,9 +149,15 @@ func NewService(options ServiceOptions) (*Service, error) {
 		}
 		webServer = ws
 	}
+
+	authSetter, err := auth.NewAuthSetter(options.Common.Auth)
+	if err != nil {
+		return nil, err
+	}
+
 	s := &Service{
 		ctx:              context.Background(),
-		authSetter:       auth.NewAuthSetter(options.Common.Auth),
+		authSetter:       authSetter,
 		webServer:        webServer,
 		common:           options.Common,
 		configFilePath:   options.ConfigFilePath,

+ 32 - 27
client/visitor/xtcp.go

@@ -145,7 +145,7 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
 			return
 		case <-ticker.C:
 			xl.Debugf("keepTunnelOpenWorker try to check tunnel...")
-			conn, err := sv.getTunnelConn()
+			conn, err := sv.getTunnelConn(sv.ctx)
 			if err != nil {
 				xl.Warnf("keepTunnelOpenWorker get tunnel connection error: %v", err)
 				_ = sv.retryLimiter.Wait(sv.ctx)
@@ -161,9 +161,9 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
 
 func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 	xl := xlog.FromContextSafe(sv.ctx)
-	isConnTransfered := false
+	isConnTransferred := false
 	defer func() {
-		if !isConnTransfered {
+		if !isConnTransferred {
 			userConn.Close()
 		}
 	}()
@@ -172,7 +172,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 
 	// Open a tunnel connection to the server. If there is already a successful hole-punching connection,
 	// it will be reused. Otherwise, it will block and wait for a successful hole-punching connection until timeout.
-	ctx := context.Background()
+	ctx := sv.ctx
 	if sv.cfg.FallbackTo != "" {
 		timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(sv.cfg.FallbackTimeoutMs)*time.Millisecond)
 		defer cancel()
@@ -191,7 +191,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 			xl.Errorf("transfer connection to visitor %s error: %v", sv.cfg.FallbackTo, err)
 			return
 		}
-		isConnTransfered = true
+		isConnTransferred = true
 		return
 	}
 
@@ -219,40 +219,37 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
 // openTunnel will open a tunnel connection to the target server.
 func (sv *XTCPVisitor) openTunnel(ctx context.Context) (conn net.Conn, err error) {
 	xl := xlog.FromContextSafe(sv.ctx)
-	ticker := time.NewTicker(500 * time.Millisecond)
-	defer ticker.Stop()
+	ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
+	defer cancel()
 
-	timeoutC := time.After(20 * time.Second)
-	immediateTrigger := make(chan struct{}, 1)
-	defer close(immediateTrigger)
-	immediateTrigger <- struct{}{}
+	timer := time.NewTimer(0)
+	defer timer.Stop()
 
 	for {
 		select {
 		case <-sv.ctx.Done():
 			return nil, sv.ctx.Err()
 		case <-ctx.Done():
+			if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+				return nil, fmt.Errorf("open tunnel timeout")
+			}
 			return nil, ctx.Err()
-		case <-immediateTrigger:
-			conn, err = sv.getTunnelConn()
-		case <-ticker.C:
-			conn, err = sv.getTunnelConn()
-		case <-timeoutC:
-			return nil, fmt.Errorf("open tunnel timeout")
-		}
-
-		if err != nil {
-			if err != ErrNoTunnelSession {
-				xl.Warnf("get tunnel connection error: %v", err)
+		case <-timer.C:
+			conn, err = sv.getTunnelConn(ctx)
+			if err != nil {
+				if !errors.Is(err, ErrNoTunnelSession) {
+					xl.Warnf("get tunnel connection error: %v", err)
+				}
+				timer.Reset(500 * time.Millisecond)
+				continue
 			}
-			continue
+			return conn, nil
 		}
-		return conn, nil
 	}
 }
 
-func (sv *XTCPVisitor) getTunnelConn() (net.Conn, error) {
-	conn, err := sv.session.OpenConn(sv.ctx)
+func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) {
+	conn, err := sv.session.OpenConn(ctx)
 	if err == nil {
 		return conn, nil
 	}
@@ -279,11 +276,19 @@ func (sv *XTCPVisitor) makeNatHole() {
 	}
 
 	xl.Tracef("nathole prepare start")
-	prepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer})
+
+	// Prepare NAT traversal options
+	var opts nathole.PrepareOptions
+	if sv.cfg.NatTraversal != nil && sv.cfg.NatTraversal.DisableAssistedAddrs {
+		opts.DisableAssistedAddrs = true
+	}
+
+	prepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer}, opts)
 	if err != nil {
 		xl.Warnf("nathole prepare error: %v", err)
 		return
 	}
+
 	xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v",
 		prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)
 

+ 29 - 0
conf/frpc_full_example.toml

@@ -55,6 +55,20 @@ auth.token = "12345678"
 # auth.oidc.additionalEndpointParams.audience = "https://dev.auth.com/api/v2/"
 # auth.oidc.additionalEndpointParams.var1 = "foobar"
 
+# OIDC TLS and proxy configuration
+# Specify a custom CA certificate file for verifying the OIDC token endpoint's TLS certificate.
+# This is useful when the OIDC provider uses a self-signed certificate or a custom CA.
+# auth.oidc.trustedCaFile = "/path/to/ca.crt"
+
+# Skip TLS certificate verification for the OIDC token endpoint.
+# INSECURE: Only use this for debugging purposes, not recommended for production.
+# auth.oidc.insecureSkipVerify = false
+
+# Specify a proxy server for OIDC token endpoint connections.
+# Supports http, https, socks5, and socks5h proxy protocols.
+# If not specified, no proxy is used for OIDC connections.
+# auth.oidc.proxyURL = "http://proxy.example.com:8080"
+
 # Set admin address for control frpc's action by http api such as reload
 webServer.addr = "127.0.0.1"
 webServer.port = 7400
@@ -372,6 +386,14 @@ localPort = 22
 # Otherwise, visitors from same user can connect. '*' means allow all users.
 allowUsers = ["user1", "user2"]
 
+# NAT traversal configuration (optional)
+[proxies.natTraversal]
+# Disable the use of local network interfaces (assisted addresses) for NAT traversal.
+# When enabled, only STUN-discovered public addresses will be used.
+# This can improve performance when you have slow VPN connections.
+# Default: false
+disableAssistedAddrs = false
+
 [[proxies]]
 name = "vnet-server"
 type = "stcp"
@@ -411,6 +433,13 @@ minRetryInterval = 90
 # fallbackTo = "stcp_visitor"
 # fallbackTimeoutMs = 500
 
+# NAT traversal configuration (optional)
+[visitors.natTraversal]
+# Disable the use of local network interfaces (assisted addresses) for NAT traversal.
+# When enabled, only STUN-discovered public addresses will be used.
+# Default: false
+disableAssistedAddrs = false
+
 [[visitors]]
 name = "vnet-visitor"
 type = "stcp"

+ 1 - 1
dockerfiles/Dockerfile-for-frpc

@@ -1,4 +1,4 @@
-FROM golang:1.23 AS building
+FROM golang:1.24 AS building
 
 COPY . /building
 WORKDIR /building

+ 1 - 1
dockerfiles/Dockerfile-for-frps

@@ -1,4 +1,4 @@
-FROM golang:1.23 AS building
+FROM golang:1.24 AS building
 
 COPY . /building
 WORKDIR /building

+ 2 - 2
go.mod

@@ -1,6 +1,6 @@
 module github.com/fatedier/frp
 
-go 1.23.0
+go 1.24.0
 
 require (
 	github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
@@ -82,4 +82,4 @@ require (
 )
 
 // TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
-replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d
+replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6

+ 2 - 2
go.sum

@@ -22,8 +22,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 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/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
+github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
 github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 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=

+ 7 - 4
pkg/auth/auth.go

@@ -27,16 +27,19 @@ type Setter interface {
 	SetNewWorkConn(*msg.NewWorkConn) error
 }
 
-func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter) {
+func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {
 	switch cfg.Method {
 	case v1.AuthMethodToken:
 		authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)
 	case v1.AuthMethodOIDC:
-		authProvider = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
+		authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
+		if err != nil {
+			return nil, err
+		}
 	default:
-		panic(fmt.Sprintf("wrong method: '%s'", cfg.Method))
+		return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method)
 	}
-	return authProvider
+	return authProvider, nil
 }
 
 type Verifier interface {

+ 68 - 3
pkg/auth/oidc.go

@@ -16,23 +16,72 @@ package auth
 
 import (
 	"context"
+	"crypto/tls"
+	"crypto/x509"
 	"fmt"
+	"net/http"
+	"net/url"
+	"os"
 	"slices"
 
 	"github.com/coreos/go-oidc/v3/oidc"
+	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/clientcredentials"
 
 	v1 "github.com/fatedier/frp/pkg/config/v1"
 	"github.com/fatedier/frp/pkg/msg"
 )
 
+// createOIDCHTTPClient creates an HTTP client with custom TLS and proxy configuration for OIDC token requests
+func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyURL string) (*http.Client, error) {
+	// Clone the default transport to get all reasonable defaults
+	transport := http.DefaultTransport.(*http.Transport).Clone()
+
+	// Configure TLS settings
+	if trustedCAFile != "" || insecureSkipVerify {
+		tlsConfig := &tls.Config{
+			InsecureSkipVerify: insecureSkipVerify,
+		}
+
+		if trustedCAFile != "" && !insecureSkipVerify {
+			caCert, err := os.ReadFile(trustedCAFile)
+			if err != nil {
+				return nil, fmt.Errorf("failed to read OIDC CA certificate file %q: %w", trustedCAFile, err)
+			}
+
+			caCertPool := x509.NewCertPool()
+			if !caCertPool.AppendCertsFromPEM(caCert) {
+				return nil, fmt.Errorf("failed to parse OIDC CA certificate from file %q", trustedCAFile)
+			}
+
+			tlsConfig.RootCAs = caCertPool
+		}
+		transport.TLSClientConfig = tlsConfig
+	}
+
+	// Configure proxy settings
+	if proxyURL != "" {
+		parsedURL, err := url.Parse(proxyURL)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse OIDC proxy URL %q: %w", proxyURL, err)
+		}
+		transport.Proxy = http.ProxyURL(parsedURL)
+	} else {
+		// Explicitly disable proxy to override DefaultTransport's ProxyFromEnvironment
+		transport.Proxy = nil
+	}
+
+	return &http.Client{Transport: transport}, nil
+}
+
 type OidcAuthProvider struct {
 	additionalAuthScopes []v1.AuthScope
 
 	tokenGenerator *clientcredentials.Config
+	httpClient     *http.Client
 }
 
-func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) *OidcAuthProvider {
+func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
 	eps := make(map[string][]string)
 	for k, v := range cfg.AdditionalEndpointParams {
 		eps[k] = []string{v}
@@ -50,14 +99,30 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
 		EndpointParams: eps,
 	}
 
+	// Create custom HTTP client if needed
+	var httpClient *http.Client
+	if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
+		var err error
+		httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
+		}
+	}
+
 	return &OidcAuthProvider{
 		additionalAuthScopes: additionalAuthScopes,
 		tokenGenerator:       tokenGenerator,
-	}
+		httpClient:           httpClient,
+	}, nil
 }
 
 func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
-	tokenObj, err := auth.tokenGenerator.Token(context.Background())
+	ctx := context.Background()
+	if auth.httpClient != nil {
+		ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
+	}
+
+	tokenObj, err := auth.tokenGenerator.Token(ctx)
 	if err != nil {
 		return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
 	}

+ 11 - 0
pkg/config/v1/client.go

@@ -228,6 +228,17 @@ type AuthOIDCClientConfig struct {
 	// AdditionalEndpointParams specifies additional parameters to be sent
 	// this field will be transfer to map[string][]string in OIDC token generator.
 	AdditionalEndpointParams map[string]string `json:"additionalEndpointParams,omitempty"`
+
+	// TrustedCaFile specifies the path to a custom CA certificate file
+	// for verifying the OIDC token endpoint's TLS certificate.
+	TrustedCaFile string `json:"trustedCaFile,omitempty"`
+	// InsecureSkipVerify disables TLS certificate verification for the
+	// OIDC token endpoint. Only use this for debugging, not recommended for production.
+	InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
+	// ProxyURL specifies a proxy to use when connecting to the OIDC token endpoint.
+	// Supports http, https, socks5, and socks5h proxy protocols.
+	// If empty, no proxy is used for OIDC connections.
+	ProxyURL string `json:"proxyURL,omitempty"`
 }
 
 type VirtualNetConfig struct {

+ 10 - 2
pkg/config/v1/common.go

@@ -85,9 +85,9 @@ func (c *WebServerConfig) Complete() {
 }
 
 type TLSConfig struct {
-	// CertPath specifies the path of the cert file that client will load.
+	// CertFile specifies the path of the cert file that client will load.
 	CertFile string `json:"certFile,omitempty"`
-	// KeyPath specifies the path of the secret key file that client will load.
+	// KeyFile specifies the path of the secret key file that client will load.
 	KeyFile string `json:"keyFile,omitempty"`
 	// TrustedCaFile specifies the path of the trusted ca file that will load.
 	TrustedCaFile string `json:"trustedCaFile,omitempty"`
@@ -96,6 +96,14 @@ type TLSConfig struct {
 	ServerName string `json:"serverName,omitempty"`
 }
 
+// NatTraversalConfig defines configuration options for NAT traversal
+type NatTraversalConfig struct {
+	// DisableAssistedAddrs disables the use of local network interfaces
+	// for assisted connections during NAT traversal. When enabled,
+	// only STUN-discovered public addresses will be used.
+	DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
+}
+
 type LogConfig struct {
 	// This is destination where frp should write the logs.
 	// If "console" is used, logs will be printed to stdout, otherwise,

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

@@ -422,6 +422,9 @@ type XTCPProxyConfig struct {
 
 	Secretkey  string   `json:"secretKey,omitempty"`
 	AllowUsers []string `json:"allowUsers,omitempty"`
+
+	// NatTraversal configuration for NAT traversal
+	NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
 }
 
 func (c *XTCPProxyConfig) MarshalToMsg(m *msg.NewProxy) {

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

@@ -160,6 +160,9 @@ type XTCPVisitorConfig struct {
 	MinRetryInterval  int    `json:"minRetryInterval,omitempty"`
 	FallbackTo        string `json:"fallbackTo,omitempty"`
 	FallbackTimeoutMs int    `json:"fallbackTimeoutMs,omitempty"`
+
+	// NatTraversal configuration for NAT traversal
+	NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
 }
 
 func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {

+ 17 - 7
pkg/metrics/prometheus/server.go

@@ -14,11 +14,12 @@ const (
 var ServerMetrics metrics.ServerMetrics = newServerMetrics()
 
 type serverMetrics struct {
-	clientCount     prometheus.Gauge
-	proxyCount      *prometheus.GaugeVec
-	connectionCount *prometheus.GaugeVec
-	trafficIn       *prometheus.CounterVec
-	trafficOut      *prometheus.CounterVec
+	clientCount        prometheus.Gauge
+	proxyCount         *prometheus.GaugeVec
+	proxyCountDetailed *prometheus.GaugeVec
+	connectionCount    *prometheus.GaugeVec
+	trafficIn          *prometheus.CounterVec
+	trafficOut         *prometheus.CounterVec
 }
 
 func (m *serverMetrics) NewClient() {
@@ -29,12 +30,14 @@ func (m *serverMetrics) CloseClient() {
 	m.clientCount.Dec()
 }
 
-func (m *serverMetrics) NewProxy(_ string, proxyType string) {
+func (m *serverMetrics) NewProxy(name string, proxyType string) {
 	m.proxyCount.WithLabelValues(proxyType).Inc()
+	m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
 }
 
-func (m *serverMetrics) CloseProxy(_ string, proxyType string) {
+func (m *serverMetrics) CloseProxy(name string, proxyType string) {
 	m.proxyCount.WithLabelValues(proxyType).Dec()
+	m.proxyCountDetailed.WithLabelValues(proxyType, name).Dec()
 }
 
 func (m *serverMetrics) OpenConnection(name string, proxyType string) {
@@ -67,6 +70,12 @@ func newServerMetrics() *serverMetrics {
 			Name:      "proxy_counts",
 			Help:      "The current proxy counts",
 		}, []string{"type"}),
+		proxyCountDetailed: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Namespace: namespace,
+			Subsystem: serverSubsystem,
+			Name:      "proxy_counts_detailed",
+			Help:      "The current number of proxies grouped by type and name",
+		}, []string{"type", "name"}),
 		connectionCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{
 			Namespace: namespace,
 			Subsystem: serverSubsystem,
@@ -88,6 +97,7 @@ func newServerMetrics() *serverMetrics {
 	}
 	prometheus.MustRegister(m.clientCount)
 	prometheus.MustRegister(m.proxyCount)
+	prometheus.MustRegister(m.proxyCountDetailed)
 	prometheus.MustRegister(m.connectionCount)
 	prometheus.MustRegister(m.trafficIn)
 	prometheus.MustRegister(m.trafficOut)

+ 15 - 4
pkg/nathole/nathole.go

@@ -68,6 +68,13 @@ var (
 	DetectRoleReceiver = "receiver"
 )
 
+// PrepareOptions defines options for NAT traversal preparation
+type PrepareOptions struct {
+	// DisableAssistedAddrs disables the use of local network interfaces
+	// for assisted connections during NAT traversal
+	DisableAssistedAddrs bool
+}
+
 type PrepareResult struct {
 	Addrs         []string
 	AssistedAddrs []string
@@ -108,7 +115,7 @@ func PreCheck(
 }
 
 // Prepare is used to do some preparation work before penetration.
-func Prepare(stunServers []string) (*PrepareResult, error) {
+func Prepare(stunServers []string, opts PrepareOptions) (*PrepareResult, error) {
 	// discover for Nat type
 	addrs, localAddr, err := Discover(stunServers, "")
 	if err != nil {
@@ -133,9 +140,13 @@ func Prepare(stunServers []string) (*PrepareResult, error) {
 		return nil, fmt.Errorf("listen local udp addr error: %v", err)
 	}
 
-	assistedAddrs := make([]string, 0, len(localIPs))
-	for _, ip := range localIPs {
-		assistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port)))
+	// Apply NAT traversal options
+	var assistedAddrs []string
+	if !opts.DisableAssistedAddrs {
+		assistedAddrs = make([]string, 0, len(localIPs))
+		for _, ip := range localIPs {
+			assistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port)))
+		}
 	}
 	return &PrepareResult{
 		Addrs:         addrs,

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

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

+ 65 - 0
pkg/util/xlog/log_writer.go

@@ -0,0 +1,65 @@
+// 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 xlog
+
+import "strings"
+
+// LogWriter forwards writes to frp's logger at configurable level.
+// It is safe for concurrent use as long as the underlying Logger is thread-safe.
+type LogWriter struct {
+	xl      *Logger
+	logFunc func(string)
+}
+
+func (w LogWriter) Write(p []byte) (n int, err error) {
+	msg := strings.TrimSpace(string(p))
+	w.logFunc(msg)
+	return len(p), nil
+}
+
+func NewTraceWriter(xl *Logger) LogWriter {
+	return LogWriter{
+		xl:      xl,
+		logFunc: func(msg string) { xl.Tracef("%s", msg) },
+	}
+}
+
+func NewDebugWriter(xl *Logger) LogWriter {
+	return LogWriter{
+		xl:      xl,
+		logFunc: func(msg string) { xl.Debugf("%s", msg) },
+	}
+}
+
+func NewInfoWriter(xl *Logger) LogWriter {
+	return LogWriter{
+		xl:      xl,
+		logFunc: func(msg string) { xl.Infof("%s", msg) },
+	}
+}
+
+func NewWarnWriter(xl *Logger) LogWriter {
+	return LogWriter{
+		xl:      xl,
+		logFunc: func(msg string) { xl.Warnf("%s", msg) },
+	}
+}
+
+func NewErrorWriter(xl *Logger) LogWriter {
+	return LogWriter{
+		xl:      xl,
+		logFunc: func(msg string) { xl.Errorf("%s", msg) },
+	}
+}

+ 2 - 2
server/service.go

@@ -19,7 +19,6 @@ import (
 	"context"
 	"crypto/tls"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"os"
@@ -516,7 +515,8 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
 			if lo.FromPtr(svr.cfg.Transport.TCPMux) && !internal {
 				fmuxCfg := fmux.DefaultConfig()
 				fmuxCfg.KeepAliveInterval = time.Duration(svr.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second
-				fmuxCfg.LogOutput = io.Discard
+				// Use trace level for yamux logs
+				fmuxCfg.LogOutput = xlog.NewTraceWriter(xlog.FromContextSafe(ctx))
 				fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
 				session, err := fmux.Server(frpConn, fmuxCfg)
 				if err != nil {