From 024c334d9dcf47ac4d69099c1ef658b6468d6b48 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 12 Aug 2025 01:48:26 +0800 Subject: [PATCH 01/28] Merge pull request #4928 from fatedier/xtcp improve context and polling logic in xtcp visitor --- client/visitor/xtcp.go | 49 ++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index 99d25d8ac81..cb374b68e08 100644 --- a/client/visitor/xtcp.go +++ b/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 } From 14253afe2f5deb66cddbd9a0aac37dd31ace7d29 Mon Sep 17 00:00:00 2001 From: immomo808 <4128219+immomo808@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:11:06 +0900 Subject: [PATCH 02/28] remove quotes (#4938) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index baf9c734cfd..9a1db495ebc 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,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: From 80d3f332e184331172f94567b6f5f5606aa4fe18 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 25 Aug 2025 15:52:52 +0800 Subject: [PATCH 03/28] xtcp: add configuration to disable assisted addresses in NAT traversal (#4951) --- Release.md | 6 +----- client/proxy/xtcp.go | 10 +++++++++- client/visitor/xtcp.go | 10 +++++++++- conf/frpc_full_example.toml | 15 +++++++++++++++ pkg/config/v1/common.go | 8 ++++++++ pkg/config/v1/proxy.go | 3 +++ pkg/config/v1/visitor.go | 3 +++ pkg/nathole/nathole.go | 19 +++++++++++++++---- 8 files changed, 63 insertions(+), 11 deletions(-) diff --git a/Release.md b/Release.md index 5b2724d80dd..e0f5084e9ee 100644 --- a/Release.md +++ b/Release.md @@ -1,7 +1,3 @@ ## 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. diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go index 31f9ac89734..6e1deac39d3 100644 --- a/client/proxy/xtcp.go +++ b/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() diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index cb374b68e08..353577db659 100644 --- a/client/visitor/xtcp.go +++ b/client/visitor/xtcp.go @@ -276,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) diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index d8d93a3fe25..dfae95734d3 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -372,6 +372,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 +419,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" diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index ddb23356823..38965a3cbcc 100644 --- a/pkg/config/v1/common.go +++ b/pkg/config/v1/common.go @@ -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, diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index 34bd712592c..37701b6d785 100644 --- a/pkg/config/v1/proxy.go +++ b/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) { diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index f00391c34e4..7629875a6f2 100644 --- a/pkg/config/v1/visitor.go +++ b/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) { diff --git a/pkg/nathole/nathole.go b/pkg/nathole/nathole.go index bdd0ee83cb2..72522fac0ed 100644 --- a/pkg/nathole/nathole.go +++ b/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, From 610e5ed4798db5452cc0b62bf4a89ab50d1ee65d Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 25 Aug 2025 17:52:58 +0800 Subject: [PATCH 04/28] improve yamux logging (#4952) --- client/connector.go | 4 +-- client/control.go | 2 ++ go.mod | 2 +- go.sum | 4 +-- pkg/util/xlog/log_writer.go | 65 +++++++++++++++++++++++++++++++++++++ server/service.go | 4 +-- 6 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 pkg/util/xlog/log_writer.go diff --git a/client/connector.go b/client/connector.go index ab7c2fdda42..03d7fc29d97 100644 --- a/client/connector.go +++ b/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 { diff --git a/client/control.go b/client/control.go index 0dd70b8c72f..4bd6a2f737a 100644 --- a/client/control.go +++ b/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() diff --git a/go.mod b/go.mod index 46e753e2a14..06bfb6ecc0e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bd044c39cbb..a411ff30581 100644 --- a/go.sum +++ b/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= diff --git a/pkg/util/xlog/log_writer.go b/pkg/util/xlog/log_writer.go new file mode 100644 index 00000000000..3fff73240a8 --- /dev/null +++ b/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) }, + } +} diff --git a/server/service.go b/server/service.go index fad0e1432c8..7ca80dc89ba 100644 --- a/server/service.go +++ b/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 { From 604700cea5e56b0a607f3e243bd85850824b495b Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 27 Aug 2025 11:07:18 +0800 Subject: [PATCH 05/28] update README (#4957) --- README.md | 2 +- README_zh.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a1db495ebc..c1afcae0477 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ frp is an open source project with its ongoing development made possible entirel
- Warp, the intelligent terminal + Warp, built for collaborating with AI Agents
Available for macOS, Linux and Windows
diff --git a/README_zh.md b/README_zh.md index d911e15230f..c21be1c0356 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,19 +15,36 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

+

+ + +
+ Warp, built for collaborating with AI Agents +
+ Available for macOS, Linux and Windows +
+

+
+ The complete IDE crafted for professional Go developers

+
+ Secure and Elastic Infrastructure for Running Your AI-Generated Code

+
+ The sovereign cloud that puts you in control +
+ An open source, self-hosted alternative to public clouds, built for data ownership and privacy

From 0a798a7a69adf3ccad352644b01c97763567be70 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 27 Aug 2025 15:10:36 +0800 Subject: [PATCH 06/28] update go version to 1.24 (#4960) --- .circleci/config.yml | 2 +- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/goreleaser.yml | 2 +- dockerfiles/Dockerfile-for-frpc | 2 +- dockerfiles/Dockerfile-for-frps | 2 +- go.mod | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ede1f5fef64..017d75c9e73 100644 --- a/.circleci/config.yml +++ b/.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 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index d4faac70a7e..eb631c240b3 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.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 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index d55913fda6c..652d156ce59 100644 --- a/.github/workflows/goreleaser.yml +++ b/.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: | diff --git a/dockerfiles/Dockerfile-for-frpc b/dockerfiles/Dockerfile-for-frpc index d8d4437ad22..7d77a26de3d 100644 --- a/dockerfiles/Dockerfile-for-frpc +++ b/dockerfiles/Dockerfile-for-frpc @@ -1,4 +1,4 @@ -FROM golang:1.23 AS building +FROM golang:1.24 AS building COPY . /building WORKDIR /building diff --git a/dockerfiles/Dockerfile-for-frps b/dockerfiles/Dockerfile-for-frps index 00fb51a89d8..489ce0f5282 100644 --- a/dockerfiles/Dockerfile-for-frps +++ b/dockerfiles/Dockerfile-for-frps @@ -1,4 +1,4 @@ -FROM golang:1.23 AS building +FROM golang:1.24 AS building COPY . /building WORKDIR /building diff --git a/go.mod b/go.mod index 06bfb6ecc0e..af633af4135 100644 --- a/go.mod +++ b/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 From 7cfa546b55ee485240c3a713b01a4923ee6b714c Mon Sep 17 00:00:00 2001 From: Charlie Blevins Date: Mon, 22 Sep 2025 11:18:49 -0500 Subject: [PATCH 07/28] add proxy name label to the proxy_count prometheus metric (#4985) * add proxy name label to the proxy_count metric * undo label addition in favor of a new metric - this change should not break existing queries * also register this new metric * add type label to proxy_counts_detailed * Update pkg/metrics/prometheus/server.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/metrics/prometheus/server.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/pkg/metrics/prometheus/server.go b/pkg/metrics/prometheus/server.go index 56dea6e82fe..a99bb1d5efe 100644 --- a/pkg/metrics/prometheus/server.go +++ b/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) From abf4942e8a48dacf3689982d41c23a0bfc3470a9 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 25 Sep 2025 10:19:19 +0800 Subject: [PATCH 08/28] auth: enhance OIDC client with TLS and proxy configuration options (#4990) --- Release.md | 1 + client/service.go | 8 ++++- conf/frpc_full_example.toml | 14 ++++++++ pkg/auth/auth.go | 11 +++--- pkg/auth/oidc.go | 71 +++++++++++++++++++++++++++++++++++-- pkg/config/v1/client.go | 11 ++++++ 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/Release.md b/Release.md index e0f5084e9ee..742c9847aec 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,4 @@ ## Features * 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. diff --git a/client/service.go b/client/service.go index 9e1833b972c..f906e4d0a82 100644 --- a/client/service.go +++ b/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, diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index dfae95734d3..6b86907e459 100644 --- a/conf/frpc_full_example.toml +++ b/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 diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index ae706986708..b954fc80eaa 100644 --- a/pkg/auth/auth.go +++ b/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 { diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index 40ce060faa9..c5f636402c8 100644 --- a/pkg/auth/oidc.go +++ b/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) } diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index a830df994a5..c6cf97a69be 100644 --- a/pkg/config/v1/client.go +++ b/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 { From 6561107945b8bbfaccfa2dc0beab0444ce6a1afe Mon Sep 17 00:00:00 2001 From: juejinyuxitu Date: Thu, 25 Sep 2025 16:47:33 +0800 Subject: [PATCH 09/28] chore: fix struct field name in comment (#4993) Signed-off-by: juejinyuxitu --- pkg/config/v1/common.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index 38965a3cbcc..8989855485b 100644 --- a/pkg/config/v1/common.go +++ b/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"` From b642a6323cf504ed555cdb984cc1ce731cfba42f Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 25 Sep 2025 16:50:14 +0800 Subject: [PATCH 10/28] update sponsors info (#4997) --- README.md | 7 +++++++ README_zh.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index c1afcae0477..00a3498cb63 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ frp is an open source project with its ongoing development made possible entirel

Gold Sponsors

+

+ + Recall.ai - API for meeting recordings
+
+ 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. +
+

diff --git a/README_zh.md b/README_zh.md index c21be1c0356..ea63d7261cb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,6 +15,13 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

+

+ + Recall.ai - API for meeting recordings
+
+ 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. +
+

From b5e90c03a1d578b9cf5205cc619dd1570ef46a25 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 25 Sep 2025 20:11:17 +0800 Subject: [PATCH 11/28] bump version to v0.65.0 and update release notes (#4998) --- Release.md | 1 + pkg/util/version/version.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Release.md b/Release.md index 742c9847aec..2ea047fa5de 100644 --- a/Release.md +++ b/Release.md @@ -2,3 +2,4 @@ * 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. diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index c6497e145f6..0f4ec433898 100644 --- a/pkg/util/version/version.go +++ b/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 From e382676659cc8d5974ea2e3fa3c4fb94eda1fb76 Mon Sep 17 00:00:00 2001 From: fatedier Date: Fri, 26 Sep 2025 12:26:08 +0800 Subject: [PATCH 12/28] update README (#5001) --- README.md | 16 +++++++++------- README_zh.md | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 00a3498cb63..07b14473f9d 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,15 @@ frp is an open source project with its ongoing development made possible entirel

Gold Sponsors

-

- - Recall.ai - API for meeting recordings
-
- 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. -
-

+
+ +## Recall.ai - API for meeting recordings + +If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp), + +an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more. + +

diff --git a/README_zh.md b/README_zh.md index ea63d7261cb..174a648063d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,13 +15,15 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

-

- - Recall.ai - API for meeting recordings
-
- 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. -
-

+
+ +## Recall.ai - API for meeting recordings + +If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp), + +an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more. + +

From ee3cc4b14ea2f8c9581fa06b7151593b94f4ddcc Mon Sep 17 00:00:00 2001 From: Zachary Whaley Date: Thu, 16 Oct 2025 21:53:43 -0500 Subject: [PATCH 13/28] Fix CloseNotifyConn.Close function (#5022) The CloseNotifyConn.Close() function calls itself if the closeFlag is equal to 0 which would mean it would immediately swap the closeFlag value to 1 and never call closeFn. It is unclear what the intent of this call to cc.Close() was but I assume it was meant to be a call to close the Conn object instead. --- pkg/util/net/conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/util/net/conn.go b/pkg/util/net/conn.go index 20468f1b8c5..6946b1c2f14 100644 --- a/pkg/util/net/conn.go +++ b/pkg/util/net/conn.go @@ -149,7 +149,7 @@ func WrapCloseNotifyConn(c net.Conn, closeFn func()) net.Conn { func (cc *CloseNotifyConn) Close() (err error) { pflag := atomic.SwapInt32(&cc.closeFlag, 1) if pflag == 0 { - err = cc.Close() + err = cc.Conn.Close() if cc.closeFn != nil { cc.closeFn() } From 2def23bb0b506efd0ed66c8aaa73abe0d631d48f Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 28 Oct 2025 15:44:03 +0800 Subject: [PATCH 14/28] update sponsor (#5030) --- README.md | 9 +++++++++ README_zh.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index 07b14473f9d..fc822a0ea8c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ 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. +

+ + +
+ Requestly - Free & Open-Source alternative to Postman +
+ All-in-one platform to Test, Mock and Intercept APIs. +
+

diff --git a/README_zh.md b/README_zh.md index 174a648063d..d3ec8aafbe7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -24,6 +24,15 @@ 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. +

+ + +
+ Requestly - Free & Open-Source alternative to Postman +
+ All-in-one platform to Test, Mock and Intercept APIs. +
+

From 469097a54971a3f1026ceb32c440d618da950e2d Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 28 Oct 2025 16:08:29 +0800 Subject: [PATCH 15/28] update sponsor pic (#5031) --- doc/pic/sponsor_daytona.png | Bin 42315 -> 14455 bytes doc/pic/sponsor_lokal.png | Bin 56387 -> 0 bytes doc/pic/sponsor_workos.png | Bin 37517 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 doc/pic/sponsor_lokal.png delete mode 100644 doc/pic/sponsor_workos.png diff --git a/doc/pic/sponsor_daytona.png b/doc/pic/sponsor_daytona.png index a5271cc9502b575014fb52ba55cfa2ef0d41eb72..e36b6bba4aff4a5396ef46a09023e97b7e107673 100644 GIT binary patch literal 14455 zcmbVyWl$V2*Y2{oFYd576pBO9vb#kVDNd2%?k)vNSzvML;_mM51&S3dT4<31#i6*i z=vr-dpy|u zi_ko^6k&kcN&5YN88!`79r>rHr=z2zm6e~Pqhrl2E!jD_;o*_)9-eyohLTb;U{)?t zQqlkozySatj;16B)p@scxCB)Ono|#(hc0nayR?w$=YICs>@;d7a^_F}?NRVz

1d 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 K literal 42315 zcmeFZ1y_~f7B#BU4NB)`3z8B764DI{(j_6%CDKYt2#C_9h#)1f=@My4L8L(m1?ldP z=C18I=exh*-tjTU8Rr~D*zfbKm~*bV-j^EcN<{dy_?IqSB2rP7*Sd5Gv-HxX%Tc&j z;7@P~oMzy^F1u+d$zC4o;tqiSgZD()(CyMCN>=oLE~{v^Aqw_R%?YEaP zxqcS*LrhJMOz9vjF0#7j2>Es%{Pesw9?l<_LV9bVqgZ8hf}UkwWr`MSrmxE$DwREa zMTCWgDHHfhc&*1&^4rGPZ$pF8zelZ8wW)7K1>CGfc(v67n=Z~Lo`?VY+Pi-lTNe56 z>#R*$SswkBolA}T@;`q|j=@ZZ{)$a|gkbsiU4z<$A^*Nh-a8^N=HGWI-{&X2`tRG8 zIsW&52`7-^th|J6d;Kb(jjqT)j#_l?fjhz zV4|hvy!fc_ijqsexU#ysuiXfpQ}fR7I}%PlIZsKt%(SuHPqRF1^jshBPP*wZoTphl z904iFK4pPm$u$1te|B7E@l{bt$>wN?Qy29;U8>3N!OB# zngyJV=$kPMBjpz6y#M_!bYZHo_NI-kh>47hEGgL}kvfgRs2c45QS?AULZV7IK)$)$ zsvF7j;K76INWFZm9K#ChoV&@2p~I81P|4Vvpn7E*KNsj*8pulKHbhFQ#3B`ZeSJw0 z9;e?%2eOn*OibKdUwV3a&VNfR(8vgikH1FgkRJJau;Rw?Hf-GP0*XL(b37&*PMptA-AD-qFeF0fbQfvt3EQV{&c-zUl%w zh!Qb$optSne|X1h!}nm#P?wF{tnsw%<+X-}hAK@iyJclW!YcfzWF8Z72?-Bz<#>_y zrq9~i+Rys<$jDxzV}pL@Pjj$ogSSboyUGY&flmG{p94o075MC~wUoG4wVvw{@+Q@f zfB!1RwVT^p9-*(l6Elu3euB_4TdYDLT!@(W#d$(=OL%lMkPz&TErLukg)n`y2@|M%>FI z6SW=%FJ34-)(_D+*zJ?L*c-Z!?qV#=(5?oN0Rb0X@oWX&Y5anMj#G_iX%ap=GpqdV z7|cJ4AGJr|TBl3;sj8`|sjHu--r(ow=OAkQIml59hjMbT&ZU(de%o4oycIr$d>s0$ z0}zqyPt{(e9!?OrJbo+U@<>|xIZB9MNN9bs;iT4MRp-5iQaFz6-MifoqMj?m9L&s@ zMYmeRNH!-Md`=FWEG=2QPY>zo=;)}Z!qR8Ve6PL7Zr_Sr*k2pW=`J>@6HF(WXF>Pb zIC?lm8u4d4mWNx@SOk3LFJs(({4391ErNn8w6QQfJsovz zg?L-q*22OKJ~^bf?2}$dFsMyOt0hg065j5sKZ=__8a__wjiYkOhpiR8OJY$^5sVit`Y;V7^(DP3C@vo=*D(%#l_K#5?UV1iCp@VYrw; zWHQjxA8h<~u(8Qq2@&I>!pOJ>30SJ4t)hSpSV+V%^#m1rz4y!FM z2pqKHPl@<9zcK~*{@r*#^9VxCtk&JGS4ZXYn!8{qw1GzN{gw5JYFk2x?(&~=-;A(Q z*74F`@d~anGDhjPHikznO zaOmX4ynKmgeFkX=h4v3dc{*){Y`pXh=*3F$ zrBzi$I{9&Rb#>!vGY9Jv;aL%3w-pRzF}1e-=D!nmp1ITnXZV7Es(-CLIqKdEX@ly= zCWZumx3*$@$u;Cx!^3gu4{-zY=zjd&p8X2fXx~Tb!J~`Pfx@L>6C$Ui^tB_BZq-PH z4XMHh`Jfp1WU~Gl+_b*OOK6fsMpgL4#Jz8uk{4M_a6=#xh>3}p81U1kiwX-L|E_(} zo33o`p5nDOx((M%FXe9>c_*aZoMODvZYWRlx?`X03w$em!qr1~#w?uF-vurjm(-~I z#^6aDips}QMBS_%9SdhJn~-LHRDW;p9ITHZFLEj)1oAp+UR1n+}6a8^#O!a zE_XFjZI{Lx2ZwAt@zpB=$t^V<`6E0?%6ukVHno(w&CQaPnf{uKg9+zqr-_d%AQ?8ykLsY%k%7N}UWr3Qn_yB%d&{yDN+fw7 z=ETv)?>eKr_sCb2H)#c|Uk^S4#N@T{ONKi+K)`vj{^d2cN5Kvio)hhPl29Md%RIN2RW%eXDJiM7&k%uk9|&WSqJIDSg-ycv+9bw;fE8JU=3O3FnAI83S^LTH zEvlPSuh0Mv(8?$tNV6m(*G8}lEG@Hxx&X+0yq~@?JDXL7Z=GROWsl?_=fBhXoU6CD z*OtzxK%X*{m;u!>O;1n%y3`%ehm(#9$J(BGYpCDf-(ITDg3^f7m4XrgK)gA-j{P4l z>#?0tw7AT6WK7$YR16myRzUipTo}LPTMuTdK%wqeERsLnY9)c#I$BE$K>nkhoR}@w z*wh7`#syhfbG$Yu;Y{^CWRjASEWSp)7k}p36~_vFrby2!N6`QD5Heo%&i&tbA*(-2 zl%m!6Jg(dRZuGlTw|X-pBjb0ihtJ_wMmB|xAzXRv`(M9)-MZ-f`O^!4Y6P2-H~|5{ zkorjZ^N;&O_obL4*m**+pFm*j^?hQH@~?NEYD6Yiuw<`TgmrawDPP}R?E7h&i0QFD zPNBvSXeL8bg)t4W75g+N-}a!t6dk;SOu0cpK{3-({-=2jMlWqgOQ!)*qOZGd1c4+% zcQ^b%J{Fd2$WsO}cM;dQaMWE6&CHPK=)TV`vxI}8DW3x_8pS*khu6j`V%2lr=WbxO z3=a>hCUQ#npBik52W(^02DZ+$pl8()vP0eYwB>W`b8oT?L5S2asaC5G#P z71WrJA+?ww8((6}T3F0JfBqcUM*eoG#`0j!+1c5=1>KS|A{8K90*7`s`k`clD^D){ zEH)|FLneObZ91JwmDe-^!%6jdY^BKq4-u`$tT592oLQ9H}!(^l10~eY?+!^mC|Fgk>qgE0rNDswjA9+?b zM%*wGCPr%Ny!GR}%SS{?W=YPBS4l#ky+U0OzI97>>D#o;^;p?l_4g62YA$7C6*gH! zi}w=Sii~S^>NXp@*NsSHQvhoyzj&Z1Cnv`w{*=#ssb9au6tA})I@6n`q9UhbUK(5? zR~FKauV1f65}3|Q2OPu0(@gda0DIETRkzFaT#<7duk+$P-k@nrzvIR8sA$HnJAotX zuve?+==k{fY&bJhavb2XLtH*e6CRLJyo>~96x@Awni%kd$z z9g*)O{c2GJ6gJMz(Y<9ePEqO?a3+mVr}xZ|Vc%0luT9ANcy#vyjnXeN%K7~L!H)f2^;}1Lj(1~5O zw6xsa_dvRc<>4{@{Ctjvf+9zY6sYYFNIU_vSkfA0TL7*NVSARH~dqosfXxf_| zaQ=4MNCV9-OzYpAr)1E7qHB!4o8`_<7QzFS_nDU&zu{YGvRr=eZ$wdlhKnR_!>eT)o~~b3KKOOCwHY`1igI(WjaNC?h8grg1z>OZmSPBy z1MqUn^xbXgyr&8gls6*`SP3c*pRaQ}OqMGk)H22osYVpb+iCM%RyV7s{Of?&toe&M zj`Ag%V!sM8zC{%E6`P1w@bRzFZ{P4eI{q#XeJ*SP{DF^$SChH`a5y$LmUCokY)rHo z8^QdaLY{&#l!X(WUu;|xS7VtW`$Eif?X%lr@0xYHv~7%bAnCLv&=+Lz{SVPkcNcp0 z_Gv_(Jk-#L`J){A@#DuxS|M8CSV^t2><8y(C!q8=O*Kj&k+OUk2|OmWmp?lEEPiYA zqBG;^cWn1?z&F{7pU5=xwO9*Z40Z>@@or2t1%z%6kSU$2OW~%`A6%*3H<%})yUniF z3ypGn)sQB#u?C$H&c8pi^tQWk~~38bl* zcr-MU-9PTii=mn1Fd(FzippYO#ep1>w9RwjEVXFAE@;^S$ z)l)@c{V$OE^x{u7EM@oh8|v$SK>x75IRuK<-?g!8MVfQ9m`UOFwGhAGr2xm?cvL#m zRW_y&`dXi&TsZS1oE$)9zy+#d+I@ult}|^f2L=WrUPaOfU<+w_EJOh7Q*SPM{g#G~ z?q`91UteFzv)u)I-aQC(G^@eGBcoT>zOlO+J4_rhs-&zf;k_p<#tB3R72gWz8hGl# z!TN*d9Uzb4%;S|rT{1g=_m_cNrygGd>t1TbYb1HG%lh+5$oBZCZ z-m^T-tcBk91^x;GGVjqfeJ?^a@CsJ{tp4T*x75X{KzZ?CgD6xCc_pERl1o=EfRA7i zQ1KWaPB=I9_^y8RhCc|q$9i69w?LJeM}-?}N9J5gxxAUB*9tV^D+oD2sbX7f-^Sli zlYRaDG;3??2^$+*ti@PoXXnU>?pBFt1akx=xXJbai!i7bEH*twNw` z2JMZ)>{UMt>9@VjsQ|zUOH$BtqG$vZmkawXe2(|>Q6<@O&yM7jXw&K} zd^J`7EciV24;FqvK)}(1O`yeq=eS%&uiU}9mie<-Q{jSwj4aUfv+`yQ&_Iav-W?^t zb^1o>_!OMtPq(CVPN*s5+)@N=q99s8wy5)1O`=W4Aqv5m2KMl?&@ijxwBi~C#YmJQXtr`N{sy&vD>Tp8%yuS@wr9L&u{nx}wy}<$9yJu12xyE!*Y9q{)&p2`9cJAR#Ph_^yBB$g@+zl(2YRC$_)KGoD&w3zbA{IvJDbe|JLw>kcWsh zs7DL>rS^}Y)DsEdQwXF71w&dg&IxKR{fp7bUcTrSNGW0@N-B zP1F$hwY9bQQGeoo; zkL}7O??6`7g#NswWrRQC76;Mk(mo^L-e%tJbR-Gl4#zozKFCARs`LLk8en%O5m0iI z()es0@2}ePD)5m$TIKK$*Vlz?hLj-ZuroIgxLIhenFF;d|C$Hs^TA=W< zGqt!#xXvl>F$@JP&&;@jq{lsFo6dHHIqqdx*jH#r+ciLjpqz6WRoWI0%kwi3hAb(X z)p@FATS6m*`(-|PEj^0TeW$%0gfyy~MhU-# zCbP3yEZaesz;Z)l7w=lM9MV*5hndbOC@5$Q%Jx*F--u}pT=P(F^x>|*s`)xlX5iU3 zv_9(<8HHnBoeY0xicWS}S$Wl&(ptC0sQP6TE$;jG@5#DeM@Ehm+UgwuD|zzg%bPdU zIjo?IslOLXJi5wtCByNLYL4)Pu)~ivU{m(`LKRS@KG_W^E>GD4zYP}%^{$q?rpW{B z_nR{eDVk0;>bN(_6!@gCbopLO*uar>S3>J8;mC`RKt+Md=+1hqjDHLc>>n7|-kI-4 z+W54Uf(rEQ*o}jO1K7Sd$k;Okw&i7i{Cs^`zF%7U42LErE^h3?@Aw7uLT?`*8~ra+ zQvtwIBW$!#Ivj;rsOSpoEx_+kRrQzWWmpzLNU|L?p(G6HbOy~w&xeDCg@xsFZEXZ0 zf7tWL_`boMyE#Vg*wDw@Hz=RQX?Xh7_=b4vVq}2!Iyk_sOF>DwFg@+EIVq9D3i98R zk3GI!3`8CP-@^0pQ)1TmlnhuWoM_&e7?AvFl+qC2+yENyP`CrX zUG;?MSi{#THa>zWfYrAIm5ZDxoEAU=uNfu8cK4Bm-SCkIo_(7>W``pw)yz_aI!-G3 z@4OGUXAMmidsWKH%9Ig6F7%BHp=(3)VGy#{@x;1%?HZ&Ed9+G+?<%lUh}8^hlcCx_ z6kK{b$9o9mH_-9NtDQ>@f(Sz}%>QouhIvMDSJ0r(-jXK7f;^*K`%6|4kt1+%|4S$tF=zc>KogqEfjbEm&Hytg!6R_!3_Q_49?cZD*ExVqQGxKQa^Tpw8 zG-Q>ohBZ)Z`1o4&8}r@CFe)pf-A~xw-IY_;_8vn)*TBV~HH{!A3^A$m{9R@d7TO&r zC(=x)Fn%A}1t`uLBL~+f-wcs-Du&GC{RR@KU>5vw*?5OPP7EdpAu%v z=%_(^I|#NrF!rE-BdX1XjKjxZ)|4azMOCtLux`zAi{K6)Unziv@{1KmMCxrPTCu%| z3vMSnI~r0_L=$<;syiQPrVY*2tH>R#AE88aFtE^zOD?p%^7}6&SWW9Gyt6lLY+{nA z9;CiL-5NHqsNIx_qK0-1RTQgFbE$`m7+dtleH>YkMgd~0(BWDID`pCFpCIUX<(nT1 z2?;g85dl(4FkPj@3n{f7Kr%5gwG-uAcE#a(*t6tuXn#BezWV#qO`}SYXFJ&_fxdfu zq{ydhlMN404#w~^DV}{GHvcj)@qkW~vjJGy{^pdKC3hnXEWQops|NC&*`^N4YE0F6 zl?*ZR~E9JB{HU%R}oM*pZEDc;|W6NFJ7>Z*6H|l=KZx6PCRzE87AnUFwWrU7M1C0!Nl6L4SOiiZCP(`*!&Z-}9(7 zKzA{iaP6*gNdhk&Sjx7BnTb531{bJ+h?eNG0XoyhE z=?PTqBm>T02e>GIySSJ2fryHI>%JM;*Yd&nTacxi=o@bXcm$aqIknBH_F`%eG#&~X zOmQ~mED$1fmd&yRH+kG{-492KcdJ8 zL-=fZ=@WELPm1roy@j5^aLB6x3V`;a+NuKdy)3KUrr`NQgggNuF>%t0VIwGzJ?TQ$0`P8z7e=;W~bDl7o3{=k2LrbG-ik$A^RM<)T5n@`LjhA95vkeL&~f$ka5RRPCc9eW)f1p}hqjc;0y9?gJU z1&X~w?X0M@IBCo2(JsVf$|ld-%VyO=|EH%-zHpp7Pm?v;#&hBJ_jEwxqD zccjB9=z-a)dua`;UxB!K2A(8M{|X3Jvg?Y#0t>{Lyp?F_?ZtG1M$=Q0KW0a`CGKN3b)%u(AA}JY0WEqsDT(!uUrmgnUZ-N4?Wp zO%`*gBB03RxZPD#yJ=WH2aTSb3a;mLy?V;1`teOIIkKQY(vxjag5v1AmFr>lMD>y% z+M_s()I<5zt1b_0fc~r6hxY=XqGcnAXp@H#7xdX@(=Avx-(#~9hS&z$9@&zhB*lt# z|MxtSA#Y&3n9)%sbmvYTT(+U1p9 zXcQ3}x3RGSVoUdFc@M;Z(`cy-f$mVaYG6OY>B|==fb5Tz0pvsB&^r!dZEhxE@%7eF zt1xjJ7Z{)Q3CAj$nE>BsEs5BCe*KZw7D{%Yl)LT>hW;*%PD{sdGYpQvaJsJSFe zk~$h4qoYrO_e7Yfg}7^$TXsR!=P;#r-3JN9g&JS)K1A5*!aHYDT-Kv*b!bar#0;Qx zf2?FN+5ryl_D63WkfKS0x9*(dlf_H9mRZnPN2#}kb_ozKA&th7$5*MU%(VuQbq5_?c_&5C%SDqAA)y{{9>j8 zG_PE}W;(-CHITeA_o{Q}{;^JTFk28aNpg4H90D!DwhtN1t_NZ~@NdyANjB(*a4E0$ zOu-AV@aNA%xSh6-az6&=rP1=U%dBF4!3`pK0&z5}kh-g^Z2PnL{U2)gpYsEoF6ePdB$aE;A5XkIc>aVPNmnL}aG^^U5^bGu(d3o@>&*&@y_>2Dl!mdiNZv zhQetBNt-`_=mVOL#=nbc3nv>Rs*FiU(1FQmPM;PnEiI6~VmP=Nk88}-xWL3;d9wTX zs>_K{;{pNTF|E%_4i+*CFr;psvxEFhxba0AMTPy=QwAP*$bD$k-j{cv^kMPG0q6 zu6wp4jnEZlZqxdw-{0^@*qD}Es)TfKJ8Z13-hoWb@qnBh18G?o^#=D}d2m`2nZxf% z&I=CII4!C<;wA;@@q-CoBZ5=ls5$R2=KD_W=(ws`p2HU?sWwnjm$7(xOF)h-NXdHr z`n6&d{mbH)=)M4f>HND^H}V}dP!0KxpFd)Xv08v)fO<|4McV`u7LWzHB{u=+fQ2i9 z_oJ1yt!+4Q4&b6&iSOT=z}Tv&s0apg{jD9z$^=;FR&Nv~U#^)+JMTaU0teBlwcWqM zd|cIueLqcHQv-XSUgXIiP|ZHIN;Hhaw?{WFQrE_ z?-xlw0LdjKC4*Cl{}!>>Mp9qiN7zd|gw%Ww;ew1%iQb_k zOpVq(wC=jP?@3q7J=cw3dI%wwp*q3I!XodVj5axZavVdR-?_q|1n#YD1VAb1PGB_1 zGqk|K$gYAbs9@PK+_7$JQGpb1UXpKzG@AI$^d&8g-13!@TSSCE(^Ugb03 zl96!g7Z>g!r_nYpFsNPJ;NZxYe|dF@hU@(WOiU{G9zST74zH3!$jc7-zO=;y9zsM+ ztY>s}fPjmq%MTS{-Vn2wkoL6{C-#yqLzspQ#x!`E0LIG`TfT)#dJ`}gik~m`3vkmO zh+i{w{3eeS=m)?Q7p1QILHhDud~dpxGYo|zjq+_^Vz5Nd#Xv7!0afSv6jI$bOAj7v zKrLWzDfXe?#$H0*ss7FlBZX0K-OqM)2Sw;|OsGAB@ZZ zDRJ@y6YU&sTO3i(16;dSGyfR`Q#SQfjVx6pCI++TG1MiXiN!}(2UM0OsyE?Y6O*|0 zi|Ms&!Tca-mN#=eIzApz`5i_&$jje46u#pGk~)#x)OaGJuCC6^%={LITz3Ls23$9~ zhI7Giq#?L%W_dY(rAo-M1=)EG%HRM>wd2T0#FD*nRmsmc{RHgFDIT=nC99-QRnykF&1t zx-JRAb>3aJ$V|bM{az1Z*Wb-4eT|OJPIPubooQZ#PL=?`ZBFz1RKSG^s{7VkJZxD( z$GtyRo7w}vpo{Xl4m}0qqDBKS_q*l&YY)GgD}|>vYB0?`8xx2;LXZ;E-FAe^fV|tx zfn}$0kj#+(NAL~^Mo{Tbq0%ceG5@S`_(|MUqnq+=V1Vp0v`vG%RdI5^6Np11-Ka8& zYahdhkiP}+P{WQaJO%KTVqENLP^daq3;b^+FZ1_s@{J!w6VN;@(_=3~;w7i17P_Nk zg0YKC8;r7ytjhsoT*km=zxh!G5dwrLGrHh2VeKc7Fa?B#l~fY+#rlAantpyF({LP4 zN*Ds_6M&Nfv!+HCg8l7n!_QAL1|Qk>RoL|D&0=FFwsmws+uMPep6cD1PJ9A_coWv{ z)j-nKR~4C^5BFd`!T^93Gj(*xb9j;KUPuU}9h`-iluKN|MuOS7L}V*U;@*>4FP<$d6Z zMe-&IDib&ZEYPkCHI#KWU|F!E#wGKDYFl{dZ*=s^vy3}-H3wKw{)6gb?|m}pZDens zRH_Q$&%lt}24s@TH=%WM{lHmB*X2_xv$AGk7{+mu`V(Us#AaxH@+;0ZHdJ8pJv!S0 z*aq`nYd}pU5UhLppm+>@R6`31pQ(eiVa5+ei8lgOw=`Hu&!MiBKQI_jP&|ZTgr=R0 zLDf}O)x)DB6>2+oHqwsv_6)D^IbH)vcJ|Kh?ry~TiUa71iMI*!bn*wGzkyFg2xWUC zD0x=!J_0npSerjB$-wE zt~a9XWhRs%5fiSoen?iZppNdR=n2Mypb_CH@m~xTPFM?e(oNBvF#l4}@pM#FQoRE; zZq#{b62^9bcrb@b(5k(LOXksLRy_o~2`FDzrpeQ%PvPVkrTh!sIh|gw9zqMa?KF;V zK6WZ98=5*6rJLLty{b&UBdNezqkZA}wHlUjF1LawG20RxOr^7x$2>DoOENMd+)YRy z%`3T^l7xhhLFlNmal`y6IXQW*wWIOrb|z3((-qm7-o>6a0rml+F~Ohtzb_1 zvJ=ylLeD@_-DbrL0Vx$c(%^$0vAppFtg0%Z&osFC_(*+JmmMr1Zr zLr)3@O)US*CzdX<13W?!6DevDS`xw8oJ1JRhsVd*n3(Jg)zZbl@zE`3PGP;46c@%m zTsnD(QRk#F5NrUFuL%UXfR9tbyKm`9)S9lY%#R88j4R_0z5o=n3XhPxm<&BQDH9 z!EZENY-0`eVAMOP#5$If)c^dHrc1`suOhf~Zf-8yTu8pqpzKNkJ=Q?q~ zb`HkB!##2M_uTL{dGN_G){9;!c{T6}NVm}LDQWW59s`HCo5US^K~)8qD7o`y*3t*C zuV}PPx;=dO2Xrk=-peW?y@7{5+P54g>yPraa|KI+ao5Ix-@;@?(XI=y^`lwkD&w_; zy_@ovLjq|8tn(W9DW2}ksX!uO(tQH&pgQr+#lpTc`_pcA^+WcL9dg6?z87bIXGmE8 z*H(`D(TK^!WaJI$8Qz>2dJ!VKqsttG;Uy8yo1q(6VpR(Z3jyaxxsj1-3sg@nnxF?s!21%D z=q0=>J8gG@U=YrAD8iqj-IF3zVp4Zwn#s@-G_Dr{?;cL`TXZ0_yDr}S#9;0X6XA;B zzOvs0h2mKE)LDcY<}>X~3h3tZPpo8iw}nQSwA*b01p^$+6v=O;0wi6t)=+3B0zBUo za0#Im3wmbnd?GEi2KJ5RCV6L%U!Pq-Q&LUNM1cibnHRQPmiP%Kf3b%7FG8db27rk9 z4(hZ7!1v|KrL*fE;inYR@@T~W_&4XR%;&EU6lJ5Mq7-w*!BlG3MepOSf&CThR+Dlm zFgqD@_kLEx$FPh7l@SVx0zMeqOQATSW5{yg7N7>DyVvU73IE%YPn)1IWvaq7Ur%f; z#LS(@N8TGwFqWt5`T*s8$9xs+|6r3Bwf;_>a}CA3h%pUU0ZqRQ#@3Do+l&>)3k1gD zmf$o10D1LoJ!AlY;M>B&`W`jQ)54liQGMrpLyOs%n`aC|uy2oSV+{ zSPH=Me_iGbypeI-RCjP)OvGoIH8Rq)tXcp+193KBN$&;>49g*cXRP1q8F9gzI|)Yd zSS}AmMMaT-2yTTk=}}~o&@M95^T7e}2>mmtuRQ}JBF!O5fGk8@)NYW)T|$B9d3J7&G&K#4A@I@I^_Psh(CKqbY9Eab zh*V-tjX$!m5C9)4=$xwMwz^Ce^2j1s5`bwAO0NiYAPX~nfu~y!=@&Rn{`Q{kOu*B| zI-^0Jg^Fw%V6J)5hRB1l#v!V#us>Er}G3N7UqaRx*dY1wW;A{>EWqL~grfBWzJH?Rz_ z$>H|^fL`Q*-6H^<1U#MJzk_CLEy4)$R$IYe7*#lWruvj!mhwGBNM^wP$bS=JSqIUk z0t5ToOBNiB=fSJbxWKS|nBi3KcS1=t z3$P6)j)$g^tCP^-`+N^N;e100X)*`tN=wJeV)?l+cYs>{d6?kjwuU>#WLCObHs%uA z9Qe{C+9^sp+$l;dt%8-nB4U6Jf|DP=i9Xut)R5D_g#P}st1 zPNX#Z;aA+xK7WgEcf{z}gAW%(2?}2f6ul>AT!v#)r)Ok+By(|q>UFM{mA4`NxG%Jx zAM{t+X(#erwbsWmoapTDwd%jE)4=kEaVNW>!7r3|R|e6h-ppUnA!`-jdh0aVmpgNB z!Wh-Wk5)zg4SHh08Vl9#6R^)S>+M~_<7;qKO3}OE3R1oE;)s`Y;SG>`E`|q%psS42 znHzJ!Zdy*T4U`|4aFzZ*>Z3Ip*oEO5an(hho6_w2Znsl`KOFA9ZzmTF7kk^=1B01D z=z$6__3u-Q!P4PIHJ?9+HyJ7!i3rRJ7Vq`{9|@Wn79>FXqL%a(xs`zu$Oj7U>nna- zr)tLEBAv&$MzzLq^de}D5JWSeIQCP0`JccrP-0Lv1GP*2F1WKgv}~ zmzbdDQTRmnd@M}Zi9?nIo<4g9U0dbu-JkN%o9=_9VP3ls7&=(|U>>DCF0+W0$f12h zP|zLr)S8={BY4;Ths}>Pig9GOrz-@cJMI7s0JkALcER3Sn{-|%=z=iP(#@WO%%u~u z&sNO-DG!;Z*$Ox({B;PA6GGf`?G0?8bj~-UrC-%1B0&7Y*<>h0`Yf?$I#1#6m+XJ`nJj225WetlzsV+Lfg z2UTR7`PMD1kuK17(5@_S7eQ?TXE(>qKIyzT806!?9*XSpyk zbhvNcBr5<=WaZ>kbZ`&z>--R==Nb4cPQfofHTUG-oCT&9iIj2y!ra{7dwbP)S?xEV zluh}ctV6HPd0+Gpkzfe3pj1&emXctEfItNR;>^qJeu&#sn6h9yRRfQBrQfq3ot?HI z1i(%2IHT|Q--p5;`~!x?Vd~zeAY=Ob`!6?{BZg!0^YbezD(dR)94Dm6%hZ4jkP&u# zyzkIslV}J+V6NJ`r0MBA&@Qc(`ZFTf*q{b+Yqj+!};;&cj+@kTDic1`Iy(N5zP+KZW!e&u#g8& zt2LXr1WD8PE2D2H5WUO+P*k)D{X3IyKKKOv4{q)82#Uh{yDIOyJQhhwbWL3%QOmN0!!pOu0NK zdP5mF_1m{^?KFA{GwS9S7CgZ|lfzo(zShqj7IbeD@G8{5JDzJAV$!cZqrKVI(bW9N z-C1m)|48Axus|^0(V>VsCe`eP!iS9fz&i*vAtr`Y_bZL??c0jEUneH+>_+dnBYXjt z0F@_A@5f2JAQ;t1A*_CH3X*4GVPQWr+I5X9pJvzpArMBcFhkDo&<^5kUNHtL0E^hL zPU;6G4Y00mgBXuHvjf_JLdO*>6RA&J;&O|6B5W_^Bw*+e=jBVJMh!~ zUCJX}^t1t!Q7tJ)!qy0~4BgkEN*n>E0L+qjL&=R(GZXUw%SZ>bqpy*4xd$I;4cuXU z4~Q6<#(*p=OrV#8_b2a$Xk%t#aq6LT^5G<*l~=-y00x`(r#ToWUN8~>A{D_@kYu8q z3O4?vv_=^AbU|$zcubMdT->%H%~@bjmf0y@riJ?!fEOSi4=$6f4n^Nvdrkj2iSL(LDpx+q49)YrcSKw%Ct66kvR{QX^b zpb4wgUCh-V!q5gE_k^shtO11%oH;q)U5=hbL3WP2!^?eLONmIb46hfAmautV`f-T~ zpf#R}CCD^;4TM8;Kg1q2l=8p`7-olxO-$+WadD-Jhl~!9BQjHD`%2Y}1rNarRqP8( zuQ4WMwwg!A`JUFWStEZ2J&H$q_+sW2uj>cZlrKpE5Ph3q)!v$x8&f~pnhsvOf3=+8 zaMWe=Iwd8gGK2CJ@QdZ^m!t&;V;GSiP^H9&P$g;7R5}Uz$jcAoFlo@VQTV>}j7!1Z zkF9c>6}08FT@3#S$D&ZA(Fz$go2#YE!PMH(7OEgg2;s3ahbQ^ap}Uu_Tu!Sozbtls z@tPz0bSyUAu3uXgK(8naLG=TGGxD_09I6SZB=OUi6Zdw!5!v;Rz~=?kfbvLU6jWhI z(*^0oN}2OTys-d+Q!XzDcuRs<5ZW+a21N;25O_|ENATzbZA;;O2<^hz{G?jMFv&cJ z4K#2Mpek9)ucdfHcDa7ggUxxgwK!ad5|luQ@}95+Oali5Ce?L?g6+U^v(SCXW9vJSVb#o z|NDZ5>>w~BIR5&q8JV#Nl2j9Hqc`in zK!X*UL`Z=U3T5hLQyV;NC8wlhBlshb`ZXY_fiETRgqMmHiPS=GBXcAtBn(3iK)250 zkDG*rF*01;lo0q=_(X$!efP61!5jnZ`{VjCDSS30`uurdMGC+L+UsY>3H!omhZ{&A zD4t?Khsf*C1xVv)VHa05ndheKv;M)s9jJ$pT%sCW@KJxYz(1WUV*FXKAPs0PB=4xVG z+^vUSF2(-coERU^>;qDhHyPA#CFQ-x-m8w!ct>6u(t8;IBGlAqspI~gvKkvvPbzTN8{HhkjZ0WV>OBOx!E#`z~qqU zH{LcU;z7=#B|mPY!p_MFv-!5+VQyw-*z)@ZN=eT;ZR|!%S-W-yp^V`7N zHlS82>&?=K&_4x%5o!>~N|%{P-RGrl2?$&qua>+Mw0pfbSZ1LF;MTa%CM`ZDrqu5w z3VvF~5DlY0F9!$z+5MY0i|82`-U&I3Lj!@)-hv)0M@2$H0;t+x82Io|X137(ygDNz z_JxAxg9j6;iEG`7Tn`$&iEI{c9BnQcfG7OE)LF>A+{roE^cC@;m-JPJ$M2l*=>*Ve znGQ|n$?2hs(DB~{wm%=DUcVN1o$CboVgN9WE*ot7+rw(|>(_+yCb;fYu_OcaAZPPP zri1qEI9ARI<9q4LtpSVi5w#iXy7^iPBSvJgNqnRW4*-z@hss{quK9i9c{*Od&G;3k{CNmvHjU7rx<;cxW_A9MEy^_u(0L=itnDZntkf|0DeuF zT-%P7=g1f#94G7B-wDTJ+XX%WH5L5%Nu|BxNvWygPyT#>4bIaOh0BF?gZU|fE~a3V zOyo?gGODiM2xAK6eL#7*d2#OCO3AKq$&mH}>D$}@$%jjt+z)amLJ^5_#{2l!Or~&dvqUZge0UpDHX;`FH?(hCeI+Aa?$33y>hMT)6_TZ?cT}ieWA*8}x1>0M5!OhBDNuDIRuQ z?zP z1Q)!PKdl*rf~w^9OKm{!8PJ}pupz-PZw!%h1ue_A-7c{7RcK)U=qR<)@nTHo+P6mzR+xP5kU8h|@ZF z7Ye*3s$gv)+;=w;*XSf5#S4?|W2UQQ-zF zVr%N9L0AOvvZakQ$H6+tTl5p2yHAR?Rq1|57XbYX)62i*FvnhLP4 zifY?9b&}%lPEp7XUIP&ZP~bamQXr#F@OlJPFS8$_vXq^YY`YW+leu}dl~e(Mg>r{oa__n1n^ev!EfgZUIRj^Frkw z`b}amx-$Qb#rAbO5&B|Gj0Q`j(d@f32B!4qL?^Heh9;jikTk{9TXCb>z;AzoCJ34q zRIc1<2W%k5&lc4U#=BUmEW5No?9dRyLqbh_U~KFdi@agL(MHm|eOJS6Y$+wLChFJw z@K4(?t*)foS_AaL#q)X%w4kGv5eJLepwUD$Stnc#V+rkeek1V3qbYOAl%_XIpjQ{X zeD$guuI=JXcc(fxcMe>VJAjw)mhhY5_xk=5NdYL)3N0Q{9IBhsYsu?3KM&S;@>^h3sTz?>)1} z!7(x-nc*ONB_gsVl06z^WFu&Vn6Ob25iS53VnTa=q1CI? zuamw;!GrpNJCu`^b1^FBF`}fTWc=t6MyV_yl)#acp8jbxYBS+tu}L=AomAK7R_HOx zyk{6wSO^blH{MV#l03W{$5?G@U5oLJvBs8dS0xM(fHFd}R5P#4Gw^^K@i&bv+#O(? zx!`}fa^G^9KfSo{1ONv*2T%?4&*ypI#z}}xczAh%11xQ zpPc&Ci+;FCoyPdI3nJR6;gTD2U9aNC<}pBiRhSy*H>f;>QD@|YJTDStPaq6iqQVE0 zp_Jpq$9-JNTV~$dKdzkqP!hF=R$;?(8cH4HlEKZ~3wY2#Kp;q^pwR1d0hTf*gP2Rqw&vYa6F^nL@MTFJFVB=u7hH zz^&B=eO~Af|K#K0-kUgAo~U4bcZ7G#P=k-Q{TyTj-y57_%%a-cFTkBuE2#Yn)L4LT z?5U<&3;|3CoxPHc>r4{`$*z{D6l<2{-*tU(zyj_0=aLI{V0_p}pWFs;TO6zQfi0DS z0;?XyCRX2ezT6cGXM)~|YbbD4V!64w?LW09vt)&?*#fsa-hu>xPL1#6cSikE-mb$i zytrmR`|fE=;K^V%i-5om^u}v*k7d4n`Eq!8$hFxG%7GIwX^G+N@DhD?KHOncG*F=n z7vSOojg(Ucv#1bJIw%P~wyAs)2dM+_lPL|>4?m4^U;2=TFI={zI?Bb`L__#96zl#) z=bslUM?7{Tv2r7EN}$KFa^pH6ZPCW6^V`U>=R<#>)Tl%&Drsz(Ch*f1S}L{s`8|H! zY||XUvHShsKdEDPi|wtAjY09%*-Y{A*ZP3Rys_)>15hw#y=#-{s*jJ)CcGdak#n5(dJI>lqz3KUoe~h3l7ss9k_@eHycY;p z1~Atp_oCWj9YaIAP`emC7~Hd^14TFVz4x^RN5I~^LO^k8Yc@6Z^$U>f%ix zqUqt}{~Efk@c8goU2gMQ(9|W|yfR*Lb_`AQt`@wkNgpDUk`oyiw$`@oJ!3!ocDJq- zg||`KlMTQj-O?bBXl&e^nVEYX8{N0_|9(4`IPOi7*$TNh6R}?cBzVz=cgynY>*Y1w zl$4krR@oPQk&$H%z=y(I)nEsgfeduEaWV0BSXj|Q9emwp1!sjy1vL4Y1!SEQ@q8Y~ z8Tef?=S_a_szb}PuvGd3*y7oy-Rwm1D2)!&nRmsC*Af$d6>wTyu`ndn(P3vn6!edJ z5Zw?9Q}G30Wi!2zi*OLP?f3FByJ*jAHa_Q{@QPVfX&a%Cmr;|4!CwHYI@q48sdm;2 zn-3Ru0R#?Xe0#AO&MU#}DnK95f(9J!grAbVyu8$*KfP3H))nTp8S3Z#64JYsBgukC zB2?g~2Z5we?SY}{V2w;s@hONZEC<3YhM~YqRF9mGCxOs}di{Dk3gzX=qlH1yIpQbL z9gnR0k>t#`tkE~cL1D@ew9@kQw)kQ^@iJp3{#ym`(`lzpN*YV1sECpLRuVb%T4!IM zT|%6{zHMy9?>-PmSJcZ2N=XHQSP2kjiRsT7f~+PET3TA9W}**r#2k9i3qT4`bVtH3 zn=9`1!70~be{B?zIC6Tj6op;$8=Eh707YULub{ z;8iAJyuMBKW3xTS7S$hJS;Z<3ATG?Z#p_ zd9#>=)BWMF>Q@>ckBd@n*W|?vN{hy+YJsp~(is(BLjSzs6hYjw4 zz{2~j0?mzcuH)WPM_5?MqFs|+=`EU?j@w=1-Lr8?Q2p?Uh+e;&w1bDh%1S(tREk7@ z$j_gb^mF|^!y&S8xJ%~!gIrcLfm#q-5H+MWf&sM=uK7#U0-Eib{0Ojx&}I+3SLV>j zngMa?r*34~{2R0<-yNn=oqAh-bRJM94xwOS6@cuAU13Xl6O7%1_lk;eK%#74Fhc0U zrLrY(YuN?Sb@lq)RO88(=4K@om5M83o0IHjQxIYGQ1v7ErrXGAetgd`Viu@VyUaIg z@l#!q;$ABuN5w1x?xxgSk1`lT4&{7rDE?@A)N{sB{A@1rx&t_ievR2I@yX$1E7lc< z<9r|xy&nF$YOm-)UV%pT6exgRmoI^r1d+lmgDH~@?!2m*N$UKmKIqt?VY_|KS^O$Y z(x%O;4Euy#e&h{6?u?Wg3}3OrO)WOt?*v#N+{;LMu)Es|3Ku}t4L*^{1ojxDcq7q& zNNosxVZ0mEq%TN3hvoIcoyzAjR|s5`-Zza;@C#XGD~N9#PB&)Se*Q7F#Vw!$Q3_Ms zB^~NWI4oC&!D6@dH<9>!(-FpQKwgPz#M^OhZLHocpf>HyIutX-{137qf zgNHK^wf-RUt;=CqSe^Fhj5u1f6T62F;!;5II2I%)h@J^Wcct{grKe7d?s4Rd2X&W?TT>m%`sOI1?@t*(km3C>W_(FFox;31m&u3IuFV;LK6 z;Fb8p;UWTNMJ0bj$Z4PxkSNWVxN}_2sme?i(l%vngrC+8kW8;G>8f06Sb$o{K`yuv zJL?@oBWV>B;^O1U+{%`>FH;cAjF0D`i4pT&LoVzLyr>ClGRfASN`NdjfTbUuCMdVS zZFX_C9lFSR!+P`EZBPbjIOe`}n!c$)8k7Ar(F}0mb$m~wO$fGHUAkO zf`pf;D4HAj&+_(_1yKBh#=v{&qs1=C+a%+WMn_bv4wpOzg+eh+q?$u+v}juYbKV!d zDySjwBTjOY;!J`}q?axSU_D6W(|PkGa7&37LBaswR_{mo+7fL1~PaI>2Mmf z0v0m>%9Ho`=)$?P4^K027sHYR3@f_n5|#lDyy|Nva~#vfQuP%sU+|}{e$K8CXTvQg z8ZCL1_uf8wYfV|fug{Ov~tZ{9~C&$ zBsjPMPN)XJni@)7sFl|g%;$*gAFthF4BEk>{oWmu1d`=fvq%6o?Mr_TJeT|nMLWhk zA^{Tsn`C^}$?syMN*|D~0O^F^a`axOFL6xXs{0q(ZF8=@9q?BC?^oN#HpKqk3+gT6 z`m^q6y6x60^Y*Zgj>CtlwQR}KaIGHx zD#=sN(u^4hIug>Xsy?K=?LN=6=|t|LFXA+%uIbxbuTUfBlCR1_EIuQ>b)V2O5=b&u zw%VK{?AU9Z>;ZMPZ^R}8x0$^Ih?k#WEOl%N$Yt|#r32H&IP8W@rfd2PMKi!k;-o^P zeTD0}z0Vr=&ZBPzcz>?tI#5P4YtaZ#>$Yk4qrQh4jV^XWvG+ zks4gSPZb6Dr_$Pi)T=_96_6#zPP`6#mX+^s9K5WTZw_t-2p0djeXcpVkmb(z1?{k^}PK#uDk0H#5!Lz`c|sNfQa72dl>=^cI|Cw5IO zXl0OQ20ejtKLT=2pamIm8TMSNfHm+HDX0)H3OzGiG^INL%d&ig*fnXKm#t+GsKE41 z!MQ{_$pE?X=>=owKFBT>bWKeN0PNApZQNgnMjU!OFgf0`Vr?p}GB%gQR_^(;+vf^$ z)Ts}SO~G#42mWcm>DCET-YK-|y9L2UU92A2k6v3xy}s0v0wjd9>2a_juDTEBhrcuI zeT2V);bYN99$l!KnCoE*c}!7ADACv9J{~b+6}VsrF+h1F;#l)F5;#NU5MYojG2dshmT=CK?BJP(h+d= zfOpsy-s{KzEjIwp4>( zLT8Krsb8nLNlZcIw#zqY*B>l6jTFIDe}l5|vK35X>>J>)IC%N7hy>MigO3NbGc2P5 z$L)+Y%FdbH8B{pWKC$3{ zknI`)0lCai9+(Ct0`?K>kQ`#U^9d?v_)8R-994I)8Zr7+{CJi|El5C#655Zi;>EmI zWY?=SEVJky9kPh;H@gK zLY5?wO1w9eO1#`vS!dE6|9D}NZF5Zxd2}PZ!ta+qs{a6arDn~0oc7|N{{∈g!Z5 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 diff --git a/doc/pic/sponsor_lokal.png b/doc/pic/sponsor_lokal.png deleted file mode 100644 index 82386356fca673b930634f86435841177e481d91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56387 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 BFwy`3 diff --git a/doc/pic/sponsor_workos.png b/doc/pic/sponsor_workos.png deleted file mode 100644 index 5bc5e627d33020a565aad61ad496dbbf0d8bbf38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37517 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 Date: Tue, 28 Oct 2025 17:37:18 +0800 Subject: [PATCH 16/28] https: add load balancing group support (#5032) --- README.md | 2 +- README_zh.md | 2 +- Release.md | 4 +- server/controller/resource.go | 3 + server/group/https.go | 197 ++++++++++++++++++++++++++++++++++ server/proxy/https.go | 41 ++++--- server/service.go | 3 + test/e2e/framework/process.go | 4 +- test/e2e/v1/features/group.go | 76 +++++++++++++ 9 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 server/group/https.go diff --git a/README.md b/README.md index fc822a0ea8c..299f9a3ccdc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and

- +
Requestly - Free & Open-Source alternative to Postman diff --git a/README_zh.md b/README_zh.md index d3ec8aafbe7..82ac8a5004e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -25,7 +25,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and

- +
Requestly - Free & Open-Source alternative to Postman diff --git a/Release.md b/Release.md index 2ea047fa5de..e237538b4ad 100644 --- a/Release.md +++ b/Release.md @@ -1,5 +1,3 @@ ## Features -* 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. +* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities. diff --git a/server/controller/resource.go b/server/controller/resource.go index 9d14b18dfbe..717c961676b 100644 --- a/server/controller/resource.go +++ b/server/controller/resource.go @@ -35,6 +35,9 @@ type ResourceController struct { // HTTP Group Controller HTTPGroupCtl *group.HTTPGroupController + // HTTPS Group Controller + HTTPSGroupCtl *group.HTTPSGroupController + // TCP Mux Group Controller TCPMuxGroupCtl *group.TCPMuxGroupCtl diff --git a/server/group/https.go b/server/group/https.go new file mode 100644 index 00000000000..4089b0cbd01 --- /dev/null +++ b/server/group/https.go @@ -0,0 +1,197 @@ +// 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 group + +import ( + "context" + "net" + "sync" + + gerr "github.com/fatedier/golib/errors" + + "github.com/fatedier/frp/pkg/util/vhost" +) + +type HTTPSGroupController struct { + groups map[string]*HTTPSGroup + + httpsMuxer *vhost.HTTPSMuxer + + mu sync.Mutex +} + +func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController { + return &HTTPSGroupController{ + groups: make(map[string]*HTTPSGroup), + httpsMuxer: httpsMuxer, + } +} + +func (ctl *HTTPSGroupController) Listen( + ctx context.Context, + group, groupKey string, + routeConfig vhost.RouteConfig, +) (l net.Listener, err error) { + indexKey := group + ctl.mu.Lock() + g, ok := ctl.groups[indexKey] + if !ok { + g = NewHTTPSGroup(ctl) + ctl.groups[indexKey] = g + } + ctl.mu.Unlock() + + return g.Listen(ctx, group, groupKey, routeConfig) +} + +func (ctl *HTTPSGroupController) RemoveGroup(group string) { + ctl.mu.Lock() + defer ctl.mu.Unlock() + delete(ctl.groups, group) +} + +type HTTPSGroup struct { + group string + groupKey string + domain string + + acceptCh chan net.Conn + httpsLn *vhost.Listener + lns []*HTTPSGroupListener + ctl *HTTPSGroupController + mu sync.Mutex +} + +func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup { + return &HTTPSGroup{ + lns: make([]*HTTPSGroupListener, 0), + ctl: ctl, + acceptCh: make(chan net.Conn), + } +} + +func (g *HTTPSGroup) Listen( + ctx context.Context, + group, groupKey string, + routeConfig vhost.RouteConfig, +) (ln *HTTPSGroupListener, err error) { + g.mu.Lock() + defer g.mu.Unlock() + if len(g.lns) == 0 { + // the first listener, listen on the real address + httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig) + if errRet != nil { + return nil, errRet + } + ln = newHTTPSGroupListener(group, g, httpsLn.Addr()) + + g.group = group + g.groupKey = groupKey + g.domain = routeConfig.Domain + g.httpsLn = httpsLn + g.lns = append(g.lns, ln) + go g.worker() + } else { + // route config in the same group must be equal + if g.group != group || g.domain != routeConfig.Domain { + return nil, ErrGroupParamsInvalid + } + if g.groupKey != groupKey { + return nil, ErrGroupAuthFailed + } + ln = newHTTPSGroupListener(group, g, g.lns[0].Addr()) + g.lns = append(g.lns, ln) + } + return +} + +func (g *HTTPSGroup) worker() { + for { + c, err := g.httpsLn.Accept() + if err != nil { + return + } + err = gerr.PanicToError(func() { + g.acceptCh <- c + }) + if err != nil { + return + } + } +} + +func (g *HTTPSGroup) Accept() <-chan net.Conn { + return g.acceptCh +} + +func (g *HTTPSGroup) CloseListener(ln *HTTPSGroupListener) { + g.mu.Lock() + defer g.mu.Unlock() + for i, tmpLn := range g.lns { + if tmpLn == ln { + g.lns = append(g.lns[:i], g.lns[i+1:]...) + break + } + } + if len(g.lns) == 0 { + close(g.acceptCh) + if g.httpsLn != nil { + g.httpsLn.Close() + } + g.ctl.RemoveGroup(g.group) + } +} + +type HTTPSGroupListener struct { + groupName string + group *HTTPSGroup + + addr net.Addr + closeCh chan struct{} +} + +func newHTTPSGroupListener(name string, group *HTTPSGroup, addr net.Addr) *HTTPSGroupListener { + return &HTTPSGroupListener{ + groupName: name, + group: group, + addr: addr, + closeCh: make(chan struct{}), + } +} + +func (ln *HTTPSGroupListener) Accept() (c net.Conn, err error) { + var ok bool + select { + case <-ln.closeCh: + return nil, ErrListenerClosed + case c, ok = <-ln.group.Accept(): + if !ok { + return nil, ErrListenerClosed + } + return c, nil + } +} + +func (ln *HTTPSGroupListener) Addr() net.Addr { + return ln.addr +} + +func (ln *HTTPSGroupListener) Close() (err error) { + close(ln.closeCh) + + // remove self from HTTPSGroup + ln.group.CloseListener(ln) + return +} diff --git a/server/proxy/https.go b/server/proxy/https.go index 4575ac13d08..f137ea7ad6d 100644 --- a/server/proxy/https.go +++ b/server/proxy/https.go @@ -15,6 +15,7 @@ package proxy import ( + "net" "reflect" "strings" @@ -58,27 +59,24 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) { continue } - routeConfig.Domain = domain - l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig) - if errRet != nil { - err = errRet - return + l, err := pxy.listenForDomain(routeConfig, domain) + if err != nil { + return "", err } - xl.Infof("https proxy listen for host [%s]", routeConfig.Domain) pxy.listeners = append(pxy.listeners, l) - addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort)) + addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort)) + xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group) } if pxy.cfg.SubDomain != "" { - routeConfig.Domain = pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost - l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig) - if errRet != nil { - err = errRet - return + domain := pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost + l, err := pxy.listenForDomain(routeConfig, domain) + if err != nil { + return "", err } - xl.Infof("https proxy listen for host [%s]", routeConfig.Domain) pxy.listeners = append(pxy.listeners, l) - addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort)) + addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort)) + xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group) } pxy.startCommonTCPListenersHandler() @@ -89,3 +87,18 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) { func (pxy *HTTPSProxy) Close() { pxy.BaseProxy.Close() } + +func (pxy *HTTPSProxy) listenForDomain(routeConfig *vhost.RouteConfig, domain string) (net.Listener, error) { + tmpRouteConfig := *routeConfig + tmpRouteConfig.Domain = domain + + if pxy.cfg.LoadBalancer.Group != "" { + return pxy.rc.HTTPSGroupCtl.Listen( + pxy.ctx, + pxy.cfg.LoadBalancer.Group, + pxy.cfg.LoadBalancer.GroupKey, + tmpRouteConfig, + ) + } + return pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, &tmpRouteConfig) +} diff --git a/server/service.go b/server/service.go index 7ca80dc89ba..1fe882d293a 100644 --- a/server/service.go +++ b/server/service.go @@ -322,6 +322,9 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { if err != nil { return nil, fmt.Errorf("create vhost httpsMuxer error, %v", err) } + + // Init HTTPS group controller after HTTPSMuxer is created + svr.rc.HTTPSGroupCtl = group.NewHTTPSGroupController(svr.rc.VhostHTTPSMuxer) } // frp tls listener diff --git a/test/e2e/framework/process.go b/test/e2e/framework/process.go index 10b3611bf6e..0b837e39fa9 100644 --- a/test/e2e/framework/process.go +++ b/test/e2e/framework/process.go @@ -75,8 +75,8 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) { if err != nil { return p, p.StdOutput(), err } - // sleep for a while to get std output - time.Sleep(2 * time.Second) + // Give frps extra time to finish binding ports before proceeding. + time.Sleep(4 * time.Second) return p, p.StdOutput(), nil } diff --git a/test/e2e/v1/features/group.go b/test/e2e/v1/features/group.go index fe0c957ba8b..f6bb1856e38 100644 --- a/test/e2e/v1/features/group.go +++ b/test/e2e/v1/features/group.go @@ -1,6 +1,7 @@ package features import ( + "crypto/tls" "fmt" "strconv" "sync" @@ -8,6 +9,7 @@ import ( "github.com/onsi/ginkgo/v2" + "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" @@ -112,6 +114,80 @@ var _ = ginkgo.Describe("[Feature: Group]", func() { framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) }) + + ginkgo.It("HTTPS", func() { + vhostHTTPSPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + vhostHTTPSPort = %d + `, vhostHTTPSPort) + clientConf := consts.DefaultClientConfig + + tlsConfig, err := transport.NewServerTLSConfig("", "", "") + framework.ExpectNoError(err) + + fooPort := f.AllocPort() + fooServer := httpserver.New( + httpserver.WithBindPort(fooPort), + httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("foo"))), + httpserver.WithTLSConfig(tlsConfig), + ) + f.RunServer("", fooServer) + + barPort := f.AllocPort() + barServer := httpserver.New( + httpserver.WithBindPort(barPort), + httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("bar"))), + httpserver.WithTLSConfig(tlsConfig), + ) + f.RunServer("", barServer) + + clientConf += fmt.Sprintf(` + [[proxies]] + name = "foo" + type = "https" + localPort = %d + customDomains = ["example.com"] + loadBalancer.group = "test" + loadBalancer.groupKey = "123" + + [[proxies]] + name = "bar" + type = "https" + localPort = %d + customDomains = ["example.com"] + loadBalancer.group = "test" + loadBalancer.groupKey = "123" + `, fooPort, barPort) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + + fooCount := 0 + barCount := 0 + for i := 0; i < 10; i++ { + framework.NewRequestExpect(f). + Explain("times " + strconv.Itoa(i)). + Port(vhostHTTPSPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, + }) + }). + Ensure(func(resp *request.Response) bool { + switch string(resp.Content) { + case "foo": + fooCount++ + case "bar": + barCount++ + default: + return false + } + return true + }) + } + + framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) + }) }) ginkgo.Describe("Health Check", func() { From a75320ef2fcbb6103c06bbd5b4ad85f9c35b4cc3 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 28 Oct 2025 17:52:34 +0800 Subject: [PATCH 17/28] update quic-go dependency from v0.53.0 to v0.55.0 (#5033) --- go.mod | 17 ++++++++--------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index af633af4135..e23facdeab5 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/pion/stun/v2 v2.0.0 github.com/pires/go-proxyproto v0.7.0 github.com/prometheus/client_golang v1.19.1 - github.com/quic-go/quic-go v0.53.0 + github.com/quic-go/quic-go v0.55.0 github.com/rodaine/table v1.2.0 github.com/samber/lo v1.47.0 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 @@ -26,10 +26,10 @@ require ( 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.37.0 - golang.org/x/net v0.39.0 + golang.org/x/crypto v0.41.0 + golang.org/x/net v0.43.0 golang.org/x/oauth2 v0.28.0 - golang.org/x/sync v0.13.0 + golang.org/x/sync v0.16.0 golang.org/x/time v0.5.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 gopkg.in/ini.v1 v1.67.0 @@ -67,11 +67,10 @@ require ( 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/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.org/x/mod v0.27.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.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 diff --git a/go.sum b/go.sum index a411ff30581..f73117a3b9b 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI= -github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA= @@ -156,24 +156,24 @@ github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2 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= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 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= @@ -187,8 +187,8 @@ 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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= @@ -197,8 +197,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ 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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.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= @@ -213,24 +213,24 @@ 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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= @@ -241,8 +241,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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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= From e025843d3c14a1a6801d3a277882cb43b6548e2b Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 29 Oct 2025 01:08:48 +0800 Subject: [PATCH 18/28] vnet: add exponential backoff for failed reconnections (#5035) --- Release.md | 4 +++ client/visitor/stcp.go | 18 ++++++++++++- client/visitor/visitor.go | 2 +- client/visitor/xtcp.go | 11 ++++++++ pkg/plugin/visitor/plugin.go | 15 ++++++++--- pkg/plugin/visitor/virtual_net.go | 44 +++++++++++++++++++++++++------ pkg/util/net/conn.go | 21 ++++++++++++--- pkg/util/net/websocket.go | 2 +- 8 files changed, 99 insertions(+), 18 deletions(-) diff --git a/Release.md b/Release.md index e237538b4ad..18aa0182935 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,7 @@ ## Features * HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities. + +## Improvements + +* **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals. diff --git a/client/visitor/stcp.go b/client/visitor/stcp.go index 124202eb8d5..31f6f1743e5 100644 --- a/client/visitor/stcp.go +++ b/client/visitor/stcp.go @@ -15,6 +15,7 @@ package visitor import ( + "fmt" "io" "net" "strconv" @@ -81,11 +82,22 @@ func (sv *STCPVisitor) internalConnWorker() { func (sv *STCPVisitor) handleConn(userConn net.Conn) { xl := xlog.FromContextSafe(sv.ctx) - defer userConn.Close() + var tunnelErr error + defer func() { + // If there was an error and connection supports CloseWithError, use it + if tunnelErr != nil { + if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok { + _ = eConn.CloseWithError(tunnelErr) + return + } + } + userConn.Close() + }() xl.Debugf("get a new stcp user connection") visitorConn, err := sv.helper.ConnectServer() if err != nil { + tunnelErr = err return } defer visitorConn.Close() @@ -102,6 +114,7 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) { err = msg.WriteMsg(visitorConn, newVisitorConnMsg) if err != nil { xl.Warnf("send newVisitorConnMsg to server error: %v", err) + tunnelErr = err return } @@ -110,12 +123,14 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) { err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg) if err != nil { xl.Warnf("get newVisitorConnRespMsg error: %v", err) + tunnelErr = err return } _ = visitorConn.SetReadDeadline(time.Time{}) if newVisitorConnRespMsg.Error != "" { xl.Warnf("start new visitor connection error: %s", newVisitorConnRespMsg.Error) + tunnelErr = fmt.Errorf("%s", newVisitorConnRespMsg.Error) return } @@ -125,6 +140,7 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) { remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey)) if err != nil { xl.Errorf("create encryption stream error: %v", err) + tunnelErr = err return } } diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go index fb2b3e118a1..87e4f29ff89 100644 --- a/client/visitor/visitor.go +++ b/client/visitor/visitor.go @@ -71,7 +71,7 @@ func NewVisitor( Name: cfg.GetBaseConfig().Name, Ctx: ctx, VnetController: helper.VNetController(), - HandleConn: func(conn net.Conn) { + SendConnToVisitor: func(conn net.Conn) { _ = baseVisitor.AcceptConn(conn) }, }, diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index 353577db659..cdfeb1ab919 100644 --- a/client/visitor/xtcp.go +++ b/client/visitor/xtcp.go @@ -162,8 +162,16 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() { func (sv *XTCPVisitor) handleConn(userConn net.Conn) { xl := xlog.FromContextSafe(sv.ctx) isConnTransferred := false + var tunnelErr error defer func() { if !isConnTransferred { + // If there was an error and connection supports CloseWithError, use it + if tunnelErr != nil { + if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok { + _ = eConn.CloseWithError(tunnelErr) + return + } + } userConn.Close() } }() @@ -181,6 +189,8 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) { tunnelConn, err := sv.openTunnel(ctx) if err != nil { xl.Errorf("open tunnel error: %v", err) + tunnelErr = err + // no fallback, just return if sv.cfg.FallbackTo == "" { return @@ -200,6 +210,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) { muxConnRWCloser, err = libio.WithEncryption(muxConnRWCloser, []byte(sv.cfg.SecretKey)) if err != nil { xl.Errorf("create encryption stream error: %v", err) + tunnelErr = err return } } diff --git a/pkg/plugin/visitor/plugin.go b/pkg/plugin/visitor/plugin.go index 94adce093b0..27eecc82fd1 100644 --- a/pkg/plugin/visitor/plugin.go +++ b/pkg/plugin/visitor/plugin.go @@ -23,11 +23,20 @@ import ( "github.com/fatedier/frp/pkg/vnet" ) +// PluginContext provides the necessary context and callbacks for visitor plugins. type PluginContext struct { - Name string - Ctx context.Context + // Name is the unique identifier for this visitor, used for logging and routing. + Name string + + // Ctx manages the plugin's lifecycle and carries the logger for structured logging. + Ctx context.Context + + // VnetController manages TUN device routing. May be nil if virtual networking is disabled. VnetController *vnet.Controller - HandleConn func(net.Conn) + + // SendConnToVisitor sends a connection to the visitor's internal processing queue. + // Does not return error; failures are handled by closing the connection. + SendConnToVisitor func(net.Conn) } // Creators is used for create plugins to handle connections. diff --git a/pkg/plugin/visitor/virtual_net.go b/pkg/plugin/visitor/virtual_net.go index f660c0c89e5..8193ce03cbd 100644 --- a/pkg/plugin/visitor/virtual_net.go +++ b/pkg/plugin/visitor/virtual_net.go @@ -42,6 +42,8 @@ type VirtualNetPlugin struct { controllerConn net.Conn closeSignal chan struct{} + consecutiveErrors int // Tracks consecutive connection errors for exponential backoff + ctx context.Context cancel context.CancelFunc } @@ -98,7 +100,6 @@ func (p *VirtualNetPlugin) Start() { func (p *VirtualNetPlugin) run() { xl := xlog.FromContextSafe(p.ctx) - reconnectDelay := 10 * time.Second for { currentCloseSignal := make(chan struct{}) @@ -121,7 +122,10 @@ func (p *VirtualNetPlugin) run() { p.controllerConn = controllerConn p.mu.Unlock() - pluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func() { + // Wrap with CloseNotifyConn which supports both close notification and error recording + var closeErr error + pluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func(err error) { + closeErr = err close(currentCloseSignal) // Signal the run loop on close. }) @@ -129,9 +133,9 @@ func (p *VirtualNetPlugin) run() { 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) + // Pass the CloseNotifyConn to the visitor for handling. + // The visitor can call CloseWithError to record the failure reason. + p.pluginCtx.SendConnToVisitor(pluginNotifyConn) // Wait for context cancellation or connection close. select { @@ -140,8 +144,32 @@ func (p *VirtualNetPlugin) run() { 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. + // Determine reconnect delay based on error with exponential backoff + var reconnectDelay time.Duration + if closeErr != nil { + p.consecutiveErrors++ + xl.Warnf("connection closed with error for visitor [%s] (consecutive errors: %d): %v", + p.pluginCtx.Name, p.consecutiveErrors, closeErr) + + // Exponential backoff: 60s, 120s, 240s, 300s (capped) + baseDelay := 60 * time.Second + reconnectDelay = baseDelay * time.Duration(1< 300*time.Second { + reconnectDelay = 300 * time.Second + } + } else { + // Reset consecutive errors on successful connection + if p.consecutiveErrors > 0 { + xl.Infof("connection closed normally for visitor [%s], resetting error counter (was %d)", + p.pluginCtx.Name, p.consecutiveErrors) + p.consecutiveErrors = 0 + } else { + xl.Infof("connection closed normally for visitor [%s]", p.pluginCtx.Name) + } + reconnectDelay = 10 * time.Second + } + + // The visitor 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) @@ -184,7 +212,7 @@ func (p *VirtualNetPlugin) Close() error { } // 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. + // This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end. p.cleanupControllerConn(xl) xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name) diff --git a/pkg/util/net/conn.go b/pkg/util/net/conn.go index 6946b1c2f14..914a7bb58e1 100644 --- a/pkg/util/net/conn.go +++ b/pkg/util/net/conn.go @@ -135,11 +135,11 @@ type CloseNotifyConn struct { // 1 means closed closeFlag int32 - closeFn func() + closeFn func(error) } -// closeFn will be only called once -func WrapCloseNotifyConn(c net.Conn, closeFn func()) net.Conn { +// closeFn will be only called once with the error (nil if Close() was called, non-nil if CloseWithError() was called) +func WrapCloseNotifyConn(c net.Conn, closeFn func(error)) *CloseNotifyConn { return &CloseNotifyConn{ Conn: c, closeFn: closeFn, @@ -151,12 +151,25 @@ func (cc *CloseNotifyConn) Close() (err error) { if pflag == 0 { err = cc.Conn.Close() if cc.closeFn != nil { - cc.closeFn() + cc.closeFn(nil) } } return } +// CloseWithError closes the connection and passes the error to the close callback. +func (cc *CloseNotifyConn) CloseWithError(err error) error { + pflag := atomic.SwapInt32(&cc.closeFlag, 1) + if pflag == 0 { + closeErr := cc.Conn.Close() + if cc.closeFn != nil { + cc.closeFn(err) + } + return closeErr + } + return nil +} + type StatsConn struct { net.Conn diff --git a/pkg/util/net/websocket.go b/pkg/util/net/websocket.go index 263b3a1d98d..3ca8b332299 100644 --- a/pkg/util/net/websocket.go +++ b/pkg/util/net/websocket.go @@ -32,7 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) { muxer := http.NewServeMux() muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) { notifyCh := make(chan struct{}) - conn := WrapCloseNotifyConn(c, func() { + conn := WrapCloseNotifyConn(c, func(_ error) { close(notifyCh) }) wl.acceptCh <- conn From b27b846971cd9f7b08023314437fdafc939f6664 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 6 Nov 2025 14:05:03 +0800 Subject: [PATCH 19/28] config: add enabled field for individual proxy and visitor (#5048) --- Release.md | 1 + conf/frpc_full_example.toml | 9 +++++++++ pkg/config/load.go | 11 +++++++++++ pkg/config/v1/proxy.go | 7 +++++-- pkg/config/v1/visitor.go | 7 +++++-- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Release.md b/Release.md index 18aa0182935..c346193f056 100644 --- a/Release.md +++ b/Release.md @@ -1,6 +1,7 @@ ## Features * HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities. +* Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load. ## Improvements diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index 6b86907e459..ad7953beb56 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -143,6 +143,11 @@ transport.tls.enable = true # Default is empty, means all proxies. # start = ["ssh", "dns"] +# Alternative to 'start': You can control each proxy individually using the 'enabled' field. +# Set 'enabled = false' in a proxy configuration to disable it. +# If 'enabled' is not set or set to true, the proxy is enabled by default. +# The 'enabled' field provides more granular control and is recommended over 'start'. + # Specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. @@ -169,6 +174,8 @@ metadatas.var2 = "123" # If global user is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh' name = "ssh" type = "tcp" +# Enable or disable this proxy. true or omit this field to enable, false to disable. +# enabled = true localIP = "127.0.0.1" localPort = 22 # Limit bandwidth for this proxy, unit is KB and MB @@ -253,6 +260,8 @@ healthCheck.httpHeaders=[ [[proxies]] name = "web02" type = "https" +# Disable this proxy by setting enabled to false +# enabled = false localIP = "127.0.0.1" localPort = 8000 subdomain = "web02" diff --git a/pkg/config/load.go b/pkg/config/load.go index 3852af9a886..6e8c251de02 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -281,6 +281,17 @@ func LoadClientConfig(path string, strict bool) ( }) } + // Filter by enabled field in each proxy + // nil or true means enabled, false means disabled + proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { + enabled := c.GetBaseConfig().Enabled + return enabled == nil || *enabled + }) + visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool { + enabled := c.GetBaseConfig().Enabled + return enabled == nil || *enabled + }) + if cliCfg != nil { if err := cliCfg.Complete(); err != nil { return nil, nil, nil, isLegacyFormat, err diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index 37701b6d785..1bbe5ac3c9c 100644 --- a/pkg/config/v1/proxy.go +++ b/pkg/config/v1/proxy.go @@ -108,8 +108,11 @@ type DomainConfig struct { } type ProxyBaseConfig struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + // Enabled controls whether this proxy is enabled. nil or true means enabled, false means disabled. + // This allows individual control over each proxy, complementing the global "start" field. + Enabled *bool `json:"enabled,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Transport ProxyTransport `json:"transport,omitempty"` // metadata info for each proxy diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index 7629875a6f2..5017f57d8d2 100644 --- a/pkg/config/v1/visitor.go +++ b/pkg/config/v1/visitor.go @@ -32,8 +32,11 @@ type VisitorTransport struct { } type VisitorBaseConfig struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + // Enabled controls whether this visitor is enabled. nil or true means enabled, false means disabled. + // This allows individual control over each visitor, complementing the global "start" field. + Enabled *bool `json:"enabled,omitempty"` Transport VisitorTransport `json:"transport,omitempty"` SecretKey string `json:"secretKey,omitempty"` // if the server user is not set, it defaults to the current user From f736d171ac98db09061d78271edbfd3012c61834 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 18 Nov 2025 00:09:37 +0800 Subject: [PATCH 20/28] rotate gold sponsor order periodically (#5067) --- README.md | 19 ++++++++++--------- README_zh.md | 18 +++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 299f9a3ccdc..0b88eb14b18 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel

Gold Sponsors

+

+ + +
+ The sovereign cloud that puts you in control +
+ An open source, self-hosted alternative to public clouds, built for data ownership and privacy +
+

+
## Recall.ai - API for meeting recordings @@ -54,15 +64,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and Secure and Elastic Infrastructure for Running Your AI-Generated Code

-

- - -
- The sovereign cloud that puts you in control -
- An open source, self-hosted alternative to public clouds, built for data ownership and privacy -
-

## What is frp? diff --git a/README_zh.md b/README_zh.md index 82ac8a5004e..4426813a80a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,6 +15,15 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

+

+ + +
+ The sovereign cloud that puts you in control +
+ An open source, self-hosted alternative to public clouds, built for data ownership and privacy +
+

## Recall.ai - API for meeting recordings @@ -56,15 +65,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and Secure and Elastic Infrastructure for Running Your AI-Generated Code

-

- - -
- The sovereign cloud that puts you in control -
- An open source, self-hosted alternative to public clouds, built for data ownership and privacy -
-

## 为什么使用 frp ? From 66973a03dbe8e300f2607ad3ada8d515948bac93 Mon Sep 17 00:00:00 2001 From: Krzysztof Bogacki Date: Mon, 17 Nov 2025 17:20:21 +0100 Subject: [PATCH 21/28] Add `exec` value source type (#5050) * config: introduce ExecSource value source * auth: introduce OidcTokenSourceAuthProvider * auth: use OidcTokenSourceAuthProvider if tokenSource config is present on the client * cmd: allow exec token source only if CLI flag was passed --- client/admin_api.go | 2 +- client/service.go | 5 +++ cmd/frpc/sub/proxy.go | 11 +++-- cmd/frpc/sub/root.go | 27 ++++++++---- cmd/frpc/sub/verify.go | 5 ++- pkg/auth/auth.go | 10 +++-- pkg/auth/oidc.go | 45 ++++++++++++++++++++ pkg/config/v1/client.go | 8 ++++ pkg/config/v1/validation/client.go | 19 +++++++-- pkg/config/v1/value_source.go | 67 +++++++++++++++++++++++++++++- 10 files changed, 179 insertions(+), 20 deletions(-) diff --git a/client/admin_api.go b/client/admin_api.go index f161d588b4d..b726dc33b43 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -92,7 +92,7 @@ func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) { log.Warnf("reload frpc proxy config error: %s", res.Msg) return } - if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs); err != nil { + if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, svr.unsafeFeatures); err != nil { res.Code = 400 res.Msg = err.Error() log.Warnf("reload frpc proxy config error: %s", res.Msg) diff --git a/client/service.go b/client/service.go index f906e4d0a82..60eb2d9030d 100644 --- a/client/service.go +++ b/client/service.go @@ -64,6 +64,8 @@ type ServiceOptions struct { ProxyCfgs []v1.ProxyConfigurer VisitorCfgs []v1.VisitorConfigurer + UnsafeFeatures v1.UnsafeFeatures + // ConfigFilePath is the path to the configuration file used to initialize. // If it is empty, it means that the configuration file is not used for initialization. // It may be initialized using command line parameters or called directly. @@ -122,6 +124,8 @@ type Service struct { visitorCfgs []v1.VisitorConfigurer clientSpec *msg.ClientSpec + unsafeFeatures v1.UnsafeFeatures + // The configuration file used to initialize this client, or an empty // string if no configuration file was used. configFilePath string @@ -161,6 +165,7 @@ func NewService(options ServiceOptions) (*Service, error) { webServer: webServer, common: options.Common, configFilePath: options.ConfigFilePath, + unsafeFeatures: options.UnsafeFeatures, proxyCfgs: options.ProxyCfgs, visitorCfgs: options.VisitorCfgs, clientSpec: options.ClientSpec, diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 67bd774f7a3..64c0aade486 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -77,7 +77,9 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm fmt.Println(err) os.Exit(1) } - if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil { + + unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) } @@ -88,7 +90,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "") + err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) @@ -106,7 +108,8 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil { + unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) } @@ -117,7 +120,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "") + err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index ee89c48947a..b9a373b16b6 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -21,6 +21,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "sync" "syscall" "time" @@ -36,11 +37,18 @@ import ( "github.com/fatedier/frp/pkg/util/version" ) +type UnsafeFeature = string + +const ( + TokenSourceExec UnsafeFeature = "TokenSourceExec" +) + var ( cfgFile string cfgDir string showVersion bool strictConfigMode bool + allowUnsafe []UnsafeFeature ) func init() { @@ -48,6 +56,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors") + rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow_unsafe", "", []string{}, "allowed unsafe features, one or more of: TokenSourceExec") } var rootCmd = &cobra.Command{ @@ -59,15 +68,17 @@ var rootCmd = &cobra.Command{ return nil } + unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + // If cfgDir is not empty, run multiple frpc service for each config file in cfgDir. // Note that it's only designed for testing. It's not guaranteed to be stable. if cfgDir != "" { - _ = runMultipleClients(cfgDir) + _ = runMultipleClients(cfgDir, unsafeFeatures) return nil } // Do not show command usage here. - err := runClient(cfgFile) + err := runClient(cfgFile, unsafeFeatures) if err != nil { fmt.Println(err) os.Exit(1) @@ -76,7 +87,7 @@ var rootCmd = &cobra.Command{ }, } -func runMultipleClients(cfgDir string) error { +func runMultipleClients(cfgDir string, unsafeFeatures v1.UnsafeFeatures) error { var wg sync.WaitGroup err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -86,7 +97,7 @@ func runMultipleClients(cfgDir string) error { time.Sleep(time.Millisecond) go func() { defer wg.Done() - err := runClient(path) + err := runClient(path, unsafeFeatures) if err != nil { fmt.Printf("frpc service error for config file [%s]\n", path) } @@ -111,7 +122,7 @@ func handleTermSignal(svr *client.Service) { svr.GracefulClose(500 * time.Millisecond) } -func runClient(cfgFilePath string) error { +func runClient(cfgFilePath string, unsafeFeatures v1.UnsafeFeatures) error { cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) if err != nil { return err @@ -127,20 +138,21 @@ func runClient(cfgFilePath string) error { } } - warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs) + warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { return err } - return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath) + return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath) } func startService( cfg *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, + unsafeFeatures v1.UnsafeFeatures, cfgFile string, ) error { log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) @@ -153,6 +165,7 @@ func startService( Common: cfg, ProxyCfgs: proxyCfgs, VisitorCfgs: visitorCfgs, + UnsafeFeatures: unsafeFeatures, ConfigFilePath: cfgFile, }) if err != nil { diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 4b971f531c7..2198114cbd7 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -17,10 +17,12 @@ package sub import ( "fmt" "os" + "slices" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" + v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" ) @@ -42,7 +44,8 @@ var verifyCmd = &cobra.Command{ fmt.Println(err) os.Exit(1) } - warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs) + unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index b954fc80eaa..64462a2085c 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -32,9 +32,13 @@ func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) { case v1.AuthMethodToken: authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token) case v1.AuthMethodOIDC: - authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC) - if err != nil { - return nil, err + if cfg.OIDC.TokenSource != nil { + authProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource) + } else { + authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC) + if err != nil { + return nil, err + } } default: return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method) diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index c5f636402c8..d9377f324c0 100644 --- a/pkg/auth/oidc.go +++ b/pkg/auth/oidc.go @@ -152,6 +152,51 @@ func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (e return err } +type OidcTokenSourceAuthProvider struct { + additionalAuthScopes []v1.AuthScope + + valueSource *v1.ValueSource +} + +func NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider { + return &OidcTokenSourceAuthProvider{ + additionalAuthScopes: additionalAuthScopes, + valueSource: valueSource, + } +} + +func (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) { + ctx := context.Background() + accessToken, err = auth.valueSource.Resolve(ctx) + if err != nil { + return "", fmt.Errorf("couldn't acquire OIDC token for login: %v", err) + } + return +} + +func (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) { + loginMsg.PrivilegeKey, err = auth.generateAccessToken() + return err +} + +func (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) { + if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { + return nil + } + + pingMsg.PrivilegeKey, err = auth.generateAccessToken() + return err +} + +func (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) { + if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { + return nil + } + + newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken() + return err +} + type TokenVerifier interface { Verify(context.Context, string) (*oidc.IDToken, error) } diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index c6cf97a69be..72a19fb3c43 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -239,8 +239,16 @@ type AuthOIDCClientConfig struct { // Supports http, https, socks5, and socks5h proxy protocols. // If empty, no proxy is used for OIDC connections. ProxyURL string `json:"proxyURL,omitempty"` + + // TokenSource specifies a custom dynamic source for the authorization token. + // This is mutually exclusive with every other field of this structure. + TokenSource *ValueSource `json:"tokenSource,omitempty"` } type VirtualNetConfig struct { Address string `json:"address,omitempty"` } + +type UnsafeFeatures struct { + TokenSourceExec bool +} diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 0c8575c990c..7734a5a4a94 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -26,7 +26,7 @@ import ( "github.com/fatedier/frp/pkg/featuregate" ) -func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { +func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.UnsafeFeatures) (Warning, error) { var ( warnings Warning errs error @@ -52,11 +52,24 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { // Validate tokenSource if specified if c.Auth.TokenSource != nil { + if c.Auth.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec { + errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.tokenSource.type")) + } if err := c.Auth.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) } } + if c.Auth.OIDC.TokenSource != nil { + // Validate oidc.tokenSource mutual exclusivity with other fields of oidc + if c.Auth.OIDC.ClientID != "" || c.Auth.OIDC.ClientSecret != "" || c.Auth.OIDC.Audience != "" || c.Auth.OIDC.Scope != "" || c.Auth.OIDC.TokenEndpointURL != "" || len(c.Auth.OIDC.AdditionalEndpointParams) > 0 || c.Auth.OIDC.TrustedCaFile != "" || c.Auth.OIDC.InsecureSkipVerify || c.Auth.OIDC.ProxyURL != "" { + errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) + } + if c.Auth.OIDC.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec { + errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.oidc.tokenSource.type")) + } + } + if err := validateLogConfig(&c.Log); err != nil { errs = AppendError(errs, err) } @@ -101,10 +114,10 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { return warnings, errs } -func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) { +func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, unsafeFeatures v1.UnsafeFeatures) (Warning, error) { var warnings Warning if c != nil { - warning, err := ValidateClientCommonConfig(c) + warning, err := ValidateClientCommonConfig(c, unsafeFeatures) warnings = AppendError(warnings, warning) if err != nil { return warnings, err diff --git a/pkg/config/v1/value_source.go b/pkg/config/v1/value_source.go index 624a2658965..88dbaff3641 100644 --- a/pkg/config/v1/value_source.go +++ b/pkg/config/v1/value_source.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "os" + "os/exec" "strings" ) @@ -27,6 +28,7 @@ import ( type ValueSource struct { Type string `json:"type"` File *FileSource `json:"file,omitempty"` + Exec *ExecSource `json:"exec,omitempty"` } // FileSource specifies how to load a value from a file. @@ -34,6 +36,18 @@ type FileSource struct { Path string `json:"path"` } +// ExecSource specifies how to get a value from another program launched as subprocess. +type ExecSource struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env []ExecEnvVar `json:"env,omitempty"` +} + +type ExecEnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + // Validate validates the ValueSource configuration. func (v *ValueSource) Validate() error { if v == nil { @@ -46,8 +60,13 @@ func (v *ValueSource) Validate() error { return errors.New("file configuration is required when type is 'file'") } return v.File.Validate() + case "exec": + if v.Exec == nil { + return errors.New("exec configuration is required when type is 'exec'") + } + return v.Exec.Validate() default: - return fmt.Errorf("unsupported value source type: %s (only 'file' is supported)", v.Type) + return fmt.Errorf("unsupported value source type: %s (only 'file' and 'exec' are supported)", v.Type) } } @@ -60,6 +79,8 @@ func (v *ValueSource) Resolve(ctx context.Context) (string, error) { switch v.Type { case "file": return v.File.Resolve(ctx) + case "exec": + return v.Exec.Resolve(ctx) default: return "", fmt.Errorf("unsupported value source type: %s", v.Type) } @@ -91,3 +112,47 @@ func (f *FileSource) Resolve(_ context.Context) (string, error) { // Trim whitespace, which is important for file-based tokens return strings.TrimSpace(string(content)), nil } + +// Validate validates the ExecSource configuration. +func (e *ExecSource) Validate() error { + if e == nil { + return errors.New("execSource cannot be nil") + } + + if e.Command == "" { + return errors.New("exec command cannot be empty") + } + + for _, env := range e.Env { + if env.Name == "" { + return errors.New("exec env name cannot be empty") + } + if strings.Contains(env.Name, "=") { + return errors.New("exec env name cannot contain '='") + } + } + return nil +} + +// Resolve reads and returns the content captured from stdout of launched subprocess. +func (e *ExecSource) Resolve(ctx context.Context) (string, error) { + if err := e.Validate(); err != nil { + return "", err + } + + cmd := exec.CommandContext(ctx, e.Command, e.Args...) + if len(e.Env) != 0 { + cmd.Env = os.Environ() + for _, env := range e.Env { + cmd.Env = append(cmd.Env, env.Name+"="+env.Value) + } + } + + content, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to execute command %v: %v", e.Command, err) + } + + // Trim whitespace, which is important for exec-based tokens + return strings.TrimSpace(string(content)), nil +} From 15fd19a16db3348b495716a5a5b41f1646e38838 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 18 Nov 2025 01:11:44 +0800 Subject: [PATCH 22/28] fix lint (#5068) --- .golangci.yml | 1 + cmd/frpc/sub/proxy.go | 4 ++-- cmd/frpc/sub/root.go | 13 +++---------- cmd/frpc/sub/verify.go | 3 +-- pkg/config/v1/client.go | 18 +++++++++++++++++- pkg/config/v1/validation/client.go | 15 +++++++++++---- 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 3ba2c60fe63..f7c0e8bdc45 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -39,6 +39,7 @@ linters: - G404 - G501 - G115 + - G204 severity: low confidence: low govet: diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 64c0aade486..0b0251a2650 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -78,7 +78,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm os.Exit(1) } - unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) @@ -108,7 +108,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index b9a373b16b6..68d69158e6d 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -21,7 +21,6 @@ import ( "os" "os/signal" "path/filepath" - "slices" "sync" "syscall" "time" @@ -37,18 +36,12 @@ import ( "github.com/fatedier/frp/pkg/util/version" ) -type UnsafeFeature = string - -const ( - TokenSourceExec UnsafeFeature = "TokenSourceExec" -) - var ( cfgFile string cfgDir string showVersion bool strictConfigMode bool - allowUnsafe []UnsafeFeature + allowUnsafe []string ) func init() { @@ -56,7 +49,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors") - rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow_unsafe", "", []string{}, "allowed unsafe features, one or more of: TokenSourceExec") + rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, "allowed unsafe features, one or more of: TokenSourceExec") } var rootCmd = &cobra.Command{ @@ -68,7 +61,7 @@ var rootCmd = &cobra.Command{ return nil } - unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) // If cfgDir is not empty, run multiple frpc service for each config file in cfgDir. // Note that it's only designed for testing. It's not guaranteed to be stable. diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 2198114cbd7..c0c9d0e275f 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -17,7 +17,6 @@ package sub import ( "fmt" "os" - "slices" "github.com/spf13/cobra" @@ -44,7 +43,7 @@ var verifyCmd = &cobra.Command{ fmt.Println(err) os.Exit(1) } - unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)} + unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index 72a19fb3c43..8528ff7e149 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -249,6 +249,22 @@ type VirtualNetConfig struct { Address string `json:"address,omitempty"` } +const ( + UnsafeFeatureTokenSourceExec = "TokenSourceExec" +) + type UnsafeFeatures struct { - TokenSourceExec bool + features map[string]bool +} + +func NewUnsafeFeatures(allowed []string) UnsafeFeatures { + features := make(map[string]bool) + for _, f := range allowed { + features[f] = true + } + return UnsafeFeatures{features: features} +} + +func (u UnsafeFeatures) IsEnabled(feature string) bool { + return u.features[feature] } diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 7734a5a4a94..1c21f01d427 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -52,7 +52,7 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.Unsa // Validate tokenSource if specified if c.Auth.TokenSource != nil { - if c.Auth.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec { + if c.Auth.TokenSource.Type == "exec" && !unsafeFeatures.IsEnabled(v1.UnsafeFeatureTokenSourceExec) { errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.tokenSource.type")) } if err := c.Auth.TokenSource.Validate(); err != nil { @@ -62,10 +62,12 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.Unsa if c.Auth.OIDC.TokenSource != nil { // Validate oidc.tokenSource mutual exclusivity with other fields of oidc - if c.Auth.OIDC.ClientID != "" || c.Auth.OIDC.ClientSecret != "" || c.Auth.OIDC.Audience != "" || c.Auth.OIDC.Scope != "" || c.Auth.OIDC.TokenEndpointURL != "" || len(c.Auth.OIDC.AdditionalEndpointParams) > 0 || c.Auth.OIDC.TrustedCaFile != "" || c.Auth.OIDC.InsecureSkipVerify || c.Auth.OIDC.ProxyURL != "" { + if c.Auth.OIDC.ClientID != "" || c.Auth.OIDC.ClientSecret != "" || c.Auth.OIDC.Audience != "" || + c.Auth.OIDC.Scope != "" || c.Auth.OIDC.TokenEndpointURL != "" || len(c.Auth.OIDC.AdditionalEndpointParams) > 0 || + c.Auth.OIDC.TrustedCaFile != "" || c.Auth.OIDC.InsecureSkipVerify || c.Auth.OIDC.ProxyURL != "" { errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) } - if c.Auth.OIDC.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec { + if c.Auth.OIDC.TokenSource.Type == "exec" && !unsafeFeatures.IsEnabled(v1.UnsafeFeatureTokenSourceExec) { errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.oidc.tokenSource.type")) } } @@ -114,7 +116,12 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.Unsa return warnings, errs } -func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, unsafeFeatures v1.UnsafeFeatures) (Warning, error) { +func ValidateAllClientConfig( + c *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, + unsafeFeatures v1.UnsafeFeatures, +) (Warning, error) { var warnings Warning if c != nil { warning, err := ValidateClientCommonConfig(c, unsafeFeatures) From c3821202b1b0c3931a9ecbe03368716a6ca792dc Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 26 Nov 2025 23:55:54 +0800 Subject: [PATCH 23/28] docs: remove zsxq section (#5077) --- README_zh.md | 6 ------ doc/pic/zsxq.jpg | Bin 12647 -> 0 bytes 2 files changed, 6 deletions(-) delete mode 100644 doc/pic/zsxq.jpg diff --git a/README_zh.md b/README_zh.md index 4426813a80a..1c3879fd67f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -137,9 +137,3 @@ frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进 国内用户可以通过 [爱发电](https://afdian.com/a/fatedier) 赞助我们。 企业赞助者可以将贵公司的 Logo 以及链接放置在项目 README 文件中。 - -### 知识星球 - -如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何 frp 使用方面的帮助,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群: - -![zsxq](/doc/pic/zsxq.jpg) diff --git a/doc/pic/zsxq.jpg b/doc/pic/zsxq.jpg deleted file mode 100644 index 0bb1f1d516de893fb25d971fa164ff6deb8c93dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12647 zcmb_?cU%)s*YAde7Ni&HB0V5T@4XX33jw8A=!8xv3Q7?X={59#2!swQ(gjpRse&|> zA_7tb1f(g7!d>uJp8LMfANP;D`Rwd>&zw1JW@qLM9KAo91?Z4^hI#-50ss*32OQ1Q zRvT(-Uok_Q>KUSRPbz2$_P)NJBy<4aud2`w6@_P{{-@r0F(e5AZ739@2hEM zh62Fr@8>c8_xkqsSULcViXF@Pm;V2R=$)MX9YF)lKyC*oe@8D6*8>17%F!1W003kP zAf4}8fbTK312Gd0G!VqT$Jq4`jylE;e{l8*4+gCb@>Bx=#VLDxoGSp(G=p@WAV*hF z4$XTIOL#kBy#auh0>o;LZuU+fHURNCFE5{C4DtcO4*wy?^*^w^y~95;?d@Iv!GC-K zTY`#pu>QWD_Q5AF|33>a&pZ($ARb%e^;P~MdHI9r#5QP*8`ki+Eodh+H^2>Z+{edqsvYf5>}LgWv$MD6UonARcJ{w` zY}<)nPR=^IAO?MhzH#=p{L5eHtZ#tUv99B`+rFMB-v-DJgFAcbAJch3%;_Iw@mE`z zS^y4nOb6|RnfL~n9mf!~6XxJ1sJ z(F5!O9N-E#17d$s{%&#Mq{bU8Er1YU9q{x?k%^a=%PJ?0AhTP_dn z-<)K+WIAL9WO`%>7&}Z2rVK-Yr3QFQ!4zPMe_Q)6Kj{+bFzGz$IO#0u#1(8L?r)C2 zea+9;6)WJRS%B5>*l_62JaP32?q10FGNg+s8K;hjn!e;L!wUerFy-Z%6TSJW>*p ziU4rDjvVsEIRs^u#*` z;)4FjgB?Fe0XRSleo<@yH^2u7gZ@YXa)2_R0q6oqz!bm$*1%y7$Izs zGY|oYC`1aP08xYJLQoJi#2RuH;tKJG1VU~?ZbRZBX^>pVeMkl55u^q39MTIJhD<=_ zAS;k<$WIa|$te;h5-t)!lJg`AB$^~fBp8xQB(5ZWBq1bGBuOOMBt<0EB#k7`N%~2~ zNoGmbNcNxrlnTlMJqs0sDnPZNrcfKG3ls+pgT_HKp+(SIXe-#yqtIFC7w8Wd8H^Fe z0}}&BumQ{x<_PnHg~1YFxv+9rBdiNH3Y&v{g&mSolX8-Zk}8oJl3pZrCA~%(O`1ts zO4>mB68xGLNxzYik+F~ok;#K!qcxd3*$uJ;vV5`!WY5Tk$v%M`1(ZO%X|vNl{7BK`}zHOmRR-M=3z5L}^Ot zL>Ww(Oj$zNN;yQiNO=IKhYP_~;TX6({1!YLUJHK-pN8*HQBmnJVW}dhvZ)?Y z^-#@G{W!&NO5~LGDZ5jFr;<*UoqBd^;?xc`HMJnM2DJ@!0Cf^|1@#N+8R~r+MjCM% zLmDTVaGE@tMw$_t4O&WC0a{Jk%d{c1nY52-hiJdhQPK(0Y17%$h0*2Fwa~qz+oNZs zm!vnP_o7drucYs#|I9$nAjqK0;KUHcP|Wax;S(d2k&jWE(UI{sV+rF+#ziJFCLtz6 zCU>TIrW&R}rmxHl%reYY%-5OonA@3WS)eR}EQTzeEXgc&EN@wUp5{8OdD{7O?CILm zBd5Q!aP7+QLPBiBY&SK61&OI(}E`2Uvt{koxTwl0ZxHY)lx$)c`+$(1o�Zjp244a zdS;b}nMaeylP8;}i|6ZE&a(z*1JB++J9zehSBTe=H~G(R|V2bEM}K&v~CKI`>ZOl$fSikXV)2oH&a( zN<31$Nqplx|9PA9spq@TA4*v~XGtTHm#mv_rL@=|FY#b>egebs2Omb#rxR^mz4L^{Vu~ z>dWfi(0^t?YG7oLYVgjG%h1uV!tg6n9vO=4GCE~sVU%k$j}k%QP_4!!#z^CI<7pEC z6K|6yQ^3^FG~M)rnUI;E*%Nazb2IZi^Ch$-`X;*Dg3;oNMWw|zj20#tGi51k8EDyQ zMQdeeRblo0g3g7s3$qu`Uktt2Z_RFvwQjZ{x3RJ*wb`@Pwau_yw3D@qwtIU?=+gB| zy_eZ9dtPq8LVLyj%Hyk~S1(+xy!y-D)V|1m&%w}v;PBN^+cC>=%}K)v@3i8q>YV1h z?4s(D=JMH9)ivF9#ZBEU)9njZ8=H&WayM|l=lcjBus?5dTa5%>hi{hJ7GVIPgy3T##B&UeNwEt80(1 z(_i<#J`gMt92>kGq8CEE0lVRNqx0t3o40Pxg=&W04}*j`gms4Vg-3-iM(9VB--6%r zxb-?xA~G#<_x6R`tx;T25mBG+=-;V|risQyzq_k^_g)Mv1{*UND;=8?dl=^w*B37l zpBeuv!7-sPQ8F<*@hHh9X)swXxgdos#XIF~s(Na98hu(w+NX5m^hP`nJ`TT|aV4WS zQzr9X7Cb93Yc?B|-JHXplali**DZHEPb=>Mfs+tR_@3{SKT@DkP2G%hBas74j7om7JByRU}n`RV&qY z)o*HaYMN`sYVSW_c@X~)co_I__0iQwZ|jWeIv*=MuB{iSCp0iN#5F=1gB!m#xi!r; zTQ?84AX_?Hm0KI0h(9T7JKL7m&fK2d0q=iLbnlt(voFtGpMUDS(mD0w;)}5^ z^RB^{MlX9_>AiZ_)ji)QYQxhwIDd&u4_L7fQR5M1Y+t4065;NV>AE~Y3YA% zkaB$ayGTo&0LUpExHZ{7I@;j^0PDU{I>^} zFNlyqoaL3!l$23aR#Q_G5Z5u(RxyxQRZ~8C22fD|)4((YLJu5uf!PxM`8_~qo$k2Gx%1wJk8Wi=GPz>N-Vb%pFYv6vvv>ftLFeT69ro|L;Ph zvJF!3(pFI`;Bw-;0HNBBk>K2w~UViUg+ zo9B?RLjK-w*Pme~x`$jydGky16ZekSz52$6(JXx|4MJKyLalyt>`j*@#N3hnPbEdZ zJPUOOpU?$tG_t)8PEIlgi%L_SKPuGy|_(UlFc(Gu4sfzw90Hrav3 z83G2+5&TX*s|jkq>WAW7)jN_qxc>`M{+6*W8l);U-Ih19bO~WD&XjM>@0IL?Jc%-I zk{!N5KYpmsxIQa8sIa~UKFB1KKKCW|%kRf8e%id5{#Bh&H_c`cxX51~(9>6JJ3M{< zcjbk*rt^uV>l}h%xw{|K?g{^7$N)Z>AT)$&Rn2K;ZoFZY%At^YyqNst>krzE*0$4%s<{N;20@HMAy% zg#{tg5Y46yQb6?uS45I`FC9a5_*o=I0hmVgFrS40`vKNY1Fl-7743y_{jV?E;%s*{j z^6p+FzI>JQ?2WnDx9CW+YM%s12QSvRaAZ-zef-6F!RXb5KEwhlGEm?uL6q%PnA_*C zt?igvBTR+`!LBdI?_p#iS&HV6hDy zB51Q%3viPiT6Uc+B&>8NseEd<_MZ1kg|tFt95EQRT_4@xit{HVE4-`s*}G5`7A-T| zgC%mi-LMKsDaht>VD8F#B2PE)D&MX|qFwP-z-IDx(xg!6H;fa@$D#w;#mJE=>eCg7TNejv0t%#E8Olu$|DhOD=TF^N^v-)K}KYYG;OE=4LMCfxPrYm31 z?%}tltderkR=sWqhcnNgj<9W}CO2kp|7fhG4P=IYDJhQ4bR#Ydob4(p5uogg{xLFuOoo56g(`_HEoV+lhoRoGirxXWl=(X%SMl6Sal zKebAC@HVtz8Q(c$f9}!P4O+w-nyQLWvKc5fnv?rDU7gDa$WOAGetHltVk$ArrZU^4W-!Hg!tsMc(XX4JaQcK}dUkELfO`IE$7n8t5 z7W(Duc*}iukbEA#+gG+H7PhC`a5954F}s&9o{p8?V==q)1cdMkIlNChZ7Jg5?(J@$A@{>mphkhuKymtv!si~VkMo>!4Xf1hV&~pi#0J^r zJ2eJG1Ug+^4!tjL7uHu!C1==p5AR!Y`kqj>Vp;nr)N{Qa_uvVy)@WLK7~0!@TxG>e zbDjV&=0Aj)l*^t*s0e44*Wqh=u>uPCDkVeMUYFCu+8_N5IpeP%z7VuFpgfh~Q_k37 zS}8JQ-0pM5qCwF~=2ML^3do`y2}EctbL@BB{x1QF_K&y3P6#btFil0C@iHzidB7t~ zXYlbI$FI}f-jxyl(Ye1@-p{MC5nbT(r(EP zUJa;2TAOM8NPrPiv*%U2j1=z@oL_Oioy2E2m<5uPf7c)Cu*^A}`&bdPHg(O5Y&Jj0 z20!^`&1=o7ROT9v{0cU1a+%nnOC0&b&?H!V0P7l(D~Gy8<-8V7pN={>HBqY?heo^HdN z$`0)q?|Xus;}RQ1ifPYzW*Kl{Zv9tUy`FdnO9fKmxNPm8_h-V{4f!4nIQCJGdrkYV zrj_-lZQF`-;hysu@C5$o5;<+zgsLr#mloHV%!~qZu3BU`nj8TNvC=51K_k@tw!M)Q z>0P~xCp((b2g-HxVIeizi=+lwn4ZydHM%?r*Pd>29n#8t!oWUNwhrI`)ZCg}rYQ zt7vk;j=~gH72TImq|}N-rn!ppiDlk{MwX&+!N~jt#PiatSP`VJ@>7WzA6l=#aYC4w(%;pnMiRZ-?a4ftQ34G9PEI`(E+#>K7rP|Q;sv+K8@j1k7sHMQ=$4z z^roN5lGk^-y8<#qcY|LapE0h*>NaSkwJ+qfc$|HSQ6wrlJ5zKrf7tjnEYjk&YW!UM zg{5_3CF$o|Ps;{Kv;-g)D0L!BM=+4F znmWGIlmxG;nni_qTT>^qK0NtO`ZAk$z1X3s%#N^a4O^`DPXPye5#_c&I9re|NO7`_W<+SN5PUe~E%TM#*}Nwt#lK_6%hId76b5nR1eduU?oGs# zaK&(Y^Q#|&xU|+P`v;N6Ml#Xai%jiR{HsEIznqE`b*@|&C&+xls{L~Qb^b$Nd5&T(R1;dWsn^1L7zcSa3{8*xj&LQ9f zOOaYBGd23?h%N5snMa#_p^tTg?#YTueM(Xz-jQJ}Apy4d`lPsN-x35I?w`HGrNI@Z zd^ljb-sjPnSSV1&soj@uR9pR+Ana5HRrpB1<33X-@A#Oyw;v^S5s4}KYVtF7CGuvG z@yTk!8NAZUj8WFjBV+`dGE6SE%p&w_2~fg1o7*n@5@{AYu`f12sgM{!6cu6}T|SoM`P-H~mGn&HSP@h0)@b@cQDvA(P`+GdsdGVAHmL}bQxn8g@iWBE4Z(=s-&8v_^#<07+CO0I8`$wc_Mn`LZ#Jp}$4>07t zYFm+uut^_Wkl_4CZW5%hmGryGuKLnxJ`7cyy#?CDgSKC+zE}il2;d|1n8=f(P(ohH zB!tiI*jPo^xIh8%*9Z~5XR}oU`XXJ6W%I)_u2(XOlX9 zCa^||#9fbh5Qq(bNER+MF_vVn^0*2-$y@Gs>r;3$!L|!OtW!UQ9dCa40i7z(Bi!#c zP*>WJ6A`e=Iw-Trp#ttA$B%%8BY^YT!u815{$8uu(h7m|%|q%0iVYI~GH!*P*b>eC zhM(+en2%dJ47Xk^UJ%Q$x}$q8ikw#Xang6gjktXkoFfKaKuocO4tdilZ+9l2zG!K~{d z9nMC#Oq4oI+PDvc#*zh%hCl@(~^ks!;;3R$9IU%iY7El z8@$yi)tCy+A$`-Qh}W+=kbQxUrD5qT>qN79mqbMaOmW6Lc2bYU0(Zi2Mp|{?S~>%= z=Cy7SiuZDvnY;CPnGO0O&Vt}-zr>r@aVzsW8K)>tau^L22oO;)o!~pMS?O<^Z~R@&}eXKEjLd%3G$nIgUY6eCOV z#sHPC;hm_myotOw0ShaGFWxaIoNPC$*4i-TBOZ_ImsaX(d94@aGQ?hz6sn+Z8uXOK z^<2^_Y%mblwZrpXfm6XZomRJERN3pFm@MGDFHsL5?kMya#XaK_P*m8O6!*|-Tc7e4 z$JuATM^D=-*$8L=z~JHgcMazhZB2qF8inpZ`@T2wa) z_R$a0^p{rz>jA7*?-gSjy1gZ}pU~@iJA2C&y?I+m@}$evzHwNxAk`F3m-91ZT&-mh zXBC_DS;O=OpJ{^M>a<08ax}x?*|xQ!bT@T9=})*d|ugWwB@g zDgI4$;_)G+PdbLy8HIIP%jnj0WCCFc_yreqzPBMxuipa0PAX!q7{jIC? zWO`Fqz8qc}7@kqd-=_Gag5`r!U~YP?T+?M_3d3Yp3S%;@ow3_@*ul$)Dn;TUbUY_#N@2$O*V$B8_Z!Na@i`FYwPG@?%5M_3}TH7gw z3FyIU#y4lqGUSR)nGzmNHI7T{G+38j5Ko!MXk`}F@RaX+%O}V!R5#Hc=(n!8-1Q!v z*ez85>R(kjHAm}-6H|;0?htK|7LgSH(%&-}hY+P??F*3$ASg{9{ zXt&zg+>ZOGq`Vy3_)YhM-&7VegY{ieZ-jAk5#GhAWdifAwt#1&M+}VeJMp(VRGv>NHB7{Zd`Lwt>KR7KltHf+S&Jye z@OzS%clWuYnUzIwA!#y3BG&7y`sZN+8YwFB^i;FE(_;f4P$*Q}XCq@ePCuhpt1Ia& z)h82a*!4CW-x*6Bhcy}2HY$5Q^Ae>HzT@6QhmrgyXU`DE3u3G)EnlNYi90o{7a^D4 zK)}o(;%$|s$htMN;wz0%N~DGtQ9!}4XX^TgW*_*6*TQ2pn$eN${@DcnnoRolU~)j|Jzk-SWwh9<8C zyx3h}eDA`kt-Fvxty~tC^ZsSSi?z(K@|HgOj>HkbD0n%wIkn%#`0P@` zL$S1?075z;L5F>*^pQdHa>d9?-JEMOWj1xySGk6UIJ@q93`QX?{>GPx(L@l0$Fgh$5|~b8&hv@6gW2E4AyDPVc^2Sfv$~%Yqi;XAQ?7 z-0e-0ko+p+sf?0|`Z02ZID(qFM!;iaL-u?0#3rX#y@}ToxhxNb1NC)2lGcDO?4!#`$zfK(i`)ZQJn zspk#8La*W*KlSl(%rb=b!ZV>@0snumkkAb+q)=?Fk| zb)3vs^m@u?&x3!b*_QV5zZepVwUMPyjSNZr#d|~6@nRYhDLCP93CU&<TuIzBk7di0c|L3re*p}S%q_ak-$9=&e1_JsTLY_YxlV0AF6+_J(Y|FAmFD%t}1k+ZZy>8|A z!HVXPo@#Dg>W)Q0u5K_YI?rCjC-(?A=RV#+qZgE<`uaQ{0%vM8ibNHA+t*Ny1QR;ICAlVX(a3HTMXvk+@GVqde2p+;?* zb?Sef8>=`0Z&xOOLr?JFcqVm0EvDtVgXD{q1ln&yp{ev8)Q2fUHB+~T?(NTzaK?PPjK}-0tkUaWP-^yqvTWK;Y_Maf`>}3tE<1v zR9JIWJy7=!6@QucwJ5m!exe;x+M0fzSd@DI96r55Bd~WTfhCJ}rWH|HE^Fp(E!JWC zrZg`08Mz`X_+?F?T}=H7^9U^YgSpLqmpsib9?09_-GNVdg zXWa9qf z_-MtD@!3YE3`Bh-L!c{{1MLRy=lwXJgW}D2-`QAT@@$f_~qDYyhFpV(v#kJ%ToslQD_!dtL z8arBkuS%s~0R7p@&L*8`G&OKFoq0dMrAC$#Dk+bmq1zNXt2j8>cJpbUSjAdqZ&e?! z4H2D^Dq zHr5MWDY;+IM9!SgpW#aH+<6>mUGfW6BJo`>e{Z_;*g?i2r!0upvWiFvT zq(RyOSH1(k8EKZ8R!HEdDfwBPzj+?9HFX5c3T?((qCF?xng`-zi%sntM_2;Zo+EL7 zqqUCA=>bmyN6+L1Su-q_wP>7mzWQBr&|t62Z;%Grx;iae_Nsx|F)hE&f0>cPAtK&A zAaw-}}&e&>RJ!>>VQ<6G%!_j4>B`a_| zH$Ui7i}Ts~hWnyp6mTI$Fgu-8kQFW=Ud|2orQJpx%wI8Vv?bc~6_#SXiN?)IxD=8n z6Cb(c2Cx&~wB_PQ#bs3%-nnU^wcao$^8dKuV0m(!!Y)37@KGq^DnlwX5Gvg5`OAN# z>1{0+eg;qZ4MQ)T2~Z{1->)=U{d{o$vy%Et^py+xAJ_y|Mx^3JGt#r{-C0GjB_*kg zDSY1avESHou*Pa5TwYfwMGerV^{o4u_{?wRTqrT=oW?fQQ!WS8tLydv@C zX(fq|miQpq0sTr#vqT}T4{7>$?Uq!%?H5r@X}*-K!;K$I?e=5#8EL;cwPMi>>@?Xib;l|cY7F%8IZKRp8Y0yCKFG7uVRDTZ8p{C~Sn zpZIEI6^@ZgpBqs_J<$=yzAA|#a60M7W_c=B^RKB|rl;@x2q}Ic(yO5fe+D>OAADMS z+>nt%VBRRASyGXD%@t=N!I(g}8bBxYnkfqZUC)F-@L5f^5FbHnkdt#^OifZ}GdsDQAj!JuE`R_tDZg$V-&8fv)K=1Q(pK=L(Ev~st*x@w1)Ds~^> zd_p*NLoPYT`5beWA?$Wa!)8f Date: Tue, 2 Dec 2025 11:22:48 +0800 Subject: [PATCH 24/28] refactor: use MessageSender interface for message transporter (#5083) --- Release.md | 4 ++++ client/control.go | 2 +- pkg/msg/handler.go | 4 ---- pkg/transport/message.go | 14 ++++++++------ server/control.go | 2 +- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Release.md b/Release.md index c346193f056..3775da513f4 100644 --- a/Release.md +++ b/Release.md @@ -6,3 +6,7 @@ ## Improvements * **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals. + +## Fixes + +* Fix deadlock issue when TCP connection is closed. Previously, sending messages could block forever if the connection handler had already stopped. diff --git a/client/control.go b/client/control.go index 4bd6a2f737a..c18ae07c358 100644 --- a/client/control.go +++ b/client/control.go @@ -100,7 +100,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn) } ctl.registerMsgHandlers() - ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) + ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController) ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, diff --git a/pkg/msg/handler.go b/pkg/msg/handler.go index cb1eb15a307..243e599ab76 100644 --- a/pkg/msg/handler.go +++ b/pkg/msg/handler.go @@ -86,10 +86,6 @@ func (d *Dispatcher) Send(m Message) error { } } -func (d *Dispatcher) SendChannel() chan Message { - return d.sendCh -} - func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) { d.msgHandlers[reflect.TypeOf(msg)] = handler } diff --git a/pkg/transport/message.go b/pkg/transport/message.go index dd43fbdc0ed..40165f5dc4a 100644 --- a/pkg/transport/message.go +++ b/pkg/transport/message.go @@ -35,15 +35,19 @@ type MessageTransporter interface { DispatchWithType(m msg.Message, msgType, laneKey string) bool } -func NewMessageTransporter(sendCh chan msg.Message) MessageTransporter { +type MessageSender interface { + Send(msg.Message) error +} + +func NewMessageTransporter(sender MessageSender) MessageTransporter { return &transporterImpl{ - sendCh: sendCh, + sender: sender, registry: make(map[string]map[string]chan msg.Message), } } type transporterImpl struct { - sendCh chan msg.Message + sender MessageSender // First key is message type and second key is lane key. // Dispatch will dispatch message to related channel by its message type @@ -53,9 +57,7 @@ type transporterImpl struct { } func (impl *transporterImpl) Send(m msg.Message) error { - return errors.PanicToError(func() { - impl.sendCh <- m - }) + return impl.sender.Send(m) } func (impl *transporterImpl) Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) { diff --git a/server/control.go b/server/control.go index b70d8d1268a..65d52062996 100644 --- a/server/control.go +++ b/server/control.go @@ -195,7 +195,7 @@ func NewControl( ctl.msgDispatcher = msg.NewDispatcher(ctl.conn) } ctl.registerMsgHandlers() - ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) + ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) return ctl, nil } From 0fe8f7a0b6bef8ea97172bfa21ff85c5e429ea40 Mon Sep 17 00:00:00 2001 From: fatedier Date: Fri, 5 Dec 2025 16:26:09 +0800 Subject: [PATCH 25/28] refactor: reorganize security policy into dedicated packag (#5088) --- client/service.go | 5 +- cmd/frpc/sub/proxy.go | 5 +- cmd/frpc/sub/root.go | 16 ++- cmd/frpc/sub/verify.go | 4 +- pkg/config/v1/client.go | 20 ---- pkg/config/v1/validation/client.go | 117 +++++++++++++------ pkg/{ => policy}/featuregate/feature_gate.go | 0 pkg/policy/security/unsafe.go | 34 ++++++ 8 files changed, 133 insertions(+), 68 deletions(-) rename pkg/{ => policy}/featuregate/feature_gate.go (100%) create mode 100644 pkg/policy/security/unsafe.go diff --git a/client/service.go b/client/service.go index 60eb2d9030d..819a2bc5be6 100644 --- a/client/service.go +++ b/client/service.go @@ -31,6 +31,7 @@ import ( "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/policy/security" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" @@ -64,7 +65,7 @@ type ServiceOptions struct { ProxyCfgs []v1.ProxyConfigurer VisitorCfgs []v1.VisitorConfigurer - UnsafeFeatures v1.UnsafeFeatures + UnsafeFeatures *security.UnsafeFeatures // ConfigFilePath is the path to the configuration file used to initialize. // If it is empty, it means that the configuration file is not used for initialization. @@ -124,7 +125,7 @@ type Service struct { visitorCfgs []v1.VisitorConfigurer clientSpec *msg.ClientSpec - unsafeFeatures v1.UnsafeFeatures + unsafeFeatures *security.UnsafeFeatures // The configuration file used to initialize this client, or an empty // string if no configuration file was used. diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 0b0251a2650..0748a8b166f 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -24,6 +24,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/policy/security" ) var proxyTypes = []v1.ProxyType{ @@ -78,7 +79,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm os.Exit(1) } - unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) @@ -108,7 +109,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 68d69158e6d..0750562b351 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -21,6 +21,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "sync" "syscall" "time" @@ -31,7 +32,8 @@ 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/policy/featuregate" + "github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) @@ -49,7 +51,9 @@ func init() { rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors") - rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, "allowed unsafe features, one or more of: TokenSourceExec") + + rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, + fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", "))) } var rootCmd = &cobra.Command{ @@ -61,7 +65,7 @@ var rootCmd = &cobra.Command{ return nil } - unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) // If cfgDir is not empty, run multiple frpc service for each config file in cfgDir. // Note that it's only designed for testing. It's not guaranteed to be stable. @@ -80,7 +84,7 @@ var rootCmd = &cobra.Command{ }, } -func runMultipleClients(cfgDir string, unsafeFeatures v1.UnsafeFeatures) error { +func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures) error { var wg sync.WaitGroup err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -115,7 +119,7 @@ func handleTermSignal(svr *client.Service) { svr.GracefulClose(500 * time.Millisecond) } -func runClient(cfgFilePath string, unsafeFeatures v1.UnsafeFeatures) error { +func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error { cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) if err != nil { return err @@ -145,7 +149,7 @@ func startService( cfg *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, - unsafeFeatures v1.UnsafeFeatures, + unsafeFeatures *security.UnsafeFeatures, cfgFile string, ) error { log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index c0c9d0e275f..830f7bf1118 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -21,8 +21,8 @@ import ( "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" - v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/policy/security" ) func init() { @@ -43,7 +43,7 @@ var verifyCmd = &cobra.Command{ fmt.Println(err) os.Exit(1) } - unsafeFeatures := v1.NewUnsafeFeatures(allowUnsafe) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index 8528ff7e149..61bc6ac67cd 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -248,23 +248,3 @@ type AuthOIDCClientConfig struct { type VirtualNetConfig struct { Address string `json:"address,omitempty"` } - -const ( - UnsafeFeatureTokenSourceExec = "TokenSourceExec" -) - -type UnsafeFeatures struct { - features map[string]bool -} - -func NewUnsafeFeatures(allowed []string) UnsafeFeatures { - features := make(map[string]bool) - for _, f := range allowed { - features[f] = true - } - return UnsafeFeatures{features: features} -} - -func (u UnsafeFeatures) IsEnabled(feature string) bool { - return u.features[feature] -} diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 1c21f01d427..b55fece7185 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -23,70 +23,111 @@ import ( "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/featuregate" + "github.com/fatedier/frp/pkg/policy/featuregate" + "github.com/fatedier/frp/pkg/policy/security" ) -func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.UnsafeFeatures) (Warning, error) { +func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures *security.UnsafeFeatures) (Warning, error) { var ( warnings Warning errs error ) - // validate feature gates + + validators := []func() (Warning, error){ + func() (Warning, error) { return validateFeatureGates(c) }, + func() (Warning, error) { return validateAuthConfig(&c.Auth, unsafeFeatures) }, + func() (Warning, error) { return nil, validateLogConfig(&c.Log) }, + func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) }, + func() (Warning, error) { return validateTransportConfig(&c.Transport) }, + func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) }, + } + + for _, v := range validators { + w, err := v() + warnings = AppendError(warnings, w) + errs = AppendError(errs, err) + } + return warnings, errs +} + +func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) { 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") + return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag") } } + return nil, nil +} - if !slices.Contains(SupportedAuthMethods, c.Auth.Method) { +func validateAuthConfig(c *v1.AuthClientConfig, unsafeFeatures *security.UnsafeFeatures) (Warning, error) { + var errs error + if !slices.Contains(SupportedAuthMethods, c.Method) { errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods)) } - if !lo.Every(SupportedAuthAdditionalScopes, c.Auth.AdditionalScopes) { + if !lo.Every(SupportedAuthAdditionalScopes, c.AdditionalScopes) { errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes)) } // Validate token/tokenSource mutual exclusivity - if c.Auth.Token != "" && c.Auth.TokenSource != nil { + if c.Token != "" && c.TokenSource != nil { errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource")) } // Validate tokenSource if specified - if c.Auth.TokenSource != nil { - if c.Auth.TokenSource.Type == "exec" && !unsafeFeatures.IsEnabled(v1.UnsafeFeatureTokenSourceExec) { - errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.tokenSource.type")) + if c.TokenSource != nil { + if c.TokenSource.Type == "exec" { + if !unsafeFeatures.IsEnabled(security.TokenSourceExec) { + errs = AppendError(errs, fmt.Errorf("unsafe feature %q is not enabled. "+ + "To enable it, start frpc with '--allow-unsafe %s'", security.TokenSourceExec, security.TokenSourceExec)) + } } - if err := c.Auth.TokenSource.Validate(); err != nil { + if err := c.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) } } - if c.Auth.OIDC.TokenSource != nil { - // Validate oidc.tokenSource mutual exclusivity with other fields of oidc - if c.Auth.OIDC.ClientID != "" || c.Auth.OIDC.ClientSecret != "" || c.Auth.OIDC.Audience != "" || - c.Auth.OIDC.Scope != "" || c.Auth.OIDC.TokenEndpointURL != "" || len(c.Auth.OIDC.AdditionalEndpointParams) > 0 || - c.Auth.OIDC.TrustedCaFile != "" || c.Auth.OIDC.InsecureSkipVerify || c.Auth.OIDC.ProxyURL != "" { - errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) - } - if c.Auth.OIDC.TokenSource.Type == "exec" && !unsafeFeatures.IsEnabled(v1.UnsafeFeatureTokenSourceExec) { - errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.oidc.tokenSource.type")) - } - } - - if err := validateLogConfig(&c.Log); err != nil { + if err := validateOIDCConfig(&c.OIDC, unsafeFeatures); err != nil { errs = AppendError(errs, err) } + return nil, errs +} - if err := validateWebServerConfig(&c.WebServer); err != nil { - errs = AppendError(errs, err) +func validateOIDCConfig(c *v1.AuthOIDCClientConfig, unsafeFeatures *security.UnsafeFeatures) error { + if c.TokenSource == nil { + return nil + } + var errs error + // Validate oidc.tokenSource mutual exclusivity with other fields of oidc + if c.ClientID != "" || c.ClientSecret != "" || c.Audience != "" || + c.Scope != "" || c.TokenEndpointURL != "" || len(c.AdditionalEndpointParams) > 0 || + c.TrustedCaFile != "" || c.InsecureSkipVerify || c.ProxyURL != "" { + errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) + } + if c.TokenSource.Type == "exec" { + if !unsafeFeatures.IsEnabled(security.TokenSourceExec) { + errs = AppendError(errs, fmt.Errorf("unsafe feature %q is not enabled. "+ + "To enable it, start frpc with '--allow-unsafe %s'", security.TokenSourceExec, security.TokenSourceExec)) + } } + if err := c.TokenSource.Validate(); err != nil { + errs = AppendError(errs, fmt.Errorf("invalid auth.oidc.tokenSource: %v", err)) + } + return errs +} + +func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) { + var ( + warnings Warning + errs error + ) - if c.Transport.HeartbeatTimeout > 0 && c.Transport.HeartbeatInterval > 0 { - if c.Transport.HeartbeatTimeout < c.Transport.HeartbeatInterval { + if c.HeartbeatTimeout > 0 && c.HeartbeatInterval > 0 { + if c.HeartbeatTimeout < c.HeartbeatInterval { errs = AppendError(errs, fmt.Errorf("invalid transport.heartbeatTimeout, heartbeat timeout should not less than heartbeat interval")) } } - if !lo.FromPtr(c.Transport.TLS.Enable) { + if !lo.FromPtr(c.TLS.Enable) { checkTLSConfig := func(name string, value string) Warning { if value != "" { return fmt.Errorf("%s is invalid when transport.tls.enable is false", name) @@ -94,16 +135,20 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.Unsa return nil } - warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.Transport.TLS.CertFile)) - warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.Transport.TLS.KeyFile)) - warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.Transport.TLS.TrustedCaFile)) + warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile)) + warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile)) + warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile)) } - if !slices.Contains(SupportedTransportProtocols, c.Transport.Protocol) { + if !slices.Contains(SupportedTransportProtocols, c.Protocol) { errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols)) } + return warnings, errs +} - for _, f := range c.IncludeConfigFiles { +func validateIncludeFiles(files []string) (Warning, error) { + var errs error + for _, f := range files { absDir, err := filepath.Abs(filepath.Dir(f)) if err != nil { errs = AppendError(errs, fmt.Errorf("include: parse directory of %s failed: %v", f, err)) @@ -113,14 +158,14 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.Unsa errs = AppendError(errs, fmt.Errorf("include: directory of %s not exist", f)) } } - return warnings, errs + return nil, errs } func ValidateAllClientConfig( c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, - unsafeFeatures v1.UnsafeFeatures, + unsafeFeatures *security.UnsafeFeatures, ) (Warning, error) { var warnings Warning if c != nil { diff --git a/pkg/featuregate/feature_gate.go b/pkg/policy/featuregate/feature_gate.go similarity index 100% rename from pkg/featuregate/feature_gate.go rename to pkg/policy/featuregate/feature_gate.go diff --git a/pkg/policy/security/unsafe.go b/pkg/policy/security/unsafe.go new file mode 100644 index 00000000000..b3f84a85278 --- /dev/null +++ b/pkg/policy/security/unsafe.go @@ -0,0 +1,34 @@ +package security + +const ( + TokenSourceExec = "TokenSourceExec" +) + +var ( + ClientUnsafeFeatures = []string{ + TokenSourceExec, + } + + ServerUnsafeFeatures = []string{ + TokenSourceExec, + } +) + +type UnsafeFeatures struct { + features map[string]bool +} + +func NewUnsafeFeatures(allowed []string) *UnsafeFeatures { + features := make(map[string]bool) + for _, f := range allowed { + features[f] = true + } + return &UnsafeFeatures{features: features} +} + +func (u *UnsafeFeatures) IsEnabled(feature string) bool { + if u == nil { + return false + } + return u.features[feature] +} From 2bdf25bae68160230bbe7047e3fef09a9553ef90 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 11 Dec 2025 12:48:08 +0800 Subject: [PATCH 26/28] rotate gold sponsor order periodically (#5094) --- README.md | 22 ++++++++-------------- README_zh.md | 22 ++++++++-------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0b88eb14b18..a99b2277094 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ frp is an open source project with its ongoing development made possible entirel

Gold Sponsors

+

+ + +
+ The complete IDE crafted for professional Go developers +
+

+

@@ -50,20 +58,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and Available for macOS, Linux and Windows

-

- - -
- The complete IDE crafted for professional Go developers -
-

-

- - -
- Secure and Elastic Infrastructure for Running Your AI-Generated Code -
-

## What is frp? diff --git a/README_zh.md b/README_zh.md index 1c3879fd67f..727299bc554 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,6 +15,14 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

+

+ + +
+ The complete IDE crafted for professional Go developers +
+

+

@@ -51,20 +59,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and Available for macOS, Linux and Windows

-

- - -
- The complete IDE crafted for professional Go developers -
-

-

- - -
- Secure and Elastic Infrastructure for Running Your AI-Generated Code -
-

## 为什么使用 frp ? From 7526d7a69af9de505f96ccec5ef47b68666a9201 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 25 Dec 2025 00:53:08 +0800 Subject: [PATCH 27/28] refactor: separate auth config from runtime and defer token resolution (#5105) --- client/control.go | 12 ++--- client/proxy/proxy.go | 5 +- client/proxy/proxy_manager.go | 7 ++- client/proxy/proxy_wrapper.go | 3 +- client/proxy/sudp.go | 2 +- client/proxy/udp.go | 2 +- client/service.go | 12 ++--- cmd/frpc/sub/proxy.go | 6 ++- cmd/frpc/sub/root.go | 1 + cmd/frps/root.go | 9 +++- cmd/frps/verify.go | 5 +- pkg/auth/auth.go | 63 ++++++++++++++++++++++++ pkg/config/v1/client.go | 13 ----- pkg/config/v1/client_test.go | 71 ++------------------------- pkg/config/v1/server.go | 14 ------ pkg/config/v1/server_test.go | 71 ++------------------------- pkg/config/v1/validation/client.go | 27 +++++----- pkg/config/v1/validation/server.go | 8 ++- pkg/config/v1/validation/validator.go | 28 +++++++++++ server/control.go | 7 ++- server/proxy/http.go | 2 +- server/proxy/proxy.go | 6 ++- server/proxy/udp.go | 2 +- server/service.go | 15 ++++-- 24 files changed, 185 insertions(+), 206 deletions(-) create mode 100644 pkg/config/v1/validation/validator.go diff --git a/client/control.go b/client/control.go index c18ae07c358..0f48c36dd52 100644 --- a/client/control.go +++ b/client/control.go @@ -43,8 +43,8 @@ type SessionContext struct { Conn net.Conn // Indicates whether the connection is encrypted. ConnEncrypted bool - // Sets authentication based on selected method - AuthSetter auth.Setter + // Auth runtime used for login, heartbeats, and encryption. + Auth *auth.ClientAuth // Connector is used to create new connections, which could be real TCP connections or virtual streams. Connector Connector // Virtual net controller @@ -91,7 +91,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro ctl.lastPong.Store(time.Now()) if sessionCtx.ConnEncrypted { - cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, []byte(sessionCtx.Common.Auth.Token)) + cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey()) if err != nil { return nil, err } @@ -102,7 +102,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) - ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController) + ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController) ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController) return ctl, nil @@ -133,7 +133,7 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) { m := &msg.NewWorkConn{ RunID: ctl.sessionCtx.RunID, } - if err = ctl.sessionCtx.AuthSetter.SetNewWorkConn(m); err != nil { + if err = ctl.sessionCtx.Auth.Setter.SetNewWorkConn(m); err != nil { xl.Warnf("error during NewWorkConn authentication: %v", err) workConn.Close() return @@ -243,7 +243,7 @@ func (ctl *Control) heartbeatWorker() { sendHeartBeat := func() (bool, error) { xl.Debugf("send heartbeat to server") pingMsg := &msg.Ping{} - if err := ctl.sessionCtx.AuthSetter.SetPing(pingMsg); err != nil { + if err := ctl.sessionCtx.Auth.Setter.SetPing(pingMsg); err != nil { xl.Warnf("error during ping authentication: %v, skip sending ping message", err) return false, err } diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go index 876ca579d38..8faff38d09b 100644 --- a/client/proxy/proxy.go +++ b/client/proxy/proxy.go @@ -57,6 +57,7 @@ func NewProxy( ctx context.Context, pxyConf v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig, + encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) (pxy Proxy) { @@ -69,6 +70,7 @@ func NewProxy( baseProxy := BaseProxy{ baseCfg: pxyConf.GetBaseConfig(), clientCfg: clientCfg, + encryptionKey: encryptionKey, limiter: limiter, msgTransporter: msgTransporter, vnetController: vnetController, @@ -86,6 +88,7 @@ func NewProxy( type BaseProxy struct { baseCfg *v1.ProxyBaseConfig clientCfg *v1.ClientCommonConfig + encryptionKey []byte msgTransporter transport.MessageTransporter vnetController *vnet.Controller limiter *rate.Limiter @@ -129,7 +132,7 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) { return } } - pxy.HandleTCPWorkConnection(conn, m, []byte(pxy.clientCfg.Auth.Token)) + pxy.HandleTCPWorkConnection(conn, m, pxy.encryptionKey) } // Common handler for tcp work connections. diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go index ea5cc553112..4615e9a2631 100644 --- a/client/proxy/proxy_manager.go +++ b/client/proxy/proxy_manager.go @@ -40,7 +40,8 @@ type Manager struct { closed bool mu sync.RWMutex - clientCfg *v1.ClientCommonConfig + encryptionKey []byte + clientCfg *v1.ClientCommonConfig ctx context.Context } @@ -48,6 +49,7 @@ type Manager struct { func NewManager( ctx context.Context, clientCfg *v1.ClientCommonConfig, + encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) *Manager { @@ -56,6 +58,7 @@ func NewManager( msgTransporter: msgTransporter, vnetController: vnetController, closed: false, + encryptionKey: encryptionKey, clientCfg: clientCfg, ctx: ctx, } @@ -163,7 +166,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, pm.vnetController) + pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController) if pm.inWorkConnCallback != nil { pxy.SetInWorkConnCallback(pm.inWorkConnCallback) } diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go index f3f17e2b10c..4698320a8a0 100644 --- a/client/proxy/proxy_wrapper.go +++ b/client/proxy/proxy_wrapper.go @@ -92,6 +92,7 @@ func NewWrapper( ctx context.Context, cfg v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig, + encryptionKey []byte, eventHandler event.Handler, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, @@ -122,7 +123,7 @@ func NewWrapper( xl.Tracef("enable health check monitor") } - pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter, pw.vnetController) + pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController) return pw } diff --git a/client/proxy/sudp.go b/client/proxy/sudp.go index 13741d0ddc7..3a7af19c93f 100644 --- a/client/proxy/sudp.go +++ b/client/proxy/sudp.go @@ -91,7 +91,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { }) } if pxy.cfg.Transport.UseEncryption { - rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token)) + rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { conn.Close() xl.Errorf("create encryption stream error: %v", err) diff --git a/client/proxy/udp.go b/client/proxy/udp.go index b70ffe4a0c8..1fca99044e5 100644 --- a/client/proxy/udp.go +++ b/client/proxy/udp.go @@ -102,7 +102,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { }) } if pxy.cfg.Transport.UseEncryption { - rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token)) + rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { conn.Close() xl.Errorf("create encryption stream error: %v", err) diff --git a/client/service.go b/client/service.go index 819a2bc5be6..b282163e220 100644 --- a/client/service.go +++ b/client/service.go @@ -111,8 +111,8 @@ type Service struct { // Uniq id got from frps, it will be attached to loginMsg. runID string - // Sets authentication based on selected method - authSetter auth.Setter + // Auth runtime and encryption materials + auth *auth.ClientAuth // web server for admin UI and apis webServer *httppkg.Server @@ -155,14 +155,14 @@ func NewService(options ServiceOptions) (*Service, error) { webServer = ws } - authSetter, err := auth.NewAuthSetter(options.Common.Auth) + authRuntime, err := auth.BuildClientAuth(&options.Common.Auth) if err != nil { return nil, err } s := &Service{ ctx: context.Background(), - authSetter: authSetter, + auth: authRuntime, webServer: webServer, common: options.Common, configFilePath: options.ConfigFilePath, @@ -296,7 +296,7 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) { } // Add auth - if err = svr.authSetter.SetLogin(loginMsg); err != nil { + if err = svr.auth.Setter.SetLogin(loginMsg); err != nil { return } @@ -350,7 +350,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE RunID: svr.runID, Conn: conn, ConnEncrypted: connEncrypted, - AuthSetter: svr.authSetter, + Auth: svr.auth, Connector: connector, VnetController: svr.vnetController, } diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 0748a8b166f..ef7fe67fddb 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -80,7 +80,8 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) - if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { + validator := validation.NewConfigValidator(unsafeFeatures) + if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil { fmt.Println(err) os.Exit(1) } @@ -110,7 +111,8 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client os.Exit(1) } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) - if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil { + validator := validation.NewConfigValidator(unsafeFeatures) + if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 0750562b351..1c2d8d5eaae 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -142,6 +142,7 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro if err != nil { return err } + return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath) } diff --git a/cmd/frps/root.go b/cmd/frps/root.go index c1bfc880624..e6ab008a343 100644 --- a/cmd/frps/root.go +++ b/cmd/frps/root.go @@ -18,12 +18,14 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/server" @@ -33,6 +35,7 @@ var ( cfgFile string showVersion bool strictConfigMode bool + allowUnsafe []string serverCfg v1.ServerConfig ) @@ -41,6 +44,8 @@ func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause errors") + rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, + fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ServerUnsafeFeatures, ", "))) config.RegisterServerConfigFlags(rootCmd, &serverCfg) } @@ -77,7 +82,9 @@ var rootCmd = &cobra.Command{ svrCfg = &serverCfg } - warning, err := validation.ValidateServerConfig(svrCfg) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) + validator := validation.NewConfigValidator(unsafeFeatures) + warning, err := validator.ValidateServerConfig(svrCfg) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } diff --git a/cmd/frps/verify.go b/cmd/frps/verify.go index 33ad3f63229..7ddef1abcf6 100644 --- a/cmd/frps/verify.go +++ b/cmd/frps/verify.go @@ -22,6 +22,7 @@ import ( "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/policy/security" ) func init() { @@ -42,7 +43,9 @@ var verifyCmd = &cobra.Command{ os.Exit(1) } - warning, err := validation.ValidateServerConfig(svrCfg) + unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) + validator := validation.NewConfigValidator(unsafeFeatures) + warning, err := validator.ValidateServerConfig(svrCfg) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 64462a2085c..366b62ef31e 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -15,6 +15,7 @@ package auth import ( + "context" "fmt" v1 "github.com/fatedier/frp/pkg/config/v1" @@ -27,6 +28,39 @@ type Setter interface { SetNewWorkConn(*msg.NewWorkConn) error } +type ClientAuth struct { + Setter Setter + key []byte +} + +func (a *ClientAuth) EncryptionKey() []byte { + return a.key +} + +// BuildClientAuth resolves any dynamic auth values and returns a prepared auth runtime. +// Caller must run validation before calling this function. +func BuildClientAuth(cfg *v1.AuthClientConfig) (*ClientAuth, error) { + if cfg == nil { + return nil, fmt.Errorf("auth config is nil") + } + resolved := *cfg + if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil { + token, err := resolved.TokenSource.Resolve(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err) + } + resolved.Token = token + } + setter, err := NewAuthSetter(resolved) + if err != nil { + return nil, err + } + return &ClientAuth{ + Setter: setter, + key: []byte(resolved.Token), + }, nil +} + func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) { switch cfg.Method { case v1.AuthMethodToken: @@ -52,6 +86,35 @@ type Verifier interface { VerifyNewWorkConn(*msg.NewWorkConn) error } +type ServerAuth struct { + Verifier Verifier + key []byte +} + +func (a *ServerAuth) EncryptionKey() []byte { + return a.key +} + +// BuildServerAuth resolves any dynamic auth values and returns a prepared auth runtime. +// Caller must run validation before calling this function. +func BuildServerAuth(cfg *v1.AuthServerConfig) (*ServerAuth, error) { + if cfg == nil { + return nil, fmt.Errorf("auth config is nil") + } + resolved := *cfg + if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil { + token, err := resolved.TokenSource.Resolve(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err) + } + resolved.Token = token + } + return &ServerAuth{ + Verifier: NewAuthVerifier(resolved), + key: []byte(resolved.Token), + }, nil +} + func NewAuthVerifier(cfg v1.AuthServerConfig) (authVerifier Verifier) { switch cfg.Method { case v1.AuthMethodToken: diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index 61bc6ac67cd..2c5ccc6f1f9 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -15,8 +15,6 @@ package v1 import ( - "context" - "fmt" "os" "github.com/samber/lo" @@ -198,17 +196,6 @@ type AuthClientConfig struct { func (c *AuthClientConfig) Complete() error { c.Method = util.EmptyOr(c.Method, "token") - - // Resolve tokenSource during configuration loading - if c.Method == AuthMethodToken && c.TokenSource != nil { - token, err := c.TokenSource.Resolve(context.Background()) - if err != nil { - return fmt.Errorf("failed to resolve auth.tokenSource: %w", err) - } - // Move the resolved token to the Token field and clear TokenSource - c.Token = token - c.TokenSource = nil - } return nil } diff --git a/pkg/config/v1/client_test.go b/pkg/config/v1/client_test.go index 120c4fd42a3..5473a5f6a76 100644 --- a/pkg/config/v1/client_test.go +++ b/pkg/config/v1/client_test.go @@ -15,8 +15,6 @@ package v1 import ( - "os" - "path/filepath" "testing" "github.com/samber/lo" @@ -38,68 +36,9 @@ func TestClientConfigComplete(t *testing.T) { } func TestAuthClientConfig_Complete(t *testing.T) { - // Create a temporary file for testing - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test_token") - testContent := "client-token-value" - err := os.WriteFile(testFile, []byte(testContent), 0o600) - require.NoError(t, err) - - tests := []struct { - name string - config AuthClientConfig - expectToken string - expectPanic bool - }{ - { - name: "tokenSource resolved to token", - config: AuthClientConfig{ - Method: AuthMethodToken, - TokenSource: &ValueSource{ - Type: "file", - File: &FileSource{ - Path: testFile, - }, - }, - }, - expectToken: testContent, - expectPanic: false, - }, - { - name: "direct token unchanged", - config: AuthClientConfig{ - Method: AuthMethodToken, - Token: "direct-token", - }, - expectToken: "direct-token", - expectPanic: false, - }, - { - name: "invalid tokenSource should panic", - config: AuthClientConfig{ - Method: AuthMethodToken, - TokenSource: &ValueSource{ - Type: "file", - File: &FileSource{ - Path: "/non/existent/file", - }, - }, - }, - expectPanic: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.expectPanic { - err := tt.config.Complete() - require.Error(t, err) - } else { - err := tt.config.Complete() - require.NoError(t, err) - require.Equal(t, tt.expectToken, tt.config.Token) - require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution") - } - }) - } + require := require.New(t) + cfg := &AuthClientConfig{} + err := cfg.Complete() + require.NoError(err) + require.EqualValues("token", cfg.Method) } diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index 54aac080bd0..a92aac97aab 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -15,9 +15,6 @@ package v1 import ( - "context" - "fmt" - "github.com/samber/lo" "github.com/fatedier/frp/pkg/config/types" @@ -138,17 +135,6 @@ type AuthServerConfig struct { func (c *AuthServerConfig) Complete() error { c.Method = util.EmptyOr(c.Method, "token") - - // Resolve tokenSource during configuration loading - if c.Method == AuthMethodToken && c.TokenSource != nil { - token, err := c.TokenSource.Resolve(context.Background()) - if err != nil { - return fmt.Errorf("failed to resolve auth.tokenSource: %w", err) - } - // Move the resolved token to the Token field and clear TokenSource - c.Token = token - c.TokenSource = nil - } return nil } diff --git a/pkg/config/v1/server_test.go b/pkg/config/v1/server_test.go index 21d18fb7653..cd9381c509d 100644 --- a/pkg/config/v1/server_test.go +++ b/pkg/config/v1/server_test.go @@ -15,8 +15,6 @@ package v1 import ( - "os" - "path/filepath" "testing" "github.com/samber/lo" @@ -35,68 +33,9 @@ func TestServerConfigComplete(t *testing.T) { } func TestAuthServerConfig_Complete(t *testing.T) { - // Create a temporary file for testing - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test_token") - testContent := "file-token-value" - err := os.WriteFile(testFile, []byte(testContent), 0o600) - require.NoError(t, err) - - tests := []struct { - name string - config AuthServerConfig - expectToken string - expectPanic bool - }{ - { - name: "tokenSource resolved to token", - config: AuthServerConfig{ - Method: AuthMethodToken, - TokenSource: &ValueSource{ - Type: "file", - File: &FileSource{ - Path: testFile, - }, - }, - }, - expectToken: testContent, - expectPanic: false, - }, - { - name: "direct token unchanged", - config: AuthServerConfig{ - Method: AuthMethodToken, - Token: "direct-token", - }, - expectToken: "direct-token", - expectPanic: false, - }, - { - name: "invalid tokenSource should panic", - config: AuthServerConfig{ - Method: AuthMethodToken, - TokenSource: &ValueSource{ - Type: "file", - File: &FileSource{ - Path: "/non/existent/file", - }, - }, - }, - expectPanic: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.expectPanic { - err := tt.config.Complete() - require.Error(t, err) - } else { - err := tt.config.Complete() - require.NoError(t, err) - require.Equal(t, tt.expectToken, tt.config.Token) - require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution") - } - }) - } + require := require.New(t) + cfg := &AuthServerConfig{} + err := cfg.Complete() + require.NoError(err) + require.EqualValues("token", cfg.Method) } diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index b55fece7185..eb4a0253dbf 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -27,7 +27,7 @@ import ( "github.com/fatedier/frp/pkg/policy/security" ) -func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures *security.UnsafeFeatures) (Warning, error) { +func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { var ( warnings Warning errs error @@ -35,15 +35,15 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures *securi validators := []func() (Warning, error){ func() (Warning, error) { return validateFeatureGates(c) }, - func() (Warning, error) { return validateAuthConfig(&c.Auth, unsafeFeatures) }, + func() (Warning, error) { return v.validateAuthConfig(&c.Auth) }, func() (Warning, error) { return nil, validateLogConfig(&c.Log) }, func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) }, func() (Warning, error) { return validateTransportConfig(&c.Transport) }, func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) }, } - for _, v := range validators { - w, err := v() + for _, validator := range validators { + w, err := validator() warnings = AppendError(warnings, w) errs = AppendError(errs, err) } @@ -59,7 +59,7 @@ func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) { return nil, nil } -func validateAuthConfig(c *v1.AuthClientConfig, unsafeFeatures *security.UnsafeFeatures) (Warning, error) { +func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) { var errs error if !slices.Contains(SupportedAuthMethods, c.Method) { errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods)) @@ -76,9 +76,8 @@ func validateAuthConfig(c *v1.AuthClientConfig, unsafeFeatures *security.UnsafeF // Validate tokenSource if specified if c.TokenSource != nil { if c.TokenSource.Type == "exec" { - if !unsafeFeatures.IsEnabled(security.TokenSourceExec) { - errs = AppendError(errs, fmt.Errorf("unsafe feature %q is not enabled. "+ - "To enable it, start frpc with '--allow-unsafe %s'", security.TokenSourceExec, security.TokenSourceExec)) + if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { + errs = AppendError(errs, err) } } if err := c.TokenSource.Validate(); err != nil { @@ -86,13 +85,13 @@ func validateAuthConfig(c *v1.AuthClientConfig, unsafeFeatures *security.UnsafeF } } - if err := validateOIDCConfig(&c.OIDC, unsafeFeatures); err != nil { + if err := v.validateOIDCConfig(&c.OIDC); err != nil { errs = AppendError(errs, err) } return nil, errs } -func validateOIDCConfig(c *v1.AuthOIDCClientConfig, unsafeFeatures *security.UnsafeFeatures) error { +func (v *ConfigValidator) validateOIDCConfig(c *v1.AuthOIDCClientConfig) error { if c.TokenSource == nil { return nil } @@ -104,9 +103,8 @@ func validateOIDCConfig(c *v1.AuthOIDCClientConfig, unsafeFeatures *security.Uns errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) } if c.TokenSource.Type == "exec" { - if !unsafeFeatures.IsEnabled(security.TokenSourceExec) { - errs = AppendError(errs, fmt.Errorf("unsafe feature %q is not enabled. "+ - "To enable it, start frpc with '--allow-unsafe %s'", security.TokenSourceExec, security.TokenSourceExec)) + if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { + errs = AppendError(errs, err) } } if err := c.TokenSource.Validate(); err != nil { @@ -167,9 +165,10 @@ func ValidateAllClientConfig( visitorCfgs []v1.VisitorConfigurer, unsafeFeatures *security.UnsafeFeatures, ) (Warning, error) { + validator := NewConfigValidator(unsafeFeatures) var warnings Warning if c != nil { - warning, err := ValidateClientCommonConfig(c, unsafeFeatures) + warning, err := validator.ValidateClientCommonConfig(c) warnings = AppendError(warnings, warning) if err != nil { return warnings, err diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go index 5694227299f..338ecc821b2 100644 --- a/pkg/config/v1/validation/server.go +++ b/pkg/config/v1/validation/server.go @@ -21,9 +21,10 @@ import ( "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/policy/security" ) -func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) { +func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) { var ( warnings Warning errs error @@ -42,6 +43,11 @@ func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) { // Validate tokenSource if specified if c.Auth.TokenSource != nil { + if c.Auth.TokenSource.Type == "exec" { + if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { + errs = AppendError(errs, err) + } + } if err := c.Auth.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) } diff --git a/pkg/config/v1/validation/validator.go b/pkg/config/v1/validation/validator.go new file mode 100644 index 00000000000..1cfe3b21299 --- /dev/null +++ b/pkg/config/v1/validation/validator.go @@ -0,0 +1,28 @@ +package validation + +import ( + "fmt" + + "github.com/fatedier/frp/pkg/policy/security" +) + +// ConfigValidator holds the context dependencies for configuration validation. +type ConfigValidator struct { + unsafeFeatures *security.UnsafeFeatures +} + +// NewConfigValidator creates a new ConfigValidator instance. +func NewConfigValidator(unsafeFeatures *security.UnsafeFeatures) *ConfigValidator { + return &ConfigValidator{ + unsafeFeatures: unsafeFeatures, + } +} + +// ValidateUnsafeFeature checks if a specific unsafe feature is enabled. +func (v *ConfigValidator) ValidateUnsafeFeature(feature string) error { + if !v.unsafeFeatures.IsEnabled(feature) { + return fmt.Errorf("unsafe feature %q is not enabled. "+ + "To enable it, ensure it is allowed in the configuration or command line flags", feature) + } + return nil +} diff --git a/server/control.go b/server/control.go index 65d52062996..af9c9de36a6 100644 --- a/server/control.go +++ b/server/control.go @@ -106,6 +106,8 @@ type Control struct { // verifies authentication based on selected method authVerifier auth.Verifier + // key used for connection encryption + encryptionKey []byte // other components can use this to communicate with client msgTransporter transport.MessageTransporter @@ -157,6 +159,7 @@ func NewControl( pxyManager *proxy.Manager, pluginManager *plugin.Manager, authVerifier auth.Verifier, + encryptionKey []byte, ctlConn net.Conn, ctlConnEncrypted bool, loginMsg *msg.Login, @@ -171,6 +174,7 @@ func NewControl( pxyManager: pxyManager, pluginManager: pluginManager, authVerifier: authVerifier, + encryptionKey: encryptionKey, conn: ctlConn, loginMsg: loginMsg, workConnCh: make(chan net.Conn, poolCount+10), @@ -186,7 +190,7 @@ func NewControl( ctl.lastPing.Store(time.Now()) if ctlConnEncrypted { - cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) + cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, ctl.encryptionKey) if err != nil { return nil, err } @@ -478,6 +482,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err GetWorkConnFn: ctl.GetWorkConn, Configurer: pxyConf, ServerCfg: ctl.serverCfg, + EncryptionKey: ctl.encryptionKey, }) if err != nil { return remoteAddr, err diff --git a/server/proxy/http.go b/server/proxy/http.go index 9a02dcddce3..2c4f1fd4c8d 100644 --- a/server/proxy/http.go +++ b/server/proxy/http.go @@ -165,7 +165,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err var rwc io.ReadWriteCloser = tmpConn if pxy.cfg.Transport.UseEncryption { - rwc, err = libio.WithEncryption(rwc, []byte(pxy.serverCfg.Auth.Token)) + rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) return diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go index c7c18c32223..564eca282c4 100644 --- a/server/proxy/proxy.go +++ b/server/proxy/proxy.go @@ -68,6 +68,7 @@ type BaseProxy struct { poolCount int getWorkConnFn GetWorkConnFn serverCfg *v1.ServerConfig + encryptionKey []byte limiter *rate.Limiter userInfo plugin.UserInfo loginMsg *msg.Login @@ -213,7 +214,6 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { xl := xlog.FromContextSafe(pxy.Context()) defer userConn.Close() - serverCfg := pxy.serverCfg cfg := pxy.configurer.GetBaseConfig() // server plugin hook rc := pxy.GetResourceController() @@ -240,7 +240,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { xl.Tracef("handler user tcp connection, use_encryption: %t, use_compression: %t", cfg.Transport.UseEncryption, cfg.Transport.UseCompression) if cfg.Transport.UseEncryption { - local, err = libio.WithEncryption(local, []byte(serverCfg.Auth.Token)) + local, err = libio.WithEncryption(local, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) return @@ -279,6 +279,7 @@ type Options struct { GetWorkConnFn GetWorkConnFn Configurer v1.ProxyConfigurer ServerCfg *v1.ServerConfig + EncryptionKey []byte } func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { @@ -298,6 +299,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { poolCount: options.PoolCount, getWorkConnFn: options.GetWorkConnFn, serverCfg: options.ServerCfg, + encryptionKey: options.EncryptionKey, limiter: limiter, xl: xl, ctx: xlog.NewContext(ctx, xl), diff --git a/server/proxy/udp.go b/server/proxy/udp.go index 53a07d52676..3751dc9bfad 100644 --- a/server/proxy/udp.go +++ b/server/proxy/udp.go @@ -205,7 +205,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) { var rwc io.ReadWriteCloser = workConn if pxy.cfg.Transport.UseEncryption { - rwc, err = libio.WithEncryption(rwc, []byte(pxy.serverCfg.Auth.Token)) + rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) workConn.Close() diff --git a/server/service.go b/server/service.go index 1fe882d293a..de3af837610 100644 --- a/server/service.go +++ b/server/service.go @@ -113,8 +113,8 @@ type Service struct { sshTunnelGateway *ssh.Gateway - // Verifies authentication based on selected method - authVerifier auth.Verifier + // Auth runtime and encryption materials + auth *auth.ServerAuth tlsConfig *tls.Config @@ -149,6 +149,11 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { } } + authRuntime, err := auth.BuildServerAuth(&cfg.Auth) + if err != nil { + return nil, err + } + svr := &Service{ ctlManager: NewControlManager(), pxyManager: proxy.NewManager(), @@ -160,7 +165,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { }, sshTunnelListener: netpkg.NewInternalListener(), httpVhostRouter: vhost.NewRouters(), - authVerifier: auth.NewAuthVerifier(cfg.Auth), + auth: authRuntime, webServer: webServer, tlsConfig: tlsConfig, cfg: cfg, @@ -586,7 +591,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter ctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch) // Check auth. - authVerifier := svr.authVerifier + authVerifier := svr.auth.Verifier if internal && loginMsg.ClientSpec.AlwaysAuthPass { authVerifier = auth.AlwaysPassVerifier } @@ -595,7 +600,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter } // TODO(fatedier): use SessionContext - ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, ctlConn, !internal, loginMsg, svr.cfg) + ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, svr.auth.EncryptionKey(), ctlConn, !internal, loginMsg, svr.cfg) if err != nil { xl.Warnf("create new controller error: %v", err) // don't return detailed errors to client From ef96481f58122ec00d2aa168cc3203532197b014 Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 25 Dec 2025 10:15:40 +0800 Subject: [PATCH 28/28] update version and release notes (#5106) --- Release.md | 1 + pkg/util/version/version.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Release.md b/Release.md index 3775da513f4..c1861c58839 100644 --- a/Release.md +++ b/Release.md @@ -2,6 +2,7 @@ * HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities. * Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load. +* OIDC authentication now supports a `tokenSource` field to dynamically obtain tokens from external sources. You can use `type = "file"` to read a token from a file, or `type = "exec"` to run an external command (e.g., a cloud CLI or secrets manager) and capture its stdout as the token. The `exec` type requires the `--allow-unsafe=TokenSourceExec` CLI flag for security reasons. ## Improvements diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index 0f4ec433898..65f30f06c9a 100644 --- a/pkg/util/version/version.go +++ b/pkg/util/version/version.go @@ -14,7 +14,7 @@ package version -var version = "0.65.0" +var version = "0.66.0" func Full() string { return version